diff --git a/.changeset/adr-0103-managedby-write-policy.md b/.changeset/adr-0103-managedby-write-policy.md new file mode 100644 index 0000000000..2db0770de5 --- /dev/null +++ b/.changeset/adr-0103-managedby-write-policy.md @@ -0,0 +1,25 @@ +--- +"@objectstack/plugin-security": minor +"@objectstack/objectql": minor +"@objectstack/plugin-hono-server": minor +"@objectstack/spec": patch +"@objectstack/platform-objects": patch +"@objectstack/plugin-approvals": patch +"@objectstack/service-automation": patch +"@objectstack/service-messaging": patch +"@objectstack/plugin-sharing": patch +"@objectstack/rest": patch +--- + +**Split the overloaded `managedBy: 'system'` bucket into engine-owned vs. admin-writable, and enforce engine-owned writes (ADR-0103, #3220).** The `system` bucket conflated two incompatible write policies: rows a platform service owns end to end (never user-written), and platform-defined schema whose rows are legitimately admin/user-writable. It carried the same all-false affordance row as `better-auth`/`append-only` but, unlike `better-auth`, had no engine enforcement — a wildcard admin could raw-write these rows through the generic data API (ADR-0049 gap). + +Rather than add a new `managedBy` enum value (which would fall through to fully-editable `platform` defaults on already-deployed Console clients), the write policy is now the **resolved affordance** (`resolveCrudAffordances` = bucket default + `userActions`), and *engine-owned* is defined as a `system`/`append-only` object that grants no write: + +- **Writable set declares `userActions`** — the RBAC link tables (`sys_user_position`, `sys_user_permission_set`, `sys_position_permission_set`), `sys_user_preference`, `sys_approval_delegation`, and the messaging config grids (`sys_notification_preference` / `…_subscription` / `…_template`) now declare `userActions: { create, edit, delete: true }`. The affordance is a declaration only — the `DelegatedAdminGate` / RLS / permission sets remain the authz. +- **Engine-owned objects locked to reads** — `apiMethods: ['get','list']` added where absent (jobs, notifications, approval request/approver/token/action, `sys_record_share`, `sys_automation_run`, mail/settings/secret audit, the messaging delivery pipeline). `sys_secret` is explicitly read-locked (an empty `apiMethods` array fails open). +- **`sys_import_job`** stays engine-owned: the REST import route now writes its job rows `isSystem`-elevated (attribution preserved via the explicit `created_by` stamp) and the object is locked to `['get','list']`. +- **New engine write guard** (`assertEngineOwnedWriteAllowed`, plugin-security) fail-closed rejects user-context generic writes to engine-owned `system`/`append-only` objects, keyed off the resolved affordance; `isSystem` and context-less engine/service writes bypass by construction. Wired into the security middleware alongside the other data-layer gates. +- **`reconcileManagedApiMethods`** (objectql registry) now runs for **every** managed bucket, not just `better-auth`: any advertised write verb an object's resolved affordances forbid is stripped at registration with a warning (the drift backstop, ADR-0049). +- **`/me/permissions` clamp** (plugin-hono-server) now clamps `system`/`append-only` as well as `better-auth`, so the client hint reflects `permission ∩ guard`. + +**Potentially breaking:** a downstream/third-party `system` object that advertised generic write verbs relying on today's fail-open behaviour will have those verbs stripped (with a warning) and user-context generic writes to it rejected. Declare `userActions` opening the verbs the object legitimately takes from a user context. `better-auth` keeps plugin-auth's identity write guard unchanged; the row-level `managed_by` provenance vocabulary (ADR-0066) is a different axis and is untouched. diff --git a/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md b/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md new file mode 100644 index 0000000000..3835d51b7b --- /dev/null +++ b/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md @@ -0,0 +1,146 @@ +# ADR-0103: `managedBy` write policy — split the overloaded `system` bucket, enforce engine-owned writes + +- **Status**: Accepted +- **Date**: 2026-07-19 +- **Issue**: #3220 (root cause surfaced by the #1591 / #3213 better-auth guard work; safe slice shipped in #3222) +- **Relates to**: ADR-0049 (no unenforced security properties), ADR-0092 (identity + write guard mechanism), ADR-0066 (system-row provenance gate) + +## Context + +`managedBy` is the object-lifecycle bucket UI clients read to render CRUD +affordances, and the security layer reads to enforce matching write defaults. +Its `system` value had come to mean two **incompatible** things: + +1. **Engine-owned** — runtime rows a platform service owns end to end and no user + ever writes directly: `sys_automation_run`, `sys_job` / `sys_job_queue`, + `sys_notification`, the approval engine's request/approver/token rows, the + sharing engine's `sys_record_share`, `sys_setting` / `sys_secret` / + `sys_setting_audit`, metadata history/commit, the messaging delivery pipeline + (`sys_notification_delivery`, `…_receipt`, `http_delivery`). Every write goes + through `isSystem: true` / a `SYSTEM_CTX` service engine, or a context-less + engine call (the messaging service's raw-engine writes, the metadata-protocol + repository's transaction context — neither carries a `userId`). +2. **Platform-schema, admin/user-writable data** — objects whose *schema* is + platform-defined but whose *rows* are legitimately written from a user + context by design: + - the RBAC link tables `sys_user_position`, `sys_user_permission_set`, + `sys_position_permission_set` — governed at write time by the + `DelegatedAdminGate` (ADR-0090 D12), which depends on the write carrying the + real caller (e.g. `suggested-audience-bindings.ts` inserts a direct grant + with `context: callerCtx`, deliberately **not** `isSystem`); + - `sys_user_preference` — a user authors their own preferences (RLS self-grant); + - `sys_approval_delegation` — user-facing "who covers for me" config; + - the messaging config grids surfaced in Setup — `sys_notification_preference` + (a user mutes their own topics), `sys_notification_subscription`, + `sys_notification_template` (admin-authored). + +All three restricted buckets (`system`, `append-only`, `better-auth`) collapsed +to one identical all-false affordance row, but only `better-auth` had real +enforcement: an engine write guard (ADR-0092) plus registration-time +`apiMethods` reconciliation. `system` had **neither**. A blast-radius audit +(#3220) disproved the original "just guard `system` wholesale" proposal — a +blanket guard would break delegated administration and self-service — and +narrowed the safe, mechanical slice to two latent holes (`sys_presence`, +`sys_metadata`), which shipped as #3222. This ADR is the root-cause fix: make the +write policy **expressible and enforced** without breaking the writable set. + +> **Not to be confused with** the row-level `managed_by` *provenance* column +> (`'platform' | 'package' | 'admin'`) on `sys_sharing_rule` / `sys_position` / +> `sys_capability`, guarded by `assertSystemRowWriteGate` (ADR-0066). That is a +> per-row provenance axis; this ADR is about the per-object schema bucket. The +> shared word is a coincidence. + +## Decision + +### D1 — The bucket is a default; the *resolved affordance* is the policy + +`managedBy` supplies only the **default** CRUD affordance row. The authoritative, +enforced write policy for an object is `resolveCrudAffordances(schema)` — the +bucket default with `userActions` overrides applied. We do **not** add a new +enum value. Instead: + +- **Engine-owned** ≔ an object in the `system` or `append-only` bucket whose + resolved affordances grant **no** write (`create`/`edit`/`delete` all false) — + i.e. the bucket default, un-opened. +- **Admin/user-writable** ≔ a `system`/`append-only` object that declares + `userActions` opening the writes it legitimately takes. `userActions` is an + affordance declaration, **not** an authorization: the real authz for these + rows remains the `DelegatedAdminGate` / RLS self-grant / permission sets. The + declaration exists so the affordance matrix, the `apiMethods` reconciliation, + and the engine guard below all agree on what the object permits. + +Why not a new enum value: a new `managedBy` string falls through to the +fully-editable `platform` default on every already-deployed Console client +(the UI keeps a hand-maintained closed union and an open-ended type), silently +re-granting New/Edit/Delete on an engine table until each client is updated. The +`userActions` approach changes no wire vocabulary, and every UI surface already +honours `userActions`. Splitting the enum later remains possible as a pure +rename on top of the now-correct affordances. + +### D2 — Engine write guard for the engine-owned set + +A new `system-write-guard` (plugin-security), modelled on the ADR-0092 identity +guard, fails-closed on any **user-context** write to a `system`/`append-only` +object whose resolved affordances do not grant the corresponding verb. Verb → +affordance: `insert → create`, `update`/`upsert` → `edit`, +`delete`/`purge`/`restore` → `delete`, `transfer → edit` (aligned with the +`DelegatedAdminGate` governed-operation set). A write is **user-context** when +`context.userId` is set and `context.isSystem !== true`; `isSystem` and +context-less engine/service writes bypass by construction — exactly the calls the +legitimate engine writers make. It is wired into the security middleware +alongside the other unconditional data-layer gates (package-managed, system-row, +audience-anchor, delegated-admin), after the `isSystem` short-circuit. Denials +raise `PermissionDeniedError` (HTTP 403). + +The writable set passes the guard because its `userActions` grant the verb; the +`DelegatedAdminGate` / RLS then adjudicate the principal, unchanged. + +### D3 — Generalize `apiMethods` reconciliation beyond `better-auth` + +`reconcileManagedApiMethods` (objectql registry) previously ran only for +`better-auth`. It now runs for **every** managed bucket, still purely +affordance-driven: any generic write verb an object advertises in +`enable.apiMethods` that its resolved affordances do not grant is stripped at +registration with a warning, so the HTTP exposure gate answers a clean 405 +instead of admitting a write that the engine guard (or permission layer) would +later 403. This makes the contradiction impossible to ship and is the drift +backstop for future `system` objects. + +### D4 — Generalize the `/me/permissions` clamp + +The hono-server `clampManagedObjectWrites` previously clamped only `better-auth` +objects' advertised affordances. It now clamps `system`/`append-only` too, so the +permissions payload the Console consumes reflects the true (guarded) write +policy. + +### D5 — `sys_import_job` writes become attributed system writes + +The REST import route created/updated `sys_import_job` under the caller's +context. Those rows are engine-owned (the import worker owns the lifecycle), so +the route now writes them `isSystem`-elevated while preserving `created_by` +attribution, and the object is locked to `apiMethods: ['get', 'list']`. This +keeps the object engine-owned rather than carving a `userActions` exception for +a row users never hand-edit. + +## Alternatives considered + +- **New `managedBy` enum value** (e.g. `engine`) — rejected: silent + fully-editable fallthrough on deployed clients, plus enum/matrix churn and + ~30 re-bucketed declarations across three UI mirrors and ten locale bundles, + for a distinction `userActions` already expresses. Revisitable later as a + rename. +- **`writeVia` capability** for third-party-managed objects — deferred (#1878): + no such object exists today; speculative until a concrete need arises + (explicitly out of scope per #3220). + +## Consequences + +- `system`/`append-only` objects reject user-context generic writes unless + `userActions` opens them — defence-in-depth for non-REST callers, matching the + HTTP-layer `apiMethods` lock several already carry. +- Third-party or downstream `system` objects that advertised write verbs relying + on today's fail-open behaviour will have those verbs stripped with a warning — + the intended ADR-0049 correction, called out in the release note. +- The taxonomy is now honest: a reader can tell engine-owned from writable by the + resolved affordances, and the guard enforces it. diff --git a/packages/objectql/src/registry.test.ts b/packages/objectql/src/registry.test.ts index ba4cd72e51..68943ffed8 100644 --- a/packages/objectql/src/registry.test.ts +++ b/packages/objectql/src/registry.test.ts @@ -723,9 +723,10 @@ describe('applySystemFields', () => { }); // ========================================== -// reconcileManagedApiMethods — #1591 / ADR-0092 -// Registration-time consistency: a better-auth-managed object may not -// advertise generic write verbs it doesn't grant. +// reconcileManagedApiMethods — #1591 / ADR-0092 / ADR-0103 +// Registration-time consistency: ANY managed object may not advertise generic +// write verbs its resolved affordances don't grant. Scope generalized from +// better-auth to every managed bucket in ADR-0103. // ========================================== describe('reconcileManagedApiMethods', () => { const managed = (extra: any = {}): any => ({ @@ -773,7 +774,7 @@ describe('reconcileManagedApiMethods', () => { expect(out.enable.apiMethods).toEqual(['get']); }); - it('leaves non-better-auth objects untouched (platform bucket keeps full CRUD)', () => { + it('leaves platform-bucket objects untouched (grant full CRUD → nothing stripped)', () => { const warn = vi.fn(); const platform: any = { name: 'sys_business_unit', @@ -786,6 +787,58 @@ describe('reconcileManagedApiMethods', () => { expect(warn).not.toHaveBeenCalled(); }); + it('leaves unmanaged objects (no managedBy) untouched', () => { + const warn = vi.fn(); + const plain: any = { + name: 'crm_lead', + enable: { apiEnabled: true, apiMethods: ['get', 'list', 'create', 'update', 'delete'] }, + }; + const out = reconcileManagedApiMethods(plain, { warn }); + expect(out).toBe(plain); + expect(warn).not.toHaveBeenCalled(); + }); + + // ADR-0103 — the scope generalization: engine-owned system/append-only + // objects derive to reads, while the writable set keeps its verbs. + it('strips write verbs from a system-bucket object with no write affordances (engine-owned)', () => { + const warn = vi.fn(); + const engineOwned: any = { + name: 'sys_automation_run', + managedBy: 'system', + enable: { apiEnabled: true, apiMethods: ['get', 'list', 'create', 'update', 'delete'] }, + }; + const out = reconcileManagedApiMethods(engineOwned, { warn }); + expect(out.enable.apiMethods).toEqual(['get', 'list']); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain("managedBy:'system'"); + }); + + it('strips write verbs from an append-only object with no write affordances', () => { + const warn = vi.fn(); + const appendOnly: any = { + name: 'sys_email', + managedBy: 'append-only', + enable: { apiEnabled: true, apiMethods: ['get', 'list', 'update'] }, + }; + const out = reconcileManagedApiMethods(appendOnly, { warn }); + expect(out.enable.apiMethods).toEqual(['get', 'list']); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('keeps CRUD on a system-bucket object whose userActions open the writes (writable set)', () => { + const warn = vi.fn(); + const writable: any = { + name: 'sys_user_position', + managedBy: 'system', + userActions: { create: true, edit: true, delete: true }, + enable: { apiEnabled: true, apiMethods: ['get', 'list', 'create', 'update', 'delete'] }, + }; + const out = reconcileManagedApiMethods(writable, { warn }); + expect(out).toBe(writable); // nothing stripped + expect(out.enable.apiMethods).toEqual(['get', 'list', 'create', 'update', 'delete']); + expect(warn).not.toHaveBeenCalled(); + }); + it('applies on registerObject (the contradiction cannot be stored)', () => { const reg = new SchemaRegistry({ multiTenant: false }); reg.registerObject(managed(), 'sys', 'sys', 'own'); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 30ebf52604..c4649d0e2b 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -381,30 +381,30 @@ const MANAGED_WRITE_VERB_AFFORDANCE: Record void }, ): ServiceObject { - if ((schema as any).managedBy !== 'better-auth') return schema; + if ((schema as any).managedBy == null) return schema; const methods = (schema as any).enable?.apiMethods; if (!Array.isArray(methods) || methods.length === 0) return schema; @@ -433,10 +433,10 @@ export function reconcileManagedApiMethods( const warn = opts?.warn ?? ((msg: string) => console.warn(msg)); warn( - `[Registry] Object "${schema.name}" is managedBy:'better-auth' but advertised ` + - `generic write verb(s) [${stripped.join(', ')}] in enable.apiMethods it does not ` + - `permit — stripping them (ADR-0092/#1591). Writes on better-auth-managed tables go ` + - `through better-auth's endpoints, not the generic data API. Kept: [${kept.join(', ')}].`, + `[Registry] Object "${schema.name}" is managedBy:'${(schema as any).managedBy}' but advertised ` + + `generic write verb(s) [${stripped.join(', ')}] in enable.apiMethods its resolved affordances ` + + `do not permit — stripping them (ADR-0092/ADR-0103). Declare userActions to open a verb the ` + + `object legitimately takes from a user context. Kept: [${kept.join(', ')}].`, ); return { diff --git a/packages/platform-objects/src/audit/sys-email.object.ts b/packages/platform-objects/src/audit/sys-email.object.ts index 72493fff83..6b8e67d337 100644 --- a/packages/platform-objects/src/audit/sys-email.object.ts +++ b/packages/platform-objects/src/audit/sys-email.object.ts @@ -186,4 +186,10 @@ export const SysEmail = ObjectSchema.create({ { fields: ['related_object', 'related_id'] }, { fields: ['sent_by'] }, ], + + enable: { + // [ADR-0103] Engine-owned append-only mail log: written only by the mail + // service (SYSTEM_CTX; even the status update is system-elevated). Reads open. + apiMethods: ['get', 'list'], + }, }); diff --git a/packages/platform-objects/src/audit/sys-import-job.object.ts b/packages/platform-objects/src/audit/sys-import-job.object.ts index 9d0ea76c22..6df826bb3f 100644 --- a/packages/platform-objects/src/audit/sys-import-job.object.ts +++ b/packages/platform-objects/src/audit/sys-import-job.object.ts @@ -106,4 +106,11 @@ export const SysImportJob = ObjectSchema.create({ { fields: ['status', 'created_at'] }, { fields: ['created_by', 'created_at'] }, ], + + enable: { + // [ADR-0103] Engine-owned: the import worker owns the job-row lifecycle and + // writes it system-elevated from the REST import route (never hand-edited + // through the generic data API). Reads stay open for the Setup import grid. + apiMethods: ['get', 'list'], + }, }); diff --git a/packages/platform-objects/src/audit/sys-job-queue.object.ts b/packages/platform-objects/src/audit/sys-job-queue.object.ts index 830efcd121..3fda7b92b8 100644 --- a/packages/platform-objects/src/audit/sys-job-queue.object.ts +++ b/packages/platform-objects/src/audit/sys-job-queue.object.ts @@ -148,4 +148,10 @@ export const SysJobQueue = ObjectSchema.create({ { fields: ['idempotency_key', 'queue'] }, { fields: ['status'] }, ], + + enable: { + // [ADR-0103] Engine-owned: written only by the job queue runner (SYSTEM_CTX), + // never the generic data API. Reads stay open for the Setup grid. + apiMethods: ['get', 'list'], + }, }); diff --git a/packages/platform-objects/src/audit/sys-job-run.object.ts b/packages/platform-objects/src/audit/sys-job-run.object.ts index 17f1e72621..2daca95793 100644 --- a/packages/platform-objects/src/audit/sys-job-run.object.ts +++ b/packages/platform-objects/src/audit/sys-job-run.object.ts @@ -82,4 +82,10 @@ export const SysJobRun = ObjectSchema.create({ { fields: ['job_name', 'started_at'] }, { fields: ['status', 'started_at'] }, ], + + enable: { + // [ADR-0103] Engine-owned append-only run log: written only by the job + // runner (SYSTEM_CTX). Reads stay open for the Setup grid. + apiMethods: ['get', 'list'], + }, }); diff --git a/packages/platform-objects/src/audit/sys-job.object.ts b/packages/platform-objects/src/audit/sys-job.object.ts index 64275db776..fe4c267745 100644 --- a/packages/platform-objects/src/audit/sys-job.object.ts +++ b/packages/platform-objects/src/audit/sys-job.object.ts @@ -92,4 +92,10 @@ export const SysJob = ObjectSchema.create({ { fields: ['name'], unique: true }, { fields: ['active'] }, ], + + enable: { + // [ADR-0103] Engine-owned: written only by the job runner (SYSTEM_CTX), + // never the generic data API. Reads stay open for the Setup grid. + apiMethods: ['get', 'list'], + }, }); diff --git a/packages/platform-objects/src/audit/sys-notification.object.ts b/packages/platform-objects/src/audit/sys-notification.object.ts index 667e7934ac..2437e31db0 100644 --- a/packages/platform-objects/src/audit/sys-notification.object.ts +++ b/packages/platform-objects/src/audit/sys-notification.object.ts @@ -152,4 +152,10 @@ export const SysNotification = ObjectSchema.create({ { fields: ['dedup_key'], unique: true }, { fields: ['source_object', 'source_id'] }, ], + + enable: { + // [ADR-0103] Engine-owned: emitted only by NotificationService (single + // ingress, SYSTEM_CTX), never the generic data API. Reads stay open. + apiMethods: ['get', 'list'], + }, }); diff --git a/packages/platform-objects/src/identity/sys-user-preference.object.ts b/packages/platform-objects/src/identity/sys-user-preference.object.ts index c1deb02f9d..2da965fe20 100644 --- a/packages/platform-objects/src/identity/sys-user-preference.object.ts +++ b/packages/platform-objects/src/identity/sys-user-preference.object.ts @@ -26,6 +26,11 @@ export const SysUserPreference = ObjectSchema.create({ // the user's own settings page, never created by an admin. The list // surface in Setup is a support/diagnostic view only. managedBy: 'system', + // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user + // authors their own preferences (RLS self-grant). Affordance only — RLS is the + // authz; opening it here keeps the system write guard from rejecting the + // legitimate self-service write. + userActions: { create: true, edit: true, delete: true }, description: 'Per-user key-value preferences (theme, locale, etc.)', nameField: 'key', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) titleFormat: '{key}', diff --git a/packages/platform-objects/src/system/sys-secret.object.ts b/packages/platform-objects/src/system/sys-secret.object.ts index ee21a8353b..9e41f25e78 100644 --- a/packages/platform-objects/src/system/sys-secret.object.ts +++ b/packages/platform-objects/src/system/sys-secret.object.ts @@ -137,5 +137,10 @@ export const SysSecret = ObjectSchema.create({ enable: { trackHistory: false, // rotation events are recorded by sys_setting_audit audit: true, + // [ADR-0103] Engine-owned: secrets are minted/rotated only by the settings / + // secret service (SYSTEM_CTX), never via the generic data API. Locked to + // reads (ciphertext only; decryption is a separate privileged path) — an + // empty apiMethods array would fail OPEN, so this list is explicit. + apiMethods: ['get', 'list'], }, }); diff --git a/packages/platform-objects/src/system/sys-setting-audit.object.ts b/packages/platform-objects/src/system/sys-setting-audit.object.ts index f403928b2c..68cb328856 100644 --- a/packages/platform-objects/src/system/sys-setting-audit.object.ts +++ b/packages/platform-objects/src/system/sys-setting-audit.object.ts @@ -175,5 +175,8 @@ export const SysSettingAudit = ObjectSchema.create({ enable: { trackHistory: false, audit: false, // this IS the audit; no recursion + // [ADR-0103] Engine-owned: appended by the settings service (SYSTEM_CTX), + // never via the generic data API. Reads stay open for compliance review. + apiMethods: ['get', 'list'], }, }); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts index bce6b1f63d..e55da409ec 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts @@ -131,4 +131,10 @@ export const SysApprovalAction = ObjectSchema.create({ { fields: ['request_id', 'created_at'] }, { fields: ['request_id', 'step_index', 'action'] }, ], + + enable: { + // [ADR-0103] Engine-owned append-only decision log: appended by the approval + // engine (SYSTEM_CTX). Reads stay open. + apiMethods: ['get', 'list'], + }, }); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts index 0ed2ea51ae..77c3feef66 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts @@ -76,4 +76,10 @@ export const SysApprovalApprover = ObjectSchema.create({ // Sync path: rewrite all rows of one request on each approver-set change. { fields: ['request_id'] }, ], + + enable: { + // [ADR-0103] Engine-owned: approver rows are rewritten by the approval + // engine (SYSTEM_CTX) on each approver-set change, never via generic CRUD. + apiMethods: ['get', 'list'], + }, }); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts index 64670309e3..2a5b112d70 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts @@ -34,6 +34,11 @@ export const SysApprovalDelegation = ObjectSchema.create({ icon: 'user-clock', isSystem: true, managedBy: 'system', + // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user + // authors their own out-of-office delegation. Affordance only (matches the + // full-CRUD apiMethods below) — RLS/permission sets are the authz; opening it + // keeps the system write guard from rejecting the self-service write. + userActions: { create: true, edit: true, delete: true }, description: 'Self-service out-of-office rule: route this user\'s approver slots to a delegate within a time window (#1322 M1).', titleFormat: '{delegator_id} → {delegate_id}', diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts index 4a113320ae..617c49ff68 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts @@ -382,4 +382,11 @@ export const SysApprovalRequest = ObjectSchema.create({ refreshAfter: true, }, ], + + enable: { + // [ADR-0103] Engine-owned: the approval engine owns the request lifecycle + // (SYSTEM_CTX); users act via domain actions (Submit/Approve/Recall), never + // generic CRUD. Reads stay open. + apiMethods: ['get', 'list'], + }, }); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-token.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-token.object.ts index 3eac233a88..408bbc133e 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-token.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-token.object.ts @@ -92,4 +92,10 @@ export const SysApprovalToken = ObjectSchema.create({ { fields: ['token_hash'] }, { fields: ['request_id'] }, ], + + enable: { + // [ADR-0103] Engine-owned: one-time email-approval tokens are minted and + // consumed by the approval engine (SYSTEM_CTX), never via the data API. + apiMethods: ['get', 'list'], + }, }); diff --git a/packages/plugins/plugin-auth/src/identity-write-guard.test.ts b/packages/plugins/plugin-auth/src/identity-write-guard.test.ts index 58b5e3a173..ff297ec9d4 100644 --- a/packages/plugins/plugin-auth/src/identity-write-guard.test.ts +++ b/packages/plugins/plugin-auth/src/identity-write-guard.test.ts @@ -92,6 +92,10 @@ describe('identity write guard — insert/delete (ADR-0092 D2)', () => { } }); + // NOTE: `system`/`append-only` buckets are guarded by plugin-security's + // engine-owned write guard (ADR-0103), NOT by THIS identity guard — which + // stays scoped to `better-auth`. So `sys_automation_run` is correctly ignored + // here even though it is guarded elsewhere. it('ignores objects that are not managed by better-auth (incl. other managedBy buckets)', async () => { for (const object of ['crm_lead', 'sys_automation_run', 'not_registered']) { await expect( diff --git a/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts b/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts index 56dc78e694..36923ffbac 100644 --- a/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts +++ b/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts @@ -50,17 +50,22 @@ describe('foldWildcardSuperUser', () => { }); /** - * ADR-0092 D2 — the identity write guard is a second enforcement layer the - * permission sets don't model. The client hint must reflect permission ∩ guard: - * managed (`better-auth`) objects are user-context-writable only where the - * object opened the affordance; others (system/config/…) are untouched. + * ADR-0092 D2 / ADR-0103 — the engine write guards are a second enforcement + * layer the permission sets don't model. The client hint must reflect + * permission ∩ guard: guarded (`better-auth`, and now engine-owned + * `system`/`append-only`) objects are user-context-writable only where the + * object opened the affordance via `userActions`; `config`/`platform` are + * untouched. */ describe('clampManagedObjectWrites', () => { const SCHEMAS: Record = { sys_user: { managedBy: 'better-auth', userActions: { edit: true } }, sys_member: { managedBy: 'better-auth' }, sys_session: { managedBy: 'better-auth' }, - sys_automation_run: { managedBy: 'system' }, // NOT better-auth → not guarded + // ADR-0103: system with no userActions → engine-owned → guarded (clamped). + sys_automation_run: { managedBy: 'system' }, + // ADR-0103: system that opened its writes → writable set → NOT clamped. + sys_user_position: { managedBy: 'system', userActions: { create: true, edit: true, delete: true } }, crm_lead: { managedBy: 'platform' }, }; const schemaOf = (n: string) => SCHEMAS[n]; @@ -83,13 +88,26 @@ describe('clampManagedObjectWrites', () => { expect(objects.sys_session.allowRead).toBe(true); // read never clamped }); - it('leaves non-better-auth managed buckets untouched (system objects have no write guard)', () => { - const objects: Record = { sys_automation_run: { allowEdit: true }, crm_lead: { allowEdit: true } }; + it('clamps engine-owned system objects (ADR-0103) but leaves config/platform untouched', () => { + const objects: Record = { + sys_automation_run: { allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true }, + crm_lead: { allowEdit: true }, + }; clampManagedObjectWrites(objects, schemaOf); - expect(objects.sys_automation_run.allowEdit).toBe(true); + // system + no userActions → engine-owned → writes clamped off; read kept. + expect(objects.sys_automation_run).toMatchObject({ allowRead: true, allowEdit: false, allowCreate: false, allowDelete: false }); + // platform bucket → not guarded → untouched. expect(objects.crm_lead.allowEdit).toBe(true); }); + it('leaves the writable system set untouched (userActions opened the writes)', () => { + const objects: Record = { + sys_user_position: { allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true }, + }; + clampManagedObjectWrites(objects, schemaOf); + expect(objects.sys_user_position).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true }); + }); + it('treats the #2614 object form by its enabled flag only (predicates are UI gating, not a grant)', () => { const schemas: Record = { sys_user: { diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index e13e5194fe..53d4de9d1d 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -234,24 +234,34 @@ function isWriteOptedIn(v: boolean | { enabled?: boolean } | undefined | null): return v === true || (typeof v === 'object' && v !== null && v.enabled === true); } +/** + * Buckets whose user-context generic writes are guarded fail-closed at the + * engine: `better-auth` by plugin-auth's identity write guard (ADR-0092 D2), + * `system` / `append-only` by plugin-security's engine-owned write guard + * (ADR-0103). `config` / `platform` have no such guard — their permission-set + * result stands. + */ +const GUARDED_WRITE_BUCKETS: ReadonlySet = new Set(['better-auth', 'system', 'append-only']); + /** * Re-clamp a `/me/permissions` `objects` map by the SECOND server-side - * enforcement layer that permission sets don't model: the identity write guard - * (ADR-0092 D2). The guard fail-closed rejects USER-CONTEXT insert/update/delete - * on every `managedBy: 'better-auth'` object except where the object opted a - * write affordance in (`userActions.{create,edit,delete}` — e.g. sys_user opens - * `edit` for its profile fields; the field-level `readonly` flags then narrow it - * to `{name, image}`). + * enforcement layer that permission sets don't model: the engine write guards. + * They fail-closed reject USER-CONTEXT insert/update/delete on every managed + * object whose resolved affordances forbid the verb — `better-auth` + * (ADR-0092 D2) and `system`/`append-only` (ADR-0103) — except where the object + * opted the write affordance in via `userActions.{create,edit,delete}` (e.g. + * sys_user opens `edit` for its profile fields; the RBAC link tables / prefs / + * messaging config open their CRUD). * * Without this clamp, {@link foldWildcardSuperUser} would report `allowEdit:true` - * for a platform admin on identity tables the guard actually blocks (sys_member, - * sys_account, …) — a false-POSITIVE that mirrors, inverted, the false-negative - * the fold fixes. The real effective answer for a user-context caller is - * `permission-set grant ∩ guard policy`, and the guard policy for a managed - * object is exactly its resolved CRUD affordance. Only `better-auth` objects are - * clamped — the guard covers only them; `system`/`config`/`append-only` objects - * have no such guard, so their permission-set result stands (an admin CAN write - * them via the data API, and the hint must not under-report that). + * for a platform admin on tables the guard actually blocks (sys_member, + * sys_automation_run, …) — a false-POSITIVE that mirrors, inverted, the + * false-negative the fold fixes. The real effective answer for a user-context + * caller is `permission-set grant ∩ guard policy`, and the guard policy for a + * guarded object is exactly its resolved CRUD affordance. `config`/`platform` + * objects are NOT clamped — no guard covers them, so their permission-set result + * stands (an admin CAN write them via the data API, and the hint must not + * under-report that). */ export function clampManagedObjectWrites( objects: Record, @@ -260,7 +270,7 @@ export function clampManagedObjectWrites( for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) { if (obj === '*' || !acc) continue; const schema = schemaOf(obj); - if (schema?.managedBy !== 'better-auth') continue; + if (!schema?.managedBy || !GUARDED_WRITE_BUCKETS.has(schema.managedBy)) continue; const ua = schema.userActions ?? {}; if (!isWriteOptedIn(ua.edit)) acc.allowEdit = false; if (ua.create !== true) acc.allowCreate = false; diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index d652c1a1b9..0e9dad0448 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -50,6 +50,8 @@ export { claimSeedOwnership } from './claim-seed-ownership.js'; export { normalizeManagedByVocab } from './normalize-managed-by.js'; export { appDefaultPermissionSetName } from './app-default-permission-set.js'; export { DelegatedAdminGate, isTenantAdmin } from './delegated-admin-gate.js'; +export { assertEngineOwnedWriteAllowed, ENGINE_OWNED_BUCKETS } from './system-write-guard.js'; +export type { EngineOwnedSchemaLike } from './system-write-guard.js'; export { explainAccess, buildContextForUser } from './explain-engine.js'; export type { ExplainEngineDeps, ExplainInput } from './explain-engine.js'; export type { DelegatedAdminGateDeps } from './delegated-admin-gate.js'; diff --git a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts index 31b57707b4..3d40afe2f7 100644 --- a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts @@ -25,6 +25,11 @@ export const SysPositionPermissionSet = ObjectSchema.create({ icon: 'shield-plus', isSystem: true, managedBy: 'system', + // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: + // `suggested-audience-bindings.ts` inserts a binding with `context: callerCtx` + // (deliberately not isSystem). Affordance only — the DelegatedAdminGate is the + // authz. + userActions: { create: true, edit: true, delete: true }, description: 'Binds a permission set to a position.', titleFormat: '{position_id} → {permission_set_id}', highlightFields: ['position_id', 'permission_set_id'], diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index d95f7a769a..88ddf94ecb 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -24,6 +24,10 @@ export const SysUserPermissionSet = ObjectSchema.create({ icon: 'user-check', isSystem: true, managedBy: 'system', + // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: delegated + // `manageBindings` direct grants write this under the caller's context. + // Affordance only — the DelegatedAdminGate is the authz. + userActions: { create: true, edit: true, delete: true }, description: 'Direct assignment of a permission set to a user (optionally scoped to an organization).', titleFormat: '{user_id} → {permission_set_id}', highlightFields: ['user_id', 'permission_set_id', 'organization_id'], diff --git a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts index 979e552ff5..4a1255496e 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts @@ -35,6 +35,11 @@ export const SysUserPosition = ObjectSchema.create({ icon: 'user-cog', isSystem: true, managedBy: 'system', + // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: delegated + // "add position" writes this under the caller's context. Affordance only — + // the DelegatedAdminGate is the authz; opening it here keeps the system write + // guard from rejecting the legitimate write. + userActions: { create: true, edit: true, delete: true }, description: 'Assigns a position (sys_position.name) to a user. Platform-owned (ADR-0057 D4, ADR-0090 D3).', titleFormat: '{user_id} → {position}', highlightFields: ['user_id', 'position', 'business_unit_id', 'organization_id'], diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index dedfa8d507..1463887327 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -40,6 +40,7 @@ import { matchesFilterCondition } from '@objectstack/formula'; import { FieldMasker } from './field-masker.js'; import { assertReadableQueryFields } from './predicate-guard.js'; import { PermissionDeniedError } from './errors.js'; +import { assertEngineOwnedWriteAllowed } from './system-write-guard.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; import { backfillOrgAdminGrants, @@ -676,6 +677,22 @@ export class SecurityPlugin implements Plugin { // is validated at seed time by the same predicate.) await this.assertAudienceAnchorBindingGate(opCtx); + // [ADR-0103] Engine-owned write guard. Fail-closed on a user-context + // generic write to a `system`/`append-only` object whose resolved + // affordances forbid the verb (the engine-owned default). Keyed off + // resolveCrudAffordances, not the raw bucket, so the admin/user-writable + // members (RBAC link tables, prefs, messaging config) — which declare + // userActions opening their verbs — pass through to the DelegatedAdminGate + // / RLS below. System/boot writes carry `isSystem` and short-circuited + // above; context-less service writes carry no userId and pass by + // construction. Runs BEFORE the empty-principal fall-open so engine-owned + // tables fail CLOSED for principal-less-but-user-context callers. + assertEngineOwnedWriteAllowed( + typeof ql?.getSchema === 'function' ? ql.getSchema(opCtx.object) : undefined, + opCtx.operation, + opCtx.context, + ); + // [ADR-0090 D12] Delegated-administration gate. Writes to the RBAC // link tables (assignments / bindings / direct grants / env-set // authoring) are a GOVERNED operation: tenant-level admins pass diff --git a/packages/plugins/plugin-security/src/system-write-guard.test.ts b/packages/plugins/plugin-security/src/system-write-guard.test.ts new file mode 100644 index 0000000000..27b3ecebbf --- /dev/null +++ b/packages/plugins/plugin-security/src/system-write-guard.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// ADR-0103 — engine-owned write guard for the `system` / `append-only` buckets. + +import { describe, it, expect } from 'vitest'; +import { assertEngineOwnedWriteAllowed, ENGINE_OWNED_BUCKETS } from './system-write-guard.js'; +import { isPermissionDeniedError } from './errors.js'; + +// A real user, not system-elevated → a user-context write. +const USER_CTX = { userId: 'u1', isSystem: false }; +// System-elevated (plugin/boot/import) → bypasses. +const SYSTEM_CTX = { userId: 'u1', isSystem: true }; +// No session (raw-engine / transaction context) → bypasses. +const CONTEXTLESS = { transaction: {} }; + +const engineOwned = { name: 'sys_automation_run', managedBy: 'system' }; +const appendOnly = { name: 'sys_audit_log', managedBy: 'append-only' }; +const writable = { + name: 'sys_user_position', + managedBy: 'system', + userActions: { create: true, edit: true, delete: true }, +}; + +/** Assert a call throws a PERMISSION_DENIED (403) error. */ +function expectDenied(fn: () => void): void { + let thrown: unknown; + try { + fn(); + } catch (e) { + thrown = e; + } + expect(thrown, 'expected a PermissionDeniedError to be thrown').toBeDefined(); + expect(isPermissionDeniedError(thrown)).toBe(true); + expect((thrown as any).statusCode).toBe(403); +} + +describe('assertEngineOwnedWriteAllowed (ADR-0103)', () => { + it('scopes to the system and append-only buckets only', () => { + expect([...ENGINE_OWNED_BUCKETS].sort()).toEqual(['append-only', 'system']); + }); + + describe('engine-owned system/append-only objects', () => { + it('rejects user-context insert/update/delete', () => { + for (const op of ['insert', 'update', 'delete', 'upsert', 'purge', 'transfer', 'restore']) { + expectDenied(() => assertEngineOwnedWriteAllowed(engineOwned, op, USER_CTX)); + } + }); + + it('rejects user-context writes to append-only objects too', () => { + expectDenied(() => assertEngineOwnedWriteAllowed(appendOnly, 'update', USER_CTX)); + expectDenied(() => assertEngineOwnedWriteAllowed(appendOnly, 'delete', USER_CTX)); + }); + + it('allows reads (find/findOne/count/aggregate) even under user context', () => { + for (const op of ['find', 'findOne', 'count', 'aggregate', 'get', 'list']) { + expect(() => assertEngineOwnedWriteAllowed(engineOwned, op, USER_CTX)).not.toThrow(); + } + }); + + it('bypasses isSystem-elevated writes', () => { + for (const op of ['insert', 'update', 'delete']) { + expect(() => assertEngineOwnedWriteAllowed(engineOwned, op, SYSTEM_CTX)).not.toThrow(); + } + }); + + it('bypasses context-less engine/service writes (no userId)', () => { + for (const op of ['insert', 'update', 'delete']) { + expect(() => assertEngineOwnedWriteAllowed(engineOwned, op, CONTEXTLESS)).not.toThrow(); + expect(() => assertEngineOwnedWriteAllowed(engineOwned, op, undefined)).not.toThrow(); + } + }); + }); + + describe('the writable set (system + userActions)', () => { + it('allows user-context insert/update/delete when userActions opened them', () => { + for (const op of ['insert', 'update', 'delete']) { + expect(() => assertEngineOwnedWriteAllowed(writable, op, USER_CTX)).not.toThrow(); + } + }); + + it('allows only the opened verbs — a partial userActions still guards the rest', () => { + const editOnly = { name: 'sys_thing', managedBy: 'system', userActions: { edit: true } }; + expect(() => assertEngineOwnedWriteAllowed(editOnly, 'update', USER_CTX)).not.toThrow(); + expectDenied(() => assertEngineOwnedWriteAllowed(editOnly, 'insert', USER_CTX)); + expectDenied(() => assertEngineOwnedWriteAllowed(editOnly, 'delete', USER_CTX)); + }); + }); + + describe('out of scope', () => { + it('ignores platform / config buckets (no guard)', () => { + for (const bucket of ['platform', 'config']) { + expect(() => + assertEngineOwnedWriteAllowed({ name: 'x', managedBy: bucket }, 'delete', USER_CTX), + ).not.toThrow(); + } + }); + + it('ignores better-auth (handled by plugin-auth identity guard, not this one)', () => { + expect(() => + assertEngineOwnedWriteAllowed({ name: 'sys_user', managedBy: 'better-auth' }, 'update', USER_CTX), + ).not.toThrow(); + }); + + it('ignores unmanaged objects and unknown schemas', () => { + expect(() => assertEngineOwnedWriteAllowed({ name: 'crm_lead' }, 'delete', USER_CTX)).not.toThrow(); + expect(() => assertEngineOwnedWriteAllowed(undefined, 'delete', USER_CTX)).not.toThrow(); + }); + }); +}); diff --git a/packages/plugins/plugin-security/src/system-write-guard.ts b/packages/plugins/plugin-security/src/system-write-guard.ts new file mode 100644 index 0000000000..5e24549c88 --- /dev/null +++ b/packages/plugins/plugin-security/src/system-write-guard.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0103 — engine-owned write guard for the `system` / `append-only` buckets. + * + * `managedBy: 'system'` and `managedBy: 'append-only'` default to *engine-owned*: + * rows a platform service owns end to end (the approval engine, the sharing + * engine, the job runner, the messaging pipeline, …), written only via + * `isSystem` / a service `SYSTEM_CTX` / a context-less engine call. Until this + * guard that promise was enforced by nothing but UI affordances and default + * permission sets — a wildcard admin could raw-write these rows through the + * generic data API (ADR-0049 violation), exactly the hole ADR-0092's identity + * write guard closed for `better-auth`. + * + * This is the `system`/`append-only` counterpart, keyed off the SAME contract + * the UI and the `apiMethods` reconciliation use — {@link resolveCrudAffordances} + * — rather than the raw bucket string. An object is engine-owned precisely when + * its resolved affordances grant no write; the admin/user-writable members of + * these buckets (the RBAC link tables, `sys_user_preference`, the messaging + * config grids, …) declare `userActions` opening the verbs they legitimately + * take, so they pass this guard and their real authz — the `DelegatedAdminGate`, + * RLS self-grants, permission sets — adjudicates the principal, unchanged. + * + * A write is USER-CONTEXT when its context carries a real `userId` and is not + * `isSystem`. `isSystem` and context-less engine/service writes bypass by + * construction — that is exactly how the legitimate engine writers reach these + * tables (the messaging service's raw-engine writes carry no session; the + * metadata-protocol repository threads only a transaction handle; approval / + * job / sharing services stamp `SYSTEM_CTX`). + * + * Denials raise {@link PermissionDeniedError} (HTTP 403), the same sentinel the + * rest of `SecurityPlugin` throws. `better-auth` is deliberately NOT handled + * here — it keeps plugin-auth's identity write guard, whose field-whitelist and + * session-snapshot-refresh semantics differ. + */ + +import { resolveCrudAffordances } from '@objectstack/spec/data'; +import { PermissionDeniedError } from './errors.js'; + +/** Buckets whose DEFAULT affordance row is engine-owned (no user writes). */ +export const ENGINE_OWNED_BUCKETS: ReadonlySet = new Set(['system', 'append-only']); + +/** + * Engine write operation → the {@link resolveCrudAffordances} flag it needs. + * Read ops (`find`/`findOne`/`count`/`aggregate`/…) are absent and always pass. + * Aligned with the `DelegatedAdminGate` governed-operation set and the registry's + * `MANAGED_WRITE_VERB_AFFORDANCE`. + */ +const WRITE_OP_AFFORDANCE: Record = { + insert: 'create', + update: 'edit', + upsert: 'edit', + transfer: 'edit', + delete: 'delete', + purge: 'delete', + restore: 'delete', +}; + +/** Minimal shape read off a registered schema. */ +export interface EngineOwnedSchemaLike { + name?: string; + managedBy?: string; + userActions?: unknown; +} + +/** + * A write is user-context when it carries a real user and is not system + * elevated. Context-less engine calls (no session) and `isSystem` plugin/system + * writes both bypass by construction. + */ +function isUserContextWrite(context: any): boolean { + return Boolean(context?.userId) && context?.isSystem !== true; +} + +/** + * Fail-closed on a user-context generic write to an engine-owned + * `system`/`append-only` object. No-op for: reads, non-engine-owned buckets, + * system/context-less writes, and objects whose `userActions` open the verb. + * + * @param schema the registered schema (or undefined — unknown objects pass) + * @param operation the engine operation (`insert`/`update`/`delete`/…) + * @param context the operation execution context + */ +export function assertEngineOwnedWriteAllowed( + schema: EngineOwnedSchemaLike | undefined | null, + operation: string, + context: any, +): void { + const bucket = schema?.managedBy; + if (!bucket || !ENGINE_OWNED_BUCKETS.has(bucket)) return; + + const need = WRITE_OP_AFFORDANCE[operation]; + if (!need) return; // read / non-write op + + if (!isUserContextWrite(context)) return; // isSystem or context-less → bypass + + const affordances = resolveCrudAffordances(schema as any); + if (affordances[need]) return; // userActions opened it → writable set + + throw new PermissionDeniedError( + `[Security] Access denied: '${schema?.name ?? 'object'}' is engine-owned ` + + `(managedBy:'${bucket}', ADR-0103) — direct ${operation} via the data API is disabled. ` + + `These rows are written only by their owning platform service; interact via the ` + + `object's domain actions instead.`, + { operation, object: schema?.name, managedBy: bucket }, + ); +} diff --git a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts index 05a8a8877d..8b48becdd0 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts @@ -226,4 +226,10 @@ export const SysRecordShare = ObjectSchema.create({ // Reconciliation key for rule-driven shares. { fields: ['source', 'source_id'] }, ], + + enable: { + // [ADR-0103] Engine-owned: materialized only by the sharing engine + // (SYSTEM_CTX), never via the generic data API. Reads stay open. + apiMethods: ['get', 'list'], + }, }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index c309f618b8..5886d13e15 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3758,7 +3758,12 @@ export class RestServer { }; try { - await (p as any).createData({ object: IMPORT_JOB_OBJECT, data: jobRow, context, ...(environmentId ? { environmentId } : {}) }); + // [ADR-0103] sys_import_job rows are engine-owned — the import + // worker owns their lifecycle, and the object is locked to + // ['get','list']. Persist system-elevated so the engine-owned + // write guard admits it; attribution is preserved because + // `created_by` is stamped explicitly on the row above. + await (p as any).createData({ object: IMPORT_JOB_OBJECT, data: jobRow, context: { ...(context as any), isSystem: true }, ...(environmentId ? { environmentId } : {}) }); } catch (err: any) { logError('[REST] Failed to persist import job:', err); res.status(500).json({ code: 'IMPORT_JOB_CREATE_FAILED', error: 'Could not create import job' }); @@ -3772,7 +3777,7 @@ export class RestServer { // handling and persists terminal state to the job row. const patch = async (data: Record) => { try { - await (p as any).updateData({ object: IMPORT_JOB_OBJECT, id: jobId, data, context, ...(environmentId ? { environmentId } : {}) }); + await (p as any).updateData({ object: IMPORT_JOB_OBJECT, id: jobId, data, context: { ...(context as any), isSystem: true }, ...(environmentId ? { environmentId } : {}) }); // [ADR-0103] engine-owned } catch (err) { logError('[REST] import job progress write failed:', err); } @@ -3879,7 +3884,7 @@ export class RestServer { // Signal the in-process worker and mark the durable row. this.cancelledImportJobs.add(jobId); try { - await (p as any).updateData({ object: IMPORT_JOB_OBJECT, id: jobId, data: { status: 'cancelled', completed_at: new Date().toISOString() }, context, ...(environmentId ? { environmentId } : {}) }); + await (p as any).updateData({ object: IMPORT_JOB_OBJECT, id: jobId, data: { status: 'cancelled', completed_at: new Date().toISOString() }, context: { ...(context as any), isSystem: true }, ...(environmentId ? { environmentId } : {}) }); // [ADR-0103] engine-owned } catch { /* worker will still stop via the in-memory flag */ } } res.json({ success: true }); @@ -3941,7 +3946,8 @@ export class RestServer { await (p as any).updateData({ object: IMPORT_JOB_OBJECT, id: jobId, data: { reverted_at: new Date().toISOString() }, - context, ...(environmentId ? { environmentId } : {}), + context: { ...(context as any), isSystem: true }, // [ADR-0103] engine-owned + ...(environmentId ? { environmentId } : {}), }); res.json({ success: true, jobId, object: objectName, deleted, restored, failed }); } catch (error: any) { diff --git a/packages/services/service-automation/src/sys-automation-run.object.ts b/packages/services/service-automation/src/sys-automation-run.object.ts index dcc57d3dfc..7be1a68c39 100644 --- a/packages/services/service-automation/src/sys-automation-run.object.ts +++ b/packages/services/service-automation/src/sys-automation-run.object.ts @@ -196,4 +196,10 @@ export const SysAutomationRun = ObjectSchema.create({ // Look up a suspended run by the pausing node's correlation key. { fields: ['correlation'] }, ], + + enable: { + // [ADR-0103] Engine-owned: written only by the automation runner / suspended + // run store (SYSTEM_CTX), never via the generic data API. Reads stay open. + apiMethods: ['get', 'list'], + }, }); diff --git a/packages/services/service-messaging/src/objects/http-delivery.object.ts b/packages/services/service-messaging/src/objects/http-delivery.object.ts index 6afd5ff610..9391d66269 100644 --- a/packages/services/service-messaging/src/objects/http-delivery.object.ts +++ b/packages/services/service-messaging/src/objects/http-delivery.object.ts @@ -171,6 +171,12 @@ export const HttpDelivery = ObjectSchema.create({ { fields: ['status', 'claimed_at'] }, { fields: ['source', 'ref_id'] }, ], + + enable: { + // [ADR-0103] Engine-owned webhook outbox: written only by SqlHttpOutbox + // (context-less raw-engine writes), never via the generic data API. + apiMethods: ['get', 'list'], + }, }); /** Canonical object name — exported so SqlHttpOutbox callers can override. */ diff --git a/packages/services/service-messaging/src/objects/notification-delivery.object.ts b/packages/services/service-messaging/src/objects/notification-delivery.object.ts index a63781f722..ecce455256 100644 --- a/packages/services/service-messaging/src/objects/notification-delivery.object.ts +++ b/packages/services/service-messaging/src/objects/notification-delivery.object.ts @@ -93,4 +93,10 @@ export const NotificationDelivery = ObjectSchema.create({ // P3b-2: the digest collapse pass — claim due batched rows by group. { fields: ['digest_key', 'status', 'next_attempt_at'] }, ], + + enable: { + // [ADR-0103] Engine-owned delivery outbox: written only by the messaging + // service (context-less raw-engine writes), never via the data API. + apiMethods: ['get', 'list'], + }, }); diff --git a/packages/services/service-messaging/src/objects/notification-preference.object.ts b/packages/services/service-messaging/src/objects/notification-preference.object.ts index db3e8a3de8..9a7833620e 100644 --- a/packages/services/service-messaging/src/objects/notification-preference.object.ts +++ b/packages/services/service-messaging/src/objects/notification-preference.object.ts @@ -26,6 +26,11 @@ export const NotificationPreference = ObjectSchema.create({ icon: 'bell-ring', isSystem: true, managedBy: 'system', + // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user + // authors their own mute/allow rows (and admins the `user_id = '*'` global + // defaults) from the Setup "Notification Preferences" grid. Affordance only — + // opening it keeps the system write guard from rejecting the self-service write. + userActions: { create: true, edit: true, delete: true }, description: 'Per-user × topic × channel notification toggle (mute/allow), with admin-global defaults.', titleFormat: '{user_id} · {topic} · {channel}', highlightFields: ['user_id', 'topic', 'channel', 'enabled', 'digest'], diff --git a/packages/services/service-messaging/src/objects/notification-receipt.object.ts b/packages/services/service-messaging/src/objects/notification-receipt.object.ts index 0d9135cde6..b648c8dedb 100644 --- a/packages/services/service-messaging/src/objects/notification-receipt.object.ts +++ b/packages/services/service-messaging/src/objects/notification-receipt.object.ts @@ -91,4 +91,11 @@ export const NotificationReceipt = ObjectSchema.create({ { fields: ['notification_id', 'user_id', 'channel'], unique: true }, { fields: ['user_id', 'state'] }, ], + + enable: { + // [ADR-0103] Engine-owned: delivery/read receipts are written by the + // messaging service (context-less raw-engine writes) when a notification + // is delivered or marked read, never via the generic data API. + apiMethods: ['get', 'list'], + }, }); diff --git a/packages/services/service-messaging/src/objects/notification-subscription.object.ts b/packages/services/service-messaging/src/objects/notification-subscription.object.ts index 2b9b3f6607..e4b0cfb1cf 100644 --- a/packages/services/service-messaging/src/objects/notification-subscription.object.ts +++ b/packages/services/service-messaging/src/objects/notification-subscription.object.ts @@ -24,6 +24,10 @@ export const NotificationSubscription = ObjectSchema.create({ icon: 'rss', isSystem: true, managedBy: 'system', + // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: authored + // from the Setup "Notification Subscriptions" grid. Affordance only — opening + // it keeps the system write guard from rejecting the write. + userActions: { create: true, edit: true, delete: true }, description: 'Standing subscription of a principal (role/team/user) to a notification topic.', titleFormat: '{principal} · {topic}', highlightFields: ['topic', 'principal', 'enabled', 'created_at'], diff --git a/packages/services/service-messaging/src/objects/notification-template.object.ts b/packages/services/service-messaging/src/objects/notification-template.object.ts index 0742ad4713..fa23593735 100644 --- a/packages/services/service-messaging/src/objects/notification-template.object.ts +++ b/packages/services/service-messaging/src/objects/notification-template.object.ts @@ -23,6 +23,10 @@ export const NotificationTemplate = ObjectSchema.create({ icon: 'file-text', isSystem: true, managedBy: 'system', + // [ADR-0103] Admin-writable DATA on a platform-defined schema: authored from + // the Setup "Notification Templates" grid. Affordance only — opening it keeps + // the system write guard from rejecting the admin authoring write. + userActions: { create: true, edit: true, delete: true }, description: 'Per (topic × channel × locale) render template for notifications.', titleFormat: '{topic} · {channel} · {locale}', highlightFields: ['topic', 'channel', 'locale', 'is_active'], diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 6e9d443265..96545bcfbc 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -590,15 +590,24 @@ const ObjectSchemaBase = z.object({ * purpose-built "Import definition (JSON)" action instead). Example: * `sys_sharing_rule`, `sys_position`, `sys_permission_set`, `sys_view`, * `sys_app`. - * - `system` — Runtime rows whose lifecycle is owned by a - * platform service (the approval engine, the sharing engine, the - * invitation service, …). Generic CRUD is hidden — users interact - * with these via *domain actions* invoked from the source record - * (e.g. "Submit for Approval" on an Opportunity creates an - * `sys_approval_request`; "Recall" on the request changes its - * state). Example: `sys_approval_request`, `sys_record_share`, - * `sys_notification`, `sys_invitation`, - * `sys_user_permission_set` / `sys_position_permission_set`. + * - `system` — Platform-defined schema. The bucket DEFAULT is + * *engine-owned*: runtime rows whose lifecycle a platform service owns + * end to end (the approval engine, the sharing engine, the job runner, + * …), written only via `isSystem` / a service `SYSTEM_CTX` / a + * context-less engine call. Generic CRUD is hidden — users interact + * with these via *domain actions* on the source record (e.g. "Submit + * for Approval" creates an `sys_approval_request`). Example: + * `sys_approval_request`, `sys_record_share`, `sys_notification`, + * `sys_automation_run`, `sys_job`. + * Some `system` objects are platform schema but hold **admin/user-writable + * DATA** — the RBAC link tables (`sys_user_position`, + * `sys_user_permission_set`, `sys_position_permission_set`, governed by + * the `DelegatedAdminGate`), `sys_user_preference`, the messaging config + * grids. They declare {@link userActions} to open the writes they take; + * the affordance is a declaration only — the real authz stays the + * delegated-admin gate / RLS. An `engine-owned` object is precisely a + * `system`/`append-only` object whose resolved affordances grant no write + * (ADR-0103). * - `append-only` — Immutable audit log. No New / Import / Edit / * Delete; only View and Export. Example: `sys_approval_action`, * `sys_audit_log`, `sys_activity`, `sys_email`, `sys_presence`. @@ -611,12 +620,18 @@ const ObjectSchemaBase = z.object({ * suppressed; replaced by purpose-built actions * (Invite User, Reset Password, Revoke Session, Rotate Key, …). * - * The flag is purely declarative on the schema. Enforcement happens in - * two places: + * The flag supplies the DEFAULT affordance row; the enforced write policy + * is {@link resolveCrudAffordances} (bucket default + `userActions`). + * Enforcement happens in three places: * 1. Default permission sets ({@link packages/platform-objects/src/security/default-permission-sets.ts}) * deny direct CRUD for `system` / `append-only` / `better-auth`. * 2. UI clients honour {@link resolveCrudAffordances} to gate the * New / Import / Edit / Delete / Export buttons accordingly. + * 3. Engine write guards fail-closed on user-context generic writes to a + * managed object whose resolved affordances forbid the verb — + * `better-auth` via plugin-auth's identity write guard (ADR-0092), + * `system` / `append-only` via plugin-security's system write guard + * (ADR-0103). `isSystem` / context-less engine writes bypass. * * Use {@link userActions} to override the default matrix for a single * field (e.g. an "append-only" table that should still allow Export). @@ -1422,8 +1437,12 @@ export interface RowCrudPredicates { * config — admin authored: New/Edit/Delete OK, no CSV import * (definitions have nested envelopes; admins should use * a purpose-built "Import definition" action instead) - * system — engine-managed runtime rows: no generic CRUD; users - * interact via domain actions on the source record + * system — platform-defined schema. DEFAULT is engine-owned: no + * generic CRUD; users interact via domain actions on the + * source record. `userActions` opens the admin/user-writable + * ones (RBAC link tables, prefs, messaging config) — the + * affordance declaration only; authz stays the delegated-admin + * gate / RLS (ADR-0103) * append-only — audit log: View + Export only * better-auth — identity tables owned by better-auth driver; CRUD * routed through purpose-built actions (Invite, Reset