CONTRACT-FINDINGS-LEDGER-RLS-011: Row-Level Security & Partitioning
Status: Implemented Version: 1.3.1 Published: 2025-12-05 Owners: Platform/DB Guild, Findings Ledger Guild Unblocks: LEDGER-TEN-48-001-DEV, DEVOPS-LEDGER-TEN-48-001-REL
Overview
This contract is for platform and database engineers working on the Stella Ops Findings Ledger, plus reviewers auditing its tenant-isolation guarantees. It specifies the Row-Level Security (RLS) and partitioning strategy for the Findings Ledger module, based on the proven Evidence Locker implementation pattern and adapted for the Findings Ledger schema.
Implementation note (re-verified against source 2026-09-30). The RLS design described in this contract is implemented. RLS is enabled and forced, and the tenant-isolation policies and validation function are created, by the consolidated Findings baseline (
src/Findings/__Libraries/StellaOps.Findings.Persistence/Migrations/001_findings_consolidated_baseline.sql, its forced-table loop at:897-929).findings-webandfindings-workerapply it on startup throughAddConsolidatedFindingsPersistence(src/Findings/StellaOps.Findings.WebService/Program.cs:64,src/Findings/StellaOps.Findings.Worker/Program.cs:31). Themigrations/007_enable_rls.sqlandmigrations/017_asset_registry_event_visibility.sqlfiles quoted below belonged to theStellaOps.Findings.Ledgerchain, which no host ran and which was withdrawn on 2026-09-30 (SPRINT_20260928_004 PCD-149). No Findings migration creates the admin bypass role; the install path does (section 3). The earlier “Required Implementation (The Missing 10%)” framing is retained as a historical record of the gap that has since been closed.
Posture ruling (SPRINT_20260930_006 PCD-241, owner ruling 2026-10-02). The
findingsowner staysNOBYPASSRLS(ADR-039) and the FORCE policies are unchanged (fourteen at the ruling; twelve since Findings migration028_findings_retire_write_only_projections.sql). No runtime path reads a FORCE-RLS table without a tenant: tenant connections reject a blank tenant, system connections are for relations without row-level security only, and the ledger projector reads each tenant under its ownapp.current_tenantafter enumerating the RLS-free registryfindings.ledger_tenants(migration018_findings_ledger_tenant_registry.sql). See Connection Patterns and Projector Reads.
Schema note. All Findings Ledger tables live in the PostgreSQL schema
findings(notpublic). Connections setsearch_path TO findings, public(seeLedgerDataSource.ConfigureSessionDefaultsAsyncandFindingsLedgerDbContextFactory.DefaultSchemaName). The schema-qualified DDL in the migration files usesfindings.<table>andfindings_ledger_app.<function>.
Current State (Implemented)
The Findings Ledger has these foundational elements:
1. LIST Partitioning by Tenant
The append-path and projection tables are LIST-partitioned by tenant_id, each with a DEFAULT partition (see migrations/001_initial.sql):
-- Example from ledger_events (schema-qualified, with default partition)
CREATE TABLE findings.ledger_events (
tenant_id TEXT NOT NULL,
...
) PARTITION BY LIST (tenant_id);
CREATE TABLE findings.ledger_events_default PARTITION OF findings.ledger_events DEFAULT;
Tables that are LIST-partitioned (migrations/001_initial.sql):
ledger_eventsledger_merkle_rootsfindings_projection
finding_history and triage_actions were partitioned the same way until Findings migration 028_findings_retire_write_only_projections.sql dropped them: the projector wrote them and nothing read them.
Tenant-scoped tables that are NOT partitioned (plain tables keyed on tenant_id; they rely on RLS + composite primary keys for isolation, not partition pruning):
ledger_attestations(migrations/004_ledger_attestations.sql)orchestrator_exports(migrations/006_orchestrator_airgap.sql)airgap_imports(migrations/006_orchestrator_airgap.sql)asset_registry_events(migrations/017_asset_registry_event_visibility.sql)
All seven tables above are tenant-scoped and RLS-protected (see
FindingsSchemaTopology.ForcedRowLevelSecurityTables). Only the first three use declarative LIST partitioning; the prior version of this contract incorrectly claimed all tables were partitioned.
2. Session Variable Configuration
Connection setup in LedgerDataSource.ConfigureSessionAsync (every tenant-scoped connection also runs SET TIME ZONE 'UTC' and SET search_path TO findings, public first, via ConfigureSessionDefaultsAsync):
await using var tenantCommand = new NpgsqlCommand(
"SELECT set_config('app.current_tenant', @tenant, false);", connection);
tenantCommand.Parameters.AddWithValue("tenant", tenantId);
await tenantCommand.ExecuteNonQueryAsync(cancellationToken);
The third set_config argument is false (session-scoped, not transaction-local).
3. Authenticated HTTP tenant binding
Source review on 2026-10-07 at c0b0490da306d66a818486b09ae9fd440a3bda1b: the consolidated src/Findings/StellaOps.Findings.WebService/Program.cs uses identity-envelope authentication, bearer authentication/authorization and the shared tenant middleware. Ledger application endpoints apply RequireTenant() and consume the resolved tenant accessor. X-StellaOps-TenantId is not a tenancy source. Missing or invalid identity-bound tenancy produces the shared tenant_missing / tenant_invalid_format problem contract, not the old header-presence missing_tenant contract.
Re-verify this composition note with rg -n "UseIdentityEnvelopeAuthentication|UseStellaOpsTenantMiddleware" src/Findings/StellaOps.Findings.WebService/Program.cs, the endpoint tenant checks and src/Findings/StellaOps.Findings.Ledger/Infrastructure/Postgres/LedgerDataSource.cs. This source lookup does not re-run the historical RLS proof.
The bound tenant is passed to the owner datasource and repository filtering below. See API tenancy and the Findings dossier for the current RLS and connection authority. This replaces the predecessor header-extraction example without changing its historical database schema record.
4. Application-Level Query Filtering
Repository queries include WHERE tenant_id = @tenant (defense in depth on top of RLS).
As-Built RLS Implementation (formerly “The Missing 10%”)
The DDL in this section is the as-built content of the withdrawn
migrations/007_enable_rls.sqlandmigrations/017_asset_registry_event_visibility.sql(theasset_registry_eventstable), without thefinding_historyandtriage_actionsblocks: Findings migration028_findings_retire_write_only_projections.sqldropped both tables. All statements are schema-qualified tofindings/findings_ledger_app. The consolidated baseline applies the same block to each forced table in one loop (001_findings_consolidated_baseline.sql:897-929); the current set isFindingsSchemaTopology.ForcedRowLevelSecurityTables, twelve tables since migration 028.
1. Tenant Validation Function
Created following the Evidence Locker pattern (migrations/007_enable_rls.sql):
-- Schema for application-level functions
CREATE SCHEMA IF NOT EXISTS findings_ledger_app;
-- Tenant validation function (TEXT version for Ledger compatibility)
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';
2. RLS Policies for All Tenant-Scoped Tables
Applied to each tenant-scoped table. Each block enables and forces RLS, drops any pre-existing policy (idempotent re-run), and creates the <table>_tenant_isolation policy. Tables are schema-qualified to findings. The first six blocks come from migrations/007_enable_rls.sql, which also covered the two tables migration 028 dropped; the asset_registry_events block comes from migrations/017_asset_registry_event_visibility.sql.
-- ============================================
-- ledger_events
-- ============================================
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 findings.ledger_events;
CREATE POLICY ledger_events_tenant_isolation
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());
-- ============================================
-- ledger_merkle_roots
-- ============================================
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 findings.ledger_merkle_roots;
CREATE POLICY ledger_merkle_roots_tenant_isolation
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());
-- ============================================
-- findings_projection
-- ============================================
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.findings_projection;
CREATE POLICY findings_projection_tenant_isolation
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());
-- ============================================
-- ledger_attestations
-- ============================================
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 findings.ledger_attestations;
CREATE POLICY ledger_attestations_tenant_isolation
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());
-- ============================================
-- orchestrator_exports
-- ============================================
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 findings.orchestrator_exports;
CREATE POLICY orchestrator_exports_tenant_isolation
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());
-- ============================================
-- airgap_imports
-- ============================================
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 findings.airgap_imports;
CREATE POLICY airgap_imports_tenant_isolation
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());
-- ============================================
-- asset_registry_events (migration 017, added after 007)
-- ============================================
ALTER TABLE findings.asset_registry_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE findings.asset_registry_events FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS asset_registry_events_tenant_isolation ON findings.asset_registry_events;
CREATE POLICY asset_registry_events_tenant_isolation
ON findings.asset_registry_events
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
3. System/Admin Bypass Role
findings_ledger_admin is a single NOLOGIN BYPASSRLS role. No Findings migration creates it. The consolidated baseline runs no role DDL (001_findings_consolidated_baseline.sql:155-164, ADR-004 exception PCD-64). On a new cluster the install path creates it as the superuser (devops/compose/postgres-init/20-service-databases.sh:257-261; the release bundle ships the same file under config/postgres-init/). The Findings host refuses to migrate without it (src/Findings/__Libraries/StellaOps.Findings.Persistence/FindingsDatabaseRoles.cs:34-42). On an existing or operator-managed cluster, create it once as the superuser; see INSTALL_GUIDE.md section 4b.
Historical: the withdrawn migrations/007_enable_rls.sql created the role inside a guarded DO block:
-- Create admin role that bypasses RLS (idempotent)
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';
Not granted, and not needed. Nothing grants
findings_ledger_adminto any application/migration login role, and membership would not help:BYPASSRLSis a role attribute and is not inherited (001_findings_consolidated_baseline.sql:882-891). The startup migration runner and the runtime service connect as the database owner configured throughSTELLAOPS_POSTGRES_FINDINGS_CONNECTION. Owning thefindingsschema does not bypass RLS, because every policed table isFORCEd (:917-920); the service therefore bindsapp.current_tenanton every session that reads one (:874-892). The install path provisions the ownerNOBYPASSRLS(20-service-databases.sh:228), and since PCD-241 that is the supported posture:doctor.findings.ledger.rls-posturereports aBYPASSRLSor superuser connection as a Warning naming ADR-039 andALTER ROLE <role> NOBYPASSRLS.
Connection Patterns
These are the actual method signatures on LedgerDataSource (Infrastructure/Postgres/LedgerDataSource.cs).
Regular Connections (Tenant-Scoped)
OpenConnectionAsync(string tenantId, CancellationToken) (with an overload that adds a role label for connection metrics). A blank tenant is rejected with ArgumentException before a connection is taken from the pool; until PCD-241 it silently skipped the tenant binding, which turned the connection into a tenantless one. The session is configured with SET TIME ZONE 'UTC', SET search_path TO findings, public, and then set_config('app.current_tenant', …):
public Task<NpgsqlConnection> OpenConnectionAsync(string tenantId, CancellationToken cancellationToken)
=> OpenConnectionInternalAsync(tenantId, "unspecified", cancellationToken);
public Task<NpgsqlConnection> OpenConnectionAsync(string tenantId, string role, CancellationToken cancellationToken)
=> OpenConnectionInternalAsync(tenantId, role, cancellationToken);
// ConfigureSessionAsync runs (per connection):
// SET TIME ZONE 'UTC';
// SET search_path TO findings, public;
// SELECT set_config('app.current_tenant', @tenant, false); -- always; a blank tenant never gets here
System Connections (No Tenant - Relations Without RLS Only)
OpenSystemConnectionAsync(string purpose, CancellationToken) opens from the same NpgsqlDataSource, sets UTC and the search path, and clears app.current_tenant. There is no separate _adminDataSource and the connection does not log in as findings_ledger_admin. It is for relations without row-level security only. Because the owner is NOBYPASSRLS and the tenant is cleared, a query on any of the twelve forced tables raises P0001 (001_findings_consolidated_baseline.sql:135-150,874-929, which forced fourteen; migration 028 dropped two of them), so such a mistake fails loudly instead of reading one tenant left on a pooled session.
public async Task<NpgsqlConnection> OpenSystemConnectionAsync(string purpose, CancellationToken cancellationToken)
{
// Relations WITHOUT row-level security only. The purpose is required and logged at debug.
var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await ConfigureSessionDefaultsAsync(connection, cancellationToken); // UTC + search_path
// SELECT set_config('app.current_tenant', '', false);
return connection;
}
Every call site is allowlisted, with the relations it touches, by FindingsLedgerTenantBindingConformanceTests (architecture pack); the same test fails on any OpenConnectionAsync(string.Empty, ...) or OpenConnectionAsync("", ...) in Findings production code.
| Call site | Purpose | Relations (none carries RLS) |
|---|---|---|
PostgresLedgerProjectionTenantSource | projection-tenant-registry | findings.ledger_tenants |
PostgresRuntimeTraceRepository.ListRetentionTenantsAsync | runtime-trace-retention-tenants | findings.runtime_traces (tenant ids) |
PostgresAssetRegistryEventProjectionRepository checkpoints | asset-registry-projection-checkpoint | findings.asset_registry_projection_offsets |
PostgresGraphAssetRegistryEventSource | asset-registry-source, asset-registry-source-admission | graph.asset_registry_events (legacy host path only) |
PostgresAssetRegistryFeedStateStore | asset-registry-feed-state | findings.asset_registry_feed_state |
RlsValidationService | rls-validation-catalog | pg_tables, pg_class, pg_policies, pg_proc |
Projector Reads
The ledger projector must read every tenant’s events without a cross-tenant read of findings.ledger_events. Migration 018_findings_ledger_tenant_registry.sql provides:
findings.ledger_tenants (tenant_id PRIMARY KEY, first_seen_at): tenant ids only, no row-level security,source-of-truth(never age-evicted).ledger_events_register_tenant, a row-levelAFTER INSERTtrigger on the partitioned parent callingfindings_ledger_app.register_ledger_tenant(). PostgreSQL clones it to every partition, including partitions created or attached later, so an insert aimed at a leaf registers its tenant too; a statement-level trigger on the parent would not fire for it.- A backfill that reads the leaf partitions (
pg_partition_tree); the leaves carry no policy, so it runs as theNOBYPASSRLSowner without liftingFORCE. - Per-tenant checkpoints:
ledger_projection_offsetsprimary key(worker_id, tenant_id). A pre-existing global cursor is copied once to every registered tenant and removed.
LedgerProjectionWorker enumerates the registry on a system connection, then reads at most one batch per tenant on a connection bound to that tenant, ordered by (recorded_at, event_id) with an explicit tenant_id predicate (the (tenant_id, recorded_at) index and partition pruning apply). A failure is contained to its tenant.
ATTACH PARTITION of a table that already holds rows fires no INSERT trigger, so its tenant stays unregistered until the backfill statement in the deploy runbook is re-run; doctor.findings.ledger.rls-posture reports such a partition (projection.unregistered-partitions).
Compliance Validation
The runtime check is the Doctor check doctor.findings.ledger.rls-posture (LedgerRowLevelSecurityPostureCheck in StellaOps.Findings.Operations). It reports Critical when any table in FindingsSchemaTopology.ForcedRowLevelSecurityTables lost ENABLE + FORCE or its <table>_tenant_isolation policy, or gained another permissive policy, and Warning when the session role is a superuser or carries BYPASSRLS. The earlier validator, RlsValidationService, was withdrawn with the ledger host and is frozen under src/__Obsoleted/Findings/StellaOps.Findings.Ledger/PCD-330/.
Pre-Deployment Checks (schema-corrected)
-- 1. Verify RLS enabled on all tenant-scoped tables
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'findings'
AND tablename IN (
'ledger_events', 'ledger_merkle_roots', 'findings_projection',
'ledger_attestations', 'orchestrator_exports', 'airgap_imports',
'asset_registry_events'
)
AND rowsecurity = false;
-- Expected: 0 rows (all should have RLS enabled)
-- 2. Verify policies exist for all tables
SELECT tablename, policyname
FROM pg_policies
WHERE schemaname = 'findings'
AND tablename IN (
'ledger_events', 'ledger_merkle_roots', 'findings_projection',
'ledger_attestations', 'orchestrator_exports', 'airgap_imports',
'asset_registry_events'
);
-- Expected: 7 rows (one tenant-isolation policy per table)
-- 3. Verify tenant validation function exists
SELECT proname, prosrc
FROM pg_proc
WHERE proname = 'require_current_tenant'
AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'findings_ledger_app');
-- Expected: 1 row
Runtime Regression Tests
Illustrative. The snippets below show the intended regression shape. The real tenant connection helper is
LedgerDataSource.OpenConnectionAsync(string tenantId, CancellationToken); a no-tenant connection is obtained viaOpenSystemConnectionAsync(string purpose, CancellationToken). Since PCD-241,LedgerProjectionUnderNoBypassRlsOwnerTests(StellaOps.Findings.Operations.Tests) runs the projector under a least-privilegedNOBYPASSRLSowner and asserts per-tenant projection with no cross-tenant visibility;FindingsRoleGrantTestscovers scoped reads and writes.
[Fact]
public async Task CrossTenantRead_ShouldFail_WithRlsError()
{
// Arrange: Insert data as tenant A (sets app.current_tenant = 'tenant-a')
await using var connA = await _dataSource.OpenConnectionAsync("tenant-a", ct);
await InsertFinding(connA, "finding-1", ct);
// Act: Try to read as tenant B
await using var connB = await _dataSource.OpenConnectionAsync("tenant-b", ct);
var result = await QueryFindings(connB, ct);
// Assert: No rows returned (RLS blocks cross-tenant access)
Assert.Empty(result);
}
[Fact]
public async Task NoTenantContext_ShouldFail_WithException()
{
// Arrange: Open a system connection (no app.current_tenant set)
await using var conn = await _dataSource.OpenSystemConnectionAsync("regression-probe", ct);
// Act & Assert: query on an RLS-forced table raises P0001 from require_current_tenant()
await Assert.ThrowsAsync<PostgresException>(async () =>
{
await conn.ExecuteAsync("SELECT * FROM findings.ledger_events LIMIT 1");
});
}
Migration Strategy
Migration File: 007_enable_rls.sql (as-built)
The real migration sets search_path TO findings, public, wraps everything in a single transaction, creates the app schema + tenant function, enables and forces RLS with idempotent DROP POLICY IF EXISTS + CREATE POLICY on the eight tables from migration 007 (two of them, finding_history and triage_actions, were later dropped by Findings migration 028), and creates the admin role inside a guarded DO block (PostgreSQL has no CREATE ROLE IF NOT EXISTS). The ninth table, asset_registry_events, gets the same treatment in migrations/017_asset_registry_event_visibility.sql.
-- migrations/007_enable_rls.sql
-- Enable Row-Level Security for Findings Ledger tenant isolation (LEDGER-TEN-48-001-DEV)
SET search_path TO findings, public;
BEGIN;
-- 1. Create app schema and tenant function
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;
$$;
-- 2. Enable + FORCE RLS and create *_tenant_isolation policies (see full SQL above)
-- ... (applied to the 8 tables in migration 007; asset_registry_events in migration 017)
-- 3. Create admin bypass role (idempotent, no CREATE ROLE IF NOT EXISTS in PostgreSQL)
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;
$$;
COMMIT;
Startup application. Steps 1 and 2 above now live in the consolidated Findings baseline (
src/Findings/__Libraries/StellaOps.Findings.Persistence/Migrations/001_findings_consolidated_baseline.sql, its forced-table loop), whichfindings-webandfindings-workerapply on startup throughAddStartupMigrations. Step 3 does not: the baseline runs no role DDL (:155-164), and the install path createsfindings_ledger_admin(section 3). The007_…and017_…files named here were theStellaOps.Findings.Ledgerchain, which no host ran and which was withdrawn on 2026-09-30 (SPRINT_20260928_004 PCD-149, frozen undersrc/__Obsoleted/Findings/StellaOps.Findings.Ledger/PCD-149/).
Rollback: 007_enable_rls_rollback.sql
This rollback script was a manual operator tool for the withdrawn Ledger chain and was never embedded; it is frozen with that chain (src/__Obsoleted/Findings/StellaOps.Findings.Ledger/PCD-149/migrations/007_enable_rls_rollback.sql). It did not drop the asset_registry_events policy/RLS and intentionally did not drop the findings_ledger_admin role. It does not describe the consolidated baseline’s policies. The listing below omits its finding_history and triage_actions statements: Findings migration 028 dropped both tables.
BEGIN;
-- 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 ledger_attestations DISABLE ROW LEVEL SECURITY;
ALTER TABLE orchestrator_exports DISABLE ROW LEVEL SECURITY;
ALTER TABLE airgap_imports DISABLE ROW LEVEL SECURITY;
-- Drop 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 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 function and schema
DROP FUNCTION IF EXISTS findings_ledger_app.require_current_tenant();
DROP SCHEMA IF EXISTS findings_ledger_app;
COMMIT;
Audit Requirements
- All write operations must log
tenant_idandactor_id - System connections carry a required purpose, logged at debug, and every call site is allowlisted with the relations it touches (
FindingsLedgerTenantBindingConformanceTests); none of those relations carries row-level security - RLS bypass operations: the service performs none. Every session that reads a FORCE-RLS table is bound to one tenant; a
BYPASSRLSor superuser connection is reported bydoctor.findings.ledger.rls-postureas a Warning - Cross-tenant queries on the FORCE-RLS tables: the service runs none. The projector’s tenant enumeration reads
findings.ledger_tenants, which holds tenant ids only
Row-level security guards against application mistakes. It is not a boundary against hostile SQL running as the owner role, which can read the leaf partitions and lift FORCE on the tables it owns.
Reference Implementation
Evidence Locker RLS implementation:
src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Infrastructure/Db/Migrations/001_initial_schema.sql
Approval Checklist
- [x] Platform/DB Guild: Schema and RLS patterns approved (delivered in
migrations/007_enable_rls.sql) - [x] Security Guild: Tenant isolation verified (FORCE RLS +
require_current_tenant()on all tenant-scoped tables) - [x] Findings Ledger Guild: Implementation feasible (shipped; applied via
AddStartupMigrations) - [x] DevOps Guild: Migration/rollback strategy approved (
007_enable_rls.sqlembedded; rollback kept as manual operator script)
Changelog
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0.0 | 2025-12-05 | Platform Guild | Initial contract based on Evidence Locker pattern |
| 1.1.0 | 2026-05-30 | Docs reconciliation | Reconciled to as-built source: status Published→Implemented; corrected schema to findings (was public); fixed partitioning claim (only 5 of 9 tenant tables are LIST-partitioned); added 9th RLS-protected table asset_registry_events (migration 017); schema-qualified all DDL and added idempotent DROP POLICY/guarded CREATE ROLE; corrected connection-pattern method names (OpenConnectionAsync/OpenSystemConnectionAsync, no _adminDataSource); pointed compliance section at RlsValidationService and flagged its schemaname='public' bug; noted no dedicated cross-tenant RLS regression test exists yet. |
| 1.2.0 | 2026-10-03 | SPRINT_20260930_006 PCD-241 | Owner ruling 2026-10-02: the owner stays NOBYPASSRLS; blank tenants are rejected; OpenSystemConnectionAsync(purpose, ct) is for relations without RLS only and allowlisted; the projector reads per tenant through the findings.ledger_tenants registry (migration 018) with per-tenant checkpoints; audit requirements restated; the rls-posture doctor check warns on a bypassing connection. |
| 1.3.0 | 2026-10-08 | SPRINT_20261007_062 DIP-8 | Findings migration 028 drops the write-only projections finding_history and triage_actions (and the ledger_action_type enum): three LIST-partitioned and seven RLS-protected tables remain in the current-state lists, twelve FORCE tables in all; the compliance section points at the doctor.findings.ledger.rls-posture check because RlsValidationService was withdrawn (PCD-330). |
| 1.3.1 | 2026-10-08 | SPRINT_20261007_062 DIP-16 | Body pruned to match 1.3.0: the as-built RLS DDL and the frozen rollback listing no longer carry finding_history / triage_actions statements; the posture-ruling count and the migration 007 description name the migration 028 drop. |
