Files
git.stella-ops.org/devops/compose/postgres-init/02-findings-ledger-tables.sql
2026-02-20 23:32:20 +02:00

566 lines
22 KiB
PL/PgSQL

-- 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';