Skip to main content
Governance

Change Management

Standing rule: every major implementation MUST update this chapter in the same change — at minimum a dated changelog entry, plus an ADR if architecturally significant.

Version history

The current manual version is shown on the cover of the exported PDF and in the sidebar header. Bump MANUAL_META.version in src/content/manual/MANUAL_META.ts whenever the manual structure or major content changes.

Release notes (newest first)

1.14.0 — 2026-06-11 — Phase 2.1 close-out artefact + Phase 6 (CI guardrails) kick-off

  • New chapter 12 — "Phase 2.1 — Multi-Tenant Isolation Remediation". Stand-alone, audit-grade evidence record of Incidents #1–6: plain-language description, CVSS v3.1 estimate, affected tables, date discovered, date remediated, remediating migration, and verification source for each. Written so it can be extracted and circulated independently of the rest of the manual, in line with ISO/IEC 27001 A.5.34/A.8.16/A.8.31/A.8.33 and DORA Art.9/Art.12 record-keeping expectations this platform is meant to satisfy for its customers. Open items section explicitly flags Tech-debt #8 as "awaiting product/business decision" with the rationale (delegated approval workflow in customer pilots) and an explicit "will not be silently closed in later engineering phases" guarantee.
  • Tech-debt #9 wired as a planned weekly recurring control. public.audit_logs_integrity_summary() SECURITY DEFINER RPC shipped (migration 20260611083942) — runs the same 4 forensic checks as scripts/audit-logs-integrity-check.ts but inside Postgres so the Cloudflare Worker can call it. Worker endpoint /api/public/hooks/audit-logs-integrity-check shipped (validates apikey, calls the RPC, persists a SYSTEM row into audit_logs for chain-of-custody, returns JSON summary). Suggested cadence: weekly via pg_cron + pg_net. pg_cron schedule SQL + alerting hook + operator playbook deferred to a later Operations chapter pass (recorded as a follow-up under Tech-debt #9 in the register; endpoint can be invoked manually for ad-hoc audits in the interim).
  • Phase 6 Tech-debt #10 lint shipped. scripts/lint-rls-policies.ts scans every committed migration under supabase/migrations/ for the two anti-patterns the 2.1 sweep surfaced: WITH CHECK (true) on public.* policies (Incident #5 root cause) and UPDATE/ALL policies with a USING clause but no matching WITH CHECK (the batch-9 sub-finding shape). Scope-correct: skips storage.* / non-public.* policies and skips TO service_role-only admin policies (service_role bypasses RLS by design). Baseline of 4 known cases frozen with explicit reasons surfaced in the CI log: historical audit_insert (Incident #5, superseded), intentional demo_leads public lead capture (INSERT-only, reviewed), and the two historical ar_update / gr_update cases (Incident #5 sub-finding, superseded). Any NEW violation that doesn't appear in the baseline (or carry an inline lint:allow-* marker) fails CI. New bun run lint:rls script and a dedicated rls-lint job added to .github/workflows/ci.yml. Runs clean against the current corpus.
  • Tech-debt #8 status reaffirmed. Listed as "awaiting product/business decision" in both the technical-debt register and the new chapter-12 open-items section. Compensating controls documented in chapter 12 §3 / Incident #3 (audit_logs captures true actor_id vs claimed approver_id; cross-tenant forgery of approver_id already blocked by Incident #6's enforce_same_tenant() trigger; access-governance workflow surfaces decisions to a second reviewer).
  • No schema changes touching tenant-scoped tables. No new isolation harness entries needed; the 32-table / 126-check baseline from 1.13.0 is unchanged and remains green.

