Skip to content

feat(iam): tenant lifecycle + operator provisioning CLI - #11

Merged
JorgeOlmosDev merged 6 commits into
mainfrom
feat/tenant-provisioning
Jul 7, 2026
Merged

feat(iam): tenant lifecycle + operator provisioning CLI#11
JorgeOlmosDev merged 6 commits into
mainfrom
feat/tenant-provisioning

Conversation

@JorgeOlmosDev

Copy link
Copy Markdown
Contributor

Layer 1 of multi-tenancy: provisioning and managing tenants. All cross-tenant
operations are operator CLI commands (gavel-server tenant …), not an HTTP
superadmin surface — provisioning crosses the tenant boundary, so it belongs to
whoever operates the host, the same privilege as serve/migrate. Propagating
TenantID through the judicial aggregates is deliberately out of scope here
(its own later layer).

What's here (4 commits, inward-out)

  1. Tenant.Activate — completes the lifecycle (SuspendActivate), with
    a TenantActivated event. Rejects re-activating an active tenant / zero time.
  2. provision + activate use casesprovision is Vernon's coarse
    provisionTenant (tenant + first admin, must_change_password); activate
    mirrors the existing suspend.
  3. Operator CLIgavel-server tenant provision|suspend|activate, tenants
    addressed by --slug. provision generates and logs a one-time admin
    password when --admin-password is unset. firstadmin.ResolvePassword is
    shared with first-boot.
  4. Atomic provisioning — a TenantProvisioner port commits tenant + admin in
    one transaction (a database.Querier lets 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

  • Breaking "one aggregate per transaction" is deliberate: provisioning creates
    brand-new rows with no contention, and it mirrors Vernon's own @Transactional
    provisionTenant. The tx is a scoped provisioner port, not a generic UoW.
  • serve short-circuits when the default tenant exists (no wasted Argon2 on
    re-boot) and serializes concurrent replicas on the slug's unique constraint
    (loser gets ErrSlugTaken → no-op). Generated password logged once, after the
    commit.

Verification

  • gavel judge core (94.7%) and server (100%) — code_quality / coverage /
    architecture / tool_execution all green; new use cases at 100%.
  • Integration tests: provisioner persists atomically and rolls back on a
    failing admin save; api integration + testkit suites pass on the new seeding.
  • Smoke: serve first-boot provisions + logs once; re-boot is idempotent;
    operator provision/suspend/activate + duplicate-slug rejection verified.

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

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.41096% with 14 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...ore/infrastructure/iam/memory/tenant_repository.go 0.00% 8 Missing ⚠️
core/infrastructure/iam/memory/provisioner.go 77.77% 2 Missing ⚠️
.../infrastructure/iam/postgres/tenant_provisioner.go 83.33% 2 Missing ⚠️
...nfrastructure/platform/database/testkit/testkit.go 80.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

Gavel verdict

Project Verdict New Fixed Coverage
cli ✅ pass 0 0 100.0%
core ✅ pass 0 0 94.7%
server ✅ pass 0 0 100.0%
tools ✅ pass 0 0 97.5%
web ✅ pass 0 0 96.1%

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
JorgeOlmosDev merged commit 51cd8b9 into main Jul 7, 2026
10 checks passed
@JorgeOlmosDev
JorgeOlmosDev deleted the feat/tenant-provisioning branch July 7, 2026 11:59
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant