Skip to content

Commit 9c40ba9

Browse files
hotlongCopilot
andcommitted
fix(plugin-security): close cross-tenant RLS leak (fail-closed)
Multi-bug regression where authenticated users — especially those without an active organization — could see data from every tenant on tables like account, sys_member, and sys_organization. Root causes (all fail-OPEN paths, now fail-CLOSED): 1. RLSCompiler.compileFilter returned null when policies were applicable but none compiled, treated by callers as "no RLS configured" → no filter → all rows. Replaced with new exported RLS_DENY_FILTER sentinel ({id: '__rls_deny__:...'}) that cleanly returns zero rows on every driver without throwing. 2. SecurityPlugin.extractTargetField regex used `\b` after `=`, but `=` and the following whitespace are both non-word chars so the word boundary could never match. The function silently returned null for *every* `field = current_user.x` policy, defeating the field-existence safety net. Replaced trailing `\b` with `(?=\s|\()` lookahead. 3. SecurityPlugin.getObjectFieldNames only handled array-shaped fields and only consulted the kernel `metadata` service. Object schemas in this runtime are dict-shaped and live on the ObjectQL registry, so the lookup always returned null → "keep all policies" → wildcard `organization_id =` policy applied to tables that don't have that column. Fixed dict-shape handling and added a fallback to `objectql.getSchema(name)`. 4. The field-existence filter dropped policies whose target field was missing on the object. Tables like CRM `account` (no organization_id) ended up with no policies at all — fail-OPEN. Dropped policies now contribute the deny sentinel, so tables that haven't adopted multi- tenancy are denied by default while per-object overrides such as `sys_user_self` still grant access via OR-combine. Verified end-to-end on `pnpm dev:crm`: - No-org user: total=0 on sys_member/sys_organization/account; total=1 on sys_user (own self via sys_user_self) - With-org user: only own org's rows on sys_member/sys_organization - Anonymous: still bypasses (documented Phase-1 limit) Tests: 33 plugin-security tests pass (3 new fail-closed cases), full runtime/rest/objectql/plugin-security regression green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6d32cc6 commit 9c40ba9

5 files changed

Lines changed: 121 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111
- **`@objectstack/driver-sql` tests failing in CI** — Added `vitest.config.ts` with resolve aliases for `@objectstack/spec/*` subpath exports (`/data`, `/contracts`, `/system`). Without these aliases, vitest could not resolve the source paths at test time, causing all 81 tests to fail with `ERR_MODULE_NOT_FOUND`.
12-
- **RLS over-filter when user has no active organization** (`@objectstack/plugin-security`) — `RLSCompiler.compileExpression` now treats a `null` user-context value the same as `undefined` and skips the policy. Before this fix, a logged-in user without an active organization (e.g. immediately after sign-up, before joining or creating one) caused the wildcard `tenant_isolation` policy to compile down to `organization_id = null`, which (a) returned zero rows from system tables that lack an `organization_id` column at all (e.g. `sys_user`) and (b) silently exposed any un-tenanted rows on application tables. The compiler now emits no filter for the `null` case, deferring to the more specific per-object rules (`sys_user_self`, `sys_organization_self`, …) and the field-existence safety net in `SecurityPlugin`. Two regression tests added in `packages/plugins/plugin-security/src/security-plugin.test.ts`. **Symptom this resolves:** the `_dashboard/apps/setup/sys_user` page now displays the signed-in user's own row instead of an empty list. Same fix applied to the `IN (current_user.array_property)` form for empty/missing arrays.
12+
- **RLS fail-open across tenants** (`@objectstack/plugin-security`) — A logged-in user with no active organization (e.g. immediately after sign-up, before joining or creating one) was previously seeing every tenant's data on `account`, `sys_member`, `sys_organization`, etc. Multiple compounding bugs were responsible:
13+
1. `RLSCompiler.compileFilter` returned `null` when policies were applicable but none compiled — interpreted by callers as "no RLS configured" → no filter → all rows. **Fix:** introduced exported `RLS_DENY_FILTER` sentinel (`{ id: '__rls_deny__:00000000-0000-0000-0000-000000000000' }`); `compileFilter` now returns it when `policies.length > 0` but every policy expression failed to compile (missing `current_user.*` variable, unsupported expression, etc.). This naturally yields zero rows on every driver without throwing.
14+
2. `SecurityPlugin.extractTargetField` used the regex `/^\s*([a-z_][a-z0-9_]*)\s*(=|IN|in)\b/` — the `\b` after `=` could never match (both `=` and the following whitespace are non-word characters, so no word boundary exists). The function silently returned `null` for *every* `field = current_user.x` policy, defeating the field-existence safety net entirely. **Fix:** replaced the trailing `\b` with a `(?=\s|\()` lookahead.
15+
3. `SecurityPlugin.getObjectFieldNames` only handled `Array<{ name }>`-shaped fields and only consulted the kernel's `metadata` service — but object schemas in this runtime are dict-shaped (`fields: { name: Field.text(...), … }`) and live on the ObjectQL registry, not the metadata-manager. The lookup always returned `null`, which short-circuited to "keep all policies" → tables that lacked `organization_id` (e.g. CRM `account`, `sys_organization`) had a bogus `organization_id = …` filter applied, which different drivers interpreted differently (some erroring, some silently returning 0/all). **Fix:** the helper now handles both array and dict shapes and falls back to `objectql.getSchema(name)` when the metadata service has no entry.
16+
4. The field-existence filter previously *dropped* policies whose target column didn't exist on the object, leaving the policy set empty for tables like `account` (no `organization_id`) — fail-OPEN. **Fix:** policies dropped for missing fields now contribute the deny sentinel, so tables that haven't adopted multi-tenancy are denied by default; per-object overrides such as `sys_user_self` (`id = current_user.id`) still grant access via OR-combine.
17+
Three regression tests added in `packages/plugins/plugin-security/src/security-plugin.test.ts` (unsupported expression → deny, missing user-context variable → deny, valid tenant policy still compiles normally). End-to-end verified on `pnpm dev:crm`: a user without an active org gets `total: 0` on `sys_member`/`sys_organization`/`account`; a user with an active org sees only their org's rows.
1318
- **`PermissionDeniedError` returned as HTTP 404 instead of 403** (`@objectstack/rest`) — `mapDataError` previously fell through to the unknown-object heuristic for any error message containing the object name and the substring `"not"`, which matched the security message `"… on object 'sys_user' is not permitted …"`. The mapper now short-circuits on `error.code === 'PERMISSION_DENIED'` / `error.name === 'PermissionDeniedError'` / message-prefix match before the heuristic and returns HTTP 403 with `code: 'PERMISSION_DENIED'`. The CRUD route catches in `RestServer` also stop logging 403s as `[REST] Unhandled error:` (4 sites updated).
1419

