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 (migration20260611083942) — runs the same 4 forensic checks asscripts/audit-logs-integrity-check.tsbut inside Postgres so the Cloudflare Worker can call it. Worker endpoint/api/public/hooks/audit-logs-integrity-checkshipped (validatesapikey, calls the RPC, persists a SYSTEM row intoaudit_logsfor 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.tsscans every committed migration undersupabase/migrations/for the two anti-patterns the 2.1 sweep surfaced:WITH CHECK (true)onpublic.*policies (Incident #5 root cause) andUPDATE/ALLpolicies with aUSINGclause but no matchingWITH CHECK(the batch-9 sub-finding shape). Scope-correct: skipsstorage.*/ non-public.*policies and skipsTO 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: historicalaudit_insert(Incident #5, superseded), intentionaldemo_leadspublic lead capture (INSERT-only, reviewed), and the two historicalar_update/gr_updatecases (Incident #5 sub-finding, superseded). Any NEW violation that doesn't appear in the baseline (or carry an inlinelint:allow-*marker) fails CI. Newbun run lint:rlsscript and a dedicatedrls-lintjob 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_idvs claimedapprover_id; cross-tenant forgery ofapprover_idalready blocked by Incident #6'senforce_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 migration20260611082547. Single genericSECURITY DEFINERplpgsql function taking(parent_table, child_fk_column)as trigger args; resolves the parent'stenant_idviaEXECUTE format(...)and raisesERRCODE = 'check_violation'if the parent's tenant differs from the child'sNEW.tenant_id. No-ops when the FK column isNULL(onlyevidence.control_idis nullable in this set).- Attached as
BEFORE INSERT OR UPDATE OF <fk_col>, tenant_idon 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 ontenant_idupdates as well as on the FK column closes the "move-the-child-to-the-other-tenant" variant (same shape as theaccess_reviews/gdpr_requestsUPDATE-without-WITH CHECKfinding 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()andtg_touch_row()continue to fire on the same tables without interaction (differentTG_OP/BEFOREordering; no shared state). - Harness — composite-PK seeding shipped, throttled signIn shipped,
group_membersnow covered. Runner changes:TableDescriptor.pkColumnsoptional field (defaults to["id"]) plumbed throughsetupFixtureseed, SELECT, UPDATE, and DELETE phases so composite-PK tables no longer fail withcolumn ... id does not exist. 1.2s pre-call sleep before eachsignInWithPasswordkeeps the--allrun comfortably under the GoTrue per-IP rate limit (Tech-debt #13 closed). Forged-INSERT rejection regex extended to acceptcross-tenant— the new trigger now fires before RLSWITH CHECKon 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 --allexit 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.tsremains queued as an Operations-chapter follow-up.
Phase 2.1 rollup (batches 1–10, closed across 1.4.0 → 1.13.0)
| Class | Outcome |
|---|---|
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_links | Closed (batch 8). Sub-shape of Incident #2. |
USING without WITH CHECK on UPDATE (access_reviews, gdpr_requests) — privileged member could mutate tenant_id | Closed (batch 9). Sub-finding of Incident #5 (same shape, narrower blast radius). |
Cross-tenant audit-row forgery via WITH CHECK (true) on audit_logs | Closed (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 GRANTs | Closed (batch 7). Tech-debt #11. |
access_requests SELECT leak to deactivated requester/subject | Closed (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 recurrence | Open (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 onis_tenant_member(...)(chatbot tables additionally pin the owner viaauth.uid() = user_id). Two minor gaps fixed in migration20260611081109:access_reviews.ar_updatehadUSING (is_tenant_member AND has_any_role([...auditor-or-better]))but noWITH CHECK. An authorised tenant member could in principle mutatetenant_idduring an UPDATE, moving an access-review row to another tenant they belong to. NowUSINGandWITH CHECKmirror each other.gdpr_requests.gr_updatehad the same shape (USINGonly, noWITH CHECK, scoped toowner / admin / compliance_officer). Same fix.
- Why this matters more than it looks:
access_reviewsandgdpr_requestsare both regulator-facing evidence sources (SOC2 / DORA periodic reviews and GDPR subject-rights workflow). The missingWITH CHECKis 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 theWITH 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 withUSINGbut noWITH CHECK" in addition to theWITH CHECK (true)regex. chatbot_sessionsandchatbot_audit_log— no changes needed. Both already pinauth.uid() = user_id AND is_tenant_member(...)on every applicable axis;chatbot_audit_logis 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.statusconstraint ispending / in_progress / completed / rejected, notreceived). 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 requireis_tenant_member(auth.uid(), tenant_id)in addition to the existing role predicate (is_admin(...)for governance tables,can_write(...)forresources/workspaces). Sameis_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_linksshape note: the old policy set was owner-only (created_by = auth.uid()) with notenant_idpredicate 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 checkedcreated_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 broaderis_active-bypass class) — same root cause, but the missing predicate was tenant-membership entirely, not just theis_activeaxis.security_policiesglobal-row handling: this table allowstenant_id IS NULLfor platform-wide policy rows (read by every authenticated user). Preserved that read branch; write branch now blockstenant_id IS NULLoutright in bothUSINGandWITH 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 closedaudit_logscase; 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 sharedenforce_same_tenant(parent regclass, parent_id uuid, tenant_id uuid)trigger function attachedBEFORE INSERT OR UPDATEto 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_membersdescriptor deferred: the table has a composite PK(group_id, user_id)and noidcolumn, which the current harness'sselect("id")seed path does not support. RLS ongroup_memberswas 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.permissionconstraint requiresVIEW_ONLY/VIEW_COMMENT/FULL_REVIEW(notread), andworkspaces.slugis NOT NULL (sampleRow now seeds it). The full--allsweep currently trips the Supabase authRequest rate limit reachedon 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 tois_tenant_member(auth.uid(), tenant_id) AND can_write(auth.uid(), tenant_id)in migration20260611074635. Sameis_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 theis_tenant_member(...)outer wrap, notWITH CHECK (true). - Cross-tenant FK leak (recorded, not remediated this batch):
control_mappings.control_idandcompliance_requirements/frameworkFK 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 acontrol_mappingsrow tagged with tenant-A'stenant_idbutcontrol_idpointing 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_mappingsparents the harness hitpermission deniedonframeworks/compliance_requirementsvia 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 — neitherauthenticatedSELECT norservice_rolewrite. End-user reads only worked from contexts that read the tables via SECURITY DEFINER functions or psql; any directsupabase.from('frameworks').select(...)from the app would fail. Fixed in migrations20260611074908and20260611075013(GRANT SELECT TO authenticated; explicitFOR ALL TO service_role USING (true) WITH CHECK (true)policies). Note:service_roleisBYPASSRLSat the role level, but PostgREST'sSET ROLE service_rolepath 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()'ssignInWithPasswordcall) is real and observable. AftersetupFixture()mints user A's session via the shared admin client, any lateradmin.from(...).insert(...)call (notably theparentsAseed 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_mappingswas the first case where parent seeding required RLS bypass. Fixed by minting a freshadminClient()forparentsA. 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 wasparentsA, and in earlier batches that call coincidentally worked. The harness-noise rows analysed byscripts/audit-logs-integrity-check.ts(theiso-*tenants andiso_proberows) 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
prepareParentsfor all three:ai_impact_assessments→ai_models,ai_risk_assessments→ai_models,control_mappings→controls+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_logs— Incident #5 closed. The previousaudit_insertpolicy wasWITH CHECK (true): any authenticated user couldINSERTan audit row tagged with anytenant_idand anyactor_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 fromtg_audit_row()and other system contexts;tg_audit_row()isSECURITY DEFINERowned bypostgres(BYPASSRLS) so trigger-driven inserts are unaffected. SELECT policy rewritten in place (semantics unchanged).access_audit_log— already correctly gated on both axes: SELECT requiresis_tenant_member(...), INSERT requiresis_tenant_member(...) AND actor_user_id = auth.uid(). Rewritten in place for template consistency, no semantic change. Positive control: second table in the rollout (afteraccess_grants) found without any gap class.audit_vault— already correctly gated: INSERT requiresis_tenant_member(...)(with the same NULL-tenant escape hatch for system events), SELECT restricted toowner / admin / auditor / compliance_officer. Append-only enforcement remains via the existingtg_audit_vault_immutable()trigger (UPDATE/DELETE blocked outright). Rewritten in place, no semantic change.- Hash-chain check (requested):
access_audit_login the current schema does not carryprev_hash/row_hashcolumns, and noverify_audit_chain()function exists inpublic.*. 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 foraudit_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— addedis_tenant_member(auth.uid(), tenant_id)to both SELECT and write paths. Write predicate isis_admin(...)-gated (unchanged for active members). Sameis_active-bypass gap class as previous batches.access_requests— addedis_tenant_member(...)to update USING+CHECK (same gap). Also tightened thear readpolicy: the previousis_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 thetasks.assignee_idbranch closed in batch 1 — so a user removed from a tenant could still see their own pending access requests. Now wrapped in an outeris_tenant_member(...)so deactivated members lose visibility consistently with the rest of the suite.access_grants— already correctly gated onis_tenant_member(...)across SELECT, INSERT, and the unusualag_revokeCHECK 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; theinsert_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_idis also not enforced by RLS. Same shape asai_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
prepareParentsforaccess_request_approvals→access_requests; theaccess_grantsdescriptor usesrevoked_atas its update column to avoid theag_revokeCHECK 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— addedis_tenant_member(auth.uid(), tenant_id)to the write path on both. Sameis_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— addedis_tenant_member(...)to both SELECT and write paths. Write predicate useshas_any_role(...)directly (notcan_write(...)), gated onowner / admin / risk_manager / compliance_officer; behaviour unchanged for active members.- New finding (Incident #3 below):
ai_approvalscarries anapprover_idcolumn that is not referenced by RLS. Any role-bearing tenant member may write or impersonate an approval on behalf of an arbitraryapprover_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
vendorsandvendor_assessmentsalready correct; rewritten in place for template consistency, no semantic change. - Test harness — descriptors registered for all three (with
prepareParentsforvendor_assessments→vendorsandai_approvals→ai_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— addedis_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— addedis_tenant_member(...)to the write path. Pre-existing gap closed: the previoustsk_writepolicy allowedassignee_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_writeOR assignee) on active tenant membership. Logged as Incident #1 below and Tech-debt #7 (resolved).evidence— addedis_tenant_member(...)to the write path. The previousev_writepolicy relied solely oncan_write(...), which checksuser_rolesbut nottenant_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— addedis_tenant_member(auth.uid(), tenant_id)to the write path on all three. Sameis_active-bypass gap class as batch 1's evidence fix was confirmed on all three by deliberate old-policy read: each*_writepolicy gated solely oncan_write(...), which queriesuser_rolesbut nottenant_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(pinned5.0.16) served from jsDelivr; falls back to Helvetica if registration throws. - Migrated the Fairness Transparency Report (
QuickActionToolbar.tsx) offjspdfto the shared@react-pdf/rendererpipeline. New document lives atsrc/lib/pdf/TransparencyPdf.tsxand reusespdfTheme+ensureFontsRegistered. - Removed
jspdfandhtml2canvasfrompackage.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
.mdfile intosrc/content/manual/chapters/, it appears in nav, on-screen ToC, and the PDF. No registration file. - Added
docs/CHECKLIST.mdDefinition of Done. - Expanded
src/content/manual/README.mdwith the frontmatter contract.
1.1.0 — 2026-06-08 — Authentication hardening wired into auth flow
auth.tsxnow callslogin-rate-limitpre- and post-sign-in.app.tsxbeforeLoadenforces mandatory AAL2 (un-enrolled users →/mfa-setupenrol; 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
/docsroute 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
| # | Item | Severity | Owner | Next step |
|---|---|---|---|---|
| 1 | No automated end-to-end (Playwright) suite | Med | Platform | Scaffold auth + MFA + access-gov flows |
| 2 | Coverage metrics not enforced in CI | Med | Platform | Add Vitest coverage gate after item 1 |
| 3 | jspdf / html2canvas still in package.json | — | Platform | Resolved in 1.3.0 — single PDF engine (@react-pdf/renderer) |
| 4 | Manual is bundled at build time (no live edit) | Low | Platform | Acceptable — keeps diff-reviewable. Revisit if non-engineers must author |
| 5 | ASCII diagrams instead of SVG | Low | Design | Mermaid / SVG render once a renderer is added |
| 6 | No search across docs | Low | Platform | Add pagefind once content stabilises |
| 7 | 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 write | — | Security | Resolved in 1.4.0–1.10.0 — every affected policy now gates on is_tenant_member(...). See Incidents #1, #2, #4. |
| 8 | ai_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_id | Medium | Security | Open — deferred as product-policy decision (delegated approval vs strict approver_id = auth.uid()). Tracked as Incident #3. |
| 9 | audit_logs integrity audit + ongoing recurring control (Incident #5 follow-up) | — | Security | Resolved 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. |
| 10 | WITH CHECK (true) on public-schema policies and (b) UPDATE/ALL policies with USING but no matching WITH CHECK | — | Security | Resolved 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. |
| 11 | 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 PostgREST | — | Security | Resolved in 1.10.0 — migrations 20260611074908 (GRANT SELECT TO authenticated) and 20260611075013 (explicit FOR ALL TO service_role policies). |
| 12 | user_roles.access_request_id; full list in 1.11.0 release notes). No existing trigger or helper enforced same-tenant. | — | Security | Resolved in 1.13.0 — enforce_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. |
| 13 | --all run trips GoTrue per-IP rate limit on the back half of the table set (test-tooling only; no RLS implication) | — | Platform | Resolved 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— theassignee_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 oncan_write(...), which reads fromuser_rolesonly; 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_activeflag became the canonical "is this user currently part of this tenant?" predicate, and were never revisited whenis_tenant_member()was introduced. - Remediation: Both policies rewritten in migrations
20260610213858and20260610214215to addis_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, andpol_writeeach gated only oncan_write(auth.uid(), tenant_id), which checksuser_rolesfor the tenant but does not consulttenant_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_activepredicate and were not revisited whenis_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
20260610214644to addis_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, andai_models; all three remediated in migration20260610215315. 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 acan_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(bothcan_write-only) andai_approvals(has_any_role-only). All three remediated in migration20260610215917. 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) andaccess_requests(update path,is_admin-only). Both remediated in migration20260610220655. Positive finding in the same batch:access_grantsis already correctly gated onis_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 uuidcolumn intended to record which user issued the decision:public.ai_approvals—aia_writepermits write to any tenant member holdingowner / admin / risk_manager / compliance_officer.approver_idis not constrained.public.access_request_approvals—ara writepermits write to any tenant admin.approver_idis not constrained. In both cases a privileged tenant member may create an approval row attributing the decision to a different user, or modify an existingapprover_idpost-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_approvalsfinding (batch 5) confirms the suspicion logged at the time of theai_approvalsfinding (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_idfield 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 throughinsert_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
20260610215917and20260610220655; 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 readpolicy onpublic.access_requestsgranted SELECT whenis_admin(...) OR requester_id = auth.uid() OR subject_user_id = auth.uid(). The requester / subject branches had nois_tenant_member(...)check — analogous in shape to thetasks.assignee_idwrite branch closed in Incident #1, but on the SELECT side. A user removed from a tenant viatenant_members.is_active = falsecould 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 readrewritten in migration20260610220655to wrap the existing OR-branch in an outeris_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_logsis 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 existingaudit_logsrows, 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_insertpolicy onpublic.audit_logswas literallyWITH CHECK (true). Any role granted toauthenticatedcould:- Cross-tenant forgery: insert an audit row carrying any other tenant's
tenant_id, which would then appear in that tenant'suseAuditExplorerview, evidence packs, and regulator exports. - Actor impersonation: set
actor_idto any user id, falsely attributing actions to other users (including internal staff or auditors).
- Cross-tenant forgery: insert an audit row carrying any other tenant's
- 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_insertpolicy was written to be wide-open intentionally so thetg_audit_row()trigger could insert audit rows from any DML context. The trigger isSECURITY DEFINERowned bypostgres(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_insertrewritten in migration20260610221659to:
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.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())) ) - Blast-radius scan (run separately as a standalone item before batch 7): Audited every policy in
public.*forWITH CHECK (true),USING (true), and missing INSERT policies. Result: no other tenant-scoped table has the same default-permissive pattern. Thequal = truehits are all on intentional global read-only reference tables with notenant_idcolumn (frameworks,compliance_requirements,permission_catalog,role_hierarchy,role_permissions,sod_rules);demo_leadshas an intentional anon-INSERT policy (public marketing form, not tenant-scoped);sec_login_attemptshas RLS enabled with zero policies (default deny — writes are service-role only via the rate-limit edge function).audit_logswas the only instance. - Recommended follow-ups (not in 1.9.0):
- Integrity backfill audit on existing
audit_logsrows for any live tenant predating the fix — look foractor_idvalues inconsistent with the tenant'stenant_membersset, ortenant_id/actor_idpairings that the trigger code path could not have produced. If anything anomalous is found, customer notification per data-protection contracts is in scope. - ADR-007 (proposed): "Audit-substrate tables (
audit_logs,access_audit_log,audit_vault) must enforce bothtenant_idmembership andactor_id = auth.uid()at the RLS layer regardless of trigger-side enforcement, because triggers can be dropped/replaced but RLS travels with the table." - 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.
- Integrity backfill audit on existing
- 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_idis the audit pointer that ties a role grant back to the access request that justified it. The column has a foreign-key constraint topublic.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 auser_rolesrow in tenant A whoseaccess_request_idpoints at anaccess_requests.idfrom 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 theuser_rolescase 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_idand a valid (by FK existence)access_request_id; the cross-tenant violation is in the parent'stenant_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 migration20260611082547, attached asBEFORE INSERT OR UPDATE OF access_request_id, tenant_idonpublic.user_rolesand on the other 11 affected tables. The trigger raisesERRCODE = 'check_violation'with across-tenant FK violationmessage if the parent'stenant_iddiffers fromNEW.tenant_id. Tenant moves on the child (UPDATE oftenant_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_idrequires 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--allrun (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.