Skip to content

Commit 2e97ff4

Browse files
xuyushun441-sysos-zhuangclaude
authored
docs(security): RLS predicates SQL → canonical CEL + fix non-compilable examples (#2113)
After "RLS using → canonical CEL" (#2099) the docs/skills still taught the old SQL-ish grammar — which now misleads AI authors (the skills/docs were lying relative to main). Fix: - skills/objectstack-data/SKILL.md: drop "restricted grammar (not arbitrary CEL): field = current_user.x, IN(...), 1=1" — RLS predicates are now canonical CEL (==, in, comparisons, &&/||/!, == null), no subqueries/cross-object (compile error, ADR-0055); legacy SQL still compiles via the deprecated bridge. - protocol/objectql/security.mdx: "SQL-like" → CEL; all using/check examples to CEL; and fix three examples that NEVER compiled — an `IN (SELECT …)` subquery, a `current_user.region` (a non-exposed variable), and a `NOW()`/`AND` time window (functions aren't pushdown-able) — replaced with pre-resolved §7.3.1 membership sets / honest "do it in the app layer" notes. - guides/security.mdx, guides/metadata/permission.mdx, concepts/implementation- status.mdx: `=`/`IN` → `==`/`in` in prose + examples. Doc/skill-only. check:skill-docs in sync; no runtime change. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9addbda commit 2e97ff4

5 files changed

Lines changed: 25 additions & 21 deletions

File tree

content/docs/concepts/implementation-status.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
287287
- Client SDK supports bearer token header — but token validation requires the auth plugin
288288
- Auth route (`/auth/*`) only appears in Discovery when the auth plugin is registered
289289
- Fine-grained authorization (RLS, sharing, territory) is internal to the auth plugin
290-
- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, roles, permissions). Default `member_default` permission set ships a wildcard RLS rule `organization_id = current_user.organization_id` plus per-object overrides `sys_organization_self` (`id = current_user.organization_id`) and `sys_user_self` (`id = current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics now uses the same reusable read scope via `security.getReadFilter`, so dataset-bound dashboards/reports do not bypass RLS. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_role_permission_set`. **Anonymous traffic still falls open by default** (the default-deny flip is release-gated); since ADR-0056 D2 the server logs a boot-time warning when `requireAuth` is unset, and public forms no longer depend on the fall-open — they carry a declaration-derived `publicFormGrant` (ADR-0056 Option A).
290+
- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, roles, permissions). Default `member_default` permission set ships a wildcard RLS rule `organization_id == current_user.organization_id` plus per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics now uses the same reusable read scope via `security.getReadFilter`, so dataset-bound dashboards/reports do not bypass RLS. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_role_permission_set`. **Anonymous traffic still falls open by default** (the default-deny flip is release-gated); since ADR-0056 D2 the server logs a boot-time warning when `requireAuth` is unset, and public forms no longer depend on the fall-open — they carry a declaration-derived `publicFormGrant` (ADR-0056 Option A).
291291
- **OWD / sharing-model enforcement is live and proven end-to-end (ADR-0056)**: `private`, `public_read`, `public_read_write`, and `controlled_by_parent` are enforced through `plugin-sharing` + `plugin-security` and verified by dogfood proofs over the real HTTP stack. `object.sharingModel` now accepts the canonical OWD vocabulary (`private` / `public_read` / `public_read_write` / `controlled_by_parent`) alongside the legacy `read` / `read_write` / `full` spellings (D1). RLS owner policies resolve `current_user.email` in addition to `id` / `organization_id` / `roles` (#2054). Permission sets may declare `isDefault: true` to act as the app-declared fallback profile (D7).
292292

293293
---
@@ -413,7 +413,7 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
413413
### Phase 9: Security (Plugin) 🟡 **PHASE-1 LANDED**
414414
- [x] Authentication Plugin (`@objectstack/plugin-auth`, better-auth)
415415
- [x] Authorization Plugin — `@objectstack/plugin-security` enforces CRUD/FLS/RLS in the ObjectQL middleware chain; REST → ObjectQL now propagates `ExecutionContext` end-to-end (Phase-1)
416-
- [x] Row-Level Security — default `member_default` permission set applies a wildcard `organization_id = current_user.organization_id` rule plus per-object overrides `sys_organization_self` / `sys_user_self`. The earlier `tenantField` rewrite indirection was removed: RLS column, placeholder, and `RLSUserContext.organization_id` use the same canonical name end-to-end
416+
- [x] Row-Level Security — default `member_default` permission set applies a wildcard `organization_id == current_user.organization_id` rule plus per-object overrides `sys_organization_self` / `sys_user_self`. The earlier `tenantField` rewrite indirection was removed: RLS column, placeholder, and `RLSUserContext.organization_id` use the same canonical name end-to-end
417417
- [x] Analytics RLS bridge — `@objectstack/service-analytics` auto-bridges to `security.getReadFilter(object, context)` and fails closed when read-scope resolution cannot be safely applied
418418
- [x] Multi-tenancy — verified cross-organization isolation on `pnpm dev:crm` (Alice@OrgAlpha vs. Bob@OrgBeta only see their own records across `sys_organization`, `sys_member`, `sys_user`, and `sys_*_permission_set` link tables)
419419
- [x] Legacy `objectql.registerTenantMiddleware` removed — SecurityPlugin is now the sole tenant-isolation authority

content/docs/guides/metadata/permission.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,13 +170,13 @@ rowLevelSecurity: [
170170
name: 'own_records_only',
171171
object: 'opportunity',
172172
operation: 'select',
173-
using: "owner_id = current_user.id",
173+
using: "owner_id == current_user.id",
174174
},
175175
{
176176
name: 'same_department',
177177
object: 'account',
178178
operation: 'select',
179-
using: "department = current_user.department",
179+
using: "department == current_user.department",
180180
},
181181
]
182182
```
@@ -271,7 +271,7 @@ const salesManagerPermission = {
271271
name: 'team_accounts',
272272
object: 'account',
273273
operation: 'select',
274-
using: "team = current_user.team",
274+
using: "team == current_user.team",
275275
},
276276
],
277277
};

