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 }