You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+6-1Lines changed: 6 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -9,7 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
10
10
### Fixed
11
11
-**`@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.
13
18
-**`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).
0 commit comments