feat(authority): wire auto-migration + idempotent schema (AGENTS.md §2.7)

Brings Authority into §2.7 compliance. Previously AutoMigrate=true was set
in Program.cs but no runner was wired; 001_initial_schema.sql was
non-idempotent so wiring AddStartupMigrations against a pre-bootstrapped
DB crash-looped. Discovered during DEPRECATE-003 when the new drop
migration couldn't apply via Authority's own startup path.

Idempotency fixes in 001_initial_schema.sql:
- CREATE INDEX → CREATE INDEX IF NOT EXISTS (27 indexes)
- CREATE TRIGGER → DROP TRIGGER IF EXISTS + CREATE TRIGGER (3 triggers)
- CREATE POLICY → DROP POLICY IF EXISTS + CREATE POLICY (12 policies)
- CREATE TABLE / FUNCTION (OR REPLACE) / RLS ENABLE / role DO blocks were
  already idempotent — left unchanged

Wiring:
- AddStartupMigrations("authority", "Authority", typeof(AuthorityDataSource)
  .Assembly) called inside RegisterAuthorityServices (canonical
  Signals/Scanner pattern).
- Stale options.AutoMigrate = true + options.MigrationsPath removed from
  Program.cs.
- Migrations\_archived\** excluded from the EmbeddedResource glob.

Init script cleanup (migrations own schema authority now):
- 04-authority-schema.sql: 569 lines → 60 lines (schema shells + guarded
  default-tenant seed fallback only; all DDL removed)
- 04b-authority-dedicated-schema.sql: same reduction for dedicated DB

Verification sequence — all PASS:
1. Green-field replay: 001 runs twice with zero semantic drift (pg_dump
   diff shows only session restrict nonce).
2. Wire against pre-migrated volume: runner applies 001+002 in 209ms, no
   crash-loop.
3. Wire + fresh schema: migrates 20 tables from empty in 395ms.
4. Idempotent restart: "Database is up to date", pure no-op.

Sprint SPRINT_20260422_003_Authority_auto_migration_compliance created
and archived in the same pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-22 16:03:43 +03:00
parent 2e78085115
commit c5cc11c28f
7 changed files with 213 additions and 1083 deletions

View File

