Replaces the M5.1-skeleton handler set with the M4.2 spec from
IMPLEMENTATION_PLAN.md:
Endpoints (authoritative shape in openapi.yaml):
POST /v1/tenants
GET /v1/tenants/{id}
GET /v1/tenants/by-slug/{slug}
POST /v1/tenants/{id}/activate
POST /v1/tenants/{id}/cancel
GET /v1/entitlements?tenant_id=...
GET /v1/catalog
POST /v1/catalog/request
POST /v1/catalog/trial-request
POST /v1/api-keys returns plaintext ONCE
GET /v1/api-keys?tenant_id=...
DELETE /v1/api-keys/{id}
POST /v1/internal/api-keys/verify always 200; valid: bool
POST /v1/audit
GET /v1/audit?{tenant_id,product,actor_id,action,since,until,limit,cursor}
Architecture:
internal/store/store.go Store interface (CRUD + audit + ping)
internal/store/memory.go in-process impl, used when DATABASE_URL
is empty (seed acme tenant, no migrations)
internal/store/postgres.go pgxpool impl against the M4.1 schema
internal/server/server.go router + healthz/readyz
internal/server/{tenants,catalog,apikeys,audit}.go
per-concern handlers (≤250 LoC each)
internal/server/helpers.go writeJSON/writeError/error mapping/log mw
openapi.yaml 3.1 spec; openapi_test.go is the contract gate
API keys:
Plaintext format 'bp_<22-char base64>'. Prefix bp_<8> stored for UI.
Hash is argon2id(salt, time=1, mem=64MB, threads=4, len=32) encoded as
'argon2id|<salt-b64>|<hash-b64>'. Format-tagged so we can rotate
parameters without re-keying. Verify is constant-time.
Store selection:
cmd/server picks Postgres when DATABASE_URL is set, otherwise Memory.
Both implementations are exercised by the same eachStore test harness —
parity is enforced.
Audit:
Every state-changing endpoint emits via s.emitAudit() (fire-and-forget).
audit_log uses ON DELETE SET NULL on tenant_id so forensic history
outlives tenant deletes (per M4.1 schema).
Routing constraint:
Go 1.22 ServeMux can't disambiguate /v1/tenants/{id}/products from
/v1/tenants/by-slug/{slug=products}. Per-tenant subresources moved to
query-param top-level paths: /v1/entitlements?tenant_id=… and
/v1/api-keys?tenant_id=….
Tests:
Every endpoint exercised against both Memory and Postgres via the
eachStore harness. Includes happy paths, validation errors, conflicts,
404s, auto-audit-emit assertion. testcontainers-go for the postgres
harness; gated by -short.
TestOpenAPISpec is the contract gate: every documented operation must
resolve against the router. (kin-openapi v0.138.0.)
Refs: M4.2
7.1 KiB
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 |
DATABASE_URL |
empty (in-memory store in skeleton) | Postgres DSN, wired up in the M4.1 schema PR |
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).
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.