Repository navigation
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
Conversation
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 <noreply@anthropic.com>
… its two builders Claude-Session: https://claude.ai/code/session_01Utp6CXSTW6WZn81ULyBVdb Co-authored-by: Claude <noreply@anthropic.com>
…-q2mcix Claude-Session: https://claude.ai/code/session_01Utp6CXSTW6WZn81ULyBVdb Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 59 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8b0d891a90f2686ea325dd583d5508f1fd597776 && git checkout 8b0d891a90f2686ea325dd583d5508f1fd597776
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a91409c42e67e9a1c417b8f2efa82c356903a19e f994c4bcae2ff346056e57c441c40112b500d99d && git checkout -B drift-repro a91409c42e67e9a1c417b8f2efa82c356903a19e && git merge --no-ff f994c4bcae2ff346056e57c441c40112b500d99d
node scripts/docs-audit/affected-docs.mjs --json a91409c42e67e9a1c417b8f2efa82c356903a19e
|
|
domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC Contract reviewServed-tier: Face: declaration ( ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
|
domain:server#3 · os-bill · session_01Utp6CbPxYq1wQmUNWvMZkC 席位核对ACCEPT · head
|
domain:server#3 · os-bill · session_01Utp6CXSTW6WZn81ULyBVdb
Requested by Bill · project thread
Fixes #30370
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
current_user.can()的有效权限不再含新建、编辑、删除、转移,可用操作里也去掉了 create/update/delete/upsert/bulk/import。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 条 [Effective object permissions ignore the external write gate, so the console offers New, Import, Edit and Delete on a read-only external object #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/permissionsserves andcurrent_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 bothdatasource.external.allowWritesandobject.external.writable). So on a read-only external object the map granted writes the data plane refuses403 EXTERNAL_WRITE_FORBIDDEN, and the console'sresolveAffordanceoffered 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, inexternalWriteGateClosure(engine-registration.ts);assertWriteAllowedthrows on that same closure (message unchanged), and the predicate answers whether it is non-null. Theengine.tsmember is a one-call delegation stub (check:facade-delegation-onlygreen).@objectstack/core— a new optionalisExternalWriteForbiddenaccessor onEffectiveObjectPermissionsSchemaSource, andengineExternalWriteForbidden(engine)(asks the engine's member when present; an engine without it forbids nothing, theregistryObjectPackageDisabledshape from disabled package: /auth/me/permissions still grants its objects so current_user.can() stays true, and an app action whose flow ships in the disabled package is still drawn and fails with Flow not found #28877). InbuildEffectiveObjectPermissions, after the managed-write clamp: the verdict is judged for every entry before anything changes (a throwing predicate is reported once and clamps nothing), thenclampExternalReadOnlyWritessetsallowCreate/allowEdit/allowDelete/allowTransfertofalseand lowersmodifyAllRecordsto theviewAllRecordsread it implies (otherwise the spec'sobjectPermissionGrantswould still answer edit / delete / transfertrueon it).annotateEffectiveApiOperationstakes 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 overengineExternalWriteForbidden(ql), so the two consumers stay byte-equal.ui/**change: the console already readsapiOperationsandcan().Expected side effect. Server-side
current_user.can(object, 'create' | 'edit' | 'delete' | 'transfer')now answersfalseon 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;modifyAllRecordslowered to the read; a narrowedapiMethodsobject 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;engineExternalWriteForbiddenasks 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 (codeEXTERNAL_WRITE_FORBIDDEN,status403, 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 servedobjectsslot for a read-only external object has no write grant and no write operation, byte-equal tobuildEffectiveObjectPermissionswith 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, andcurrent_user.can('deal', 'create' | 'edit' | 'delete' | 'transfer')evaluatesfalse.Acceptance notes
engine.tsstub, 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 exportsengineExternalWriteForbiddenbesideregistryObjectPackageDisabled(the two plugins import it from@objectstack/core). Noui/**orpackages/spec/**change.Clause-②: yes (widening)(new publicObjectQL.isExternalWriteForbidden, new exportengineExternalWriteForbidden, new optional accessor and optional third argument onannotateEffectiveApiOperations; a caller passing none gets the old map),Metadata-protocol: none(nothing an author can write changes).patchchangesets for the four published packages.#30334item 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.objectPermissionGrantsreading meansclampManagedObjectWritescan leave edit / delete / transfertrueon a guarded managed object when a permission set names that object explicitly withmodifyAllRecords: true(the clamp clears theallow*bits only). This PR lowersmodifyAllRecordsfor 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.engine.tsdocblocks; the stub added here is a new public member below the privateassertWriteAllowedstub and edits no existing docblock. Whichever lands second mergesmain.Verification
Head
f994c4bc(merge oforigin/mainatefcc1a7a, 12 commits, none touching the four packages; tests and gates below ran onf875455e, the same diff).Targeted tests
pnpm --filter @objectstack/core exec vitest run --maxWorkers=2 src/security/effective-object-permissions.test.ts— 49/49pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/external-write-gate.test.ts— 18/18pnpm --filter @objectstack/plugin-hono-server exec vitest run --maxWorkers=2 src/current-user-endpoints-effective-objects.test.ts— 7/7pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/get-effective-object-permissions.test.ts— 66/66Package suites and typecheck
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 --filterthe same fourtypecheck— VERDICT command-exit 0.Ablation (one-shot, restored)
node scripts/ablation-replace.mjsreplaced theclampExternalReadOnlyWrites(objects, externalWriteForbidden);call (anchor 1 → 0, blob 0c7fdba6 → 9ba853e2) and ran the core file: 2 failed / 47 passed (the explicit / wildcard / super-user case and themodifyAllRecordscase). Restored: blob == HEAD 0c7fdba6,git diff HEADempty.Derived gates (dispatch-gates --commands, reconciled with --ran)
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-onlyexit 0,pnpm --filter @objectstack/spec check:livenessexit 0. Reconciliation:✓ dispatch-gates --ran: 69 derived famil(ies) accounted for — 66 run, 3 NOT-MEASURED.Generated by Claude Code