Skip to main content
General

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()) on ai_approvals and access_request_approvals INSERT/UPDATE policies. Harness assertions (4/4 PASS). Delegation-during-leave handled via existing access_grants mechanism (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 setting approver_id to 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

TableColumnCurrent RLS posture
public.ai_approvalsapprover_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_approvalsapprover_idSame 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_logs via the tg_audit_row trigger with the real actor_id = auth.uid(). The forgery is detectable in audit, just not prevented at write time.
  • External actor: requires authenticated tenant membership + a can_write role.

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 CHECK per 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_grants with expires_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 (delegations table 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_grants already 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

StandardClause / controlWhat it expectsHow each option addresses it
ISO/IEC 27001A.5.3 Segregation of dutiesConflicting 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 27001A.5.18 Access rightsAccess 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 RMFGOVERN-2.1Roles, 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 RMFGOVERN-3.2Policies 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 ActArt.14 Human oversightHigh-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.
DORAArt.5 ICT governance and control frameworkSound, 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)

  1. Audit-row truth. Every write captures the real actor_id via the tg_audit_row trigger. A forged approver_id is detectable in audit_logs even before any policy change.
  2. Append-only audit substrate. audit_logs carries no UPDATE/DELETE policy; the integrity sweep (Tech-debt #9 cron, Phase 7) periodically re-validates the corpus.
  3. Tenant isolation. Closed under Phase 2.1 Incidents #1–6. The forgery is bounded to within-tenant impact.
  4. Access reviews. Quarterly access_reviews per schedule_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

OptionMigrationCodeHarnessUXRollout risk
A1 file, ~10 lines (drop+recreate WITH CHECK on two policies)none2 new assertions (approver_id forgery rejected on both tables)noneLow — 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.nonesamenoneLow — same as A.
B1 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:

  1. Chosen option (A, A+, or B).
  2. The policy answer on delegation-during-leave (use existing access_grants time-bounded role, or build delegate-on-behalf-of attribution).
  3. Acknowledgement that the current state — where any can_write role can record an approval on behalf of any other tenant member — is closed by the chosen option and not by some other compensating control.
  4. 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 optionOption A+.
2. Delegation-during-leave policyUse 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 controlAcknowledged. 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-offDecision recorded by project owner — see manual revision history for date/version.

Implementation verification

  • Pre-check: queried ai_approvals and access_request_approvals for rows where approver_id differed from the audit_logs.actor_id who 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-null approver_id — all live approvals are still in in_review / pending state). The new constraint introduces zero retroactive violations.
  • Migration: dropped aia_write and "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 with approver_id = NULL when first opened for review).
  • Harness: tests/integration/sod-approver-self.ts — four assertions: INSERT with spoofed approver_id rejected on each table, UPDATE setting approver_id to another user's id rejected on each table. Live run: 4 / 4 PASS. Postgres returned new row violates row-level security policy on all four attempts, as expected.
  • Runbook: chapter 3 (Administrator Guide) gained a new section "Delegating approval authority during leave" describing the access_grants flow as the supported pattern, with the ISO/IEC 27001 A.5.18 citation as the basis.