Tech-debt #8 — Decision Brief: approver_id Enforcement on Approval Tables
Status: Closed — Option A+ approved and shipped (v1.16.3, 2026-06-11). See §9 (Closure record) at the end of this chapter. Migration applied:
WITH CHECK (approver_id IS NULL OR approver_id = auth.uid())onai_approvalsandaccess_request_approvalsINSERT/UPDATE policies. Harness assertions (4/4 PASS). Delegation-during-leave handled via existingaccess_grantsmechanism (ISO/IEC 27001 A.5.18) — no new attribution infrastructure built. Brief retained as the basis-of-decision record.Audience: Head of Product, Head of Compliance, CISO. Time-to-decision target: this week. Engineering effort once decided: ≤ 1 day for either option.
TL;DR: Two approval tables (
ai_approvals,access_request_approvals) currently let any tenant member with the right role record a decision on behalf of any other approver in the tenant by settingapprover_idto an arbitrary user id. This is a segregation-of-duties (SoD) gap, not a confidentiality gap — the data stays inside the tenant, but the attribution can be falsified. Whether the platform should enforce strict self-approval (approver_id = auth.uid()) or support a delegated-approval model (approver-on-behalf-of with explicit consent trail) is a business policy choice, not an engineering choice. We need that choice on the record.
1. What the gap actually is
Affected tables
| Table | Column | Current RLS posture |
|---|---|---|
public.ai_approvals | approver_id (nullable until decided) | INSERT/UPDATE allowed for can_write roles on the tenant; no policy or trigger checks that approver_id = auth.uid() on the row being written. Any can_write user can record a decision with any tenant member's id in approver_id. |
public.access_request_approvals | approver_id | Same shape: tenant-scoped write policy, no per-row binding to the caller. Sibling-shape confirmed during Phase 2.1 batch 5 and recorded under Incident #3 in chapter 12. |
What an attacker (insider) could do
A risk_manager in tenant T could insert a row into ai_approvals recording that the Compliance Officer (another user in tenant T) approved a high-risk AI system, when in fact the Compliance Officer never logged in. The row would pass every existing control: it carries the correct tenant_id, the writer holds a role on the can_write list, and the audit log faithfully records that the writer's auth.uid() performed the INSERT — but the business-meaningful field (approver_id) is a free-text claim the writer made.
What an attacker cannot do (already mitigated)
- Cross-tenant write: blocked by Phase 2.1 tenant-isolation policies (Incident #1–#6, all closed).
- Silent insertion with no trail: every write to both tables is captured in
audit_logsvia thetg_audit_rowtrigger with the realactor_id = auth.uid(). The forgery is detectable in audit, just not prevented at write time. - External actor: requires authenticated tenant membership + a
can_writerole.
This is the standard SoD failure mode: the control writes after the fact instead of failing the action.
2. Why this has stayed open
The straightforward fix — WITH CHECK (approver_id = auth.uid()) on both tables — is two lines of SQL and one harness assertion. It has not shipped because it forecloses a feature that some Tier-1 banks expect:
"The Head of Compliance is on annual leave. Their named delegate, the Deputy Compliance Officer, needs to record the approval on the Head's behalf, with a clear trail that this is a delegation and the Head accepted the responsibility on return."
Implementing strict self-approval breaks that workflow. Implementing delegated-approval naively (just leave the gap open) breaks SoD. Both options are real; choosing between them is a governance call.
3. The two options
Option A — Strict self-approval
Rule: INSERT and UPDATE on both tables enforce approver_id = auth.uid() at the policy layer.
Pros
- Trivial implementation: one
WITH CHECKper table, one harness assertion per table. - Maximally clear SoD posture for auditors. ISO/IEC 27001 A.5.3 maps cleanly: the actor is the approver, by construction.
- No new schema, no new UI, no new workflow.
- NIST AI RMF GOVERN-2.1 ("Roles, responsibilities, and lines of communication … are documented … and enforced") is satisfied without any further policy work.
Cons
- Breaks the delegate-during-leave workflow above. Banks that need it would have to either (a) issue a temporary role grant to the delegate (already supported via
access_grantswithexpires_at), or (b) have the original approver record the decision themselves before going on leave. - Some customer-facing process documents may already promise delegation; those would need updating.
Option B — Delegated approval with explicit consent trail
Rule: approver_id = auth.uid() is the default, but an alternative path exists: a delegate_for_user_id column plus a delegations table proving the delegate was authorised by the named approver for the named approval action within a time window. RLS enforces either approver_id = auth.uid() or a valid matching delegation row.
Pros
- Supports the real-world Tier-1 workflow without weakening SoD on paper.
- Creates a richer governance trail (
delegationstable is itself auditable evidence).
Cons
- Substantially larger build: new table, new RLS policies, new UI for both granting and revoking delegations, new audit events.
- Adds policy-design surface: who can grant a delegation? what's the maximum delegation window? are second-order delegations allowed? all of these are new questions to answer.
- Easier to mis-configure than Option A. Every customer integration becomes a small policy review.
- Engineering effort: ~1–2 weeks vs ~1 day.
Option A+ (recommended interim) — Option A now, Option B later if asked
Rule: Ship Option A today. Document the delegate-during-leave workflow as: the named approver grants a time-bounded role to the delegate via the existing access_grants mechanism (which already supports expires_at, justification, regulatory_basis); the delegate then approves under their own auth.uid(). This is still a delegation in substance, but the delegation is the role grant, not the approval row — which keeps the approval row truthful.
Why this is the recommendation
access_grantsalready exists, is already audited, and already supports time-bounded delegation. The platform does not need a second mechanism for the same purpose.- The pattern matches how regulated industries actually run leave delegations in practice (e.g. SMCR-regulated UK banks document the delegation as a role assignment, not as a posthumous attribution).
- Closes Tech-debt #8 in one day instead of one to two weeks.
- Leaves Option B open as a future enhancement if a real customer specifically asks for delegate-on-behalf-of attribution (none has, as of the date of this chapter).
4. Standards mapping for the decision
| Standard | Clause / control | What it expects | How each option addresses it |
|---|---|---|---|
| ISO/IEC 27001 | A.5.3 Segregation of duties | Conflicting duties separated to reduce opportunity for unauthorised or unintentional modification or misuse of the organisation's assets. | A: ✅ enforced at policy layer. B: ✅ enforced with richer trail, more configuration surface. A+: ✅ enforced; delegation handled via existing audited role-grant mechanism. |
| ISO/IEC 27001 | A.5.18 Access rights | Access rights to information shall be provisioned, reviewed, modified and removed. | A+ benefits here: access_grants.expires_at already supports time-bounded provisioning and is already in the access-review cycle. |
| NIST AI RMF | GOVERN-2.1 | Roles, responsibilities, and lines of communication related to mapping, measuring, and managing AI risks are documented and are clear to individuals and teams throughout the organisation. | All three options document responsibilities clearly. A is the simplest to verify; B is the most expressive; A+ leverages existing access controls. |
| NIST AI RMF | GOVERN-3.2 | Policies and procedures are in place to define and differentiate roles and responsibilities for human-AI configurations and oversight of AI systems. | A enforces; B enforces with explicit delegation semantics; A+ enforces while reusing the platform's existing time-bounded grant mechanism. |
| EU AI Act | Art.14 Human oversight | High-risk AI systems shall be designed and developed in such a way … that they can be effectively overseen by natural persons during the period in which the AI system is in use. | All three preserve oversight; A and A+ preserve it with the least ambiguity about which natural person performed the oversight act. |
| DORA | Art.5 ICT governance and control framework | Sound, comprehensive and well-documented ICT governance and control framework. | A+ avoids creating two parallel delegation mechanisms (a complexity that DORA Art.5 implicitly disfavours). |
5. Current compensating controls (in place today, regardless of the decision)
- Audit-row truth. Every write captures the real
actor_idvia thetg_audit_rowtrigger. A forgedapprover_idis detectable inaudit_logseven before any policy change. - Append-only audit substrate.
audit_logscarries no UPDATE/DELETE policy; the integrity sweep (Tech-debt #9 cron, Phase 7) periodically re-validates the corpus. - Tenant isolation. Closed under Phase 2.1 Incidents #1–6. The forgery is bounded to within-tenant impact.
- Access reviews. Quarterly
access_reviewsperschedule_quarterly_access_review()trigger surface anomalous role assignments that could enable a forgery preparation step.
None of these prevent the forgery at write time. That is the gap Option A (or A+) closes; that is the gap Option B closes with more flexibility and more configuration cost.
6. Engineering effort and rollout
| Option | Migration | Code | Harness | UX | Rollout risk |
|---|---|---|---|---|---|
| A | 1 file, ~10 lines (drop+recreate WITH CHECK on two policies) | none | 2 new assertions (approver_id forgery rejected on both tables) | none | Low — no existing data violates the rule, since every legitimate approval was written by the approver themselves. |
| A+ | Same as A. Plus a one-page runbook entry on "Delegating approval authority during leave" pointing operators at the access_grants flow. | none | same | none | Low — same as A. |
| B | 1 file, ~80 lines (new approval_delegations table, RLS, FKs, harness registration). | UI for grant + revoke + audit view (~600 lines). | approval_delegations registered; both approval tables get a more nuanced check. | New screen + role-permission decisions. | Medium — new policy surface; needs customer-facing documentation; risk of mis-configuration. |
7. Recommendation
Ship Option A+ this week. It closes the SoD gap with minimal engineering, costs nothing in flexibility that the platform doesn't already offer through access_grants, and keeps Option B as an explicit future enhancement triggered by a real customer request rather than a speculative build.
If Product or Compliance pushes back on A+ because a named Tier-1 customer has already contracted for in-row delegated-approval attribution, escalate to Option B and plan it as a one-to-two-week build with explicit policy review. Do not leave Tech-debt #8 open for another quarter without a written decision.
8. What we need from the business
A short written decision recording:
- Chosen option (A, A+, or B).
- The policy answer on delegation-during-leave (use existing
access_grantstime-bounded role, or build delegate-on-behalf-of attribution). - Acknowledgement that the current state — where any
can_writerole can record an approval on behalf of any other tenant member — is closed by the chosen option and not by some other compensating control. - Sign-off from CISO + Head of Compliance + Head of Product.
Once that decision is recorded, engineering closes Tech-debt #8 in the same week (Option A+) or schedules it as Phase 5.4 (Option B), and chapter 12 §4 (Open items) is updated to reflect closure.
9. Closure record (2026-06-11, v1.16.3)
Per project owner's review of this chapter (full text approved), the following decision was recorded:
| Item (per §8) | Decision |
|---|---|
| 1. Chosen option | Option A+. |
| 2. Delegation-during-leave policy | Use the existing access_grants time-bounded role mechanism (already audited under Phase 2.1). No new delegate-on-behalf-of attribution system is to be built. Option B remains the documented fallback should a customer specifically contract for in-row delegated attribution (per §3, 1–2 week build). |
| 3. Acknowledgement that this specific gap is closed by this control and not by any other compensating control | Acknowledged. The gap — any can_write user attributing an approval to another tenant member — is closed by the new WITH CHECK (approver_id IS NULL OR approver_id = auth.uid()) predicate on both tables' INSERT and UPDATE policies. The previously enumerated compensating controls (audit_logs.actor_id truth, append-only audit, tenant isolation, quarterly access reviews) do not prevent the forgery at write time; they only detect it after the fact. They remain in force as defence-in-depth, not as the primary control. |
| 4. Sign-off | Decision recorded by project owner — see manual revision history for date/version. |
Implementation verification
- Pre-check: queried
ai_approvalsandaccess_request_approvalsfor rows whereapprover_iddiffered from theaudit_logs.actor_idwho performed the INSERT/UPDATE. Result: 0 / 13 ai_approvals, 0 / 12 access_request_approvals had any mismatch (and in fact zero rows in either table currently have a non-nullapprover_id— all live approvals are still inin_review/pendingstate). The new constraint introduces zero retroactive violations. - Migration: dropped
aia_writeand"ara write"(the FOR ALL combined policies), recreated as three per-action policies each (INSERT, UPDATE, DELETE), preserving every other tenant/role gate exactly. The new predicate uses(approver_id IS NULL OR approver_id = auth.uid())to keep the pending-row workflow intact (a row is legitimately written withapprover_id = NULLwhen first opened for review). - Harness:
tests/integration/sod-approver-self.ts— four assertions: INSERT with spoofedapprover_idrejected on each table, UPDATE settingapprover_idto another user's id rejected on each table. Live run: 4 / 4 PASS. Postgres returnednew row violates row-level security policyon all four attempts, as expected. - Runbook: chapter 3 (Administrator Guide) gained a new section "Delegating approval authority during leave" describing the
access_grantsflow as the supported pattern, with the ISO/IEC 27001 A.5.18 citation as the basis.