content/docs/guides/security.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ description: "Complete guide to implementing enterprise-grade security in Object
77

88
Complete guide to implementing enterprise-grade security in ObjectStack with fine-grained permissions and data access controls.
99

10-
> **Implementation status — Phase-1 RBAC is live.** REST → ObjectQL now propagates a populated `ExecutionContext` (userId, tenantId, roles, permissions) into the SecurityPlugin middleware, so CRUD / FLS / RLS checks actually fire on every authenticated request. The default `member_default` permission set ships a wildcard RLS rule `organization_id = current_user.organization_id` plus explicit per-object overrides `sys_organization_self` (`id = current_user.organization_id`) and `sys_user_self` (`id = current_user.id`) for the two global tables that lack an `organization_id` column. RLS expressions, the physical column, and `RLSUserContext.organization_id` all use the same canonical name — there is no `tenantField` rewrite indirection (schemas with a different physical tenant column should fork the defaults). The legacy `objectql.registerTenantMiddleware` has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics also reuses the same read scope: `@objectstack/service-analytics` auto-bridges to `security.getReadFilter(object, context)` when the security service is registered, so dataset-bound dashboards/reports do not bypass RLS. End-to-end verified on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_role_permission_set`. **Anonymous traffic still falls open by default** (the default-deny flip is release-gated); since ADR-0056 D2 the server logs a boot-time warning when `requireAuth` is unset, and public forms self-authorize via a declaration-derived `publicFormGrant` (ADR-0056 Option A — see [Public Forms](./public-forms)). Organization-Wide Defaults (`private` / `public_read` / `public_read_write` / `controlled_by_parent`) and Sharing Rules (owner + criteria, with `role_and_subordinates` hierarchy widening) are live and dogfood-proven (ADR-0056); the Studio RLS visual editor, per-user×org permission cache, and audit UI for denied access are queued. See `CHANGELOG.md` and `concepts/implementation-status.mdx` for the latest matrix.
10+
> **Implementation status — Phase-1 RBAC is live.** REST → ObjectQL now propagates a populated `ExecutionContext` (userId, tenantId, roles, permissions) into the SecurityPlugin middleware, so CRUD / FLS / RLS checks actually fire on every authenticated request. The default `member_default` permission set ships a wildcard RLS rule `organization_id == current_user.organization_id` plus explicit per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the two global tables that lack an `organization_id` column. RLS expressions, the physical column, and `RLSUserContext.organization_id` all use the same canonical name — there is no `tenantField` rewrite indirection (schemas with a different physical tenant column should fork the defaults). The legacy `objectql.registerTenantMiddleware` has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics also reuses the same read scope: `@objectstack/service-analytics` auto-bridges to `security.getReadFilter(object, context)` when the security service is registered, so dataset-bound dashboards/reports do not bypass RLS. End-to-end verified on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_role_permission_set`. **Anonymous traffic still falls open by default** (the default-deny flip is release-gated); since ADR-0056 D2 the server logs a boot-time warning when `requireAuth` is unset, and public forms self-authorize via a declaration-derived `publicFormGrant` (ADR-0056 Option A — see [Public Forms](./public-forms)). Organization-Wide Defaults (`private` / `public_read` / `public_read_write` / `controlled_by_parent`) and Sharing Rules (owner + criteria, with `role_and_subordinates` hierarchy widening) are live and dogfood-proven (ADR-0056); the Studio RLS visual editor, per-user×org permission cache, and audit UI for denied access are queued. See `CHANGELOG.md` and `concepts/implementation-status.mdx` for the latest matrix.
1111

