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-web and findings-worker apply it on startup through AddConsolidatedFindingsPersistence (src/Findings/StellaOps.Findings.WebService/Program.cs:64, src/Findings/StellaOps.Findings.Worker/Program.cs:31). The migrations/007_enable_rls.sql and migrations/017_asset_registry_event_visibility.sql files quoted below belonged to the StellaOps.Findings.Ledger chain, 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 findings owner stays NOBYPASSRLS (ADR-039) and the FORCE policies are unchanged (fourteen at the ruling; twelve since Findings migration 028_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 own app.current_tenant after enumerating the RLS-free registry findings.ledger_tenants (migration 018_findings_ledger_tenant_registry.sql). See Connection Patterns and Projector Reads.

Schema note. All Findings Ledger tables live in the PostgreSQL schema findings (not public). Connections set search_path TO findings, public (see LedgerDataSource.ConfigureSessionDefaultsAsync and FindingsLedgerDbContextFactory.DefaultSchemaName). The schema-qualified DDL in the migration files uses findings.<table> and findings_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):

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):

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.sql and migrations/017_asset_registry_event_visibility.sql (the asset_registry_events table), without the finding_history and triage_actions blocks: Findings migration 028_findings_retire_write_only_projections.sql dropped both tables. All statements are schema-qualified to findings / 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 is FindingsSchemaTopology.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_admin to any application/migration login role, and membership would not help: BYPASSRLS is 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 through STELLAOPS_POSTGRES_FINDINGS_CONNECTION. Owning the findings schema does not bypass RLS, because every policed table is FORCEd (:917-920); the service therefore binds app.current_tenant on every session that reads one (:874-892). The install path provisions the owner NOBYPASSRLS (20-service-databases.sh:228), and since PCD-241 that is the supported posture: doctor.findings.ledger.rls-posture reports a BYPASSRLS or superuser connection as a Warning naming ADR-039 and ALTER 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 sitePurposeRelations (none carries RLS)
PostgresLedgerProjectionTenantSourceprojection-tenant-registryfindings.ledger_tenants
PostgresRuntimeTraceRepository.ListRetentionTenantsAsyncruntime-trace-retention-tenantsfindings.runtime_traces (tenant ids)
PostgresAssetRegistryEventProjectionRepository checkpointsasset-registry-projection-checkpointfindings.asset_registry_projection_offsets
PostgresGraphAssetRegistryEventSourceasset-registry-source, asset-registry-source-admissiongraph.asset_registry_events (legacy host path only)
PostgresAssetRegistryFeedStateStoreasset-registry-feed-statefindings.asset_registry_feed_state
RlsValidationServicerls-validation-catalogpg_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:

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 via OpenSystemConnectionAsync(string purpose, CancellationToken). Since PCD-241, LedgerProjectionUnderNoBypassRlsOwnerTests (StellaOps.Findings.Operations.Tests) runs the projector under a least-privileged NOBYPASSRLS owner and asserts per-tenant projection with no cross-tenant visibility; FindingsRoleGrantTests covers 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), which findings-web and findings-worker apply on startup through AddStartupMigrations. Step 3 does not: the baseline runs no role DDL (:155-164), and the install path creates findings_ledger_admin (section 3). The 007_… and 017_… files named here were the StellaOps.Findings.Ledger chain, which no host ran and which was withdrawn on 2026-09-30 (SPRINT_20260928_004 PCD-149, frozen under src/__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

  1. All write operations must log tenant_id and actor_id
  2. 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
  3. RLS bypass operations: the service performs none. Every session that reads a FORCE-RLS table is bound to one tenant; a BYPASSRLS or superuser connection is reported by doctor.findings.ledger.rls-posture as a Warning
  4. 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:


Approval Checklist


Changelog

VersionDateAuthorChanges
1.0.02025-12-05Platform GuildInitial contract based on Evidence Locker pattern
1.1.02026-05-30Docs reconciliationReconciled 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.02026-10-03SPRINT_20260930_006 PCD-241Owner 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.02026-10-08SPRINT_20261007_062 DIP-8Findings 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.12026-10-08SPRINT_20261007_062 DIP-16Body 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.