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
16 changes: 16 additions & 0 deletions .changeset/destructive-ops-rbac-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/plugin-security": minor
"@objectstack/spec": patch
---

feat(security): pre-map `transfer`/`restore`/`purge` to their RBAC bits (#1883)

The permission evaluator now maps the destructive record-lifecycle operations
to their spec permission bits (`transfer` → `allowTransfer`, `restore` →
`allowRestore`, `purge` → `allowPurge`) and extends the `modifyAllRecords`
super-user bypass to cover them. The ObjectQL operations themselves are still
roadmap M2 — but the gate now exists ahead of them: the moment such an
operation is dispatched through the security middleware it is denied unless a
resolved permission set grants the matching bit. Unmapped destructive
operations continue to fail closed (ADR-0049). Spec descriptions updated from
`[EXPERIMENTAL — not enforced]` to `[RBAC-gated; operation pending M2]`.
45 changes: 45 additions & 0 deletions .changeset/require-auth-secure-by-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@objectstack/spec": major
"@objectstack/rest": major
"@objectstack/verify": patch
"@objectstack/cli": patch
---

feat(security)!: `api.requireAuth` now defaults to `true` — anonymous access to the data API is denied by default (ADR-0056 D2 flip)

**BREAKING.** The global `requireAuth` default flipped FROM `false` TO `true`
(`RestApiConfigSchema.requireAuth` in `@objectstack/spec`, mirrored by
`RestServer.normalizeConfig` in `@objectstack/rest`). Anonymous requests to
the `/data/*` CRUD + batch endpoints are now rejected with HTTP 401 unless the
deployment explicitly opts out. (Scope note: this gate covers the REST
`/data/*` surface — the metadata read/write endpoints and the dispatcher
GraphQL route have their own pre-existing anonymous posture, tracked
separately; this flip does not change them.)

**Migration (one line):** a deployment that intentionally serves data publicly
(demo / playground / kiosk) sets the flag on the stack config — now a declared
`ObjectStackDefinitionSchema.api` field, so it survives `defineStack` strict
parsing (previously an undeclared top-level `api` key was silently stripped):

```ts
export default defineStack({
// …
api: { requireAuth: false },
});
```

The REST plugin logs a boot warning for the explicit opt-out so a fail-open
posture is always visible. A misplaced `api.requireAuth` at the plugin level
(one nesting short) is now also called out with a boot warning instead of
being silently ignored.

**What keeps working with no action:**

- **Share links** — validate their token, then read under a system context.
- **Public forms** — self-authorizing via the declaration-derived
`publicFormGrant` (create + read-back on the declared target object only);
no `guest_portal` profile needed.
- **Control plane** — `/auth`, `/health`, `/discovery` are exempt.
- **`objectstack serve` with an auth-less stack** — the CLI passes an explicit
`requireAuth: false` for stacks whose tier set has no `auth` (nothing could
authenticate against them), with the boot warning.
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed — Security: anonymous access denied by default (BREAKING) + destructive-op RBAC gates

- **`api.requireAuth` now defaults to `true`** (ADR-0056 D2 flip): anonymous
requests to `/data/*` CRUD + batch endpoints are rejected with HTTP 401
unless the deployment explicitly opts out with `api: { requireAuth: false }`
(the REST plugin logs a boot warning for the explicit opt-out). Share-links,
public forms (via the declaration-derived `publicFormGrant`), and the
control plane (`/auth`, `/health`, `/discovery`) keep working with no
action. `objectstack serve` passes an explicit `false` only for stacks with
no `auth` tier (nothing could authenticate against them). Conformance row
`requireAuth-default-flip` → `enforced`.
- **`transfer` / `restore` / `purge` are RBAC-gated ahead of the ops** (#1883):
the permission evaluator maps them to `allowTransfer` / `allowRestore` /
`allowPurge` (with the `modifyAllRecords` super-user bypass), so when the
M2 operations ship there is no ungated window. Unmapped destructive
operations keep failing closed (ADR-0049).

### Added — ObjectUI one-month frontend scan + backend alignment summary

Scanning `../objectui` from 2026-05-08 through 2026-06-08 shows a broad Studio
Expand Down
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 is denied by default** (the ADR-0056 D2 default-deny flip landed: `requireAuth` defaults to `true`); an explicit `requireAuth: false` opt-out logs a boot-time warning, and public forms do not depend on any 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 @@ -429,7 +429,7 @@ cloud / EE.
- [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
- [x] Default-deny for anonymous traffic — the global default-deny **flip landed** (ADR-0056 D2): `requireAuth` defaults to `true`, explicit `requireAuth: false` opt-outs warn at boot, and public forms self-authorize via `publicFormGrant` (Option A)
- [ ] Studio RLS visual editor
- [ ] Per-user×org permission cache
- [ ] Audit UI / denied-access logging
Expand Down
6 changes: 5 additions & 1 deletion content/docs/guides/cheatsheets/permissions-matrix.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ ObjectStack's `ObjectPermission` schema defines these boolean flags for object a
| **Modify All** | `modifyAllRecords` | Edit/delete all records regardless of ownership | Full object access (bypass sharing) |

<Callout type="tip">
**Super-user bypass:** When `modifyAllRecords` is set it satisfies write checks (`allowEdit`/`allowDelete`) on any record; `viewAllRecords` (or `modifyAllRecords`) satisfies `allowRead` on any record — both bypass ownership and sharing. See `packages/plugins/plugin-security/src/permission-evaluator.ts`.
**Super-user bypass:** When `modifyAllRecords` is set it satisfies write checks (`allowEdit`/`allowDelete`, and the lifecycle class `allowTransfer`/`allowRestore`/`allowPurge`) on any record; `viewAllRecords` (or `modifyAllRecords`) satisfies `allowRead` on any record — both bypass ownership and sharing. See `packages/plugins/plugin-security/src/permission-evaluator.ts`.
</Callout>

<Callout type="warn">
**Lifecycle operations are pending:** the `transfer` / `restore` / `purge` operations do not exist in ObjectQL yet (roadmap M2). Their RBAC gate is already mapped in the permission evaluator — the moment the operations ship they are denied unless the matching flag (or `modifyAllRecords`) is granted — but authoring these flags today grants nothing (#1883).
</Callout>

---
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 is denied by default** (the ADR-0056 D2 default-deny flip landed: `requireAuth` defaults to `true`); an explicit `requireAuth: false` opt-out logs a boot-time warning, 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
6 changes: 3 additions & 3 deletions content/docs/protocol/objectql/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ Beyond the four CRUD flags, the schema also exposes lifecycle and super-user gra
| Flag | Meaning |
| --- | --- |
| `allowCreate` / `allowRead` / `allowEdit` / `allowDelete` | Standard CRUD |
| `allowTransfer` | Change record ownership |
| `allowRestore` | Restore from trash (undelete) |
| `allowPurge` | Permanently delete (hard delete / GDPR) |
| `allowTransfer` | Change record ownership — *operation pending (M2); RBAC gate pre-mapped (#1883)* |
| `allowRestore` | Restore from trash (undelete) — *operation pending (M2); RBAC gate pre-mapped (#1883)* |
| `allowPurge` | Permanently delete (hard delete / GDPR) — *operation pending (M2); RBAC gate pre-mapped (#1883)* |
| `viewAllRecords` | Read every record, bypassing sharing & ownership |
| `modifyAllRecords` | Write every record, bypassing sharing & ownership |

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/rest-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ const result = BatchEndpointsConfig.parse(data);
| **enableOpenApi** | `boolean` | ✅ | Enable OpenAPI 3.1 spec & docs viewer endpoints |
| **enableProjectScoping** | `boolean` | ✅ | Enable project-scoped routing for data/meta/AI APIs |
| **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | ✅ | Project ID resolution strategy |
| **requireAuth** | `boolean` | ✅ | Reject anonymous requests on /data/* with HTTP 401 |
| **requireAuth** | `boolean` | ✅ | Reject anonymous requests on /data/* with HTTP 401 (secure-by-default; set false to serve data publicly) |
| **documentation** | `Object` | optional | OpenAPI/Swagger documentation config |
| **responseFormat** | `Object` | optional | Response format options |

Expand Down
6 changes: 3 additions & 3 deletions content/docs/references/security/permission.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ const result = FieldPermission.parse(data);
| **allowRead** | `boolean` | ✅ | Read permission |
| **allowEdit** | `boolean` | ✅ | Edit permission |
| **allowDelete** | `boolean` | ✅ | Delete permission |
| **allowTransfer** | `boolean` | ✅ | [EXPERIMENTAL — not enforced] Change record ownership |
| **allowRestore** | `boolean` | ✅ | [EXPERIMENTAL — not enforced] Restore from trash (Undelete) |
| **allowPurge** | `boolean` | ✅ | [EXPERIMENTAL — not enforced] Permanently delete (Hard Delete/GDPR) |
| **allowTransfer** | `boolean` | ✅ | [RBAC-gated; operation pending M2] Change record ownership |
| **allowRestore** | `boolean` | ✅ | [RBAC-gated; operation pending M2] Restore from trash (Undelete) |
| **allowPurge** | `boolean` | ✅ | [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) |
| **viewAllRecords** | `boolean` | ✅ | View All Data (Bypass Sharing) |
| **modifyAllRecords** | `boolean` | ✅ | Modify All Data (Bypass Sharing) |
| **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own|unit|unit_and_below|org |
Expand Down
Loading