Config-flag → email user 1 an enrolment link (D-033), with the approve/deny actions and dual control.
136 lines
4.4 KiB
Go
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
|
|
}
|