diff --git a/devops/compose/docker-compose.stella-ops.yml b/devops/compose/docker-compose.stella-ops.yml index 8227e39aa..79da32956 100644 --- a/devops/compose/docker-compose.stella-ops.yml +++ b/devops/compose/docker-compose.stella-ops.yml @@ -26,7 +26,12 @@ x-release-labels: &release-labels com.stellaops.profile: "default" x-postgres-connection: &postgres-connection - "Host=db.stella-ops.local;Port=5432;Database=${POSTGRES_DB:-stellaops_platform};Username=${POSTGRES_USER:-stellaops};Password=${POSTGRES_PASSWORD:-stellaops}" + "Host=db.stella-ops.local;Port=5432;Database=${POSTGRES_DB:-stellaops_platform};Username=${POSTGRES_USER:-stellaops};Password=${POSTGRES_PASSWORD:-stellaops};Maximum Pool Size=50" + +# Dedicated Authority connection — isolated DB + pool to prevent OIDC +# login timeouts when Concelier or other services are under heavy write load. +x-postgres-authority-connection: &postgres-authority-connection + "Host=db.stella-ops.local;Port=5432;Database=stellaops_authority;Username=${POSTGRES_USER:-stellaops};Password=${POSTGRES_PASSWORD:-stellaops};Maximum Pool Size=20;Minimum Pool Size=2" x-kestrel-cert: &kestrel-cert Kestrel__Certificates__Default__Path: "/app/etc/certs/kestrel-dev.pfx" @@ -487,7 +492,7 @@ services: STELLAOPS_AUTHORITY_AUTHORITY__ACCESSTOKENLIFETIME: "00:30:00" STELLAOPS_AUTHORITY_AUTHORITY__SCHEMAVERSION: "1" STELLAOPS_AUTHORITY_AUTHORITY__ISSUER: "${AUTHORITY_ISSUER:-http://authority.stella-ops.local}" - STELLAOPS_AUTHORITY_AUTHORITY__STORAGE__CONNECTIONSTRING: *postgres-connection + STELLAOPS_AUTHORITY_AUTHORITY__STORAGE__CONNECTIONSTRING: *postgres-authority-connection STELLAOPS_AUTHORITY_AUTHORITY__CACHE__REDIS__CONNECTIONSTRING: "cache.stella-ops.local:6379" STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__ACTIVEKEYID: "dev-signing-key-1" STELLAOPS_AUTHORITY_AUTHORITY__SIGNING__KEYPATH: "/app/etc/authority/keys/signing-dev.pem" diff --git a/devops/compose/postgres-init/00-create-authority-db.sql b/devops/compose/postgres-init/00-create-authority-db.sql new file mode 100644 index 000000000..db8b3d64a --- /dev/null +++ b/devops/compose/postgres-init/00-create-authority-db.sql @@ -0,0 +1,15 @@ +-- Create dedicated Authority database for OIDC connection pool isolation. +-- +-- Problem: When Concelier runs advisory sync jobs (heavy writes to +-- stellaops_platform), the shared connection pool starves Authority's +-- OIDC token validation, causing login timeouts. +-- +-- Solution: Authority gets its own database with an independent Npgsql +-- connection pool. Concelier and other services continue using +-- stellaops_platform. Different database = separate pool automatically. +-- +-- This script runs first (00-) to create the database before other +-- init scripts run against stellaops_platform. + +SELECT 'CREATE DATABASE stellaops_authority OWNER stellaops' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'stellaops_authority')\gexec diff --git a/devops/compose/postgres-init/04b-authority-dedicated-schema.sql b/devops/compose/postgres-init/04b-authority-dedicated-schema.sql new file mode 100644 index 000000000..7225469f3 --- /dev/null +++ b/devops/compose/postgres-init/04b-authority-dedicated-schema.sql @@ -0,0 +1,496 @@ +-- Authority Schema: Applied to dedicated stellaops_authority database. +-- +-- This is the same schema as 04-authority-schema.sql but targets +-- the dedicated Authority database for connection pool isolation. +-- 04-authority-schema.sql continues to run against stellaops_platform +-- for backward compatibility. + +\c stellaops_authority + +-- ============================================================================ +-- SECTION 1: Schema Creation +-- ============================================================================ + +CREATE SCHEMA IF NOT EXISTS authority; +CREATE SCHEMA IF NOT EXISTS authority_app; + +-- ============================================================================ +-- SECTION 2: Helper Functions +-- ============================================================================ + +CREATE OR REPLACE FUNCTION authority.update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION authority_app.require_current_tenant() +RETURNS TEXT +LANGUAGE plpgsql STABLE SECURITY DEFINER +AS $$ +DECLARE + v_tenant TEXT; +BEGIN + v_tenant := current_setting('app.tenant_id', true); + IF v_tenant IS NULL OR v_tenant = '' THEN + RAISE EXCEPTION 'app.tenant_id session variable not set' + USING HINT = 'Set via: SELECT set_config(''app.tenant_id'', '''', false)', + ERRCODE = 'P0001'; + END IF; + RETURN v_tenant; +END; +$$; + +REVOKE ALL ON FUNCTION authority_app.require_current_tenant() FROM PUBLIC; + +-- ============================================================================ +-- SECTION 3: Core Tables +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS authority.tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + display_name TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'deleted')), + settings JSONB NOT NULL DEFAULT '{}', + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by TEXT, + updated_by TEXT +); +CREATE INDEX IF NOT EXISTS idx_tenants_status ON authority.tenants(status); +CREATE INDEX IF NOT EXISTS idx_tenants_created_at ON authority.tenants(created_at); + +CREATE TABLE IF NOT EXISTS authority.users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id), + username TEXT NOT NULL, + email TEXT, + display_name TEXT, + password_hash TEXT, + password_salt TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + password_algorithm TEXT DEFAULT 'argon2id', + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'locked', 'deleted')), + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE, + mfa_secret TEXT, + mfa_backup_codes TEXT, + failed_login_attempts INT NOT NULL DEFAULT 0, + locked_until TIMESTAMPTZ, + last_login_at TIMESTAMPTZ, + password_changed_at TIMESTAMPTZ, + last_password_change_at TIMESTAMPTZ, + password_expires_at TIMESTAMPTZ, + settings JSONB NOT NULL DEFAULT '{}', + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by TEXT, + updated_by TEXT, + UNIQUE(tenant_id, username), + UNIQUE(tenant_id, email) +); +CREATE INDEX IF NOT EXISTS idx_users_tenant_id ON authority.users(tenant_id); +CREATE INDEX IF NOT EXISTS idx_users_status ON authority.users(tenant_id, status); +CREATE INDEX IF NOT EXISTS idx_users_email ON authority.users(tenant_id, email); + +CREATE TABLE IF NOT EXISTS authority.roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id), + name TEXT NOT NULL, + display_name TEXT, + description TEXT, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, name) +); +CREATE INDEX IF NOT EXISTS idx_roles_tenant_id ON authority.roles(tenant_id); + +CREATE TABLE IF NOT EXISTS authority.permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id), + name TEXT NOT NULL, + resource TEXT NOT NULL, + action TEXT NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, name) +); +CREATE INDEX IF NOT EXISTS idx_permissions_tenant_id ON authority.permissions(tenant_id); +CREATE INDEX IF NOT EXISTS idx_permissions_resource ON authority.permissions(tenant_id, resource); + +CREATE TABLE IF NOT EXISTS authority.role_permissions ( + role_id UUID NOT NULL REFERENCES authority.roles(id) ON DELETE CASCADE, + permission_id UUID NOT NULL REFERENCES authority.permissions(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (role_id, permission_id) +); + +CREATE TABLE IF NOT EXISTS authority.user_roles ( + user_id UUID NOT NULL REFERENCES authority.users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES authority.roles(id) ON DELETE CASCADE, + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + granted_by TEXT, + expires_at TIMESTAMPTZ, + PRIMARY KEY (user_id, role_id) +); + +CREATE TABLE IF NOT EXISTS authority.api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id), + user_id UUID REFERENCES authority.users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + key_hash TEXT NOT NULL, + key_prefix TEXT NOT NULL, + scopes TEXT[] NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked', 'expired')), + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ, + revoked_by TEXT +); +CREATE INDEX IF NOT EXISTS idx_api_keys_tenant_id ON authority.api_keys(tenant_id); +CREATE INDEX IF NOT EXISTS idx_api_keys_key_prefix ON authority.api_keys(key_prefix); +CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON authority.api_keys(user_id); +CREATE INDEX IF NOT EXISTS idx_api_keys_status ON authority.api_keys(tenant_id, status); + +CREATE TABLE IF NOT EXISTS authority.tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id), + user_id UUID REFERENCES authority.users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + token_type TEXT NOT NULL DEFAULT 'access' CHECK (token_type IN ('access', 'refresh', 'api')), + scopes TEXT[] NOT NULL DEFAULT '{}', + client_id TEXT, + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + revoked_by TEXT, + metadata JSONB NOT NULL DEFAULT '{}' +); +CREATE INDEX IF NOT EXISTS idx_tokens_tenant_id ON authority.tokens(tenant_id); +CREATE INDEX IF NOT EXISTS idx_tokens_user_id ON authority.tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_tokens_expires_at ON authority.tokens(expires_at); +CREATE INDEX IF NOT EXISTS idx_tokens_token_hash ON authority.tokens(token_hash); + +CREATE TABLE IF NOT EXISTS authority.refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id), + user_id UUID NOT NULL REFERENCES authority.users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + access_token_id UUID REFERENCES authority.tokens(id) ON DELETE SET NULL, + client_id TEXT, + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + revoked_by TEXT, + replaced_by UUID, + metadata JSONB NOT NULL DEFAULT '{}' +); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_tenant_id ON authority.refresh_tokens(tenant_id); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON authority.refresh_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON authority.refresh_tokens(expires_at); + +CREATE TABLE IF NOT EXISTS authority.sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id), + user_id UUID NOT NULL REFERENCES authority.users(id) ON DELETE CASCADE, + session_token_hash TEXT NOT NULL UNIQUE, + ip_address TEXT, + user_agent TEXT, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_activity_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + ended_at TIMESTAMPTZ, + end_reason TEXT, + metadata JSONB NOT NULL DEFAULT '{}' +); +CREATE INDEX IF NOT EXISTS idx_sessions_tenant_id ON authority.sessions(tenant_id); +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON authority.sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON authority.sessions(expires_at); + +CREATE TABLE IF NOT EXISTS authority.audit ( + id BIGSERIAL PRIMARY KEY, + tenant_id TEXT NOT NULL, + user_id UUID, + action TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT, + old_value JSONB, + new_value JSONB, + ip_address TEXT, + user_agent TEXT, + correlation_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_audit_tenant_id ON authority.audit(tenant_id); +CREATE INDEX IF NOT EXISTS idx_audit_user_id ON authority.audit(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_action ON authority.audit(action); +CREATE INDEX IF NOT EXISTS idx_audit_resource ON authority.audit(resource_type, resource_id); +CREATE INDEX IF NOT EXISTS idx_audit_created_at ON authority.audit(created_at); +CREATE INDEX IF NOT EXISTS idx_audit_correlation_id ON authority.audit(correlation_id); + +-- ============================================================================ +-- SECTION 4: OIDC Tables +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS authority.bootstrap_invites ( + id TEXT PRIMARY KEY, token TEXT NOT NULL UNIQUE, type TEXT NOT NULL, + provider TEXT, target TEXT, expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + issued_by TEXT, reserved_until TIMESTAMPTZ, reserved_by TEXT, + consumed BOOLEAN NOT NULL DEFAULT FALSE, status TEXT NOT NULL DEFAULT 'pending', + metadata JSONB NOT NULL DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS authority.service_accounts ( + id TEXT PRIMARY KEY, account_id TEXT NOT NULL UNIQUE, tenant TEXT NOT NULL, + display_name TEXT NOT NULL, description TEXT, enabled BOOLEAN NOT NULL DEFAULT TRUE, + allowed_scopes TEXT[] NOT NULL DEFAULT '{}', authorized_clients TEXT[] NOT NULL DEFAULT '{}', + attributes JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_service_accounts_tenant ON authority.service_accounts(tenant); + +CREATE TABLE IF NOT EXISTS authority.clients ( + id TEXT PRIMARY KEY, client_id TEXT NOT NULL UNIQUE, client_secret TEXT, + secret_hash TEXT, display_name TEXT, description TEXT, plugin TEXT, + sender_constraint TEXT, enabled BOOLEAN NOT NULL DEFAULT TRUE, + redirect_uris TEXT[] NOT NULL DEFAULT '{}', post_logout_redirect_uris TEXT[] NOT NULL DEFAULT '{}', + allowed_scopes TEXT[] NOT NULL DEFAULT '{}', allowed_grant_types TEXT[] NOT NULL DEFAULT '{}', + require_client_secret BOOLEAN NOT NULL DEFAULT TRUE, require_pkce BOOLEAN NOT NULL DEFAULT FALSE, + allow_plain_text_pkce BOOLEAN NOT NULL DEFAULT FALSE, client_type TEXT, + properties JSONB NOT NULL DEFAULT '{}', certificate_bindings JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS authority.revocations ( + id TEXT PRIMARY KEY, category TEXT NOT NULL, revocation_id TEXT NOT NULL, + subject_id TEXT, client_id TEXT, token_id TEXT, reason TEXT NOT NULL, + reason_description TEXT, revoked_at TIMESTAMPTZ NOT NULL, + effective_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}' +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_revocations_category_revocation_id ON authority.revocations(category, revocation_id); + +CREATE TABLE IF NOT EXISTS authority.login_attempts ( + id TEXT PRIMARY KEY, subject_id TEXT, client_id TEXT, event_type TEXT NOT NULL, + outcome TEXT NOT NULL, reason TEXT, ip_address TEXT, user_agent TEXT, + occurred_at TIMESTAMPTZ NOT NULL, properties JSONB NOT NULL DEFAULT '[]' +); +CREATE INDEX IF NOT EXISTS idx_login_attempts_subject ON authority.login_attempts(subject_id, occurred_at DESC); + +CREATE TABLE IF NOT EXISTS authority.oidc_tokens ( + id TEXT PRIMARY KEY, token_id TEXT NOT NULL UNIQUE, subject_id TEXT, + client_id TEXT, token_type TEXT NOT NULL, reference_id TEXT, + created_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ, + redeemed_at TIMESTAMPTZ, payload TEXT, properties JSONB NOT NULL DEFAULT '{}' +); +CREATE INDEX IF NOT EXISTS idx_oidc_tokens_subject ON authority.oidc_tokens(subject_id); +CREATE INDEX IF NOT EXISTS idx_oidc_tokens_client ON authority.oidc_tokens(client_id); +CREATE INDEX IF NOT EXISTS idx_oidc_tokens_reference ON authority.oidc_tokens(reference_id); + +CREATE TABLE IF NOT EXISTS authority.oidc_refresh_tokens ( + id TEXT PRIMARY KEY, token_id TEXT NOT NULL UNIQUE, subject_id TEXT, + client_id TEXT, handle TEXT, created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ, consumed_at TIMESTAMPTZ, payload TEXT +); +CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_subject ON authority.oidc_refresh_tokens(subject_id); +CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_handle ON authority.oidc_refresh_tokens(handle); + +CREATE TABLE IF NOT EXISTS authority.airgap_audit ( + id TEXT PRIMARY KEY, event_type TEXT NOT NULL, operator_id TEXT, + component_id TEXT, outcome TEXT NOT NULL, reason TEXT, + occurred_at TIMESTAMPTZ NOT NULL, properties JSONB NOT NULL DEFAULT '[]' +); +CREATE INDEX IF NOT EXISTS idx_airgap_audit_occurred_at ON authority.airgap_audit(occurred_at DESC); + +CREATE TABLE IF NOT EXISTS authority.revocation_export_state ( + id INT PRIMARY KEY DEFAULT 1, sequence BIGINT NOT NULL DEFAULT 0, + bundle_id TEXT, issued_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS authority.offline_kit_audit ( + event_id UUID PRIMARY KEY, tenant_id TEXT NOT NULL, event_type TEXT NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, actor TEXT NOT NULL, + details JSONB NOT NULL, result TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_offline_kit_audit_ts ON authority.offline_kit_audit(timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_offline_kit_audit_type ON authority.offline_kit_audit(event_type); +CREATE INDEX IF NOT EXISTS idx_offline_kit_audit_tenant_ts ON authority.offline_kit_audit(tenant_id, timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_offline_kit_audit_result ON authority.offline_kit_audit(tenant_id, result, timestamp DESC); + +CREATE TABLE IF NOT EXISTS authority.verdict_manifests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + manifest_id TEXT NOT NULL, tenant TEXT NOT NULL, + asset_digest TEXT NOT NULL, vulnerability_id TEXT NOT NULL, + inputs_json JSONB NOT NULL, + status TEXT NOT NULL CHECK (status IN ('affected', 'not_affected', 'fixed', 'under_investigation')), + confidence DOUBLE PRECISION NOT NULL CHECK (confidence >= 0 AND confidence <= 1), + result_json JSONB NOT NULL, policy_hash TEXT NOT NULL, + lattice_version TEXT NOT NULL, evaluated_at TIMESTAMPTZ NOT NULL, + manifest_digest TEXT NOT NULL, signature_base64 TEXT, rekor_log_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_verdict_manifest_id UNIQUE (tenant, manifest_id) +); +CREATE INDEX IF NOT EXISTS idx_verdict_asset_vuln ON authority.verdict_manifests(tenant, asset_digest, vulnerability_id); +CREATE INDEX IF NOT EXISTS idx_verdict_policy ON authority.verdict_manifests(tenant, policy_hash, lattice_version); +CREATE INDEX IF NOT EXISTS idx_verdict_time ON authority.verdict_manifests USING BRIN (evaluated_at); +CREATE UNIQUE INDEX IF NOT EXISTS idx_verdict_replay ON authority.verdict_manifests(tenant, asset_digest, vulnerability_id, policy_hash, lattice_version); +CREATE INDEX IF NOT EXISTS idx_verdict_digest ON authority.verdict_manifests(manifest_digest); + +-- ============================================================================ +-- SECTION 5: Seed Data +-- ============================================================================ + +INSERT INTO authority.tenants (tenant_id, name, display_name, status) +VALUES ('demo-prod', 'Production', 'Demo Production', 'active') +ON CONFLICT (tenant_id) DO NOTHING; + +-- ============================================================================ +-- SECTION 6: Triggers +-- ============================================================================ + +CREATE TRIGGER trg_tenants_updated_at BEFORE UPDATE ON authority.tenants FOR EACH ROW EXECUTE FUNCTION authority.update_updated_at(); +CREATE TRIGGER trg_users_updated_at BEFORE UPDATE ON authority.users FOR EACH ROW EXECUTE FUNCTION authority.update_updated_at(); +CREATE TRIGGER trg_roles_updated_at BEFORE UPDATE ON authority.roles FOR EACH ROW EXECUTE FUNCTION authority.update_updated_at(); + +-- ============================================================================ +-- SECTION 7: Row-Level Security +-- ============================================================================ + +ALTER TABLE authority.users ENABLE ROW LEVEL SECURITY; +ALTER TABLE authority.users FORCE ROW LEVEL SECURITY; +CREATE POLICY users_tenant_isolation ON authority.users FOR ALL + USING (tenant_id = authority_app.require_current_tenant()) + WITH CHECK (tenant_id = authority_app.require_current_tenant()); + +ALTER TABLE authority.roles ENABLE ROW LEVEL SECURITY; +ALTER TABLE authority.roles FORCE ROW LEVEL SECURITY; +CREATE POLICY roles_tenant_isolation ON authority.roles FOR ALL + USING (tenant_id = authority_app.require_current_tenant()) + WITH CHECK (tenant_id = authority_app.require_current_tenant()); + +ALTER TABLE authority.permissions ENABLE ROW LEVEL SECURITY; +ALTER TABLE authority.permissions FORCE ROW LEVEL SECURITY; +CREATE POLICY permissions_tenant_isolation ON authority.permissions FOR ALL + USING (tenant_id = authority_app.require_current_tenant()) + WITH CHECK (tenant_id = authority_app.require_current_tenant()); + +ALTER TABLE authority.role_permissions ENABLE ROW LEVEL SECURITY; +ALTER TABLE authority.role_permissions FORCE ROW LEVEL SECURITY; +CREATE POLICY role_permissions_tenant_isolation ON authority.role_permissions FOR ALL + USING (role_id IN (SELECT id FROM authority.roles WHERE tenant_id = authority_app.require_current_tenant())); + +ALTER TABLE authority.user_roles ENABLE ROW LEVEL SECURITY; +ALTER TABLE authority.user_roles FORCE ROW LEVEL SECURITY; +CREATE POLICY user_roles_tenant_isolation ON authority.user_roles FOR ALL + USING (user_id IN (SELECT id FROM authority.users WHERE tenant_id = authority_app.require_current_tenant())); + +ALTER TABLE authority.api_keys ENABLE ROW LEVEL SECURITY; +ALTER TABLE authority.api_keys FORCE ROW LEVEL SECURITY; +CREATE POLICY api_keys_tenant_isolation ON authority.api_keys FOR ALL + USING (tenant_id = authority_app.require_current_tenant()) + WITH CHECK (tenant_id = authority_app.require_current_tenant()); + +ALTER TABLE authority.tokens ENABLE ROW LEVEL SECURITY; +ALTER TABLE authority.tokens FORCE ROW LEVEL SECURITY; +CREATE POLICY tokens_tenant_isolation ON authority.tokens FOR ALL + USING (tenant_id = authority_app.require_current_tenant()) + WITH CHECK (tenant_id = authority_app.require_current_tenant()); + +ALTER TABLE authority.refresh_tokens ENABLE ROW LEVEL SECURITY; +ALTER TABLE authority.refresh_tokens FORCE ROW LEVEL SECURITY; +CREATE POLICY refresh_tokens_tenant_isolation ON authority.refresh_tokens FOR ALL + USING (tenant_id = authority_app.require_current_tenant()) + WITH CHECK (tenant_id = authority_app.require_current_tenant()); + +ALTER TABLE authority.sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE authority.sessions FORCE ROW LEVEL SECURITY; +CREATE POLICY sessions_tenant_isolation ON authority.sessions FOR ALL + USING (tenant_id = authority_app.require_current_tenant()) + WITH CHECK (tenant_id = authority_app.require_current_tenant()); + +ALTER TABLE authority.audit ENABLE ROW LEVEL SECURITY; +ALTER TABLE authority.audit FORCE ROW LEVEL SECURITY; +CREATE POLICY audit_tenant_isolation ON authority.audit FOR ALL + USING (tenant_id = authority_app.require_current_tenant()) + WITH CHECK (tenant_id = authority_app.require_current_tenant()); + +ALTER TABLE authority.offline_kit_audit ENABLE ROW LEVEL SECURITY; +ALTER TABLE authority.offline_kit_audit FORCE ROW LEVEL SECURITY; +CREATE POLICY offline_kit_audit_tenant_isolation ON authority.offline_kit_audit FOR ALL + USING (tenant_id = authority_app.require_current_tenant()) + WITH CHECK (tenant_id = authority_app.require_current_tenant()); + +ALTER TABLE authority.verdict_manifests ENABLE ROW LEVEL SECURITY; +CREATE POLICY verdict_tenant_isolation ON authority.verdict_manifests + USING (tenant = current_setting('app.current_tenant', true)) + WITH CHECK (tenant = current_setting('app.current_tenant', true)); + +-- ============================================================================ +-- SECTION 8: Roles and Permissions +-- ============================================================================ + +DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authority_admin') THEN + CREATE ROLE authority_admin WITH NOLOGIN BYPASSRLS; + END IF; +END $$; + +-- ============================================================================ +-- SECTION 9: Demo Seed Data +-- ============================================================================ + +INSERT INTO authority.roles (id, tenant_id, name, display_name, description, is_system) +VALUES + ('a0000002-0000-0000-0000-000000000001', 'demo-prod', 'admin', 'Administrator', 'Full platform access', true), + ('a0000002-0000-0000-0000-000000000002', 'demo-prod', 'operator', 'Operator', 'Release and deployment operations', true), + ('a0000002-0000-0000-0000-000000000003', 'demo-prod', 'viewer', 'Viewer', 'Read-only access', true) +ON CONFLICT (tenant_id, name) DO NOTHING; + +INSERT INTO authority.clients (id, client_id, display_name, description, enabled, redirect_uris, post_logout_redirect_uris, allowed_scopes, allowed_grant_types, require_client_secret, require_pkce, properties) +VALUES + ('demo-client-ui', 'stella-ops-ui', 'Stella Ops Console', 'Web UI application', true, + ARRAY['https://stella-ops.local/auth/callback', 'https://stella-ops.local/auth/silent-refresh', 'https://127.1.0.1/auth/callback', 'https://127.1.0.1/auth/silent-refresh'], + ARRAY['https://stella-ops.local/', 'https://127.1.0.1/'], + ARRAY['openid', 'profile', 'email', 'offline_access', + 'ui.read', 'ui.admin', 'ui.preferences.read', 'ui.preferences.write', + 'authority:tenants.read', 'authority:tenants.write', + 'authority:users.read', 'authority:users.write', + 'authority:roles.read', 'authority:roles.write', + 'authority:clients.read', 'authority:clients.write', + 'authority:tokens.read', 'authority:tokens.revoke', + 'authority:branding.read', 'authority:branding.write', + 'authority.audit.read', + 'graph:read', 'sbom:read', 'scanner:read', + 'policy:read', 'policy:simulate', 'policy:author', 'policy:review', 'policy:approve', + 'policy:run', 'policy:activate', 'policy:audit', 'policy:edit', 'policy:operate', 'policy:publish', + 'airgap:seal', 'airgap:status:read', + 'orch:read', 'analytics.read', 'advisory:read', 'advisory-ai:view', 'advisory-ai:operate', + 'vex:read', 'vexhub:read', + 'exceptions:read', 'exceptions:approve', 'aoc:verify', 'findings:read', + 'release:read', 'release:write', 'release:publish', 'scheduler:read', 'scheduler:operate', + 'notify.viewer', 'notify.operator', 'notify.admin', 'notify.escalate', + 'evidence:read', + 'export.viewer', 'export.operator', 'export.admin', + 'vuln:view', 'vuln:investigate', 'vuln:operate', 'vuln:audit', + 'platform.context.read', 'platform.context.write', + 'platform.idp.read', 'platform.idp.admin', + 'doctor:run', 'doctor:admin', 'ops.health', + 'integration:read', 'integration:write', 'integration:operate', 'registry.admin', + 'timeline:read', 'timeline:write', + 'signer:read', 'signer:sign', 'signer:rotate', 'signer:admin', + 'trust:read', 'trust:write', 'trust:admin'], + ARRAY['authorization_code', 'refresh_token'], + false, true, '{"tenant": "demo-prod"}'::jsonb) +ON CONFLICT (client_id) DO NOTHING;