diff --git a/.changeset/destructive-ops-rbac-mapping.md b/.changeset/destructive-ops-rbac-mapping.md new file mode 100644 index 0000000000..126044f515 --- /dev/null +++ b/.changeset/destructive-ops-rbac-mapping.md @@ -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]`. diff --git a/.changeset/require-auth-secure-by-default.md b/.changeset/require-auth-secure-by-default.md new file mode 100644 index 0000000000..f5fa8f57a1 --- /dev/null +++ b/.changeset/require-auth-secure-by-default.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9695f5b52a..a3b0354176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/content/docs/concepts/implementation-status.mdx b/content/docs/concepts/implementation-status.mdx index 2647e826d1..ab683a9f99 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 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). --- @@ -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 diff --git a/content/docs/guides/cheatsheets/permissions-matrix.mdx b/content/docs/guides/cheatsheets/permissions-matrix.mdx index 38cb7326f4..c61684373b 100644 --- a/content/docs/guides/cheatsheets/permissions-matrix.mdx +++ b/content/docs/guides/cheatsheets/permissions-matrix.mdx @@ -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) | -**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`. + + + +**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). --- diff --git a/content/docs/guides/security.mdx b/content/docs/guides/security.mdx index 44d4cd2400..61337996a8 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 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 diff --git a/content/docs/protocol/objectql/security.mdx b/content/docs/protocol/objectql/security.mdx index bcf6b2563a..59c61e7f5f 100644 --- a/content/docs/protocol/objectql/security.mdx +++ b/content/docs/protocol/objectql/security.mdx @@ -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 | diff --git a/content/docs/references/api/rest-server.mdx b/content/docs/references/api/rest-server.mdx index 65a4ff8866..923420de3a 100644 --- a/content/docs/references/api/rest-server.mdx +++ b/content/docs/references/api/rest-server.mdx @@ -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 | diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index e62a73b4cc..2544554b34 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -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 | diff --git a/docs/adr/0056-permission-model-landing-verification.md b/docs/adr/0056-permission-model-landing-verification.md index 86efad5905..d141b85ce6 100644 --- a/docs/adr/0056-permission-model-landing-verification.md +++ b/docs/adr/0056-permission-model-landing-verification.md @@ -199,7 +199,7 @@ The decisions landed incrementally, each with a runtime proof: | :-- | :-- | :-- | | OWD scenarios (private + public-read) | ✅ landed | `showcase-private-owd`, `showcase-public-read-owd` dogfood | | D1 — canonical OWD vocabulary | ✅ landed | `plugin-sharing` units + OWD dogfood | -| D2 — anonymous deny (warn) | ✅ landed (warn) | `showcase-anonymous-deny` dogfood; **flip release-gated** | +| D2 — anonymous deny (default flip) | ✅ landed (**enforced**) | `showcase-anonymous-deny` dogfood on the platform default; spec `requireAuth` `default(true)` | | D4 — RLS compiler no-silent-drop | ✅ landed | `plugin-security` units | | D6 — configurable role hierarchy | ✅ landed | `RoleGraphService` units (`role_and_subordinates`) | | D7 — app-declared default profile | ✅ landed | `showcase-default-profile` dogfood | @@ -223,4 +223,15 @@ pre-flip audit of the legitimate anonymous surfaces found: **Pre-requisite for the flip:** make public-form submission self-authorizing (grant INSERT on the form's declared target object) or ship a built-in `guest_portal`, then warn→enforce -with a migration note. Until then, D2's boot WARN is the landed state. +with a migration note. + +**Flip status (2026-07): LANDED.** The pre-requisite was satisfied by the +declaration-derived `publicFormGrant` (Option A — self-authorizing public-form submission, +proven by `showcase-public-form` / `form-self-auth` dogfood). The global default is now +deny: spec `requireAuth` `default(true)` + `rest-server.ts` `?? true`. An explicit +`requireAuth: false` opt-out remains available for deployments that intentionally serve +data publicly and is surfaced with a boot warning (the previous warn-state behavior, +now scoped to the explicit opt-out). The CLI keeps one carve-out: a stack with no `auth` +tier cannot authenticate anyone, so `objectstack serve` passes an explicit `false` for it +(warned). The verify harness boots on the platform default, so every dogfood proof runs +under the flipped posture. Conformance row: `requireAuth-default-flip` → `enforced`. diff --git a/docs/adr/0066-unified-authorization-model.md b/docs/adr/0066-unified-authorization-model.md index 31d4174e06..d218c13ffc 100644 --- a/docs/adr/0066-unified-authorization-model.md +++ b/docs/adr/0066-unified-authorization-model.md @@ -106,3 +106,5 @@ Captured for the record; **out of scope for Phases 1–4 above**. Each is anchor - **⑤ Per-operation `requiredPermissions`.** Today object-level `requiredPermissions` gates all of CRUD. ERP routinely needs "read-open / write-gated" (Salesforce & Dataverse separate capability by operation). Allow `requiredPermissions` to be either `string[]` (all operations) or a per-operation map `{ read, create, update, delete }`. Field-level (D3) and action-level (D4) requirements already give finer control; this closes the object-level gap. - **⑥ Capabilities in the expression surface.** Salesforce *Custom Permissions* are referenceable in formulas / validation / flows (`$Permission.X`). Expose the caller's held capabilities to the CEL/predicate surface (ADR-0058) so `visible` / validation / sharing predicates can branch on a capability. High-leverage once D1 makes capabilities first-class. - **⑦ Permission-set groups + subtractive *muting*.** Pure union does not scale governance ("permission-set explosion"); Salesforce added permission-set-group *muting* precisely to allow taking access away. Roles→permission-sets already bundle; a subtractive/deny layer (precedence step 4) is the missing piece for large-org administration. Pairs with delegated admin (#9). +- **⑧ FLS posture: unlisted fields are visible, and field grants union without deny** *(recorded 2026-07 pre-launch architecture assessment)*. Runtime FLS is block-list-shaped: `field-masker.ts` hides a field only when an explicit rule marks it `readable:false`, so an **undeclared** sensitive field is exposed by default, and `getFieldPermissions` merges most-permissively, so one set's `readable:true` permanently out-votes another's `false`. Consequences: protecting a salary/ID-number field relies on discipline (grant it only where needed), not mechanism. The object-level `private` posture (D2/④) mitigates the unlisted-default-visible half for objects that adopt it; the union half is the field-level face of ⑦ — a muting/deny layer must cover **field** grants, not just object grants, when it lands. Until then, treat "sensitive field on a `public` object" as a smell in review. +- **⑨ Authoring-time validation for capability references** *(recorded 2026-07 pre-launch architecture assessment)*. `systemPermissions` / `requiredPermissions` are free strings today; a typo (`mange_users`) fails closed at runtime — the safe direction — but is undiscoverable: nothing reports that the referenced capability exists nowhere. When D1 lands the `sys_permission` registry, add the authoring/publish-gate lint alongside it: referencing an unregistered capability warns at author time (consistent with ADR-0049 honesty and the contract-first Prime Directive — reject at the producer, don't tolerate at the consumer). diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 8a9486474e..ccca3e8429 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1381,11 +1381,20 @@ export default class Serve extends Command { // `api.enforceProjectMembership: false`. Undefined → dispatcher default. const enforceProjectMembership = apiConfig.enforceProjectMembership; // `requireAuth: true` rejects anonymous requests on `/api/v1/data/*` - // with HTTP 401 before they reach ObjectQL. Default-on when the - // stack opts in OR when the resolved tier set includes `auth` - // (because anonymous data access is almost never desirable when - // auth is enabled). Apps can override via stack `api.requireAuth`. - const requireAuth = apiConfig.requireAuth ?? (tierEnabled('auth') ? true : false); + // with HTTP 401 before they reach ObjectQL. The platform default is + // now secure-by-default (ADR-0056 D2): deny anonymous. The CLI keeps + // one deliberate carve-out — a stack with NO `auth` tier has no way + // to authenticate anyone, so denying would brick its data API + // entirely; such auth-less playgrounds get an EXPLICIT `false` + // (fail-open), which the REST plugin surfaces with a boot warning. + // Apps can always override via stack `api.requireAuth`. + // Auth availability = tier auto-registers it OR the stack mounts + // AuthPlugin explicitly (hasAuthPlugin, computed above). Keying only on + // the tier would hand an explicit fail-open to a stack that ships auth + // via `plugins:` under a minimal tier set — re-opening the very hole + // the flip closes. Only a stack with NO auth at all gets the carve-out. + const requireAuth = apiConfig.requireAuth + ?? ((tierEnabled('auth') || hasAuthPlugin) ? true : false); try { const { createRestApiPlugin } = await import('@objectstack/rest'); diff --git a/packages/client/src/client.environment-scoping.test.ts b/packages/client/src/client.environment-scoping.test.ts index a0d53e0f1c..aead0f2d05 100644 --- a/packages/client/src/client.environment-scoping.test.ts +++ b/packages/client/src/client.environment-scoping.test.ts @@ -44,6 +44,9 @@ describe('Project-scoped REST routing (live Hono)', () => { createRestApiPlugin({ api: { api: { + // Routing test, no auth stack mounted — opt out of the + // secure-by-default anonymous deny (ADR-0056 D2). + requireAuth: false, enableProjectScoping: true, projectResolution: 'auto', } as any, diff --git a/packages/dogfood/test/authz-conformance.matrix.ts b/packages/dogfood/test/authz-conformance.matrix.ts index 4739de7379..89d71a53eb 100644 --- a/packages/dogfood/test/authz-conformance.matrix.ts +++ b/packages/dogfood/test/authz-conformance.matrix.ts @@ -78,10 +78,13 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ { id: 'field-encryption', summary: 'at-rest field encryption', state: 'experimental', note: 'no crypto provider reads the config; marked [EXPERIMENTAL] (D8)' }, { id: 'data-masking', summary: 'role-based data masking', state: 'experimental', note: 'FLS is the enforced field-visibility path; marked [EXPERIMENTAL] (D8)' }, { id: 'rls-config-global', summary: 'global RLSConfig / RLSAuditEvent', state: 'experimental', note: 'not read by the RLS path; marked [EXPERIMENTAL] (D8)' }, - { id: 'requireAuth-default-flip', summary: 'flip the global requireAuth default to secure-by-default', state: 'experimental', - note: 'D2 warn landed; the flip is release-gated. Pre-req: built-in guest_portal so public forms survive (share-links already read as SYSTEM after token validation, so they are safe).' }, + { id: 'requireAuth-default-flip', summary: 'global requireAuth default is secure-by-default (deny anonymous)', state: 'enforced', + enforcement: 'spec/api/rest-server.zod.ts requireAuth default(true) + rest/rest-server.ts normalizeConfig ?? true; explicit requireAuth:false opt-out warns at boot (rest-api-plugin)', + proof: 'showcase-anonymous-deny.dogfood.test.ts', + note: 'ADR-0056 D2 flip LANDED. The verify harness boots on the platform default (no override), so anonymous-deny AND public-form survival (showcase-public-form.dogfood.test.ts — the publicFormGrant pre-req that unblocked the flip) are proven on the default posture. Share-links read as SYSTEM after token validation. CLI carve-out: auth-less stacks get an explicit fail-open (warned).' }, // ── Removed — by ADR-0049 (roadmap M2) ───────────────────────────────── - { id: 'allow-transfer-restore-purge', summary: 'transfer/restore/purge ops', state: 'removed', note: 'ADR-0049 → roadmap M2' }, + { id: 'allow-transfer-restore-purge', summary: 'transfer/restore/purge ops (RBAC gate pre-mapped)', state: 'removed', + note: 'ADR-0049 → roadmap M2. #1883: the ops still do not exist in ObjectQL, but the evaluator PRE-MAPS them (OPERATION_TO_PERMISSION transfer/restore/purge → allowTransfer/allowRestore/allowPurge, modifyAllRecords bypass, unmapped destructive ops fail closed) — there is no ungated window when the ops ship. Unit-proven in plugin-security/security-plugin.test.ts.' }, { id: 'flow-run-as', summary: 'flow runAs', state: 'removed', note: 'ADR-0049 → roadmap M2' }, ]; diff --git a/packages/dogfood/test/showcase-anonymous-deny.dogfood.test.ts b/packages/dogfood/test/showcase-anonymous-deny.dogfood.test.ts index 43eba8e53b..599590b453 100644 --- a/packages/dogfood/test/showcase-anonymous-deny.dogfood.test.ts +++ b/packages/dogfood/test/showcase-anonymous-deny.dogfood.test.ts @@ -1,11 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // ADR-0056 D2 — secure-by-default (anonymous deny) posture, proven on the real -// showcase HTTP stack. With `requireAuth` on, an UNAUTHENTICATED request to the -// data API is rejected (401), while authenticated members are unaffected and the -// control-plane (`/auth/*`) stays open (sign-up itself is an anonymous call). This -// is the enforcement capability D2 builds on; the framework does NOT flip the -// global default — it makes the deny posture available + warns when it is off. +// showcase HTTP stack ON THE PLATFORM DEFAULT: the verify harness passes no +// `requireAuth` override, so this proves the flipped global default +// (spec `requireAuth` default(true)) rejects an UNAUTHENTICATED request to the +// data API (401), while authenticated members are unaffected and the +// control-plane (`/auth/*`) stays open (sign-up itself is an anonymous call). +// Public forms survive the same default via the declaration-derived +// publicFormGrant — see showcase-public-form.dogfood.test.ts. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; @@ -18,7 +20,7 @@ describe('showcase: anonymous default-deny (ADR-0056 D2)', () => { let memberToken: string; beforeAll(async () => { - stack = await bootStack(showcaseStack); // harness runs requireAuth: true + stack = await bootStack(showcaseStack); // harness boots on the platform default (deny anonymous) await stack.signIn(); memberToken = await stack.signUp('d2-member@verify.test'); // anonymous /auth call → proves control-plane is open }, 60_000); diff --git a/packages/dogfood/test/showcase-public-form.dogfood.test.ts b/packages/dogfood/test/showcase-public-form.dogfood.test.ts index a045827cda..97e45800ee 100644 --- a/packages/dogfood/test/showcase-public-form.dogfood.test.ts +++ b/packages/dogfood/test/showcase-public-form.dogfood.test.ts @@ -4,7 +4,8 @@ // PUBLIC FORM. `views/inquiry.view.ts` declares a FormView with // `sharing.allowAnonymous: true` + `publicLink: '/forms/contact-us'`, which // wires the anonymous REST endpoints. This exercises them end-to-end over the -// real HTTP stack — the harness boots with `requireAuth: true`, so a passing +// real HTTP stack — the harness boots on the platform DEFAULT, which is now +// secure-by-default deny (ADR-0056 D2 flip), so a passing // anonymous submit proves the route works under SECURE-BY-DEFAULT auth, with // NO `guest_portal` profile, authorized solely by the declaration-derived // `publicFormGrant` (create + read-back on `showcase_inquiry` ONLY). diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 275b1abd26..cdbde08ffb 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -528,6 +528,7 @@ export class DevPlugin implements Plugin { // as part of its manifest), so no separate child plugin is needed. // 4. Auth Plugin + let authMounted = false; if (enabled('auth')) { try { const { AuthPlugin } = await import('@objectstack/plugin-auth') as any; @@ -536,6 +537,7 @@ export class DevPlugin implements Plugin { baseUrl: this.options.authBaseUrl, }); this.childPlugins.push(authPlugin); + authMounted = true; ctx.logger.info(' ✔ Auth plugin enabled (dev credentials)'); } catch { ctx.logger.warn(' ✘ @objectstack/plugin-auth not installed — skipping auth'); @@ -601,9 +603,21 @@ export class DevPlugin implements Plugin { if (enabled('rest')) { try { const { createRestApiPlugin } = await import('@objectstack/rest') as any; - const restPlugin = createRestApiPlugin(); + // Secure-by-default carve-out (mirrors `objectstack serve`, ADR-0056 + // D2): when NO auth mounted in this dev stack (service disabled or + // plugin-auth not installed), nobody could ever authenticate — the + // deny default would brick the local playground's data API. Pass an + // EXPLICIT fail-open for that case; the REST plugin's boot warning + // keeps the posture visible. + const restPlugin = authMounted + ? createRestApiPlugin() + : createRestApiPlugin({ api: { api: { requireAuth: false } } as any }); this.childPlugins.push(restPlugin); - ctx.logger.info(' ✔ REST API endpoints enabled (CRUD + metadata)'); + ctx.logger.info( + authMounted + ? ' ✔ REST API endpoints enabled (CRUD + metadata)' + : ' ✔ REST API endpoints enabled (CRUD + metadata; anonymous ALLOWED — no auth mounted)', + ); } catch { ctx.logger.debug(' ℹ @objectstack/rest not installed — skipping REST endpoints'); } diff --git a/packages/plugins/plugin-security/src/permission-evaluator.ts b/packages/plugins/plugin-security/src/permission-evaluator.ts index f2a248a743..75f2822250 100644 --- a/packages/plugins/plugin-security/src/permission-evaluator.ts +++ b/packages/plugins/plugin-security/src/permission-evaluator.ts @@ -3,7 +3,13 @@ import type { PermissionSet, ObjectPermission, FieldPermission } from '@objectstack/spec/security'; /** - * Operation type mapping to permission checks + * Operation type mapping to permission checks. + * + * `transfer`/`restore`/`purge` are pre-mapped to their RBAC bits (#1883) even + * though the ObjectQL operations do not exist yet (roadmap M2): the moment such + * an operation is dispatched through the security middleware it is gated by the + * corresponding `allow*` bit — deny unless a resolved permission set grants it. + * There is no window where the ops could ship ungated. */ const OPERATION_TO_PERMISSION: Record = { find: 'allowRead', @@ -13,19 +19,39 @@ const OPERATION_TO_PERMISSION: Record = { insert: 'allowCreate', update: 'allowEdit', delete: 'allowDelete', + transfer: 'allowTransfer', + restore: 'allowRestore', + purge: 'allowPurge', }; /** * Destructive operation class — operations that must FAIL CLOSED when they are * not mapped to a concrete permission key. See ADR-0049: an unrecognised - * destructive operation (e.g. a future `transfer`/`restore`/`purge` added - * without a matching `OPERATION_TO_PERMISSION` entry, gated by the spec's - * `allowTransfer`/`allowRestore`/`allowPurge` bits) must be DENIED rather than - * silently allowed by the default-allow fallthrough. Non-destructive unknown - * operations retain default-allow so custom read-side operations are not broken. + * destructive operation must be DENIED rather than silently allowed by the + * default-allow fallthrough. `transfer`/`restore`/`purge` are now mapped above + * (#1883), so this set acts as a backstop: it keeps them (and any future + * destructive op prefixed here before its mapping lands) fail-closed if the + * mapping is ever removed. Non-destructive unknown operations retain + * default-allow so custom read-side operations are not broken. */ const DESTRUCTIVE_OPERATIONS = new Set(['transfer', 'restore', 'purge']); +/** + * Permission keys covered by the `modifyAllRecords` super-user WRITE bypass: + * edit/delete plus the destructive lifecycle class, DERIVED from the two + * constants above so a future destructive op added to the map+set is covered + * automatically (hand-listing it inline is how bypass gaps happen — #1883). + * NOTE this means "Modify All Data" grants (incl. the wildcard on + * organization_admin / admin_full_access defaults) will cover + * transfer/restore/purge the moment the M2 ops ship — Salesforce semantics, + * confirmed in the #1883 disposition; revisit per-op when M2 lands. + */ +const MODIFY_ALL_WRITE_KEYS = new Set([ + 'allowEdit', + 'allowDelete', + ...[...DESTRUCTIVE_OPERATIONS].map((op) => OPERATION_TO_PERMISSION[op]), +]); + /** * [ADR-0066 D2] Resolve the object permission a permission set contributes for * `objectName`, honouring the secure-by-default posture: @@ -82,8 +108,9 @@ export class PermissionEvaluator { // but a `private` object is excluded from a non-super-user wildcard. const objPerm = resolveObjectPermission(ps, objectName, opts.isPrivate ?? false); if (objPerm) { - // Check if modifyAllRecords is set (super-user bypass for write ops) - if (['allowEdit', 'allowDelete'].includes(permKey) && objPerm.modifyAllRecords) { + // Super-user WRITE bypass ("Modify All Data") — covers edit/delete and + // the destructive lifecycle class (see MODIFY_ALL_WRITE_KEYS). + if (MODIFY_ALL_WRITE_KEYS.has(permKey) && objPerm.modifyAllRecords) { return true; } // Check if viewAllRecords is set (super-user bypass for read ops) diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 8998510059..c1daa0b670 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -292,6 +292,36 @@ describe('SecurityPlugin', () => { expect(harness.findOne).toHaveBeenCalledTimes(1); }); + it('DENIES a purge of a not-owned row via the pre-image check (#1883 — destructive ops inherit row-level gating)', async () => { + // The destructive lifecycle class (transfer/restore/purge) is pre-wired + // into OPERATION_TO_PERMISSION, so it clears the object-level RBAC gate. + // The record-level pre-image RLS check must therefore ALSO cover it — + // otherwise a grant-holder could destroy out-of-scope rows by id. purge + // maps onto the `delete` RLS class. + const purgerSet: PermissionSet = { + name: 'purger', label: 'Purger', isProfile: true, + objects: { '*': { allowRead: true, allowPurge: true } }, + rowLevelSecurity: [ + { name: 'owner_only_deletes', object: '*', operation: 'delete', using: 'created_by = current_user.id' }, + ], + } as any; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'purger' }); + const harness = makeMiddlewareCtx({ + permissionSets: [purgerSet], + objectFields: ownerFields, + findOneImpl: () => null, // row exists but not owned → filtered out → deny + }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const opCtx: any = { + object: 'task', operation: 'purge', + options: { where: { id: 'r1' } }, + context: memberCtx, + }; + await expect(harness.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); + expect(harness.findOne).toHaveBeenCalledTimes(1); + }); + it('SKIPS the check when no RLS policy applies (e.g. modifyAllRecords / admin) — no extra read', async () => { const adminSet: PermissionSet = { name: 'admin_full_access', label: 'Admin', isProfile: true, @@ -940,17 +970,40 @@ describe('PermissionEvaluator', () => { expect(evaluator.checkObjectPermission('unknownOp', 'contact', [])).toBe(true); }); - it('should fail CLOSED for unmapped destructive operations (ADR-0049)', () => { + it('denies transfer/restore/purge without the matching RBAC bit (#1883)', () => { const evaluator = new PermissionEvaluator(); - // transfer/restore/purge are not in OPERATION_TO_PERMISSION; they must be - // denied rather than falling through to default-allow — even for an - // otherwise fully-permissioned set. - const ps = makePermSet('admin', { - contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, modifyAllRecords: true }, + // Full CRUD does NOT imply the destructive lifecycle class: each op is + // gated by its own bit (allowTransfer/allowRestore/allowPurge) and must + // be denied when the bit is absent — never default-allow (ADR-0049). + const ps = makePermSet('member', { + contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, }); expect(evaluator.checkObjectPermission('transfer', 'contact', [ps])).toBe(false); expect(evaluator.checkObjectPermission('restore', 'contact', [ps])).toBe(false); expect(evaluator.checkObjectPermission('purge', 'contact', [ps])).toBe(false); + // …and an empty permission-set list denies too (fail-closed baseline). + expect(evaluator.checkObjectPermission('purge', 'contact', [])).toBe(false); + }); + + it('allows transfer/restore/purge via their specific RBAC bits (#1883)', () => { + const evaluator = new PermissionEvaluator(); + const transferOnly = makePermSet('t', { contact: { allowTransfer: true } }); + const restoreOnly = makePermSet('r', { contact: { allowRestore: true } }); + const purgeOnly = makePermSet('p', { contact: { allowPurge: true } }); + expect(evaluator.checkObjectPermission('transfer', 'contact', [transferOnly])).toBe(true); + expect(evaluator.checkObjectPermission('restore', 'contact', [restoreOnly])).toBe(true); + expect(evaluator.checkObjectPermission('purge', 'contact', [purgeOnly])).toBe(true); + // A bit on one op never leaks to another. + expect(evaluator.checkObjectPermission('purge', 'contact', [transferOnly])).toBe(false); + expect(evaluator.checkObjectPermission('transfer', 'contact', [purgeOnly])).toBe(false); + }); + + it('modifyAllRecords super-user bypass covers transfer/restore/purge (#1883)', () => { + const evaluator = new PermissionEvaluator(); + const admin = makePermSet('admin', { contact: { modifyAllRecords: true } }); + expect(evaluator.checkObjectPermission('transfer', 'contact', [admin])).toBe(true); + expect(evaluator.checkObjectPermission('restore', 'contact', [admin])).toBe(true); + expect(evaluator.checkObjectPermission('purge', 'contact', [admin])).toBe(true); }); it('should allow via viewAllRecords', () => { diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 7425a55078..c66d465459 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -433,7 +433,7 @@ export class SecurityPlugin implements Plugin { const sc: any = opCtx.context; if (['find', 'findOne', 'count', 'aggregate'].includes(opCtx.operation)) { sc.__readScope = this.permissionEvaluator.getEffectiveScope('read', opCtx.object, permissionSets, { isPrivate: secMeta.isPrivate }); - } else if (opCtx.operation === 'update' || opCtx.operation === 'delete') { + } else if (['update', 'delete', 'transfer', 'restore', 'purge'].includes(opCtx.operation)) { sc.__writeScope = this.permissionEvaluator.getEffectiveScope('write', opCtx.object, permissionSets, { isPrivate: secMeta.isPrivate }); } } @@ -457,17 +457,28 @@ export class SecurityPlugin implements Plugin { // applies — e.g. an admin set with no RLS, or `modifyAllRecords`) the // check is skipped and behaviour is unchanged. if ( - (opCtx.operation === 'update' || opCtx.operation === 'delete') && + // update/delete today; transfer/restore/purge are pre-wired (#1883) so + // the M2 ops inherit the pre-image check the moment they dispatch — + // the CRUD bit alone must never be the only row-level defense. + ['update', 'delete', 'transfer', 'restore', 'purge'].includes(opCtx.operation) && permissionSets.length > 0 && !!opCtx.context?.userId && this.ql ) { const targetId = this.extractSingleId(opCtx); if (targetId != null) { + // RLS policies declare select/insert/update/delete — map the + // destructive lifecycle class onto its nearest write class so + // authored policies apply (purge destroys like delete; + // transfer/restore mutate like update). + const rlsOperation = + opCtx.operation === 'purge' ? 'delete' + : opCtx.operation === 'transfer' || opCtx.operation === 'restore' ? 'update' + : opCtx.operation; const writeFilter = await this.computeRlsFilter( permissionSets, opCtx.object, - opCtx.operation, + rlsOperation, opCtx.context, ); if (writeFilter) { @@ -504,7 +515,7 @@ export class SecurityPlugin implements Plugin { // authored RLS, so the #1994 pre-image check above is a no-op for it; this // closes the by-id write path by checking the master instead. if ( - (opCtx.operation === 'insert' || opCtx.operation === 'update' || opCtx.operation === 'delete') && + ['insert', 'update', 'delete', 'transfer', 'restore', 'purge'].includes(opCtx.operation) && permissionSets.length > 0 && !!opCtx.context?.userId && this.ql diff --git a/packages/rest/src/analytics-routes.test.ts b/packages/rest/src/analytics-routes.test.ts index 095d0bfe1e..81f8a4b8ab 100644 --- a/packages/rest/src/analytics-routes.test.ts +++ b/packages/rest/src/analytics-routes.test.ts @@ -33,7 +33,7 @@ const selection = { dimensions: ['region'], measures: ['revenue'] }; function buildServer(analyticsProvider?: any) { const server = mockServer(); const rest = new RestServer( - server as any, mockProtocol() as any, {} as any, + server as any, mockProtocol() as any, { api: { requireAuth: false } } as any, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, analyticsProvider, diff --git a/packages/rest/src/export-integration.test.ts b/packages/rest/src/export-integration.test.ts index 073162db08..e315376458 100644 --- a/packages/rest/src/export-integration.test.ts +++ b/packages/rest/src/export-integration.test.ts @@ -185,7 +185,7 @@ async function boot() { await engine.insert('task', { id: '2', title: '写文档', done: false, priority: 'low', due: '2026-07-01T00:00:00.000Z', owner: 'u2' }); const protocol = new ObjectStackProtocolImplementation(engine as any); - const rest = new RestServer(createMockServer() as any, protocol as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); rest.registerRoutes(); const route = rest.getRoutes().find( (r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export', diff --git a/packages/rest/src/import-integration.test.ts b/packages/rest/src/import-integration.test.ts index ae82f94b74..4a7e7ebb46 100644 --- a/packages/rest/src/import-integration.test.ts +++ b/packages/rest/src/import-integration.test.ts @@ -123,7 +123,7 @@ async function boot() { await engine.insert('user', { id: 'u2', name: '李四', email: 'li@x.com' }); const protocol = new ObjectStackProtocolImplementation(engine as any); - const rest = new RestServer(createMockServer() as any, protocol as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); rest.registerRoutes(); const route = rest.getRoutes().find( (r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/import', diff --git a/packages/rest/src/import-job-integration.test.ts b/packages/rest/src/import-job-integration.test.ts index eadc62bc48..b053d75cff 100644 --- a/packages/rest/src/import-job-integration.test.ts +++ b/packages/rest/src/import-job-integration.test.ts @@ -129,7 +129,7 @@ async function boot() { engine.registry.registerObject(SYS_IMPORT_JOB as any); const protocol = new ObjectStackProtocolImplementation(engine as any); - const rest = new RestServer(createMockServer() as any, protocol as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); rest.registerRoutes(); const routes = rest.getRoutes(); const find = (method: string, path: string) => routes.find((r: any) => r.method === method && r.path === path); diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index 3dfd8d8a90..401146dc47 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -214,17 +214,27 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { ctx.logger.info('REST API successfully registered'); - // ADR-0056 D2 (warn → enforce): surface the fail-open posture. - // When `requireAuth` is off, anonymous requests reach the data API - // and read any object with no OWD/RLS — secure-by-default would deny - // them and route public access through share-links / `publicSharing`. - // We do NOT flip the default here (it would break deployments that - // rely on anonymous reads); we make the posture explicit instead. - if (!(config.api as any)?.requireAuth) { + // ADR-0056 D2 (warn → enforce, ENFORCED): the global default is + // secure-by-default — anonymous /data/* is denied unless the + // deployment explicitly opts out. The warning remains for that + // explicit opt-out so a fail-open posture is always visible. + if ((config.api as any)?.api?.requireAuth === false) { ctx.logger.warn( - '[security] anonymous access to the data API is ALLOWED (api.requireAuth=false) — ' + - 'objects without OWD/RLS are world-readable. For secure-by-default set ' + - 'api.requireAuth=true and expose public records via share-links / publicSharing (ADR-0056 D2).', + '[security] anonymous access to the data API is ALLOWED (api.requireAuth=false, explicit opt-out) — ' + + 'objects without OWD/RLS are world-readable. Remove the opt-out for secure-by-default and ' + + 'expose public records via share-links / publicSharing / public forms (ADR-0056 D2).', + ); + } + // Misplaced-key guard: the effective key is `api.api.requireAuth` + // (RestApiPluginConfig.api is the full RestServerConfig). A flat + // `api.requireAuth` is silently ignored by normalizeConfig — under + // the deny default that turns an INTENDED public deployment into a + // 401 outage with no diagnostic, so name the mistake loudly. + if ((config.api as any)?.requireAuth !== undefined) { + ctx.logger.warn( + '[security] `api.requireAuth` is set at the WRONG nesting level and has NO effect — ' + + 'move it to `api.api.requireAuth` (RestServerConfig.api.requireAuth). ' + + `The effective value this boot is ${(config.api as any)?.api?.requireAuth ?? true}.`, ); } } catch (err: any) { diff --git a/packages/rest/src/rest-auth-gate.test.ts b/packages/rest/src/rest-auth-gate.test.ts index 3748ccfc9a..34e3c0fd15 100644 --- a/packages/rest/src/rest-auth-gate.test.ts +++ b/packages/rest/src/rest-auth-gate.test.ts @@ -20,7 +20,7 @@ const makeRes = () => { return { res, state }; }; -const rest = new RestServer(server, protocol, { requireAuth: false } as any); +const rest = new RestServer(server, protocol, { api: { requireAuth: false } } as any); describe('RestServer.enforceAuth — ADR-0069 auth-policy gate', () => { const gate = (req: any, context: any) => { diff --git a/packages/rest/src/rest-exec-ctx-memo.test.ts b/packages/rest/src/rest-exec-ctx-memo.test.ts index ecd1c21e00..0619af8fda 100644 --- a/packages/rest/src/rest-exec-ctx-memo.test.ts +++ b/packages/rest/src/rest-exec-ctx-memo.test.ts @@ -17,7 +17,7 @@ const protocol: any = {}; describe('RestServer.resolveExecCtx — request-scoped memoization (#2409)', () => { it('resolves once per request object and returns the cached instance', async () => { - const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any); + const rest: any = new RestServer(httpServer, protocol, { api: { requireAuth: false } } as any); let calls = 0; rest.computeExecCtx = async () => { calls++; return { userId: 'u1' }; }; @@ -30,7 +30,7 @@ describe('RestServer.resolveExecCtx — request-scoped memoization (#2409)', () }); it('re-resolves for a different request object', async () => { - const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any); + const rest: any = new RestServer(httpServer, protocol, { api: { requireAuth: false } } as any); let calls = 0; rest.computeExecCtx = async () => { calls++; return { userId: 'u1' }; }; @@ -41,7 +41,7 @@ describe('RestServer.resolveExecCtx — request-scoped memoization (#2409)', () }); it('keys the memo by environmentId within the same request', async () => { - const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any); + const rest: any = new RestServer(httpServer, protocol, { api: { requireAuth: false } } as any); let calls = 0; rest.computeExecCtx = async (env: any) => { calls++; return { env }; }; @@ -54,7 +54,7 @@ describe('RestServer.resolveExecCtx — request-scoped memoization (#2409)', () }); it('caches an anonymous (undefined) resolution so repeat callers do not re-resolve', async () => { - const rest: any = new RestServer(httpServer, protocol, { requireAuth: false } as any); + const rest: any = new RestServer(httpServer, protocol, { api: { requireAuth: false } } as any); let calls = 0; rest.computeExecCtx = async () => { calls++; return undefined; }; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 65c497067e..14c8c5e469 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1708,7 +1708,7 @@ export class RestServer { enableSearch: (api as any).enableSearch ?? true, enableProjectScoping: api.enableProjectScoping ?? false, projectResolution: api.projectResolution ?? 'auto', - requireAuth: (api as any).requireAuth ?? false, + requireAuth: (api as any).requireAuth ?? true, // secure-by-default (ADR-0056 D2; mirrors RestApiConfigSchema) documentation: api.documentation, responseFormat: api.responseFormat, }, diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index fcdf007420..a7f8e721ba 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -75,6 +75,13 @@ function createMockPluginContext(services: Record = {}) { /** Dummy handler */ const noop = vi.fn(); +// The platform default is secure-by-default (`requireAuth` defaults to true, +// ADR-0056 D2). These unit tests dispatch route handlers with NO auth context +// on purpose — they exercise routing/CRUD mechanics, not the auth gate (that +// is covered by rest-auth-gate.test.ts + the anonymous-deny dogfood proof) — +// so they opt out explicitly, like an intentionally-public deployment would. +const ANON_API = { api: { requireAuth: false } }; + // --------------------------------------------------------------------------- // RouteManager // --------------------------------------------------------------------------- @@ -286,7 +293,7 @@ describe('RestServer', () => { describe('constructor', () => { it('should create a RestServer with default config', () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); expect(rest).toBeDefined(); expect(rest.getRouteManager()).toBeInstanceOf(RouteManager); }); @@ -303,7 +310,7 @@ describe('RestServer', () => { describe('registerRoutes', () => { it('should register discovery, metadata, UI, CRUD, and batch routes by default', () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const routes = rest.getRoutes(); @@ -382,7 +389,7 @@ describe('RestServer', () => { protocol.updateManyData = vi.fn().mockResolvedValue([]); protocol.deleteManyData = vi.fn().mockResolvedValue([]); - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const batchRoutes = rest.getRoutes().filter((r) => @@ -392,7 +399,7 @@ describe('RestServer', () => { }); it('should register UI view endpoint when enableUi is true', () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const uiRoutes = rest.getRoutes().filter((r) => @@ -402,7 +409,7 @@ describe('RestServer', () => { }); it('should not register i18n endpoints (i18n routes are self-registered by service-i18n)', () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const i18nRoutes = rest.getRoutes().filter((r) => @@ -416,7 +423,7 @@ describe('RestServer', () => { describe('getRouteManager', () => { it('should return the internal RouteManager instance', () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); const rm = rest.getRouteManager(); expect(rm).toBeInstanceOf(RouteManager); }); @@ -424,7 +431,7 @@ describe('RestServer', () => { describe('getRoutes', () => { it('should return an empty array before registerRoutes is called', () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); expect(rest.getRoutes()).toEqual([]); }); }); @@ -438,7 +445,7 @@ describe('RestServer', () => { } it('should pass expand and select query params to protocol.getData', async () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const getByIdRoute = getRoute(rest, ':object/:id'); @@ -473,7 +480,7 @@ describe('RestServer', () => { }); it('should omit expand/select when not present in query', async () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const getByIdRoute = getRoute(rest, ':object/:id'); @@ -510,13 +517,13 @@ describe('RestServer', () => { const mockRes = () => ({ json: vi.fn(), status: vi.fn().mockReturnThis() }); it('registers the clone route alongside CRUD', () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); expect(cloneRoute(rest)).toBeDefined(); }); it('forwards object/id and nested overrides to protocol.cloneData and responds 201', async () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = cloneRoute(rest); @@ -531,7 +538,7 @@ describe('RestServer', () => { }); it('treats a bare body (no `overrides` key) as the override map', async () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = cloneRoute(rest); @@ -545,7 +552,7 @@ describe('RestServer', () => { }); it('maps a CLONE_DISABLED protocol error to 403', async () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = cloneRoute(rest); @@ -562,7 +569,7 @@ describe('RestServer', () => { it('returns 501 when the protocol does not implement cloneData', async () => { const noClone = { ...protocol, cloneData: undefined }; - const rest = new RestServer(server as any, noClone as any); + const rest = new RestServer(server as any, noClone as any, ANON_API as any); rest.registerRoutes(); const route = cloneRoute(rest); @@ -588,7 +595,7 @@ describe('RestServer', () => { }); it('GET /meta/:type forwards previewDrafts to protocol.getMetaItems', async () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type'); expect(route).toBeDefined(); @@ -603,7 +610,7 @@ describe('RestServer', () => { }); it('GET /meta/:type omits previewDrafts without the flag', async () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type'); @@ -619,7 +626,7 @@ describe('RestServer', () => { // A cached protocol would normally win; preview must skip it (ETags are // keyed on the published checksum). protocol.getMetaItemCached = vi.fn(); - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type/:name'); expect(route).toBeDefined(); @@ -641,7 +648,7 @@ describe('RestServer', () => { // shipping the same type/name share one cache entry and the scope hint is // silently lost. protocol.getMetaItemCached = vi.fn(); - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type/:name'); expect(route).toBeDefined(); @@ -683,7 +690,7 @@ describe('RestServer', () => { cacheControl: { directives: ['private', 'no-cache'] }, notModified: false, }); - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = getMetaItemRoute(rest); expect(route).toBeDefined(); @@ -710,7 +717,7 @@ describe('RestServer', () => { cacheControl: { directives: ['public', 'max-age'], maxAge: 3600 }, notModified: false, }); - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = getMetaItemRoute(rest); @@ -734,7 +741,7 @@ describe('RestServer', () => { } it('should pass query params including expand to protocol.findData', async () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const listRoute = getListRoute(rest); @@ -764,7 +771,7 @@ describe('RestServer', () => { }); it('should pass populate query param to protocol.findData', async () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const listRoute = getListRoute(rest); @@ -819,7 +826,7 @@ describe('RestServer', () => { } it('streams CSV with header row and quotes risky cells', async () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = getExportRoute(rest); expect(route).toBeDefined(); @@ -851,7 +858,7 @@ describe('RestServer', () => { }); it('streams JSON array when format=json', async () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = getExportRoute(rest); @@ -872,7 +879,7 @@ describe('RestServer', () => { }); it('rejects invalid JSON in filter query', async () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = getExportRoute(rest); @@ -885,7 +892,7 @@ describe('RestServer', () => { }); it('honours the hard 50k row cap', async () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = getExportRoute(rest); @@ -955,7 +962,7 @@ describe('RestServer', () => { it('formats values readably in CSV using field metadata', async () => { const p = protocolWithSchema([RAW_TASK_ROW]); - const rest = new RestServer(server as any, p as any); + const rest = new RestServer(server as any, p as any, ANON_API as any); rest.registerRoutes(); const route = getExportRoute(rest); @@ -972,7 +979,7 @@ describe('RestServer', () => { it('omits the header row when header=false', async () => { const p = protocolWithSchema([RAW_TASK_ROW]); - const rest = new RestServer(server as any, p as any); + const rest = new RestServer(server as any, p as any, ANON_API as any); rest.registerRoutes(); const route = getExportRoute(rest); @@ -986,7 +993,7 @@ describe('RestServer', () => { it('injects $expand for reference fields into the findData query', async () => { const p = protocolWithSchema([RAW_TASK_ROW]); - const rest = new RestServer(server as any, p as any); + const rest = new RestServer(server as any, p as any, ANON_API as any); rest.registerRoutes(); const route = getExportRoute(rest); @@ -1000,7 +1007,7 @@ describe('RestServer', () => { it('formats values readably in JSON, leaving unknown keys untouched', async () => { const p = protocolWithSchema([{ ...RAW_TASK_ROW, extra: 'keep' }]); - const rest = new RestServer(server as any, p as any); + const rest = new RestServer(server as any, p as any, ANON_API as any); rest.registerRoutes(); const route = getExportRoute(rest); @@ -1016,7 +1023,7 @@ describe('RestServer', () => { it('streams a valid xlsx workbook with formatted cells', async () => { const p = protocolWithSchema([RAW_TASK_ROW]); - const rest = new RestServer(server as any, p as any); + const rest = new RestServer(server as any, p as any, ANON_API as any); rest.registerRoutes(); const route = getExportRoute(rest); @@ -1047,7 +1054,7 @@ describe('RestServer', () => { // `/data//export` is captured by `:id` ("export" treated as a record // id) and 404s with RECORD_NOT_FOUND instead of streaming the export. it('registers GET /export ahead of the greedy GET /:object/:id matcher', () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const routes = rest.getRoutes(); @@ -1099,7 +1106,7 @@ describe('RestServer', () => { // i18nServiceProvider is the 14th constructor arg (after server, protocol, // config, and the 10 service providers preceding it). const rest = new RestServer( - server as any, p as any, {}, + server as any, p as any, ANON_API as any, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, async () => i18n as any, @@ -1140,7 +1147,7 @@ describe('RestServer', () => { } it('returns 501 when no email service provider is wired', async () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = getEmailRoute(rest); expect(route).toBeDefined(); @@ -1154,7 +1161,7 @@ describe('RestServer', () => { const send = vi.fn(async () => ({ id: 'em-1', status: 'sent', messageId: '' })); const provider = async () => ({ send }); const rest = new RestServer( - server as any, protocol as any, {}, + server as any, protocol as any, ANON_API as any, undefined, undefined, undefined, undefined, undefined, provider as any, ); @@ -1174,7 +1181,7 @@ describe('RestServer', () => { const send = vi.fn(async () => { throw new Error('VALIDATION_FAILED: subject is required'); }); const provider = async () => ({ send }); const rest = new RestServer( - server as any, protocol as any, {}, + server as any, protocol as any, ANON_API as any, undefined, undefined, undefined, undefined, undefined, provider as any, ); @@ -1204,7 +1211,7 @@ describe('RestServer', () => { } it('returns 501 when no sharing service provider is wired', async () => { - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const { list, grant, revoke } = getShareRoutes(rest); expect(list && grant && revoke).toBeDefined(); @@ -1219,7 +1226,7 @@ describe('RestServer', () => { const listShares = vi.fn(async () => [{ id: 'shr_1', recipient_id: 'bob' }]); const provider = async () => ({ listShares, grant: vi.fn(), revoke: vi.fn() }); const rest = new RestServer( - server as any, protocol as any, {}, + server as any, protocol as any, ANON_API as any, undefined, undefined, undefined, undefined, undefined, undefined, provider as any, ); @@ -1235,7 +1242,7 @@ describe('RestServer', () => { const grant = vi.fn(async (input: any) => ({ id: 'shr_2', ...input })); const provider = async () => ({ listShares: vi.fn(), grant, revoke: vi.fn() }); const rest = new RestServer( - server as any, protocol as any, {}, + server as any, protocol as any, ANON_API as any, undefined, undefined, undefined, undefined, undefined, undefined, provider as any, ); @@ -1257,7 +1264,7 @@ describe('RestServer', () => { const grant = vi.fn(async () => { throw new Error('VALIDATION_FAILED: recipientId is required'); }); const provider = async () => ({ listShares: vi.fn(), grant, revoke: vi.fn() }); const rest = new RestServer( - server as any, protocol as any, {}, + server as any, protocol as any, ANON_API as any, undefined, undefined, undefined, undefined, undefined, undefined, provider as any, ); @@ -1273,7 +1280,7 @@ describe('RestServer', () => { const revoke = vi.fn(async () => undefined); const provider = async () => ({ listShares: vi.fn(), grant: vi.fn(), revoke }); const rest = new RestServer( - server as any, protocol as any, {}, + server as any, protocol as any, ANON_API as any, undefined, undefined, undefined, undefined, undefined, undefined, provider as any, ); @@ -1306,7 +1313,7 @@ describe('RestServer', () => { function makeRest(provider?: any) { const rest = new RestServer( - server as any, protocol as any, {}, + server as any, protocol as any, ANON_API as any, undefined, undefined, undefined, undefined, undefined, undefined, undefined, provider, ); @@ -1528,7 +1535,7 @@ describe('PUT /meta/:type/:name handler — header → request plumbing (PR-10d. const server = createMockServer(); const protocol = createMockProtocol(); protocol.saveMetaItem = vi.fn().mockResolvedValue({ success: true }); - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = getPutRoute(rest, '/api/v1/meta/:type/:name'); @@ -1557,7 +1564,7 @@ describe('PUT /meta/:type/:name handler — header → request plumbing (PR-10d. const server = createMockServer(); const protocol = createMockProtocol(); protocol.saveMetaItem = vi.fn().mockResolvedValue({ success: true }); - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = getPutRoute(rest, '/api/v1/meta/:type/:name'); @@ -1579,7 +1586,7 @@ describe('PUT /meta/:type/:name handler — header → request plumbing (PR-10d. const server = createMockServer(); const protocol = createMockProtocol(); protocol.saveMetaItem = vi.fn().mockResolvedValue({ success: true }); - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = getPutRoute(rest, '/api/v1/meta/:type/:name'); @@ -1604,7 +1611,7 @@ describe('PUT /meta/:type/:name handler — header → request plumbing (PR-10d. err.code = 'metadata_conflict'; err.status = 409; protocol.saveMetaItem = vi.fn().mockRejectedValue(err); - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const route = getPutRoute(rest, '/api/v1/meta/:type/:name'); @@ -1626,7 +1633,7 @@ describe('RestServer project-scoped routing', () => { it('only registers unscoped routes by default', () => { const server = createMockServer(); const protocol = createMockProtocol(); - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const paths = rest.getRoutes().map(r => r.path); @@ -1642,7 +1649,7 @@ describe('RestServer project-scoped routing', () => { const server = createMockServer(); const protocol = createMockProtocol(); const rest = new RestServer(server as any, protocol as any, { - api: { enableProjectScoping: true, projectResolution: 'auto' } as any, + api: { requireAuth: false, enableProjectScoping: true, projectResolution: 'auto' } as any, } as any); rest.registerRoutes(); @@ -1657,7 +1664,7 @@ describe('RestServer project-scoped routing', () => { const server = createMockServer(); const protocol = createMockProtocol(); const rest = new RestServer(server as any, protocol as any, { - api: { enableProjectScoping: true, projectResolution: 'required' } as any, + api: { requireAuth: false, enableProjectScoping: true, projectResolution: 'required' } as any, } as any); rest.registerRoutes(); @@ -1670,7 +1677,7 @@ describe('RestServer project-scoped routing', () => { const server = createMockServer(); const protocol = createMockProtocol(); const rest = new RestServer(server as any, protocol as any, { - api: { enableProjectScoping: true, projectResolution: 'required' } as any, + api: { requireAuth: false, enableProjectScoping: true, projectResolution: 'required' } as any, } as any); rest.registerRoutes(); @@ -1694,7 +1701,7 @@ describe('RestServer project-scoped routing', () => { const server = createMockServer(); const protocol = createMockProtocol(); const rest = new RestServer(server as any, protocol as any, { - api: { enableProjectScoping: true, projectResolution: 'auto' } as any, + api: { requireAuth: false, enableProjectScoping: true, projectResolution: 'auto' } as any, } as any); rest.registerRoutes(); @@ -2015,7 +2022,7 @@ describe('discovery — routes.mcp (ADR-0036, #152)', () => { const protocol = createMockProtocol(); // protocol discovery carries a `routes` object the server augments. (protocol.getDiscovery as any) = vi.fn().mockResolvedValue({ routes: { data: '', metadata: '' } }); - const rest = new RestServer(server as any, protocol as any); + const rest = new RestServer(server as any, protocol as any, ANON_API as any); rest.registerRoutes(); const entry = rest.getRouteManager().get('GET', '/api/v1/discovery'); if (!entry) throw new Error('discovery route not registered'); @@ -2094,7 +2101,7 @@ describe('RestServer metadata translation — envelope unwrap', () => { }); it('translates the inner document of a getMetaItem envelope', async () => { - const rest = new RestServer(createMockServer() as any, createMockProtocol() as any); + const rest = new RestServer(createMockServer() as any, createMockProtocol() as any, ANON_API as any); const envelope = { type: 'app', name: 'setup', item: makeDoc(), lock: null }; const out = await (rest as any).translateMetaItem(zhReq, 'app', undefined, envelope, fakeI18n); // Envelope shape preserved … @@ -2108,14 +2115,14 @@ describe('RestServer metadata translation — envelope unwrap', () => { }); it('still translates a bare (already-unwrapped) document', async () => { - const rest = new RestServer(createMockServer() as any, createMockProtocol() as any); + const rest = new RestServer(createMockServer() as any, createMockProtocol() as any, ANON_API as any); const out = await (rest as any).translateMetaItem(zhReq, 'app', undefined, makeDoc(), fakeI18n); expect(out.label).toBe('系统设置'); expect(out.navigation[0].children[0].label).toBe('文件存储'); }); it('translates list responses in the `{ items: [...] }` envelope shape', async () => { - const rest = new RestServer(createMockServer() as any, createMockProtocol() as any); + const rest = new RestServer(createMockServer() as any, createMockProtocol() as any, ANON_API as any); // translateMetaItems resolves the i18n service itself; stub the lookup. (rest as any).resolveI18nService = async () => fakeI18n; const listEnvelope = { items: [{ type: 'app', name: 'setup', item: makeDoc() }] }; @@ -2125,7 +2132,7 @@ describe('RestServer metadata translation — envelope unwrap', () => { }); it('translates a bare array of unwrapped documents', async () => { - const rest = new RestServer(createMockServer() as any, createMockProtocol() as any); + const rest = new RestServer(createMockServer() as any, createMockProtocol() as any, ANON_API as any); (rest as any).resolveI18nService = async () => fakeI18n; const out = await (rest as any).translateMetaItems(zhReq, 'app', undefined, [makeDoc()]); expect(Array.isArray(out)).toBe(true); @@ -2138,7 +2145,7 @@ describe('RestServer metadata translation — envelope unwrap', () => { // --------------------------------------------------------------------------- describe('filterAppForUser — ADR-0045 hidden-app gate', () => { - const make = () => new RestServer(createMockServer() as any, createMockProtocol() as any); + const make = () => new RestServer(createMockServer() as any, createMockProtocol() as any, ANON_API as any); const hiddenApp = { name: 'production_management', hidden: true, navigation: [] }; const visibleApp = { name: 'crm', navigation: [] }; @@ -2174,7 +2181,7 @@ describe('filterAppForUser — ADR-0045 hidden-app gate', () => { // --------------------------------------------------------------------------- describe('filterAppForUser — ADR-0057 D10 requiresService gate', () => { - const make = () => new RestServer(createMockServer() as any, createMockProtocol() as any); + const make = () => new RestServer(createMockServer() as any, createMockProtocol() as any, ANON_API as any); const app = () => ({ name: 'setup', navigation: [ @@ -2240,7 +2247,7 @@ describe('filterAppForUser — ADR-0057 D10 requiresService gate', () => { // --------------------------------------------------------------------------- describe('filterDashboardForUser — ADR-0057 D10 widget requiresService gate', () => { - const make = () => new RestServer(createMockServer() as any, createMockProtocol() as any); + const make = () => new RestServer(createMockServer() as any, createMockProtocol() as any, ANON_API as any); const dash = () => ({ name: 'system_overview', widgets: [ diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 427cf9e415..a46c5e8021 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -1,6 +1,6 @@ { "type": "permission", - "_note": "PermissionSetSchema (also aliased as `profile`). Seeded from docs/audits/2026-06-security-identity-property-liveness.md. CRUD/FLS/RLS enforced via plugin-security; destructive ops + isProfile/contextVariables dead. allowTransfer/Restore/Purge are marker-driven experimental (spec `[EXPERIMENTAL — not enforced]`).", + "_note": "PermissionSetSchema (also aliased as `profile`). Seeded from docs/audits/2026-06-security-identity-property-liveness.md. CRUD/FLS/RLS enforced via plugin-security. allowTransfer/Restore/Purge are RBAC-mapped in the evaluator (#1883) — deny unless granted; the ObjectQL operations themselves are pending M2.", "props": { "name": { "status": "live", @@ -39,6 +39,21 @@ "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" }, + "allowTransfer": { + "status": "live", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts (OPERATION_TO_PERMISSION transfer→allowTransfer + modifyAllRecords bypass)", + "note": "#1883 — RBAC gate pre-mapped, deny unless granted; the `transfer` ObjectQL operation is pending M2, so granting delivers nothing until it ships." + }, + "allowRestore": { + "status": "live", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts (OPERATION_TO_PERMISSION restore→allowRestore + modifyAllRecords bypass)", + "note": "#1883 — RBAC gate pre-mapped, deny unless granted; the `restore` ObjectQL operation is pending M2." + }, + "allowPurge": { + "status": "live", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts (OPERATION_TO_PERMISSION purge→allowPurge + modifyAllRecords bypass)", + "note": "#1883 — RBAC gate pre-mapped, deny unless granted; the `purge` ObjectQL operation is pending M2." + }, "viewAllRecords": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:64" diff --git a/packages/spec/src/api/rest-server.zod.ts b/packages/spec/src/api/rest-server.zod.ts index 60bccd5de8..9704cdeaf8 100644 --- a/packages/spec/src/api/rest-server.zod.ts +++ b/packages/spec/src/api/rest-server.zod.ts @@ -118,13 +118,22 @@ export const RestApiConfigSchema = lazySchema(() => z.object({ * counterpart to the security plugin's RBAC enforcement and the * **only** defense for deployments where the security plugin is not * mounted (legacy bare-runtime hosts) or where it would otherwise fall - * through anonymous traffic. Cloud-connected per-project kernels set - * this to `true` automatically. Default `false` for backward compat with - * standalone single-environment setups that have always served data - * publicly (e.g. demo/playground deployments). - */ - requireAuth: z.boolean().default(false) - .describe('Reject anonymous requests on /data/* with HTTP 401'), + * through anonymous traffic. + * + * **Default `true` — secure by default (ADR-0056 D2).** Anonymous access + * to the data API must be an explicit deployment decision + * (`requireAuth: false`), not a silent fallthrough. Legitimate anonymous + * surfaces survive the deny posture without any opt-out: the control + * plane (`/auth`, `/health`, `/discovery`) is exempt, share-links read + * under a system context after token validation, and public-form + * submission is self-authorizing via the declaration-derived + * `publicFormGrant` (create + read-back on the declared target object + * only). Demo/playground deployments that intentionally serve data + * publicly must now set `requireAuth: false` explicitly — the REST + * plugin logs a boot warning when they do. + */ + requireAuth: z.boolean().default(true) + .describe('Reject anonymous requests on /data/* with HTTP 401 (secure-by-default; set false to serve data publicly)'), /** * API documentation configuration diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index e42f8d35e6..ba8d7fb31e 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -34,16 +34,19 @@ export const ObjectPermissionSchema = lazySchema(() => z.object({ /** * Lifecycle Operations. * - * EXPERIMENTAL — not enforced (ADR-0049). The `transfer`/`restore`/`purge` - * operations these bits gate do not yet exist as ObjectQL operations, and no - * runtime consumer reads these bits. Authoring them is currently a no-op. - * The runtime fails CLOSED if such an operation is ever introduced without a - * matching permission mapping (see `permission-evaluator.ts` - * DESTRUCTIVE_OPERATIONS). Tracked by #1883. + * RBAC-gated, operations pending (#1883 / roadmap M2). The + * `transfer`/`restore`/`purge` ObjectQL operations do not exist yet, but the + * permission evaluator PRE-MAPS them to these bits + * (`permission-evaluator.ts` OPERATION_TO_PERMISSION): the moment such an + * operation is dispatched it is denied unless a resolved permission set + * grants the bit (or `modifyAllRecords`). Until the operations ship, + * authoring these bits grants nothing — there is no ungated window either + * way (unmapped destructive ops additionally fail CLOSED via + * DESTRUCTIVE_OPERATIONS, per ADR-0049). */ - allowTransfer: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Change record ownership'), - allowRestore: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Restore from trash (Undelete)'), - allowPurge: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Permanently delete (Hard Delete/GDPR)'), + allowTransfer: z.boolean().default(false).describe('[RBAC-gated; operation pending M2] Change record ownership'), + allowRestore: z.boolean().default(false).describe('[RBAC-gated; operation pending M2] Restore from trash (Undelete)'), + allowPurge: z.boolean().default(false).describe('[RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR)'), /** * View All Records: Super-user read access. diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 2816cea3a6..c2cc612290 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -241,6 +241,30 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ apis: z.array(ApiEndpointSchema).optional().describe('API Endpoints'), webhooks: z.array(WebhookSchema).optional().describe('Outbound Webhooks'), + /** + * Server-facing API configuration read by `objectstack serve` / `dev` when + * it mounts the REST + dispatcher plugins. Declared here (rather than only + * consumed ad-hoc) so it SURVIVES `defineStack` strict parsing — an + * undeclared key is silently stripped, which previously made these knobs a + * no-op through the primary authoring path. Forwarded to the REST plugin as + * `api.api.*`. + */ + api: z.object({ + /** + * Reject anonymous requests on `/data/*` with HTTP 401. Secure-by-default + * (ADR-0056 D2) at the REST layer; set `false` here to intentionally serve + * data publicly (the REST plugin logs a boot warning). + */ + requireAuth: z.boolean().optional() + .describe('[ADR-0056 D2] Reject anonymous /data/* requests (secure-by-default; set false to serve publicly)'), + /** Enable environment-scoped routing for data/meta/AI APIs. */ + enableProjectScoping: z.boolean().optional(), + /** Environment id resolution strategy when scoping is on. */ + projectResolution: z.enum(['required', 'optional', 'auto']).optional(), + /** Per-environment membership 403 gate (dispatcher). Undefined → default. */ + enforceProjectMembership: z.boolean().optional(), + }).optional().describe('Server-facing API config consumed by objectstack serve/dev'), + /** * ObjectAI: Artificial Intelligence Layer * diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index ec64fc333b..2221feb328 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -172,7 +172,11 @@ export async function bootStack( await kernel.use(new SharingServicePlugin()); // REST + dispatcher route surfaces (mount onto the http-server service). - await kernel.use(createRestApiPlugin({ api: { api: { requireAuth: true } } as never })); + // No `requireAuth` override: the harness deliberately boots on the platform + // DEFAULT (secure-by-default deny, ADR-0056 D2) so every dogfood proof — + // anonymous-deny, public-form survival, share-links — exercises the posture + // a fresh production deployment actually gets. + await kernel.use(createRestApiPlugin({})); await kernel.use(createDispatcherPlugin({})); // Fire the ready lifecycle: seed data, dev-admin bootstrap, route registration.