Mindbridge ยท track status

SECURITY

Cross-tenant exposure and privilege defects โ€” one tenant's data reachable from another.

Measured 2026-08-16 from gh issue list. Verify before starting โ€” this page has never been published and its prose decays as the track moves; re-measure against live issues before publishing.

The only p0 (#2968, cross-tenant UNIQUE index) is closed โ€” fixed by PR #3443 and confirmed on prod with zero lost rows (see History). #2445 superuser reads is the next live p1. Decision #2938 default-grant convention โ€” needs an owner call, not scheduled.
2 open โ€” 0 p0 (the cross-tenant UNIQUE index closed), 3 p1 isolation defects: live impersonation poisoning, superuser reads, future-table default grants.
2open on the track
1live person suppressed
3default-grant incidents
~24Ask reads still service-role
Open work Why this order Standing rulings History & forensics
Cross-tenant UNIQUE index โ€” closed

None. The p0 and its follow-up census both closed โ€” see History.

Impersonation poisoning โ€” live on prod
p1 #2037 ADR-097: impersonation flag keyed on spoofable From address poisons the legitimate mailbox owner forged From stamps real owner Mizo
ADR-097 keys the impersonation verdict โ€” and the is_impersonation stamp on the graph person node โ€” on the spoofable From address, so a phish forging a real mailbox stamps its legitimate owner. Live on prod: the Regina employee Kareem ([email protected]) is flagged is_impersonation=true though he is the real, active owner โ€” 3,886 messages over 2017โ€“2026, bound to a real app login. The verdict correctly identified a phish (sent from oldenglishsuperstores.com) but keyed it on the forged From and stamped the real owner. Systemic: 8 own-domain @pastaregina.com addresses carry impersonation verdicts (7 are harmless role addresses). Fix: key the verdict on the authenticated sending origin (envelope/DKIM), never let one forged message flip a person whose real footprint contradicts it, then backfill to clear the false flags.
Privilege backstops โ€” superuser reads and defaults
p1 #2445 Client end-user read endpoints run as DB superuser (RLS bypassed) โ€” migrate to run-as-user superuser reads have no backstop Mizo
Client end-user reads run as the DB superuser (async_execute_query / service-role) and rebuild tenant + tier isolation with hand-written predicates โ€” one forgotten WHERE tenant_id is a cross-tenant leak with no RLS backstop. The compliant path (async_execute_query_as_user) already exists. Phases 0โ€“2 merged 2026-07-16: the no-silent-admin-fallback guard, the leaf endpoints, and the disclosure-join endpoints. Remaining: Phase 2d (~24 Ask-reachable reads still on service-role), the follow-up to give the eval harness synthetic JWT claims, and the /api/tenants/me/health decision. Acceptance per endpoint: RLS is the primary gate, proven by a real-DB test that blocks cross-tenant and cross-tier reads even with the manual predicate removed.
p1 #2938 Supabase default privileges grant ALL on every new public table to anon/authenticated โ€” close it at the schema, not per object new tables silently world-grantable Latent
Supabase's ALTER DEFAULT PRIVILEGES grants ALL on every new public table to anon/authenticated. tenant_health_snapshot (created 2026-07-10) claimed deny-all but held it via RLS-with-zero-policies while both roles kept every grant โ€” one permissive policy or DISABLE ROW LEVEL SECURITY would have exposed every tenant's health assessment. Three incidents of one failure mode (two function-side lockdowns plus this table), each patched reactively per object. Fix: ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated โ€” affects only future tables, but inverts the repo convention (every client-facing table then needs an explicit GRANT), so it needs an owner call. Cheaper alternative: a check.py-style gate failing any migration that creates a public table without an explicit REVOKE/GRANT.
Why is it in this order?

Applied top to bottom; each rung breaks only the ties left above it.

  • Severity โ€” p0 before p1. The cross-tenant alias index (#2968) was the only p0 on the label; it closed 2026-08-19 (PR #3443) and the follow-up prod census (#3446) found zero lost rows โ€” see History. Order below now runs among the remaining p1s.
  • Live, measured harm before latent risk. A defect already suppressing a real person on prod outranks one with no live leak today.
  • A live surface without an RLS backstop before a future-only default. Superuser reads today outrank a default that only affects tables not yet created.

๐Ÿ›‘ What this deliberately ignores: issue age and filing order; how cheap or well-understood a fix looks; how much of an issue is already merged.

What the audit found โ€” generated

โš ๏ธŽ The prose above cites 2 issues that are no longer open

#2938, #2037. A closed issue named as current work is the drift this section exists to surface โ€” re-derive the prose, or move the reference into History.

Evidence below is generated from the read-only audit of 2026-08-29 and reconciled against the open issues at build time. Every claim cites the issue it came from. Nothing here is a decision โ€” rulings stay in the prose above, which is written by hand.

#2445 Client end-user read endpoints run as DB superuser (RLS bypassed) โ€” migrate to run-as-usercodehigh confidence

Phases 0/1/2 are shipped and reachable (cc827d20a on origin/main; async_execute_query_as_user is called in backend/app/db/pg.py, feed_digest_repository.py (4), feed_intelligence_repository.py (5), needs_you_repository.py, suggested_questions_repository.py (4), services/help/agent/graph_tool.py, and routed conditionally in backend/app/slices/message/repo/search.py:47-54 which returns pg.async_execute_query_as_user when a JWT is present and pg.async_execute_query otherwise). Phase 2d is NOT done: on origin/main the relocated serving repos still call the superuser path โ€” backend/app/slices/message = 27 plain async_execute_query( calls and 0 as_user; backend/app/slices/edge = 24 plain, 0 as_user; backend/app/slices/node = 3 plain, 0 as_user (git grep over origin/main, test files excluded). The remaining unticked boxes (Phase 2d, eval-harness synthetic JWTs, GET /api/tenants/me/health decision) match that.

Open questions โ€” what the audit could not settle
  • #2445 Which of the 54 remaining plain async_execute_query call sites in slices/{message,edge,node} are actually reachable from /api/chat (versus ingestion/worker-only, where as_user would be wrong). Close with: codegraph_explore on each call site's enclosing function for a call path from backend/app/api/endpoints/chat.py.
Not yet assessed โ€” filed after the audit
  • #6105 Security audit: is email/WhatsApp/document text fenced as data in every prompt, incl.โ€ฆ

8 assessed issues closed since the audit and are not shown โ€” a closed issue's evidence is history, and printing it as current is how a page starts lying.

Standing rulings

None recorded โ€” track:security carries an empty decided list in track-order.json. The load-bearing open ruling is the default-grant convention decision (#2938); record it in track-order.json when made, so the next session reads it instead of re-asking.

History โ€” how each defect was found

#2968 was found while draining the _v1_substrate_xfail allowlist (batch 4), not caused by that work. The index was recreated verbatim from its pre-tenant form in 20260320163011_rename_groups_to_sources.sql; tenant_id landed three days later (20260323000000_tenant_id_everywhere.sql) and the recreated index was never rescoped. Closed 2026-08-19 by PR #3443, which rescoped the index and added a repository-boundary source_id requirement. A panel lens on that PR filed the follow-up #3446 to check whether the ~5-month exposure window had already lost rows; a read-only prod census across all 9 tenants (2026-08-26) found zero confirmed lost rows on every angle checked, with the two structural blind spots each showing an empty loser-side shadow โ€” closed measured-no-damage 2026-08-27.

#2037 surfaced during the Regina disclose() + email-spoofing audit (docs/investigations/2026-06-21-regina-email-spoofing-report.md), on the ADR-097 impersonation track.

#2445 surfaced during the ADR-096 read-projection work (#2437). Phases 0โ€“2 merged 2026-07-16; Phase 2d and the follow-up remain.

#2938 surfaced by the adversarial review on #2936 (ADR-135 stage 4a). tenant_health_snapshot sat world-grantable for three weeks before #2936 revoked it for that one table.

Forensics โ€” the production evidence

Impersonation (#2037): person node 61b97263-dd6f-484e-aa32-20d361e33b5c ("Kareem") is flagged is_impersonation=true on prod (tenant e9d96b09โ€ฆ). The verdict that flagged it cites a message actually sent from oldenglishsuperstores.com โ€” a correct phish identification keyed on the forged From and stamped onto the real owner. No evidence or reason is stored on the node (impersonation_evidence/impersonation_reason are null), so the suppression is silent.

Default grants (#2938): measured 2026-08-01 on tenant_health_snapshot โ€” relacl showed anon=arwdDxtm and authenticated=arwdDxtm (every privilege) with RLS enabled and zero policies.