- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded migration runner; each module's migrations are name-prefixed (D-012) - audit: the append-only audit log; the actor threads via context (R-1) - users: the user_id/GUID/email anchors (D-013) - config: the YAML settings file (D-035) + validation - httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security headers · redaction - middleware: correlation id · audit · rate limiting - mail: the invite/recovery mailer seam - server: the core DOOR — the resolution switch, the dispatch registry (RegisterModule), the role gates, /setup · enroll/login · recovery flows, the reserved `static` workflow, and the loopback-only bootstrap (D-025)
256 lines
7.2 KiB
Go
256 lines
7.2 KiB
Go
package users
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
|
|
)
|
|
|
|
// Store is the SQLite-backed Users service.
|
|
type Store struct {
|
|
db *sql.DB
|
|
audit audit.Audit
|
|
now func() time.Time
|
|
}
|
|
|
|
// New returns a Users service over db. aud may be nil (tests) — audit is then
|
|
// skipped rather than panicking.
|
|
func New(db *sql.DB, aud audit.Audit) *Store {
|
|
return &Store{db: db, audit: aud, now: time.Now}
|
|
}
|
|
|
|
var _ Users = (*Store)(nil)
|
|
|
|
// Create implements Users.Create (SPEC-004 §5, AC-6, D-019).
|
|
func (s *Store) Create(ctx context.Context, creatorID int64, email, displayName string, role Role) (UserID, error) {
|
|
email = strings.ToLower(strings.TrimSpace(email))
|
|
displayName = strings.TrimSpace(displayName)
|
|
if email == "" {
|
|
return 0, ErrEmailRequired
|
|
}
|
|
if displayName == "" {
|
|
return 0, ErrNameRequired
|
|
}
|
|
if !role.Valid() {
|
|
return 0, ErrInvalidRole
|
|
}
|
|
|
|
var manager Optional[UserID]
|
|
switch {
|
|
case creatorID == 0:
|
|
// Bootstrap (D-021/D-022): the first user IS the top admin.
|
|
n, err := s.Count(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if n != 0 {
|
|
return 0, ErrBootstrapClosed
|
|
}
|
|
if role != RoleAdmin {
|
|
return 0, ErrBootstrapAdmin
|
|
}
|
|
manager = None[UserID]()
|
|
default:
|
|
creator, err := s.Get(ctx, creatorID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
// One admin only — user 1 already exists (AC-6).
|
|
if role == RoleAdmin {
|
|
return 0, ErrOneAdmin
|
|
}
|
|
// Creation authority: a role strictly above the new user's (D-019).
|
|
if !creator.Role.Above(role) {
|
|
return 0, fmt.Errorf("%w: %s cannot create %s", ErrRoleNotAllowed, creator.Role, role)
|
|
}
|
|
manager = Some(creatorID)
|
|
}
|
|
|
|
createdAt := store.FormatTime(s.now())
|
|
res, err := s.db.Exec(
|
|
`INSERT INTO users (email, display_name, role, manager_user_id, status, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
email, displayName, string(role), nullableID(manager), StatusActive, createdAt,
|
|
)
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return 0, fmt.Errorf("%w: %s", ErrDuplicateEmail, email)
|
|
}
|
|
return 0, fmt.Errorf("users: insert: %w", err)
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("users: last insert id: %w", err)
|
|
}
|
|
|
|
s.record(ctx, audit.Event{
|
|
ActorUserID: creatorID,
|
|
Action: "user.create",
|
|
Subject: fmt.Sprintf("user:%d", id),
|
|
Detail: fmt.Sprintf("email=%s role=%s", email, role),
|
|
})
|
|
return id, nil
|
|
}
|
|
|
|
// Get implements Users.Get.
|
|
func (s *Store) Get(ctx context.Context, id int64) (User, error) {
|
|
row := s.db.QueryRow(`
|
|
SELECT user_id, email, display_name, role, manager_user_id, passkey_opt_out, status, created_at
|
|
FROM users WHERE user_id = ?`, id)
|
|
return scanUser(row)
|
|
}
|
|
|
|
// SetRole implements Users.SetRole. The role ripples down (D-034); the
|
|
// one-admin invariant is preserved (AC-6) and user 1 is protected (A14).
|
|
func (s *Store) SetRole(ctx context.Context, id int64, role Role) error {
|
|
if !role.Valid() {
|
|
return ErrInvalidRole
|
|
}
|
|
switch {
|
|
case id == 1 && role != RoleAdmin:
|
|
return ErrTopAdmin
|
|
case id != 1 && role == RoleAdmin:
|
|
return ErrOneAdmin
|
|
}
|
|
|
|
if _, err := s.Get(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.db.Exec(`UPDATE users SET role = ? WHERE user_id = ?`, string(role), id); err != nil {
|
|
return fmt.Errorf("users: update role: %w", err)
|
|
}
|
|
// The actor of a role change is whoever the door authenticated, not the
|
|
// user being changed — it comes from the audit.Meta on ctx (R-1/R-3).
|
|
s.record(ctx, audit.Event{
|
|
Action: "user.set_role",
|
|
Subject: fmt.Sprintf("user:%d", id),
|
|
Detail: fmt.Sprintf("role=%s", role),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// Manager implements Users.Manager — the chain link above id.
|
|
func (s *Store) Manager(ctx context.Context, id int64) (Optional[UserID], error) {
|
|
var m sql.NullInt64
|
|
err := s.db.QueryRow(`SELECT manager_user_id FROM users WHERE user_id = ?`, id).Scan(&m)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return None[UserID](), ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return None[UserID](), fmt.Errorf("users: manager lookup: %w", err)
|
|
}
|
|
if m.Valid {
|
|
return Some(m.Int64), nil
|
|
}
|
|
return None[UserID](), nil
|
|
}
|
|
|
|
// Count returns the number of users. It answers "is the instance in setup
|
|
// mode?" (SPEC-005 §3: no user exists).
|
|
func (s *Store) Count(ctx context.Context) (int, error) {
|
|
var n int
|
|
if err := s.db.QueryRow(`SELECT count(*) FROM users`).Scan(&n); err != nil {
|
|
return 0, fmt.Errorf("users: count: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// ByEmail returns the user with the given email (recovery lookup, SPEC-007).
|
|
// The match is case-insensitive.
|
|
func (s *Store) ByEmail(ctx context.Context, email string) (User, error) {
|
|
row := s.db.QueryRow(`
|
|
SELECT user_id, email, display_name, role, manager_user_id, passkey_opt_out, status, created_at
|
|
FROM users WHERE email = ?`, strings.ToLower(strings.TrimSpace(email)))
|
|
return scanUser(row)
|
|
}
|
|
|
|
// PasskeyOptOut implements Users.PasskeyOptOut (D-041): the user's stored
|
|
// preference about the post-login passkey nudge.
|
|
func (s *Store) PasskeyOptOut(ctx context.Context, id int64) (bool, error) {
|
|
var optOut int
|
|
err := s.db.QueryRow(`SELECT passkey_opt_out FROM users WHERE user_id = ?`, id).Scan(&optOut)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return false, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("users: passkey opt-out lookup: %w", err)
|
|
}
|
|
return optOut != 0, nil
|
|
}
|
|
|
|
// SetPasskeyOptOut implements Users.SetPasskeyOptOut (D-041). It is a plain
|
|
// preference write — no one-admin or role rule applies — and is audited (A11).
|
|
// The actor comes from the context (ruling R-1); the user is the subject.
|
|
func (s *Store) SetPasskeyOptOut(ctx context.Context, id int64, optOut bool) error {
|
|
if _, err := s.Get(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
v := 0
|
|
if optOut {
|
|
v = 1
|
|
}
|
|
if _, err := s.db.Exec(`UPDATE users SET passkey_opt_out = ? WHERE user_id = ?`, v, id); err != nil {
|
|
return fmt.Errorf("users: set passkey opt-out: %w", err)
|
|
}
|
|
s.record(ctx, audit.Event{
|
|
Action: "user.passkey_opt_out",
|
|
Subject: fmt.Sprintf("user:%d", id),
|
|
Detail: fmt.Sprintf("passkey_opt_out=%t", optOut),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// rowScanner is satisfied by *sql.Row and *sql.Rows.
|
|
type rowScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanUser(row rowScanner) (User, error) {
|
|
var (
|
|
u User
|
|
role string
|
|
manager sql.NullInt64
|
|
optOut int
|
|
created string
|
|
)
|
|
err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &role, &manager, &optOut, &u.Status, &created)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return User{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return User{}, fmt.Errorf("users: scan: %w", err)
|
|
}
|
|
u.Role = Role(role)
|
|
u.PasskeyOptOut = optOut != 0
|
|
if manager.Valid {
|
|
u.Manager = Some(manager.Int64)
|
|
}
|
|
if u.CreatedAt, err = store.ParseTime(created); err != nil {
|
|
return User{}, fmt.Errorf("users: parse created_at: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func nullableID(o Optional[UserID]) any {
|
|
if !o.Set {
|
|
return nil
|
|
}
|
|
return o.Value
|
|
}
|
|
|
|
func (s *Store) record(ctx context.Context, e audit.Event) {
|
|
if s.audit != nil {
|
|
s.audit.Record(ctx, e)
|
|
}
|
|
}
|
|
|
|
// isUniqueViolation reports whether err is a SQLite UNIQUE-constraint failure.
|
|
func isUniqueViolation(err error) bool {
|
|
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
|
|
}
|