Files
tenant-registry/migrations/0001_init.up.sql
T
sharang 80181855bc feat(schema): M4.1 — full tenant_registry schema + migrate binary
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 + verified)
            api_keys           argon2 hash + prefix + scopes + revoked_at;
                               single source of truth across all products
            audit_log          Retraced-compatible: actor/action/target/
                               product/metadata; indexed for cross-product
                               filtering (PRODUCT_INTEGRATION_SPEC.md §8.4)

  triggers: updated_at auto-bump on every mutable table.
  fks:      ON DELETE CASCADE for owned rows; SET NULL for audit_log so
            forensic history outlives the tenant delete.

cmd/migrate (new binary):
  golang-migrate as a library with the migrations embedded via
  migrations/embed.go → embed.FS. Sub-commands: up / down / version /
  force. Ships as a self-contained binary; in prod it's the Orca init
  container per IMPLEMENTATION_PLAN.md §1.7.

Dockerfile builds both binaries; the migrate one is invoked separately
as the init step.

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

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

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

Refs: M4.1
2026-05-19 12:01:16 +02:00

198 lines
8.2 KiB
PL/PgSQL

-- M4.1 — initial tenant_registry schema.
-- Source of truth: PLATFORM_ARCHITECTURE.md §5c.
-- Forward-only per IMPLEMENTATION_PLAN.md §1.7.
-- =========================================================================
-- enums
-- =========================================================================
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
);
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][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_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)
);
CREATE INDEX tenant_products_product_idx ON tenant_products (product) WHERE enabled = TRUE;
-- =========================================================================
-- 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,
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 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();