// Package recovery implements SPEC-007: the non-admin recovery path (manager // escalation) and the config-gated admin recovery (D-033). // // The shape mirrors PROJECT_PLAN.md §6.4: // // /access -> enter the email on record (ALWAYS a generic response) // match -> recovery_request(pending) · notify the chain // approver (an ancestor in the chain — the manager upward — or the admin, // never the requester) approves/denies // approval -> revoke the OLD identity · mint a NEW GUID · email the link // // Email only INITIATES (D-009); a human in the chain authorises. Identity // rotation (D-007) means a compromised inbox cannot take over alone. // // The package owns no routing: the door (SPEC-003/007) gates /access to the // anonymous pre-session CSRF path and the approve/deny actions to a session. // // Spec: docs/specs/spec-007-roles-chain-recovery.md. package recovery 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" ) // Status is a recovery request's lifecycle state (SPEC-004 §4 // `recovery_requests`). type Status string const ( StatusPending Status = "pending" StatusApproved Status = "approved" StatusDenied Status = "denied" StatusExpired Status = "expired" ) // Defaults (code defaults; settings live in config later, D-035). const ( // DefaultPendingTTL bounds how long a recovery request waits for a decision // before it may no longer be approved (~72h, SPEC-007 §4). DefaultPendingTTL = 72 * time.Hour // DefaultMaxPending caps the number of open requests one user may hold, so // the inbox and the chain cannot be flooded. DefaultMaxPending = 3 ) // Request is one recovery request (SPEC-004 §4 `recovery_requests`). type Request struct { ID int64 UserID int64 Status Status RequestedAt time.Time // DecidedBy is the approver/denier; nil while pending. DecidedBy *int64 DecidedAt *time.Time ClientIP string } // ExpiredAt reports the moment the request stops being approvable. func (r Request) ExpiredAt(ttl time.Duration) time.Time { return r.RequestedAt.Add(ttl) } // Sentinel errors. var ( // ErrNotFound is returned when no matching request exists. ErrNotFound = errors.New("recovery: request not found") // ErrTooManyPending is returned when a user is already at the cap. ErrTooManyPending = errors.New("recovery: too many pending recovery requests") ) // Store is the SQLite-backed recovery-request service. type Store struct { db *sql.DB audit audit.Audit ttl time.Duration cap int now func() time.Time } // Option configures a Store. type Option func(*Store) // WithTTL overrides the pending lifetime (default DefaultPendingTTL). func WithTTL(d time.Duration) Option { return func(s *Store) { if d > 0 { s.ttl = d } } } // WithCap overrides the per-user pending cap (default DefaultMaxPending). func WithCap(n int) Option { return func(s *Store) { if n > 0 { s.cap = n } } } // WithClock injects a clock (tests). func WithClock(fn func() time.Time) Option { return func(s *Store) { if fn != nil { s.now = fn } } } // NewStore returns a recovery-request service over db. aud may be nil (tests). func NewStore(db *sql.DB, aud audit.Audit, opts ...Option) *Store { s := &Store{ db: db, audit: aud, ttl: DefaultPendingTTL, cap: DefaultMaxPending, now: time.Now, } for _, opt := range opts { opt(s) } return s } // TTL returns the configured pending lifetime. func (s *Store) TTL() time.Duration { return s.ttl } func (s *Store) clock() time.Time { return s.now().UTC().Truncate(time.Second) } // Create opens a pending recovery request for userID. It expires stale rows // first, then: // // - reuses an existing live pending request (idempotent — created=false, so // the caller does NOT notify the chain a second time), and // - refuses once the per-user cap is reached (ErrTooManyPending). // // created reports whether a NEW row was inserted; only then should the caller // send the notifications. func (s *Store) Create(ctx context.Context, userID int64, clientIP string) (Request, bool, error) { now := s.clock() if _, err := s.Expire(ctx, now); err != nil { return Request{}, false, err } // The cap is a hard bound on a user's open requests (SPEC-007 §4); under // it, a live pending request is reused rather than duplicated. n, err := s.countPending(userID) if err != nil { return Request{}, false, err } if n >= s.cap { return Request{}, false, fmt.Errorf("%w: user:%d has %d pending", ErrTooManyPending, userID, n) } if existing, err := s.Pending(ctx, userID); err == nil { return existing, false, nil } else if !errors.Is(err, ErrNotFound) { return Request{}, false, err } res, err := s.db.Exec( `INSERT INTO recovery_requests (user_id, status, requested_at, client_ip) VALUES (?, ?, ?, ?)`, userID, string(StatusPending), store.FormatTime(now), nullString(clientIP), ) if err != nil { return Request{}, false, fmt.Errorf("recovery: insert request: %w", err) } id, err := res.LastInsertId() if err != nil { return Request{}, false, fmt.Errorf("recovery: request id: %w", err) } s.record(ctx, audit.Event{ Action: "recovery.request", Subject: fmt.Sprintf("user:%d", userID), Detail: fmt.Sprintf("request:%d status=pending", id), ClientIP: clientIP, }) return Request{ ID: id, UserID: userID, Status: StatusPending, RequestedAt: now, ClientIP: clientIP, }, true, nil } // Pending returns the user's live pending request (within the TTL). A stale or // absent one yields ErrNotFound. func (s *Store) Pending(ctx context.Context, userID int64) (Request, error) { row := s.db.QueryRow(` SELECT request_id, user_id, status, requested_at, decided_by, decided_at, client_ip FROM recovery_requests WHERE user_id = ? AND status = ? ORDER BY request_id DESC LIMIT 1`, userID, string(StatusPending)) req, err := scanRequest(row) if err != nil { return Request{}, err } if !req.ExpiredAt(s.ttl).After(s.clock()) { return Request{}, ErrNotFound // stale; Expire will tombstone it } return req, nil } // Get returns one request by id, whatever its status. func (s *Store) Get(ctx context.Context, id int64) (Request, error) { row := s.db.QueryRow(` SELECT request_id, user_id, status, requested_at, decided_by, decided_at, client_ip FROM recovery_requests WHERE request_id = ?`, id) return scanRequest(row) } // Decide moves a PENDING request to a terminal status, recording the deciding // user (0 = the system, stored NULL) and the moment. It refuses a request that // is no longer pending (ErrNotFound). func (s *Store) Decide(ctx context.Context, id, decidedBy int64, status Status) error { switch status { case StatusApproved, StatusDenied, StatusExpired: default: return fmt.Errorf("recovery: cannot decide to %q", status) } now := store.FormatTime(s.clock()) res, err := s.db.Exec( `UPDATE recovery_requests SET status = ?, decided_by = ?, decided_at = ? WHERE request_id = ? AND status = ?`, string(status), nullableID(decidedBy), now, id, string(StatusPending), ) if err != nil { return fmt.Errorf("recovery: decide %d: %w", id, err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("recovery: decide rows: %w", err) } if n == 0 { return fmt.Errorf("%w: request:%d is not pending", ErrNotFound, id) } s.record(ctx, audit.Event{ ActorUserID: decidedBy, Action: decisionAction(status), Subject: fmt.Sprintf("request:%d", id), Detail: string(status), }) return nil } // decisionAction names the audit event for a terminal decision, following the // SPEC-004 §5 vocabulary (recovery request/approve/deny). func decisionAction(status Status) string { switch status { case StatusApproved: return "recovery.approve" case StatusDenied: return "recovery.deny" default: return "recovery.expire" } } // Expire tombstones every pending request older than the TTL and returns how // many it retired. func (s *Store) Expire(ctx context.Context, now time.Time) (int, error) { cutoff := store.FormatTime(now.UTC().Add(-s.ttl)) res, err := s.db.Exec( `UPDATE recovery_requests SET status = ?, decided_at = ? WHERE status = ? AND requested_at <= ?`, string(StatusExpired), store.FormatTime(now.UTC()), string(StatusPending), cutoff, ) if err != nil { return 0, fmt.Errorf("recovery: expire: %w", err) } n, err := res.RowsAffected() if err != nil { return 0, fmt.Errorf("recovery: expire rows: %w", err) } if n > 0 { s.record(ctx, audit.Event{ Action: "recovery.expire", Detail: fmt.Sprintf("expired=%d", n), }) } return int(n), nil } // countPending counts a user's live pending requests. func (s *Store) countPending(userID int64) (int, error) { var n int if err := s.db.QueryRow( `SELECT count(*) FROM recovery_requests WHERE user_id = ? AND status = ?`, userID, string(StatusPending), ).Scan(&n); err != nil { return 0, fmt.Errorf("recovery: count pending: %w", err) } return n, nil } // scanRequest reads one recovery_requests row. func scanRequest(row rowScanner) (Request, error) { var ( req Request status string requested string decidedBy sql.NullInt64 decidedAt sql.NullString clientIP sql.NullString ) err := row.Scan(&req.ID, &req.UserID, &status, &requested, &decidedBy, &decidedAt, &clientIP) if errors.Is(err, sql.ErrNoRows) { return Request{}, ErrNotFound } if err != nil { return Request{}, fmt.Errorf("recovery: scan request: %w", err) } req.Status = Status(status) if req.RequestedAt, err = store.ParseTime(requested); err != nil { return Request{}, fmt.Errorf("recovery: parse requested_at: %w", err) } if decidedBy.Valid { v := decidedBy.Int64 req.DecidedBy = &v } if decidedAt.Valid && decidedAt.String != "" { t, err := store.ParseTime(decidedAt.String) if err != nil { return Request{}, fmt.Errorf("recovery: parse decided_at: %w", err) } req.DecidedAt = &t } req.ClientIP = clientIP.String return req, nil } // rowScanner is satisfied by *sql.Row and *sql.Rows. type rowScanner interface { Scan(dest ...any) error } func nullableID(id int64) any { if id == 0 { return nil } return id } // nullString maps "" to NULL so the trail distinguishes "absent" from "empty". func nullString(s string) any { if strings.TrimSpace(s) == "" { return nil } return s } func (s *Store) record(ctx context.Context, e audit.Event) { if s.audit != nil { s.audit.Record(ctx, e) } }