1520
### Added

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
export { SecurityPlugin } from './security-plugin.js';
1111
export { PermissionEvaluator } from './permission-evaluator.js';
12-
export { RLSCompiler } from './rls-compiler.js';
12+
export { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js';
1313
export { FieldMasker } from './field-masker.js';
1414
export { PermissionDeniedError, isPermissionDeniedError } from './errors.js';
1515
export {

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,25 @@ interface RLSUserContext {
2121
[key: string]: unknown;
2222
}
2323

24+
/**
25+
* Sentinel filter used when applicable RLS policies exist but none can
26+
* be compiled against the current execution context (typically because a
27+
* required `current_user.*` variable is missing — e.g. the user has no
28+
* active organization). The filter compares `id` against a non-printable
29+
* UUID-shaped string that no real record will ever carry, so the upstream
30+
* SQL layer naturally returns zero rows without raising an error. This
31+
* gives us **fail-closed** semantics for select/update/delete on tables
32+
* that the user is not entitled to see, without forcing every caller to
33+
* handle a thrown `PermissionDeniedError` for what is conceptually an
34+
* empty result set.
35+
*
36+
* Exposed for the SecurityPlugin's optional short-circuit path and for
37+
* tests; see {@link RLSCompiler.compileFilter}.
38+
*/
39+
export const RLS_DENY_FILTER: Record<string, unknown> = Object.freeze({
40+
id: '__rls_deny__:00000000-0000-0000-0000-000000000000',
41+
});
42+
2443
/**
2544
* RLSCompiler
2645
*
@@ -31,6 +50,15 @@ export class RLSCompiler {
3150
/**
3251
* Compile RLS policies into a query filter for the given user context.
3352
* Multiple policies for the same object/operation are OR-combined (any match allows access).
53+
*
54+
* Return-value semantics:
55+
* - `null` → no policies applicable → caller applies no RLS filter.
56+
* - non-null → caller AND's it onto the existing where clause.
57+
* - {@link RLS_DENY_FILTER} → policies were defined but none could be
58+
* compiled (e.g. wildcard `tenant_isolation` against a user with no
59+
* active organization). The caller must treat this as "deny by
60+
* default" — its `id` comparison naturally yields zero rows on
61+
* select/update/delete, which is the safe fail-closed answer.
3462
*/
3563
compileFilter(
3664
policies: RowLevelSecurityPolicy[],
@@ -54,7 +82,14 @@ export class RLSCompiler {
5482
}
5583
}
5684

57-
if (filters.length === 0) return null;
85+
if (filters.length === 0) {
86+
// Policies *were* applicable but every one of them depended on a
87+
// `current_user.*` variable that wasn't populated (or used an
88+
// expression we couldn't compile). Fail closed — return a sentinel
89+
// filter that matches no rows. This prevents the "user without an
90+
// active org sees every tenant's data" class of bug.
91+
return RLS_DENY_FILTER;
92+
}
5893
if (filters.length === 1) return filters[0];
5994

6095
// Multiple policies: OR-combine (any policy allows access)

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

Lines changed: 26 additions & 12 deletions
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 } from './rls-compiler.js';
7+
import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js';
88
import type { PermissionSet } from '@objectstack/spec/security';
99

1010
// ---------------------------------------------------------------------------
@@ -310,30 +310,35 @@ describe('RLSCompiler', () => {
310310
expect(filter).toEqual({ $or: [{ owner_id: 'u99' }, { status: 'public' }] });
311311
});
312312

313-
it('should return null for unsupported expression', () => {
313+
it('should fail-closed (deny filter) when expression is unsupported', () => {
314+
// Previously returned null, which is fail-OPEN (no RLS applied →
315+
// every row visible). Now returns the deny sentinel so a misconfigured
316+
// policy doesn't silently disable tenant isolation.
314317
const compiler = new RLSCompiler();
315318
const policy: any = { object: 'x', operation: 'select', using: 'complex expression WITH unsupported syntax' };
316319
const filter = compiler.compileFilter([policy]);
317-
expect(filter).toBeNull();
320+
expect(filter).toEqual(RLS_DENY_FILTER);
318321
});
319322

320-
it('should skip equality policy when user-context value is null', () => {
323+
it('should fail-closed when the only policy depends on a missing user-context value', () => {
321324
// Repro: a logged-in user without an active organization. The
322-
// tenant_isolation rule would otherwise emit `organization_id = null`,
323-
// which silently exposes un-tenanted rows or breaks queries on system
324-
// tables that lack the column. Treating null as "skip" is safe because
325-
// the field-existence check + most-permissive permission-set merge
326-
// remain in force.
325+
// tenant_isolation rule would compile to `organization_id = null`,
326+
// which previously either (a) returned every row from tables that
327+
// lack `organization_id` (e.g. sys_user) or (b) returned every row
328+
// because compileFilter dropped it as null. We now fail-closed so
329+
// the user sees zero rows on tenant-aware tables until they pick an
330+
// active organization. Per-object rules that *do* compile (e.g.
331+
// `sys_user_self`) still grant access — see the next test.
327332
const compiler = new RLSCompiler();
328333
const policy: any = { object: '*', operation: 'all', using: 'organization_id = current_user.organization_id' };
329334
const ctx: any = { userId: 'u1', tenantId: null, roles: [] };
330335
const filter = compiler.compileFilter([policy], ctx);
331-
expect(filter).toBeNull();
336+
expect(filter).toEqual(RLS_DENY_FILTER);
332337
});
333338

334339
it('should still apply policy when only one of multiple has a usable value', () => {
335-
// Same scenario as above but combined with a self-row rule. The
336-
// organization_id rule should drop out, leaving only `id = current_user.id`.
340+
// tenant policy can't compile (null tenantId) but sys_user_self can.
341+
// Result: just the self-row filter — the broken tenant policy drops out.
337342
const compiler = new RLSCompiler();
338343
const tenantPolicy: any = { object: '*', operation: 'all', using: 'organization_id = current_user.organization_id' };
339344
const selfPolicy: any = { object: 'sys_user', operation: 'select', using: 'id = current_user.id' };
@@ -342,6 +347,15 @@ describe('RLSCompiler', () => {
342347
expect(filter).toEqual({ id: 'u1' });
343348
});
344349

350+
it('should compile tenant policy normally when an active organization is set', () => {
351+
// Sanity check — the deny path is only triggered by *missing* values.
352+
const compiler = new RLSCompiler();
353+
const policy: any = { object: '*', operation: 'all', using: 'organization_id = current_user.organization_id' };
354+
const ctx: any = { userId: 'u1', tenantId: 'org-1', roles: [] };
355+
const filter = compiler.compileFilter([policy], ctx);
356+
expect(filter).toEqual({ organization_id: 'org-1' });
357+
});
358+
345359
it('should get applicable policies for object and operation', () => {
346360
const compiler = new RLSCompiler();
347361
const policies: any[] = [

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

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { Plugin, PluginContext } from '@objectstack/core';
44
import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security';
55
import { PermissionEvaluator } from './permission-evaluator.js';
6-
import { RLSCompiler } from './rls-compiler.js';
6+
import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js';
77
import { FieldMasker } from './field-masker.js';
88
import { PermissionDeniedError } from './errors.js';
99
import {
@@ -175,19 +175,33 @@ export class SecurityPlugin implements Plugin {
175175
// 3. RLS filter injection
176176
const allRlsPolicies = this.collectRLSPolicies(permissionSets, opCtx.object, opCtx.operation);
177177
if (allRlsPolicies.length > 0 && opCtx.ast) {
178-
// Drop policies whose target field doesn't exist on the object —
179-
// wildcard policies like `organization_id = ...` must not corrupt
180-
// queries against system tables that lack the column (sys_jwks,
181-
// sys_audit_log, etc.). When schema lookup fails we keep the
182-
// policy (fail-closed for unknown objects).
183-
const objectFields = await this.getObjectFieldNames(metadata, opCtx.object);
184-
const safe = objectFields
178+
// Field-existence safety: wildcard policies (`object: '*'`) target
179+
// fields like `organization_id` that may not exist on every object
180+
// (e.g. system tables, CRM apps that haven't yet adopted multi-tenancy).
181+
//
182+
// We treat such policies as a *deny* contribution rather than dropping
183+
// them, so they fail-closed when no per-object policy provides an
184+
// alternate match. Any per-object policy that DOES compile against
185+
// the object will OR-combine and grant access (e.g. `sys_user_self`).
186+
// When the schema lookup itself fails we keep all policies (drivers
187+
// will surface column errors clearly during compilation).
188+
const objectFields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
189+
let dropped = 0;
190+
const compilable = objectFields
185191
? allRlsPolicies.filter((p) => {
186192
const targetField = this.extractTargetField(p.using);
187-
return targetField ? objectFields.has(targetField) : true;
193+
const ok = targetField ? objectFields.has(targetField) : true;
194+
if (!ok) dropped++;
195+
return ok;
188196
})
189197
: allRlsPolicies;
190-
const rlsFilter = this.rlsCompiler.compileFilter(safe, opCtx.context);
198+
let rlsFilter = this.rlsCompiler.compileFilter(compilable, opCtx.context);
199+
// If every applicable policy was dropped because of missing fields,
200+
// contribute the deny sentinel (zero rows) — matches the rls-compiler
201+
// semantics for "policies were applicable but none compiled".
202+
if (rlsFilter == null && dropped > 0) {
203+
rlsFilter = { ...RLS_DENY_FILTER };
204+
}
191205
if (rlsFilter) {
192206
if (opCtx.ast.where) {
193207
opCtx.ast.where = { $and: [opCtx.ast.where, rlsFilter] };
@@ -241,13 +255,31 @@ export class SecurityPlugin implements Plugin {
241255
private async getObjectFieldNames(
242256
metadata: any,
243257
objectName: string,
258+
ql?: any,
244259
): Promise<Set<string> | null> {
245260
try {
246-
const obj = await metadata?.get?.('object', objectName);
247-
if (!obj || !Array.isArray(obj.fields)) return null;
261+
let obj = await metadata?.get?.('object', objectName);
262+
// Fallback: in some runtimes the kernel's `metadata` service does not
263+
// hold object schemas (those live on the ObjectQL registry instead).
264+
// Ask ObjectQL directly so wildcard RLS filtering still works against
265+
// identity tables (sys_organization, sys_user, etc.).
266+
if ((!obj || !obj.fields) && typeof ql?.getSchema === 'function') {
267+
obj = ql.getSchema(objectName);
268+
}
269+
if (!obj || !obj.fields) return null;
248270
const set = new Set<string>(['id']);
249-
for (const f of obj.fields) {
250-
if (f?.name) set.add(String(f.name));
271+
if (Array.isArray(obj.fields)) {
272+
for (const f of obj.fields) {
273+
if (f?.name) set.add(String(f.name));
274+
}
275+
} else if (typeof obj.fields === 'object') {
276+
for (const key of Object.keys(obj.fields)) {
277+
set.add(key);
278+
const v = (obj.fields as Record<string, any>)[key];
279+
if (v && typeof v === 'object' && v.name) set.add(String(v.name));
280+
}
281+
} else {
282+
return null;
251283
}
252284
return set;
253285
} catch {
@@ -262,7 +294,12 @@ export class SecurityPlugin implements Plugin {
262294
*/
263295
private extractTargetField(using?: string): string | null {
264296
if (!using) return null;
265-
const m = using.match(/^\s*([a-z_][a-z0-9_]*)\s*(=|IN|in)\b/);
297+
// Match `field =` or `field IN`/`in`. Note: `\b` is omitted after `=`
298+
// because `=` is non-word and the next char (space) is non-word too —
299+
// a word boundary cannot exist between two non-word chars, so `=\b`
300+
// would never match. We instead require the alternation token to be
301+
// followed by whitespace or `(`.
302+
const m = using.match(/^\s*([a-z_][a-z0-9_]*)\s*(?:=|IN|in)(?=\s|\()/);
266303
return m ? m[1] : null;
267304
}
268305
}

0 commit comments

Comments
 (0)