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
9 changes: 6 additions & 3 deletions content/docs/concepts/implementation-status.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/guides/cheatsheets/permissions-matrix.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

<Callout type="info">
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.
</Callout>

### Configuration Example
Expand Down
29 changes: 22 additions & 7 deletions content/docs/guides/public-forms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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: <form target> }
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: <the form's target 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
25 changes: 23 additions & 2 deletions 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 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

Expand Down Expand Up @@ -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',
Expand All @@ -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:
Expand Down
Loading