Skip to content

Commit 470746a

Browse files
Trumpclaude
andauthored
fix(security): resolve current_user.accessible_org_ids into the RLS variable bag (#17200)
* test(security): pin `current_user.accessible_org_ids` plumbing end to end (#16518) The measurement first, red, before any source change. Same rig, same caller context, same rows, same driver — differing only in which key the predicate names: employer_org IN (current_user.my_org_ids) 4 of 6 rows PASS employer_org IN (current_user.accessible_org_ids) 0 of 6 rows FAIL So an app CAN rename around the defect, and the documented spelling is the one that does not work. The second line is also this file's positive control: without it the first would read the same on a tree where nothing was broken. 13 failed | 18 passed. The 18 are the cells that must not move — the fail-closed paths, #16119's non-existent-variable face, and the pre-existing `current_user.*` variables. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * fix(security): resolve `current_user.accessible_org_ids` into the RLS variable bag (#16518) `RLSUserContext` named `id`, `organization_id`, `positions`, `org_user_ids` and `email`, and nothing copied `accessible_org_ids` out of `ExecutionContext` — while `packages/spec` declared the key's SHAPE, named CORE as its resolver, and refused an app from supplying it. A slot with a declared shape and no filler. Route 1, following `org_user_ids`' precedent exactly: both are core-resolved membership sets the runtime pre-resolves so this compiler never issues a subquery. The producer already existed and is unconditional — `resolve-authz-context.ts` types the set as required and `assemble-execution-context.ts:343` copies it in every posture ("in `single` posture the set is resolved but no wall consumes it"). Only the consuming line was missing. 31 of 31 green, from 13 failed | 18 passed on the same file one commit ago. The 18 did not move: the fail-closed paths, #16119's non-existent-variable face, and every pre-existing `current_user.*` variable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * chore(changeset): patch @objectstack/plugin-security for #16518 A bug fix in a released package takes a `patch` (AGENTS.md). No API signature change, no exported symbol added, no spec or ADR edit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5a95b0e commit 470746a

3 files changed

Lines changed: 531 additions & 0 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
fix(security): resolve `current_user.accessible_org_ids` into the RLS variable bag (#16518)
6+
7+
`patch` — a bug fix in a released package. No API signature changes, no exported
8+
symbol added, no spec or ADR edit: the contract already promised this, and only
9+
the line that delivers it was missing.
10+
11+
## What was wrong
12+
13+
`packages/spec/src/contracts/rls-membership-resolver.ts` does not merely reserve
14+
the name `accessible_org_ids`. It declares the field's SHAPE (`:53`,
15+
`accessible_org_ids?: string[]`), states at `:35` that the key is CORE-resolved
16+
and not an app resolver, and lists it at `:70` in
17+
`RESERVED_RLS_MEMBERSHIP_KEYS` — so an app's membership resolver is refused when
18+
it tries to supply the set itself. `ExecutionContext.accessible_org_ids` goes
19+
further and names the RLS spelling outright: *"RLS policies may reference it as
20+
`organization_id IN (current_user.accessible_org_ids)`"*.
21+
22+
`RLSUserContext` declared `id`, `organization_id`, `positions`, `org_user_ids`
23+
and `email`, and nothing copied `accessible_org_ids` out of the execution
24+
context. So the key was reserved on the grounds that core resolves it, and core
25+
did not resolve it — a slot with a declared shape and no filler, which is the
26+
ADR-0049 "declared but unenforced" shape.
27+
28+
**The cost is the invisible one.** A predicate such as
29+
`employer_org IN (current_user.accessible_org_ids)` compiled to an unresolved
30+
variable, every applicable policy dropped out, and `RLS_DENY_FILTER` returned
31+
**zero rows with no error raised**. Nothing failed. An empty list is
32+
indistinguishable from "this user really has no data", which is how the shape
33+
survived three green static gates and, in the reporting app, left ten policies
34+
across six objects inert — the entire multi-tenant isolation model.
35+
36+
The failure direction is **closed**: zero rows, never a cross-tenant read. This
37+
is a usability and declared-means-enforced defect on a security surface, not a
38+
leak.
39+
40+
## What it does now
41+
42+
`RLSCompiler.compileFilter` copies `ExecutionContext.accessible_org_ids` into
43+
`RLSUserContext`, following `org_user_ids`' precedent exactly — both are
44+
core-resolved membership sets the runtime **pre-resolves**, precisely so this
45+
compiler never has to issue a subquery. The compiler is unchanged otherwise; it
46+
already handled the value correctly once present.
47+
48+
The producer already existed and is unconditional: `resolve-authz-context.ts`
49+
types the set as required and `assemble-execution-context.ts` copies it on every
50+
face, in every posture (*"in `single` posture the set is resolved but no wall
51+
consumes it"*). Only the consuming line was missing.
52+
53+
One consequence worth naming: **reserved now means reserved at the compiler
54+
too.** `stageRlsMembership` screens reserved keys out of a *resolver's* answer,
55+
but a bag already present on the context was spread through unscreened, and
56+
landed in the variable bag because nothing named the field. Now that the kernel
57+
names it, the compiler's own "a membership key never clobbers a named field"
58+
rule covers it and the kernel's value wins.
59+
60+
## Measured, end to end
61+
62+
A rig on real drivers (`driver-sql`, `driver-sqlite-wasm`), six rows across
63+
three organizations, a caller holding membership in two of them:
64+
65+
| predicate | before | after |
66+
|:--|--:|--:|
67+
| `employer_org IN (current_user.accessible_org_ids)` | **0 of 6** | **4 of 6** — the rows of both orgs |
68+
| same, caller scoped to ONE org | 0 of 6 | 2 of 6 — that org only |
69+
| same, caller with no set / an empty set / an org with no rows | 0 of 6 | 0 of 6 — unchanged, still fails closed |
70+
| a predicate naming a NON-EXISTENT variable | 0 of 6 | 0 of 6 — unchanged (#16119's face, untouched) |
71+
| `org_user_ids`, `organization_id`, `email`, `id`, an app membership key | — | byte-identical |
72+
73+
An app **could** work around the defect by supplying the same set under its own
74+
unreserved key through `rlsMembership` and rewriting its predicates to
75+
`current_user.my_org_ids`; that reads 4 of 6 on the same rig, before and after.
76+
The workaround costs every app a membership-resolver registration it should not
77+
need and moves every predicate off the documented spelling — and it is no longer
78+
necessary.

0 commit comments

Comments
 (0)