adminprovision

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 7, 2026 License: MIT Imports: 10 Imported by: 0

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

func (p *Provisioner) Compensate(ctx context.Context, t tenant.TenantID, userID string)

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

Ensure idempotently provisions the first admin. It is race-safe across concurrent replicas; see ProvisionOutcome for branch semantics.

Steps:

  1. Fast-path CountByRole: if > 0, return OutcomeAlreadyExists (no I/O writes).
  2. Ensure admin role exists (tolerate ErrAuthRoleDuplicate).
  3. 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).
  4. AssignToUser(user, admin) — idempotent per port contract.

func (*Provisioner) Status

func (p *Provisioner) Status(ctx context.Context, t tenant.TenantID) (bool, error)

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL