feat(schema): M4.1 — full tenant_registry schema + migrate binary
ci / shared (pull_request) Successful in 5s
ci / test (pull_request) Successful in 38s
ci / image (pull_request) Has been skipped

PLATFORM_ARCHITECTURE.md §5c schema, end-to-end:

  enums:    tenant_status (demo/trial/active/frozen/archived),
            tenant_kind (customer/demo), idp_kind (oidc/saml),
            tenant_project_status (active/archived)

  tables:   tenants            id/slug/name/status/kind/plan/erp_id/
                               stripe_id/trial_ends_at/contract_dates/
                               sales_owner
            tenant_projects    sub-tenancy (GCP-Project style); opt-in
                               via product manifest.supports_projects=true
            tenant_products    tenant ↔ product matrix + JSONB config
            tenant_idp_config  enterprise SSO (OIDC/SAML metadata)
            api_keys           argon2 hash + prefix + scopes + revoked_at
            audit_log          Retraced-compatible; indexed for cross-
                               product filtering per §8.4

  triggers: updated_at auto-bump on every mutable table
  fks:      ON DELETE CASCADE for owned rows; SET NULL for audit_log

cmd/migrate (new binary): golang-migrate as a library with migrations
embedded via migrations/embed.go; subcommands up/down/version/force.
Ships as a self-contained Orca init container in prod.

Tests (require Docker; gated by -short):
  TestMigrate_upDownRoundTrip   schema → 6 tables + 4 enums; down→
                                empty; up-after-down clean
  TestSeed_canInsertAndQuery    insert across every table; FK cascade;
                                audit_log SET-NULL keeps the row
  TestSlugConstraint            regex rejects too-short / leading dash /
                                trailing dash / uppercase / underscore

Makefile: migrate-up/down/down-all/version/create NAME=...; test-short
to skip integration when Docker isn't around; build-migrate for just
the migrator.

CI: pin golangci-lint to v2.12.2 (Go 1.25-compatible) + bump
golangci-lint-action to v7 (v6 rejects v2.x).

The handler-layer in-memory store is unchanged; M4.2 swaps it for the
pgx-backed implementation against this schema.