@@ -1,625 +1,63 @@
-- Authority Schema: Consolidated Initial Schema
-- Consolidated from migrations 001-005 (pre_1.0 archived)
-- Creates the complete authority schema for IAM, tenants, users, tokens, RLS, and audit
-- Authority Schema: Bootstrap Fallback (NOT the migration authority).
--
-- AGENTS.md §2.7 / SPRINT_20260422_003 (AUTH-MIGRATE-003):
-- The Authority service now owns its schema via embedded SQL migrations in
-- `src/Authority/__Libraries/StellaOps.Authority.Persistence/Migrations/*.sql`
-- applied by `StellaOps.Infrastructure.Postgres.Migrations.AddStartupMigrations`
-- on service startup. This init script is retained only as a first-run fallback
-- for the shared `stellaops_platform` database; it creates the `authority`
-- namespace shells so compose health checks can start, but DDL authority lives
-- with the service migration runner.
--
-- Table/index/trigger/policy creation was removed in AUTH-MIGRATE-003. The
-- Authority service is responsible for applying all schema DDL. This file
-- only:
-- 1. Creates the `authority` and `authority_app` schemas so the service can
-- connect before its first migration run (migration 001 also does this
-- via CREATE SCHEMA IF NOT EXISTS, but having the schema pre-exist keeps
-- legacy bootstrap scripts that reference `authority.*` qualifiers safe).
-- 2. Seeds the `default` tenant row only if the `authority.tenants` table
-- already exists (guarded so the init script is a pure no-op when the
-- migration runner has not yet populated the schema).
--
-- First-party clients (stella-ops-ui, stellaops-cli) and demo seed data come
-- from migration `S001_demo_seed.sql` in the persistence assembly, not here.
-- ============================================================================
-- SECTION 1: Schema Creation
-- SECTION 1: Schema shells (idempotent)
-- ============================================================================
CREATE SCHEMA IF NOT EXISTS authority;
CREATE SCHEMA IF NOT EXISTS authority_app;
-- ============================================================================
-- SECTION 2: Helper Functions
-- ============================================================================
-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION authority.update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Tenant context helper function for RLS
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'', ''<tenant>'', false)',
ERRCODE = 'P0001';
END IF;
RETURN v_tenant;
END;
$$;
REVOKE ALL ON FUNCTION authority_app.require_current_tenant() FROM PUBLIC;
-- ============================================================================
-- SECTION 3: Core Tables
-- ============================================================================
-- Tenants table (NOT RLS-protected - defines tenant boundaries)
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 idx_tenants_status ON authority.tenants(status);
CREATE INDEX idx_tenants_created_at ON authority.tenants(created_at);
COMMENT ON TABLE authority.tenants IS
'Tenant registry. Not RLS-protected - defines tenant boundaries for the system.';
-- Users table
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 idx_users_tenant_id ON authority.users(tenant_id);
CREATE INDEX idx_users_status ON authority.users(tenant_id, status);
CREATE INDEX idx_users_email ON authority.users(tenant_id, email);
-- Roles table
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 idx_roles_tenant_id ON authority.roles(tenant_id);
-- Permissions table
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 idx_permissions_tenant_id ON authority.permissions(tenant_id);
CREATE INDEX idx_permissions_resource ON authority.permissions(tenant_id, resource);
-- Role-Permission assignments
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)
);
-- User-Role assignments
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)
);
-- API Keys table
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 idx_api_keys_tenant_id ON authority.api_keys(tenant_id);
CREATE INDEX idx_api_keys_key_prefix ON authority.api_keys(key_prefix);
CREATE INDEX idx_api_keys_user_id ON authority.api_keys(user_id);
CREATE INDEX idx_api_keys_status ON authority.api_keys(tenant_id, status);
-- Tokens table (access tokens)
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 idx_tokens_tenant_id ON authority.tokens(tenant_id);
CREATE INDEX idx_tokens_user_id ON authority.tokens(user_id);
CREATE INDEX idx_tokens_expires_at ON authority.tokens(expires_at);
CREATE INDEX idx_tokens_token_hash ON authority.tokens(token_hash);
-- Refresh Tokens table
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 idx_refresh_tokens_tenant_id ON authority.refresh_tokens(tenant_id);
CREATE INDEX idx_refresh_tokens_user_id ON authority.refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_expires_at ON authority.refresh_tokens(expires_at);
-- Sessions table
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 idx_sessions_tenant_id ON authority.sessions(tenant_id);
CREATE INDEX idx_sessions_user_id ON authority.sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON authority.sessions(expires_at);
-- Audit log table
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 idx_audit_tenant_id ON authority.audit(tenant_id);
CREATE INDEX idx_audit_user_id ON authority.audit(user_id);
CREATE INDEX idx_audit_action ON authority.audit(action);
CREATE INDEX idx_audit_resource ON authority.audit(resource_type, resource_id);
CREATE INDEX idx_audit_created_at ON authority.audit(created_at);
CREATE INDEX idx_audit_correlation_id ON authority.audit(correlation_id);
-- ============================================================================
-- SECTION 4: OIDC and Mongo Store Equivalent Tables
-- ============================================================================
-- Bootstrap invites
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 '{}'
);
-- Service accounts
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);
-- Clients
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()
);
-- Revocations
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);
-- Login attempts
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);
-- OIDC tokens
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);
-- OIDC refresh tokens
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);
-- Airgap audit
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);
-- Revocation export state (singleton row with optimistic concurrency)
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
);
-- Offline Kit Audit
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);
-- Verdict manifests table
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);
COMMENT ON TABLE authority.verdict_manifests IS 'VEX verdict manifests for deterministic replay verification';
-- ============================================================================
-- SECTION 4b: Bootstrap Default Tenant
-- ============================================================================
INSERT INTO authority.tenants (tenant_id, name, display_name, status)
VALUES ('default', 'Default', 'Default Tenant', 'active')
ON CONFLICT (tenant_id) DO NOTHING;
-- ============================================================================
-- SECTION 5: 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 6: Row-Level Security
-- ============================================================================
-- authority.users
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());
-- authority.roles
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());
-- authority.permissions
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());
-- authority.role_permissions (FK-based, inherits from roles)
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()
)
);
-- authority.user_roles (FK-based, inherits from users)
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()
)
);
-- authority.api_keys
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());
-- authority.tokens
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());
-- authority.refresh_tokens
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());
-- authority.sessions
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());
-- authority.audit
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());
-- authority.offline_kit_audit
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());
-- authority.verdict_manifests
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 7: Roles and Permissions
-- SECTION 2: Guarded default tenant seed
-- ============================================================================
--
-- Only inserts if `authority.tenants` already exists (i.e. the migration
-- runner has already applied 001_initial_schema.sql in a previous boot).
-- On a fresh volume where Authority has not yet started, this block is a
-- no-op and the `default` tenant is seeded by the migration path instead.
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authority_admin') THEN
CREATE ROLE authority_admin WITH NOLOGIN BYPASSRLS;
END IF;
END
$$;
-- Grant permissions (if role exists)
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'stellaops_app') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON authority.verdict_manifests TO stellaops_app;
GRANT USAGE ON SCHEMA authority TO stellaops_app;
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'authority'
AND table_name = 'tenants'
) THEN
INSERT INTO authority.tenants (tenant_id, name, display_name, status)
VALUES ('default', 'Default', 'Default Tenant', 'active')
ON CONFLICT (tenant_id) DO NOTHING;
END IF;
END
$$;
-- ============================================================================
-- SECTION 8: Bootstrap Notes
-- Bootstrap Notes
-- ============================================================================
-- First-party clients are seeded by the Authority standard plugin at runtime.
-- The first administrator is created through the setup wizard, not compose SQL.
--
-- First-party clients are seeded by the Authority standard plugin at runtime
-- and by the S001_demo_seed.sql migration. The first administrator is created
-- through the setup wizard, not compose SQL.