1212
## Table of Contents
1313

content/docs/protocol/objectql/security.mdx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ app.get('/api/accounts', (req, res) => {
3333
// row-level policy filters by ownership
3434

3535
const accounts = await dataEngine.find('account');
36-
// USING (owner_id = current_user.id) injected by the RLS compiler
36+
// owner_id == current_user.id (CEL) → compiled to a row filter by the RLS compiler
3737
```
3838

3939
The model is composed of three distinct metadata types (see `packages/spec/src/security` and `packages/spec/src/identity`):
@@ -129,7 +129,7 @@ Return filtered result
129129
130130
## 2. Row-Level Security
131131
132-
Row-level filtering is expressed as **RLS policies** (`RowLevelSecurityPolicySchema` in `packages/spec/src/security/rls.zod.ts`). Policies carry a SQL-like `using` clause (for SELECT/UPDATE/DELETE) and/or a `check` clause (for INSERT/UPDATE). Multiple policies for one object are combined with OR (most-permissive wins). Available context variables are the **unique identifiers and membership sets** the runtime pre-resolves: equality predicates may use `current_user.id`, `current_user.email` (the unique, seedable owner anchor), or `current_user.organization_id`; set-membership predicates may use `id IN (current_user.org_user_ids)`, `IN (current_user.roles)`, or any §7.3.1 set staged in `ExecutionContext.rlsMembership`. Display `name` and arbitrary user fields are **intentionally not** resolvable — only unique identifiers, so an ownership predicate can never leak access through a name collision.
132+
Row-level filtering is expressed as **RLS policies** (`RowLevelSecurityPolicySchema` in `packages/spec/src/security/rls.zod.ts`). Policies carry a **CEL** `using` clause (for SELECT/UPDATE/DELETE) and/or a `check` clause (for INSERT/UPDATE) — canonical CEL since ADR-0058; a legacy SQL-style `=`/`IN (...)` predicate still compiles via a **deprecated bridge** (warns). Multiple policies for one object are combined with OR (most-permissive wins). Available context variables are the **unique identifiers and membership sets** the runtime pre-resolves: equality predicates may use `current_user.id`, `current_user.email` (the unique, seedable owner anchor), or `current_user.organization_id`; set-membership predicates may use `id in current_user.org_user_ids`, `id in current_user.roles`, or any §7.3.1 set staged in `ExecutionContext.rlsMembership`. Display `name` and arbitrary user fields are **intentionally not** resolvable — only unique identifiers, so an ownership predicate can never leak access through a name collision.
133133
134134
Policies can be attached to a permission set via its `rowLevelSecurity` array, or registered as standalone metadata.
135135
@@ -140,7 +140,7 @@ rowLevelSecurity:
140140
- name: opportunity_owner_access
141141
object: opportunity
142142
operation: select
143-
using: 'owner_id = current_user.id'
143+
using: 'owner_id == current_user.id'
144144
```
145145

146146
```typescript
@@ -158,8 +158,8 @@ rowLevelSecurity:
158158
- name: account_tenant_isolation
159159
object: account
160160
operation: all
161-
using: 'organization_id = current_user.organization_id'
162-
check: 'organization_id = current_user.organization_id'
161+
using: 'organization_id == current_user.organization_id'
162+
check: 'organization_id == current_user.organization_id'
163163
```
164164
165165
The `RLS` helper factory exports `RLS.tenantPolicy(object)` / `RLS.ownerPolicy(object)` / `RLS.rolePolicy(...)` to generate these.
@@ -173,7 +173,7 @@ rowLevelSecurity:
173173
- name: manager_team_access
174174
object: task
175175
operation: select
176-
using: 'assigned_to_id IN (SELECT id FROM users WHERE manager_id = current_user.id)'
176+
using: 'assigned_to_id in current_user.team_member_ids' # pre-resolved §7.3.1 set — NOT a subquery (ADR-0055)
177177
roles: [manager, director]
178178
```
179179

@@ -184,7 +184,7 @@ rowLevelSecurity:
184184
- name: regional_sales_access
185185
object: account
186186
operation: select
187-
using: 'region = current_user.region OR region IS NULL'
187+
using: 'territory_id in current_user.territory_ids' # pre-resolved set (current_user.region is not exposed)
188188
roles: [sales_rep]
189189
```
190190

@@ -195,7 +195,7 @@ rowLevelSecurity:
195195
- name: active_records_only
196196
object: contract
197197
operation: select
198-
using: "status = 'active' AND start_date <= NOW() AND end_date >= NOW()"
198+
using: "status == 'active'" # date-window/function predicates (NOW(), arithmetic) are NOT pushdown-able (ADR-0055) — enforce time windows in the app layer or a pre-resolved set
199199
```
200200

201201
> RLS conditions are compiled to parameterized queries. The default policy when no rule matches is **deny** (`RLSConfigSchema.defaultPolicy`).
@@ -435,7 +435,7 @@ rowLevelSecurity:
435435
- name: account_owner_access
436436
object: account
437437
operation: select
438-
using: 'owner_id = current_user.id'
438+
using: 'owner_id == current_user.id'
439439
```
440440

441441
### Defense in Depth

skills/objectstack-data/SKILL.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -439,20 +439,24 @@ rowLevelSecurity: [
439439
{
440440
name: 'own_records',
441441
operations: ['select', 'update', 'delete'],
442-
using: 'owner_id = current_user.id', // read scope
443-
check: 'owner_id = current_user.id', // write scope
442+
using: 'owner_id == current_user.id', // read scope
443+
check: 'owner_id == current_user.id', // write scope
444444
},
445445
{
446446
name: 'org_isolation',
447447
operations: ['all'],
448-
using: 'organization_id = current_user.organization_id',
448+
using: 'organization_id == current_user.organization_id',
449449
},
450450
]
451451
```
452452

453-
Predicates use a **restricted grammar** (not arbitrary CEL): `field = current_user.<prop>`,
454-
`field = 'literal'`, `field IN (current_user.<array>)`, or `1=1`. The
455-
compiler resolves these `current_user.*` placeholders:
453+
Predicates are **canonical CEL** (ADR-0058): `field == current_user.<prop>`,
454+
`field == 'literal'`, `field in current_user.<array>`, comparisons (`>`/`<`/`>=`/`<=`),
455+
`&&`/`||`/`!`, and `== null` checks all lower to a pushdown filter. **No** cross-object
456+
traversal or subqueries — those are a compile error (ADR-0055), never silently dropped.
457+
A legacy SQL-style `=` / `IN (...)` predicate still compiles via a **deprecated** bridge
458+
(emits a warning) but should be authored in CEL. The compiler resolves these
459+
`current_user.*` placeholders:
456460

457461
| Placeholder | Resolves to |
458462
|:--|:--|

0 commit comments

Comments
 (0)