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)
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:
+175
-30
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user