Sharang ParnerkarandClaude Fable 5 576d733eae
ci / shared (pull_request) Successful in 14s
ci / test (pull_request) Failing after 16m9s
ci / image (pull_request) Skipped
feat(tenants): provision the product tenant with the registry UUID on create
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
2026-09-01 23:16:33 +02:00

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 acme tenant (id: 00000000-0000-0000-0000-000000000001). Useful for portal dev without spinning Postgres.
  • set → pgx-backed Postgres. Run make migrate-up against the same DSN first.

Both implementations pass the same test harness (internal/server/server_test.goeachStore).

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 at platform/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.

S
Description
Multi-tenant glue: orgs, entitlements, API keys, audit.
Readme
236 KiB
Languages
Go 93%
PLpgSQL 4.5%
Makefile 1.5%
JavaScript 0.6%
Dockerfile 0.4%