Skip to content

Commit f7afab8

Browse files
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 (#30416)
domain:server#3 · os-bill · session_01Utp6CXSTW6WZn81ULyBVdb <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgT9jn2ZF9jPKnKcCY79qSph)_ Fixes #30370 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:对外部只读对象(数据源 external 且未双重开写),服务端给控制台和 `current_user.can()` 的有效权限不再含新建、编辑、删除、转移,可用操作里也去掉了 create/update/delete/upsert/bulk/import。 - 为什么改:这类对象的写入早就被引擎一律拒绝(403 EXTERNAL_WRITE_FORBIDDEN),但权限答复仍说可写,管理员看到新建/导入/编辑/删除按钮,填完表单才被拒。 - 风险与回滚:只收紧本来就写不进去的对象的答复,托管数据源对象字节不变;回滚即 revert 本 PR。 - 先看:`packages/core/src/security/effective-object-permissions.ts`(新夹紧步骤与 apiOperations 过滤)、`packages/objectql/src/engine-registration.ts`(写闸判定抽成一处,谓词与拒绝共用)、`packages/plugins/plugin-hono-server/src/current-user-endpoints.ts`。 - 两分钟验证:`pnpm --filter @objectstack/core exec vitest run src/security/effective-object-permissions.test.ts`,预期 49 条全过,其中 6 条 [#30370]。 - 需要你做的:无,席位落地。 ## What changes **Root cause.** `buildEffectiveObjectPermissions` (`@objectstack/core`) folds permission sets, super-user wildcards, the managed-write guards and API exposure into the map that `/auth/me/permissions` serves and `current_user.can()` reads, and its stated purpose is to reflect what the server actually enforces. It never read ADR-0015 §5.3 Gate 3, the external write gate objectql applies to every insert, update and delete (`assertWriteAllowed`: a federated datasource, and not both `datasource.external.allowWrites` and `object.external.writable`). So on a read-only external object the map granted writes the data plane refuses `403 EXTERNAL_WRITE_FORBIDDEN`, and the console's `resolveAffordance` offered New, Import, Edit and Delete. - **`@objectstack/objectql`** — `ObjectQL.isExternalWriteForbidden(objectName)`: the gate's verdict as a read-only question. The gate is now judged once, in `externalWriteGateClosure` (`engine-registration.ts`); `assertWriteAllowed` throws on that same closure (message unchanged), and the predicate answers whether it is non-null. The `engine.ts` member is a one-call delegation stub (`check:facade-delegation-only` green). - **`@objectstack/core`** — a new optional `isExternalWriteForbidden` accessor on `EffectiveObjectPermissionsSchemaSource`, and `engineExternalWriteForbidden(engine)` (asks the engine's member when present; an engine without it forbids nothing, the `registryObjectPackageDisabled` shape from #28877). In `buildEffectiveObjectPermissions`, after the managed-write clamp: the verdict is judged for every entry before anything changes (a throwing predicate is reported once and clamps nothing), then `clampExternalReadOnlyWrites` sets `allowCreate` / `allowEdit` / `allowDelete` / `allowTransfer` to `false` and lowers `modifyAllRecords` to the `viewAllRecords` read it implies (otherwise the spec's `objectPermissionGrants` would still answer edit / delete / transfer `true` on it). `annotateEffectiveApiOperations` takes the same verdicts as an optional third argument and drops the write operations (`create`, `update`, `delete`, `upsert`, `bulk`, `import`, `restore`, `purge`) from what such an object is served, always annotating it. Read and export are untouched. - **`@objectstack/plugin-security`** (`getEffectiveObjectPermissions`) and **`@objectstack/plugin-hono-server`** (`/auth/me/permissions`) both hand over `engineExternalWriteForbidden(ql)`, so the two consumers stay byte-equal. - No `ui/**` change: the console already reads `apiOperations` and `can()`. **Expected side effect.** Server-side `current_user.can(object, 'create' | 'edit' | 'delete' | 'transfer')` now answers `false` on a read-only external object, matching what the write itself always met. ## Tests - `packages/core/src/security/effective-object-permissions.test.ts` — new `[#30370]` describe (6 cases): explicit grant, plain wildcard and super-user wildcard all lose every write verb and keep read and export; `modifyAllRecords` lowered to the read; a narrowed `apiMethods` object is served its narrowed reads only; an open gate or no accessor leaves the map byte-identical; a throwing predicate clamps nothing and warns once; `engineExternalWriteForbidden` asks the engine's own member. - `packages/objectql/src/external-write-gate.test.ts` — new `[#30370]` describe: over the double opt-in matrix (neither, datasource only, object only, `validate-only`, both, managed) the predicate's verdict equals what insert, update and delete each meet (`code` `EXTERNAL_WRITE_FORBIDDEN`, `status` 403, or no refusal); default-datasource and unknown objects are not forbidden. - `packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts` — the served `objects` slot for a read-only external object has no write grant and no write operation, byte-equal to `buildEffectiveObjectPermissions` with the engine's predicate; reopening the gate restores them. - `packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts` — the member's map is byte-equal to the endpoint computation, and `current_user.can('deal', 'create' | 'edit' | 'delete' | 'transfer')` evaluates `false`. ## Acceptance notes - Scope as the seat's claim widened it (6654819469): objectql predicate plus its `engine.ts` stub, the core step, the two map builders, their tests, one changeset. One file beyond the list: `packages/core/src/security/index.ts`, the barrel line that exports `engineExternalWriteForbidden` beside `registryObjectPackageDisabled` (the two plugins import it from `@objectstack/core`). No `ui/**` or `packages/spec/**` change. - Declarations measured from the diff: `Clause-②: yes (widening)` (new public `ObjectQL.isExternalWriteForbidden`, new export `engineExternalWriteForbidden`, new optional accessor and optional third argument on `annotateEffectiveApiOperations`; a caller passing none gets the old map), `Metadata-protocol: none` (nothing an author can write changes). `patch` changesets for the four published packages. - The `#30334` item 2 refusal line (ui thread) is untouched; with this change the console should no longer reach that refusal from New / Import / Edit / Delete on such an object. - Noted, not filed (read-only inference, no public-door reproduction): the same `objectPermissionGrants` reading means `clampManagedObjectWrites` can leave edit / delete / transfer `true` on a guarded managed object when a permission set names that object explicitly with `modifyAllRecords: true` (the clamp clears the `allow*` bits only). This PR lowers `modifyAllRecords` for the external gate only and leaves the managed clamp as it was. Dedupe words: `clampManagedObjectWrites modifyAllRecords`, `managed write clamp super-user explicit entry`, `objectPermissionGrants modifyAll guarded`. - Serial note: #30053 slice 2 also edits `engine.ts` docblocks; the stub added here is a new public member below the private `assertWriteAllowed` stub and edits no existing docblock. Whichever lands second merges `main`. ## Verification Head `f994c4bc` (merge of `origin/main` at `efcc1a7a`, 12 commits, none touching the four packages; tests and gates below ran on `f875455e`, the same diff). <details><summary>Targeted tests</summary> - `pnpm --filter @objectstack/core exec vitest run --maxWorkers=2 src/security/effective-object-permissions.test.ts` — 49/49 - `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/external-write-gate.test.ts` — 18/18 - `pnpm --filter @objectstack/plugin-hono-server exec vitest run --maxWorkers=2 src/current-user-endpoints-effective-objects.test.ts` — 7/7 - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/get-effective-object-permissions.test.ts` — 66/66 </details> <details><summary>Package suites and typecheck</summary> - `pnpm --filter @objectstack/core --filter @objectstack/objectql --filter @objectstack/plugin-security --filter @objectstack/plugin-hono-server test` — VERDICT command-exit 0: core 134 files / 2995 tests; objectql 550 / 10979; plugin-hono-server 38 / 477; plugin-security 243 / 5357 passed, 45 skipped. - `pnpm --filter` the same four `typecheck` — VERDICT command-exit 0. </details> <details><summary>Ablation (one-shot, restored)</summary> `node scripts/ablation-replace.mjs` replaced the `clampExternalReadOnlyWrites(objects, externalWriteForbidden);` call (anchor 1 → 0, blob 0c7fdba → 9ba853e2) and ran the core file: 2 failed / 47 passed (the explicit / wildcard / super-user case and the `modifyAllRecords` case). Restored: blob == HEAD 0c7fdba, `git diff HEAD` empty. </details> <details><summary>Derived gates (dispatch-gates --commands, reconciled with --ran)</summary> 69 derived, 66 run exit 0, 3 NOT MEASURED (exit 3, prerequisite not met in this container; CI runs them): `node scripts/check-plugin-teardown-shape.mjs --self-test` (shallow clone), `pnpm check:dual-build-cjs-loads` (needs a full workspace build), `pnpm check:i18n` (needs the built CLI). Also run, outside the derivation: `pnpm check:facade-delegation-only` exit 0, `pnpm --filter @objectstack/spec check:liveness` exit 0. Reconciliation: `✓ dispatch-gates --ran: 69 derived famil(ies) accounted for — 66 run, 3 NOT-MEASURED`. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXSTW6WZn81ULyBVdb)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8293c74 commit f7afab8

11 files changed

Lines changed: 391 additions & 27 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/plugin-security': patch
5+
'@objectstack/plugin-hono-server': patch
6+
---
7+
8+
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
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: none
12+
13+
- **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.
14+
- **`@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.
15+
- **`@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.

‎packages/core/src/security/effective-object-permissions.test.ts‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { objectPermissionGrants } from '@objectstack/spec/security';
1818
import {
1919
buildEffectiveFieldPermissions,
2020
buildEffectiveObjectPermissions,
21+
engineExternalWriteForbidden,
2122
fieldPermissionVote,
2223
holdsSuperuserBypass,
2324
registryObjectPackageDisabled,
@@ -474,6 +475,86 @@ describe('[#20135] apiOperations says what the REST door serves', () => {
474475
});
475476
});
476477

