feat(iam): tenant lifecycle + operator provisioning CLI - #11
Merged
Conversation
Tenant had Suspend but no way back — a suspended tenant was stuck. Add Tenant.Activate (with a TenantActivated event) as the mirror of Suspend: it rejects an already-active tenant and a zero timestamp, and flips a suspended tenant back to active. Closes the Suspend <-> Activate lifecycle ahead of the operator CLI that will drive tenant management.
Provisioning and re-activating a tenant had no application entry point — the default tenant was seeded by raw SQL and there was no way to create a second one or bring a suspended one back. Add two use cases: - provision: the coarse Vernon "provisionTenant" — creates a Tenant together with its first admin (admin role, must-change-password) in one operation, since a tenant without an admin is unusable. The admin display name is a command field (the operator names a real person; the CLI will default it), and a duplicate slug is rejected via the repository's ErrSlugTaken. The two saves are not one transaction; the handler documents the retry semantics. - activate: the mirror of suspend — loads by id, flips a suspended tenant back to active, records TenantActivated. Both are black-box tested with in-memory fakes and white-box tested with stubs for the error branches, at 100% statement coverage.
Tenant lifecycle had no entry point: the default tenant was seeded on first boot and there was no way to create a second one or suspend/reactivate any. Add a `gavel-server tenant` command group with provision, suspend, and activate. It sits beside serve/migrate because provisioning crosses the tenant boundary — it is the host operator's job, not an in-tenant admin's and not an HTTP endpoint, so no cross-tenant superadmin surface is introduced. Tenants are addressed by --slug (resolved via the repository); provision generates and logs a one-time admin password when --admin-password is unset, and a runtime error no longer dumps the flags help. Refactor firstadmin.ResolvePassword to take the configured password as a plain string rather than *config.Config, so both first-boot (GAVEL_ADMIN_PASSWORD) and provision (--admin-password) share the resolve-or-generate logic. The commands are composition-root wiring (smoke-tested against Postgres); the provision/suspend/activate use cases underneath stay unit-tested at 100%.
provision saved the tenant and admin in two separate repository calls, so a failure after the tenant was written could leave a tenant with no admin — the one thing the coarse provision use case exists to prevent. Meanwhile first-boot seeded the same tenant+admin a second way, as raw SQL in platform/database, which had no business knowing the IAM schema. Introduce a TenantProvisioner port (Vernon's TenantProvisioningService): the application builds both aggregates, the port commits them in one transaction. The Postgres impl runs the existing repositories against a *Tx — enabled by a small database.Querier interface that both *DB and *Tx satisfy — so there is no second copy of the insert SQL and a failed admin save rolls the tenant back (covered by an integration test). Breaking "one aggregate per transaction" is deliberate here: provisioning creates brand-new rows with no contention, and it mirrors Vernon's own @transactional provisionTenant. first-boot and the test kit now seed through provision, and seed.go is deleted. serve short-circuits when the default tenant already exists (no wasted Argon2 on re-boot) and relies on the slug's unique constraint to serialize concurrent replicas — the loser gets ErrSlugTaken and no-ops. The generated password is still logged once, only after the atomic commit.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Gavel verdict
|
Multi-agent review of the seeding refactor surfaced correctness/operability regressions and cleanups; this fixes the confirmed ones. - First-boot no longer locks an operator out. seedFirstAdmin gated on the default tenant existing, so a database whose admin was deleted was never re-seeded. It now gates on the admin being present and recreates it when missing — a fresh database is provisioned tenant+admin atomically, an existing tenant gets just the admin recreated (via createuser) — restoring the no-lockout behavior. - Restore single-winner seeding under a Postgres advisory lock, so concurrent replicas no longer each pay the Argon2 hash before losing on a unique-slug insert; the loser waits, sees the admin, and no-ops. - The in-memory Provisioner is now genuinely all-or-nothing: a failing admin save rolls the tenant back, matching the Postgres transaction, so a fake can't leave a phantom tenant a real run never would. - `tenant` operator subcommands migrate before running (openOperatorDB reuses openAndMigrateDB), so provisioning a never-migrated database gets a clear schema apply instead of a raw "relation does not exist". - provision.NewCommand validates required fields in a fixed order (slice, not a map), so the "must not be empty" error names a deterministic field. - The default tenant/admin identity lives in one place (core/infrastructure/iam/ bootstrap) instead of being copy-pasted across main.go, tenant_commands.go and the test kit, where it could silently drift. - Refresh status.md's first-boot description and drop a dead no-op block in UserRepo hydration.
The deleted seed tests read must_change_password / is_active / tenant status straight from Postgres; the provisioner's happy-path test only checked the role. Assert those columns from the database too, so a repo INSERT that drops or mis-defaults the forced-password-change or active flag is caught instead of shipping green.
JorgeOlmosDev
added a commit
that referenced
this pull request
Jul 13, 2026
Layer 1 of multi-tenancy: provisioning and managing tenants, all via operator CLI commands (gavel-server tenant …), no HTTP superadmin surface. Propagating TenantID through the judicial aggregates stays out of scope (a later layer). - Tenant.Activate completes the Suspend <-> Activate lifecycle. - provision (Vernon's coarse provisionTenant: tenant + first admin) and activate use cases; suspend reused. - gavel-server tenant provision|suspend|activate, tenants by --slug; provision generates and logs a one-time admin password when --admin-password is unset. - A TenantProvisioner port commits tenant + admin in one transaction (a database.Querier lets the repos run against a *Tx, no duplicated SQL, and a failed admin save rolls the tenant back). first-boot and the test kit seed through it; the raw-SQL seed.go is deleted. - First-boot recreates the admin whenever it is missing (no operator lockout), serializes replicas on a Postgres advisory lock, and shares the default tenant/admin identity from one place. Breaking one-aggregate-per-transaction is deliberate for provisioning (no contention; mirrors Vernon's @transactional provisionTenant). Verified: gavel judge 5/5 green; provisioner integration test asserts atomic persistence, rollback, and the seeded admin's security columns; first-boot fresh / reboot-noop / admin-recovery paths smoke-tested.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Layer 1 of multi-tenancy: provisioning and managing tenants. All cross-tenant
operations are operator CLI commands (
gavel-server tenant …), not an HTTPsuperadmin surface — provisioning crosses the tenant boundary, so it belongs to
whoever operates the host, the same privilege as
serve/migrate. PropagatingTenantIDthrough the judicial aggregates is deliberately out of scope here(its own later layer).
What's here (4 commits, inward-out)
Tenant.Activate— completes the lifecycle (Suspend↔Activate), witha
TenantActivatedevent. Rejects re-activating an active tenant / zero time.provision+activateuse cases —provisionis Vernon's coarseprovisionTenant(tenant + first admin,must_change_password);activatemirrors the existing
suspend.gavel-server tenant provision|suspend|activate, tenantsaddressed by
--slug.provisiongenerates and logs a one-time adminpassword when
--admin-passwordis unset.firstadmin.ResolvePasswordisshared with first-boot.
TenantProvisionerport commits tenant + admin inone transaction (a
database.Querierlets the existing repos run against a*Tx, so no duplicated SQL and a failed admin save rolls the tenant back).first-boot and the test kit now seed through
provision;seed.go(raw SQL)is deleted.
Design notes
brand-new rows with no contention, and it mirrors Vernon's own
@TransactionalprovisionTenant. The tx is a scoped provisioner port, not a generic UoW.serveshort-circuits when the default tenant exists (no wasted Argon2 onre-boot) and serializes concurrent replicas on the slug's unique constraint
(loser gets
ErrSlugTaken→ no-op). Generated password logged once, after thecommit.
Verification
gavel judgecore (94.7%) and server (100%) — code_quality / coverage /architecture / tool_execution all green; new use cases at 100%.
failing admin save; api integration + testkit suites pass on the new seeding.
servefirst-boot provisions + logs once; re-boot is idempotent;operator
provision/suspend/activate+ duplicate-slug rejection verified.