Closes the tenant-anchor divergence that blocked the Auth-5 sdk/backend flips. The registry is the authority for tenant identity (model B2), but nothing ever told the product about a new tenant. The anchors drifted: the registry held acme/matrix-acme while the product database held only the legacy seed tenant 9282a473. With the SDK gate enabled its TenantResolver does GetTenantBySlug and would 403 EVERY authenticated request, because no real user's org slug existed locally. New internal/product port, mirroring keycloak.Adapter: handlers depend on the interface, main wires HTTPProvisioner when PRODUCT_API_URL is set and NoopProvisioner otherwise, so an unconfigured deployment still creates tenants. Tenant creation now also provisions the product tenant with OUR uuid, using the same best-effort contract as Keycloak provisioning: a failure does not roll the tenant back, it emits a product.provision_failed audit event so the divergence is traceable. Success emits product.tenant_provisioned. A 409 from the product counts as success — onboarding may retry and the product's insert is idempotent on the primary key (compliance#217). Server.productProvisioner() guarantees the documented never-nil invariant; tests construct Server directly and would otherwise panic mid-tenant-creation. 5 tests incl. the point of the whole port (the registry UUID is what gets sent). Full suite green with -race, coverage 71.4% (gate 70). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNdLL9BdsWm7MCyui5ffPD
tenant-registry
Multi-tenant glue: orgs, entitlements, API keys, audit.
Part of the Breakpilot Platform. For the big picture see
platform/docs: Architecture · Infrastructure · Product Integration Spec · Implementation Plan
What this is
Multi-tenant glue: orgs, entitlements, API keys, audit. Scaffolded under milestone M4.1. See platform/docs for the full architecture context.
Plane: Control Owner: @sharang Status: pre-alpha Linked milestone: M4.1
Run locally
# Prerequisites: Go 1.25+
# Dependencies (Keycloak, pg-app) come from the dev stack — see platform/orca-platform/dev.
# In one terminal — bring up dev dependencies (in the orca-platform clone):
cd /path/to/platform/orca-platform && make dev-up
# In another — run the service:
make dev # APP_ENV=dev, listens on :8090 (Keycloak owns :8080 in the dev stack)
make test # unit tests
make build # compile to ./bin/tenant-registry
Env vars (override at the shell):
| Var | Default | Purpose |
|---|---|---|
APP_ENV |
dev |
one of dev, stage, prod |
ADDR |
:8090 |
listen address (avoids Keycloak's :8080) |
KEYCLOAK_ISSUER |
http://localhost:8080/realms/breakpilot-dev |
OIDC issuer URL (the JWT signer) |
DATABASE_URL |
empty (in-memory store fallback) | Postgres DSN; service uses Memory when empty |
KEYCLOAK_ADMIN_URL |
empty (Mock adapter used in dev) | KC base URL for the Admin API |
KEYCLOAK_REALM |
breakpilot-dev |
Realm name for Admin API calls |
KEYCLOAK_CLIENT_ID |
empty | Service-account client id (Admin) |
KEYCLOAK_CLIENT_SECRET |
empty | Service-account client secret |
Endpoints
Authoritative spec: openapi.yaml. Summary:
| Method | Path | Purpose |
|---|---|---|
| GET | /healthz |
Liveness |
| GET | /readyz |
Pings the store |
| POST | /v1/tenants |
Create a tenant |
| GET | /v1/tenants/{id} |
Read by id |
| GET | /v1/tenants/by-slug/{slug} |
Read by slug (portal middleware uses this) |
| POST | /v1/tenants/{id}/activate |
trial → active |
| POST | /v1/tenants/{id}/cancel |
active → frozen |
| GET | /v1/entitlements?tenant_id={id} |
List product entitlements |
| GET | /v1/catalog |
List requestable products |
| POST | /v1/catalog/request |
Customer requests a product (sales follow-up) |
| POST | /v1/catalog/trial-request |
Self-serve 14-day trial |
| GET | /v1/api-keys?tenant_id={id} |
List keys |
| POST | /v1/api-keys |
Create key (plaintext shown once) |
| DELETE | /v1/api-keys/{id} |
Revoke |
| POST | /v1/internal/api-keys/verify |
Used by headless products to validate inbound keys |
| POST | /v1/audit |
Append an audit event |
| GET | /v1/audit |
Query (cursor-paginated) |
State-changing endpoints emit audit events automatically. The OpenAPI contract test (openapi_test.go) asserts every listed path resolves against the committed spec.
Storage
The service picks its store based on DATABASE_URL:
- empty → in-memory store, pre-seeded with the
acmetenant (id: 00000000-0000-0000-0000-000000000001). Useful for portal dev without spinning Postgres. - set → pgx-backed Postgres. Run
make migrate-upagainst the same DSN first.
Both implementations pass the same test harness (internal/server/server_test.go → eachStore).
Keycloak adapter (M4.3)
internal/keycloak is the seam between tenant-registry and Keycloak. The
Adapter interface has two implementations:
| Implementation | When used |
|---|---|
Mock |
Default in dev when KEYCLOAK_ADMIN_URL is empty |
HTTPAdapter |
Real KC Admin API client; activated when KC env vars are populated |
POST /v1/tenants now accepts admin_email and admin_name. When set, the
adapter creates a Keycloak organization (alias = the tenant slug), invites
the user as the IT_ADMIN, and triggers the verify-email + set-password
flow. The response body includes invite_url so dev testers can use it
without waiting for the email — production discards it.
KC failures are non-fatal. The tenant row still lands; a
keycloak.provision_failed audit event captures the error so the operator
can resend the invite from the KC UI.
POST /v1/internal/keycloak/claims resolves a tenant's current entitlement
bundle (tenant_id, slug, products, plan, status). The realm's protocol
mapper calls this at token-issuance time (or whenever user attributes
need a refresh).
For production, provision a service-account client in the realm with the
realm-management:manage-users + manage-organizations roles. Drop its
credentials in Infisical at /{env}/tenant-registry/KEYCLOAK_CLIENT_*.
Schema migrations (M4.1)
# Apply all pending migrations against the dev Postgres (assumes
# `make dev-up` in platform/orca-platform is running):
make migrate-up
# Inspect current version:
make migrate-version
# Roll back the most recent migration:
make migrate-down
# Wipe everything (DESTRUCTIVE — only safe against a dev DB):
make migrate-down-all
# Create the next pair of empty migration files:
make migrate-create NAME=add_team_table
Migrations are embedded into both cmd/server and cmd/migrate via migrations/embed.go. In production, cmd/migrate ships as an Orca init container so the schema is applied before the API server starts (IMPLEMENTATION_PLAN.md §1.7: migrations are forward-only and run as an init container before the service).
The migrations package ships three integration tests (require Docker):
| Test | What it asserts |
|---|---|
TestMigrate_upDownRoundTrip |
up → all 6 tables + 4 enums exist; down → schema empty; up again succeeds |
TestSeed_canInsertAndQuery |
end-to-end insert across all 6 tables, FK cascade behaviour, audit_log SET-NULL on tenant delete |
TestSlugConstraint |
tenant slug regex enforced (rejects too-short / leading dash / uppercase / underscore) |
Run them with make test. Use make test-short in environments without Docker.
Deployment
| Env | URL | How |
|---|---|---|
| dev | http://localhost:8090 |
make dev |
| stage | https://tenant-registry.stage.breakpilot.com |
auto on merge to main |
| prod | https://tenant-registry.breakpilot.com |
manual: tag vX.Y.Z + sign-off |
Rollback: orca rollout undo tenant-registry --env={{env}}.
Observability
- Traces, logs, metrics: SigNoz — service name
tenant-registry - Audit events: Tenant Registry
/audit(Retraced-shape schema) - On-call:
oncall@breakpilot.com· runbook atplatform/docs/runbooks/tenant-registry.md
Contributing
See CONTRIBUTING.md. TL;DR: branch from main, open a PR, 1 review + green CI, squash-merge.
License
Proprietary — all rights reserved. Copyright (c) 2026 Sharang Parnerkar and Benjamin Boenisch. See LICENSE.