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.
This commit is contained in:
parent
65618c57db
commit
4c839ff30d
5 changed files with 1232 additions and 0 deletions
136
internal/core/recovery/admin.go
Normal file
136
internal/core/recovery/admin.go
Normal file
|
|
@ -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
|
||||
}
|
||||
168
internal/core/recovery/admin_test.go
Normal file
168
internal/core/recovery/admin_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
450
internal/core/recovery/flow.go
Normal file
450
internal/core/recovery/flow.go
Normal file
|
|
@ -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(`<h1>Account access</h1>
|
||||
<p>Enter the email address on record. If we find it, your manager will be asked to approve a new access link.</p>
|
||||
<form method="post" action="/access?do=auth&action=recover">
|
||||
<input type="hidden" name="csrf" value="` + c + `">
|
||||
<label>Email <input name="email" type="email" autocomplete="email" required></label>
|
||||
<button type="submit">Request access recovery</button></form>`),
|
||||
}
|
||||
}
|
||||
|
||||
// noticePage is the generic outcome page (AC-4).
|
||||
func noticePage() web.Page {
|
||||
return web.Page{
|
||||
Title: "Account access",
|
||||
Guest: true,
|
||||
Content: web.Markup(`<h1>Account access</h1>
|
||||
<p>` + genericNotice + `</p>`),
|
||||
}
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
366
internal/core/recovery/recovery.go
Normal file
366
internal/core/recovery/recovery.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
112
internal/core/recovery/recovery_test.go
Normal file
112
internal/core/recovery/recovery_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue