Skip to content

Commit 7c4cc8a

Browse files
committed
feat(security): ADR-0056 D4 — RLS compiler surfaces uncompilable predicates
Stop silently dropping a `using`/`check` whose SHAPE the compiler cannot compile (`==`, AND/OR, ranges) — warn via the security plugin logger so an unenforced predicate is observable, distinguished from the intentional context-var-absent skip. Export `isSupportedRlsExpression(expr)` for a future authoring-time compile gate. Additive: valid predicates compile identically; fail-closed preserved. 4 new unit tests; plugin-security 83; RLS/OWD dogfood proofs 16/16. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
1 parent 4885112 commit 7c4cc8a

4 files changed

Lines changed: 95 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
feat(security): RLS predicates that won't compile are surfaced, not silently dropped (ADR-0056 D4)
6+
7+
The RLS compiler previously dropped any `using`/`check` it could not parse (e.g. `==`,
8+
`AND`/`OR`, ranges) in silence — if it was the only policy, the object lost protection
9+
with no signal (the class of bug that left a showcase owner predicate inert for two PRs).
10+
Now the compiler WARNS (via the security plugin's logger) when an **unsupported-shape**
11+
predicate is dropped, distinguishing it from the intentional "context variable absent"
12+
fail-closed skip. Also exports `isSupportedRlsExpression(expr)` so an authoring-time gate
13+
(`objectstack compile`) can reject a predicate the runtime would never enforce. No change
14+
to compiled filters for valid predicates; fail-closed semantics preserved.

packages/plugins/plugin-security/src/rls-compiler.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,41 @@ export const RLS_DENY_FILTER: Record<string, unknown> = Object.freeze({
5858
id: '__rls_deny__:00000000-0000-0000-0000-000000000000',
5959
});
6060

61+
/**
62+
* Recognize whether an RLS `using` / `check` expression matches one of the SHAPES
63+
* the compiler can compile (equality against a `current_user.*` var, equality
64+
* against a string literal, set-membership against a `current_user.*` array, or
65+
* the `1 = 1` allow-all). This is SHAPE-only — it does not check whether the
66+
* referenced context variable is populated at runtime.
67+
*
68+
* ADR-0056 D4: exposed so an authoring-time gate (`objectstack compile`) can REJECT
69+
* a predicate the runtime would silently drop — the class of bug where
70+
* `owner == current_user.name` (`==`, unsupported) compiled to nothing and left an
71+
* object unprotected. A `false` here means "this predicate will never enforce".
72+
*/
73+
export function isSupportedRlsExpression(expression: string): boolean {
74+
if (!expression) return false;
75+
const e = expression.trim();
76+
if (/^\s*1\s*=\s*1\s*$/.test(e)) return true;
77+
if (/^\s*\w+\s*=\s*current_user\.\w+\s*$/.test(e)) return true;
78+
if (/^\s*\w+\s*=\s*'[^']*'\s*$/.test(e)) return true;
79+
if (/^\s*\w+\s+IN\s+\(\s*current_user\.\w+\s*\)\s*$/i.test(e)) return true;
80+
return false;
81+
}
82+
6183
/**
6284
* RLSCompiler
6385
*
6486
* Compiles Row-Level Security policy expressions into query filters.
6587
* Converts `using` / `check` expressions into ObjectQL-compatible filter conditions.
6688
*/
6789
export class RLSCompiler {
90+
/** Optional logger so a SILENTLY-dropped (uncompilable-shape) policy is observable (ADR-0056 D4). */
91+
private logger?: { warn?: (message: string, meta?: Record<string, unknown>) => void };
92+
setLogger(logger: { warn?: (message: string, meta?: Record<string, unknown>) => void } | undefined): void {
93+
this.logger = logger;
94+
}
95+
6896
/**
6997
* Compile RLS policies into a query filter for the given user context.
7098
* Multiple policies for the same object/operation are OR-combined (any match allows access).
@@ -115,6 +143,16 @@ export class RLSCompiler {
115143
const filter = this.compileExpression(policy.using, userCtx);
116144
if (filter) {
117145
filters.push(filter);
146+
} else if (!isSupportedRlsExpression(policy.using)) {
147+
// ADR-0056 D4: an UNSUPPORTED-SHAPE predicate (e.g. `==`, AND/OR, ranges)
148+
// compiles to nothing and would silently vanish, leaving the object
149+
// unprotected. Surface it instead of dropping in silence. (A SUPPORTED
150+
// shape that returned null is the intentional "context var absent" path —
151+
// it fails closed downstream and is not warned here.)
152+
this.logger?.warn?.(
153+
`[RLS] policy '${(policy as { name?: string }).name ?? '(unnamed)'}' on '${(policy as { object?: string }).object ?? '?'}' ` +
154+
`has an uncompilable predicate and was DROPPED (no enforcement): ${policy.using}`,
155+
);
118156
}
119157
}
120158

packages/plugins/plugin-security/src/security-plugin.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { describe, it, expect, vi } from 'vitest';
44
import { SecurityPlugin } from './security-plugin.js';
55
import { PermissionEvaluator } from './permission-evaluator.js';
66
import { FieldMasker } from './field-masker.js';
7-
import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js';
7+
import { RLSCompiler, RLS_DENY_FILTER, isSupportedRlsExpression } from './rls-compiler.js';
88
import type { PermissionSet } from '@objectstack/spec/security';
99
import { RLS } from '@objectstack/spec/security';
1010

@@ -1193,3 +1193,44 @@ describe('RLSCompiler', () => {
11931193
expect(filter).not.toEqual(RLS_DENY_FILTER);
11941194
});
11951195
});
1196+
1197+
// ---------------------------------------------------------------------------
1198+
// ADR-0056 D4 — RLS predicates that won't compile must not vanish in silence
1199+
// ---------------------------------------------------------------------------
1200+
describe('RLSCompiler D4 — uncompilable predicates are surfaced', () => {
1201+
it('isSupportedRlsExpression accepts the compilable shapes', () => {
1202+
expect(isSupportedRlsExpression('owner_id = current_user.id')).toBe(true);
1203+
expect(isSupportedRlsExpression('owner = current_user.email')).toBe(true);
1204+
expect(isSupportedRlsExpression("status = 'published'")).toBe(true);
1205+
expect(isSupportedRlsExpression('id IN (current_user.org_user_ids)')).toBe(true);
1206+
expect(isSupportedRlsExpression('1 = 1')).toBe(true);
1207+
});
1208+
1209+
it('isSupportedRlsExpression rejects shapes the runtime would drop', () => {
1210+
expect(isSupportedRlsExpression('owner == current_user.name')).toBe(false); // `==`
1211+
expect(isSupportedRlsExpression('a = current_user.id AND b = 1')).toBe(false); // AND
1212+
expect(isSupportedRlsExpression('amount > 100')).toBe(false); // range
1213+
expect(isSupportedRlsExpression('')).toBe(false);
1214+
});
1215+
1216+
it('WARNS (does not silently drop) an unsupported-shape policy', () => {
1217+
const warned: string[] = [];
1218+
const compiler = new RLSCompiler();
1219+
compiler.setLogger({ warn: (message: string) => warned.push(message) });
1220+
const policy: any = { name: 'bad', object: 'thing', operation: 'select', using: 'owner == current_user.name' };
1221+
const filter = compiler.compileFilter([policy], { userId: 'u1' } as any);
1222+
expect(filter).toEqual(RLS_DENY_FILTER); // only policy dropped → fail-closed
1223+
expect(warned.length).toBe(1);
1224+
expect(warned[0]).toContain('uncompilable predicate');
1225+
});
1226+
1227+
it('does NOT warn a SUPPORTED shape whose context var is merely absent', () => {
1228+
const warned: string[] = [];
1229+
const compiler = new RLSCompiler();
1230+
compiler.setLogger({ warn: (message: string) => warned.push(message) });
1231+
// valid shape; `department` simply isn't in the context → intentional fail-closed skip.
1232+
const policy: any = { name: 'dept', object: 'thing', operation: 'select', using: 'dept = current_user.department' };
1233+
compiler.compileFilter([policy], { userId: 'u1' } as any);
1234+
expect(warned.length).toBe(0);
1235+
});
1236+
});

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ export class SecurityPlugin implements Plugin {
205205
this.metadata = metadata;
206206
this.ql = ql;
207207
this.logger = ctx.logger;
208+
this.rlsCompiler.setLogger?.(ctx.logger);
208209

209210
// Probe for OrgScopingPlugin presence. When registered, its
210211
// `init()` exposes itself as the `org-scoping` service. We capture

0 commit comments

Comments
 (0)