Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/rls-reserved-membership-keys-refused-by-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@objectstack/plugin-security': patch
---

security(rls): the RLS compiler refuses `RESERVED_RLS_MEMBERSHIP_KEYS` by name

A caller-supplied `ExecutionContext.rlsMembership` entry could supply a RESERVED
kernel key — `id`, `organization_id`, `positions`, `org_user_ids`,
`accessible_org_ids`, `email` — whenever the kernel had not resolved a value for
that key on the request. `RLSCompiler.compileFilter` admitted a membership key on
the test `userCtx[key] === undefined` ("did the kernel happen to resolve one"),
not on whether the key is reserved, so an absent kernel value handed the name to
the bag.

The direction was widening. With the key unresolved, the predicate referencing it
fails CLOSED — it joins the dropped-policy path and the compile returns the deny
sentinel, which yields zero rows. The bag instead produced a satisfiable filter
over caller-chosen values, converting a denial into a match.

The merge now refuses reserved keys by name, at the one seam both faces pass
through (the read layer compiles `using` there, the ADR-0058 D4 write gate
compiles `check` there). `stageRlsMembership`'s existing screen covers only the
registered resolver's answer, and only when a resolver is registered at all — it
returns at its first line otherwise — so it could not carry this guarantee.

No behaviour change for non-reserved membership keys, and none when the kernel
did resolve the reserved value: the kernel's value already won, and still does.
A refused key simply stays unresolved, so its policies drop out and fail closed
through the reason vocabulary that already exists.
Original file line number Diff line number Diff line change
Expand Up @@ -299,10 +299,12 @@ describe('[#16518] RLSCompiler.compileFilter resolves `current_user.accessible_o
});

it('⭐ RESERVED means reserved at the COMPILER too — a membership bag cannot clobber it', () => {
// `stageRlsMembership` screens a RESOLVER's answer, but a bag already on the
// context is spread through unscreened. Now that the kernel names the field,
// the compiler's own "never let a membership key clobber a named field" rule
// covers it: the kernel's value wins.
// `stageRlsMembership` screens a RESOLVER's answer, but a bag already on
// the context is seeded through unscreened. The compiler's merge now
// refuses `RESERVED_RLS_MEMBERSHIP_KEYS` BY NAME rather than by "was this
// field already defined", so the kernel's value wins here and the bag is
// refused even on a request where the kernel resolved nothing
// (`rls-reserved-membership-keys.test.ts` pins the absent-kernel half).
const filter = compiler.compileFilter(
[policy(SQL_BRIDGE)],
{
Expand Down
39 changes: 38 additions & 1 deletion packages/plugins/plugin-security/src/rls-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

import type { RowLevelSecurityPolicy } from '@objectstack/spec/security';
import type { ExecutionContext } from '@objectstack/spec/kernel';
// The contract's own list of kernel-resolved context keys an app may never
// supply. Consumed — not redeclared — so this merge and `stageRlsMembership`'s
// resolver screen can never disagree about which keys are reserved.
import { RESERVED_RLS_MEMBERSHIP_KEYS } from '@objectstack/spec/contracts';
// [ADR-0056 D4 / ADR-0058 D1] `isSupportedRlsExpression` and `sqlPredicateToCel`
// used to be DEFINED in this file. #4983 hoisted them into `@objectstack/formula`
// — verbatim, behaviour-preserving — because `@objectstack/lint` must ask the
Expand Down Expand Up @@ -467,10 +471,43 @@ export class RLSCompiler {
// into `ExecutionContext.rlsMembership`. Merge each set under its key
// so `field IN (current_user.<key>)` resolves without subquery support.
// Arrays only; a missing/empty set still fails closed downstream.
// We never let a membership key clobber the named fields above.
//
// A RESERVED key is refused BY NAME, never by "was this field already
// defined". The two tests are not the same test, and the difference is
// the whole guarantee: `userCtx[key] === undefined` asks whether the
// KERNEL happened to resolve a value on THIS request, so on any request
// where it did not — an anonymous caller, a principal with no active
// organization, a deployment that resolves no `org_user_ids` — the bag
// won the name and supplied the authorization vocabulary itself. The
// direction is WIDENING: with the key unresolved the predicate would
// have joined `deniedBy` and returned {@link RLS_DENY_FILTER} (zero
// rows), so a reserved-key entry converted a denial into a satisfiable
// filter over attacker-chosen values.
//
// `RESERVED_RLS_MEMBERSHIP_KEYS` is the contract's own list of "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" (`@objectstack/spec/contracts`). `stageRlsMembership`
// screens a RESOLVER's answer against it, but that screen covers only
// one producer and only when it runs: it returns at its first line when
// no `rls-membership-resolver` is registered, which is every deployment
// that has not opted into the ADR-0105 D11 seam, and it never screens
// the bag it SEEDS from an already-present `context.rlsMembership` at
// all. This merge is the choke point both faces pass through — the read
// layer compiles `using` here and the ADR-0058 D4 write gate compiles
// `check` here — so the refusal belongs here, where it holds for every
// producer including ones outside this repo.
//
// No new drop reason: a reserved key that is not merged leaves its
// variable unresolved, so the predicate takes the existing
// unresolved-variable path, joins `deniedBy`, warns with the vocabulary
// that already exists and fails CLOSED. Refusing is silent here on
// purpose — the observable event is the policy drop, which is already
// reported one loop below.
const membership = (executionContext as any)?.rlsMembership;
if (membership && typeof membership === 'object') {
for (const [key, value] of Object.entries(membership)) {
if (RESERVED_RLS_MEMBERSHIP_KEYS.includes(key)) continue;
if (Array.isArray(value) && userCtx[key] === undefined) {
userCtx[key] = value;
}
Expand Down
Loading
Loading