-- Findings Ledger: Consolidated init from migrations 001-009 -- Auto-generated for docker-compose postgres-init -- Creates all tables required by stellaops-findings-ledger-web -- ============================================================================ -- 001_initial.sql - Bootstrap schema (LEDGER-29-001) -- ============================================================================ BEGIN; CREATE TYPE ledger_event_type AS ENUM ( 'finding.created', 'finding.status_changed', 'finding.severity_changed', 'finding.tag_updated', 'finding.comment_added', 'finding.assignment_changed', 'finding.accepted_risk', 'finding.remediation_plan_added', 'finding.attachment_added', 'finding.closed' ); CREATE TYPE ledger_action_type AS ENUM ( 'assign', 'comment', 'attach_evidence', 'link_ticket', 'remediation_plan', 'status_change', 'accept_risk', 'reopen', 'close' ); CREATE TABLE ledger_events ( tenant_id TEXT NOT NULL, chain_id UUID NOT NULL, sequence_no BIGINT NOT NULL, event_id UUID NOT NULL, event_type ledger_event_type NOT NULL, policy_version TEXT NOT NULL, finding_id TEXT NOT NULL, artifact_id TEXT NOT NULL, source_run_id UUID, actor_id TEXT NOT NULL, actor_type TEXT NOT NULL, occurred_at TIMESTAMPTZ NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), event_body JSONB NOT NULL, event_hash CHAR(64) NOT NULL, previous_hash CHAR(64) NOT NULL, merkle_leaf_hash CHAR(64) NOT NULL, CONSTRAINT pk_ledger_events PRIMARY KEY (tenant_id, chain_id, sequence_no), CONSTRAINT uq_ledger_events_event_id UNIQUE (tenant_id, event_id), CONSTRAINT uq_ledger_events_chain_hash UNIQUE (tenant_id, chain_id, event_hash), CONSTRAINT ck_ledger_events_event_hash_hex CHECK (event_hash ~ '^[0-9a-f]{64}$'), CONSTRAINT ck_ledger_events_previous_hash_hex CHECK (previous_hash ~ '^[0-9a-f]{64}$'), CONSTRAINT ck_ledger_events_leaf_hash_hex CHECK (merkle_leaf_hash ~ '^[0-9a-f]{64}$'), CONSTRAINT ck_ledger_events_actor_type CHECK (actor_type IN ('system', 'operator', 'integration')) ) PARTITION BY LIST (tenant_id); CREATE TABLE ledger_events_default PARTITION OF ledger_events DEFAULT; CREATE INDEX ix_ledger_events_finding ON ledger_events (tenant_id, finding_id, policy_version); CREATE INDEX ix_ledger_events_type ON ledger_events (tenant_id, event_type, recorded_at DESC); CREATE INDEX ix_ledger_events_recorded_at ON ledger_events (tenant_id, recorded_at DESC); CREATE TABLE ledger_merkle_roots ( tenant_id TEXT NOT NULL, anchor_id UUID NOT NULL, window_start TIMESTAMPTZ NOT NULL, window_end TIMESTAMPTZ NOT NULL, sequence_start BIGINT NOT NULL, sequence_end BIGINT NOT NULL, root_hash CHAR(64) NOT NULL, leaf_count INTEGER NOT NULL, anchored_at TIMESTAMPTZ NOT NULL, anchor_reference TEXT, CONSTRAINT pk_ledger_merkle_roots PRIMARY KEY (tenant_id, anchor_id), CONSTRAINT uq_ledger_merkle_root_hash UNIQUE (tenant_id, root_hash), CONSTRAINT ck_ledger_merkle_root_hash_hex CHECK (root_hash ~ '^[0-9a-f]{64}$') ) PARTITION BY LIST (tenant_id); CREATE TABLE ledger_merkle_roots_default PARTITION OF ledger_merkle_roots DEFAULT; CREATE INDEX ix_merkle_sequences ON ledger_merkle_roots (tenant_id, sequence_end DESC); CREATE TABLE findings_projection ( tenant_id TEXT NOT NULL, finding_id TEXT NOT NULL, policy_version TEXT NOT NULL, status TEXT NOT NULL, severity NUMERIC(6,3), labels JSONB NOT NULL DEFAULT '{}'::JSONB, current_event_id UUID NOT NULL, explain_ref TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), cycle_hash CHAR(64) NOT NULL, CONSTRAINT pk_findings_projection PRIMARY KEY (tenant_id, finding_id, policy_version), CONSTRAINT ck_findings_projection_cycle_hash_hex CHECK (cycle_hash ~ '^[0-9a-f]{64}$') ) PARTITION BY LIST (tenant_id); CREATE TABLE findings_projection_default PARTITION OF findings_projection DEFAULT; CREATE INDEX ix_projection_status ON findings_projection (tenant_id, status, severity DESC); CREATE INDEX ix_projection_labels_gin ON findings_projection USING GIN (labels JSONB_PATH_OPS); CREATE TABLE finding_history ( tenant_id TEXT NOT NULL, finding_id TEXT NOT NULL, policy_version TEXT NOT NULL, event_id UUID NOT NULL, status TEXT NOT NULL, severity NUMERIC(6,3), actor_id TEXT NOT NULL, comment TEXT, occurred_at TIMESTAMPTZ NOT NULL, CONSTRAINT pk_finding_history PRIMARY KEY (tenant_id, finding_id, event_id) ) PARTITION BY LIST (tenant_id); CREATE TABLE finding_history_default PARTITION OF finding_history DEFAULT; CREATE INDEX ix_finding_history_timeline ON finding_history (tenant_id, finding_id, occurred_at DESC); CREATE TABLE triage_actions ( tenant_id TEXT NOT NULL, action_id UUID NOT NULL, event_id UUID NOT NULL, finding_id TEXT NOT NULL, action_type ledger_action_type NOT NULL, payload JSONB NOT NULL DEFAULT '{}'::JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by TEXT NOT NULL, CONSTRAINT pk_triage_actions PRIMARY KEY (tenant_id, action_id) ) PARTITION BY LIST (tenant_id); CREATE TABLE triage_actions_default PARTITION OF triage_actions DEFAULT; CREATE INDEX ix_triage_actions_event ON triage_actions (tenant_id, event_id); CREATE INDEX ix_triage_actions_created_at ON triage_actions (tenant_id, created_at DESC); COMMIT; -- ============================================================================ -- 002_projection_offsets.sql - Projection worker checkpoints (LEDGER-29-003) -- ============================================================================ BEGIN; CREATE TABLE IF NOT EXISTS ledger_projection_offsets ( worker_id TEXT NOT NULL PRIMARY KEY, last_recorded_at TIMESTAMPTZ NOT NULL, last_event_id UUID NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); INSERT INTO ledger_projection_offsets (worker_id, last_recorded_at, last_event_id, updated_at) VALUES ( 'default', '1970-01-01T00:00:00Z', '00000000-0000-0000-0000-000000000000', NOW()) ON CONFLICT (worker_id) DO NOTHING; COMMIT; -- ============================================================================ -- 002_add_evidence_bundle_ref.sql - Evidence bundle references (LEDGER-OBS-53-001) -- ============================================================================ ALTER TABLE ledger_events ADD COLUMN evidence_bundle_ref text NULL; CREATE INDEX IF NOT EXISTS ix_ledger_events_finding_evidence_ref ON ledger_events (tenant_id, finding_id, recorded_at DESC) WHERE evidence_bundle_ref IS NOT NULL; -- ============================================================================ -- 003_policy_rationale.sql - Policy rationale column (LEDGER-29-004) -- ============================================================================ BEGIN; ALTER TABLE findings_projection ADD COLUMN IF NOT EXISTS policy_rationale JSONB NOT NULL DEFAULT '[]'::JSONB; ALTER TABLE findings_projection ALTER COLUMN policy_rationale SET DEFAULT '[]'::JSONB; UPDATE findings_projection SET policy_rationale = '[]'::JSONB WHERE policy_rationale IS NULL; COMMIT; -- ============================================================================ -- 004_ledger_attestations.sql - Attestation verification exports (LEDGER-OBS-54-001) -- ============================================================================ BEGIN; CREATE TABLE IF NOT EXISTS ledger_attestations ( tenant_id text NOT NULL, attestation_id uuid NOT NULL, artifact_id text NOT NULL, finding_id text NULL, verification_status text NOT NULL, verification_time timestamptz NOT NULL, dsse_digest text NOT NULL, rekor_entry_id text NULL, evidence_bundle_ref text NULL, ledger_event_id uuid NOT NULL, recorded_at timestamptz NOT NULL, merkle_leaf_hash text NOT NULL, root_hash text NOT NULL, cycle_hash text NOT NULL, projection_version text NOT NULL ); ALTER TABLE ledger_attestations ADD CONSTRAINT pk_ledger_attestations PRIMARY KEY (tenant_id, attestation_id); CREATE INDEX IF NOT EXISTS ix_ledger_attestations_recorded ON ledger_attestations (tenant_id, recorded_at, attestation_id); CREATE INDEX IF NOT EXISTS ix_ledger_attestations_artifact ON ledger_attestations (tenant_id, artifact_id, recorded_at DESC); CREATE INDEX IF NOT EXISTS ix_ledger_attestations_finding ON ledger_attestations (tenant_id, finding_id, recorded_at DESC) WHERE finding_id IS NOT NULL; CREATE INDEX IF NOT EXISTS ix_ledger_attestations_status ON ledger_attestations (tenant_id, verification_status, recorded_at DESC); COMMIT; -- ============================================================================ -- 004_risk_fields.sql - Risk scoring fields (LEDGER-RISK-66-001/002) -- ============================================================================ BEGIN; ALTER TABLE findings_projection ADD COLUMN IF NOT EXISTS risk_score NUMERIC(6,3), ADD COLUMN IF NOT EXISTS risk_severity TEXT, ADD COLUMN IF NOT EXISTS risk_profile_version TEXT, ADD COLUMN IF NOT EXISTS risk_explanation_id UUID, ADD COLUMN IF NOT EXISTS risk_event_sequence BIGINT; CREATE INDEX IF NOT EXISTS ix_projection_risk ON findings_projection (tenant_id, risk_severity, risk_score DESC); COMMIT; -- ============================================================================ -- 005_risk_fields.sql - Risk scoring fields idempotent re-add (LEDGER-RISK-66-001) -- ============================================================================ BEGIN; ALTER TABLE findings_projection ADD COLUMN IF NOT EXISTS risk_score numeric(6,2) NULL, ADD COLUMN IF NOT EXISTS risk_severity text NULL, ADD COLUMN IF NOT EXISTS risk_profile_version text NULL, ADD COLUMN IF NOT EXISTS risk_explanation_id text NULL, ADD COLUMN IF NOT EXISTS risk_event_sequence bigint NULL; CREATE INDEX IF NOT EXISTS ix_findings_projection_risk ON findings_projection (tenant_id, risk_severity, risk_score DESC); COMMIT; -- ============================================================================ -- 006_orchestrator_airgap.sql - Export and import provenance (LEDGER-34-101, LEDGER-AIRGAP-56-001) -- ============================================================================ BEGIN; CREATE TABLE IF NOT EXISTS orchestrator_exports ( tenant_id TEXT NOT NULL, run_id UUID NOT NULL, job_type TEXT NOT NULL, artifact_hash TEXT NOT NULL, policy_hash TEXT NOT NULL, started_at TIMESTAMPTZ NOT NULL, completed_at TIMESTAMPTZ, status TEXT NOT NULL, manifest_path TEXT, logs_path TEXT, merkle_root CHAR(64) NOT NULL, created_at TIMESTAMPTZ NOT NULL, PRIMARY KEY (tenant_id, run_id) ); CREATE UNIQUE INDEX IF NOT EXISTS ix_orchestrator_exports_artifact_run ON orchestrator_exports (tenant_id, artifact_hash, run_id); CREATE INDEX IF NOT EXISTS ix_orchestrator_exports_artifact ON orchestrator_exports (tenant_id, artifact_hash); CREATE TABLE IF NOT EXISTS airgap_imports ( tenant_id TEXT NOT NULL, bundle_id TEXT NOT NULL, mirror_generation TEXT, merkle_root TEXT NOT NULL, time_anchor TIMESTAMPTZ NOT NULL, publisher TEXT, hash_algorithm TEXT, contents JSONB, imported_at TIMESTAMPTZ NOT NULL, import_operator TEXT, ledger_event_id UUID, PRIMARY KEY (tenant_id, bundle_id, time_anchor) ); CREATE INDEX IF NOT EXISTS ix_airgap_imports_bundle ON airgap_imports (tenant_id, bundle_id); CREATE INDEX IF NOT EXISTS ix_airgap_imports_event ON airgap_imports (tenant_id, ledger_event_id); COMMIT; -- ============================================================================ -- 007_enable_rls.sql - Row-Level Security (LEDGER-TEN-48-001-DEV) -- ============================================================================ BEGIN; CREATE SCHEMA IF NOT EXISTS findings_ledger_app; CREATE OR REPLACE FUNCTION findings_ledger_app.require_current_tenant() RETURNS TEXT LANGUAGE plpgsql STABLE AS $$ DECLARE tenant_text TEXT; BEGIN tenant_text := current_setting('app.current_tenant', true); IF tenant_text IS NULL OR length(trim(tenant_text)) = 0 THEN RAISE EXCEPTION 'app.current_tenant is not set for the current session' USING ERRCODE = 'P0001'; END IF; RETURN tenant_text; END; $$; COMMENT ON FUNCTION findings_ledger_app.require_current_tenant() IS 'Returns the current tenant ID from session variable, raises exception if not set'; ALTER TABLE ledger_events ENABLE ROW LEVEL SECURITY; ALTER TABLE ledger_events FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS ledger_events_tenant_isolation ON ledger_events; CREATE POLICY ledger_events_tenant_isolation ON ledger_events FOR ALL USING (tenant_id = findings_ledger_app.require_current_tenant()) WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant()); ALTER TABLE ledger_merkle_roots ENABLE ROW LEVEL SECURITY; ALTER TABLE ledger_merkle_roots FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS ledger_merkle_roots_tenant_isolation ON ledger_merkle_roots; CREATE POLICY ledger_merkle_roots_tenant_isolation ON ledger_merkle_roots FOR ALL USING (tenant_id = findings_ledger_app.require_current_tenant()) WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant()); ALTER TABLE findings_projection ENABLE ROW LEVEL SECURITY; ALTER TABLE findings_projection FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS findings_projection_tenant_isolation ON findings_projection; CREATE POLICY findings_projection_tenant_isolation ON findings_projection FOR ALL USING (tenant_id = findings_ledger_app.require_current_tenant()) WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant()); ALTER TABLE finding_history ENABLE ROW LEVEL SECURITY; ALTER TABLE finding_history FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS finding_history_tenant_isolation ON finding_history; CREATE POLICY finding_history_tenant_isolation ON finding_history FOR ALL USING (tenant_id = findings_ledger_app.require_current_tenant()) WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant()); ALTER TABLE triage_actions ENABLE ROW LEVEL SECURITY; ALTER TABLE triage_actions FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS triage_actions_tenant_isolation ON triage_actions; CREATE POLICY triage_actions_tenant_isolation ON triage_actions FOR ALL USING (tenant_id = findings_ledger_app.require_current_tenant()) WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant()); ALTER TABLE ledger_attestations ENABLE ROW LEVEL SECURITY; ALTER TABLE ledger_attestations FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS ledger_attestations_tenant_isolation ON ledger_attestations; CREATE POLICY ledger_attestations_tenant_isolation ON ledger_attestations FOR ALL USING (tenant_id = findings_ledger_app.require_current_tenant()) WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant()); ALTER TABLE orchestrator_exports ENABLE ROW LEVEL SECURITY; ALTER TABLE orchestrator_exports FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS orchestrator_exports_tenant_isolation ON orchestrator_exports; CREATE POLICY orchestrator_exports_tenant_isolation ON orchestrator_exports FOR ALL USING (tenant_id = findings_ledger_app.require_current_tenant()) WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant()); ALTER TABLE airgap_imports ENABLE ROW LEVEL SECURITY; ALTER TABLE airgap_imports FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS airgap_imports_tenant_isolation ON airgap_imports; CREATE POLICY airgap_imports_tenant_isolation ON airgap_imports FOR ALL USING (tenant_id = findings_ledger_app.require_current_tenant()) WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant()); DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'findings_ledger_admin') THEN CREATE ROLE findings_ledger_admin NOLOGIN BYPASSRLS; END IF; END; $$; COMMENT ON ROLE findings_ledger_admin IS 'Admin role that bypasses RLS for migrations and cross-tenant operations'; COMMIT; -- ============================================================================ -- 008_attestation_pointers.sql - Finding-to-attestation pointers (LEDGER-ATTEST-73-001) -- ============================================================================ BEGIN; CREATE TABLE IF NOT EXISTS ledger_attestation_pointers ( tenant_id text NOT NULL, pointer_id uuid NOT NULL, finding_id text NOT NULL, attestation_type text NOT NULL, relationship text NOT NULL, attestation_ref jsonb NOT NULL, verification_result jsonb NULL, created_at timestamptz NOT NULL, created_by text NOT NULL, metadata jsonb NULL, ledger_event_id uuid NULL ); ALTER TABLE ledger_attestation_pointers ADD CONSTRAINT pk_ledger_attestation_pointers PRIMARY KEY (tenant_id, pointer_id); CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_finding ON ledger_attestation_pointers (tenant_id, finding_id, created_at DESC); CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_digest ON ledger_attestation_pointers (tenant_id, (attestation_ref->>'digest')); CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_type ON ledger_attestation_pointers (tenant_id, attestation_type, created_at DESC); CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_verified ON ledger_attestation_pointers (tenant_id, ((verification_result->>'verified')::boolean)) WHERE verification_result IS NOT NULL; CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_signer ON ledger_attestation_pointers (tenant_id, (attestation_ref->'signer_info'->>'subject')) WHERE attestation_ref->'signer_info' IS NOT NULL; CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_predicate ON ledger_attestation_pointers (tenant_id, (attestation_ref->>'predicate_type')) WHERE attestation_ref->>'predicate_type' IS NOT NULL; ALTER TABLE ledger_attestation_pointers ENABLE ROW LEVEL SECURITY; ALTER TABLE ledger_attestation_pointers FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS ledger_attestation_pointers_tenant_isolation ON ledger_attestation_pointers; CREATE POLICY ledger_attestation_pointers_tenant_isolation ON ledger_attestation_pointers FOR ALL USING (tenant_id = findings_ledger_app.require_current_tenant()) WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant()); COMMENT ON TABLE ledger_attestation_pointers IS 'Links findings to verification reports and attestation envelopes for explainability (LEDGER-ATTEST-73-001)'; COMMIT; -- ============================================================================ -- 009_snapshots.sql - Ledger snapshots for time-travel -- ============================================================================ CREATE TABLE IF NOT EXISTS ledger_snapshots ( tenant_id TEXT NOT NULL, snapshot_id UUID NOT NULL, label TEXT, description TEXT, status TEXT NOT NULL DEFAULT 'Creating', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ, expires_at TIMESTAMPTZ, sequence_number BIGINT NOT NULL, snapshot_timestamp TIMESTAMPTZ NOT NULL, findings_count BIGINT NOT NULL DEFAULT 0, vex_statements_count BIGINT NOT NULL DEFAULT 0, advisories_count BIGINT NOT NULL DEFAULT 0, sboms_count BIGINT NOT NULL DEFAULT 0, events_count BIGINT NOT NULL DEFAULT 0, size_bytes BIGINT NOT NULL DEFAULT 0, merkle_root TEXT, dsse_digest TEXT, metadata JSONB, include_entity_types JSONB, sign_requested BOOLEAN NOT NULL DEFAULT FALSE, PRIMARY KEY (tenant_id, snapshot_id) ); CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_status ON ledger_snapshots (tenant_id, status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_expires ON ledger_snapshots (expires_at) WHERE expires_at IS NOT NULL AND status = 'Available'; CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_sequence ON ledger_snapshots (tenant_id, sequence_number); CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_label ON ledger_snapshots (tenant_id, label) WHERE label IS NOT NULL; ALTER TABLE ledger_snapshots ENABLE ROW LEVEL SECURITY; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_policies WHERE tablename = 'ledger_snapshots' AND policyname = 'ledger_snapshots_tenant_isolation' ) THEN CREATE POLICY ledger_snapshots_tenant_isolation ON ledger_snapshots USING (tenant_id = current_setting('app.tenant_id', true)) WITH CHECK (tenant_id = current_setting('app.tenant_id', true)); END IF; END $$; COMMENT ON TABLE ledger_snapshots IS 'Point-in-time snapshots of ledger state for time-travel queries';