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(`

Account access

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(`

Account access

` + 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 "" }