Documentation
¶
Overview ¶
Package setup implements the interactive first-run admin provisioning slice.
Two Public HTTP endpoints:
GET /api/v1/access/setup/status — returns {"hasAdmin": bool}
POST /api/v1/access/setup/admin — creates the first admin; 410 Gone after initialized
Race-safe admin creation is delegated to cells/accesscore/internal/adminprovision so the semantics match the headless initialadmin Lifecycle exactly.
Index ¶
- Constants
- func SliceMetadata() *metadata.SliceMeta
- type AdminAdapter
- type CreateAdminInput
- type CreateAdminOutput
- type Handler
- type Option
- type Service
- func (s *Service) CreateAdmin(ctx context.Context, in CreateAdminInput) (*CreateAdminOutput, error)
- func (s *Service) RecordBootstrapAuthFail(ctx context.Context, reason string, clientIPHash redaction.IPHash) error
- func (s *Service) Status(ctx context.Context, t tenant.TenantID) (StatusOutput, error)
- type StatusAdapter
- type StatusOutput
Constants ¶
const ( MaxUsernameLen = 128 MaxEmailLen = 256 MinPasswordBytes = 8 MaxPasswordBytes = 72 )
Password bounds for the setup endpoint:
- Password bytes must be printable ASCII so JSON Schema maxLength and bcrypt's byte-counted input limit have the same semantics.
- MaxPasswordBytes matches golang.org/x/crypto/bcrypt's hard input limit.
Variables ¶
This section is empty.
Functions ¶
func SliceMetadata ¶
SliceMetadata returns the package-scope *metadata.SliceMeta projected from slice.yaml. Composition roots consume this via cell.MustNewBaseSliceFromMeta(<slicePkg>.SliceMetadata()) — the typed funnel that replaces the legacy `cell.NewBaseSlice(id, cellID, level)` literal pattern.
Types ¶
type AdminAdapter ¶
type AdminAdapter struct{ S *Service }
AdminAdapter implements adminGen.Service for http.auth.setup.admin.v1.
func (AdminAdapter) Admin ¶
func (a AdminAdapter) Admin(ctx context.Context, req *adminGen.Request) (adminGen.AdminResponseObject, error)
Admin implements adminGen.Service. The generated handler validates and decodes username+email+password from the request body and populates req.XTenantID from the X-Tenant-ID header. A missing/malformed tenant fails closed inside Service.CreateAdmin (tenant.ParseTenantID on CreateAdminInput.TenantID) to a 400 ERR_AUTH_IDENTITY_INVALID_INPUT (ADR 1160).
type CreateAdminInput ¶
CreateAdminInput holds the operator-supplied first-admin fields. TenantID is the tenant for which the admin is being provisioned; it must be a canonical UUID and is required (bootstrap must designate a specific tenant).
type CreateAdminOutput ¶
type CreateAdminOutput struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
CreatedAt string `json:"createdAt"`
}
CreateAdminOutput is the response shape for POST /api/v1/access/setup/admin.
type Handler ¶
type Handler struct {
// contains filtered or unexported fields
}
Handler exposes the setup endpoints over HTTP.
The status endpoint is always Public (no admin exists yet during first-run). The admin endpoint is Bootstrap (HTTP Basic Auth via env credentials); the bootstrapAuth middleware is mandatory and is threaded straight to the generated handler — see ADR §D1 (auth.bootstrap is a closed contract: no "declared bootstrap but no auth wired" intermediate state).
func NewHandler ¶
NewHandler creates a setup Handler.
bootstrapAuth is REQUIRED — it is the per-route replacement authentication (typically runtime/auth.NewBootstrapMiddleware wired by the composition root). The generated admin handler panics at construction if bootstrapAuth is nil; this constructor enforces the same invariant up front so the failure mode is "Cell.Init returns a clear error" rather than "process panic deep in the generated layer".
func (*Handler) RegisterRoutes ¶
func (h *Handler) RegisterRoutes(mux kcell.RouteHandler) error
RegisterRoutes mounts the setup contract handlers on mux. The X-Tenant-ID header is consumed by the generated handlers (Request.XTenantID), so no tenant-injection middleware is needed.
type Option ¶
type Option func(*Service)
Option configures a Service.
func WithEmitter ¶
func WithEmitter(e outbox.CellEmitter) Option
WithEmitter sets the event emitter. Accepts a sealed outbox.CellEmitter; typed-nil inputs are silently ignored (builder-option semantics).
func WithPasswordHasher ¶
func WithPasswordHasher(h credential.Hasher) Option
WithPasswordHasher overrides the password hasher (default credential.NewProductionHasher(), cost 12). Unit tests wire credential.NewTestHasher(bcrypt.MinCost) for speed. BCRYPT-COST-FUNNEL-01 guards that production never reaches the low-cost door. Bare/typed-nil inputs are silently ignored (builder-option semantics) so the production default survives.
func WithSetupLock ¶
func WithSetupLock(lock ports.SetupLockAcquirer) Option
WithSetupLock injects the REQUIRED serialization primitive for the admin-provisioning path. CreateAdmin acquires the lock at the start of the RunInTx body before calling adminprovision.Ensure — the lock, user write, and outbox emit share a single transaction scope.
NewService rejects a missing or nil setupLock with ErrCellInvalidConfig so that mis-wired assemblies fail at startup rather than at the first CreateAdmin call. The cell-level WithSetupLock injects this option from cells/accesscore composition; see accesscore.WithSetupLock godoc for the PG vs memstore wiring choice.
Bare-nil inputs are silently ignored (builder-option semantics); final nil validation (including typed-nil) is handled by validateRequired().
func WithTxManager ¶
func WithTxManager(tx persistence.CellTxManager) Option
WithTxManager sets the CellTxManager for L2 atomicity (user write + event emit). Callers obtain the sealed marker via persistence.WrapForCell from a composition root.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service implements the setup slice's business logic.
func NewService ¶
func NewService(clk clock.Clock, provisioner *adminprovision.Provisioner, logger *slog.Logger, opts ...Option) (*Service, error)
NewService constructs a Service. provisioner is required; passing nil returns an error so mis-wired assemblies fail at startup.
func (*Service) CreateAdmin ¶
func (s *Service) CreateAdmin(ctx context.Context, in CreateAdminInput) (*CreateAdminOutput, error)
CreateAdmin provisions the first admin user with an operator-chosen password.
Returns errcode.ErrSetupAlreadyInitialized when an admin already exists (either at the fast-path Status check or after a race detected inside adminprovision.Ensure).
Consistency: L2 (OutboxFact) in durable mode. The user write + event emit share a single TxRunner scope so event publication is atomic with row persistence — if the emit fails, the tx rolls back and the user row is removed.
Demo-mode caveat: When wired with persistence.NoopTxRunner (in-memory repositories), RunInTx has no rollback, so a publishUserCreated failure after a successful adminprovision.Ensure leaves the user + role persisted without the event emitted. The next POST hits the fast-path 410 via CountByRole. Production must wire a real TxRunner; demo mode accepts this gap as it matches the identitymanage.Create pattern (service.go:128-139).
Security: bcrypt runs AFTER the Status fast-path so a flood of POSTs after admin exists returns 410 in ~milliseconds without CPU burn. The hash cost (credential.ProductionCost in production) is only paid on the single winning request (plus same-process concurrent race-losers serialized by memTxRunner.RunInTx holding store.mu in memstore mode, or by pg_advisory_xact_lock in PG mode).
func (*Service) RecordBootstrapAuthFail ¶
func (s *Service) RecordBootstrapAuthFail(ctx context.Context, reason string, clientIPHash redaction.IPHash) error
RecordBootstrapAuthFail emits event.auth.bootstrap-failed.v1 inside a transaction so the outbox row is persisted atomically with the emitter database write. Called from the accesscore bootstrap observer closure after a 401/429 response is written; errors are logged by the caller and not surfaced to the HTTP response (best-effort audit for non-durable emitters, persistent outbox row for durable mode).
reason must be one of "missing_header", "wrong_credentials", "rate_limited". clientIPHash is the sealed, keyed hash of the client IP (redaction.HashIP), computed by the composition root — the plaintext IP never reaches the cell or the replayable payload (#1488). It is the zero IPHash when no IP was available.
func (*Service) Status ¶
Status returns whether the given tenant already has at least one admin.
The admin-existence check reads role_assignments / users, which are under FORCE ROW LEVEL SECURITY (migration 053). setup is a PRE-AUTH endpoint (no JWT → no ctxkeys.TenantID fallback), so the read is scoped explicitly through scopedtx.Do (#1617 PR-3b review F1): under the restricted app-serving pool (#1676) a bare-pool read would be fail-closed to 0 rows by the unset app.tenant_id GUC, falsely reporting hasAdmin:false.
type StatusAdapter ¶
type StatusAdapter struct{ S *Service }
StatusAdapter implements statusGen.Service for http.auth.setup.status.v1. The status endpoint is always Public (no JWT required): no admin exists yet during first-run bootstrap.
func (StatusAdapter) Status ¶
func (a StatusAdapter) Status(ctx context.Context, req *statusGen.Request) (statusGen.StatusResponseObject, error)
Status implements statusGen.Service. The generated handler populates req.XTenantID from the X-Tenant-ID header declared in contract.yaml endpoints.http.headers; this adapter parses it to scope the admin existence check to the tenant.
type StatusOutput ¶
type StatusOutput struct {
HasAdmin bool `json:"hasAdmin"`
}
StatusOutput is the response shape for GET /api/v1/access/setup/status.