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:
1 parent
e61f2e4740
commit
2bbfb5dfbe
5 files changed
+1079
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user