Found while implementing #16518 (PR #17200). ⛔ Not fixed there — out of that card's scope, and the repair shape is not settled.
The contract
packages/spec/src/contracts/rls-membership-resolver.ts:87 states it as the reason the list exists:
Context keys a membership resolver may never supply — they are resolved by the kernel and carry authorization meaning an app must not be able to redefine.
and stageRlsMembership (packages/plugins/plugin-security/src/security-plugin.ts) repeats the guarantee in its own doc comment:
Reserved kernel keys can never be overwritten, so an app cannot redefine the org wall's own vocabulary.
The measurement
That guarantee holds only for the keys the kernel actually populated on this request. stageRlsMembership screens RESERVED_RLS_MEMBERSHIP_KEYS out of the resolver's answer, but it seeds the bag from an already-present one and never screens that:
const bag: Record<string, string[]> = { ...(context.rlsMembership ?? {}) };
for (const [key, value] of Object.entries(resolved)) {
if (RESERVED_RLS_MEMBERSHIP_KEYS.includes(key)) { /* warn, skip */ }
RLSCompiler.compileFilter then merges the bag under the rule "a membership key never clobbers a NAMED field" — which is keyed on userCtx[key] === undefined, i.e. on whether the kernel had a value, not on whether the key is reserved.
Probe on plugin-security at fb05fc119, compiler seam, three cells:
predicate: owner_email == current_user.email
ctx { rlsMembership: { email: ['victim@e.example'] } } -> {"owner_email":["victim@e.example"]} BAG WINS
ctx { email: 'real@e.example', rlsMembership: { email: [...] } } -> {"owner_email":"real@e.example"} kernel wins, correct
predicate: employer_org IN (current_user.accessible_org_ids)
ctx { rlsMembership: { accessible_org_ids: ['org_victim'] } } -> {"employer_org":{"$in":["org_victim"]}} BAG WINS
The middle cell is the control: with the kernel value present the rule works, so the first and third are not an artifact of the rig.
⚠️ The direction is widening. With the kernel value absent, the policy would otherwise have found an unresolved variable and failed closed (deny sentinel, zero rows). The bag instead produces a satisfiable filter, so a reserved-key entry turns a denial into a match on attacker-chosen values.
Evidence boundary
⛔ Measured at the compiler/plugin seam only. I did not establish that a request-path caller can get rlsMembership onto the ExecutionContext — it is a declared ExecutionContext field that core normally populates, and whether any inbound face copies a client-supplied value into it is unmeasured. That reachability question is what decides whether this is a hardening or a live hole, and it should be answered before grading.
Not covered by the neighbours
⛔ Not #17170 / #17172 (both are the ADR-0058 field guard / phantom columns). ⛔ Not #16607 (the missing write-side stageRlsMembership call site). ⛔ Not #16518, which fills accessible_org_ids and, as a side effect, closes the third cell above only for requests where the kernel resolved the set — the general gap over the other five reserved keys is untouched.
Two candidate shapes, ⛔ not chosen here
- Screen the seeded bag in
stageRlsMembership the same way the resolver's answer is screened — one line, but it only covers the one producer.
- Make the compiler's merge refuse
RESERVED_RLS_MEMBERSHIP_KEYS by name rather than by "was the field already defined" — covers every producer, at the seam both faces pass through.
Shape 2 looks right, since the compiler is the choke point, but it changes a security boundary and belongs to whoever owns that call.
Found while implementing #16518 (PR #17200). ⛔ Not fixed there — out of that card's scope, and the repair shape is not settled.
The contract
packages/spec/src/contracts/rls-membership-resolver.ts:87states it as the reason the list exists:and
stageRlsMembership(packages/plugins/plugin-security/src/security-plugin.ts) repeats the guarantee in its own doc comment:The measurement
That guarantee holds only for the keys the kernel actually populated on this request.
stageRlsMembershipscreensRESERVED_RLS_MEMBERSHIP_KEYSout of the resolver's answer, but it seeds the bag from an already-present one and never screens that:RLSCompiler.compileFilterthen merges the bag under the rule "a membership key never clobbers a NAMED field" — which is keyed onuserCtx[key] === undefined, i.e. on whether the kernel had a value, not on whether the key is reserved.Probe on
plugin-securityatfb05fc119, compiler seam, three cells:The middle cell is the control: with the kernel value present the rule works, so the first and third are not an artifact of the rig.
Evidence boundary
⛔ Measured at the compiler/plugin seam only. I did not establish that a request-path caller can get
rlsMembershiponto theExecutionContext— it is a declaredExecutionContextfield that core normally populates, and whether any inbound face copies a client-supplied value into it is unmeasured. That reachability question is what decides whether this is a hardening or a live hole, and it should be answered before grading.Not covered by the neighbours
⛔ Not #17170 / #17172 (both are the ADR-0058 field guard / phantom columns). ⛔ Not #16607 (the missing write-side
stageRlsMembershipcall site). ⛔ Not #16518, which fillsaccessible_org_idsand, as a side effect, closes the third cell above only for requests where the kernel resolved the set — the general gap over the other five reserved keys is untouched.Two candidate shapes, ⛔ not chosen here
stageRlsMembershipthe same way the resolver's answer is screened — one line, but it only covers the one producer.RESERVED_RLS_MEMBERSHIP_KEYSby name rather than by "was the field already defined" — covers every producer, at the seam both faces pass through.Shape 2 looks right, since the compiler is the choke point, but it changes a security boundary and belongs to whoever owns that call.