478+
describe('[#30370] an object whose external write gate is closed grants no write', () => {
479+
const SCHEMAS: Record<string, any> = {
480+
customer: { name: 'customer', datasource: 'showcase_external' },
481+
order: { name: 'order', datasource: 'showcase_external', enable: { apiMethods: ['get', 'list', 'create', 'update'] } },
482+
lead: { name: 'lead' },
483+
};
484+
const source = (forbidden: (name: string) => boolean = (name) => name === 'customer' || name === 'order') => ({
485+
allSchemas: () => Object.values(SCHEMAS),
486+
schemaOf: (name: string) => SCHEMAS[name],
487+
isExternalWriteForbidden: forbidden,
488+
});
489+
const WRITE_OPERATIONS = ['create', 'update', 'delete', 'upsert', 'bulk', 'import', 'restore', 'purge'];
490+
const VERBS = ['allowCreate', 'allowEdit', 'allowDelete', 'allowTransfer'] as const;
491+
492+
it('an explicit grant, a plain wildcard and a super-user wildcard all lose every write verb, and keep read and export', () => {
493+
for (const sets of [
494+
[{ objects: { customer: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowTransfer: true, allowExport: true }, lead: { allowRead: true, allowCreate: true } } }],
495+
[{ objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowExport: true } } }],
496+
[{ objects: { '*': { allowRead: true, allowExport: true, viewAllRecords: true, modifyAllRecords: true } } }],
497+
]) {
498+
const map: any = buildEffectiveObjectPermissions(sets, source());
499+
const label = JSON.stringify(sets);
500+
for (const verb of VERBS) expect(objectPermissionGrants(map.customer, verb), `${label} ${verb}`).toBe(false);
501+
expect(objectPermissionGrants(map.customer, 'allowRead'), label).toBe(true);
502+
expect(objectPermissionGrants(map.customer, 'allowExport'), label).toBe(true);
503+
// The read operations stay served, export included; no write operation is.
504+
expect(map.customer.apiOperations, label).toEqual(['get', 'list', 'aggregate', 'search', 'export']);
505+
// An object on a managed datasource keeps exactly what it was granted.
506+
const { isExternalWriteForbidden: _drop, ...without } = source();
507+
expect(map.lead, label).toEqual((buildEffectiveObjectPermissions(sets, without) as any).lead);
508+
expect(objectPermissionGrants(map.lead, 'allowCreate') || objectPermissionGrants(map.lead, 'allowEdit'), label).toBe(true);
509+
}
510+
});
511+
512+
it('modifyAllRecords on the object is lowered to the read it implies, so can() answers no write and the same read', () => {
513+
const map: any = buildEffectiveObjectPermissions(
514+
[{ objects: { customer: { modifyAllRecords: true } } }],
515+
source(),
516+
);
517+
expect(map.customer.modifyAllRecords).toBe(false);
518+
expect(map.customer.viewAllRecords).toBe(true);
519+
expect(objectPermissionGrants(map.customer, 'allowRead')).toBe(true);
520+
for (const verb of VERBS) expect(objectPermissionGrants(map.customer, verb), verb).toBe(false);
521+
});
522+
523+
it('an object that already narrows its operations is served its narrowed reads only', () => {
524+
const map: any = buildEffectiveObjectPermissions([{ objects: { order: { allowRead: true, allowCreate: true } } }], source());
525+
expect(map.order.apiOperations).toEqual(['get', 'list', 'aggregate', 'search']);
526+
for (const op of WRITE_OPERATIONS) expect(map.order.apiOperations).not.toContain(op);
527+
});
528+
529+
it('an open gate, or no accessor, leaves the map exactly as before', () => {
530+
const sets = [{ objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowExport: true } } }];
531+
const { isExternalWriteForbidden: _drop, ...without } = source();
532+
const before = JSON.stringify(buildEffectiveObjectPermissions(sets, without));
533+
expect(JSON.stringify(buildEffectiveObjectPermissions(sets, source(() => false)))).toBe(before);
534+
expect((JSON.parse(before) as any).customer).toMatchObject({ allowCreate: true, allowEdit: true, allowDelete: true });
535+
});
536+
537+
it('a predicate that throws is reported and clamps nothing — never half the map', () => {
538+
const sets = [{ objects: { customer: { allowRead: true, allowEdit: true }, order: { allowRead: true, allowEdit: true } } }];
539+
const warnings: string[] = [];
540+
const map: any = buildEffectiveObjectPermissions(sets, {
541+
...source((name) => { if (name === 'order') throw new Error('registry offline'); return true; }),
542+
logger: { warn: (message) => warnings.push(message) },
543+
});
544+
expect(map.customer.allowEdit).toBe(true);
545+
expect(map.order.allowEdit).toBe(true);
546+
expect(warnings).toEqual(['[effective-permissions] external write gate read failed']);
547+
});
548+
549+
it('engineExternalWriteForbidden asks the engine\'s own verdict, and an engine without it forbids nothing', () => {
550+
const engine = { isExternalWriteForbidden(name: string) { return this === engine && name === 'customer'; } };
551+
expect(engineExternalWriteForbidden(engine)('customer')).toBe(true);
552+
expect(engineExternalWriteForbidden(engine)('lead')).toBe(false);
553+
expect(engineExternalWriteForbidden({})('customer')).toBe(false);
554+
expect(engineExternalWriteForbidden(undefined)('customer')).toBe(false);
555+
});
556+
});
557+
477558
// ── [#25866 / ruling of record 6408558941] A field permission entry's row
478559
// condition (`when`): the ONE vote per verdict, and the row-blind merge behind
479560
// `/auth/me/permissions`' `fields` slot.

