Skip to content
Merged
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
34 changes: 34 additions & 0 deletions .changeset/authz-2936-rls-eq-recognition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
"@objectstack/plugin-security": patch
---

fix(plugin-security): #2936 — RLS field-existence / tenancy-disabled safety nets now recognize canonical `==`

`extractTargetField` (the lightweight left-hand-field parser feeding the Layer 1
**field-existence** net and the **tenancy-disabled** skip net in
`computeLayeredRlsFilter`) only matched the legacy single-`=` / `IN` shape. It
returned `null` for canonical CEL `==` (`field == …`), which is how real seeds
and business policies author equality. A `null` target field means "keep the
policy", so both safety nets were **inert** for every `==` policy: a wildcard
policy targeting a column the object lacks was NOT failed closed, and a
`==`-form `organization_id` policy on a `tenancy.enabled:false` object was NOT
skipped. The regex now recognizes `==` (listed before `=` so the ordered
alternation does not mis-match the first `=`), alongside the existing `=`/`IN`.
Recognition is only **extended** — the net semantics are unchanged, and
`!=`/`>`/`<`/`>=`/`<=` still return `null` (conservative keep), matching prior
behavior for any unmatched shape.

Behavior delta (fail-closed strengthening, same effective visibility): the
wildcard `owner_only_writes` / `owner_only_deletes` seed policies
(`created_by == current_user.id`) now correctly fail closed on an object that
lacks a `created_by` column (platform-global / system tables). Previously they
slipped the net and compiled to a phantom `{ created_by: … }` filter against a
missing column — a driver-dependent, effectively-deny result; now the net drops
the sole applicable write policy and yields the deny sentinel. A member could not
by-id write such a column-less object either way, so the visible/writable row set
is unchanged; only the mechanism is now an explicit fail-closed deny. All ordinary
tenant/business objects carry `created_by`, so they are unaffected (proven green by
the dogfood authz-conformance + RLS matrices). The tenancy-disabled skip net has no
effect on any current seed (no `==` seed policy targets `organization_id` on a
tenancy-disabled object). The tenant wall itself is Layer 0 (`tenant-layer.ts`),
which never used this parser, so tenant isolation is unaffected (ADR-0095 D1).
21 changes: 16 additions & 5 deletions packages/plugins/plugin-security/src/authz-matrix-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,22 @@ const EXPECTED_MATRIX: Record<string, Record<string, { read: unknown; write: unk
org_admin: { read: null, write: 'BYPASS(no-write-filter)' },
// [D1 accepted delta (c)] A member reading a `tenancy.enabled:false` global
// object: Layer 0 treats it as a NON-tenant object (no phantom org filter) →
// the global catalog is VISIBLE (read: null). The write drops the dead
// org-disjunct to plain owner scope (same visibility, no org column exists).
member: { read: null, write: [{ created_by: 'u1' }] },
// [D1 accepted delta (c)] no-org member likewise sees the global catalog (was deny-sentinel).
no_org_member: { read: null, write: [{ created_by: 'u2' }] },
// the global catalog is VISIBLE (read: null).
// [#2936] WRITE now fail-closes to the DENY sentinel. `sys_package` has no
// `created_by` column, and the wildcard `owner_only_writes` policy authors
// its predicate as `created_by == current_user.id` (canonical `==`).
// Pre-#2936 `extractTargetField` did not recognize `==`, so the
// field-existence net let the policy through and it compiled to a phantom
// `{ created_by: … }` filter against a column the object lacks — a
// driver-dependent, effectively-deny result. Now the net recognizes `==`,
// sees `created_by` is absent, and drops the only applicable write policy →
// deny sentinel (fail-closed). SAME effective visibility (a member cannot
// by-id write a column-less global object either way); the mechanism is now
// an explicit fail-closed deny instead of a phantom-column filter.
member: { read: null, write: [{ id: DENY }] },
// [#2936] no-org member: same fail-closed deny on the write (see above); the
// global catalog stays VISIBLE on read (Layer 0 non-tenant, delta (c)).
no_org_member: { read: null, write: [{ id: DENY }] },
},
better_auth: {
// [W2] better-auth-managed posture → superuser read bypass → null.
Expand Down
28 changes: 28 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,34 @@ describe('SecurityPlugin', () => {
expect(filter).toEqual(RLS_DENY_FILTER);
});

it('[#2936] fail-closed on a CANONICAL `==` wildcard policy targeting a missing column → deny sentinel', async () => {
// The `=`-form twin above is the L692 test. This is its canonical-CEL
// sibling: pre-#2936 `extractTargetField` did not recognize `==`, so the
// field-existence net was INERT for it — the policy slipped through and
// compiled to a phantom `{ organization_id: … }` filter against a column
// the object lacks. The net now recognizes `==` and fails closed here,
// exactly as it always did for the legacy `=` form. Real seeds/business
// policies author equality as `==`, so this is the path that mattered.
const eqPolicySet: PermissionSet = {
name: 'member_default',
label: 'Member',
objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
rowLevelSecurity: [
{ name: 'tenant_isolation', object: '*', operation: 'all', using: 'organization_id == current_user.organization_id' },
],
} as any;
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
const harness = makeMiddlewareCtx({
permissionSets: [eqPolicySet],
objectFields: ['id', 'name'], // no organization_id, tenancy not opted out
orgScoping: true,
});
await plugin.init(harness.ctx);
await plugin.start(harness.ctx);
const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] });
expect(filter).toEqual(RLS_DENY_FILTER);
});

it('tenancy opt-out → undefined (not denied), matching the find-path', async () => {
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
const harness = makeMiddlewareCtx({
Expand Down
21 changes: 15 additions & 6 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2487,12 +2487,21 @@ export class SecurityPlugin implements Plugin {
*/
private extractTargetField(using?: string): string | null {
if (!using) return null;
// Match `field =` or `field IN`/`in`. Note: `\b` is omitted after `=`
// because `=` is non-word and the next char (space) is non-word too —
// a word boundary cannot exist between two non-word chars, so `=\b`
// would never match. We instead require the alternation token to be
// followed by whitespace or `(`.
const m = using.match(/^\s*([a-z_][a-z0-9_]*)\s*(?:=|IN|in)(?=\s|\()/);
// Match `field ==` (canonical CEL), `field =` (legacy SQL-ish), or
// `field IN`/`in`. [#2936] `==` MUST be listed before `=` in the
// alternation: alternation is ordered, so a bare `=` branch would match
// the first `=` of `==` and then the `(?=\s|\()` lookahead — seeing the
// SECOND `=`, not whitespace — would fail, leaving `==` unrecognized (the
// original bug: real seeds/business policies author equality as `==`, so
// the field-existence and tenancy-disabled safety nets that consume this
// were inert for them). `\b` is omitted after the operator because `=` is
// non-word and the next char (space) is non-word too — a word boundary
// cannot exist between two non-word chars — so we require the operator to
// be followed by whitespace or `(` instead. `!=`/`>`/`<`/`>=`/`<=` are
// deliberately NOT recognized: returning `null` keeps such a policy (the
// conservative default), matching the pre-#2936 behavior for any shape the
// regex did not match.
const m = using.match(/^\s*([a-z_][a-z0-9_]*)\s*(?:==|=|IN|in)(?=\s|\()/);
return m ? m[1] : null;
}
}