From 3cb020934bb5c820477543d23854533e04d19d1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 10:48:27 +0000 Subject: [PATCH] docs(security): sync hand-written docs + data skill to ADR-0056 landings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nine ADR-0056 PRs merged but updated only one doc line (the email-RLS note in #2054). This brings the hand-written security docs and the data authoring skill in line with what actually shipped and is dogfood-proven: - public-forms: public forms are now self-authorizing via a declaration- derived `publicFormGrant` (create + read-back on the form's target object). A `guest_portal` profile is no longer required — it is retained only for back-compat (hooks detecting a guest via falsy `ctx.user?.id`). (ADR-0056 Option A, #2073) - implementation-status + security guide: corrected the "anonymous traffic bypasses enforcement" posture (boot warning lands in D2; public forms no longer depend on the fall-open; the default-deny flip is release-gated); marked OWD/sharing-model enforcement, the sharing-rule evaluator, and the app-declarable default profile as live. - security guide: documented the `role_and_subordinates` sharing recipient (configurable role-hierarchy widening, D6). - permissions-matrix: `object.sharingModel` now accepts the canonical OWD vocabulary (`private`/`public_read`/`public_read_write`/ `controlled_by_parent`) alongside the legacy spellings (D1). - objectstack-data skill: corrected the RLS section to the enforced `rowLevelSecurity` policy shape (`using`/`check` + `current_user.*` placeholders, incl. the new `current_user.email`), and flagged the object-level CEL `rls` config as experimental (D8). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx --- .../docs/concepts/implementation-status.mdx | 9 ++-- .../guides/cheatsheets/permissions-matrix.mdx | 2 +- content/docs/guides/public-forms.mdx | 29 ++++++++--- content/docs/guides/security.mdx | 25 +++++++++- skills/objectstack-data/SKILL.md | 49 ++++++++++++++----- 5 files changed, 90 insertions(+), 24 deletions(-) diff --git a/content/docs/concepts/implementation-status.mdx b/content/docs/concepts/implementation-status.mdx index 85468380d1..737662b968 100644 --- a/content/docs/concepts/implementation-status.mdx +++ b/content/docs/concepts/implementation-status.mdx @@ -287,7 +287,8 @@ 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 bypasses enforcement** until a default-deny pass lands. +- **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). --- @@ -416,8 +417,10 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity - [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 -- [ ] Default-deny for anonymous traffic -- [ ] Sharing Rule evaluator +- [x] Organization-Wide Defaults / sharing model — `private`, `public_read`, `public_read_write`, and `controlled_by_parent` enforced via `plugin-sharing` + `plugin-security`, proven by dogfood over the real HTTP stack (ADR-0056). `object.sharingModel` accepts the canonical OWD vocabulary alongside the legacy `read` / `read_write` / `full` spellings (D1) +- [x] Sharing Rule evaluator — owner + criteria rules re-evaluated on `afterInsert` / `afterUpdate` (`plugin-sharing/rule-hooks.ts`); recipients include user / role / group and configurable `role_and_subordinates` role-hierarchy widening (ADR-0056 D6) +- [x] App-declarable default profile — a permission set may set `isDefault: true` to be the fallback profile for unassigned users (ADR-0056 D7) +- [ ] Default-deny for anonymous traffic — boot-time warning lands when `requireAuth` is unset (ADR-0056 D2) and public forms self-authorize via `publicFormGrant` (Option A); the global default-deny **flip** is release-gated - [ ] Studio RLS visual editor - [ ] Per-user×org permission cache - [ ] Audit UI / denied-access logging diff --git a/content/docs/guides/cheatsheets/permissions-matrix.mdx b/content/docs/guides/cheatsheets/permissions-matrix.mdx index 88f56bb813..38cb7326f4 100644 --- a/content/docs/guides/cheatsheets/permissions-matrix.mdx +++ b/content/docs/guides/cheatsheets/permissions-matrix.mdx @@ -179,7 +179,7 @@ OWD sets the baseline access level for each object across the entire organizatio | **Full Access** | `full` | All users | All users (incl. transfer/share) | Fully collaborative data | -A standalone `OWDModel` enum also exists (`packages/spec/src/security/sharing.zod.ts`) with the values `private`, `public_read`, `public_read_write`, and `controlled_by_parent` — the last is for child objects in master-detail relationships, where access is derived from the parent record. Note the per-object `sharingModel` field (`read`/`read_write`) and `OWDModel` (`public_read`/`public_read_write`) use different value spellings. +Since ADR-0056 (D1), `object.sharingModel` accepts the **canonical OWD vocabulary** — `private`, `public_read`, `public_read_write`, and `controlled_by_parent` — *in addition to* the legacy `read` / `read_write` / `full` spellings shown above (both are valid; the canonical values are preferred for new objects). `controlled_by_parent` is for child objects in a master-detail relationship, where access is **derived from the parent record** (a line is visible/editable only if its master is). These models are enforced by `plugin-sharing` + `plugin-security` and dogfood-proven over the real HTTP stack. ### Configuration Example diff --git a/content/docs/guides/public-forms.mdx b/content/docs/guides/public-forms.mdx index ac39212897..897a5bc327 100644 --- a/content/docs/guides/public-forms.mdx +++ b/content/docs/guides/public-forms.mdx @@ -9,7 +9,7 @@ ObjectStack Forms are Airtable-style **metadata-driven** forms with two render m | Mode | Route | Auth | Spec source | Submit target | |---|---|---|---|---| -| **Public** | `/console/f/:slug` | anonymous | `GET /api/v1/forms/:slug` (resolved by `sharing.publicLink`) | `POST /api/v1/forms/:slug/submit` (field whitelist + `guest_portal` profile) | +| **Public** | `/console/f/:slug` | anonymous | `GET /api/v1/forms/:slug` (resolved by `sharing.publicLink`) | `POST /api/v1/forms/:slug/submit` (field whitelist + declaration-derived `publicFormGrant`) | | **Internal** | `/console/forms/:name` | authed | `GET /api/v1/meta/view/:name` (+ `meta/object/:object`) | `POST /api/v1/data/:object` (full RBAC) | Both modes: @@ -31,14 +31,24 @@ GET /api/v1/forms/:slug → rest-server scans views → ← { form, objectSchema } POST /api/v1/forms/:slug/ → field whitelist (form) - submit → inject permissions: - ['guest_portal'] → + submit → derive publicFormGrant: + { object:
} → protocol.createData() → INSERT ← { object, id, record } ``` Only the spec'd whitelist of form fields is accepted; everything else (status, owner, internal_notes, …) is stripped server-side. Lifecycle hooks then stamp server-controlled defaults. +> **Self-authorizing forms (ADR-0056 Option A).** The submit route does **not** +> require a deployment-configured profile. It derives a narrow `publicFormGrant` +> from the form's own declaration — `{ object: }` — +> and the SecurityPlugin authorizes **only** create + the immediate read-back on +> exactly that object, never anything else and never the anonymous fall-open. So +> public forms work under secure-by-default (`requireAuth: true`) with **no** +> `guest_portal` profile. The `guest_portal` permission set + `anonymous` flag +> are still attached for **back-compat** (object hooks that detect a guest via a +> falsy `ctx.user?.id`), but they are no longer the authorization mechanism. + ## 1. Declare the form view ```ts @@ -87,9 +97,14 @@ export default defineView({ > - Anything not in the `sections[].fields[]` whitelist is silently stripped at submit time. Treat the whitelist as the form's authoritative "what the public is allowed to set" list. > - Multiple form views per object are fine — only the one(s) with `sharing.allowAnonymous === true` are exposed. -## 2. Create the `guest_portal` profile +## 2. (Optional) Create the `guest_portal` profile -The submit handler attaches `permissions: ['guest_portal']` to the anonymous execution context, so you must define a profile by that name (or rename and adjust the handler logic). Restrict it to INSERT-only on the target object. +Authorization no longer depends on this profile — the declaration-derived +`publicFormGrant` (see the note above) is what permits the insert. You only need +a `guest_portal` profile if you rely on the legacy back-compat path (e.g. an +older runtime, or object hooks that branch on the `guest_portal` permission). +When present it is still attached to the anonymous context, so keep it +INSERT-only on the target object. ```ts // hotcrm/src/profiles/guest-portal.profile.ts @@ -214,7 +229,7 @@ The companion `GET /api/v1/forms/:slug` route returns `500 FORM_RESOLVE_FAILED` ### Auth model - Neither route calls `enforceAuth`, so they work even when the project is configured with `requireAuth: true`. -- The execution context handed to ObjectQL is `{ permissions: ['guest_portal'], anonymous: true }` with no `userId`. The Security plugin resolves the profile and enforces its allow-list. +- The execution context handed to ObjectQL is `{ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }` with no `userId`. The Security plugin honors `publicFormGrant` first — a create + read-back grant scoped to exactly the declared object — so authorization holds even without a `guest_portal` profile. `permissions: ['guest_portal']` is retained for back-compat. - No CSRF or auth header is needed; embed the form on any domain. ## 5. Embedding from a front-end @@ -372,7 +387,7 @@ The same FormView can therefore drive **three** experiences — public collectio - [x] **Whitelist enforced server-side** — clients cannot widen the field set by hand-crafting JSON. - [x] **Hook strips server-controlled fields** — `owner`, `status`, `internal_notes`, `is_*` flags, conversion fields are removed even if they survive the whitelist. -- [x] **Profile is INSERT-only** — anonymous callers cannot read, edit, or delete via the form path. +- [x] **Grant is create + read-back only** — the declaration-derived `publicFormGrant` authorizes only insert (and the immediate read-back) on the form's target object; anonymous callers cannot read other records, edit, or delete via the form path. - [x] **No tenancy leakage** — submissions land on the project resolved from hostname/path, not from a client-supplied tenant id. - [ ] **Rate limiting / captcha** — not built in. Add a reverse-proxy rate limit (e.g. nginx, Cloudflare) or fronting plugin if the form sits on the public internet. - [ ] **Schema disclosure** — `GET /forms/:slug` returns labels and select options for whitelisted fields. If any of those are commercially sensitive, do not include them in the form. diff --git a/content/docs/guides/security.mdx b/content/docs/guides/security.mdx index 6b26ed3185..de2e24db85 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 bypasses enforcement** until a default-deny pass lands; Sharing Rules, 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 @@ -399,7 +399,7 @@ export const AccountTeamSharingRule: SharingRule = { // Predicate (CEL): which records to share condition: P`record.type == "customer" && record.is_active == true`, - // Who to share with (a single recipient: user, group, role, etc.) + // Who to share with (a single recipient — see the recipient types below) sharedWith: { type: 'role', value: 'sales_manager', @@ -410,6 +410,27 @@ export const AccountTeamSharingRule: SharingRule = { }; ``` +#### Recipient types + +`sharedWith` (and `ownedBy`) accept a `{ type, value }` recipient. The supported +`type` values are: + +| `type` | Shares with | +|:--|:--| +| `user` | A single user | +| `group` | All members of a public group | +| `role` | Everyone assigned that role | +| `role_and_subordinates` | Everyone in that role **and every role below it** in the hierarchy (ADR-0056 D6) — configurable per rule, so one rule can cascade down a branch of the org chart | + +```typescript +// Share with a sales manager AND everyone reporting up to them. +sharedWith: { type: 'role_and_subordinates', value: 'sales_manager' }, +``` + +The recipient set is expanded by `@objectstack/plugin-sharing` when the rule is +evaluated (`afterInsert` / `afterUpdate`); `role_and_subordinates` walks the +`sys_role.parent` graph (cycle-safe). + ### Owner-Based Sharing Rules Share based on record owner characteristics: diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index 5e9304b3f8..8860c96b29 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -426,27 +426,54 @@ permissions: { ### Row-Level Security (RLS) -Filter records visible to a role using a CEL predicate. Returns the rows the -caller may see — the runtime ANDs it into every query. +The **enforced** RLS surface is a list of `rowLevelSecurity` policies on a +**permission set / profile** (`PermissionSetSchema.rowLevelSecurity`), *not* a +CEL predicate on the object. Each policy carries a `using` (read filter) and/or +`check` (write filter) **string** predicate. The compiler ANDs `using` into +every read for users carrying that set; `check` gates writes. (`@objectstack/plugin-security` +re-reads the target row through the write filter before single-id `update`/`delete`.) ```typescript -rls: [ +// in a *.profile.ts / permission-set +rowLevelSecurity: [ { name: 'own_records', - roles: ['sales'], - predicate: P`record.owner_id == os.user.id`, + operations: ['select', 'update', 'delete'], + using: 'owner_id = current_user.id', // read scope + check: 'owner_id = current_user.id', // write scope }, { - name: 'territory_scope', - roles: ['sales_manager'], - predicate: P`record.territory in os.user.managedTerritories`, + name: 'org_isolation', + operations: ['all'], + using: 'organization_id = current_user.organization_id', }, ] ``` -- Source: `node_modules/@objectstack/spec/src/security/rls.zod.ts` -- The CEL predicate uses the same syntax as formulas — load - **objectstack-formula** when authoring complex predicates. +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: + +| Placeholder | Resolves to | +|:--|:--| +| `current_user.id` | the caller's user id (ownership) | +| `current_user.email` | the caller's email (ADR-0056 #2054) | +| `current_user.organization_id` | the caller's tenant | +| `current_user.org_user_ids` | ids of users in the same org (for `IN`) | +| `current_user.roles` | the caller's roles (for `IN`) | + +- Source: `node_modules/@objectstack/spec/src/security/permission.zod.ts` (policy shape), + `node_modules/@objectstack/spec/src/security/rls.zod.ts` (predicate grammar). +- Owner-scoping shortcut: the built-in `member_default` set already owner-scopes + writes via `owner_only_writes` / `owner_only_deletes`, and an object's + `sharingModel` (`private` / `public_read` / `controlled_by_parent`, ADR-0056 D1) + is the declarative way to set the org-wide default — prefer those over + hand-written policies for the common cases. + +> **Experimental:** a separate object-level `rls` config with a free-form CEL +> `predicate` exists in `rls.zod.ts` but is marked experimental (ADR-0056 D8) and +> is **not** the path the runtime compiles/enforces. Author RLS as +> `rowLevelSecurity` policies as shown above. ### Field-level encryption