diff --git a/internal/core/recovery/admin.go b/internal/core/recovery/admin.go new file mode 100644 index 0000000..a1d4598 --- /dev/null +++ b/internal/core/recovery/admin.go @@ -0,0 +1,136 @@ +package recovery + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "strings" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/mail" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" +) + +// adminUserID is the top admin — the first user (D-013/D-021). +const adminUserID int64 = 1 + +// AdminUsers is the slice of the user service the admin recovery needs. +type AdminUsers interface { + Get(ctx context.Context, id int64) (users.User, error) +} + +// AdminDeps assembles the admin-recovery dependencies. +type AdminDeps struct { + // Config carries the admin_recovery approval flag (D-033). + Config *config.Config + // ConfigPath is where the cleared flag is persisted (config.yaml, never + // the committed example). + ConfigPath string + // Users loads user 1 and its address on record. + Users AdminUsers + // Identities rotates user 1's identity (D-007). + Identities auth.Identities + // Mailer delivers the enrolment link to the address on record. + Mailer mail.Mailer + // Audit receives the rotation event (A11). + Audit audit.Audit + // BaseURL prefixes the enrolment link. + BaseURL string + // WriteFile persists the cleared flag (tests inject a failing writer). + // Defaults to os.WriteFile 0600. + WriteFile func(path string, data []byte) error + // Logger receives structured logs. Defaults to slog.Default(). + Logger *slog.Logger +} + +// RunAdminRecovery implements the config-gated admin recovery (D-033, SPEC-007 +// §5). With the flag clear it does NOTHING (dual control, AC-5). With +// `admin_recovery: true` it: +// +// 1. rotates user 1's identity — revoking the old GUID and every session, +// 2. emails the enrolment link to user 1's ADDRESS ON RECORD, +// 3. audits the rotation, and +// 4. clears the flag in the config file. +// +// It reports whether it acted. A missing user 1 is a no-op (nothing to +// recover); a write failure is logged and does not fail startup. +func RunAdminRecovery(ctx context.Context, d AdminDeps) (bool, error) { + if d.Logger == nil { + d.Logger = slog.Default() + } + if d.Config == nil || !d.Config.AdminRecovery { + return false, nil // D-033: without the flag, NOTHING happens + } + if d.Users == nil || d.Identities == nil { + return false, errors.New("admin recovery: Users and Identities are required") + } + + admin, err := d.Users.Get(ctx, adminUserID) + if err != nil { + if errors.Is(err, users.ErrNotFound) { + d.Logger.Warn("admin recovery: no top admin exists — nothing to rotate") + return false, nil + } + return false, fmt.Errorf("admin recovery: load user 1: %w", err) + } + + guid, err := d.Identities.Rotate(ctx, adminUserID) + if err != nil { + return false, fmt.Errorf("admin recovery: rotate identity: %w", err) + } + if d.Audit != nil { + d.Audit.Record(ctx, audit.Event{ + Action: "admin.recovery", + Subject: fmt.Sprintf("user:%d", adminUserID), + Detail: "identity rotated; enrolment link emailed", + }) + } + + if d.Mailer != nil && admin.Email != "" { + body := fmt.Sprintf(`Hello %s, + +A system operator has approved recovery of your MyDay account. +Your previous access link is now disabled. + +Enrol your new passkey and authenticator here: +%s/%s +`, admin.DisplayName, strings.TrimRight(d.BaseURL, "/"), guid) + if err := d.Mailer.Send(ctx, mail.Message{ + To: admin.Email, + Subject: "MyDay: your admin access was reset", + Body: body, + }); err != nil { + d.Logger.Warn("admin recovery: mail delivery failed", "to", admin.Email, "error", err) + } + } + + d.Logger.Warn("admin recovery: user 1 identity rotated; the flag is cleared", + "guid", guid) + return true, clearFlag(d) +} + +// clearFlag rewrites the config with admin_recovery: false (D-033 "auto +// clears"). A not-writable file is logged, never fatal. +func clearFlag(d AdminDeps) error { + c := *d.Config + c.AdminRecovery = false + data, err := config.Marshal(&c) + if err != nil { + return fmt.Errorf("admin recovery: marshal config: %w", err) + } + write := d.WriteFile + if write == nil { + write = func(path string, b []byte) error { return os.WriteFile(path, b, 0o600) } + } + if err := write(d.ConfigPath, data); err != nil { + d.Logger.Warn("admin recovery: could not clear the flag in the config file — "+ + "set admin_recovery: false by hand", "path", d.ConfigPath, "error", err) + return nil + } + d.Logger.Info("admin recovery: flag cleared", "path", d.ConfigPath) + return nil +} diff --git a/internal/core/recovery/admin_test.go b/internal/core/recovery/admin_test.go new file mode 100644 index 0000000..9fb90ca --- /dev/null +++ b/internal/core/recovery/admin_test.go @@ -0,0 +1,168 @@ +package recovery_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/mail" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/recovery" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" +) + +type adminFixture struct { + t *testing.T + ctx context.Context + db *store.DB + users *users.Store + ids *auth.IdentitiesService + sessions *auth.SessionsService + mailer *mail.Fake + cfg *config.Config + cfgPath string + guid string + sessionID string +} + +func newAdminFixture(t *testing.T) *adminFixture { + t.Helper() + ctx := context.Background() + dir := t.TempDir() + + db, err := store.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + aud := audit.New(db.DB) + us := users.New(db.DB, aud) + core := auth.New(db.DB, aud) + + adminID, err := us.Create(ctx, 0, "admin@example.com", "Claus", users.RoleAdmin) + if err != nil { + t.Fatalf("bootstrap admin: %v", err) + } + id, guid, err := core.Identities().Mint(ctx, adminID) + if err != nil { + t.Fatalf("Mint: %v", err) + } + if err := core.Identities().Activate(ctx, id); err != nil { + t.Fatalf("Activate: %v", err) + } + sess, err := core.Sessions().Mint(ctx, id, "127.0.0.1", "test") + if err != nil { + t.Fatalf("session Mint: %v", err) + } + + return &adminFixture{ + t: t, ctx: ctx, db: db, users: us, + ids: core.Identities(), sessions: core.Sessions(), mailer: mail.NewFake(), + cfg: &config.Config{ + Server: config.Server{Listen: "127.0.0.1:8080", BaseURL: "https://myday.example.com"}, + Database: config.Database{Path: db.Path()}, + }, + cfgPath: filepath.Join(dir, config.FileName), + guid: guid, + sessionID: sess.ID, + } +} + +func (f *adminFixture) deps(flag bool) recovery.AdminDeps { + f.cfg.AdminRecovery = flag + return recovery.AdminDeps{ + Config: f.cfg, + ConfigPath: f.cfgPath, + Users: f.users, + Identities: f.ids, + Mailer: f.mailer, + Audit: audit.New(f.db.DB), + BaseURL: "https://myday.example.com", + WriteFile: func(p string, b []byte) error { return os.WriteFile(p, b, 0o600) }, + } +} + +// AC-5: `admin_recovery: false` ⇒ nothing happens. With the flag set the +// identity is rotated, the enrolment link is emailed to user 1's address on +// record, the rotation is audited, and the flag is cleared (D-033). +func TestSpec007AC5_AdminRecoveryIsConfigGated(t *testing.T) { + f := newAdminFixture(t) + + // ── flag clear: nothing happens ───────────────────────────────────────── + acted, err := recovery.RunAdminRecovery(f.ctx, f.deps(false)) + if err != nil { + t.Fatalf("RunAdminRecovery(false): %v", err) + } + if acted { + t.Error("admin recovery acted without the config flag (AC-5)") + } + if idn, err := f.ids.ByGUID(f.ctx, f.guid); err != nil || idn.Status != auth.StatusActive { + t.Errorf("identity changed without the flag: %+v (AC-5)", idn) + } + if len(f.mailer.Sent()) != 0 { + t.Errorf("mail was sent without the flag: %v (AC-5)", f.mailer.Sent()) + } + if _, err := os.Stat(f.cfgPath); !os.IsNotExist(err) { + t.Error("the config was rewritten without the flag (AC-5)") + } + + // ── flag set: rotate + email + audit + clear ──────────────────────────── + acted, err = recovery.RunAdminRecovery(f.ctx, f.deps(true)) + if err != nil { + t.Fatalf("RunAdminRecovery(true): %v", err) + } + if !acted { + t.Fatal("admin recovery did not act with the flag set") + } + + old, err := f.ids.ByGUID(f.ctx, f.guid) + if err != nil || old.Status != auth.StatusRevoked { + t.Errorf("old identity = %+v, want revoked", old) + } + fresh, err := f.ids.ByUserID(f.ctx, 1) + if err != nil { + t.Fatalf("ByUserID(1): %v", err) + } + if fresh.Status != auth.StatusPendingEnrollment || fresh.GUID == f.guid { + t.Errorf("new identity = %+v, want a fresh pending GUID", fresh) + } + if _, err := f.sessions.Validate(f.ctx, f.sessionID); err == nil { + t.Error("the admin's old session survived the rotation") + } + + // Email to the address on record with the enrolment link. + msgs := f.mailer.To("admin@example.com") + if len(msgs) == 0 { + t.Fatal("no enrolment email was sent to user 1's address on record") + } + if !strings.Contains(msgs[0].Body, fresh.GUID) { + t.Errorf("the enrolment email does not carry the new GUID: %q", msgs[0].Body) + } + + // Audited (AC-6). + var n int + if err := f.db.QueryRow(`SELECT count(*) FROM audit_log WHERE action = 'admin.recovery'`).Scan(&n); err != nil { + t.Fatalf("read audit_log: %v", err) + } + if n == 0 { + t.Error("the admin recovery was not audited (A11/AC-6)") + } + + // The flag is cleared in the persisted config. + data, err := os.ReadFile(f.cfgPath) + if err != nil { + t.Fatalf("config was not rewritten: %v", err) + } + if !strings.Contains(string(data), "admin_recovery: false") { + t.Errorf("the flag was not cleared in the config file:\n%s", data) + } +} diff --git a/internal/core/recovery/flow.go b/internal/core/recovery/flow.go new file mode 100644 index 0000000..94eab27 --- /dev/null +++ b/internal/core/recovery/flow.go @@ -0,0 +1,450 @@ +package recovery + +import ( + "context" + "errors" + "fmt" + "html" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/mail" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web" +) + +// genericNotice is the ONLY answer POST /access ever gives, whether or not the +// email matched (SPEC-007 AC-4, A3): the recovery entry must not be an +// enumeration oracle. +const genericNotice = "If a record exists, we have emailed you." + +// errHandled marks a decision that already wrote its own HTTP response (a +// client error such as 403/404/409/410). Callers return nil so the door does +// NOT also write a 500 on top the response. +var errHandled = errors.New("recovery: response already written") + +// Users is the narrow slice of the user service the recovery flow needs. +type Users interface { + Get(ctx context.Context, id int64) (users.User, error) + ByEmail(ctx context.Context, email string) (users.User, error) + // Manager returns the chain link above id. The approval authority is the + // CHAIN (R-7): the flow walks this link upward from the requester. + Manager(ctx context.Context, id int64) (users.Optional[users.UserID], error) +} + +// maxChainDepth bounds the manager-line walk (R-7) so a corrupted chain (a +// cycle) can never spin: it fails closed well before the bound. +const maxChainDepth = 64 + +// Requests is the recovery-request service the flow drives. +type Requests interface { + // Create opens (or reuses) a pending request. created reports whether a + // NEW row was inserted — only a new request triggers notification, so a + // repeated POST cannot spam the chain. + Create(ctx context.Context, userID int64, clientIP string) (Request, bool, error) + Get(ctx context.Context, id int64) (Request, error) + Decide(ctx context.Context, id, decidedBy int64, status Status) error +} + +// Deps assembles the recovery flow's dependencies. +type Deps struct { + // Requests owns the recovery_requests table. + Requests Requests + // Users resolves the email-on-record and the chain (the approver). + Users Users + // Identities rotates the identity on approval (D-007). + Identities auth.Identities + // Mailer delivers the notifications and the enrolment link. + Mailer mail.Mailer + // CSRF issues the signed pre-session token for the anonymous /access form + // (A15). + CSRF *httpx.PreSessionCSRF + // Audit receives the request/approve/deny/rotation events (A11). + Audit audit.Audit + // BaseURL prefixes the enrolment link in the emailed recovery. + BaseURL string + // PendingTTL bounds the approvable window (default DefaultPendingTTL). + PendingTTL time.Duration + // Logger receives structured logs. Defaults to slog.Default(). + Logger *slog.Logger + // Now is the clock (tests). Defaults to time.Now. + Now func() time.Time +} + +// Actions is the SPEC-007 recovery flow. +type Actions struct { + d Deps +} + +// New validates and builds the flow. +func New(d Deps) (*Actions, error) { + switch { + case d.Requests == nil: + return nil, errors.New("recovery: Requests is required") + case d.Users == nil: + return nil, errors.New("recovery: Users is required") + case d.Identities == nil: + return nil, errors.New("recovery: Identities is required") + case d.CSRF == nil: + return nil, errors.New("recovery: CSRF is required") + } + if d.Mailer == nil { + d.Mailer = mail.Nop{} + } + if d.Logger == nil { + d.Logger = slog.Default() + } + if d.Now == nil { + d.Now = time.Now + } + if d.PendingTTL <= 0 { + d.PendingTTL = DefaultPendingTTL + } + return &Actions{d: d}, nil +} + +// ── the anonymous /access entry ───────────────────────────────────────────── + +// Page renders the recovery entry (GET /access). It runs before any session, +// so the form carries the signed pre-session double-submit token (A15). +func (a *Actions) Page(w http.ResponseWriter, r *http.Request) error { + token, err := a.d.CSRF.Ensure(w, r) + if err != nil { + return err + } + return renderHTML(w, http.StatusOK, accessPage(token)) +} + +// Request handles POST /access?do=auth&action=recover. It ALWAYS answers with +// the same generic notice (AC-4): a match opens a pending request and notifies +// the chain; a miss does nothing observable. +func (a *Actions) Request(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + email := strings.TrimSpace(r.FormValue("email")) + + if email != "" { + u, err := a.d.Users.ByEmail(ctx, email) + switch { + case err == nil: + a.openRequest(ctx, u) + case errors.Is(err, users.ErrNotFound): + // Miss: deliberately indistinguishable from a hit (AC-4). + default: + return err // a real fault is a 500, not an enumeration signal + } + } + return renderHTML(w, http.StatusOK, noticePage()) +} + +// openRequest opens the pending request and notifies the chain and the +// employee. A reused request is NOT re-notified (no email spam). Failures are +// logged, never surfaced — the response is generic. +func (a *Actions) openRequest(ctx context.Context, requester users.User) { + req, created, err := a.d.Requests.Create(ctx, requester.ID, clientIP(ctx)) + if err != nil { + if !errors.Is(err, ErrTooManyPending) { + a.d.Logger.Error("recovery: open request failed", "user_id", requester.ID, "error", err) + } + return + } + if created { + a.notify(ctx, requester, req) + } +} + +// notify emails the approver (the requester's manager, the chain link above +// them) and the employee (SPEC-007 §4: "notify the approver chain + employee"). +func (a *Actions) notify(ctx context.Context, requester users.User, req Request) { + if requester.Manager.Set { + if mgr, err := a.d.Users.Get(ctx, requester.Manager.Value); err == nil && mgr.Email != "" { + a.send(ctx, mail.Message{ + To: mgr.Email, + Subject: "MyDay: a recovery request awaits your decision", + Body: a.approverBody(requester, req), + }) + } + } + if requester.Email != "" { + a.send(ctx, mail.Message{ + To: requester.Email, + Subject: "MyDay: your access recovery request", + Body: a.employeeBody(requester, req), + }) + } +} + +// ── the approver actions (session-gated by the door) ──────────────────────── + +// Approve decides a pending request: an approver on the requester's chain +// (an ancestor, or the admin) and never the requester (AC-2/R-7). On success it +// revokes the OLD identity and every session, mints a NEW GUID and emails the +// enrolment link (AC-3, D-007). +func (a *Actions) Approve(w http.ResponseWriter, r *http.Request, actorID int64) error { + req, requester, approver, err := a.resolveDecision(w, r, actorID) + switch { + case errors.Is(err, errHandled): + return nil + case err != nil: + return err + } + ctx := r.Context() + + // Claim the request ATOMICALLY first (a status-conditional UPDATE): when + // two approvers race, exactly one wins and only the winner rotates. The + // decision is audited by the store. + if err := a.d.Requests.Decide(ctx, req.ID, approver.ID, StatusApproved); err != nil { + writeError(w, http.StatusConflict, "the request is no longer pending") + return nil + } + + // Revoke the OLD identity + every session and mint a NEW GUID (D-007). If + // this fails the request stays approved and audited; the operator can + // intervene — we never rotate twice for one request. + guid, err := a.d.Identities.Rotate(ctx, requester.ID) + if err != nil { + a.d.Logger.Error("recovery: approved but rotation failed", + "request", req.ID, "user_id", requester.ID, "error", err) + return err + } + + a.send(ctx, mail.Message{ + To: requester.Email, + Subject: "MyDay: your access recovery was approved", + Body: a.enrolmentBody(requester, guid), + }) + writeJSON(w, http.StatusOK, `{"status":"approved"}`) + return nil +} + +// Deny rejects a pending request. No rotation happens. +func (a *Actions) Deny(w http.ResponseWriter, r *http.Request, actorID int64) error { + req, requester, approver, err := a.resolveDecision(w, r, actorID) + switch { + case errors.Is(err, errHandled): + return nil + case err != nil: + return err + } + ctx := r.Context() + + // The store's conditional UPDATE both decides the request and audits it + // (A11); a request already decided by someone else is refused. + if err := a.d.Requests.Decide(ctx, req.ID, approver.ID, StatusDenied); err != nil { + writeError(w, http.StatusConflict, "the request is no longer pending") + return nil + } + + if requester.Email != "" { + a.send(ctx, mail.Message{ + To: requester.Email, + Subject: "MyDay: your access recovery request was denied", + Body: "Your access recovery request was denied. Contact your manager if this was unexpected.\n", + }) + } + writeJSON(w, http.StatusOK, `{"status":"denied"}`) + return nil +} + +// resolveDecision loads and validates a decision: the request (pending, in +// window), the requester, and the approver's authority (an ancestor in the +// chain, or the admin — never the requester, R-7). A client-side rejection +// returns errHandled (the response is already written); a genuine fault returns +// the raw error so the door answers 500. +func (a *Actions) resolveDecision(w http.ResponseWriter, r *http.Request, actorID int64) (Request, users.User, users.User, error) { + ctx := r.Context() + approver, err := a.d.Users.Get(ctx, actorID) + if errors.Is(err, users.ErrNotFound) { + writeError(w, http.StatusForbidden, "forbidden") + return Request{}, users.User{}, users.User{}, errHandled + } + if err != nil { + return Request{}, users.User{}, users.User{}, err + } + + id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("request")), 10, 64) + if err != nil || id <= 0 { + writeError(w, http.StatusBadRequest, "bad request") + return Request{}, users.User{}, users.User{}, errHandled + } + req, err := a.d.Requests.Get(ctx, id) + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "not found") + return Request{}, users.User{}, users.User{}, errHandled + } + if err != nil { + return Request{}, users.User{}, users.User{}, err + } + if req.Status != StatusPending { + writeError(w, http.StatusConflict, "the request is no longer pending") + return Request{}, users.User{}, users.User{}, errHandled + } + if !req.ExpiredAt(a.d.PendingTTL).After(a.d.Now().UTC()) { + _ = a.d.Requests.Decide(ctx, req.ID, 0, StatusExpired) + writeError(w, http.StatusGone, "the request has expired") + return Request{}, users.User{}, users.User{}, errHandled + } + + requester, err := a.d.Users.Get(ctx, req.UserID) + if errors.Is(err, users.ErrNotFound) { + writeError(w, http.StatusNotFound, "not found") + return Request{}, users.User{}, users.User{}, errHandled + } + if err != nil { + return Request{}, users.User{}, users.User{}, err + } + // AC-2 / R-7: the authority is the CHAIN — the approver must be an ancestor + // (on the requester's manager line, upward) or the admin, and never the + // requester themselves. + if !users.CanApprove(approver, requester, a.isAncestor(ctx, approver.ID, requester.ID)) { + a.record(ctx, "recovery.forbidden", requester.ID, fmt.Sprintf("request:%d actor:%d", req.ID, actorID)) + writeError(w, http.StatusForbidden, "forbidden") + return Request{}, users.User{}, users.User{}, errHandled + } + return req, requester, approver, nil +} + +// isAncestor reports whether ancestorID sits on requesterID's manager line, +// upward (R-7). It walks the chain, following manager_user_id links, and fails +// closed on a broken lookup, an absent link, or a cycle. +func (a *Actions) isAncestor(ctx context.Context, ancestorID, requesterID int64) bool { + if ancestorID == 0 || requesterID == 0 { + return false + } + seen := map[int64]bool{} + cur := requesterID + for depth := 0; depth < maxChainDepth; depth++ { + if seen[cur] { + return false // a cycle in the chain — fail closed + } + seen[cur] = true + mgr, err := a.d.Users.Manager(ctx, cur) + if err != nil || !mgr.Set { + return false + } + if mgr.Value == ancestorID { + return true + } + cur = mgr.Value + } + return false +} + +// ── email bodies ──────────────────────────────────────────────────────────── + +func (a *Actions) approverBody(requester users.User, req Request) string { + return fmt.Sprintf(`A user has requested access recovery. + +User: %s +Email: %s + +You are their manager. Sign in to MyDay and approve or deny request #%d. +If this request is unexpected, deny it. + +%s +`, requester.DisplayName, requester.Email, req.ID, a.baseURL()) +} + +func (a *Actions) employeeBody(requester users.User, req Request) string { + return fmt.Sprintf(`Hello %s, + +Your access recovery request (#%d) has been submitted for approval. +Your manager must approve it before a new access link is issued. + +%s +`, requester.DisplayName, req.ID, a.baseURL()) +} + +func (a *Actions) enrolmentBody(requester users.User, guid string) string { + return fmt.Sprintf(`Hello %s, + +Your access recovery was approved. Your old access link is now disabled. + +Enrol your new passkey and authenticator here: +%s/%s + +%s +`, requester.DisplayName, strings.TrimRight(a.baseURL(), "/"), guid, a.baseURL()) +} + +func (a *Actions) baseURL() string { return strings.TrimRight(a.d.BaseURL, "/") } + +func (a *Actions) send(ctx context.Context, m mail.Message) { + if err := a.d.Mailer.Send(ctx, m); err != nil { + a.d.Logger.Warn("recovery: mail delivery failed", "to", m.To, "error", err) + } +} + +// record writes one audit row for the flow (A11). The door has already attached +// the actor to the context for the session-gated decisions. +func (a *Actions) record(ctx context.Context, action string, subjectUser int64, detail string) { + if a.d.Audit == nil { + return + } + a.d.Audit.Record(ctx, audit.Event{ + Action: action, + Subject: fmt.Sprintf("user:%d", subjectUser), + Detail: detail, + }) +} + +// ── rendering helpers ─────────────────────────────────────────────────────── + +func accessPage(token string) web.Page { + c := html.EscapeString(token) + return web.Page{ + Title: "Account access", + Guest: true, + CSRF: token, + Content: web.Markup(`
Enter the email address on record. If we find it, your manager will be asked to approve a new access link.
+`), + } +} + +// noticePage is the generic outcome page (AC-4). +func noticePage() web.Page { + return web.Page{ + Title: "Account access", + Guest: true, + Content: web.Markup(`` + genericNotice + `
`), + } +} + +func renderHTML(w http.ResponseWriter, status int, page web.Page) error { + w.Header().Set("Cache-Control", "no-store") + return web.Render(w, status, page) +} + +func writeJSON(w http.ResponseWriter, status int, body string) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _, _ = io.WriteString(w, body) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(status) + _, _ = io.WriteString(w, msg+"\n") +} + +// clientIP returns the resolved client IP the door attached to the context +// (A6) — empty when unavailable. +func clientIP(ctx context.Context) string { + if m, ok := audit.MetaFrom(ctx); ok { + return m.ClientIP + } + return "" +} diff --git a/internal/core/recovery/recovery.go b/internal/core/recovery/recovery.go new file mode 100644 index 0000000..ab8a764 --- /dev/null +++ b/internal/core/recovery/recovery.go @@ -0,0 +1,366 @@ +// 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) + } +} diff --git a/internal/core/recovery/recovery_test.go b/internal/core/recovery/recovery_test.go new file mode 100644 index 0000000..7171471 --- /dev/null +++ b/internal/core/recovery/recovery_test.go @@ -0,0 +1,112 @@ +package recovery_test + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/recovery" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" +) + +func newStore(t *testing.T, opts ...recovery.Option) (*recovery.Store, *store.DB, int64) { + t.Helper() + ctx := context.Background() + db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + aud := audit.New(db.DB) + us := users.New(db.DB, aud) + admin, err := us.Create(ctx, 0, "admin@example.com", "A", users.RoleAdmin) + if err != nil { + t.Fatalf("create admin: %v", err) + } + return recovery.NewStore(db.DB, aud, opts...), db, admin +} + +// TestCreateIsIdempotent: a second create reuses the live pending request and +// reports created=false (so the chain is not notified twice). +func TestCreateIsIdempotent(t *testing.T) { + ctx := context.Background() + s, _, uid := newStore(t) + + first, created, err := s.Create(ctx, uid, "127.0.0.1") + if err != nil || !created { + t.Fatalf("first Create = (%+v, %v, %v), want created", first, created, err) + } + again, created, err := s.Create(ctx, uid, "127.0.0.1") + if err != nil { + t.Fatalf("second Create: %v", err) + } + if created { + t.Error("a second Create reported created=true, want a reuse") + } + if again.ID != first.ID { + t.Errorf("reuse returned request %d, want %d", again.ID, first.ID) + } +} + +// TestCapIsEnforced: once a user is at the cap, Create refuses with +// ErrTooManyPending (SPEC-007 §4: "capped number per user"). +func TestCapIsEnforced(t *testing.T) { + ctx := context.Background() + s, _, uid := newStore(t, recovery.WithCap(1)) + + if _, created, err := s.Create(ctx, uid, "127.0.0.1"); err != nil || !created { + t.Fatalf("first Create = (created=%v, %v), want a new request", created, err) + } + if _, _, err := s.Create(ctx, uid, "127.0.0.1"); !errors.Is(err, recovery.ErrTooManyPending) { + t.Errorf("Create at the cap = %v, want ErrTooManyPending", err) + } +} + +// TestExpireTombstonesStaleAndFreesTheUser: a request past the TTL is expired +// and the user may open a fresh one. +func TestExpireTombstonesStaleAndFreesTheUser(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + clock := now + s, db, uid := newStore(t, recovery.WithTTL(72*time.Hour), recovery.WithClock(func() time.Time { return clock })) + + first, _, err := s.Create(ctx, uid, "127.0.0.1") + if err != nil { + t.Fatalf("Create: %v", err) + } + + // Advance well past the TTL and open another: the stale row is expired and + // a NEW request is created. + clock = now.Add(100 * time.Hour) + second, created, err := s.Create(ctx, uid, "127.0.0.1") + if err != nil || !created { + t.Fatalf("Create after expiry = (%+v, %v, %v), want a new request", second, created, err) + } + if second.ID == first.ID { + t.Error("the stale request was reused instead of expired") + } + + stale, err := s.Get(ctx, first.ID) + if err != nil { + t.Fatalf("Get(first): %v", err) + } + if stale.Status != recovery.StatusExpired { + t.Errorf("stale request status = %s, want expired", stale.Status) + } + + // The expiry is audited (A11). + var n int + if err := db.QueryRow(`SELECT count(*) FROM audit_log WHERE action = 'recovery.expire'`).Scan(&n); err != nil { + t.Fatalf("read audit_log: %v", err) + } + if n == 0 { + t.Error("the expiry was not audited") + } +}