1.13.0 — 2026-06-11 — Phase 2.1 tenant-isolation rollout (batch 10: FK-tenant trigger sweep — closes 2.1)

  • enforce_same_tenant() trigger function added in migration 20260611082547. Single generic SECURITY DEFINER plpgsql function taking (parent_table, child_fk_column) as trigger args; resolves the parent's tenant_id via EXECUTE format(...) and raises ERRCODE = 'check_violation' if the parent's tenant differs from the child's NEW.tenant_id. No-ops when the FK column is NULL (only evidence.control_id is nullable in this set).
  • Attached as BEFORE INSERT OR UPDATE OF <fk_col>, tenant_id on all 12 tenant-scoped child→parent FKs identified during batch 8 scoping: access_request_approvals.request_id, ai_approvals.model_id, ai_impact_assessments.model_id, ai_risk_assessments.model_id, control_mappings.control_id, evidence.control_id, group_members.group_id, group_roles.group_id, resources.workspace_id, risk_treatments.risk_id, user_roles.access_request_id, vendor_assessments.vendor_id. Triggering on tenant_id updates as well as on the FK column closes the "move-the-child-to-the-other-tenant" variant (same shape as the access_reviews / gdpr_requests UPDATE-without-WITH CHECK finding in batch 9).
  • 🚨 Headline case — user_roles.access_request_id (Incident #6 below). Of the 12 FKs, this is the one with the most direct governance impact: a role grant whose justification points at an access request from a different tenant is fabricated evidence in the access-governance audit trail. Same root pattern as Tech-debt #12 across all 12 tables; called out separately in the incident register because the impact class is "fabricated audit evidence" rather than "data integrity drift". Closed by the same migration.
  • Sequencing rationale honoured: trigger sits on top of fully-normalised RLS (batches 1–9) rather than underneath a still-leaky predicate layer. tg_audit_row() and tg_touch_row() continue to fire on the same tables without interaction (different TG_OP / BEFORE ordering; no shared state).
  • Harness — composite-PK seeding shipped, throttled signIn shipped, group_members now covered. Runner changes: TableDescriptor.pkColumns optional field (defaults to ["id"]) plumbed through setupFixture seed, SELECT, UPDATE, and DELETE phases so composite-PK tables no longer fail with column ... id does not exist. 1.2s pre-call sleep before each signInWithPassword keeps the --all run comfortably under the GoTrue per-IP rate limit (Tech-debt #13 closed). Forged-INSERT rejection regex extended to accept cross-tenant — the new trigger now fires before RLS WITH CHECK on the harness's "parents seeded in tenant A, row tagged tenant B" probe, which is still a valid block (defence in depth on top of RLS, not a substitute).
  • Test harness — executed and passing for all 32 tables. bun run tests/integration/tenant-isolation.ts --all exit 0, 32 tables / 126 checks green (4 checks per table for the 26 read+write tables, 3 for the 5 append-only or no-mutable-column tables — audit_logs, access_audit_log, audit_vault, chatbot_audit_log, group_members). No remaining deferrals; no remaining test-tooling debt.
  • Phase 2.1 is complete. Tech-debt #10 (CI lint — now widened in scope, see register) remains queued for Phase 6 as the forward-looking guardrail. Scripts/audit-logs-integrity-check.ts remains queued as an Operations-chapter follow-up.

Phase 2.1 rollup (batches 1–10, closed across 1.4.0 → 1.13.0)

ClassOutcome
is_active-bypass on write paths (16 tables: tasks, evidence, incidents, controls, policies, risks, risk_treatments, ai_models, vendors, vendor_assessments, ai_approvals, access_request_approvals, access_requests, ai_impact_assessments, ai_risk_assessments, control_mappings)Closed (batches 1–7). Incidents #1, #2 + Tech-debt #7.
is_active-bypass on Cluster A (groups, group_members, group_roles, resources, security_policies, workspaces) + tenant-membership entirely missing on case_share_linksClosed (batch 8). Sub-shape of Incident #2.
USING without WITH CHECK on UPDATE (access_reviews, gdpr_requests) — privileged member could mutate tenant_idClosed (batch 9). Sub-finding of Incident #5 (same shape, narrower blast radius).
Cross-tenant audit-row forgery via WITH CHECK (true) on audit_logsClosed (batch 6). Incident #5 — headline.
Cross-tenant FK leak on 12 child→parent FKs (headline: user_roles.access_request_id)Closed (batch 10). Incident #6 + Tech-debt #12.
Six global-reference tables had no Data API GRANTsClosed (batch 7). Tech-debt #11.
access_requests SELECT leak to deactivated requester/subjectClosed (batch 5). Incident #4.
approver_id not enforced on ai_approvals + access_request_approvals (within-tenant attribution integrity)Open (Tech-debt #8) — deferred product-policy decision (delegated approval vs strict approver_id = auth.uid()).
Pre-fix audit_logs integrity (live tenants before 2026-06-10)Clean against current corpus via scripts/audit-logs-integrity-check.ts; queued as Operations runbook item. Tech-debt #9.
Positive controls (no gap found, rewritten in place for template consistency)access_grants, access_audit_log, audit_vault, chatbot_sessions, chatbot_audit_log.
CI guardrails to prevent recurrenceOpen (Tech-debt #10, scope widened in 1.13.0 to also catch UPDATE policy with USING but no WITH CHECK). Queued for Phase 6.

Final harness count: 32 tables registered, 32 executed and passing, 126 checks green, 0 deferred.

1.12.0 — 2026-06-11 — Phase 2.1 tenant-isolation rollout (batch 9: Cluster B positive-control sweep)

  • access_reviews, chatbot_sessions, chatbot_audit_log, gdpr_requests — read+write paths confirmed already correctly gated on is_tenant_member(...) (chatbot tables additionally pin the owner via auth.uid() = user_id). Two minor gaps fixed in migration 20260611081109:
    • access_reviews.ar_update had USING (is_tenant_member AND has_any_role([...auditor-or-better])) but no WITH CHECK. An authorised tenant member could in principle mutate tenant_id during an UPDATE, moving an access-review row to another tenant they belong to. Now USING and WITH CHECK mirror each other.
    • gdpr_requests.gr_update had the same shape (USING only, no WITH CHECK, scoped to owner / admin / compliance_officer). Same fix.
  • Why this matters more than it looks: access_reviews and gdpr_requests are both regulator-facing evidence sources (SOC2 / DORA periodic reviews and GDPR subject-rights workflow). The missing WITH CHECK is an integrity gap on records that, once moved, are likely to be picked up by a tenant's evidence pack without any tamper signal. Same class as the WITH CHECK (true) finding in Incident #5 in shape (write-side predicate weaker than read-side), much narrower in blast radius (requires an authenticated authorised user, not "any authenticated user"). Logged as a sub-finding of Incident #5 — same root pattern, different severity. Worth flagging that this is the second small variant of the "write predicate isn't actually checking what we think it's checking" class surfaced by the 2.1 sweep; recommend Phase 6 CI lint (Tech-debt #10) also catches "UPDATE policy with USING but no WITH CHECK" in addition to the WITH CHECK (true) regex.
  • chatbot_sessions and chatbot_audit_log — no changes needed. Both already pin auth.uid() = user_id AND is_tenant_member(...) on every applicable axis; chatbot_audit_log is append-only by absence of UPDATE/DELETE policies. Genuine positive controls.
  • Test harness — executed and passing for all 4 Cluster B tables. Descriptors added; one descriptor patch needed on first run (gdpr_requests.status constraint is pending / in_progress / completed / rejected, not received). 31 tables registered, 30 executed and passing (120 checks), 1 deferred (group_members, harness-runner limitation carried over from batch 8).
  • Sequencing: batch 10 (FK-tenant trigger sweep via shared enforce_same_tenant()) is now the only remaining item in the 2.1 rollout. Tech-debt #10 (CI lint) and Tech-debt #13 (harness signIn pacing) remain queued for Phase 6 / runner work respectively.

1.11.0 — 2026-06-11 — Phase 2.1 tenant-isolation rollout (batch 8: Cluster A)

  • case_share_links, groups, group_members, group_roles, resources, security_policies, workspaces — write policies normalised to require is_tenant_member(auth.uid(), tenant_id) in addition to the existing role predicate (is_admin(...) for governance tables, can_write(...) for resources / workspaces). Same is_active-bypass gap class as batches 1–7; the gap is now closed on 23 tables in total. Read policies were already correctly gated and are unchanged.
  • case_share_links shape note: the old policy set was owner-only (created_by = auth.uid()) with no tenant_id predicate at all on SELECT / INSERT / UPDATE. A deactivated owner — or, more concerningly, a user whose membership was revoked but whose auth.user still exists — could continue to enumerate and rotate share links scoped to a tenant they no longer belong to, because RLS only checked created_by. New policies AND the membership predicate alongside the owner check on every path. Worth flagging in the incident register as a small but distinct sub-shape under Incident #2 (the broader is_active-bypass class) — same root cause, but the missing predicate was tenant-membership entirely, not just the is_active axis.
  • security_policies global-row handling: this table allows tenant_id IS NULL for platform-wide policy rows (read by every authenticated user). Preserved that read branch; write branch now blocks tenant_id IS NULL outright in both USING and WITH CHECK, so a tenant admin cannot escalate a tenant-scoped policy into a global one. Global rows are now write-only via service_role / migrations.
  • No Incident #5-class shape on this cluster. Blast-radius scan (run as part of batch 6) already confirmed WITH CHECK (true) is unique to the closed audit_logs case; re-verified on this cluster's old policies before rewrite. No new occurrences.
  • Tech-debt #12 (cross-tenant FK leak) scoping completed before batch 8 landed. 12 child→parent FKs across 12 tenant-scoped tables do not validate same-tenant: access_request_approvals.request_id, ai_approvals.model_id, ai_impact_assessments.model_id, ai_risk_assessments.model_id, control_mappings.control_id, evidence.control_id, group_members.group_id, group_roles.group_id, resources.workspace_id, risk_treatments.risk_id, user_roles.access_request_id, vendor_assessments.vendor_id. No existing trigger or helper enforces same-tenant anywhere in the schema — this is a platform-wide class, same shape (different severity) as Incident #5. Priority case for the writeup: user_roles.access_request_id — a role grant whose justification points to the wrong tenant's access request is the most directly governance-impacting variant of this class and should be called out separately in the incident register alongside Tech-debt #12, even though the fix is shared across all 12 tables. To be remediated in batch 10 via a single shared enforce_same_tenant(parent regclass, parent_id uuid, tenant_id uuid) trigger function attached BEFORE INSERT OR UPDATE to each of the 12 tables.
  • Sequencing decision recorded: batch 9 (Cluster B positive-control sweep — access_reviews, chatbot_sessions, chatbot_audit_log, gdpr_requests) runs before batch 10 so the FK-tenant trigger sits on top of already-correct RLS rather than underneath a still-leaky predicate layer.
  • Test harness — executed and passing for 6 of 7 Cluster A tables. Descriptors added for workspaces, resources, groups, group_roles, security_policies, case_share_links — all four checks (SELECT / forged-INSERT / UPDATE / DELETE isolation) green when run individually. group_members descriptor deferred: the table has a composite PK (group_id, user_id) and no id column, which the current harness's select("id") seed path does not support. RLS on group_members was still normalised in this batch (same predicate as the other six), but harness coverage will land alongside a small runner enhancement teaching it about composite PKs (tracked with batch 10). Two descriptor patches were needed on first run before green: case_share_links.permission constraint requires VIEW_ONLY / VIEW_COMMENT / FULL_REVIEW (not read), and workspaces.slug is NOT NULL (sampleRow now seeds it). The full --all sweep currently trips the Supabase auth Request rate limit reached on the back half of the 27-table run — per-table invocation green; an aggregate run needs a throttled-signIn pacing change to the runner (tech-debt #13, harness-only, no RLS implication). 27 tables registered, 26 executed and passing (104 checks), 1 deferred (group_members, harness-runner limitation).

1.10.0 — 2026-06-11 — Phase 2.1 tenant-isolation rollout (batch 7: AI-governance trio)

  • ai_impact_assessments, ai_risk_assessments, control_mappings — write policies normalised to is_tenant_member(auth.uid(), tenant_id) AND can_write(auth.uid(), tenant_id) in migration 20260611074635. Same is_active-bypass gap class as Incident #2; the gap is now closed on sixteen tables in total. SELECT policies were already correctly gated and are unchanged.
  • No Incident #5-class shape on this trio. All three already required a role (can_write(...)) on writes; the gap was the absence of the is_tenant_member(...) outer wrap, not WITH CHECK (true).
  • Cross-tenant FK leak (recorded, not remediated this batch): control_mappings.control_id and compliance_requirements/framework FK chains do not validate that the referenced control belongs to the same tenant as the mapping row. A role-bearing tenant-A user could in principle insert a control_mappings row tagged with tenant-A's tenant_id but control_id pointing to a control in tenant B (if they discovered the id). Within-tenant integrity issue, same class as Incident #3 but on FKs rather than attribution columns. Logged as Tech-debt #12.
  • Harness-side finding A — Tech-debt #11 (Data API grants on global reference tables): While wiring control_mappings parents the harness hit permission denied on frameworks / compliance_requirements via PostgREST. Investigation showed the six global reference tables (frameworks, compliance_requirements, permission_catalog, role_hierarchy, role_permissions, sod_rules) had no Data API grants whatsoever — neither authenticated SELECT nor service_role write. End-user reads only worked from contexts that read the tables via SECURITY DEFINER functions or psql; any direct supabase.from('frameworks').select(...) from the app would fail. Fixed in migrations 20260611074908 and 20260611075013 (GRANT SELECT TO authenticated; explicit FOR ALL TO service_role USING (true) WITH CHECK (true) policies). Note: service_role is BYPASSRLS at the role level, but PostgREST's SET ROLE service_role path still requires a permissive policy for any RLS-enabled table that has policies for other roles — confirmed empirically here.
  • Harness-side finding B — session bleed-through confirmed and fixed: The hypothesis raised during the audit-logs integrity-check pass (admin client session pollution from setupFixture()'s signInWithPassword call) is real and observable. After setupFixture() mints user A's session via the shared admin client, any later admin.from(...).insert(...) call (notably the parentsA seed in the forged-INSERT check) goes through PostgREST as user A, not service_role. The check still PASSed in earlier batches because the parent rows it tried to seed always had open admin policies or no RLS conflict — control_mappings was the first case where parent seeding required RLS bypass. Fixed by minting a fresh adminClient() for parentsA. Prior batches' PASS verdicts are intact: they were based on REST response codes against the user-A bearer token (asA), not on admin reads — the only admin call that bled through was parentsA, and in earlier batches that call coincidentally worked. The harness-noise rows analysed by scripts/audit-logs-integrity-check.ts (the iso-* tenants and iso_probe rows) were all written under correct attribution before this bug bites, so the clean 4/4 result there also stands.
  • Test harness — three descriptors registered (with prepareParents for all three: ai_impact_assessmentsai_models, ai_risk_assessmentsai_models, control_mappingscontrols + frameworks/compliance_requirements). 21 tables registered; 84/84 isolation checks pass.

1.9.0 — 2026-06-10 — Phase 2.1 tenant-isolation rollout (batch 6: audit trio)

  • audit_logsIncident #5 closed. The previous audit_insert policy was WITH CHECK (true): any authenticated user could INSERT an audit row tagged with any tenant_id and any actor_id. This is the most serious gap surfaced so far in the 2.1 effort — a cross-tenant audit-stream forgery path that would have undermined evidence-pack integrity for every regulator-facing workflow downstream. New rule: ((tenant_id IS NULL) OR is_tenant_member(auth.uid(), tenant_id)) AND ((actor_id IS NULL) OR actor_id = auth.uid()). The NULL branches preserve writes from tg_audit_row() and other system contexts; tg_audit_row() is SECURITY DEFINER owned by postgres (BYPASSRLS) so trigger-driven inserts are unaffected. SELECT policy rewritten in place (semantics unchanged).
  • access_audit_logalready correctly gated on both axes: SELECT requires is_tenant_member(...), INSERT requires is_tenant_member(...) AND actor_user_id = auth.uid(). Rewritten in place for template consistency, no semantic change. Positive control: second table in the rollout (after access_grants) found without any gap class.
  • audit_vaultalready correctly gated: INSERT requires is_tenant_member(...) (with the same NULL-tenant escape hatch for system events), SELECT restricted to owner / admin / auditor / compliance_officer. Append-only enforcement remains via the existing tg_audit_vault_immutable() trigger (UPDATE/DELETE blocked outright). Rewritten in place, no semantic change.
  • Hash-chain check (requested): access_audit_log in the current schema does not carry prev_hash / row_hash columns, and no verify_audit_chain() function exists in public.*. The hash-chain hardening pack referenced has not been applied to this database, so nothing is at risk of interference from the new predicates. Flagged here so the assumption is recorded. If/when that pack lands, re-verify the chain function tolerates the new predicates (it almost certainly will — chain verification is a read against the row sequence, not a write).
  • Test harness — three descriptors registered, all marked testDelete: false (append-only by trigger for audit_vault, by absence of UPDATE/DELETE policies for the other two). 18 tables registered; 72/72 isolation checks pass.

1.8.0 — 2026-06-10 — Phase 2.1 tenant-isolation rollout (batch 5: access-control surface)

  • access_request_approvals — added is_tenant_member(auth.uid(), tenant_id) to both SELECT and write paths. Write predicate is is_admin(...)-gated (unchanged for active members). Same is_active-bypass gap class as previous batches.
  • access_requests — added is_tenant_member(...) to update USING+CHECK (same gap). Also tightened the ar read policy: the previous is_admin OR requester_id = auth.uid() OR subject_user_id = auth.uid() had no tenant-membership check on the requester / subject branches — analogous in shape to the tasks.assignee_id branch closed in batch 1 — so a user removed from a tenant could still see their own pending access requests. Now wrapped in an outer is_tenant_member(...) so deactivated members lose visibility consistently with the rest of the suite.
  • access_grantsalready correctly gated on is_tenant_member(...) across SELECT, INSERT, and the unusual ag_revoke CHECK pattern (WITH CHECK (revoked_at IS NOT NULL), which intentionally narrows updates to "set revocation" only). Left unchanged. Positive finding: not every table carries the gap class; the insert_grant_with_audit() SECURITY DEFINER function predates and apparently informed the policy authoring.
  • Within-tenant finding — Incident #3 sibling-shape confirmed: access_request_approvals.approver_id is also not enforced by RLS. Same shape as ai_approvals.approver_id: any admin can write rows attributing the decision to any user. Tech-debt #8 scope extended to enumerate both tables; remediation sequenced together in a dedicated follow-up migration (still deferred).
  • Test harness — descriptors registered for all three (with prepareParents for access_request_approvalsaccess_requests; the access_grants descriptor uses revoked_at as its update column to avoid the ag_revoke CHECK masking a true RLS rejection). 15 tables registered; 60/60 isolation checks pass.

1.7.0 — 2026-06-10 — Phase 2.1 tenant-isolation rollout (batch 4: vendors, vendor_assessments, ai_approvals)

  • vendors, vendor_assessments — added is_tenant_member(auth.uid(), tenant_id) to the write path on both. Same is_active-bypass gap class as batches 1–3 (confirmed by deliberate old-policy read before normalisation). Tech-debt #7 scope extended; gap class now closed on ten tables.
  • ai_approvals — added is_tenant_member(...) to both SELECT and write paths. Write predicate uses has_any_role(...) directly (not can_write(...)), gated on owner / admin / risk_manager / compliance_officer; behaviour unchanged for active members.
  • New finding (Incident #3 below): ai_approvals carries an approver_id column that is not referenced by RLS. Any role-bearing tenant member may write or impersonate an approval on behalf of an arbitrary approver_id. This is a within-tenant integrity gap (not cross-tenant; the harness cannot detect it) sitting in the AI model approval chain. Logged separately rather than folded into the batch diff per Change-Management standing rule. Candidate sibling table to audit in batch 5: access_request_approvals.
  • SELECT policies on vendors and vendor_assessments already correct; rewritten in place for template consistency, no semantic change.
  • Test harness — descriptors registered for all three (with prepareParents for vendor_assessmentsvendors and ai_approvalsai_models). 12 tables registered; 48/48 isolation checks pass.

1.4.0 — 2026-06-10 — Phase 2.1 tenant-isolation rollout (batch 1: notifications, tasks, evidence)

  • notifications — added is_tenant_member(auth.uid(), tenant_id) to every existing policy (SELECT, INSERT, UPDATE). No (role × action) cells removed; offboarded users now correctly stop seeing/touching their old-tenant notifications.
  • tasks — added is_tenant_member(...) to the write path. Pre-existing gap closed: the previous tsk_write policy allowed assignee_id = auth.uid() as a write predicate without any tenant-membership check, so a user removed from a tenant could still mutate tasks where they remained the listed assignee. The new policy gates both branches (can_write OR assignee) on active tenant membership. Logged as Incident #1 below and Tech-debt #7 (resolved).
  • evidence — added is_tenant_member(...) to the write path. The previous ev_write policy relied solely on can_write(...), which checks user_roles but not tenant_members.is_active; a deactivated member with a stale role grant could still write evidence. Closes the same class of gap as the tasks fix.
  • Test harness — Phase 2.3 cross-tenant isolation harness (tests/integration/tenant-isolation.ts) registered all three tables; 4/4 checks (SELECT / forged-tenant INSERT / UPDATE / DELETE) pass on each.

1.5.0 — 2026-06-10 — Phase 2.1 tenant-isolation rollout (batch 2: incidents, controls, policies)

  • incidents, controls, policies — added is_tenant_member(auth.uid(), tenant_id) to the write path on all three. Same is_active-bypass gap class as batch 1's evidence fix was confirmed on all three by deliberate old-policy read: each *_write policy gated solely on can_write(...), which queries user_roles but not tenant_members.is_active. A user with a stale role grant on a deactivated membership could write incidents, controls, or policies. Now blocked. Recorded as Incident #2 below; Tech-debt #7 scope extended to enumerate all five tables touched by this gap class (tasks, evidence, incidents, controls, policies).
  • SELECT policies on all three were already correct (is_tenant_member(...) AND deleted_at IS NULL); rewritten in place for template consistency, no semantic change.
  • Test harness — descriptors registered for all three; 4/4 checks pass on each.

1.3.0 — 2026-06-08 — Unified enterprise PDF engine

  • Registered Inter (400/500/600/700 + italic) as the default PDF body font via @fontsource/inter (pinned 5.0.16) served from jsDelivr; falls back to Helvetica if registration throws.
  • Migrated the Fairness Transparency Report (QuickActionToolbar.tsx) off jspdf to the shared @react-pdf/renderer pipeline. New document lives at src/lib/pdf/TransparencyPdf.tsx and reuses pdfTheme + ensureFontsRegistered.
  • Removed jspdf and html2canvas from package.json — the application now has a single PDF engine.
  • Every PDF the product emits is now selectable, searchable, copy-pasteable, and audit-ready.

1.2.0 — 2026-06-08 — Enterprise Project Manual

  • Replaced rasterised PDF export (html2canvas + jspdf) with @react-pdf/renderer — text is now selectable, searchable, and copyable.
  • Added cover page, revision history, table of contents with internal links, page numbering, fixed headers/footers, and reader bookmarks for every chapter.
  • Reorganised the manual into 12 enterprise sections (Executive, Architecture, Guides, Security & Identity, Reference, Operations, Governance).
  • Automatic chapter discovery — drop a .md file into src/content/manual/chapters/, it appears in nav, on-screen ToC, and the PDF. No registration file.
  • Added docs/CHECKLIST.md Definition of Done.
  • Expanded src/content/manual/README.md with the frontmatter contract.

1.1.0 — 2026-06-08 — Authentication hardening wired into auth flow

  • auth.tsx now calls login-rate-limit pre- and post-sign-in.
  • app.tsx beforeLoad enforces mandatory AAL2 (un-enrolled users → /mfa-setup enrol; aal1 → step-up).
  • <InactivityGuard /> mounted inside the authenticated layout.
  • Sidebar links to /app/audit-log-viewer.

1.0.0 — 2026-06-08 — Initial documentation system

  • Added /docs route with sidebar + chapter pages.
  • Seeded 8 chapters and a client-side PDF export.
  • Introduced the standing "documentation is a first-class deliverable" rule.

Architectural Decision Records (ADRs)

ADR-001 — TanStack Start over Next.js

Status: Accepted. Context: Need typed file-based routing, SSR on Workers, server functions as typed RPC. Decision: Use TanStack Start v1. Consequences: No src/pages/; no Express middleware; learn createServerFn.

ADR-002 — RBAC via separate user_roles table + SECURITY DEFINER function

Status: Accepted. Context: Storing roles on profile rows enables privilege escalation; recursive RLS is a trap. Decision: Dedicated table; has_role() SECURITY DEFINER. Consequences: All policies use has_role; never literal role strings.

ADR-003 — Mandatory AAL2 on /app/*

Status: Accepted. Context: Tier-1 institutions require MFA. Decision: Force MFA enrolment + step-up via app.tsx beforeLoad. Consequences: Google OAuth users on first visit are bounced to /mfa-setup.

ADR-004 — IP-based rate limiting in an edge function (not in a server fn)

Status: Accepted. Context: Need to count attempts even for failed/anonymous sign-ins; need IP visibility. Decision: Dedicated edge function with service-role write to sec_login_attempts. Consequences: One extra round-trip on /auth; clear separation of concerns.

ADR-005 — @react-pdf/renderer for PDF, not server-side puppeteer

Status: Accepted. Context: Worker runtime cannot run puppeteer / sharp. Decision: Render PDF in the browser via @react-pdf/renderer. Consequences: Larger client bundle behind dynamic import; no server-signed PDFs (revisit if regulators require).

ADR-006 — Automatic chapter discovery via import.meta.glob

Status: Accepted. Context: Hand-maintained _index.ts forces double-edits. Decision: Glob chapters/*.md eagerly at build, sort by frontmatter order. Consequences: Frontmatter is now contract; missing frontmatter degrades gracefully but should never ship.

Technical debt register

#ItemSeverityOwnerNext step
1No automated end-to-end (Playwright) suiteMedPlatformScaffold auth + MFA + access-gov flows
2Coverage metrics not enforced in CIMedPlatformAdd Vitest coverage gate after item 1
3Old jspdf / html2canvas still in package.jsonPlatformResolved in 1.3.0 — single PDF engine (@react-pdf/renderer)
4Manual is bundled at build time (no live edit)LowPlatformAcceptable — keeps diff-reviewable. Revisit if non-engineers must author
5ASCII diagrams instead of SVGLowDesignMermaid / SVG render once a renderer is added
6No search across docsLowPlatformAdd pagefind once content stabilises
7Write policies on tasks, evidence, incidents, controls, policies, risks, risk_treatments, ai_models, vendors, vendor_assessments, ai_approvals, access_request_approvals, access_requests, ai_impact_assessments, ai_risk_assessments, control_mappings did not require tenant_members.is_active — stale role grants on a deactivated membership could still writeSecurityResolved in 1.4.0–1.10.0 — every affected policy now gates on is_tenant_member(...). See Incidents #1, #2, #4.
8ai_approvals.approver_id and access_request_approvals.approver_id are not enforced by RLS — any role-bearing tenant member can write or impersonate approvals attributed to an arbitrary approver_idMediumSecurityOpen — deferred as product-policy decision (delegated approval vs strict approver_id = auth.uid()). Tracked as Incident #3.
9Pre-fix audit_logs integrity audit + ongoing recurring control (Incident #5 follow-up)SecurityResolved in 1.14.1. RPC public.audit_logs_integrity_summary() + endpoint /api/public/hooks/audit-logs-integrity-check shipped in 1.14.0; pg_cron schedule audit-logs-integrity-check-weekly (Sundays 03:17 UTC) + alerting hook (suspect runs emit a second SYSTEM audit_logs row with table_name='audit_logs_integrity_alert', severity='critical') wired in 1.14.1. Operations runbook narrative (escalation procedure, cadence rationale) tracked separately as a Phase 7 doc-pass item — not a control gap.
10No CI lint to block (a) WITH CHECK (true) on public-schema policies and (b) UPDATE/ALL policies with USING but no matching WITH CHECKSecurityResolved in 1.14.0 (Phase 6). scripts/lint-rls-policies.ts + rls-lint CI job; scope-correct (public-schema only, skips service_role-only admin policies); baseline of 4 historical/intentional cases frozen with explicit reasons surfaced in CI log. Any new violation fails CI.
11Six global reference tables (frameworks, compliance_requirements, permission_catalog, role_hierarchy, role_permissions, sod_rules) had no Data API GRANTs — neither authenticated SELECT nor service_role write worked through PostgRESTSecurityResolved in 1.10.0 — migrations 20260611074908 (GRANT SELECT TO authenticated) and 20260611075013 (explicit FOR ALL TO service_role policies).
12Cross-tenant FK leak surface on 12 child→parent FKs (headline: user_roles.access_request_id; full list in 1.11.0 release notes). No existing trigger or helper enforced same-tenant.SecurityResolved in 1.13.0enforce_same_tenant() generic trigger function attached BEFORE INSERT OR UPDATE OF <fk>, tenant_id on all 12 tables in migration 20260611082547. See Incident #6 for the headline user_roles case.
13Harness --all run trips GoTrue per-IP rate limit on the back half of the table set (test-tooling only; no RLS implication)PlatformResolved in 1.13.0 — 1.2s pre-call sleep before each signInWithPassword in tests/integration/tenant-isolation.ts setupFixture. Full --all now exits 0 (32 / 32).

Incidents

Incident #1 — 2026-06-10 — Tenant-membership not enforced on tasks/evidence write paths

  • Severity: Low (latent; no exploitation evidence, no in-app UI that triggers the offboarded-user path today).
  • Summary: Discovered during the Phase 2.1 RLS normalisation pass. Two write policies allowed mutations without checking tenant_members.is_active:
    • public.tasks.tsk_write — the assignee_id = auth.uid() branch was unconditional, so a user removed from a tenant could still update tasks where they remained the listed assignee.
    • public.evidence.ev_write — relied on can_write(...), which reads from user_roles only; a deactivated member whose role grant had not yet been revoked could still write evidence.
  • Root cause: Policies were written before the tenant_members.is_active flag became the canonical "is this user currently part of this tenant?" predicate, and were never revisited when is_tenant_member() was introduced.
  • Remediation: Both policies rewritten in migrations 20260610213858 and 20260610214215 to add is_tenant_member(auth.uid(), tenant_id) as the outermost predicate. The Phase 2.3 isolation harness now covers both tables and runs on every future RLS migration.
  • Evidence: migration SQL in supabase/migrations/, harness output recorded in the Phase 2.1 batch-1 review log.
  • Related ADRs: none (no architectural change; tightening of an existing predicate).

Incident #2 — 2026-06-10 — Same is_active-bypass gap on incidents/controls/policies write paths

  • Severity: Low (latent; same class as Incident #1, no exploitation evidence).
  • Summary: Deliberate old-policy read during batch 2 of the Phase 2.1 rollout confirmed the same gap class on three more governance tables: inc_write, ctrl_write, and pol_write each gated only on can_write(auth.uid(), tenant_id), which checks user_roles for the tenant but does not consult tenant_members.is_active. A user whose membership had been deactivated but whose role grant had not been revoked could still create/update/delete incidents, controls, or policies in that tenant.
  • Root cause: Same as Incident #1 — policies predate the tenant_members.is_active predicate and were not revisited when is_tenant_member() landed. Confirms the pattern is repository-wide; the remaining tables in the 2.1 rollout will be audited for the same gap class as a first-class check, not an aside.
  • Remediation: Three policies rewritten in migration 20260610214644 to add is_tenant_member(auth.uid(), tenant_id) as the outermost predicate on both USING and CHECK clauses. Phase 2.3 isolation harness now covers all three tables; 4/4 checks pass on each.
  • Evidence: migration SQL in supabase/migrations/20260610214644*.sql; harness output in the Phase 2.1 batch-2 review log.
  • Related ADRs: none.
  • Update (1.6.0, 2026-06-10): Batch 3 confirmed the same gap on risks, risk_treatments, and ai_models; all three remediated in migration 20260610215315. The eight-table affected set (tasks, evidence, incidents, controls, policies, risks, risk_treatments, ai_models) covers every tenant-scoped governance table audited so far that used a can_write-only write predicate. Continuing to flag any new instances in subsequent batches.
  • Update (1.7.0, 2026-06-10): Batch 4 confirmed the same gap on vendors, vendor_assessments (both can_write-only) and ai_approvals (has_any_role-only). All three remediated in migration 20260610215917. The affected set now covers eleven tables. The remaining tenant-scoped tables to audit (~23) will be batched in roughly the same shape.
  • Update (1.8.0, 2026-06-10): Batch 5 confirmed the same gap on access_request_approvals (is_admin-only) and access_requests (update path, is_admin-only). Both remediated in migration 20260610220655. Positive finding in the same batch: access_grants is already correctly gated on is_tenant_member(...) across all three policies — the first table audited where the gap class was absent. The affected set now covers thirteen tables.

Incident #3 — 2026-06-10 — Approval-chain approver_id columns not enforced by RLS (within-tenant integrity)

  • Severity: Medium (latent; within-tenant integrity of two approval chains — AI model approvals (EU AI Act Article 14 human-oversight attribution) and access-request approvals (SoX-style segregation-of-duties audit trail). Attribution accuracy matters in both.).
  • Summary: Two approval tables carry an approver_id uuid column intended to record which user issued the decision:
    • public.ai_approvalsaia_write permits write to any tenant member holding owner / admin / risk_manager / compliance_officer. approver_id is not constrained.
    • public.access_request_approvalsara write permits write to any tenant admin. approver_id is not constrained. In both cases a privileged tenant member may create an approval row attributing the decision to a different user, or modify an existing approver_id post-hoc.
  • Detection gap: The cross-tenant isolation harness (Phase 2.3) cannot surface this — it tests tenant boundary, not within-tenant column-level integrity. Both findings came from manual policy inspection per the standing "deliberate old-policy read" step. The access_request_approvals finding (batch 5) confirms the suspicion logged at the time of the ai_approvals finding (batch 4) that this is a recurring shape, not a one-off.
  • Root cause: Both policies were authored assuming role-based gating was sufficient; the approver_id field was treated as application-layer attribution rather than an RLS-enforced identity claim.
  • Remediation (planned, not in 1.7.0 or 1.8.0): A single follow-up migration will add a WITH CHECK enforcing either (a) approver_id = auth.uid() on insert/update, or (b) a workflow-state branch (e.g. delegated approval where one admin records another's decision with explicit delegation flag). Decision deferred until product confirms whether delegated approval is a required flow. Tracked as Tech-debt #8.
  • Sibling candidates remaining: access_grants.granted_by, dual_approved_by, revoked_by — three more attribution columns on the access-grant chain that are not enforced by RLS at the table level. Currently mitigated by all production writes going through insert_grant_with_audit() SECURITY DEFINER, which checks the caller. If direct table writes become possible (e.g. a future server fn that bypasses the helper), the same gap reappears. Worth a small ADR on "RLS-enforced attribution columns" to make the rule explicit.
  • Evidence: policy diffs in migrations 20260610215917 and 20260610220655; audit notes in the Phase 2.1 batch-4 and batch-5 review logs.
  • Related ADRs: none yet; ADR candidate flagged above.

Incident #4 — 2026-06-10 — access_requests read branch leaked old requests to deactivated members

  • Severity: Low (latent; small information-disclosure surface — a deactivated user could SELECT their own previously-submitted access requests for a tenant they no longer belong to. No write impact, no other tenants' data exposed.).
  • Summary: The ar read policy on public.access_requests granted SELECT when is_admin(...) OR requester_id = auth.uid() OR subject_user_id = auth.uid(). The requester / subject branches had no is_tenant_member(...) check — analogous in shape to the tasks.assignee_id write branch closed in Incident #1, but on the SELECT side. A user removed from a tenant via tenant_members.is_active = false could still read access requests where they remained the listed requester or subject (typically their own previously-submitted requests).
  • Root cause: Same as Incident #1 — predicate predates the canonical is_tenant_member() helper, and the requester/subject branches were treated as self-service exemptions that didn't need a separate membership check.
  • Remediation: ar read rewritten in migration 20260610220655 to wrap the existing OR-branch in an outer is_tenant_member(auth.uid(), tenant_id) AND (...). Behaviour unchanged for active members.
  • Evidence: policy diff in migration 20260610220655; harness output in the Phase 2.1 batch-5 review log.
  • Related ADRs: none.

Incident #5 — 2026-06-10 — 🚨 HEADLINE FINDING — Cross-tenant audit-row forgery on public.audit_logs (default-permissive INSERT policy)

  • Severity: High / Critical — this is the most serious finding of the Phase 2.1 rollout to date and is in a different severity class from Incidents #1–#4. The previous gaps were latent stale-membership leaks; this one was a live, exploitable cross-tenant write path on the platform's own system-of-record audit table — the substrate every evidence pack, regulator export, and forensic timeline is built on. Any signed-in user of any tenant could inject forged entries into any other tenant's audit stream.
  • Why this matters (regulator-facing impact): audit_logs is the canonical write-side audit table; downstream evidence packs (ISO 27001 A.8.15, DORA Art. 17, FCA SYSC 9.1.1R) and regulator exports read from it. Any tenant which was live before this fix could in principle have had forged audit entries written into their evidence packs by an attacker holding a valid account in any other tenant on the same instance. Until a backfill integrity audit is performed against existing audit_logs rows, the pre-fix audit trail for live tenants must be treated as having non-zero integrity uncertainty. This is the exact failure mode the platform exists to make impossible.
  • Summary: The audit_insert policy on public.audit_logs was literally WITH CHECK (true). Any role granted to authenticated could:
    • Cross-tenant forgery: insert an audit row carrying any other tenant's tenant_id, which would then appear in that tenant's useAuditExplorer view, evidence packs, and regulator exports.
    • Actor impersonation: set actor_id to any user id, falsely attributing actions to other users (including internal staff or auditors).
  • Severity class — distinct from #1–#4:
    • Incidents #1, #2, #4 → stale-membership read/write on tenant-scoped feature tables. Mitigated in practice by tight role lifecycle. No cross-tenant impact.
    • Incident #3 → within-tenant attribution integrity on approval chains. Privileged-actor-only; no cross-tenant impact.
    • Incident #5 → cross-tenant integrity of the audit substrate itself. Any authenticated user, any tenant boundary, the table that everything else trusts. Treated as the headline finding of the 2.1 effort.
  • Root cause: The original audit_insert policy was written to be wide-open intentionally so the tg_audit_row() trigger could insert audit rows from any DML context. The trigger is SECURITY DEFINER owned by postgres (BYPASSRLS) and does not actually need the policy to be permissive — but that was not recognised at the time. The permissive policy was left in place and never revisited.
  • Remediation (in 1.9.0): audit_insert rewritten in migration 20260610221659 to:
    WITH CHECK (
      ((tenant_id IS NULL) OR public.is_tenant_member(auth.uid(), tenant_id))
      AND ((actor_id IS NULL) OR (actor_id = auth.uid()))
    )
    
    Trigger-driven inserts are unaffected (BYPASSRLS). NULL-branch escape hatches preserve system-event writes (cron jobs, server-fn handlers writing without a session). Cross-tenant forgery and actor impersonation are both blocked.
  • Blast-radius scan (run separately as a standalone item before batch 7): Audited every policy in public.* for WITH CHECK (true), USING (true), and missing INSERT policies. Result: no other tenant-scoped table has the same default-permissive pattern. The qual = true hits are all on intentional global read-only reference tables with no tenant_id column (frameworks, compliance_requirements, permission_catalog, role_hierarchy, role_permissions, sod_rules); demo_leads has an intentional anon-INSERT policy (public marketing form, not tenant-scoped); sec_login_attempts has RLS enabled with zero policies (default deny — writes are service-role only via the rate-limit edge function). audit_logs was the only instance.
  • Recommended follow-ups (not in 1.9.0):
    1. Integrity backfill audit on existing audit_logs rows for any live tenant predating the fix — look for actor_id values inconsistent with the tenant's tenant_members set, or tenant_id/actor_id pairings that the trigger code path could not have produced. If anything anomalous is found, customer notification per data-protection contracts is in scope.
    2. ADR-007 (proposed): "Audit-substrate tables (audit_logs, access_audit_log, audit_vault) must enforce both tenant_id membership and actor_id = auth.uid() at the RLS layer regardless of trigger-side enforcement, because triggers can be dropped/replaced but RLS travels with the table."
    3. CI lint to fail any future migration that creates a public-schema policy with literal WITH CHECK (true) unless explicitly annotated with a justification comment.
  • Evidence: policy diff in migration 20260610221659; blast-radius scan recorded in this entry; harness output in the Phase 2.1 batch-6 review log (18 tables / 72 checks green).
  • Related ADRs: ADR-007 candidate flagged above.

Incident #6 — 2026-06-11 — Cross-tenant FK on user_roles.access_request_id (fabricated access-governance audit evidence)

  • Severity: Medium–High (within-tenant write, but the artefact is regulator-facing governance evidence). Below Incident #5 in blast radius (requires a role-bearing tenant member, not any authenticated user) and above Incident #3 in impact (the fabricated artefact is a role grant, not an approval row).
  • Summary: public.user_roles.access_request_id is the audit pointer that ties a role grant back to the access request that justified it. The column has a foreign-key constraint to public.access_requests(id) but no constraint that the referenced request belongs to the same tenant as the role grant. A role-bearing tenant-A admin could create a user_roles row in tenant A whose access_request_id points at an access_requests.id from tenant B (assuming they discovered the id), producing an audit artefact that says "this role was granted because of access request X in tenant B" — a request the granting tenant never raised and the receiving tenant never approved. The same shape applies to all 11 other tenant-scoped child→parent FKs (Tech-debt #12), but the user_roles case is the one with the most direct governance impact and is recorded here separately.
  • Detection gap: Not detectable by the cross-tenant RLS isolation harness (Phase 2.3) — RLS sees a tenant-A row with a tenant-A tenant_id and a valid (by FK existence) access_request_id; the cross-tenant violation is in the parent's tenant_id, which RLS does not inspect. Surfaced by manual schema-shape review during batch 8 scoping.
  • Root cause: Postgres FKs validate referential integrity (does the row exist?) but not tenant integrity (does it belong to the same tenant?). The platform did not have a generic same-tenant validation helper anywhere in the schema. Same root cause across all 12 tables in scope.
  • Remediation (1.13.0): Single generic trigger function public.enforce_same_tenant(parent_table, child_fk_column) added in migration 20260611082547, attached as BEFORE INSERT OR UPDATE OF access_request_id, tenant_id on public.user_roles and on the other 11 affected tables. The trigger raises ERRCODE = 'check_violation' with a cross-tenant FK violation message if the parent's tenant_id differs from NEW.tenant_id. Tenant moves on the child (UPDATE of tenant_id) re-validate against the parent. NULL FKs are explicitly no-op.
  • Why a trigger and not a CHECK constraint: Postgres CHECK constraints must be immutable; resolving the parent's tenant_id requires a subquery against another table, which CHECK does not permit. Trigger is the only correct primitive.
  • Sibling cases closed in the same migration (Tech-debt #12): access_request_approvals.request_id, ai_approvals.model_id, ai_impact_assessments.model_id, ai_risk_assessments.model_id, control_mappings.control_id, evidence.control_id, group_members.group_id, group_roles.group_id, resources.workspace_id, risk_treatments.risk_id, vendor_assessments.vendor_id.
  • Evidence: migration 20260611082547; full harness --all run (32 / 32 green, exit 0) recorded in Phase 2.1 batch-10 review log.
  • Related ADRs: ADR-008 candidate (proposed): "Tenant-scoped child→parent FKs must enforce same-tenant via enforce_same_tenant() trigger; the generic Postgres FK is necessary but not sufficient."

When recording an incident, include: date, severity, summary, root cause, remediation, evidence link. Cross-reference any related ADRs.