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/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/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-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/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); 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, })); }