diff --git a/content/docs/concepts/implementation-status.mdx b/content/docs/concepts/implementation-status.mdx index 737662b968..d733f25ab3 100644 --- a/content/docs/concepts/implementation-status.mdx +++ b/content/docs/concepts/implementation-status.mdx @@ -287,7 +287,7 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity - Client SDK supports bearer token header — but token validation requires the auth plugin - Auth route (`/auth/*`) only appears in Discovery when the auth plugin is registered - Fine-grained authorization (RLS, sharing, territory) is internal to the auth plugin -- **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). +- **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). - **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). --- @@ -413,7 +413,7 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity ### Phase 9: Security (Plugin) 🟡 **PHASE-1 LANDED** - [x] Authentication Plugin (`@objectstack/plugin-auth`, better-auth) - [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) -- [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 +- [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 - [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 - [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) - [x] Legacy `objectql.registerTenantMiddleware` removed — SecurityPlugin is now the sole tenant-isolation authority diff --git a/content/docs/guides/metadata/permission.mdx b/content/docs/guides/metadata/permission.mdx index 6ffe709429..6b6460e810 100644 --- a/content/docs/guides/metadata/permission.mdx +++ b/content/docs/guides/metadata/permission.mdx @@ -170,13 +170,13 @@ rowLevelSecurity: [ name: 'own_records_only', object: 'opportunity', operation: 'select', - using: "owner_id = current_user.id", + using: "owner_id == current_user.id", }, { name: 'same_department', object: 'account', operation: 'select', - using: "department = current_user.department", + using: "department == current_user.department", }, ] ``` @@ -271,7 +271,7 @@ const salesManagerPermission = { name: 'team_accounts', object: 'account', operation: 'select', - using: "team = current_user.team", + using: "team == current_user.team", }, ], }; diff --git a/content/docs/guides/security.mdx b/content/docs/guides/security.mdx index de2e24db85..b95f96c3a5 100644 --- a/content/docs/guides/security.mdx +++ b/content/docs/guides/security.mdx @@ -7,7 +7,7 @@ description: "Complete guide to implementing enterprise-grade security in Object Complete guide to implementing enterprise-grade security in ObjectStack with fine-grained permissions and data access controls. -> **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. +> **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. ## Table of Contents diff --git a/content/docs/protocol/objectql/security.mdx b/content/docs/protocol/objectql/security.mdx index c800852e89..bcf6b2563a 100644 --- a/content/docs/protocol/objectql/security.mdx +++ b/content/docs/protocol/objectql/security.mdx @@ -33,7 +33,7 @@ app.get('/api/accounts', (req, res) => { // row-level policy filters by ownership const accounts = await dataEngine.find('account'); -// USING (owner_id = current_user.id) injected by the RLS compiler +// owner_id == current_user.id (CEL) → compiled to a row filter by the RLS compiler ``` 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 ## 2. Row-Level Security -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. +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. Policies can be attached to a permission set via its `rowLevelSecurity` array, or registered as standalone metadata. @@ -140,7 +140,7 @@ rowLevelSecurity: - name: opportunity_owner_access object: opportunity operation: select - using: 'owner_id = current_user.id' + using: 'owner_id == current_user.id' ``` ```typescript @@ -158,8 +158,8 @@ rowLevelSecurity: - name: account_tenant_isolation object: account operation: all - using: 'organization_id = current_user.organization_id' - check: 'organization_id = current_user.organization_id' + using: 'organization_id == current_user.organization_id' + check: 'organization_id == current_user.organization_id' ``` The `RLS` helper factory exports `RLS.tenantPolicy(object)` / `RLS.ownerPolicy(object)` / `RLS.rolePolicy(...)` to generate these. @@ -173,7 +173,7 @@ rowLevelSecurity: - name: manager_team_access object: task operation: select - using: 'assigned_to_id IN (SELECT id FROM users WHERE manager_id = current_user.id)' + using: 'assigned_to_id in current_user.team_member_ids' # pre-resolved §7.3.1 set — NOT a subquery (ADR-0055) roles: [manager, director] ``` @@ -184,7 +184,7 @@ rowLevelSecurity: - name: regional_sales_access object: account operation: select - using: 'region = current_user.region OR region IS NULL' + using: 'territory_id in current_user.territory_ids' # pre-resolved set (current_user.region is not exposed) roles: [sales_rep] ``` @@ -195,7 +195,7 @@ rowLevelSecurity: - name: active_records_only object: contract operation: select - using: "status = 'active' AND start_date <= NOW() AND end_date >= NOW()" + 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 ``` > RLS conditions are compiled to parameterized queries. The default policy when no rule matches is **deny** (`RLSConfigSchema.defaultPolicy`). @@ -435,7 +435,7 @@ rowLevelSecurity: - name: account_owner_access object: account operation: select - using: 'owner_id = current_user.id' + using: 'owner_id == current_user.id' ``` ### Defense in Depth diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index 8860c96b29..ea55113545 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -439,20 +439,24 @@ rowLevelSecurity: [ { name: 'own_records', operations: ['select', 'update', 'delete'], - using: 'owner_id = current_user.id', // read scope - check: 'owner_id = current_user.id', // write scope + using: 'owner_id == current_user.id', // read scope + check: 'owner_id == current_user.id', // write scope }, { name: 'org_isolation', operations: ['all'], - using: 'organization_id = current_user.organization_id', + using: 'organization_id == current_user.organization_id', }, ] ``` -Predicates use a **restricted grammar** (not arbitrary CEL): `field = current_user.`, -`field = 'literal'`, `field IN (current_user.)`, or `1=1`. The -compiler resolves these `current_user.*` placeholders: +Predicates are **canonical CEL** (ADR-0058): `field == current_user.`, +`field == 'literal'`, `field in current_user.`, comparisons (`>`/`<`/`>=`/`<=`), +`&&`/`||`/`!`, and `== null` checks all lower to a pushdown filter. **No** cross-object +traversal or subqueries — those are a compile error (ADR-0055), never silently dropped. +A legacy SQL-style `=` / `IN (...)` predicate still compiles via a **deprecated** bridge +(emits a warning) but should be authored in CEL. The compiler resolves these +`current_user.*` placeholders: | Placeholder | Resolves to | |:--|:--|