Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/30370-external-read-only-effective-permissions.md
Original file line number Diff line number Diff line change
@@ -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.
81 changes: 81 additions & 0 deletions packages/core/src/security/effective-object-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { objectPermissionGrants } from '@objectstack/spec/security';
import {
buildEffectiveFieldPermissions,
buildEffectiveObjectPermissions,
engineExternalWriteForbidden,
fieldPermissionVote,
holdsSuperuserBypass,
registryObjectPackageDisabled,
Expand Down Expand Up @@ -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<string, any> = {
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.
Expand Down
125 changes: 116 additions & 9 deletions packages/core/src/security/effective-object-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
resolveEffectiveApiMethods,
effectiveOperationsArray,
canServeApiOperation,
type ApiOperation,
type EnableLike,
} from '@objectstack/spec/data';
import { objectPermissionGrants, type EffectiveObjectPermission } from '@objectstack/spec/security';
Expand Down Expand Up @@ -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<string, any>,
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;
Expand All @@ -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<ApiOperation> = new Set<ApiOperation>([
'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<string, any>, forbidden: ReadonlySet<string>): 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
Expand All @@ -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<string, unknown>) => void };
}
Expand Down Expand Up @@ -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).
*
Expand Down Expand Up @@ -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<string> = 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 });
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading