Documentation
¶
Overview ¶
Package adminprovision encapsulates the idempotent, race-safe "bring the first admin into existence" domain logic. After PR #392 the only consumer is cells/accesscore/slices/setup (the interactive POST /api/v1/access/setup/admin endpoint with an operator-supplied password); the headless initialadmin Lifecycle has been deleted.
The package is caller-tx-neutral: Ensure does not open its own transaction and does not emit events, so callers compose it with whichever persistence boundary they own (TxRunner + outbox for setup). Ensure is NOT internally serialized — the in-process sync.Mutex was removed once PR #482 wired the PG adapter; concurrent invocations must be serialized by the caller through a RunInTx + ports.SetupLockAcquirer pair:
- PG mode: accesspg.NewBundle(pool, txm, clk).SetupLock() uses pg_advisory_xact_lock for cross-pod mutual exclusion.
- Memstore mode: accesscore.NoopSetupLock — memTxRunner.RunInTx holds store.mu for the whole closure, already serializing goroutines.
The cell-level accesscore.WithSetupLock is mandatory and rejects nil at phase0 (cells/accesscore/cell.go), so a composition root that forgets to wire either lock fails fast at startup, not at the first concurrent setup.
Outcomes are modeled as a ProvisionOutcome enum rather than a boolean so callers can distinguish fresh creates (write credfile / emit event), prior completions (silent skip / 410), and concurrent-replica races (silent skip).
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type ProvisionInput ¶
type ProvisionInput struct {
TenantID tenant.TenantID
Username string
Email string
PasswordHash []byte
RequireReset bool
}
ProvisionInput holds the inputs for a single Ensure call.
PasswordHash is pre-hashed by the caller (bcrypt). Provisioner never sees plaintext. A duplicate username returns 409 ErrAuthUserDuplicate; the caller must use a unique username (setup path enforces this at HTTP layer). TenantID identifies the tenant for which the admin is being provisioned.
type ProvisionOutcome ¶
type ProvisionOutcome int
ProvisionOutcome is the result classification returned from Ensure.
Callers decide per outcome whether to persist side effects (credfile, event) or surface 409 / silently skip.
const ( // OutcomeUnknown is the zero value; it is never returned successfully. OutcomeUnknown ProvisionOutcome = iota // OutcomeCreated means a fresh admin user + role assignment were persisted. // Caller may emit user.created event and/or write credential file. OutcomeCreated // OutcomeAlreadyExists means at least one admin existed at the fast-path // CountByRole check; no side effects were performed. Caller returns 409 // (HTTP) or nil (Lifecycle — silent skip). OutcomeAlreadyExists // OutcomeRaceSkipped means the fast-path CountByRole read zero admins but // a concurrent replica persisted the admin between check and create. // No rows were written. Caller treats the same as OutcomeAlreadyExists. OutcomeRaceSkipped )
type ProvisionResult ¶
type ProvisionResult struct {
User *domain.User
Outcome ProvisionOutcome
}
ProvisionResult holds the successful outcome of Ensure.
type Provisioner ¶
type Provisioner struct {
// contains filtered or unexported fields
}
Provisioner is the shared domain service.
It is caller-tx-neutral: Ensure does not open a transaction; callers wrap it with their own TxRunner if atomicity across Ensure + adjacent writes is required.
Concurrency: Ensure is NOT internally serialized. Callers must serialize concurrent invocations through a transactional boundary that locks the CountByRole-Create-Assign window:
- PG mode: open a transaction via persistence.TxRunner.RunInTx and call ports.SetupLockAcquirer.Acquire inside it. The PG implementation (PGSetupLock) uses pg_advisory_xact_lock, which is exclusive across pods and goroutines until tx commit/rollback.
- Memstore mode: use Store.TxRunner — memTxRunner.RunInTx holds store.mu for the entire closure, serializing all goroutines (equivalent to PG SELECT FOR UPDATE held until commit).
The single production caller (cells/accesscore/slices/setup.Service.CreateAdmin) wires both via RunInTx + the mandatory accesscore.WithSetupLock option. PG composition roots inject accesspg.NewBundle(pool, txm, clk).SetupLock(); memstore callers inject accesscore.NoopSetupLock{} (no second lock — store.mu does the work).
func NewProvisioner ¶
func NewProvisioner( userRepo ports.UserRepository, roleRepo ports.RoleRepository, logger *slog.Logger, newID UUIDGenerator, clk clock.Clock, ) (*Provisioner, error)
NewProvisioner constructs a Provisioner. All dependencies are required; passing nil returns an error so mis-wired assemblies fail at startup rather than at the first Ensure call.
func (*Provisioner) Compensate ¶
Compensate best-effort removes the admin role assignment and user row after a post-Ensure side effect (e.g., credfile write) fails. Errors are logged, not returned: the operator's immediate concern is the outer failure.
func (*Provisioner) Ensure ¶
func (p *Provisioner) Ensure(ctx context.Context, in ProvisionInput) (ProvisionResult, error)
Ensure idempotently provisions the first admin. It is race-safe across concurrent replicas; see ProvisionOutcome for branch semantics.
Steps:
- Fast-path CountByRole: if > 0, return OutcomeAlreadyExists (no I/O writes).
- Ensure admin role exists (tolerate ErrAuthRoleDuplicate).
- Build user with a fresh UUID, persist via UserRepo.Create. - On ErrAuthUserDuplicate: recount admins. > 0 → OutcomeRaceSkipped (concurrent replica finished first). == 0 → 409 ErrAuthUserDuplicate (username conflict, operator must use a different username).
- AssignToUser(user, admin) — idempotent per port contract.
func (*Provisioner) Status ¶
Status reports whether at least one *effective* admin exists — that is, a user with status='active' AND holding the admin role (S4.0). A locked/suspended admin alone does NOT count: setup-retirement and Ensure fast-paths must allow operator recovery when the only remaining admins can't actually administer (see ADR `docs/architecture/202605101400-adr-admin-invariant.md`).
Pre-S4.0 this method counted any role_assignments holder via CountByRole — that semantic was wrong for setup retirement: a system with only locked admins would silently retire setup, leaving the operator with no HTTP recovery path.
Infrastructure errors bubble up unchanged so callers can distinguish a known "no effective admin" from a transient RoleRepo outage.
type UUIDGenerator ¶
type UUIDGenerator func() string
UUIDGenerator returns a fresh UUID string. Injected for deterministic tests.