You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit f7afab8
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
0 commit comments