D-045 — the setup gate is now `config.setup`, not the peer/SSH tunnel:
- setup: true -> /setup serves the wizard (the code form first when
`setup_code` is set, via the existing D-044 gate);
GET / points at the wizard; every other route -> 404.
- setup: false + no admin -> LOCKED: GET / and /setup render an "enable
setup" boilerplate; every other route -> 404; the
wizard is not claimable.
- completion -> writes `setup: false` and creates the admin.
`setup_code` is now OPTIONAL: blank => Gate.Required() false => the toggle
alone admits; set => required (reusing the unlock cookie/fingerprint/
redaction/rate-limit). The peer/tunnel gate is retired: `httpx.
SetupRequestAllowed` and `httpx.IsLoopbackIP` are deleted; the XFF/XFP
handling is kept for the client IP and the Secure/__Host- cookies + HSTS
(A6/A11/D-043). The !bootstrapped() fail-safe becomes the locked predicate,
latched at startup.
New door seams: Deps.LockedMode and SetupGate.{Required,CodeForm,Landing,
Locked}; the Gate renders three first-run pages. The startup banner states
the mode and prints the code only when required.
Tests cover the three states, the boilerplate, the code optionality and the
unlock mechanics; live-smoked end to end (locked / wizard / code form /
full bootstrap -> setup: false).
604 lines
21 KiB
Go
604 lines
21 KiB
Go
// Package setup implements SPEC-005: the `/setup` wizard that brings a fresh
|
|
// instance to life. The gate is the **config toggle `setup`** (D-045):
|
|
//
|
|
// - `setup: true` → `/setup` serves the wizard at the public domain;
|
|
// - `setup: false` + no admin → the instance is LOCKED: `GET /` and
|
|
// `GET /setup` render a boilerplate telling the operator to enable setup,
|
|
// and every other route is 404 (this state is not publicly claimable);
|
|
// - completion → the wizard writes `setup: false` and creates the admin.
|
|
//
|
|
// The setup CODE (`setup_code`) is an OPTIONAL second factor (D-044 · D-045):
|
|
// when set, `GET /setup` first shows the code form and `POST /setup?do=setup&
|
|
// action=unlock` with the correct code grants the unlock (a signed cookie, not
|
|
// a session — no user exists yet); when empty, the toggle alone admits.
|
|
//
|
|
// The wizard walks three steps — (1) the domain / base URL, (2) the top admin
|
|
// with TOTP enrollment, (3) mail · locale · timezone — and finishes by
|
|
// persisting the settings to `config.yaml` with `setup: false` (D-035) and
|
|
// signalling completion (`Actions.Done`), after which `/setup` 404s (delete,
|
|
// don't redirect).
|
|
//
|
|
// Step 2 enrolls TOTP ONLY (D-041): a passkey registered over plain-HTTP
|
|
// loopback binds RP ID 127.0.0.1 and is unusable at the public domain. The
|
|
// admin adds a passkey at first login on the real origin, via the passkey
|
|
// nudge (SPEC-006 §8). It reuses SPEC-006's TOTP ceremony (flow.Actions) —
|
|
// no duplication.
|
|
//
|
|
// The wizard holds no routing of its own — the door (SPEC-003/005) gates it to
|
|
// setup mode and verifies the pre-session CSRF token (A15) before invoking any
|
|
// action here.
|
|
//
|
|
// Spec: docs/specs/spec-005-setup-wizard.md.
|
|
package setup
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"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/httpx"
|
|
"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
|
|
|
|
// Users is the narrow slice of the user service the wizard needs.
|
|
type Users interface {
|
|
// Count reports how many users exist — the fail-safe setup-mode check
|
|
// (SPEC-005 §3) and the "is the admin already created?" question.
|
|
Count(ctx context.Context) (int, error)
|
|
// Create creates the top admin via the bootstrap path (creatorID == 0).
|
|
Create(ctx context.Context, creatorID int64, email, displayName string, role users.Role) (users.UserID, error)
|
|
// Get loads a user.
|
|
Get(ctx context.Context, id int64) (users.User, error)
|
|
}
|
|
|
|
// Enroller is the SPEC-006 TOTP ceremony, reused so the wizard never
|
|
// duplicates the work (SPEC-005 §4). Step 2 enrolls TOTP only (D-041) — the
|
|
// wizard never calls the WebAuthn registration actions.
|
|
type Enroller interface {
|
|
TOTPBegin(w http.ResponseWriter, r *http.Request, guid string) error
|
|
TOTPVerify(w http.ResponseWriter, r *http.Request, guid string) error
|
|
}
|
|
|
|
// Deps assembles the wizard's dependencies.
|
|
type Deps struct {
|
|
// Config is the startup configuration; the wizard persists a copy of it
|
|
// (with `setup: false` and the new settings) to ConfigPath.
|
|
Config *config.Config
|
|
// ConfigPath is where the settings are persisted to (usually config.yaml,
|
|
// never the committed example).
|
|
ConfigPath string
|
|
// Code is the OPTIONAL setup code (D-044 · D-045). When blank the toggle
|
|
// alone admits and the wizard is served directly; when set, the wizard
|
|
// requires it (a second factor). The startup banner prints it only when set
|
|
// (`Actions.Code`).
|
|
Code string
|
|
// Users creates and inspects the top admin.
|
|
Users Users
|
|
// Identities mints and resolves the admin's identity.
|
|
Identities auth.Identities
|
|
// Enroll is the SPEC-006 ceremony (flow.Actions).
|
|
Enroll Enroller
|
|
// Audit receives the bootstrap events (A11).
|
|
Audit audit.Audit
|
|
// CSRF issues and verifies the anonymous double-submit token (A15).
|
|
CSRF *httpx.PreSessionCSRF
|
|
// Logger receives structured logs. Defaults to slog.Default().
|
|
Logger *slog.Logger
|
|
// Printout receives the rendered config when the file is not writable.
|
|
// Defaults to os.Stdout.
|
|
Printout io.Writer
|
|
// WriteFile writes the config file (tests inject a failing writer to
|
|
// exercise the "print the values" path). Defaults to os.WriteFile 0600.
|
|
WriteFile func(path string, data []byte) error
|
|
}
|
|
|
|
// Actions is the /setup wizard.
|
|
type Actions struct {
|
|
d Deps
|
|
gate *Gate
|
|
mu sync.Mutex
|
|
draft draft
|
|
// done signals that the bootstrap is complete. The live config is treated
|
|
// as an immutable startup snapshot (D-035: the FILE is the source of
|
|
// truth), so completion is a concurrency-safe flag here rather than a
|
|
// mutation of config.Setup — the door reads it on every request.
|
|
done atomic.Bool
|
|
}
|
|
|
|
// Done reports whether the wizard has completed — the door's "setup is over"
|
|
// signal (SPEC-005 §5). It is safe for concurrent use.
|
|
func (a *Actions) Done() bool { return a.done.Load() }
|
|
|
|
// draft is the small amount of wizard state that is not yet in the database or
|
|
// the config: the base URL captured in step 1 (empty = step 1 unanswered).
|
|
type draft struct {
|
|
domain string
|
|
}
|
|
|
|
// New validates and builds the wizard.
|
|
func New(d Deps) (*Actions, error) {
|
|
switch {
|
|
case d.Config == nil:
|
|
return nil, errors.New("setup: Config is required")
|
|
case d.Users == nil:
|
|
return nil, errors.New("setup: Users is required")
|
|
case d.Identities == nil:
|
|
return nil, errors.New("setup: Identities is required")
|
|
case d.Enroll == nil:
|
|
return nil, errors.New("setup: Enroll is required")
|
|
case d.CSRF == nil:
|
|
return nil, errors.New("setup: CSRF is required")
|
|
}
|
|
if strings.TrimSpace(d.ConfigPath) == "" {
|
|
return nil, errors.New("setup: ConfigPath is required")
|
|
}
|
|
if d.Logger == nil {
|
|
d.Logger = slog.Default()
|
|
}
|
|
if d.Printout == nil {
|
|
d.Printout = os.Stdout
|
|
}
|
|
if d.WriteFile == nil {
|
|
d.WriteFile = writeFile0600
|
|
}
|
|
// D-044: the first-run gate — the OPTIONAL setup code + the signed unlock
|
|
// cookie. A blank code means no code is required (D-045).
|
|
gate, err := NewGate(d.Code, d.CSRF, d.Logger)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Actions{d: d, gate: gate}, nil
|
|
}
|
|
|
|
// Code returns the setup code to print in the startup banner, or "" when no
|
|
// code is required (D-045) — there is nothing to print in that case.
|
|
func (a *Actions) Code() string { return a.gate.Code() }
|
|
|
|
// CodeRequired reports whether the wizard requires a code (D-045: `setup_code`
|
|
// is set). The startup banner prints the code only when it does.
|
|
func (a *Actions) CodeRequired() bool { return a.gate.Required() }
|
|
|
|
// Gate exposes the first-run gate so the door can ask whether a request is
|
|
// unlocked and render the first-run pages (D-044/D-045). The wizard itself
|
|
// never parses a path — the door decides.
|
|
func (a *Actions) Gate() *Gate { return a.gate }
|
|
|
|
// ── the first-run gate (D-044) ──────────────────────────────────────────────
|
|
|
|
// Unlock is the D-044 code gate: `POST /setup?do=setup&action=unlock` carrying
|
|
// the setup code. The door has already verified the pre-session CSRF token
|
|
// (A15) and rate-limited the request (A3); here we check the code, grant the
|
|
// unlock for this browser, and send the operator on to the wizard
|
|
// (post/redirect/get).
|
|
//
|
|
// When no code is required (D-045) the action is a no-op that simply lands on
|
|
// the wizard. A wrong or absent code gets a GENERIC answer — the same page and
|
|
// message either way, so the form is no oracle — and is audited (no code value
|
|
// logged).
|
|
func (a *Actions) Unlock(w http.ResponseWriter, r *http.Request) error {
|
|
if a.done.Load() {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return nil
|
|
}
|
|
if !a.gate.Required() {
|
|
redirect(w, r, "/setup")
|
|
return nil
|
|
}
|
|
if !a.gate.Accept(r.FormValue("code")) {
|
|
a.recordSetup(r.Context(), "setup.unlock", "refused")
|
|
return a.gate.codeFormError(w, r)
|
|
}
|
|
a.gate.SetUnlockCookie(w, r)
|
|
a.recordSetup(r.Context(), "setup.unlock", "unlocked")
|
|
redirect(w, r, "/setup")
|
|
return nil
|
|
}
|
|
|
|
// ── the wizard page ─────────────────────────────────────────────────────────
|
|
|
|
// Page renders the wizard at its current step (GET /setup).
|
|
func (a *Actions) Page(w http.ResponseWriter, r *http.Request) error {
|
|
st, err := a.step(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
token, err := a.d.CSRF.Ensure(w, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return renderHTML(w, http.StatusOK, wizardPage(st, token, a.draftDomain()))
|
|
}
|
|
|
|
// ── step 1 · the domain ─────────────────────────────────────────────────────
|
|
|
|
// DomainSave records the base URL (step 1) and advances to step 2.
|
|
func (a *Actions) DomainSave(w http.ResponseWriter, r *http.Request) error {
|
|
if a.done.Load() {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return nil
|
|
}
|
|
base := strings.TrimSpace(r.FormValue("base_url"))
|
|
if base == "" {
|
|
writeError(w, http.StatusBadRequest, "a base URL is required")
|
|
return nil
|
|
}
|
|
if u, err := url.Parse(base); err != nil || u.Scheme == "" || u.Host == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid base URL")
|
|
return nil
|
|
}
|
|
a.mu.Lock()
|
|
a.draft.domain = base
|
|
a.mu.Unlock()
|
|
redirect(w, r, "/setup")
|
|
return nil
|
|
}
|
|
|
|
// ── step 2 · the top admin ──────────────────────────────────────────────────
|
|
|
|
// AdminCreate creates `user_id = 1` (role admin) and mints its identity. Only
|
|
// one admin may ever exist (SPEC-004 AC-6) — a second attempt is refused.
|
|
func (a *Actions) AdminCreate(w http.ResponseWriter, r *http.Request) error {
|
|
ctx := r.Context()
|
|
if a.done.Load() {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return nil
|
|
}
|
|
n, err := a.d.Users.Count(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n > 0 {
|
|
// The admin may already exist. Refuse a SECOND admin (AC-5), but repair
|
|
// a first one left without an identity (a crash after Create, before
|
|
// Mint) — otherwise the wizard could never be resumed.
|
|
repaired, err := a.ensureAdminIdentity(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !repaired {
|
|
writeError(w, http.StatusConflict, "an admin already exists")
|
|
return nil
|
|
}
|
|
redirect(w, r, "/setup")
|
|
return nil
|
|
}
|
|
|
|
name := strings.TrimSpace(r.FormValue("display_name"))
|
|
email := strings.TrimSpace(r.FormValue("email"))
|
|
if name == "" || email == "" {
|
|
writeError(w, http.StatusBadRequest, "a display name and an email are required")
|
|
return nil
|
|
}
|
|
|
|
userID, err := a.d.Users.Create(ctx, 0, email, name, users.RoleAdmin)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, users.ErrBootstrapClosed), errors.Is(err, users.ErrOneAdmin),
|
|
errors.Is(err, users.ErrDuplicateEmail):
|
|
writeError(w, http.StatusConflict, "cannot create the admin")
|
|
case errors.Is(err, users.ErrEmailRequired), errors.Is(err, users.ErrNameRequired),
|
|
errors.Is(err, users.ErrInvalidRole), errors.Is(err, users.ErrBootstrapAdmin):
|
|
writeError(w, http.StatusBadRequest, "bad request")
|
|
default:
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
if _, _, err := a.d.Identities.Mint(ctx, userID); err != nil {
|
|
return err
|
|
}
|
|
a.record(ctx, "setup.admin", fmt.Sprintf("user:%d", userID))
|
|
redirect(w, r, "/setup")
|
|
return nil
|
|
}
|
|
|
|
// ensureAdminIdentity mints the top admin's identity when the user exists but
|
|
// has none (a crash between Create and Mint). It reports repaired=false when an
|
|
// identity already exists — the caller then refuses a second admin (AC-5).
|
|
func (a *Actions) ensureAdminIdentity(ctx context.Context) (repaired bool, err error) {
|
|
if _, err := a.d.Identities.ByUserID(ctx, adminUserID); err == nil {
|
|
return false, nil
|
|
} else if !errors.Is(err, auth.ErrIdentityNotFound) {
|
|
return false, err
|
|
}
|
|
u, err := a.d.Users.Get(ctx, adminUserID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if u.Role != users.RoleAdmin {
|
|
return false, nil
|
|
}
|
|
if _, _, err := a.d.Identities.Mint(ctx, adminUserID); err != nil {
|
|
return false, err
|
|
}
|
|
a.record(ctx, "setup.admin", fmt.Sprintf("user:%d", adminUserID))
|
|
return true, nil
|
|
}
|
|
|
|
// TOTPBegin issues the admin's TOTP secret.
|
|
func (a *Actions) TOTPBegin(w http.ResponseWriter, r *http.Request) error {
|
|
guid, ok, err := a.pendingGUID(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
writeError(w, http.StatusConflict, "no admin is awaiting enrollment")
|
|
return nil
|
|
}
|
|
return a.d.Enroll.TOTPBegin(w, r, guid)
|
|
}
|
|
|
|
// TOTPVerify verifies the code, activating the identity and minting a session
|
|
// (the ceremony lives in SPEC-006).
|
|
func (a *Actions) TOTPVerify(w http.ResponseWriter, r *http.Request) error {
|
|
guid, ok, err := a.pendingGUID(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
writeError(w, http.StatusConflict, "no admin is awaiting enrollment")
|
|
return nil
|
|
}
|
|
return a.d.Enroll.TOTPVerify(w, r, guid)
|
|
}
|
|
|
|
// ── step 3 · settings & finish ──────────────────────────────────────────────
|
|
|
|
// Finish persists the mail/locale/timezone settings (a copy of the startup
|
|
// config with `setup: false`), signals completion (`Done`) and lands the freshly
|
|
// enrolled admin on their dashboard.
|
|
//
|
|
// If `config.yaml` is not writable the values are PRINTED for the operator
|
|
// (SPEC-005 §4) and setup still completes.
|
|
func (a *Actions) Finish(w http.ResponseWriter, r *http.Request) error {
|
|
ctx := r.Context()
|
|
if a.done.Load() {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return nil
|
|
}
|
|
st, err := a.step(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if st != stepSettings {
|
|
writeError(w, http.StatusConflict, "the admin is not enrolled yet")
|
|
return nil
|
|
}
|
|
|
|
port := 0
|
|
if v := strings.TrimSpace(r.FormValue("mail.port")); v != "" {
|
|
p, err := strconv.Atoi(v)
|
|
if err != nil || p < 0 || p > 65535 {
|
|
writeError(w, http.StatusBadRequest, "invalid mail port")
|
|
return nil
|
|
}
|
|
port = p
|
|
}
|
|
|
|
c := *a.d.Config // a copy: the live config stays the startup snapshot (D-035)
|
|
c.Setup = false
|
|
c.Mail = config.Mail{
|
|
Host: strings.TrimSpace(r.FormValue("mail.host")),
|
|
Port: port,
|
|
Username: strings.TrimSpace(r.FormValue("mail.username")),
|
|
Password: r.FormValue("mail.password"),
|
|
From: strings.TrimSpace(r.FormValue("mail.from")),
|
|
}
|
|
c.App = config.App{
|
|
Locale: strings.TrimSpace(r.FormValue("app.locale")),
|
|
Timezone: strings.TrimSpace(r.FormValue("app.timezone")),
|
|
}
|
|
if d := a.draftDomain(); d != "" {
|
|
c.Server.BaseURL = d
|
|
}
|
|
|
|
// R-6: a first boot from config.example.yaml must persist a VIABLE config.
|
|
// Any secret that is still empty or a placeholder is replaced with a
|
|
// strong, freshly generated value before it is written.
|
|
if _, err := a.generateSecrets(&c); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := a.persist(&c); err != nil {
|
|
return err
|
|
}
|
|
a.done.Store(true)
|
|
a.gate.Clear() // retire the setup code + every outstanding unlock (D-044)
|
|
a.record(ctx, "setup.complete", "bootstrap finished")
|
|
|
|
target := "/home"
|
|
if guid, err := a.adminGUID(ctx); err == nil && guid != "" {
|
|
target = "/" + guid + "/home"
|
|
}
|
|
redirect(w, r, target)
|
|
return nil
|
|
}
|
|
|
|
// generateSecrets replaces any empty or placeholder secret with a strong,
|
|
// cryptographically-random value (R-6), so a copy of config.example.yaml boots
|
|
// straight into productive mode after the wizard. It reports whether it changed
|
|
// anything.
|
|
//
|
|
// The TOTP encryption key feeds AES-256-GCM, whose key must be EXACTLY 32
|
|
// bytes (SPEC-003 D-2), so it is base64url of 24 random bytes = 32 printable
|
|
// characters. The other three are signing keys and only need >= 32 bytes.
|
|
func (a *Actions) generateSecrets(c *config.Config) (bool, error) {
|
|
changed := false
|
|
for _, s := range []struct {
|
|
name string
|
|
dst *string
|
|
n int
|
|
}{
|
|
{"secrets.cookie_secret", &c.Secrets.CookieSecret, 32},
|
|
{"secrets.csrf_key", &c.Secrets.CSRFKey, 32},
|
|
} {
|
|
if !config.SecretIsPlaceholder(*s.dst) {
|
|
continue
|
|
}
|
|
v, err := randomString(s.n)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
*s.dst = v
|
|
changed = true
|
|
a.d.Logger.Info("setup: generated a strong secret", "name", s.name)
|
|
}
|
|
if config.SecretIsPlaceholder(c.Secrets.TOTPEncryptionKey) {
|
|
v, err := randomString(24) // 24 bytes -> 32 base64url chars
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
c.Secrets.TOTPEncryptionKey = v
|
|
changed = true
|
|
a.d.Logger.Info("setup: generated a strong secret", "name", "secrets.totp_encryption_key")
|
|
}
|
|
return changed, nil
|
|
}
|
|
|
|
// randomString returns n cryptographically-random bytes, base64url-encoded.
|
|
// It delegates to config.GenerateSecret so the wizard and the startup path (the
|
|
// TOTP key, R-6) mint secrets the same way.
|
|
func randomString(n int) (string, error) { return config.GenerateSecret(n) }
|
|
|
|
// persist writes the config to disk; when it cannot, it prints the values and
|
|
// instructs the operator (SPEC-005 §4). It is complete either way.
|
|
func (a *Actions) persist(c *config.Config) error {
|
|
data, err := config.Marshal(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := a.d.WriteFile(a.d.ConfigPath, data); err != nil {
|
|
a.printValues(data, err)
|
|
return nil
|
|
}
|
|
a.d.Logger.Info("setup: settings persisted", "path", a.d.ConfigPath)
|
|
return nil
|
|
}
|
|
|
|
// printValues is the operator fallback when config.yaml is not writable: the
|
|
// values go to stdout with an instruction, and setup still completes.
|
|
func (a *Actions) printValues(data []byte, cause error) {
|
|
a.d.Logger.Warn("setup: config file is not writable — printing the values for the operator",
|
|
"path", a.d.ConfigPath, "error", cause)
|
|
fmt.Fprintf(a.d.Printout,
|
|
"\n# MyDay setup could not write %s: %v\n"+
|
|
"# Write the following to that path by hand, then restart MyDay:\n\n%s\n",
|
|
a.d.ConfigPath, cause, data)
|
|
}
|
|
|
|
// ── state ───────────────────────────────────────────────────────────────────
|
|
|
|
// step is the wizard's position.
|
|
type step int
|
|
|
|
const (
|
|
stepDomain step = iota // 1 · the domain / base URL
|
|
stepAdmin // 2a · the admin's details
|
|
stepEnroll // 2b · TOTP enrollment (D-041: no passkey)
|
|
stepSettings // 3 · mail · locale · timezone
|
|
)
|
|
|
|
// step derives the current step from the database and the in-memory draft.
|
|
func (a *Actions) step(ctx context.Context) (step, error) {
|
|
n, err := a.d.Users.Count(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if n == 0 {
|
|
if a.draftDomain() != "" {
|
|
return stepAdmin, nil
|
|
}
|
|
return stepDomain, nil
|
|
}
|
|
|
|
idn, err := a.d.Identities.ByUserID(ctx, adminUserID)
|
|
if err != nil {
|
|
if errors.Is(err, auth.ErrIdentityNotFound) {
|
|
// A user exists but has no live identity — restart step 2.
|
|
return stepAdmin, nil
|
|
}
|
|
return 0, err
|
|
}
|
|
if idn.Status == auth.StatusPendingEnrollment {
|
|
return stepEnroll, nil
|
|
}
|
|
return stepSettings, nil
|
|
}
|
|
|
|
// draftDomain returns the base URL captured in step 1, if any.
|
|
func (a *Actions) draftDomain() string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return a.draft.domain
|
|
}
|
|
|
|
// adminGUID returns the admin's current GUID.
|
|
func (a *Actions) adminGUID(ctx context.Context) (string, error) {
|
|
idn, err := a.d.Identities.ByUserID(ctx, adminUserID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return idn.GUID, nil
|
|
}
|
|
|
|
// pendingGUID returns the admin's GUID when its identity still awaits
|
|
// enrollment; ok=false means there is nothing to enroll.
|
|
func (a *Actions) pendingGUID(ctx context.Context) (guid string, ok bool, err error) {
|
|
idn, err := a.d.Identities.ByUserID(ctx, adminUserID)
|
|
if err != nil {
|
|
if errors.Is(err, auth.ErrIdentityNotFound) {
|
|
return "", false, nil
|
|
}
|
|
return "", false, err
|
|
}
|
|
if idn.Status != auth.StatusPendingEnrollment {
|
|
return "", false, nil
|
|
}
|
|
return idn.GUID, true, nil
|
|
}
|
|
|
|
// record writes one bootstrap audit row (A11).
|
|
func (a *Actions) record(ctx context.Context, action, detail string) {
|
|
if a.d.Audit == nil {
|
|
return
|
|
}
|
|
a.d.Audit.Record(ctx, audit.Event{
|
|
Action: action,
|
|
Subject: fmt.Sprintf("user:%d", adminUserID),
|
|
Detail: detail,
|
|
})
|
|
}
|
|
|
|
// recordSetup writes an audit row for an event that happens BEFORE the admin
|
|
// exists (the D-044 unlock). The subject is anonymous — there is no user yet.
|
|
func (a *Actions) recordSetup(ctx context.Context, action, detail string) {
|
|
if a.d.Audit == nil {
|
|
return
|
|
}
|
|
a.d.Audit.Record(ctx, audit.Event{
|
|
Action: action,
|
|
Subject: "anonymous",
|
|
Detail: detail,
|
|
})
|
|
}
|
|
|
|
// writeFile0600 is the default persister: the config holds secrets, so it is
|
|
// written owner-only.
|
|
func writeFile0600(path string, data []byte) error {
|
|
return os.WriteFile(path, data, 0o600)
|
|
}
|