‎packages/core/src/security/effective-object-permissions.ts‎

Lines changed: 116 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
resolveEffectiveApiMethods,
3737
effectiveOperationsArray,
3838
canServeApiOperation,
39+
type ApiOperation,
3940
type EnableLike,
4041
} from '@objectstack/spec/data';
4142
import { objectPermissionGrants, type EffectiveObjectPermission } from '@objectstack/spec/security';
@@ -478,10 +479,18 @@ export function seedSuperUserRestrictedObjects(
478479
* Which entries carry the annotation is unchanged in rule: only an
479480
* unrestricted object that keeps its whole closure — every operation served,
480481
* `export` included — gets none.
482+
*
483+
* [#30370] With `isExternalWriteForbidden`, an object whose external write
484+
* gate is closed (ADR-0015 §5.3 Gate 3) is served without every write
485+
* operation ({@link EXTERNAL_WRITE_GATED_OPERATIONS}): the data plane refuses
486+
* each one `403 EXTERNAL_WRITE_FORBIDDEN` whatever the permission sets say,
487+
* and such an object is always annotated, since the client's default-allow
488+
* path would offer every write. Without it, the answer is what it was.
481489
*/
482490
export function annotateEffectiveApiOperations(
483491
objects: Record<string, any>,
484492
schemaOf: (objectName: string) => ApiExposureSchemaLike | undefined,
493+
isExternalWriteForbidden?: (objectName: string) => boolean,
485494
): void {
486495
for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) {
487496
if (obj === '*' || !acc) continue;
@@ -497,18 +506,86 @@ export function annotateEffectiveApiOperations(
497506
const closure = effectiveOperationsArray(eff);
498507
// [#20135] Only what the door itself admits: `apiEnabled: false` empties
499508
// the set; any other `enable` leaves the closure as it is.
500-
const served = closure.filter((operation) => canServeApiOperation(enable, operation));
509+
// [#30370] And only what the external write gate admits: a closed gate
510+
// refuses every write operation on the data plane.
511+
const writeForbidden = isExternalWriteForbidden?.(obj) === true;
512+
const served = closure.filter((operation) =>
513+
canServeApiOperation(enable, operation)
514+
&& !(writeForbidden && EXTERNAL_WRITE_GATED_OPERATIONS.has(operation)));
501515
// Annotate when the object tightens via `apiMethods`, when the export
502516
// axis removes `export` from an otherwise-open object (so the client
503517
// hides the Export button), OR when the door serves less than the closure
504-
// (an API-disabled object). An unrestricted object with every operation
505-
// still served needs no annotation — the client keeps its default-allow
506-
// path.
518+
// (an API-disabled object, or [#30370] a closed external write gate). An
519+
// unrestricted object with every operation still served needs no
520+
// annotation — the client keeps its default-allow path.
507521
if (eff.mode === 'unrestricted' && userExportAllowed && served.length === closure.length) continue;
508522
acc.apiOperations = served;
509523
}
510524
}
511525

526+
/**
527+
* [#30370] The effective API operations that WRITE a row — every one the
528+
* external write gate (ADR-0015 §5.3 Gate 3, objectql `assertWriteAllowed`)
529+
* refuses on an object whose gate is closed: the three primitives it judges
530+
* (`insert` is `create`), and every derived operation built on them —
531+
* `upsert` (create ∧ update), `bulk` (batched create / update / delete),
532+
* `import` (create ∨ update), and `restore` / `purge`, which never derive
533+
* today but write when they do. The read operations (`get`, `list`,
534+
* `aggregate`, `history`, `search`, `export`) are untouched: the gate judges
535+
* writes only.
536+
*/
537+
const EXTERNAL_WRITE_GATED_OPERATIONS: ReadonlySet<ApiOperation> = new Set<ApiOperation>([
538+
'create', 'update', 'delete', 'upsert', 'bulk', 'import', 'restore', 'purge',
539+
]);
540+
541+
/**
542+
* [#30370] Re-clamp the map by the THIRD server-side enforcement layer
543+
* permission sets do not model: the external write gate (ADR-0015 §5.3
544+
* Gate 3). On an object whose gate is closed, objectql refuses every insert,
545+
* update and delete `403 EXTERNAL_WRITE_FORBIDDEN` for every caller — no
546+
* permission set, super-user bypass included, opens it — so the entry grants
547+
* no write: `allowCreate`, `allowEdit`, `allowDelete` and `allowTransfer`
548+
* (an owner change is an update) are `false`.
549+
*
550+
* `modifyAllRecords` is a write grant too: the spec's `objectPermissionGrants`
551+
* answers edit, delete and transfer `true` on it alone, so an entry keeping it
552+
* would still answer `current_user.can(object, 'edit')` `true`. It is lowered
553+
* to `viewAllRecords`, the read half it implies, so the entry keeps exactly
554+
* the read it had.
555+
*
556+
* An object the map does not mention already grants nothing, and is left out.
557+
*/
558+
function clampExternalReadOnlyWrites(objects: Record<string, any>, forbidden: ReadonlySet<string>): void {
559+
if (forbidden.size === 0) return;
560+
for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) {
561+
if (obj === '*' || !acc || !forbidden.has(obj)) continue;
562+
acc.allowCreate = false;
563+
acc.allowEdit = false;
564+
acc.allowDelete = false;
565+
acc.allowTransfer = false;
566+
if (acc.modifyAllRecords === true) {
567+
acc.modifyAllRecords = false;
568+
acc.viewAllRecords = true;
569+
}
570+
}
571+
}
572+
573+
/**
574+
* [#30370] The {@link EffectiveObjectPermissionsSchemaSource.isExternalWriteForbidden}
575+
* accessor over an engine: its `isExternalWriteForbidden`, the verdict objectql's
576+
* own write gate (`assertWriteAllowed`) reads before it refuses a write
577+
* `403 EXTERNAL_WRITE_FORBIDDEN`. That member is not on the contract's engine
578+
* view, so it is asked when present; an engine without it forbids nothing.
579+
* Both builders of the map (`/auth/me/permissions` and
580+
* `ISecurityService.getEffectiveObjectPermissions`) hand over this one reading.
581+
*/
582+
export function engineExternalWriteForbidden(engine: unknown): (objectName: string) => boolean {
583+
return (objectName) => {
584+
const predicate = (engine as { isExternalWriteForbidden?: unknown } | null | undefined)?.isExternalWriteForbidden;
585+
return typeof predicate === 'function' && predicate.call(engine, objectName) === true;
586+
};
587+
}
588+
512589
/**
513590
* The schema reads {@link buildEffectiveObjectPermissions} needs, as accessors
514591
* over whatever engine the caller holds. Every accessor is optional and every
@@ -534,6 +611,15 @@ export interface EffectiveObjectPermissionsSchemaSource {
534611
* the map must leave out exactly the objects the data plane refuses.
535612
*/
536613
isObjectPackageDisabled?: (objectName: string) => boolean;
614+
/**
615+
* [#30370] Whether the external write gate (ADR-0015 §5.3 Gate 3) is closed
616+
* on `objectName` — the engine's `isExternalWriteForbidden`, the verdict its
617+
* own `assertWriteAllowed` refuses a write on. Hand over that predicate
618+
* itself ({@link engineExternalWriteForbidden}), never a second reading of
619+
* the datasource definitions: the map must withhold exactly the writes the
620+
* data plane refuses.
621+
*/
622+
isExternalWriteForbidden?: (objectName: string) => boolean;
537623
/** Where a failed seed / annotation pass is reported. */
538624
logger?: { warn?: (message: string, meta?: Record<string, unknown>) => void };
539625
}
@@ -604,8 +690,15 @@ export interface EffectiveObjectPermissionsInputSet {
604690
* not a step, so an entry a super-user set names itself keeps that set's
605691
* explicit answer, and `modifyAllRecords` alone grants no create;
606692
* 5. {@link clampManagedObjectWrites};
607-
* 6. {@link annotateEffectiveApiOperations} — guarded like (2);
608-
* 7. {@link omitDisabledPackageObjects} — [#28877] an object whose owning
693+
* 6. {@link clampExternalReadOnlyWrites} — [#30370] an object whose external
694+
* write gate is closed grants no write, as the data plane refuses every
695+
* one; the verdict is judged for every entry before anything is clamped,
696+
* and a predicate that throws is reported and clamps nothing (guarded
697+
* like (2));
698+
* 7. {@link annotateEffectiveApiOperations} — guarded like (2); [#30370] it
699+
* reads the same verdicts, so a closed gate's object is served no write
700+
* operation;
701+
* 8. {@link omitDisabledPackageObjects} — [#28877] an object whose owning
609702
* package is disabled is left out, as the data plane refuses it; guarded
610703
* like (2).
611704
*
@@ -659,19 +752,33 @@ export function buildEffectiveObjectPermissions(
659752
}
660753
// Make the per-object map reflect the server's ACTUAL effective enforcement
661754
// = permission-set grant ∩ identity write guard (ADR-0057 D10, cited as an
662-
// attribution, #9628): (1) fold each set's `'*'` super-user grant into every
755+
// attribution, #9628) ∩ [#30370] external write gate (ADR-0015 §5.3): (1) fold each set's `'*'` super-user grant into every
663756
// entry that set does not name, so an admin's wildcard is not shadowed by
664757
// another set's explicit deny — [#20134] every bit it grants, `transfer` and
665758
// its own plain bits included; [#20136] per set only, never the merged
666759
// bypass, which would also override the super-user set's OWN narrower entry;
667-
// (2) re-clamp guarded managed objects by their write affordance.
760+
// (2) re-clamp guarded managed objects by their write affordance; (3)
761+
// [#30370] re-clamp objects whose external write gate is closed.
668762
foldSuperUserWildcardGrants(objects, sets);
669763
clampManagedObjectWrites(objects, schemaOf);
764+
// [#30370] Judged in full before anything is clamped, so a predicate that
765+
// throws midway clamps nothing rather than half the map — and the
766+
// annotation below reads these same verdicts, never a second asking.
767+
let externalWriteForbidden: ReadonlySet<string> = new Set();
768+
const isForbidden = source.isExternalWriteForbidden;
769+
if (typeof isForbidden === 'function') {
770+
try {
771+
externalWriteForbidden = new Set(Object.keys(objects).filter((name) => name !== '*' && isForbidden(name) === true));
772+
} catch (e: any) {
773+
source.logger?.warn?.('[effective-permissions] external write gate read failed', { err: e?.message });
774+
}
775+
}
776+
clampExternalReadOnlyWrites(objects, externalWriteForbidden);
670777
// [#3391] Annotate the per-object effective API operation set. Guarded: on
671778
// failure `apiOperations` is simply omitted and a client falls back to its
672779
// default-allow behaviour.
673780
try {
674-
annotateEffectiveApiOperations(objects, schemaOf);
781+
annotateEffectiveApiOperations(objects, schemaOf, (name) => externalWriteForbidden.has(name));
675782
} catch (e: any) {
676783
source.logger?.warn?.('[effective-permissions] apiOperations annotate failed', { err: e?.message });
677784
}

‎packages/core/src/security/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,8 @@ export {
324324
buildEffectiveObjectPermissions,
325325
// [#28877] The map's disabled-package accessor over an engine registry.
326326
registryObjectPackageDisabled,
327+
// [#30370] The map's external-write-gate accessor over an engine.
328+
engineExternalWriteForbidden,
327329
foldWildcardSuperUser,
328330
clampManagedObjectWrites,
329331
seedSuperUserRestrictedObjects,

0 commit comments

Comments
 (0)