View File

@@ -1,455 +1,32 @@
-- Authority Schema: Applied to dedicated stellaops_authority database.
-- Authority Schema (dedicated DB): Bootstrap Fallback.
--
-- 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.
-- Counterpart to 04-authority-schema.sql. Targets the dedicated
-- `stellaops_authority` database created by 00-create-authority-db.sql for
-- OIDC connection-pool isolation.
--
-- AGENTS.md §2.7 / SPRINT_20260422_003 (AUTH-MIGRATE-003): Schema DDL is
-- owned by the Authority service migration runner (embedded SQL in
-- `StellaOps.Authority.Persistence.dll`). This file only creates the
-- `authority` / `authority_app` schemas so the service can connect, and
-- guards a `default` tenant fallback seed.
\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 $$
-- Guarded default tenant seed (no-op if authority.tenants does not yet exist).
DO $$
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'', ''<tenant>'', false)',
ERRCODE = 'P0001';
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'authority'
AND table_name = 'tenants'
) THEN
INSERT INTO authority.tenants (tenant_id, name, display_name, status)
VALUES ('default', 'Default', 'Default Tenant', 'active')
ON CONFLICT (tenant_id) DO NOTHING;
END IF;
RETURN v_tenant;
END;
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: Bootstrap Default Tenant
-- ============================================================================
INSERT INTO authority.tenants (tenant_id, name, display_name, status)
VALUES ('default', 'Default', 'Default Tenant', '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: Bootstrap Notes
-- ============================================================================
-- First-party clients are seeded by the Authority standard plugin at runtime.
-- The first administrator is created through the setup wizard, not compose SQL.