You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A four-axis sweep (context-less calls, elevation literals, fall-open
seams, execution-surface identity) confirms #2849 is a populated class,
not a lone bug. Records the findings as the ADR's evidence base:
- E1: the empty-principal fall-open predicate replicated across getReadFilter,
the tenant-scope path, guest routes, custom API endpoints, the authored-body
sandbox, MCP resource reads, and messaging.
- E2: three confirmed-exploitable trusted-implicit surfaces, spun out as
#2980 (reports IDOR + scheduled-report RLS bypass), #2981 (knowledge/RAG
retrieval fall-open), #2982 (bulk-write OWD gap) — fixed independently.
- E3: structural middleware-bypass paths (executeAction, raw execute, driver
getters).
- E4: unknown seams (GraphQL, realtime, blob RLS, chatWithTools contract).
- E5: the D2 migration sizing (~300 sites / ~50 constructors / ~25 files) and
its existing prototype (resolveRunDataContext).
Wires the seed rows into D4, the exploitable instances into 'what lands now',
and the References/Trigger notes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155bjzJZxZaZTKSMSvg6Aum
Copy file name to clipboardExpand all lines: docs/adr/0096-execution-surface-identity-admission.md
+57-3Lines changed: 57 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -8,7 +8,7 @@
8
8
9
9
**Premise**: pre-launch (ADR-0049 idiom) — specify the end-state invariant now, land only the non-speculative slice, and gate every runtime phase on evidence (audit telemetry), not on dates.
10
10
11
-
> **Trigger**: #2849 (fixed in #2964 at the invoke-time boundary) found that business-action bodies dispatch through an engine facade whose `insert/update/delete/find` carry **no `ExecutionContext`**. A context-less call hits the SecurityPlugin's empty-principal skip and runs with **ambient full authority** — no RLS, no FLS, no CRUD, no tenant scoping, no ADR-0090 D10 agent intersection. #2308 had already found the *same shape* on schedule-triggered flows (`runAs:'user'` with no user → UNSCOPED). Two independent surfaces, one root cause. This ADR is about the class, not the instances.
11
+
> **Trigger**: #2849 (fixed in #2964 at the invoke-time boundary) found that business-action bodies dispatch through an engine facade whose `insert/update/delete/find` carry **no `ExecutionContext`**. A context-less call hits the SecurityPlugin's empty-principal skip and runs with **ambient full authority** — no RLS, no FLS, no CRUD, no tenant scoping, no ADR-0090 D10 agent intersection. #2308 had already found the *same shape* on schedule-triggered flows (`runAs:'user'` with no user → UNSCOPED). Two independent surfaces, one root cause. A subsequent four-axis sweep (see **Evidence**) confirmed the class is broadly populated — a replicated fall-open predicate, several trusted-implicit surfaces (three confirmed exploitable: #2980, #2981, #2982), and structural middleware-bypass paths. This ADR is about the class, not the instances.
12
12
13
13
---
14
14
@@ -140,6 +140,8 @@ A surface earns admission only if ALL three hold:
140
140
141
141
**The meta-test**: a conformance test enumerates engine-reaching surfaces (mechanically: the modules invoking the identity-required entry points from D3, plus a maintained allowlist during transition) and **fails CI if a surface is not registered**. This is the piece that protects the *future*: the next engineer adding a surface gets a red build with a checklist, not a silent fall-open and an adversarial-review finding two months later.
142
142
143
+
The **Evidence** section below enumerates the surfaces found by the initial sweep — those become the matrix's seed rows in their honest current states (BOUNDED / TRUSTED-EXPLICIT / TRUSTED-IMPLICIT / UNKNOWN).
144
+
143
145
### D5 — Strict mode: fail-closed, staged by evidence
144
146
145
147
A kernel-level mode (`security.identityStrict`):
@@ -164,15 +166,66 @@ Rollout is gated on evidence, not dates (ADR-0049/0073 idiom):
164
166
165
167
---
166
168
169
+
## Evidence: the class, enumerated
170
+
171
+
The invariant above is not motivated by a single bug. A four-axis sweep of every data-engine-reaching path (context-less calls, elevation literals, fall-open seams, execution-surface identity) found the class is already populated — **two instances were previously found by adversarial review; the sweep found the rest sitting in the open.** This is the concrete case for a *mechanism* over per-surface patches, and it seeds the D4 matrix. Instances are grouped by how they relate to the invariant.
172
+
173
+
### E1 — Missing-identity fall-open (the #2849 predicate, replicated)
174
+
175
+
All trace to the same `positions==0 && permissions==0 && !userId → skip` predicate; fixing #2849 at one site does **not** close the others.
176
+
177
+
| Site | Surface | Note |
178
+
|:---|:---|:---|
179
+
|`plugin-security/security-plugin.ts:626`| find/write middleware | the original seam (#2849) |
180
+
|`plugin-security/security-plugin.ts:1689`|`getReadFilter` (analytics / reports / raw-SQL RLS compile) | returns `undefined` = *no filter*; the analytics mirror of :626 |
181
+
|`objectql/engine.ts` Layer-0 + `driver-sql``applyTenantScope` (opt-in on `tenantId`) | tenant scoping | Layer-0 is computed **after** the :626 skip, and the driver scope is opt-in → a principal-less context gets **no tenant filter at either layer** (cross-tenant read/write) |
182
+
|`rest/rest-server.ts:4317` (+ lookup `:4744`) | guest / public-form routes | bypass `enforceAuth`; fall open when `guest_portal` unregistered (partly mitigated by the `publicFormGrant`) |
183
+
|`runtime/http-dispatcher.ts:4231,4236,4240`| custom `object_operation` API endpoints |**accidental** — `callData` invoked with no `executionContext` (every sibling threads it) |
184
+
|`runtime/sandbox/body-runner.ts:155`| authored action/hook **body** interior facade | the inside of #2849 — only the *invoke* is gated (#2964); the body's `api.object().find/insert/...` run context-less |
185
+
|`mcp/mcp-server-runtime.ts:335`| MCP resource `…/records/{id}`| record read with no session identity |
186
+
|`service-messaging/messaging-service.ts` (+ recipient resolver) | notification read/write + recipient-role lookup | unscoped reads of `sys_notification*` and `sys_user`/`sys_member`|
187
+
| minor: `http-dispatcher.ts:1322` (env-member), `plugin-webhooks/auto-enqueuer.ts:175`, `objectql/engine.ts:722` (`visibleWhen`) | system plumbing | rely on the *implicit* fall-open; should carry an **explicit** grant so they survive the D5 flip |
188
+
189
+
### E2 — Trusted-implicit surfaces (ignore the caller / fall back to system) — confirmed exploitable, spun out
190
+
191
+
| Instance | Verdict | Issue |
192
+
|:---|:---|:---|
193
+
| Reports `getReport`/`deleteReport`/`listReports` discard the caller and query with `SYSTEM_CTX`; routes only `enforceAuth` → cross-user/cross-tenant IDOR (read+delete any report) | CONFIRMED exploitable |#2980|
194
+
| Scheduled reports (`report-service.ts:425``dispatchDue`) run `executeReport(..., {isSystem:true})` → a member-owned schedule emails the target object's **entire** table, RLS bypassed | CONFIRMED exploitable |#2980|
195
+
| Knowledge/RAG `applyPermissionFilter` (`knowledge-service.ts:312`) returns **all** hits when `ctx` is missing/system; `chatWithTools`'s `ToolExecutionContext.actor` is optional with a system fallback → agent retrieval escapes the data ceiling | CONFIRMED (framework); exposure gated on cloud impl |#2981|
196
+
197
+
These are *not* the :626 fall-open (they use an unconditional `SYSTEM_CTX`), but they are exactly what a D4 conformance row (`caller-scoped?` proof) + the D2 audit would have flagged. Fixed independently of the mechanism, tracked as the mechanism's motivating evidence.
198
+
199
+
### E3 — Structural: paths that never enter the security middleware (Class B)
200
+
201
+
| Site | Note |
202
+
|:---|:---|
203
+
|`objectql/engine.ts:641``executeAction`| handler invoked **directly**, not via `executeWithMiddleware` — an action touching the driver gets no RLS/FLS/CRUD/tenant |
204
+
|`objectql/engine.ts:2793``engine.execute()` / `driver-sql:1371``driver.execute()`| raw SQL, documented tenant-bypass; caller trusted to inline the filter |
205
+
|`objectql/engine.ts:2956/2991``getDriverByName`/`getDriverForObject`| hand out the raw driver — middleware-free read/write |
206
+
| bulk `update({multi:true})`/`deleteMany` on pure-OWD `private` objects (`sharing-plugin.ts:391`, `security-plugin.ts:821`) | single-id owner check not applied to multi-writes → members modify peers' rows | (#2982)
207
+
208
+
### E4 — Unknown seams (identity not visibly threaded — investigate, then register)
209
+
210
+
`runtime/http-dispatcher.ts:1488`**GraphQL** (passes only `{request}`, not the resolved `ec`); `service-realtime`**websocket** delivery (no per-subscriber RLS re-check); attachment **blob-level** RLS; the `chatWithTools` contract's optional actor. Each becomes a D4 matrix row in state `experimental`/`UNKNOWN` with a follow-up.
211
+
212
+
### E5 — The D2 migration is large but has a prototype
213
+
214
+
~300 elevation call sites resolve to ~50 hand-rolled `SYSTEM_CTX`/`isSystem` constructors across ~25 files (heaviest: `plugin-approvals`~64, `plugin-security`~58, `plugin-sharing`~52, `plugin-auth`~44 with 13 in one file). **None are audited today.** The canonical shape already exists — `service-automation/runtime-identity.ts``resolveRunDataContext` (the sole `runAs:'system'→context` mapper) — which `systemContext(reason)` generalizes. This sizes D2 honestly: a mechanical but wide migration, front-loaded on four packages.
215
+
216
+
---
217
+
167
218
## What lands now (the non-speculative slice)
168
219
169
220
Per ADR-0049's staging discipline, v1 of this ADR builds only what has a consumer today:
170
221
171
222
1.**This decision record** — the invariant, the admission test, the staging plan.
172
223
2.**`systemContext(reason)` + `SystemGrant`** in the engine contract package, with audit emission; migrate the *known* elevation literals (boot/seeding, the action facades' trusted dispatch, report scheduler's `SYSTEM_CTX`) as the proof-of-idiom.
173
-
3.**Matrix rows** for today's surfaces (REST/MCP CRUD, actions, flows, triggers) in their *honest current states* — actions register as `surface: trusted-body, gated at invoke (ai.exposed + capability)` per #2964 — plus the meta-test skeleton with the transition allowlist.
224
+
3.**Matrix rows** for the surfaces the Evidence section enumerated, in their *honest current states* — actions register as `trusted-body, gated at invoke (ai.exposed + capability)` per #2964 — plus the meta-test skeleton with the transition allowlist.
174
225
4.**The authoring lint** from D6.3 (`ai.exposed` + trusted body ⇒ warn).
175
226
227
+
The confirmed-exploitable instances the sweep surfaced (E2) are **not** waiting on this mechanism — they are fixed independently and tracked at #2980 (reports IDOR + scheduled-report RLS bypass), #2981 (knowledge/RAG retrieval fall-open), #2982 (bulk-write OWD gap). Their existence is this ADR's motivating evidence, not its deliverable.
228
+
176
229
Everything else — the D3 signature migration, strict-mode-ON-in-CI per package, the D5 default flip, D6's `runAs` field — is explicitly **M2+, gated on the prior step's evidence**, tracked as separate issues under #2849.
177
230
178
231
---
@@ -215,6 +268,7 @@ Everything else — the D3 signature migration, strict-mode-ON-in-CI per package
0 commit comments