RLS Policy SQL
The actual row-level-security policies the whole isolation claim defers to, enforcing tenant, classification, and cell boundaries.
The actual row-level-security policies the whole isolation claim defers to (
10,11,20,43,60). RLS is defense-in-depth (the per-tenant database is the primary boundary) but it is what holds when application code forgets a filter, and it enforces the in-tenant boundaries that matter: classification and wargame cell.
Session context (set per short tx by the api middleware — 60, 40)
SET LOCAL app.tenant_id = '…'; -- from JWT tid
SET LOCAL app.user_id = '…'; -- from JWT sub
SET LOCAL app.role = '…'; -- from JWT role
SET LOCAL app.clearance = '…'; -- from JWT clearance: highest classification this user may read
SET LOCAL app.cell = '…'; -- from JWT cell (nullable; wargame ONLY)
SET LOCAL app.is_faculty = 'f'; -- 't' only for the wargame umpire roleSET LOCAL scopes to the transaction so a pooled connection can't leak context between requests.
Classification and cell are two INDEPENDENT dimensions. The earlier draft conflated them (a missing cell implied "see everything"), which is the exact hole RLS exists to catch. They are now gated separately:
- Classification is gated on
app.clearance(an ordered level the user holds), NOT on "is OPEN". A non-OPEN row is visible only if the user's clearance ≥ the row's classification. - Cell is gated so that "no cell" never means "see everything": a row with no
cell_idis shared within the tenant; a row with acell_idis visible only to that cell (or to faculty).
-- classification ranking helper (immutable; OPEN < RESTRICTED < SECRET < TOPSECRET)
CREATE FUNCTION class_rank(c text) RETURNS int IMMUTABLE LANGUAGE sql AS $$
SELECT CASE c WHEN 'OPEN' THEN 0 WHEN 'RESTRICTED' THEN 1
WHEN 'SECRET' THEN 2 WHEN 'TOPSECRET' THEN 3 ELSE 99 END $$;Enable + policies
ALTER TABLE observations ENABLE ROW LEVEL SECURITY;
ALTER TABLE observations FORCE ROW LEVEL SECURITY; -- applies even to the table owner
-- ONE combined policy that AND-s three INDEPENDENT predicates:
-- (1) tenant (2) clearance ≥ classification (3) cell isolation
CREATE POLICY obs_access ON observations USING (
tenant_id = current_setting('app.tenant_id')::uuid
AND
-- (2) CLEARANCE: user's clearance must dominate the row's classification.
-- No fall-through to "OPEN only"; an unset clearance ranks as OPEN(0),
-- so a no-clearance user sees ONLY OPEN rows — fail-closed.
class_rank(classification) <= class_rank(coalesce(current_setting('app.clearance', true), 'OPEN'))
AND
-- (3) CELL: "no cell" is NOT "see everything". A NULL-cell row is tenant-shared;
-- a cell-tagged row needs a matching cell, UNLESS the user is faculty (umpire).
( cell_id IS NULL
OR cell_id = nullif(current_setting('app.cell', true), '')::uuid
OR coalesce(current_setting('app.is_faculty', true), 'f') = 't' )
);Why each branch is safe:
- Clearance fails closed: if
app.clearanceis unset,coalesce(…, 'OPEN')ranks it at 0, so the user sees only OPEN rows. A regular analyst with no clearance can never see a classified row at the data layer — the app engine (41) is no longer the only thing protecting classification. - Cell fails closed: a missing
app.cellmatches onlycell_id IS NULLrows (tenant-shared), never cell-tagged rows. Cell isolation no longer depends on guaranteeing every analyst has a cell. - Faculty is an explicit, separate flag for the umpire — and note (decide per deployment) whether faculty clearance also dominates classified rows within an exercise; clearance still applies to faculty unless you set their
app.clearanceaccordingly.
Apply the same tenant_id (+ classification/cell where the column exists) pattern to every table:
ALTER TABLE entities ENABLE ROW LEVEL SECURITY; ALTER TABLE entities FORCE ROW LEVEL SECURITY;
CREATE POLICY ent_access ON entities USING (tenant_id = current_setting('app.tenant_id')::uuid);
ALTER TABLE events ENABLE ROW LEVEL SECURITY; ALTER TABLE events FORCE ROW LEVEL SECURITY;
CREATE POLICY evt_access ON events USING (tenant_id = current_setting('app.tenant_id')::uuid);
ALTER TABLE cases ENABLE ROW LEVEL SECURITY; ALTER TABLE cases FORCE ROW LEVEL SECURITY;
CREATE POLICY case_access ON cases USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- … reports, sources, case_bindings, audit, etc.Faculty bypass — visibility ONLY, never approval
The wargame umpire (faculty) sees every cell — handled by the is_faculty = 't' branch inside the single obs_access policy above (no separate, partly-redundant obs_faculty_view policy; one policy keeps the AND-semantics intact). Faculty's cell bypass does not grant an approval/commit bypass — that is enforced in the POLICY ENGINE (41), where the faculty role does not carry proposal:decide. Visibility (RLS) and approval (engine) are independent on purpose (the real bug class in product §7). Faculty cell-visibility still respects clearance unless app.clearance is set for them.
Writes
RLS USING governs read/update/delete visibility; add WITH CHECK so a row can't be written outside the tenant:
CREATE POLICY obs_insert ON observations FOR INSERT
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);The app DB role (hard requirement)
RLS is silently void if the connection role can bypass it. The api connects as a dedicated, non-owner role created without BYPASSRLS and without SUPERUSER:
CREATE ROLE insigz_app LOGIN PASSWORD '…' NOBYPASSRLS NOSUPERUSER;
-- own the schema with a SEPARATE migration/owner role; insigz_app only gets table DML grants.Do not run the app as the Cloud SQL default high-privilege user — it would defeat every policy above. FORCE ROW LEVEL SECURITY also covers the owner case, but the app must still use insigz_app.
Verification (CI — 43, 52)
- A test sets
app.tenant_idto tenant A and asserts a query for tenant B's known row returns 0 rows. - Clearance test: with
app.clearance='OPEN'(or unset), a query for aSECRETrow returns 0 rows; withapp.clearance='SECRET'it returns the row. - Cell test: with
app.cellunset, a query for a cell-tagged row returns 0 rows (proves "no cell" ≠ "see everything"); with the matching cell it returns the row; withapp.is_faculty='t'it returns rows from every cell. - A migration test asserts every table with a
tenant_idcolumn has RLS enabled + forced and an access policy (fail CI otherwise). - A startup assertion verifies the connection role has neither
rolsupernorrolbypassrls.
Gaps to close
- Generate the per-table policy migrations from a table manifest (avoid hand-drift).
- Finalize the classification level vocabulary + the JWT
clearanceclaim mapping (which IdP attribute → which level). - Decide whether the wargame umpire is cleared for classified rows within an exercise (set their
app.clearanceaccordingly).
OpenAPI Endpoint Inventory
The route surface the api exposes per domain service — the source the OpenAPI spec and generated SDK are built from.
Contracts: insigzctl & the Break-Glass Token
The CLI command contracts and the security-critical break-glass token, made concrete for the provisioning orchestrator and control plane.