From a14c198fc00bb093ba58635972ceeaa696472dfd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 09:50:03 +0000 Subject: [PATCH 1/2] fix(core): effective object permissions read the external write gate A read-only external object (ADR-0015 Gate 3 closed) is served no write grant and no write operation, so the console stops offering New, Import, Edit and Delete the data plane refuses. objectql gains the public isExternalWriteForbidden verdict that assertWriteAllowed itself reads; both map builders hand it over. Claude-Session: https://claude.ai/code/session_01Utp6CXSTW6WZn81ULyBVdb Co-authored-by: Claude --- .../security/effective-object-permissions.ts | 125 ++++++++++++++++-- packages/core/src/security/index.ts | 2 + packages/objectql/src/engine-registration.ts | 59 ++++++--- packages/objectql/src/engine.ts | 19 +++ .../src/current-user-endpoints.ts | 5 + .../plugin-security/src/security-plugin.ts | 5 +- 6 files changed, 190 insertions(+), 25 deletions(-) diff --git a/packages/core/src/security/effective-object-permissions.ts b/packages/core/src/security/effective-object-permissions.ts index 09e5a75cc96..0c7fdba6e4e 100644 --- a/packages/core/src/security/effective-object-permissions.ts +++ b/packages/core/src/security/effective-object-permissions.ts @@ -36,6 +36,7 @@ import { resolveEffectiveApiMethods, effectiveOperationsArray, canServeApiOperation, + type ApiOperation, type EnableLike, } from '@objectstack/spec/data'; import { objectPermissionGrants, type EffectiveObjectPermission } from '@objectstack/spec/security'; @@ -478,10 +479,18 @@ export function seedSuperUserRestrictedObjects( * Which entries carry the annotation is unchanged in rule: only an * unrestricted object that keeps its whole closure — every operation served, * `export` included — gets none. + * + * [#30370] With `isExternalWriteForbidden`, an object whose external write + * gate is closed (ADR-0015 §5.3 Gate 3) is served without every write + * operation ({@link EXTERNAL_WRITE_GATED_OPERATIONS}): the data plane refuses + * each one `403 EXTERNAL_WRITE_FORBIDDEN` whatever the permission sets say, + * and such an object is always annotated, since the client's default-allow + * path would offer every write. Without it, the answer is what it was. */ export function annotateEffectiveApiOperations( objects: Record, schemaOf: (objectName: string) => ApiExposureSchemaLike | undefined, + isExternalWriteForbidden?: (objectName: string) => boolean, ): void { for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) { if (obj === '*' || !acc) continue; @@ -497,18 +506,86 @@ export function annotateEffectiveApiOperations( const closure = effectiveOperationsArray(eff); // [#20135] Only what the door itself admits: `apiEnabled: false` empties // the set; any other `enable` leaves the closure as it is. - const served = closure.filter((operation) => canServeApiOperation(enable, operation)); + // [#30370] And only what the external write gate admits: a closed gate + // refuses every write operation on the data plane. + const writeForbidden = isExternalWriteForbidden?.(obj) === true; + const served = closure.filter((operation) => + canServeApiOperation(enable, operation) + && !(writeForbidden && EXTERNAL_WRITE_GATED_OPERATIONS.has(operation))); // Annotate when the object tightens via `apiMethods`, when the export // axis removes `export` from an otherwise-open object (so the client // hides the Export button), OR when the door serves less than the closure - // (an API-disabled object). An unrestricted object with every operation - // still served needs no annotation — the client keeps its default-allow - // path. + // (an API-disabled object, or [#30370] a closed external write gate). An + // unrestricted object with every operation still served needs no + // annotation — the client keeps its default-allow path. if (eff.mode === 'unrestricted' && userExportAllowed && served.length === closure.length) continue; acc.apiOperations = served; } } +/** + * [#30370] The effective API operations that WRITE a row — every one the + * external write gate (ADR-0015 §5.3 Gate 3, objectql `assertWriteAllowed`) + * refuses on an object whose gate is closed: the three primitives it judges + * (`insert` is `create`), and every derived operation built on them — + * `upsert` (create ∧ update), `bulk` (batched create / update / delete), + * `import` (create ∨ update), and `restore` / `purge`, which never derive + * today but write when they do. The read operations (`get`, `list`, + * `aggregate`, `history`, `search`, `export`) are untouched: the gate judges + * writes only. + */ +const EXTERNAL_WRITE_GATED_OPERATIONS: ReadonlySet = new Set([ + 'create', 'update', 'delete', 'upsert', 'bulk', 'import', 'restore', 'purge', +]); + +/** + * [#30370] Re-clamp the map by the THIRD server-side enforcement layer + * permission sets do not model: the external write gate (ADR-0015 §5.3 + * Gate 3). On an object whose gate is closed, objectql refuses every insert, + * update and delete `403 EXTERNAL_WRITE_FORBIDDEN` for every caller — no + * permission set, super-user bypass included, opens it — so the entry grants + * no write: `allowCreate`, `allowEdit`, `allowDelete` and `allowTransfer` + * (an owner change is an update) are `false`. + * + * `modifyAllRecords` is a write grant too: the spec's `objectPermissionGrants` + * answers edit, delete and transfer `true` on it alone, so an entry keeping it + * would still answer `current_user.can(object, 'edit')` `true`. It is lowered + * to `viewAllRecords`, the read half it implies, so the entry keeps exactly + * the read it had. + * + * An object the map does not mention already grants nothing, and is left out. + */ +function clampExternalReadOnlyWrites(objects: Record, forbidden: ReadonlySet): void { + if (forbidden.size === 0) return; + for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) { + if (obj === '*' || !acc || !forbidden.has(obj)) continue; + acc.allowCreate = false; + acc.allowEdit = false; + acc.allowDelete = false; + acc.allowTransfer = false; + if (acc.modifyAllRecords === true) { + acc.modifyAllRecords = false; + acc.viewAllRecords = true; + } + } +} + +/** + * [#30370] The {@link EffectiveObjectPermissionsSchemaSource.isExternalWriteForbidden} + * accessor over an engine: its `isExternalWriteForbidden`, the verdict objectql's + * own write gate (`assertWriteAllowed`) reads before it refuses a write + * `403 EXTERNAL_WRITE_FORBIDDEN`. That member is not on the contract's engine + * view, so it is asked when present; an engine without it forbids nothing. + * Both builders of the map (`/auth/me/permissions` and + * `ISecurityService.getEffectiveObjectPermissions`) hand over this one reading. + */ +export function engineExternalWriteForbidden(engine: unknown): (objectName: string) => boolean { + return (objectName) => { + const predicate = (engine as { isExternalWriteForbidden?: unknown } | null | undefined)?.isExternalWriteForbidden; + return typeof predicate === 'function' && predicate.call(engine, objectName) === true; + }; +} + /** * The schema reads {@link buildEffectiveObjectPermissions} needs, as accessors * over whatever engine the caller holds. Every accessor is optional and every @@ -534,6 +611,15 @@ export interface EffectiveObjectPermissionsSchemaSource { * the map must leave out exactly the objects the data plane refuses. */ isObjectPackageDisabled?: (objectName: string) => boolean; + /** + * [#30370] Whether the external write gate (ADR-0015 §5.3 Gate 3) is closed + * on `objectName` — the engine's `isExternalWriteForbidden`, the verdict its + * own `assertWriteAllowed` refuses a write on. Hand over that predicate + * itself ({@link engineExternalWriteForbidden}), never a second reading of + * the datasource definitions: the map must withhold exactly the writes the + * data plane refuses. + */ + isExternalWriteForbidden?: (objectName: string) => boolean; /** Where a failed seed / annotation pass is reported. */ logger?: { warn?: (message: string, meta?: Record) => void }; } @@ -604,8 +690,15 @@ export interface EffectiveObjectPermissionsInputSet { * not a step, so an entry a super-user set names itself keeps that set's * explicit answer, and `modifyAllRecords` alone grants no create; * 5. {@link clampManagedObjectWrites}; - * 6. {@link annotateEffectiveApiOperations} — guarded like (2); - * 7. {@link omitDisabledPackageObjects} — [#28877] an object whose owning + * 6. {@link clampExternalReadOnlyWrites} — [#30370] an object whose external + * write gate is closed grants no write, as the data plane refuses every + * one; the verdict is judged for every entry before anything is clamped, + * and a predicate that throws is reported and clamps nothing (guarded + * like (2)); + * 7. {@link annotateEffectiveApiOperations} — guarded like (2); [#30370] it + * reads the same verdicts, so a closed gate's object is served no write + * operation; + * 8. {@link omitDisabledPackageObjects} — [#28877] an object whose owning * package is disabled is left out, as the data plane refuses it; guarded * like (2). * @@ -659,19 +752,33 @@ export function buildEffectiveObjectPermissions( } // Make the per-object map reflect the server's ACTUAL effective enforcement // = permission-set grant ∩ identity write guard (ADR-0057 D10, cited as an - // attribution, #9628): (1) fold each set's `'*'` super-user grant into every + // attribution, #9628) ∩ [#30370] external write gate (ADR-0015 §5.3): (1) fold each set's `'*'` super-user grant into every // entry that set does not name, so an admin's wildcard is not shadowed by // another set's explicit deny — [#20134] every bit it grants, `transfer` and // its own plain bits included; [#20136] per set only, never the merged // bypass, which would also override the super-user set's OWN narrower entry; - // (2) re-clamp guarded managed objects by their write affordance. + // (2) re-clamp guarded managed objects by their write affordance; (3) + // [#30370] re-clamp objects whose external write gate is closed. foldSuperUserWildcardGrants(objects, sets); clampManagedObjectWrites(objects, schemaOf); + // [#30370] Judged in full before anything is clamped, so a predicate that + // throws midway clamps nothing rather than half the map — and the + // annotation below reads these same verdicts, never a second asking. + let externalWriteForbidden: ReadonlySet = new Set(); + const isForbidden = source.isExternalWriteForbidden; + if (typeof isForbidden === 'function') { + try { + externalWriteForbidden = new Set(Object.keys(objects).filter((name) => name !== '*' && isForbidden(name) === true)); + } catch (e: any) { + source.logger?.warn?.('[effective-permissions] external write gate read failed', { err: e?.message }); + } + } + clampExternalReadOnlyWrites(objects, externalWriteForbidden); // [#3391] Annotate the per-object effective API operation set. Guarded: on // failure `apiOperations` is simply omitted and a client falls back to its // default-allow behaviour. try { - annotateEffectiveApiOperations(objects, schemaOf); + annotateEffectiveApiOperations(objects, schemaOf, (name) => externalWriteForbidden.has(name)); } catch (e: any) { source.logger?.warn?.('[effective-permissions] apiOperations annotate failed', { err: e?.message }); } diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 7db4e52e3d5..38a675ce52b 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -324,6 +324,8 @@ export { buildEffectiveObjectPermissions, // [#28877] The map's disabled-package accessor over an engine registry. registryObjectPackageDisabled, + // [#30370] The map's external-write-gate accessor over an engine. + engineExternalWriteForbidden, foldWildcardSuperUser, clampManagedObjectWrites, seedSuperUserRestrictedObjects, diff --git a/packages/objectql/src/engine-registration.ts b/packages/objectql/src/engine-registration.ts index 5c31f303d70..273a880d117 100644 --- a/packages/objectql/src/engine-registration.ts +++ b/packages/objectql/src/engine-registration.ts @@ -9,7 +9,8 @@ * `listDatasourceDefs`, `markDatasourceUnavailable`, * `clearDatasourceUnavailable`, `getDefaultDriverName`, * `listUnavailableDatasources`, `assertWriteAllowed` moved out of `engine.ts` - * whole by `scripts/codemod-split-engine.mjs`. + * whole by `scripts/codemod-split-engine.mjs`. `isExternalWriteForbidden` + * (#30370) was written here, beside the gate whose verdict it answers. * * A MOVE, not a redesign. {@link RegistrationFacet} is a stateless facet of `ObjectQL` * (ruling 6624345766): each static method of it is the body of the `ObjectQL` @@ -493,22 +494,50 @@ export class RegistrationFacet { * definition) are unaffected. */ static assertWriteAllowed(this: Pick, objectName: string, operation: 'insert' | 'update' | 'delete'): void { - const object = this._registry.getObject(objectName) as any; - const dsName = object?.datasource; - if (!dsName || dsName === 'default') return; - - const ds = this.datasourceDefs.get(dsName); - // No recorded definition, or an explicitly managed one ⇒ allow. - if (!ds || !ds.schemaMode || ds.schemaMode === 'managed') return; - - const dsAllows = ds.external?.allowWrites ?? false; - const objAllows = object?.external?.writable ?? false; - if (!(dsAllows && objAllows)) { + const closed = externalWriteGateClosure(this, objectName); + if (closed) { throw new ExternalWriteForbiddenError( - `Write '${operation}' blocked on object '${objectName}': datasource '${dsName}' is external ` + - `(schemaMode=${ds.schemaMode}). Requires datasource.external.allowWrites=true (got ${dsAllows}) ` + - `AND object.external.writable=true (got ${objAllows}).`, + `Write '${operation}' blocked on object '${objectName}': datasource '${closed.dsName}' is external ` + + `(schemaMode=${closed.schemaMode}). Requires datasource.external.allowWrites=true (got ${closed.dsAllows}) ` + + `AND object.external.writable=true (got ${closed.objAllows}).`, ); } } + + /** The body of `ObjectQL#isExternalWriteForbidden`; the method's docblock there is its contract. */ + static isExternalWriteForbidden(this: Pick, objectName: string): boolean { + return externalWriteGateClosure(this, objectName) !== null; + } +} + +/** + * [#30370] Gate 3 of ADR-0015 §5.3, judged ONCE: why the external write gate + * is closed on `objectName`, or `null` when it is open. Both + * {@link RegistrationFacet.assertWriteAllowed} (the refusal) and + * {@link RegistrationFacet.isExternalWriteForbidden} (the verdict the + * effective-permission map reads) ask this one function, so the map can never + * offer a write the gate refuses, nor hide one it admits. + * + * Closed exactly when the object sits on a recorded, federated datasource + * (`schemaMode !== 'managed'`) and NOT both the datasource opts in + * (`external.allowWrites`) and the object opts in (`external.writable`). + * Managed datasources (the common case, including the absence of any + * definition) are open. + */ +function externalWriteGateClosure( + host: Pick, + objectName: string, +): { dsName: string; schemaMode: string; dsAllows: boolean; objAllows: boolean } | null { + const object = host._registry.getObject(objectName) as any; + const dsName = object?.datasource; + if (!dsName || dsName === 'default') return null; + + const ds = host.datasourceDefs.get(dsName); + // No recorded definition, or an explicitly managed one ⇒ allow. + if (!ds || !ds.schemaMode || ds.schemaMode === 'managed') return null; + + const dsAllows = ds.external?.allowWrites ?? false; + const objAllows = object?.external?.writable ?? false; + if (dsAllows && objAllows) return null; + return { dsName, schemaMode: ds.schemaMode, dsAllows, objAllows }; } diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 5c6a86fb071..ec5487d3c4d 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3162,6 +3162,25 @@ export class ObjectQL implements IObjectQLEngine { return RegistrationFacet.assertWriteAllowed.call(registrationHost.of(this), objectName, operation); } + /** + * [#30370] Is the external write gate (ADR-0015 §5.3 Gate 3) CLOSED on + * `objectName`? `true` exactly when every insert / update / delete on it is + * refused `403 EXTERNAL_WRITE_FORBIDDEN`: the object sits on a federated + * datasource (`schemaMode !== 'managed'`) and not both + * `datasource.external.allowWrites` and `object.external.writable` opt in. + * `false` for a managed datasource, an object with no datasource, and an + * unknown object (the gate judges none of them). + * + * The write gate itself reads this same verdict, so the two can never + * disagree. A read-only question for the effective-permission map + * (`buildEffectiveObjectPermissions` in `@objectstack/core`), which must not + * offer a write the data plane refuses. Holds for every caller alike: no + * permission set, super-user bypass included, opens a closed gate. + */ + isExternalWriteForbidden(objectName: string): boolean { + return RegistrationFacet.isExternalWriteForbidden.call(registrationHost.of(this), objectName); + } + /** * Set the realtime service for publishing data change events. * Should be called after kernel resolves the realtime service. diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index 91b6c15387f..3b77a5709b4 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -42,6 +42,7 @@ import { buildEffectiveFieldPermissions, buildEffectiveObjectPermissions, registryObjectPackageDisabled, + engineExternalWriteForbidden, IDataEngine, resolveLocalizationContext, resolveRequestExecutionContext, @@ -1037,6 +1038,10 @@ export function registerCurrentUserEndpoints( // [#28877] The data plane's own predicate: a disabled package's // objects leave the map, as the data plane refuses them. isObjectPackageDisabled: registryObjectPackageDisabled(ql?.registry), + // [#30370] The external write gate's own verdict: a read-only + // external object is served no write, as the data plane + // refuses every one. + isExternalWriteForbidden: engineExternalWriteForbidden(ql), logger: ctx.logger, }); // Spread into a literal ON PURPOSE, here and in the `catch`: the diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 7d92a2069a6..a96cc14b46a 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -2,7 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { declaredHttpStatus } from '@objectstack/types'; -import { Plugin, PluginContext, POSTURE_LADDER, buildEffectiveObjectPermissions, registryObjectPackageDisabled } from '@objectstack/core'; +import { Plugin, PluginContext, POSTURE_LADDER, buildEffectiveObjectPermissions, engineExternalWriteForbidden, registryObjectPackageDisabled } from '@objectstack/core'; import type { EffectiveObjectPermission, PermissionSet, RowLevelSecurityPolicy, TenantLayer0Verdict } from '@objectstack/spec/security'; import { describeHighPrivilegeBits } from '@objectstack/spec/security'; import type { AnchorBindingContext } from '@objectstack/spec/security'; @@ -3647,6 +3647,9 @@ export class SecurityPlugin implements Plugin { // [#28877] The data plane's own predicate, as `/auth/me/permissions` hands // it: a disabled package's objects leave the map `can()` reads. isObjectPackageDisabled: registryObjectPackageDisabled(ql?.registry), + // [#30370] The write gate's own verdict, as `/auth/me/permissions` hands + // it: a read-only external object grants no write `can()` reads. + isExternalWriteForbidden: engineExternalWriteForbidden(ql), logger: this.logger, })); } From f875455e1f9548c0718bb878e8e0b618ac242a6f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 09:56:22 +0000 Subject: [PATCH 2/2] test: pin the external write gate in the effective permission map and its two builders Claude-Session: https://claude.ai/code/session_01Utp6CXSTW6WZn81ULyBVdb Co-authored-by: Claude --- ...xternal-read-only-effective-permissions.md | 15 ++++ .../effective-object-permissions.test.ts | 81 +++++++++++++++++++ .../objectql/src/external-write-gate.test.ts | 46 +++++++++++ ...t-user-endpoints-effective-objects.test.ts | 32 +++++++- .../get-effective-object-permissions.test.ts | 29 ++++++- 5 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 .changeset/30370-external-read-only-effective-permissions.md diff --git a/.changeset/30370-external-read-only-effective-permissions.md b/.changeset/30370-external-read-only-effective-permissions.md new file mode 100644 index 00000000000..bb6a3c4ff7e --- /dev/null +++ b/.changeset/30370-external-read-only-effective-permissions.md @@ -0,0 +1,15 @@ +--- +'@objectstack/core': patch +'@objectstack/objectql': patch +'@objectstack/plugin-security': patch +'@objectstack/plugin-hono-server': patch +--- + +fix(core,objectql): effective object permissions read the external write gate, so a read-only external object is no longer offered New, Import, Edit or Delete + +Clause-②: yes (widening) +Metadata-protocol: none + +- **Behaviour.** On an object whose external write gate is closed (ADR-0015 §5.3 Gate 3: a federated datasource, and not both `datasource.external.allowWrites` and `object.external.writable`), every insert, update and delete is refused `403 EXTERNAL_WRITE_FORBIDDEN` for every caller. The effective object-permission map now says so: the entry grants no `allowCreate`, `allowEdit`, `allowDelete` or `allowTransfer`, a `modifyAllRecords` on it is lowered to the `viewAllRecords` read it implies, and `apiOperations` carries no write operation (`create`, `update`, `delete`, `upsert`, `bulk`, `import`, `restore`, `purge`). Read and export are unchanged. Both consumers move together: `/auth/me/permissions` (the console hides those doors with no client change) and `ISecurityService.getEffectiveObjectPermissions`, so `current_user.can(object, 'create' | 'edit' | 'delete' | 'transfer')` now answers `false` on such an object, as the write itself always did. +- **`@objectstack/objectql`: `ObjectQL.isExternalWriteForbidden(objectName)`**, the gate's verdict as a read-only question. The write gate (`assertWriteAllowed`) reads the same verdict, so the two cannot disagree. +- **`@objectstack/core`: `engineExternalWriteForbidden(engine)`** (the accessor over an engine, asked when the member is present) and the optional `isExternalWriteForbidden` accessor on `EffectiveObjectPermissionsSchemaSource`; `annotateEffectiveApiOperations` takes the same predicate as an optional third argument. A caller that hands over neither gets the map it got before. diff --git a/packages/core/src/security/effective-object-permissions.test.ts b/packages/core/src/security/effective-object-permissions.test.ts index ddc5a7718e5..a83f1b2fe98 100644 --- a/packages/core/src/security/effective-object-permissions.test.ts +++ b/packages/core/src/security/effective-object-permissions.test.ts @@ -18,6 +18,7 @@ import { objectPermissionGrants } from '@objectstack/spec/security'; import { buildEffectiveFieldPermissions, buildEffectiveObjectPermissions, + engineExternalWriteForbidden, fieldPermissionVote, holdsSuperuserBypass, registryObjectPackageDisabled, @@ -474,6 +475,86 @@ describe('[#20135] apiOperations says what the REST door serves', () => { }); }); +describe('[#30370] an object whose external write gate is closed grants no write', () => { + const SCHEMAS: Record = { + customer: { name: 'customer', datasource: 'showcase_external' }, + order: { name: 'order', datasource: 'showcase_external', enable: { apiMethods: ['get', 'list', 'create', 'update'] } }, + lead: { name: 'lead' }, + }; + const source = (forbidden: (name: string) => boolean = (name) => name === 'customer' || name === 'order') => ({ + allSchemas: () => Object.values(SCHEMAS), + schemaOf: (name: string) => SCHEMAS[name], + isExternalWriteForbidden: forbidden, + }); + const WRITE_OPERATIONS = ['create', 'update', 'delete', 'upsert', 'bulk', 'import', 'restore', 'purge']; + const VERBS = ['allowCreate', 'allowEdit', 'allowDelete', 'allowTransfer'] as const; + + it('an explicit grant, a plain wildcard and a super-user wildcard all lose every write verb, and keep read and export', () => { + for (const sets of [ + [{ objects: { customer: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowTransfer: true, allowExport: true }, lead: { allowRead: true, allowCreate: true } } }], + [{ objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowExport: true } } }], + [{ objects: { '*': { allowRead: true, allowExport: true, viewAllRecords: true, modifyAllRecords: true } } }], + ]) { + const map: any = buildEffectiveObjectPermissions(sets, source()); + const label = JSON.stringify(sets); + for (const verb of VERBS) expect(objectPermissionGrants(map.customer, verb), `${label} ${verb}`).toBe(false); + expect(objectPermissionGrants(map.customer, 'allowRead'), label).toBe(true); + expect(objectPermissionGrants(map.customer, 'allowExport'), label).toBe(true); + // The read operations stay served, export included; no write operation is. + expect(map.customer.apiOperations, label).toEqual(['get', 'list', 'aggregate', 'search', 'export']); + // An object on a managed datasource keeps exactly what it was granted. + const { isExternalWriteForbidden: _drop, ...without } = source(); + expect(map.lead, label).toEqual((buildEffectiveObjectPermissions(sets, without) as any).lead); + expect(objectPermissionGrants(map.lead, 'allowCreate') || objectPermissionGrants(map.lead, 'allowEdit'), label).toBe(true); + } + }); + + it('modifyAllRecords on the object is lowered to the read it implies, so can() answers no write and the same read', () => { + const map: any = buildEffectiveObjectPermissions( + [{ objects: { customer: { modifyAllRecords: true } } }], + source(), + ); + expect(map.customer.modifyAllRecords).toBe(false); + expect(map.customer.viewAllRecords).toBe(true); + expect(objectPermissionGrants(map.customer, 'allowRead')).toBe(true); + for (const verb of VERBS) expect(objectPermissionGrants(map.customer, verb), verb).toBe(false); + }); + + it('an object that already narrows its operations is served its narrowed reads only', () => { + const map: any = buildEffectiveObjectPermissions([{ objects: { order: { allowRead: true, allowCreate: true } } }], source()); + expect(map.order.apiOperations).toEqual(['get', 'list', 'aggregate', 'search']); + for (const op of WRITE_OPERATIONS) expect(map.order.apiOperations).not.toContain(op); + }); + + it('an open gate, or no accessor, leaves the map exactly as before', () => { + const sets = [{ objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowExport: true } } }]; + const { isExternalWriteForbidden: _drop, ...without } = source(); + const before = JSON.stringify(buildEffectiveObjectPermissions(sets, without)); + expect(JSON.stringify(buildEffectiveObjectPermissions(sets, source(() => false)))).toBe(before); + expect((JSON.parse(before) as any).customer).toMatchObject({ allowCreate: true, allowEdit: true, allowDelete: true }); + }); + + it('a predicate that throws is reported and clamps nothing — never half the map', () => { + const sets = [{ objects: { customer: { allowRead: true, allowEdit: true }, order: { allowRead: true, allowEdit: true } } }]; + const warnings: string[] = []; + const map: any = buildEffectiveObjectPermissions(sets, { + ...source((name) => { if (name === 'order') throw new Error('registry offline'); return true; }), + logger: { warn: (message) => warnings.push(message) }, + }); + expect(map.customer.allowEdit).toBe(true); + expect(map.order.allowEdit).toBe(true); + expect(warnings).toEqual(['[effective-permissions] external write gate read failed']); + }); + + it('engineExternalWriteForbidden asks the engine\'s own verdict, and an engine without it forbids nothing', () => { + const engine = { isExternalWriteForbidden(name: string) { return this === engine && name === 'customer'; } }; + expect(engineExternalWriteForbidden(engine)('customer')).toBe(true); + expect(engineExternalWriteForbidden(engine)('lead')).toBe(false); + expect(engineExternalWriteForbidden({})('customer')).toBe(false); + expect(engineExternalWriteForbidden(undefined)('customer')).toBe(false); + }); +}); + // ── [#25866 / ruling of record 6408558941] A field permission entry's row // condition (`when`): the ONE vote per verdict, and the row-blind merge behind // `/auth/me/permissions`' `fields` slot. diff --git a/packages/objectql/src/external-write-gate.test.ts b/packages/objectql/src/external-write-gate.test.ts index 738669abb71..a5273a2d420 100644 --- a/packages/objectql/src/external-write-gate.test.ts +++ b/packages/objectql/src/external-write-gate.test.ts @@ -140,6 +140,52 @@ describe('write gate (ADR-0015 Gate 3)', () => { }); }); +/** + * [#30370] `isExternalWriteForbidden` is the gate's own verdict: the + * effective-permission map reads it to withhold the writes the gate refuses, + * so over every case of the double opt-in it must answer exactly what the + * write itself meets. + */ +describe('[#30370] isExternalWriteForbidden agrees with the write gate', () => { + /** What an insert, an update and a delete each meet: the refusal envelope, or none. */ + async function refusals(engine: ObjectQL, object: string) { + const meet = (p: Promise) => + p.then(() => null, (err: any) => (err?.code === 'EXTERNAL_WRITE_FORBIDDEN' ? { code: err.code, status: err.status } : null)); + return [ + await meet(engine.insert(object, { order_id: 'o1' })), + await meet(engine.update(object, { id: 'x', amount: 1 })), + await meet(engine.delete(object, { where: { id: 'x' } } as any)), + ]; + } + + const CASES: Array<{ label: string; opts: Parameters[0]; forbidden: boolean }> = [ + { label: 'external, neither opts in', opts: { dsAllowWrites: false, objWritable: false }, forbidden: true }, + { label: 'external, only the datasource opts in', opts: { dsAllowWrites: true, objWritable: false }, forbidden: true }, + { label: 'external, only the object opts in', opts: { dsAllowWrites: false, objWritable: true }, forbidden: true }, + { label: 'validate-only, neither opts in', opts: { dsSchemaMode: 'validate-only' }, forbidden: true }, + { label: 'external, the full double opt-in', opts: { dsAllowWrites: true, objWritable: true }, forbidden: false }, + { label: 'managed datasource', opts: { dsSchemaMode: 'managed' }, forbidden: false }, + ]; + + for (const { label, opts, forbidden } of CASES) { + it(`${label}: verdict ${forbidden}, and every write meets it`, async () => { + const engine = makeEngine(opts); + expect(engine.isExternalWriteForbidden('wh_order')).toBe(forbidden); + const met = await refusals(engine, 'wh_order'); + for (const refusal of met) { + expect(refusal).toEqual(forbidden ? { code: 'EXTERNAL_WRITE_FORBIDDEN', status: 403 } : null); + } + }); + } + + it('an object on the default datasource, and an unknown object, are not forbidden', () => { + const engine = makeEngine({}); + engine.registerApp({ id: 'local_pkg', name: 'Local', objects: [{ name: 'task', fields: { title: { type: 'text' } } }] } as any); + expect(engine.isExternalWriteForbidden('task')).toBe(false); + expect(engine.isExternalWriteForbidden('no_such_object')).toBe(false); + }); +}); + describe('engine.introspectDatasource (ADR-0015)', () => { it('delegates to the named driver introspectSchema()', async () => { const engine = new ObjectQL(); diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts index cab41abd1d2..41859f9a8a2 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts @@ -17,7 +17,7 @@ import { describe, it, expect } from 'vitest'; import { Hono } from 'hono'; -import { buildEffectiveObjectPermissions, registryObjectPackageDisabled } from '@objectstack/core'; +import { buildEffectiveObjectPermissions, engineExternalWriteForbidden, registryObjectPackageDisabled } from '@objectstack/core'; import { registerCurrentUserEndpoints } from './current-user-endpoints'; const ME_PERMISSIONS = '/api/v1/auth/me/permissions'; @@ -53,6 +53,9 @@ const SCHEMAS: Record = { /** [#28877] Objects whose owning package the registry reports disabled — empty but in one case. */ const DISABLED = new Set(); +/** [#30370] Objects whose external write gate the engine reports closed — empty but in one case. */ +const READ_ONLY_EXTERNAL = new Set(); + const ql = { find: async () => [], registry: { @@ -61,6 +64,7 @@ const ql = { isObjectPackageDisabled: (name: string) => DISABLED.has(name), }, getSchema: (name: string) => SCHEMAS[name], + isExternalWriteForbidden: (name: string) => READ_ONLY_EXTERNAL.has(name), }; function mount(resolved: unknown[] = RESOLVED) { @@ -197,6 +201,32 @@ describe('[#18783] /auth/me/permissions `objects` is the one effective-map funct expect(again.objects.deal).toMatchObject({ allowEdit: true }); }); + it('[#30370] serves a read-only external object no write grant and no write operation — the same bytes', async () => { + READ_ONLY_EXTERNAL.add('deal'); + try { + const body: any = await (await mount().request(`http://localhost${ME_PERMISSIONS}`)).json(); + const expected = buildEffectiveObjectPermissions(RESOLVED, { + allSchemas: () => ql.registry.getAllObjects(), + schemaOf: (name) => ql.getSchema(name), + isObjectPackageDisabled: registryObjectPackageDisabled(ql.registry), + isExternalWriteForbidden: engineExternalWriteForbidden(ql), + }); + expect(JSON.stringify(body.objects)).toBe(JSON.stringify(expected)); + // The super-user's wildcard would grant every write on `deal`; the gate refuses each one. + expect(body.objects.deal).toMatchObject({ allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false, allowTransfer: false }); + for (const op of ['create', 'update', 'delete', 'upsert', 'bulk', 'import']) { + expect(body.objects.deal.apiOperations, op).not.toContain(op); + } + expect(body.objects.deal.apiOperations).toEqual(expect.arrayContaining(['get', 'list'])); + } finally { + READ_ONLY_EXTERNAL.clear(); + } + // Gate open again: the write is back on the next request. + const again: any = await (await mount().request(`http://localhost${ME_PERMISSIONS}`)).json(); + expect(again.objects.deal).toMatchObject({ allowCreate: true, allowEdit: true }); + expect(again.objects.deal.apiOperations).toEqual(expect.arrayContaining(['create', 'update', 'delete', 'import'])); + }); + it('keeps the rest of the envelope on its own merges', async () => { const body: any = await (await mount().request(`http://localhost${ME_PERMISSIONS}`)).json(); expect(body.permissionSets).toEqual(['ops_admin', 'sales']); diff --git a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts index 783fa1ee8e0..14bffb0cc3a 100644 --- a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts +++ b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts @@ -29,7 +29,7 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { buildEffectiveObjectPermissions, registryObjectPackageDisabled } from '@objectstack/core'; +import { buildEffectiveObjectPermissions, engineExternalWriteForbidden, registryObjectPackageDisabled } from '@objectstack/core'; import { ExpressionEngine, toEvalPermissions } from '@objectstack/formula'; import { SysAttachment, SysMember, SysSecret, SysUser, SysUserPreference } from '@objectstack/platform-objects'; import type { ISecurityService } from '@objectstack/spec/contracts'; @@ -108,6 +108,8 @@ function bootPlugin( dbRows?: Array>; engineSeam?: boolean; schemas?: Record; /** [#28877] Objects whose owning package the registry reports disabled. */ disabledObjects?: string[]; + /** [#30370] Objects whose external write gate the engine reports closed. */ + readOnlyExternal?: string[]; } = {}, ) { const dbRows = opts.dbRows ?? []; @@ -140,6 +142,7 @@ function bootPlugin( isObjectPackageDisabled: (name: string) => (opts.disabledObjects ?? []).includes(name), }, getSchema: (name: string) => schemas[name] ?? null, + isExternalWriteForbidden: (name: string) => (opts.readOnlyExternal ?? []).includes(name), // No table answers here: every set is the catalog's (above). find: async () => [], }; @@ -177,6 +180,7 @@ function endpointObjects(sets: unknown, ql: any) { allSchemas: () => ql.registry.getAllObjects(), schemaOf: (name) => ql.getSchema(name), isObjectPackageDisabled: registryObjectPackageDisabled(ql.registry), + isExternalWriteForbidden: engineExternalWriteForbidden(ql), }); } @@ -238,6 +242,29 @@ describe('[#18783] getEffectiveObjectPermissions — reachable, and the endpoint expect(admin.sys_member).toMatchObject({ allowRead: true }); }); + it('[#30370] a read-only external object grants no write — the same bytes as the endpoint, and can() answers false', async () => { + const { svc, ql } = await locate({ dbRows: [OPS_ADMIN_ROW, SALES_ROW], readOnlyExternal: ['deal'] }); + for (const context of [ADMIN, REP]) { + const sets = await svc.resolvePermissionSetsForContext!(context); + const map: any = await svc.getEffectiveObjectPermissions!(context); + expect(JSON.stringify(map), context.userId).toBe(JSON.stringify(endpointObjects(sets, ql))); + for (const verb of ['create', 'edit', 'delete', 'transfer']) { + const answer = ExpressionEngine.evaluate( + { dialect: 'cel', source: `current_user.can('deal', '${verb}')` }, + { user: { id: context.userId }, permissions: toEvalPermissions(map) } as any, + ); + expect(answer, `${context.userId} ${verb}`).toMatchObject({ ok: true, value: false }); + } + for (const op of ['create', 'update', 'delete', 'upsert', 'bulk', 'import']) { + expect(map.deal.apiOperations, `${context.userId} ${op}`).not.toContain(op); + } + } + // Read stays; and an object on a managed datasource keeps its writes. + const admin: any = await svc.getEffectiveObjectPermissions!(ADMIN); + expect(admin.deal).toMatchObject({ allowRead: true }); + expect(admin.report).toMatchObject({ allowEdit: true }); + }); + it('is frozen at the top level and aliases no permission set', async () => { const { svc } = await locate({ dbRows: [SALES_ROW] }); const sets: any[] = await svc.resolvePermissionSetsForContext!(REP);