NextMyDay/internal/core/recovery/recovery.go
Claus Lohmar 4c839ff30d feat(recovery): add the admin recovery flow (SPEC-007)
Config-flag → email user 1 an enrolment link (D-033), with the approve/deny
actions and dual control.
2026-10-14 10:53:53 +00:00

366 lines
10 KiB
Go

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