Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015a8P6WMRPeqdpd5VCh6jAy
183 lines
5.1 KiB
Go
183 lines
5.1 KiB
Go
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
|
|
}
|