store : SQLite (clusters à secrets chiffrés, paramètres, utilisateurs, journal, audit)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015a8P6WMRPeqdpd5VCh6jAy
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-13 15:58:25 +02:00
1 parent e61f2e4740
commit 2bbfb5dfbe
5 files changed
+1079

No files matched your search

+182
View File
@@ -0,0 +1,182 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// Roles, from the most to the least privileged.
const (
RoleAdmin = "admin" // everything, including clusters, settings and users
RoleOperator = "operator" // dry-run and synchronization
RoleViewer = "viewer" // read only
)
// ValidRole tells whether r is a known role.
func ValidRole(r string) bool { return r == RoleAdmin || r == RoleOperator || r == RoleViewer }
// RoleRank orders the roles: admin 3, operator 2, viewer 1, unknown 0.
func RoleRank(r string) int {
switch r {
case RoleAdmin:
return 3
case RoleOperator:
return 2
case RoleViewer:
return 1
}
return 0
}
// User is a local account (password hash never returned).
type User struct {
Name string `json:"name"`
Role string `json:"role"`
Epoch int64 `json:"-"` // changes with the password: ends the sessions
CreatedAt time.Time `json:"createdAt"`
LastLogin time.Time `json:"lastLogin"`
hash string
}
// Users lists the local accounts.
func (s *Store) Users(ctx context.Context) ([]User, error) {
rows, err := s.db.QueryContext(ctx, `SELECT name, role, epoch, created_at, last_login FROM users ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
var c, l int64
if err := rows.Scan(&u.Name, &u.Role, &u.Epoch, &c, &l); err != nil {
return nil, err
}
u.CreatedAt = time.Unix(c, 0).UTC()
if l > 0 {
u.LastLogin = time.Unix(l, 0).UTC()
}
out = append(out, u)
}
return out, rows.Err()
}
// User returns one account.
func (s *Store) User(ctx context.Context, name string) (User, error) {
var u User
var c, l int64
err := s.db.QueryRowContext(ctx, `SELECT name, role, password, epoch, created_at, last_login FROM users WHERE name = ?`, name).
Scan(&u.Name, &u.Role, &u.hash, &u.Epoch, &c, &l)
if errors.Is(err, sql.ErrNoRows) {
return u, ErrNotFound
}
u.CreatedAt = time.Unix(c, 0).UTC()
if l > 0 {
u.LastLogin = time.Unix(l, 0).UTC()
}
return u, err
}
// CountUsers returns the number of local accounts.
func (s *Store) CountUsers(ctx context.Context) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&n)
return n, err
}
// Authenticate checks a password and records the login.
func (s *Store) Authenticate(ctx context.Context, name, pass string) (User, bool) {
u, err := s.User(ctx, name)
if err != nil {
// same cost as a real check: the answer time does not tell whether the user exists
CheckPassword(dummyHash, pass)
return User{}, false
}
if !CheckPassword(u.hash, pass) {
return User{}, false
}
_, _ = s.db.ExecContext(ctx, `UPDATE users SET last_login = ? WHERE name = ?`, time.Now().Unix(), name)
return u, true
}
var dummyHash, _ = HashPassword("dummy password")
// SaveUser creates an account or changes its role and/or password (empty: unchanged).
func (s *Store) SaveUser(ctx context.Context, name, role, pass string) error {
name = strings.TrimSpace(name)
if name == "" || strings.ContainsAny(name, " \t\r\n") {
return errors.New("invalid user name")
}
if !ValidRole(role) {
return fmt.Errorf("unknown role %q", role)
}
if pass != "" && len(pass) < 8 {
return errors.New("password: 8 characters minimum")
}
var hash string
if pass != "" {
var err error
if hash, err = HashPassword(pass); err != nil {
return err
}
}
now := time.Now()
_, err := s.User(ctx, name)
switch {
case errors.Is(err, ErrNotFound):
if pass == "" {
return errors.New("a password is required for a new user")
}
_, err = s.db.ExecContext(ctx, `INSERT INTO users (name, role, password, epoch, created_at) VALUES (?,?,?,?,?)`,
name, role, hash, now.UnixNano(), now.Unix())
return err
case err != nil:
return err
}
if err := s.keepAnAdmin(ctx, name, role); err != nil {
return err
}
if pass == "" {
_, err = s.db.ExecContext(ctx, `UPDATE users SET role = ? WHERE name = ?`, role, name)
} else {
_, err = s.db.ExecContext(ctx, `UPDATE users SET role = ?, password = ?, epoch = ? WHERE name = ?`, role, hash, now.UnixNano(), name)
}
return err
}
// DeleteUser removes an account (never the last administrator).
func (s *Store) DeleteUser(ctx context.Context, name string) error {
if err := s.keepAnAdmin(ctx, name, ""); err != nil {
return err
}
res, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE name = ?`, name)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// keepAnAdmin refuses a change that would leave no local administrator
// (OIDC may be down: a local admin is the way back in).
func (s *Store) keepAnAdmin(ctx context.Context, name, newRole string) error {
if newRole == RoleAdmin {
return nil
}
var n int
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM users WHERE role = 'admin' AND name <> ?`, name).Scan(&n); err != nil {
return err
}
var cur string
_ = s.db.QueryRowContext(ctx, `SELECT role FROM users WHERE name = ?`, name).Scan(&cur)
if cur == RoleAdmin && n == 0 {
return errors.New("this is the last local administrator")
}
return nil
}