Config-flag → email user 1 an enrolment link (D-033), with the approve/deny actions and dual control.
366 lines
10 KiB
Go
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)
|
|
}
|
|
}
|