Repository navigation
Commit 78f78ac
fix(metadata-protocol)!: a runtime permission-set save refuses a grant on an object that does not exist (#29093)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XMKCA9XHroSxfdBE6T4UZr6)_
Fixes #28990
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — `skills/objectstack-data/rules/security.md` teaches
permission sets declared in source, where `defineStack` already refuses
a grant on an undeclared object; the runtime refusal names its own fix.
## 维护者速读
- **改了什么**:运行时保存权限集(`PUT
/api/v1/meta/permission/NAME`,直接保存、草稿、发布、回滚/撤销提交)时,`objects`
里如果授权给一个运行时不存在的对象,现在返回 `422 INVALID_METADATA`,在 `objects.对象名`
点名,不再存下。和构建期 `defineStack` 的同一条规则对齐。
- **为什么改**:之前返回 200,管理员以为授权成功,实际什么都没授(例如写成 `lead` 而不是
`crm_lead`)。构建期早就硬失败,运行时这条路静默。
- **风险与回滚**:低。已有的授权不受影响:权限集当前已持有的授权键在重存、`os migrate meta
--stored`、回滚时都不判;`'*'`、平台对象、同批草稿里的对象都放行。实测 showcase/crm/multi-package
三个启动里所有内置权限集重存都不会被拒。回滚即撤回 `@objectstack/metadata-protocol` 这次改动。
-
**先看**:`packages/metadata-protocol/src/protocol-save-door-refusals.ts`(新增
`unknownGrantObjectSaveRefusal`);`packages/metadata-protocol/src/protocol.ts`(三处调用点与
`batchObjects`);`packages/metadata-protocol/src/protocol-authoring-gate.ts`(回滚门)。
- **两分钟验证**:`pnpm --filter @objectstack/metadata-protocol exec vitest
run src/protocol.unknown-grant-object-refusal.test.ts`,预期 16/16 通过(main
上 9 条红)。
- **需要你做的**:无,席位落地。
## What changes
- **`unknownGrantObjectSaveRefusal`**
(`packages/metadata-protocol/src/protocol-save-door-refusals.ts`),
beside #26724's `unknownDatasourceSaveRefusal` and in its shape. A
`permission` body whose `objects` map has a key the runtime does not
know is refused with the save door's existing envelope: `422
INVALID_METADATA`, one `issues[]` entry per unknown key at
`objects.NAME`, `code: 'custom'`, opening with the build rule's own
sentence ("Permission 'SET' grants on object 'NAME' which is not defined
in objects"), then why (the grant applies to nothing) and the fix. No
new error code, no new gate, no `packages/spec/**` edit.
- **Known**, in order: the `'*'` wildcard (the evaluator's fallback
entry); an object the engine's registry registers under exactly that
name (`registry.getObject(key)?.name === key`, so the legacy `pkg__name`
lookup does not count, and platform objects pass because they are
registered, without restating `isPlatformObjectName`); an object
`readMetaItem` resolves (stored row, else code layer); on a draft save,
any object draft (`listDrafts`); on a package publish, an object draft
of the same batch (`batchObjects`, beside `batchDatasources`, since
`listDrafts` has no type order). A registry with no `getObject` refuses
nothing.
- **Exempt:** a grant key the set's current definition (`readMetaItem`
of the set) already holds, so a re-save, `os migrate meta --stored`
(which re-saves every stored row through `saveMetaItem`) or a restore
that keeps it is not refused, even when the object has gone since
(#26724's declaration-boot trap).
- **Call sites:** `saveMetaItem` (draft and live, after the datasource
refusal), `promoteDraftForPublish` (shared by `publishMetaItem` and
`publishPackageDrafts`), and `refuseRestoredBodyTheSaveDoorRefuses`
(rollback and commit revert), with the new member on
`AuthoringGateHost`.
- **Changeset** `.changeset/28990-permission-grant-unknown-object.md`:
`minor` on `@objectstack/metadata-protocol`, BREAKING banner, both
declaration lines, ADR-0087 `not-required (no-migration-prescription)`.
- **`scripts/engine-double-contract.pinned.json`**: the new test file's
engine double recorded (`check:engine-double-contract --write`).
## Tests
-
**`packages/metadata-protocol/src/protocol.unknown-grant-object-refusal.test.ts`**
(new, 16 cases): live, draft (with and without a package) and `force`
saves refused at `objects.NAME` with nothing stored; one finding per
unknown key and a short `lead` beside a registered `crm_lead`; the
per-item publish of a set whose object is still a draft;
`publishMetaItem` and `publishPackageDrafts` of a draft stored before
the rule (batch `refused`, nothing promoted). Controls: a registered
object, a platform object (`sys_user_position`), `'*'`, no grant, an
object stored but not registered, an object drafted in the same package
and promoted after the set, a re-save keeping a held grant on an object
gone since (and adding a new unknown one is refused), a registry with no
`getObject`.
-
**`packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts`**:
one new `pinBothDoors` case: `rollbackMetaItem` and `revertCommit`
refuse a version granting on an object that has gone, and restore an
acceptable version (the double's `getObject` now answers a mutable
object set, empty by default).
-
**`packages/qa/dogfood/test/permission-unknown-grant-object-refusal.dogfood.test.ts`**
(new, 7 cases, real showcase over REST): the card's live `PUT` and draft
`PUT` refused 422 with nothing stored; a rollback to a version granting
on an object deleted since refused; controls `showcase_task`,
`sys_user_position`, `'*'` save live, and an object plus a set drafted
in their own package publish in one batch (`publishedCount: 2`).
- **Red on base `0be247ce`** (implementation files restored from base,
tests kept): unit files 11 failed / 41 passed; dogfood
(metadata-protocol rebuilt from base, `ablation-dist-preflight --absent`
exit 0) 3 failed / 4 passed. Every failure is a refusal pin; every
control passes on both trees. Restored: `git status --porcelain` empty.
- **Green on head:** unit files 52/52; dogfood file 7/7 (rebuilt,
preflight present).
<details><summary>Ablation (one-off, from committed HEAD)</summary>
`node scripts/ablation-replace.mjs` on `protocol-save-door-refusals.ts`:
the first guard of `unknownGrantObjectSaveRefusal` made to return early
behind the marker `ABLATED_28990`; anchor 1 → 0, blob `dc2672fab3e1` →
`a1fbab11be87`. The two unit files: 11 failed / 41 passed (the same 11
refusal pins). Restored by the tool: blob equals HEAD `dc2672fab3e1`,
`git diff HEAD` empty.
</details>
## Acceptance notes
- **Measured first (ruling 2).** Runtime probe over three real boots
(`@objectstack/verify` `bootStack`, at head of this branch; the registry
listing does not depend on the change): showcase 18 sets / 217 grant
keys, crm 11 / 179, multi-package 9 / 172. The only key no object
answers is `sys_audit_log`, in plugin-security's shipped
`organization_admin` and `organization_admin_no_bypass` (plugin-audit
registers it, and the verify composition does not load plugin-audit).
Re-saving every one of those shipped sets through the new refusal with
its own body: **0 refused** (the held-grant exemption). hotcrm
`d4ffe99`: 8 profiles, 23 distinct keys, 20 `crm_*` (declared, the build
rule holds them) and `sys_comment`, `sys_activity` (plugin-audit),
`sys_attachment` (platform-objects). No QA seed stores a permission row.
So no shipped set is refused.
- **Re-save paths:** `migrateStoredMetadata` (`os migrate meta
--stored`) re-saves every stored row through `saveMetaItem` with `force`
and `source: 'migrate-stored'`; the org-ownership ceremony and the
restore doors re-save too. All keep the grants the stored set already
holds, which the exemption passes.
- **Out of scope, noted:** `fields` keys (`object.field`) are not judged
here; QA record #28981 also observed a translation's
`objects.no_such_object` saving 200 (different metadata type, not this
card).
## Verification
- `@objectstack/metadata-protocol`: `pnpm --filter
@objectstack/metadata-protocol test` under the lock, VERDICT
command-exit 0, Test Files 298 passed, 4 skipped / Tests 22126 passed,
26 skipped; `typecheck` exit 0; `pnpm exec eslint
packages/metadata-protocol
packages/qa/dogfood/test/permission-unknown-grant-object-refusal.dogfood.test.ts
--no-inline-config` exit 0.
- **Derived gates** (`dispatch-gates --commands`, 65 families): run at
`db04498a`+changeset; at hand-back 55 had run, all exit 0 except three:
`check-plugin-teardown-shape --self-test` exit 3 (shallow clone; green
after fetching its fixture commit), `check:engine-double-contract` exit
1 (the new double; ledger written, recheck exit 0, committed in
`7d6842b3`), `check:dual-build-cjs-loads` exit 3 PREREQUISITE NOT MET (7
packages outside this closure unbuilt): NOT MEASURED, CI runs it. The
last 10 families: NOT MEASURED at hand-back (wrap-up ordered by the
seat).
- **Dogfood battery:** NOT MEASURED at hand-back: it was running under
the lock when the seat ordered the wrap-up.
- **CI** runs the full farm on this PR; `test:repo` (Test Core) is CI's,
not run locally.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01FEkQ5oyJ4R1betVYFyp6SD
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent db6b408 commit 78f78ac
10 files changed
Lines changed: 727 additions & 7 deletions
File tree
- .changeset
- packages
- metadata-protocol/src
- objectql/src
- qa/dogfood/test
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
| 93 | + | |
93 | 94 | | |
94 | 95 | | |
95 | 96 | | |
| |||
124 | 125 | | |
125 | 126 | | |
126 | 127 | | |
127 | | - | |
| 128 | + | |
128 | 129 | | |
129 | 130 | | |
130 | 131 | | |
| |||
141 | 142 | | |
142 | 143 | | |
143 | 144 | | |
| 145 | + | |
| 146 | + | |
144 | 147 | | |
145 | 148 | | |
146 | 149 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
527 | 527 | | |
528 | 528 | | |
529 | 529 | | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
530 | 606 | | |
531 | 607 | | |
532 | 608 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
11 | | - | |
| 11 | + | |
| 12 | + | |
12 | 13 | | |
13 | 14 | | |
14 | 15 | | |
| |||
506 | 507 | | |
507 | 508 | | |
508 | 509 | | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
509 | 532 | | |
510 | 533 | | |
511 | 534 | | |
| |||
0 commit comments