refactor: DB schema fixes + container renames + compose include + audit sprint
- FindingsLedger: change schema from public to findings (V3-01) - Add 9 migration module plugins: RiskEngine, Replay, ExportCenter, Integrations, Signer, IssuerDirectory, Workflow, PacksRegistry, OpsMemory (V4-01 to V4-09) - Remove 16 redundant inline CREATE SCHEMA patterns (V4-10) - Rename export→export-web, excititor→excititor-web for consistency - Compose stella-ops.yml: thin wrapper using include: directive - Fix dead /api/v1/jobengine/* gateway routes → release-orchestrator/packsregistry - Scheduler plugin architecture: ISchedulerJobPlugin + ScanJobPlugin + DoctorJobPlugin - Create unified audit sink sprint plan - VulnExplorer integration tests + gap analysis Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ public partial class FindingsLedgerDbContext : DbContext
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "public"
|
||||
? "findings"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
|
||||
internal static class FindingsLedgerDbContextFactory
|
||||
{
|
||||
public const string DefaultSchemaName = "public";
|
||||
public const string DefaultSchemaName = "findings";
|
||||
|
||||
public static FindingsLedgerDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
-- 001_initial.sql
|
||||
-- Findings Ledger bootstrap schema (LEDGER-29-001)
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS findings;
|
||||
SET search_path TO findings, public;
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TYPE ledger_event_type AS ENUM (
|
||||
CREATE TYPE findings.ledger_event_type AS ENUM (
|
||||
'finding.created',
|
||||
'finding.status_changed',
|
||||
'finding.severity_changed',
|
||||
@@ -16,7 +19,7 @@ CREATE TYPE ledger_event_type AS ENUM (
|
||||
'finding.closed'
|
||||
);
|
||||
|
||||
CREATE TYPE ledger_action_type AS ENUM (
|
||||
CREATE TYPE findings.ledger_action_type AS ENUM (
|
||||
'assign',
|
||||
'comment',
|
||||
'attach_evidence',
|
||||
@@ -28,12 +31,12 @@ CREATE TYPE ledger_action_type AS ENUM (
|
||||
'close'
|
||||
);
|
||||
|
||||
CREATE TABLE ledger_events (
|
||||
CREATE TABLE findings.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,
|
||||
event_type findings.ledger_event_type NOT NULL,
|
||||
policy_version TEXT NOT NULL,
|
||||
finding_id TEXT NOT NULL,
|
||||
artifact_id TEXT NOT NULL,
|
||||
@@ -55,13 +58,13 @@ CREATE TABLE ledger_events (
|
||||
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 TABLE findings.ledger_events_default PARTITION OF findings.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 INDEX ix_ledger_events_finding ON findings.ledger_events (tenant_id, finding_id, policy_version);
|
||||
CREATE INDEX ix_ledger_events_type ON findings.ledger_events (tenant_id, event_type, recorded_at DESC);
|
||||
CREATE INDEX ix_ledger_events_recorded_at ON findings.ledger_events (tenant_id, recorded_at DESC);
|
||||
|
||||
CREATE TABLE ledger_merkle_roots (
|
||||
CREATE TABLE findings.ledger_merkle_roots (
|
||||
tenant_id TEXT NOT NULL,
|
||||
anchor_id UUID NOT NULL,
|
||||
window_start TIMESTAMPTZ NOT NULL,
|
||||
@@ -77,11 +80,11 @@ CREATE TABLE ledger_merkle_roots (
|
||||
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 TABLE findings.ledger_merkle_roots_default PARTITION OF findings.ledger_merkle_roots DEFAULT;
|
||||
|
||||
CREATE INDEX ix_merkle_sequences ON ledger_merkle_roots (tenant_id, sequence_end DESC);
|
||||
CREATE INDEX ix_merkle_sequences ON findings.ledger_merkle_roots (tenant_id, sequence_end DESC);
|
||||
|
||||
CREATE TABLE findings_projection (
|
||||
CREATE TABLE findings.findings_projection (
|
||||
tenant_id TEXT NOT NULL,
|
||||
finding_id TEXT NOT NULL,
|
||||
policy_version TEXT NOT NULL,
|
||||
@@ -96,12 +99,12 @@ CREATE TABLE findings_projection (
|
||||
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 TABLE findings.findings_projection_default PARTITION OF findings.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 INDEX ix_projection_status ON findings.findings_projection (tenant_id, status, severity DESC);
|
||||
CREATE INDEX ix_projection_labels_gin ON findings.findings_projection USING GIN (labels JSONB_PATH_OPS);
|
||||
|
||||
CREATE TABLE finding_history (
|
||||
CREATE TABLE findings.finding_history (
|
||||
tenant_id TEXT NOT NULL,
|
||||
finding_id TEXT NOT NULL,
|
||||
policy_version TEXT NOT NULL,
|
||||
@@ -114,25 +117,25 @@ CREATE TABLE finding_history (
|
||||
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 TABLE findings.finding_history_default PARTITION OF findings.finding_history DEFAULT;
|
||||
|
||||
CREATE INDEX ix_finding_history_timeline ON finding_history (tenant_id, finding_id, occurred_at DESC);
|
||||
CREATE INDEX ix_finding_history_timeline ON findings.finding_history (tenant_id, finding_id, occurred_at DESC);
|
||||
|
||||
CREATE TABLE triage_actions (
|
||||
CREATE TABLE findings.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,
|
||||
action_type findings.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 TABLE findings.triage_actions_default PARTITION OF findings.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);
|
||||
CREATE INDEX ix_triage_actions_event ON findings.triage_actions (tenant_id, event_id);
|
||||
CREATE INDEX ix_triage_actions_created_at ON findings.triage_actions (tenant_id, created_at DESC);
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
-- LEDGER-OBS-53-001: persist evidence bundle references alongside ledger entries.
|
||||
|
||||
ALTER TABLE ledger_events
|
||||
SET search_path TO findings, public;
|
||||
|
||||
ALTER TABLE findings.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)
|
||||
ON findings.ledger_events (tenant_id, finding_id, recorded_at DESC)
|
||||
WHERE evidence_bundle_ref IS NOT NULL;
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
-- 002_projection_offsets.sql
|
||||
-- Projection worker checkpoint storage (LEDGER-29-003)
|
||||
|
||||
SET search_path TO findings, public;
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ledger_projection_offsets (
|
||||
CREATE TABLE IF NOT EXISTS findings.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)
|
||||
INSERT INTO findings.ledger_projection_offsets (worker_id, last_recorded_at, last_event_id, updated_at)
|
||||
VALUES (
|
||||
'default',
|
||||
'1970-01-01T00:00:00Z',
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
-- 003_policy_rationale.sql
|
||||
-- Add policy rationale column to findings_projection (LEDGER-29-004)
|
||||
|
||||
SET search_path TO findings, public;
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE findings_projection
|
||||
ALTER TABLE findings.findings_projection
|
||||
ADD COLUMN IF NOT EXISTS policy_rationale JSONB NOT NULL DEFAULT '[]'::JSONB;
|
||||
|
||||
ALTER TABLE findings_projection
|
||||
ALTER TABLE findings.findings_projection
|
||||
ALTER COLUMN policy_rationale SET DEFAULT '[]'::JSONB;
|
||||
|
||||
UPDATE findings_projection
|
||||
UPDATE findings.findings_projection
|
||||
SET policy_rationale = '[]'::JSONB
|
||||
WHERE policy_rationale IS NULL;
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
-- 004_ledger_attestations.sql
|
||||
-- LEDGER-OBS-54-001: storage for attestation verification exports
|
||||
|
||||
SET search_path TO findings, public;
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ledger_attestations (
|
||||
CREATE TABLE IF NOT EXISTS findings.ledger_attestations (
|
||||
tenant_id text NOT NULL,
|
||||
attestation_id uuid NOT NULL,
|
||||
artifact_id text NOT NULL,
|
||||
@@ -21,20 +23,20 @@ CREATE TABLE IF NOT EXISTS ledger_attestations (
|
||||
projection_version text NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE ledger_attestations
|
||||
ALTER TABLE findings.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);
|
||||
ON findings.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);
|
||||
ON findings.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)
|
||||
ON findings.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);
|
||||
ON findings.ledger_attestations (tenant_id, verification_status, recorded_at DESC);
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
-- 004_risk_fields.sql
|
||||
-- Add risk scoring fields to findings_projection (LEDGER-RISK-66-001/002)
|
||||
|
||||
SET search_path TO findings, public;
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE findings_projection
|
||||
ALTER TABLE findings.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);
|
||||
CREATE INDEX IF NOT EXISTS ix_projection_risk ON findings.findings_projection (tenant_id, risk_severity, risk_score DESC);
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
-- 005_risk_fields.sql
|
||||
-- LEDGER-RISK-66-001: add risk scoring fields to findings projection
|
||||
|
||||
SET search_path TO findings, public;
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE findings_projection
|
||||
ALTER TABLE findings.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,
|
||||
@@ -11,6 +13,6 @@ ALTER TABLE findings_projection
|
||||
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, recorded_at DESC);
|
||||
ON findings.findings_projection (tenant_id, risk_severity, risk_score DESC, recorded_at DESC);
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
-- 006_orchestrator_airgap.sql
|
||||
-- Add orchestrator export provenance and air-gap import provenance tables (LEDGER-34-101, LEDGER-AIRGAP-56-001)
|
||||
|
||||
SET search_path TO findings, public;
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orchestrator_exports
|
||||
CREATE TABLE IF NOT EXISTS findings.orchestrator_exports
|
||||
(
|
||||
tenant_id TEXT NOT NULL,
|
||||
run_id UUID NOT NULL,
|
||||
@@ -21,12 +23,12 @@ CREATE TABLE IF NOT EXISTS orchestrator_exports
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ix_orchestrator_exports_artifact_run
|
||||
ON orchestrator_exports (tenant_id, artifact_hash, run_id);
|
||||
ON findings.orchestrator_exports (tenant_id, artifact_hash, run_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_orchestrator_exports_artifact
|
||||
ON orchestrator_exports (tenant_id, artifact_hash);
|
||||
ON findings.orchestrator_exports (tenant_id, artifact_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS airgap_imports
|
||||
CREATE TABLE IF NOT EXISTS findings.airgap_imports
|
||||
(
|
||||
tenant_id TEXT NOT NULL,
|
||||
bundle_id TEXT NOT NULL,
|
||||
@@ -43,9 +45,9 @@ CREATE TABLE IF NOT EXISTS airgap_imports
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_airgap_imports_bundle
|
||||
ON airgap_imports (tenant_id, bundle_id);
|
||||
ON findings.airgap_imports (tenant_id, bundle_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_airgap_imports_event
|
||||
ON airgap_imports (tenant_id, ledger_event_id);
|
||||
ON findings.airgap_imports (tenant_id, ledger_event_id);
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
-- Enable Row-Level Security for Findings Ledger tenant isolation (LEDGER-TEN-48-001-DEV)
|
||||
-- Based on Evidence Locker pattern per CONTRACT-FINDINGS-LEDGER-RLS-011
|
||||
|
||||
SET search_path TO findings, public;
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================
|
||||
@@ -34,12 +36,12 @@ COMMENT ON FUNCTION findings_ledger_app.require_current_tenant() IS
|
||||
-- 2. Enable RLS on ledger_events
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE ledger_events ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE ledger_events FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.ledger_events ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.ledger_events FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS ledger_events_tenant_isolation ON ledger_events;
|
||||
DROP POLICY IF EXISTS ledger_events_tenant_isolation ON findings.ledger_events;
|
||||
CREATE POLICY ledger_events_tenant_isolation
|
||||
ON ledger_events
|
||||
ON findings.ledger_events
|
||||
FOR ALL
|
||||
USING (tenant_id = findings_ledger_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
|
||||
@@ -48,12 +50,12 @@ CREATE POLICY ledger_events_tenant_isolation
|
||||
-- 3. Enable RLS on ledger_merkle_roots
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE ledger_merkle_roots ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE ledger_merkle_roots FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.ledger_merkle_roots ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.ledger_merkle_roots FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS ledger_merkle_roots_tenant_isolation ON ledger_merkle_roots;
|
||||
DROP POLICY IF EXISTS ledger_merkle_roots_tenant_isolation ON findings.ledger_merkle_roots;
|
||||
CREATE POLICY ledger_merkle_roots_tenant_isolation
|
||||
ON ledger_merkle_roots
|
||||
ON findings.ledger_merkle_roots
|
||||
FOR ALL
|
||||
USING (tenant_id = findings_ledger_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
|
||||
@@ -62,12 +64,12 @@ CREATE POLICY ledger_merkle_roots_tenant_isolation
|
||||
-- 4. Enable RLS on findings_projection
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE findings_projection ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings_projection FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.findings_projection ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.findings_projection FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS findings_projection_tenant_isolation ON findings_projection;
|
||||
DROP POLICY IF EXISTS findings_projection_tenant_isolation ON findings.findings_projection;
|
||||
CREATE POLICY findings_projection_tenant_isolation
|
||||
ON findings_projection
|
||||
ON findings.findings_projection
|
||||
FOR ALL
|
||||
USING (tenant_id = findings_ledger_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
|
||||
@@ -76,12 +78,12 @@ CREATE POLICY findings_projection_tenant_isolation
|
||||
-- 5. Enable RLS on finding_history
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE finding_history ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE finding_history FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.finding_history ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.finding_history FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS finding_history_tenant_isolation ON finding_history;
|
||||
DROP POLICY IF EXISTS finding_history_tenant_isolation ON findings.finding_history;
|
||||
CREATE POLICY finding_history_tenant_isolation
|
||||
ON finding_history
|
||||
ON findings.finding_history
|
||||
FOR ALL
|
||||
USING (tenant_id = findings_ledger_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
|
||||
@@ -90,12 +92,12 @@ CREATE POLICY finding_history_tenant_isolation
|
||||
-- 6. Enable RLS on triage_actions
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE triage_actions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE triage_actions FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.triage_actions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.triage_actions FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS triage_actions_tenant_isolation ON triage_actions;
|
||||
DROP POLICY IF EXISTS triage_actions_tenant_isolation ON findings.triage_actions;
|
||||
CREATE POLICY triage_actions_tenant_isolation
|
||||
ON triage_actions
|
||||
ON findings.triage_actions
|
||||
FOR ALL
|
||||
USING (tenant_id = findings_ledger_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
|
||||
@@ -104,12 +106,12 @@ CREATE POLICY triage_actions_tenant_isolation
|
||||
-- 7. Enable RLS on ledger_attestations
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE ledger_attestations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE ledger_attestations FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.ledger_attestations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.ledger_attestations FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS ledger_attestations_tenant_isolation ON ledger_attestations;
|
||||
DROP POLICY IF EXISTS ledger_attestations_tenant_isolation ON findings.ledger_attestations;
|
||||
CREATE POLICY ledger_attestations_tenant_isolation
|
||||
ON ledger_attestations
|
||||
ON findings.ledger_attestations
|
||||
FOR ALL
|
||||
USING (tenant_id = findings_ledger_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
|
||||
@@ -118,12 +120,12 @@ CREATE POLICY ledger_attestations_tenant_isolation
|
||||
-- 8. Enable RLS on orchestrator_exports
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE orchestrator_exports ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE orchestrator_exports FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.orchestrator_exports ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.orchestrator_exports FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS orchestrator_exports_tenant_isolation ON orchestrator_exports;
|
||||
DROP POLICY IF EXISTS orchestrator_exports_tenant_isolation ON findings.orchestrator_exports;
|
||||
CREATE POLICY orchestrator_exports_tenant_isolation
|
||||
ON orchestrator_exports
|
||||
ON findings.orchestrator_exports
|
||||
FOR ALL
|
||||
USING (tenant_id = findings_ledger_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
|
||||
@@ -132,12 +134,12 @@ CREATE POLICY orchestrator_exports_tenant_isolation
|
||||
-- 9. Enable RLS on airgap_imports
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE airgap_imports ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE airgap_imports FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.airgap_imports ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.airgap_imports FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS airgap_imports_tenant_isolation ON airgap_imports;
|
||||
DROP POLICY IF EXISTS airgap_imports_tenant_isolation ON findings.airgap_imports;
|
||||
CREATE POLICY airgap_imports_tenant_isolation
|
||||
ON airgap_imports
|
||||
ON findings.airgap_imports
|
||||
FOR ALL
|
||||
USING (tenant_id = findings_ledger_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
-- 007_enable_rls_rollback.sql
|
||||
-- Rollback: Disable Row-Level Security for Findings Ledger (LEDGER-TEN-48-001-DEV)
|
||||
|
||||
SET search_path TO findings, public;
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================
|
||||
-- 1. Disable RLS on all tables
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE ledger_events DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE ledger_merkle_roots DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings_projection DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE finding_history DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE triage_actions DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE ledger_attestations DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE orchestrator_exports DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE airgap_imports DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.ledger_events DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.ledger_merkle_roots DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.findings_projection DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.finding_history DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.triage_actions DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.ledger_attestations DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.orchestrator_exports DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.airgap_imports DISABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- ============================================
|
||||
-- 2. Drop all tenant isolation policies
|
||||
-- ============================================
|
||||
|
||||
DROP POLICY IF EXISTS ledger_events_tenant_isolation ON ledger_events;
|
||||
DROP POLICY IF EXISTS ledger_merkle_roots_tenant_isolation ON ledger_merkle_roots;
|
||||
DROP POLICY IF EXISTS findings_projection_tenant_isolation ON findings_projection;
|
||||
DROP POLICY IF EXISTS finding_history_tenant_isolation ON finding_history;
|
||||
DROP POLICY IF EXISTS triage_actions_tenant_isolation ON triage_actions;
|
||||
DROP POLICY IF EXISTS ledger_attestations_tenant_isolation ON ledger_attestations;
|
||||
DROP POLICY IF EXISTS orchestrator_exports_tenant_isolation ON orchestrator_exports;
|
||||
DROP POLICY IF EXISTS airgap_imports_tenant_isolation ON airgap_imports;
|
||||
DROP POLICY IF EXISTS ledger_events_tenant_isolation ON findings.ledger_events;
|
||||
DROP POLICY IF EXISTS ledger_merkle_roots_tenant_isolation ON findings.ledger_merkle_roots;
|
||||
DROP POLICY IF EXISTS findings_projection_tenant_isolation ON findings.findings_projection;
|
||||
DROP POLICY IF EXISTS finding_history_tenant_isolation ON findings.finding_history;
|
||||
DROP POLICY IF EXISTS triage_actions_tenant_isolation ON findings.triage_actions;
|
||||
DROP POLICY IF EXISTS ledger_attestations_tenant_isolation ON findings.ledger_attestations;
|
||||
DROP POLICY IF EXISTS orchestrator_exports_tenant_isolation ON findings.orchestrator_exports;
|
||||
DROP POLICY IF EXISTS airgap_imports_tenant_isolation ON findings.airgap_imports;
|
||||
|
||||
-- ============================================
|
||||
-- 3. Drop tenant validation function and schema
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
-- 008_attestation_pointers.sql
|
||||
-- LEDGER-ATTEST-73-001: Persist pointers from findings to verification reports and attestation envelopes
|
||||
|
||||
SET search_path TO findings, public;
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================
|
||||
-- 1. Create attestation pointers table
|
||||
-- ============================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ledger_attestation_pointers (
|
||||
CREATE TABLE IF NOT EXISTS findings.ledger_attestation_pointers (
|
||||
tenant_id text NOT NULL,
|
||||
pointer_id uuid NOT NULL,
|
||||
finding_id text NOT NULL,
|
||||
@@ -21,7 +23,7 @@ CREATE TABLE IF NOT EXISTS ledger_attestation_pointers (
|
||||
ledger_event_id uuid NULL
|
||||
);
|
||||
|
||||
ALTER TABLE ledger_attestation_pointers
|
||||
ALTER TABLE findings.ledger_attestation_pointers
|
||||
ADD CONSTRAINT pk_ledger_attestation_pointers PRIMARY KEY (tenant_id, pointer_id);
|
||||
|
||||
-- ============================================
|
||||
@@ -30,41 +32,41 @@ ALTER TABLE ledger_attestation_pointers
|
||||
|
||||
-- Index for finding lookups (most common query pattern)
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_finding
|
||||
ON ledger_attestation_pointers (tenant_id, finding_id, created_at DESC);
|
||||
ON findings.ledger_attestation_pointers (tenant_id, finding_id, created_at DESC);
|
||||
|
||||
-- Index for digest-based lookups (idempotency checks)
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_digest
|
||||
ON ledger_attestation_pointers (tenant_id, (attestation_ref->>'digest'));
|
||||
ON findings.ledger_attestation_pointers (tenant_id, (attestation_ref->>'digest'));
|
||||
|
||||
-- Index for attestation type filtering
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_type
|
||||
ON ledger_attestation_pointers (tenant_id, attestation_type, created_at DESC);
|
||||
ON findings.ledger_attestation_pointers (tenant_id, attestation_type, created_at DESC);
|
||||
|
||||
-- Index for verification status filtering (verified/unverified/failed)
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_verified
|
||||
ON ledger_attestation_pointers (tenant_id, ((verification_result->>'verified')::boolean))
|
||||
ON findings.ledger_attestation_pointers (tenant_id, ((verification_result->>'verified')::boolean))
|
||||
WHERE verification_result IS NOT NULL;
|
||||
|
||||
-- Index for signer identity searches
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_signer
|
||||
ON ledger_attestation_pointers (tenant_id, (attestation_ref->'signer_info'->>'subject'))
|
||||
ON findings.ledger_attestation_pointers (tenant_id, (attestation_ref->'signer_info'->>'subject'))
|
||||
WHERE attestation_ref->'signer_info' IS NOT NULL;
|
||||
|
||||
-- Index for predicate type searches
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_predicate
|
||||
ON ledger_attestation_pointers (tenant_id, (attestation_ref->>'predicate_type'))
|
||||
ON findings.ledger_attestation_pointers (tenant_id, (attestation_ref->>'predicate_type'))
|
||||
WHERE attestation_ref->>'predicate_type' IS NOT NULL;
|
||||
|
||||
-- ============================================
|
||||
-- 3. Enable Row-Level Security
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE ledger_attestation_pointers ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE ledger_attestation_pointers FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.ledger_attestation_pointers ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.ledger_attestation_pointers FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS ledger_attestation_pointers_tenant_isolation ON ledger_attestation_pointers;
|
||||
DROP POLICY IF EXISTS ledger_attestation_pointers_tenant_isolation ON findings.ledger_attestation_pointers;
|
||||
CREATE POLICY ledger_attestation_pointers_tenant_isolation
|
||||
ON ledger_attestation_pointers
|
||||
ON findings.ledger_attestation_pointers
|
||||
FOR ALL
|
||||
USING (tenant_id = findings_ledger_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
|
||||
@@ -73,28 +75,28 @@ CREATE POLICY ledger_attestation_pointers_tenant_isolation
|
||||
-- 4. Add comments for documentation
|
||||
-- ============================================
|
||||
|
||||
COMMENT ON TABLE ledger_attestation_pointers IS
|
||||
COMMENT ON TABLE findings.ledger_attestation_pointers IS
|
||||
'Links findings to verification reports and attestation envelopes for explainability (LEDGER-ATTEST-73-001)';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.pointer_id IS
|
||||
COMMENT ON COLUMN findings.ledger_attestation_pointers.pointer_id IS
|
||||
'Unique identifier for this attestation pointer';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.finding_id IS
|
||||
COMMENT ON COLUMN findings.ledger_attestation_pointers.finding_id IS
|
||||
'Finding that this pointer references';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.attestation_type IS
|
||||
COMMENT ON COLUMN findings.ledger_attestation_pointers.attestation_type IS
|
||||
'Type of attestation: verification_report, dsse_envelope, slsa_provenance, vex_attestation, sbom_attestation, scan_attestation, policy_attestation, approval_attestation';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.relationship IS
|
||||
COMMENT ON COLUMN findings.ledger_attestation_pointers.relationship IS
|
||||
'Semantic relationship: verified_by, attested_by, signed_by, approved_by, derived_from';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.attestation_ref IS
|
||||
COMMENT ON COLUMN findings.ledger_attestation_pointers.attestation_ref IS
|
||||
'JSON object containing digest, storage_uri, payload_type, predicate_type, subject_digests, signer_info, rekor_entry';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.verification_result IS
|
||||
COMMENT ON COLUMN findings.ledger_attestation_pointers.verification_result IS
|
||||
'JSON object containing verified (bool), verified_at, verifier, verifier_version, policy_ref, checks, warnings, errors';
|
||||
|
||||
COMMENT ON COLUMN ledger_attestation_pointers.ledger_event_id IS
|
||||
COMMENT ON COLUMN findings.ledger_attestation_pointers.ledger_event_id IS
|
||||
'Reference to the ledger event that recorded this pointer creation';
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
-- Description: Creates ledger_snapshots table for time-travel/snapshot functionality
|
||||
-- Date: 2025-12-07
|
||||
|
||||
SET search_path TO findings, public;
|
||||
|
||||
-- Create ledger_snapshots table
|
||||
CREATE TABLE IF NOT EXISTS ledger_snapshots (
|
||||
CREATE TABLE IF NOT EXISTS findings.ledger_snapshots (
|
||||
tenant_id TEXT NOT NULL,
|
||||
snapshot_id UUID NOT NULL,
|
||||
label TEXT,
|
||||
@@ -30,24 +32,24 @@ CREATE TABLE IF NOT EXISTS ledger_snapshots (
|
||||
|
||||
-- Index for listing snapshots by status
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_status
|
||||
ON ledger_snapshots (tenant_id, status, created_at DESC);
|
||||
ON findings.ledger_snapshots (tenant_id, status, created_at DESC);
|
||||
|
||||
-- Index for finding expired snapshots
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_expires
|
||||
ON ledger_snapshots (expires_at)
|
||||
ON findings.ledger_snapshots (expires_at)
|
||||
WHERE expires_at IS NOT NULL AND status = 'Available';
|
||||
|
||||
-- Index for sequence lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_sequence
|
||||
ON ledger_snapshots (tenant_id, sequence_number);
|
||||
ON findings.ledger_snapshots (tenant_id, sequence_number);
|
||||
|
||||
-- Index for label search
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_label
|
||||
ON ledger_snapshots (tenant_id, label)
|
||||
ON findings.ledger_snapshots (tenant_id, label)
|
||||
WHERE label IS NOT NULL;
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE ledger_snapshots ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE findings.ledger_snapshots ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS policy for tenant isolation
|
||||
DO $$
|
||||
@@ -57,15 +59,15 @@ BEGIN
|
||||
WHERE tablename = 'ledger_snapshots'
|
||||
AND policyname = 'ledger_snapshots_tenant_isolation'
|
||||
) THEN
|
||||
CREATE POLICY ledger_snapshots_tenant_isolation ON ledger_snapshots
|
||||
CREATE POLICY ledger_snapshots_tenant_isolation ON findings.ledger_snapshots
|
||||
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add comment
|
||||
COMMENT ON TABLE ledger_snapshots IS 'Point-in-time snapshots of ledger state for time-travel queries';
|
||||
COMMENT ON COLUMN ledger_snapshots.sequence_number IS 'Ledger sequence number at snapshot time';
|
||||
COMMENT ON COLUMN ledger_snapshots.snapshot_timestamp IS 'Timestamp of ledger state captured';
|
||||
COMMENT ON COLUMN ledger_snapshots.merkle_root IS 'Merkle root hash of all events up to sequence_number';
|
||||
COMMENT ON COLUMN ledger_snapshots.dsse_digest IS 'DSSE envelope digest if signed';
|
||||
COMMENT ON TABLE findings.ledger_snapshots IS 'Point-in-time snapshots of ledger state for time-travel queries';
|
||||
COMMENT ON COLUMN findings.ledger_snapshots.sequence_number IS 'Ledger sequence number at snapshot time';
|
||||
COMMENT ON COLUMN findings.ledger_snapshots.snapshot_timestamp IS 'Timestamp of ledger state captured';
|
||||
COMMENT ON COLUMN findings.ledger_snapshots.merkle_root IS 'Merkle root hash of all events up to sequence_number';
|
||||
COMMENT ON COLUMN findings.ledger_snapshots.dsse_digest IS 'DSSE envelope digest if signed';
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 001_initial_schema.sql
|
||||
-- RiskEngine: schema and risk_score_results table.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS riskengine;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS riskengine.risk_score_results (
|
||||
job_id UUID PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
score DOUBLE PRECISION NOT NULL,
|
||||
success BOOLEAN NOT NULL,
|
||||
error TEXT NULL,
|
||||
signals JSONB NOT NULL,
|
||||
completed_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_risk_score_results_completed_at
|
||||
ON riskengine.risk_score_results (completed_at DESC);
|
||||
@@ -14,6 +14,11 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Embed SQL migrations as resources -->
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ public sealed class PostgresRiskScoreResultStore : IRiskScoreResultStore, IAsync
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly object _initGate = new();
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresRiskScoreResultStore(string connectionString)
|
||||
{
|
||||
@@ -32,7 +30,6 @@ public sealed class PostgresRiskScoreResultStore : IRiskScoreResultStore, IAsync
|
||||
public async Task SaveAsync(RiskScoreResult result, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO riskengine.risk_score_results (
|
||||
@@ -79,8 +76,6 @@ public sealed class PostgresRiskScoreResultStore : IRiskScoreResultStore, IAsync
|
||||
|
||||
public bool TryGet(Guid jobId, out RiskScoreResult result)
|
||||
{
|
||||
EnsureTable();
|
||||
|
||||
const string sql = """
|
||||
SELECT provider, subject, score, success, error, signals, completed_at
|
||||
FROM riskengine.risk_score_results
|
||||
@@ -127,75 +122,4 @@ public sealed class PostgresRiskScoreResultStore : IRiskScoreResultStore, IAsync
|
||||
return _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_initGate)
|
||||
{
|
||||
if (_tableInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const string ddl = """
|
||||
CREATE SCHEMA IF NOT EXISTS riskengine;
|
||||
CREATE TABLE IF NOT EXISTS riskengine.risk_score_results (
|
||||
job_id UUID PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
score DOUBLE PRECISION NOT NULL,
|
||||
success BOOLEAN NOT NULL,
|
||||
error TEXT NULL,
|
||||
signals JSONB NOT NULL,
|
||||
completed_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_risk_score_results_completed_at
|
||||
ON riskengine.risk_score_results (completed_at DESC);
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(ddl, connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (_initGate)
|
||||
{
|
||||
_tableInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureTable()
|
||||
{
|
||||
lock (_initGate)
|
||||
{
|
||||
if (_tableInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const string ddl = """
|
||||
CREATE SCHEMA IF NOT EXISTS riskengine;
|
||||
CREATE TABLE IF NOT EXISTS riskengine.risk_score_results (
|
||||
job_id UUID PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
score DOUBLE PRECISION NOT NULL,
|
||||
success BOOLEAN NOT NULL,
|
||||
error TEXT NULL,
|
||||
signals JSONB NOT NULL,
|
||||
completed_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_risk_score_results_completed_at
|
||||
ON riskengine.risk_score_results (completed_at DESC);
|
||||
""";
|
||||
|
||||
using var connection = _dataSource.OpenConnection();
|
||||
using var command = new NpgsqlCommand(ddl, connection);
|
||||
command.ExecuteNonQuery();
|
||||
|
||||
lock (_initGate)
|
||||
{
|
||||
_tableInitialized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Integration tests for VulnExplorer endpoints merged into Findings Ledger WebService.
|
||||
// Sprint: SPRINT_20260408_002 Task: VXLM-005
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests validating the VulnExplorer endpoints that were merged into
|
||||
/// the Findings Ledger WebService. Tests cover:
|
||||
/// - VEX decision CRUD (create, get, list, update)
|
||||
/// - VEX decision with attestation (signed override + rekor reference)
|
||||
/// - Fix verification workflow (create + state transition)
|
||||
/// - Audit bundle creation from persisted decisions
|
||||
/// - Evidence subgraph retrieval
|
||||
/// - Vulnerability list/detail queries via Ledger projections
|
||||
/// - Input validation (bad request handling)
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public sealed class VulnExplorerEndpointsIntegrationTests : IClassFixture<FindingsLedgerWebApplicationFactory>
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly FindingsLedgerWebApplicationFactory _factory;
|
||||
|
||||
public VulnExplorerEndpointsIntegrationTests(FindingsLedgerWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// VEX Decision endpoints
|
||||
// ====================================================================
|
||||
|
||||
[Fact(DisplayName = "POST /v1/vex-decisions creates decision and GET returns it")]
|
||||
public async Task CreateAndGetVexDecision_WorksEndToEnd()
|
||||
{
|
||||
using var client = CreateAuthenticatedClient();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var createPayload = BuildVexDecisionPayload("CVE-2025-LEDGER-001", "notAffected", withAttestation: false);
|
||||
var createResponse = await client.PostAsJsonAsync("/v1/vex-decisions", createPayload, JsonOptions, ct);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
|
||||
Assert.NotNull(created);
|
||||
var decisionId = created?["id"]?.GetValue<string>();
|
||||
Assert.False(string.IsNullOrWhiteSpace(decisionId), "Created decision should have a non-empty ID");
|
||||
|
||||
// Verify GET by ID
|
||||
var getResponse = await client.GetAsync($"/v1/vex-decisions/{decisionId}", ct);
|
||||
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
|
||||
|
||||
var fetched = await getResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
|
||||
Assert.Equal("CVE-2025-LEDGER-001", fetched?["vulnerabilityId"]?.GetValue<string>());
|
||||
Assert.Equal("notAffected", fetched?["status"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "POST /v1/vex-decisions with attestation returns signed override")]
|
||||
public async Task CreateWithAttestation_ReturnsSignedOverrideAndRekorReference()
|
||||
{
|
||||
using var client = CreateAuthenticatedClient();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var payload = BuildVexDecisionPayload("CVE-2025-LEDGER-002", "affectedMitigated", withAttestation: true);
|
||||
var response = await client.PostAsJsonAsync("/v1/vex-decisions", payload, JsonOptions, ct);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonObject>(ct);
|
||||
var signedOverride = body?["signedOverride"]?.AsObject();
|
||||
Assert.NotNull(signedOverride);
|
||||
Assert.False(string.IsNullOrWhiteSpace(signedOverride?["envelopeDigest"]?.GetValue<string>()),
|
||||
"Signed override should contain an envelope digest");
|
||||
Assert.NotNull(signedOverride?["rekorLogIndex"]);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GET /v1/vex-decisions lists created decisions")]
|
||||
public async Task ListVexDecisions_ReturnsCreatedDecisions()
|
||||
{
|
||||
using var client = CreateAuthenticatedClient();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Create a decision first
|
||||
var payload = BuildVexDecisionPayload("CVE-2025-LEDGER-LIST", "notAffected", withAttestation: false);
|
||||
var createResponse = await client.PostAsJsonAsync("/v1/vex-decisions", payload, JsonOptions, ct);
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
|
||||
// List decisions
|
||||
var listResponse = await client.GetAsync("/v1/vex-decisions", ct);
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
|
||||
var listBody = await listResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
|
||||
Assert.NotNull(listBody?["items"]);
|
||||
var items = listBody!["items"]!.AsArray();
|
||||
Assert.True(items.Count > 0, "Decision list should contain at least one item");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "PATCH /v1/vex-decisions/{id} updates decision status")]
|
||||
public async Task UpdateVexDecision_ChangesStatus()
|
||||
{
|
||||
using var client = CreateAuthenticatedClient();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Create
|
||||
var payload = BuildVexDecisionPayload("CVE-2025-LEDGER-PATCH", "notAffected", withAttestation: false);
|
||||
var createResponse = await client.PostAsJsonAsync("/v1/vex-decisions", payload, JsonOptions, ct);
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
|
||||
var decisionId = created?["id"]?.GetValue<string>();
|
||||
Assert.False(string.IsNullOrWhiteSpace(decisionId));
|
||||
|
||||
// Update
|
||||
var patchResponse = await client.PatchAsync(
|
||||
$"/v1/vex-decisions/{decisionId}",
|
||||
JsonContent.Create(new { status = "affectedMitigated", justificationText = "Mitigation deployed." }),
|
||||
ct);
|
||||
Assert.Equal(HttpStatusCode.OK, patchResponse.StatusCode);
|
||||
|
||||
// Verify
|
||||
var getResponse = await client.GetAsync($"/v1/vex-decisions/{decisionId}", ct);
|
||||
var fetched = await getResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
|
||||
Assert.Equal("affectedMitigated", fetched?["status"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "POST /v1/vex-decisions with invalid status returns 400")]
|
||||
public async Task CreateVexDecision_InvalidStatus_ReturnsBadRequest()
|
||||
{
|
||||
using var client = CreateAuthenticatedClient();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
const string invalidJson = """
|
||||
{
|
||||
"vulnerabilityId": "CVE-2025-LEDGER-BAD",
|
||||
"subject": {
|
||||
"type": "image",
|
||||
"name": "registry.example/app:9.9.9",
|
||||
"digest": { "sha256": "zzz999" }
|
||||
},
|
||||
"status": "invalidStatusLiteral",
|
||||
"justificationType": "other"
|
||||
}
|
||||
""";
|
||||
|
||||
using var content = new StringContent(invalidJson, Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/v1/vex-decisions", content, ct);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Evidence subgraph endpoint
|
||||
// ====================================================================
|
||||
|
||||
[Fact(DisplayName = "GET /v1/evidence-subgraph/{vulnId} returns subgraph structure")]
|
||||
public async Task EvidenceSubgraph_ReturnsGraphStructure()
|
||||
{
|
||||
using var client = CreateAuthenticatedClient();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Use a non-GUID vulnerability ID to exercise the stub fallback path
|
||||
var response = await client.GetAsync("/v1/evidence-subgraph/CVE-2025-0001", ct);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonObject>(ct);
|
||||
Assert.NotNull(body?["root"]);
|
||||
Assert.NotNull(body?["edges"]);
|
||||
Assert.NotNull(body?["verdict"]);
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Fix verification endpoints
|
||||
// ====================================================================
|
||||
|
||||
[Fact(DisplayName = "POST + PATCH /v1/fix-verifications tracks state transitions")]
|
||||
public async Task FixVerificationWorkflow_TracksStateTransitions()
|
||||
{
|
||||
using var client = CreateAuthenticatedClient();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/v1/fix-verifications",
|
||||
new
|
||||
{
|
||||
cveId = "CVE-2025-LEDGER-FIX-001",
|
||||
componentPurl = "pkg:maven/org.example/app@1.2.3",
|
||||
artifactDigest = "sha256:abc123"
|
||||
},
|
||||
ct);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
|
||||
var patchResponse = await client.PatchAsync(
|
||||
"/v1/fix-verifications/CVE-2025-LEDGER-FIX-001",
|
||||
JsonContent.Create(new { verdict = "verified_by_scanner" }),
|
||||
ct);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, patchResponse.StatusCode);
|
||||
|
||||
var body = await patchResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
|
||||
Assert.Equal("verified_by_scanner", body?["verdict"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Audit bundle endpoint
|
||||
// ====================================================================
|
||||
|
||||
[Fact(DisplayName = "POST /v1/audit-bundles creates bundle from persisted decisions")]
|
||||
public async Task CreateAuditBundle_ReturnsBundleForDecisionSet()
|
||||
{
|
||||
using var client = CreateAuthenticatedClient();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Create a decision first
|
||||
var createPayload = BuildVexDecisionPayload("CVE-2025-LEDGER-BUNDLE", "notAffected", withAttestation: false);
|
||||
var decisionResponse = await client.PostAsJsonAsync("/v1/vex-decisions", createPayload, JsonOptions, ct);
|
||||
Assert.Equal(HttpStatusCode.Created, decisionResponse.StatusCode);
|
||||
|
||||
var decision = await decisionResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
|
||||
var decisionId = decision?["id"]?.GetValue<string>();
|
||||
Assert.False(string.IsNullOrWhiteSpace(decisionId));
|
||||
|
||||
// Create audit bundle
|
||||
var bundleResponse = await client.PostAsJsonAsync(
|
||||
"/v1/audit-bundles",
|
||||
new
|
||||
{
|
||||
tenant = "tenant-qa",
|
||||
decisionIds = new[] { decisionId }
|
||||
},
|
||||
ct);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, bundleResponse.StatusCode);
|
||||
|
||||
var bundle = await bundleResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
|
||||
Assert.NotNull(bundle?["bundleId"]);
|
||||
Assert.NotNull(bundle?["decisions"]);
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Vulnerability list/detail endpoints (Ledger projection queries)
|
||||
// ====================================================================
|
||||
|
||||
[Fact(DisplayName = "GET /v1/vulns returns vulnerability list")]
|
||||
public async Task ListVulns_ReturnsListFromLedgerProjection()
|
||||
{
|
||||
using var client = CreateAuthenticatedClient();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var response = await client.GetAsync("/v1/vulns", ct);
|
||||
|
||||
// May return OK with empty list or items depending on DB state
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonObject>(ct);
|
||||
Assert.NotNull(body?["items"]);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GET /v1/vulns/{id} returns 404 for non-existent finding")]
|
||||
public async Task GetVulnDetail_NonExistent_ReturnsNotFound()
|
||||
{
|
||||
using var client = CreateAuthenticatedClient();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var response = await client.GetAsync("/v1/vulns/non-existent-id", ct);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Full triage workflow (end-to-end sequence)
|
||||
// ====================================================================
|
||||
|
||||
[Fact(DisplayName = "Full triage workflow: VEX decision -> fix verification -> audit bundle")]
|
||||
public async Task FullTriageWorkflow_EndToEnd()
|
||||
{
|
||||
using var client = CreateAuthenticatedClient();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Step 1: Create VEX decision
|
||||
var vexPayload = BuildVexDecisionPayload("CVE-2025-LEDGER-TRIAGE", "affectedMitigated", withAttestation: true);
|
||||
var vexResponse = await client.PostAsJsonAsync("/v1/vex-decisions", vexPayload, JsonOptions, ct);
|
||||
Assert.Equal(HttpStatusCode.Created, vexResponse.StatusCode);
|
||||
|
||||
var vexDecision = await vexResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
|
||||
var vexDecisionId = vexDecision?["id"]?.GetValue<string>();
|
||||
Assert.False(string.IsNullOrWhiteSpace(vexDecisionId));
|
||||
|
||||
// Verify attestation was created
|
||||
Assert.NotNull(vexDecision?["signedOverride"]?.AsObject());
|
||||
|
||||
// Step 2: Create fix verification
|
||||
var fixResponse = await client.PostAsJsonAsync(
|
||||
"/v1/fix-verifications",
|
||||
new
|
||||
{
|
||||
cveId = "CVE-2025-LEDGER-TRIAGE",
|
||||
componentPurl = "pkg:npm/stellaops/core@3.0.0",
|
||||
artifactDigest = "sha256:triage123"
|
||||
},
|
||||
ct);
|
||||
Assert.Equal(HttpStatusCode.Created, fixResponse.StatusCode);
|
||||
|
||||
// Step 3: Update fix verification
|
||||
var fixPatchResponse = await client.PatchAsync(
|
||||
"/v1/fix-verifications/CVE-2025-LEDGER-TRIAGE",
|
||||
JsonContent.Create(new { verdict = "verified_by_scanner" }),
|
||||
ct);
|
||||
Assert.Equal(HttpStatusCode.OK, fixPatchResponse.StatusCode);
|
||||
|
||||
// Step 4: Create audit bundle
|
||||
var bundleResponse = await client.PostAsJsonAsync(
|
||||
"/v1/audit-bundles",
|
||||
new
|
||||
{
|
||||
tenant = "tenant-qa",
|
||||
decisionIds = new[] { vexDecisionId }
|
||||
},
|
||||
ct);
|
||||
Assert.Equal(HttpStatusCode.Created, bundleResponse.StatusCode);
|
||||
|
||||
// Step 5: Retrieve evidence subgraph
|
||||
var subgraphResponse = await client.GetAsync("/v1/evidence-subgraph/CVE-2025-LEDGER-TRIAGE", ct);
|
||||
Assert.Equal(HttpStatusCode.OK, subgraphResponse.StatusCode);
|
||||
|
||||
var subgraph = await subgraphResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
|
||||
Assert.NotNull(subgraph?["root"]);
|
||||
Assert.NotNull(subgraph?["verdict"]);
|
||||
|
||||
// Step 6: Verify all decisions are queryable
|
||||
var listResponse = await client.GetAsync("/v1/vex-decisions?vulnerabilityId=CVE-2025-LEDGER-TRIAGE", ct);
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
|
||||
var listBody = await listResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
|
||||
var items = listBody!["items"]!.AsArray();
|
||||
Assert.True(items.Count >= 1, "Should find at least the decision we created");
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Authorization checks
|
||||
// ====================================================================
|
||||
|
||||
[Fact(DisplayName = "Unauthenticated requests to VulnExplorer endpoints are rejected")]
|
||||
public async Task UnauthenticatedRequest_IsRejected()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// No auth headers at all
|
||||
var response = await client.GetAsync("/v1/vex-decisions", ct);
|
||||
|
||||
// Should be 401 or 403 (depends on auth handler config)
|
||||
Assert.True(
|
||||
response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden,
|
||||
$"Expected 401 or 403 but got {(int)response.StatusCode}");
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Helpers
|
||||
// ====================================================================
|
||||
|
||||
private HttpClient CreateAuthenticatedClient()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes",
|
||||
"vuln:view vuln:investigate vuln:operate vuln:audit findings:read findings:write");
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "11111111-1111-1111-1111-111111111111");
|
||||
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-qa");
|
||||
client.DefaultRequestHeaders.Add("x-stella-user-id", "integration-test-user");
|
||||
client.DefaultRequestHeaders.Add("x-stella-user-name", "Integration Test User");
|
||||
return client;
|
||||
}
|
||||
|
||||
private static object BuildVexDecisionPayload(string vulnerabilityId, string status, bool withAttestation)
|
||||
{
|
||||
if (withAttestation)
|
||||
{
|
||||
return new
|
||||
{
|
||||
vulnerabilityId,
|
||||
subject = new
|
||||
{
|
||||
type = "image",
|
||||
name = "registry.example/app:2.0.0",
|
||||
digest = new Dictionary<string, string> { ["sha256"] = "def456" }
|
||||
},
|
||||
status,
|
||||
justificationType = "runtimeMitigationPresent",
|
||||
justificationText = "Runtime guard active.",
|
||||
attestationOptions = new
|
||||
{
|
||||
createAttestation = true,
|
||||
anchorToRekor = true,
|
||||
signingKeyId = "test-key"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
vulnerabilityId,
|
||||
subject = new
|
||||
{
|
||||
type = "image",
|
||||
name = "registry.example/app:1.2.3",
|
||||
digest = new Dictionary<string, string> { ["sha256"] = "abc123" }
|
||||
},
|
||||
status,
|
||||
justificationType = "codeNotReachable",
|
||||
justificationText = "Guarded by deployment policy."
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user