-- 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();