NextMyDay/internal/core/recovery/admin.go
Claus Lohmar 4c839ff30d 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.
2026-10-14 10:53:53 +00:00

136 lines
4.4 KiB
Go

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
}