Refs: M4.1
This commit is contained in:
2026-05-19 12:06:03 +02:00
parent e70ed771ca
commit f9e9f0e21b
13 changed files with 973 additions and 50 deletions
+18 -2
View File
@@ -1,5 +1,21 @@
-- M4.1 down — reverse of 0001_init.up.sql.
-- Forward-only in prod (column drops require two releases); the down
-- migration exists for testcontainers round-trips + dev tear-downs.
DROP TRIGGER IF EXISTS tenant_idp_config_touch_updated_at ON tenant_idp_config;
DROP TRIGGER IF EXISTS tenant_products_touch_updated_at ON tenant_products;
DROP TRIGGER IF EXISTS tenant_projects_touch_updated_at ON tenant_projects;
DROP TRIGGER IF EXISTS tenants_touch_updated_at ON tenants;
DROP FUNCTION IF EXISTS touch_updated_at();
DROP TABLE IF EXISTS audit_log;
DROP TABLE IF EXISTS api_keys;
DROP TABLE IF EXISTS tenant_idp_config;
DROP TABLE IF EXISTS tenant_products;
DROP TABLE IF EXISTS tenant_projects;
DROP TABLE IF EXISTS tenants;
DROP TYPE IF EXISTS tenant_kind;
DROP TYPE IF EXISTS tenant_status;
DROP TYPE IF EXISTS tenant_project_status;
DROP TYPE IF EXISTS idp_kind;
DROP TYPE IF EXISTS tenant_kind;
DROP TYPE IF EXISTS tenant_status;
+175 -30
View File
@@ -1,52 +1,197 @@
-- Placeholder for the M4.1 schema (see PLATFORM_ARCHITECTURE.md §5c).
-- The skeleton uses an in-memory store; this file lands the table shape
-- the real M4.1 PR will use, so the schema review can happen alongside
-- the rest of the boot scaffolding.
-- M4.1 — initial tenant_registry schema.
-- Source of truth: PLATFORM_ARCHITECTURE.md §5c.
-- Forward-only per IMPLEMENTATION_PLAN.md §1.7.
-- enums --------------------------------------------------------------------
-- =========================================================================
-- enums
-- =========================================================================
CREATE TYPE tenant_status AS ENUM ('trial', 'active', 'frozen', 'archived', 'demo');
CREATE TYPE tenant_kind AS ENUM ('customer', 'demo', 'stage', 'internal');
CREATE TYPE tenant_status AS ENUM (
'demo', -- shared demo tenant; reset nightly; no billing
'trial', -- real customer in their N-day evaluation window
'active', -- paid; contract or self-serve plan
'frozen', -- read-only after cancel / non-payment (30d grace)
'archived' -- data export window closed; only audit log retained
);
-- tenants ------------------------------------------------------------------
CREATE TYPE tenant_kind AS ENUM (
'customer', -- real paying / trialing customer
'demo' -- shared demo tenant; never billed
);
CREATE TYPE idp_kind AS ENUM (
'oidc',
'saml'
);
CREATE TYPE tenant_project_status AS ENUM (
'active',
'archived'
);
-- =========================================================================
-- tenants — the root entity. tenants.id ↔ Keycloak org_id 1:1.
-- =========================================================================
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9-]{2,40}$'),
name TEXT NOT NULL,
status tenant_status NOT NULL DEFAULT 'trial',
kind tenant_kind NOT NULL DEFAULT 'customer',
plan TEXT NOT NULL DEFAULT 'starter',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
trial_ends_at TIMESTAMPTZ
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$'),
name TEXT NOT NULL,
status tenant_status NOT NULL DEFAULT 'trial',
kind tenant_kind NOT NULL DEFAULT 'customer',
plan TEXT NOT NULL DEFAULT 'starter',
-- External system references (one-to-one per §5c "Links")
erp_customer_id TEXT UNIQUE,
stripe_cust_id TEXT UNIQUE,
-- Lifecycle dates
trial_ends_at TIMESTAMPTZ,
contract_start DATE,
contract_end DATE,
-- CRM ownership (ERPNext sales_owner equivalent)
sales_owner TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX tenants_status_idx ON tenants (status);
CREATE INDEX tenants_kind_idx ON tenants (kind);
CREATE INDEX tenants_trial_ends_idx ON tenants (trial_ends_at) WHERE trial_ends_at IS NOT NULL;
-- tenant ↔ product entitlements -------------------------------------------
-- =========================================================================
-- tenant_projects — OPTIONAL sub-tenancy (GCP-Project-style).
-- Customers without need operate as a single implicit "default" project.
-- Products opt in via manifest.supports_projects=true.
-- =========================================================================
CREATE TABLE tenant_projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
slug TEXT NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$'),
status tenant_project_status NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, slug)
);
CREATE INDEX tenant_projects_tenant_idx ON tenant_projects (tenant_id);
-- =========================================================================
-- tenant_products — entitlement matrix: which tenant has which product.
-- config holds product-specific knobs (litellm_url, max_seats, modules_enabled…).
-- =========================================================================
CREATE TABLE tenant_products (
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
product TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, product)
);
-- audit log (Retraced-shape; PRODUCT_INTEGRATION_SPEC.md §8.4) ------------
CREATE INDEX tenant_products_product_idx ON tenant_products (product) WHERE enabled = TRUE;
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id),
actor_id TEXT,
actor_name TEXT,
action TEXT NOT NULL,
target_id TEXT,
target_type TEXT,
-- =========================================================================
-- tenant_idp_config — external identity provider per tenant (enterprise SSO).
-- metadata holds OIDC discovery URL + client_id, or SAML cert + entity_id.
-- =========================================================================
CREATE TABLE tenant_idp_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
type idp_kind NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
source_ip INET,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, type)
);
CREATE INDEX audit_log_tenant_idx ON audit_log (tenant_id, created_at DESC);
CREATE INDEX tenant_idp_config_tenant_idx ON tenant_idp_config (tenant_id);
-- =========================================================================
-- api_keys — portal-owned. Single source of truth across all products.
-- hash is bcrypt/argon2 of the raw key; the plaintext is shown ONCE on create.
-- =========================================================================
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
product TEXT, -- nullable = applies to all products
name TEXT NOT NULL, -- human-readable label
scopes TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
hash TEXT NOT NULL, -- argon2id encoded hash
prefix TEXT NOT NULL, -- first 8 chars of the raw key, for UI display
created_by TEXT, -- Keycloak user_id
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, name)
);
CREATE INDEX api_keys_tenant_idx ON api_keys (tenant_id) WHERE revoked_at IS NULL;
CREATE INDEX api_keys_prefix_idx ON api_keys (prefix);
-- =========================================================================
-- audit_log — every state-changing action across portal + products.
-- Retraced-compatible shape (PRODUCT_INTEGRATION_SPEC.md §8.4) so we can
-- swap implementations without changing producers.
-- =========================================================================
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
project_id UUID REFERENCES tenant_projects(id) ON DELETE SET NULL,
actor_id TEXT,
actor_name TEXT,
actor_type TEXT, -- user | service | system
action TEXT NOT NULL,
target_id TEXT,
target_type TEXT,
target_name TEXT,
product TEXT, -- which product emitted this (NULL = portal/tenant-registry)
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
source_ip INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX audit_log_tenant_idx ON audit_log (tenant_id, created_at DESC);
CREATE INDEX audit_log_product_idx ON audit_log (product, created_at DESC) WHERE product IS NOT NULL;
CREATE INDEX audit_log_actor_idx ON audit_log (actor_id, created_at DESC) WHERE actor_id IS NOT NULL;
CREATE INDEX audit_log_action_idx ON audit_log (action);
CREATE INDEX audit_log_tenant_action_idx ON audit_log (tenant_id, action, created_at DESC);
-- =========================================================================
-- update timestamp trigger — applied to every table with an updated_at.
-- =========================================================================
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tenants_touch_updated_at
BEFORE UPDATE ON tenants
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER tenant_projects_touch_updated_at
BEFORE UPDATE ON tenant_projects
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER tenant_products_touch_updated_at
BEFORE UPDATE ON tenant_products
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER tenant_idp_config_touch_updated_at
BEFORE UPDATE ON tenant_idp_config
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
+10
View File
@@ -0,0 +1,10 @@
// Package migrations exposes the SQL migration files as an embed.FS so the
// migrate binary doesn't have to ship them as loose files at runtime.
package migrations
import "embed"
// FS holds every *.sql file in this directory at build time.
//
//go:embed *.sql
var FS embed.FS
+291
View File
@@ -0,0 +1,291 @@
package migrations
import (
"context"
"database/sql"
"errors"
"fmt"
"testing"
"time"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/jackc/pgx/v5/stdlib" // pgx stdlib driver for database/sql
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
)
// startPostgres spins a fresh postgres:16-alpine container and returns its
// DSN + a cleanup func. Skips the test if Docker is unreachable.
func startPostgres(t *testing.T) (string, func()) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
pgc, err := tcpostgres.Run(ctx,
"postgres:16-alpine",
tcpostgres.WithDatabase("tenant_registry_test"),
tcpostgres.WithUsername("test"),
tcpostgres.WithPassword("test"),
tcpostgres.BasicWaitStrategies(),
)
if err != nil {
t.Skipf("skipping: docker unreachable (%v)", err)
}
dsn, err := pgc.ConnectionString(ctx, "sslmode=disable")
if err != nil {
_ = pgc.Terminate(context.Background())
t.Fatalf("dsn: %v", err)
}
cleanup := func() {
c, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_ = pgc.Terminate(c)
}
return dsn, cleanup
}
func newMigrator(t *testing.T, dsn string) *migrate.Migrate {
t.Helper()
src, err := iofs.New(FS, ".")
if err != nil {
t.Fatal(err)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
t.Fatal(err)
}
m, err := migrate.NewWithInstance("iofs", src, "postgres", driver)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = m.Close()
_ = db.Close()
})
return m
}
func TestMigrate_upDownRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test under -short")
}
dsn, stop := startPostgres(t)
defer stop()
m := newMigrator(t, dsn)
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
t.Fatalf("up: %v", err)
}
// Schema assertions — every table the spec requires must exist.
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer func() { _ = db.Close() }()
wantTables := []string{
"tenants",
"tenant_projects",
"tenant_products",
"tenant_idp_config",
"api_keys",
"audit_log",
}
for _, table := range wantTables {
var exists bool
err := db.QueryRow(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=$1)",
table,
).Scan(&exists)
if err != nil {
t.Fatalf("query for table %s: %v", table, err)
}
if !exists {
t.Errorf("table %s missing after migrate up", table)
}
}
// Enum assertions.
wantEnums := map[string][]string{
"tenant_status": {"demo", "trial", "active", "frozen", "archived"},
"tenant_kind": {"customer", "demo"},
"idp_kind": {"oidc", "saml"},
"tenant_project_status": {"active", "archived"},
}
for enum, values := range wantEnums {
rows, err := db.Query(
"SELECT e.enumlabel FROM pg_type t JOIN pg_enum e ON t.oid = e.enumtypid WHERE t.typname = $1 ORDER BY e.enumsortorder",
enum,
)
if err != nil {
t.Fatalf("query enum %s: %v", enum, err)
}
var got []string
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
t.Fatal(err)
}
got = append(got, v)
}
_ = rows.Close()
if fmt.Sprint(got) != fmt.Sprint(values) {
t.Errorf("enum %s = %v, want %v", enum, got, values)
}
}
// Round-trip: down all, then up again — must succeed without leftover state.
if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
t.Fatalf("down: %v", err)
}
var afterDown int
err = db.QueryRow(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name = ANY($1)",
wantTables,
).Scan(&afterDown)
if err != nil {
t.Fatal(err)
}
if afterDown != 0 {
t.Errorf("after down: %d tables still present, want 0", afterDown)
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
t.Fatalf("up after down: %v", err)
}
}
// TestSeed_canInsertAndQuery is the lightweight happy-path: insert a tenant,
// give it a project + a product + an api_key + an audit record, query back.
// Catches schema-level mistakes (NOT NULL, FK direction, enum cast) that
// table-existence checks miss.
func TestSeed_canInsertAndQuery(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test under -short")
}
dsn, stop := startPostgres(t)
defer stop()
m := newMigrator(t, dsn)
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
t.Fatalf("up: %v", err)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer func() { _ = db.Close() }()
ctx := context.Background()
var tid string
err = db.QueryRowContext(ctx,
`INSERT INTO tenants (slug, name, plan, status, kind)
VALUES ($1, $2, 'professional', 'active', 'customer')
RETURNING id`,
"acme", "Acme Inc.").Scan(&tid)
if err != nil {
t.Fatalf("insert tenant: %v", err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO tenant_projects (tenant_id, name, slug) VALUES ($1, $2, $3)`,
tid, "Production", "prod"); err != nil {
t.Fatalf("insert project: %v", err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO tenant_products (tenant_id, product, config) VALUES ($1, 'certifai', '{"max_seats":10}'::jsonb)`,
tid); err != nil {
t.Fatalf("insert product: %v", err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO api_keys (tenant_id, name, hash, prefix, scopes)
VALUES ($1, 'ci-bot', 'argon2-hash', 'bp_12345', ARRAY['certifai:read'])`,
tid); err != nil {
t.Fatalf("insert api_key: %v", err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO audit_log (tenant_id, action, actor_id, actor_name, metadata)
VALUES ($1, 'tenant.created', 'sys', 'system', '{"source":"test"}'::jsonb)`,
tid); err != nil {
t.Fatalf("insert audit: %v", err)
}
// Round-trip read.
var slug, status string
err = db.QueryRowContext(ctx, `SELECT slug, status::text FROM tenants WHERE id = $1`, tid).Scan(&slug, &status)
if err != nil {
t.Fatal(err)
}
if slug != "acme" || status != "active" {
t.Errorf("tenant readback: slug=%q status=%q", slug, status)
}
// FK cascade — delete tenant, projects/products/keys/audit_log handling.
if _, err := db.ExecContext(ctx, `DELETE FROM tenants WHERE id = $1`, tid); err != nil {
t.Fatalf("delete tenant: %v", err)
}
var nProjects, nProducts, nKeys int
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tenant_projects WHERE tenant_id = $1`, tid).Scan(&nProjects)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tenant_products WHERE tenant_id = $1`, tid).Scan(&nProducts)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM api_keys WHERE tenant_id = $1`, tid).Scan(&nKeys)
if nProjects != 0 || nProducts != 0 || nKeys != 0 {
t.Errorf("FK cascade incomplete: projects=%d products=%d keys=%d", nProjects, nProducts, nKeys)
}
// audit_log uses ON DELETE SET NULL — tenant_id becomes NULL but row stays
var nAudit, nAuditNullTenant int
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log`).Scan(&nAudit)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log WHERE tenant_id IS NULL`).Scan(&nAuditNullTenant)
if nAudit != 1 || nAuditNullTenant != 1 {
t.Errorf("audit_log SET NULL: total=%d null=%d, want 1/1", nAudit, nAuditNullTenant)
}
}
func TestSlugConstraint(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test under -short")
}
dsn, stop := startPostgres(t)
defer stop()
m := newMigrator(t, dsn)
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
t.Fatalf("up: %v", err)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer func() { _ = db.Close() }()
cases := []struct {
slug string
wantErr bool
}{
{"acme", false},
{"a-c-m-e", false},
{"a1b2c3", false},
{"a", true}, // too short
{"-acme", true}, // leading dash
{"acme-", true}, // trailing dash
{"AcMe", true}, // uppercase
{"a_b", true}, // underscore
}
for _, c := range cases {
_, err := db.Exec(`INSERT INTO tenants (slug, name) VALUES ($1, 'X')`, c.slug)
gotErr := err != nil
if gotErr != c.wantErr {
t.Errorf("slug %q: gotErr=%v wantErr=%v (err=%v)", c.slug, gotErr, c.wantErr, err)
}
}
}