|
| 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