Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions content/docs/concepts/implementation-status.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions content/docs/guides/metadata/permission.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
]
```
Expand Down Expand Up @@ -271,7 +271,7 @@ const salesManagerPermission = {
name: 'team_accounts',
object: 'account',
operation: 'select',
using: "team = current_user.team",
using: "team == current_user.team",
},
],
};
Expand Down
2 changes: 1 addition & 1 deletion content/docs/guides/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 9 additions & 9 deletions content/docs/protocol/objectql/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`):
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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]
```

Expand All @@ -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]
```

Expand All @@ -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`).
Expand Down Expand Up @@ -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
Expand Down
16 changes: 10 additions & 6 deletions skills/objectstack-data/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<prop>`,
`field = 'literal'`, `field IN (current_user.<array>)`, or `1=1`. The
compiler resolves these `current_user.*` placeholders:
Predicates are **canonical CEL** (ADR-0058): `field == current_user.<prop>`,
`field == 'literal'`, `field in current_user.<array>`, 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 |
|:--|:--|
Expand Down