NextMyDay/internal/core/users/store.go
Claus Lohmar d11e14295b feat(core): add the store, identity plumbing and the door (SPEC-003/004)
- 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)
2026-10-14 10:53:51 +00:00

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")
}