Skip to content

Commit 73648ba

Browse files
feat(rest,runtime): 元数据写入的其余三扇门同样要求 manage_metadata (#7019) (#7043)
#6603 只给 PUT /api/v1/meta/:type/:name 落了 manage_metadata 门,同一个写 操作还有三扇门没有门。本次补齐,各自照抄所在文件的既有先例,不引入新写法: - PUT /api/v1/meta/:type/:section/:name(复合名保存) - DELETE /api/v1/meta/:type/:name(重置为构件默认值) - 运行时 dispatcher 自己的 /meta PUT(同一操作的第二条传输) 三处门都落在解析 protocol 之前,使未授权调用方无法用 501-vs-200 指纹探测 内核能力,且拒绝时什么都没写、什么都没删。isSystem 照例旁路。 复合名那扇门的前后实测(同一往返、真实内存库): 加门前 PUT 200 / saveMetaItem 1 次 / 库中只剩 id,name 加门后 PUT 403 / saveMetaItem 0 次 / 库中四个字段完好 DELETE 那扇门是另一条理由:不往返、不掩码,只是把定制覆盖层整个丢掉。 连带修改 3 个既有测试文件的 boot 桩(补 manage_metadata):它们的桩把 「只要有 session 就能写元数据」当作既成前提,而这正是本次要消灭的东西。 被测机制、断言、期望值一字未动。 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx Co-authored-by: Claude <noreply@anthropic.com>
1 parent debe2f6 commit 73648ba

9 files changed

Lines changed: 779 additions & 12 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/rest": minor
3+
"@objectstack/runtime": minor
4+
---
5+
6+
feat(rest,runtime): 元数据写入的其余三扇门同样要求 `manage_metadata` 能力 (#7019)
7+
8+
**这是一次访问面收紧,线上可见。** #6603 只给 `PUT /api/v1/meta/:type/:name`
9+
一条路由落了 `manage_metadata` 门,而同一个写操作还有另外三扇门没有门。本次
10+
把它们补齐,用的是**同一道门、同一套机制**(各自照抄所在文件的既有先例):
11+
12+
- `PUT /api/v1/meta/:type/:section/:name` —— 复合名保存(`@objectstack/rest`);
13+
- `DELETE /api/v1/meta/:type/:name` —— 重置为构件默认值(`@objectstack/rest`);
14+
- 运行时 dispatcher 自己的 `/meta` PUT —— 同一操作的**第二条传输**(`@objectstack/runtime`)。
15+
16+
## 谁开始吃 403,需要什么
17+
18+
**任何不持 `manage_metadata` 的已认证调用方**,对上述三条路径的写入一律 403
19+
(匿名调用方仍先吃 `/meta` 伞下的 401,能力门是第二层)。`isSystem`(引擎自调)
20+
照例放行。平台自带的 `admin_full_access` 权限集本就带 `manage_metadata`,所以
21+
Studio / Setup 里的管理员与 CLI 的 dev admin **不受影响**;受影响的是自建集成、
22+
自建权限集,以及只持 `setup.access``organization_admin`
23+
24+
**要恢复写入:给该调用方的权限集加上 `manage_metadata`**(Setup →
25+
Permission Sets → `systemPermissions`),而不是绕过这些路由。
26+
27+
## 为什么必须收紧
28+
29+
两条**各自独立成立**的理由:
30+
31+
1. **ADR-0106 的读写不对称。** D1 会把调用方不可读的字段**整个**从服务出的对象
32+
schema 里摘掉,而这些路由原样持久化收到的 body。#6603 落地后**实测**:同一次
33+
GET → 改个 label → PUT 的字段丢失,经复合名这扇门可原样复现 —— 缺陷没有被修复,
34+
只是换了一扇门。本次复测的前后对照:
35+
36+
```
37+
加门前: compound PUT status : 200 | saveMetaItem calls : 1 | STORE after PUT : id, name
38+
加门后: compound PUT status : 403 | saveMetaItem calls : 0 | STORE after PUT : bonus_formula, id, name, salary_grade
39+
```
40+
41+
2. **一个与掩码无关、更早就存在的洞:** 任何已认证会话都能覆写(或重置)任意
42+
元数据项。`DELETE` 这条尤其是这个理由而**不是**掩码理由 —— 它不往返、不掩码,
43+
只是把定制覆盖层整个丢掉,`?dropStorage=true` 还会连对象的物理表一起拆掉。
44+
45+
三处门都落在解析 protocol **之前**,所以未授权调用方无法用 501-vs-200 指纹探测
46+
内核能力,且拒绝时**什么都没写、什么都没删**
47+
48+
## 不在本次范围
49+
50+
只收紧写入面;读路径的姿态(ADR-0106 掩码)不变。#7020 记录的「门要求的能力集
51+
与 D4 掩码豁免集不是同一个集合」仍然成立,本次不替维护者选对齐方向。

packages/rest/src/meta-compound-save-and-reset-capability-gate.test.ts

Lines changed: 331 additions & 0 deletions
Large diffs are not rendered by default.

packages/rest/src/rest-route-ledger.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
160160
{ route: 'PUT /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem',
161161
note: '[#6603] gated on `manage_metadata` (ADR-0066 D1), same mechanism as POST /meta/_migrate-stored — a session alone is no longer enough. The write-side answer to ADR-0106 D1: a masked read PUT back verbatim used to delete the fields the caller could not see' },
162162
{ route: 'DELETE /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.deleteItem',
163-
note: 'REST-only: the dispatcher /meta branch has no DELETE handling — it falls into the read path' },
163+
note: 'REST-only: the dispatcher /meta branch has no DELETE handling — it falls into the read path. [#7019] gated on `manage_metadata` (ADR-0066 D1), same mechanism as the PUT twins — but NOT for the ADR-0106 reason: nothing is masked or round-tripped here, this discards a customization overlay outright, and `?dropStorage=true` takes the object table with it' },
164164
{ route: 'GET /api/v1/meta/:type/:name/history', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getHistory',
165165
note: 'REST-only: the dispatcher /meta branch swallows /history as a compound name and 404s' },
166166
{ route: 'GET /api/v1/meta/:type/:name/audit', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getAudit' },
@@ -171,7 +171,7 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
171171
{ route: 'GET /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItem',
172172
note: 'compound names pass through getItem unencoded (URL-pinned in client.test.ts); only deleteItem encodes' },
173173
{ route: 'PUT /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem',
174-
note: 'compound names pass through saveItem unencoded (URL-pinned in client.test.ts)' },
174+
note: 'compound names pass through saveItem unencoded (URL-pinned in client.test.ts). [#7019] gated on `manage_metadata` (ADR-0066 D1), identical to the single-name PUT — it was MEASURED that with #6603 in place the same ADR-0106 masked round trip still deleted fields through this door' },
175175

176176
// ── ui ────────────────────────────────────────────────────────────────────
177177
{ route: 'GET /api/v1/ui/view/:object/:type', family: 'ui', source: 'route-manager', disposition: 'sdk', client: 'meta.getView',

packages/rest/src/rest-server.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5252,6 +5252,40 @@ export class RestServer {
52525252
handler: async (req: any, res: any) => {
52535253
try {
52545254
const environmentId = isScoped ? req.params?.environmentId : undefined;
5255+
// [#7019] Same gate, same mechanism as the `PUT` twins —
5256+
// but the argument for it is NOT the ADR-0106 round trip,
5257+
// and saying so matters. Nothing is masked here and nothing
5258+
// is round-tripped: this route discards a customization
5259+
// overlay outright, so before this gate an authenticated
5260+
// session holding no authoring capability at all could
5261+
// reset any customized metadata item in the deployment to
5262+
// its artifact default — and with `?dropStorage=true`, drop
5263+
// the object's physical table with it.
5264+
//
5265+
// It belongs with the two PUTs because deleting a
5266+
// customization is authoring it (ADR-0066 D1), and because
5267+
// the fix is the same four lines — not because it is the
5268+
// same argument.
5269+
//
5270+
// Gate FIRST — before the protocol is resolved — so the
5271+
// 501-vs-200 answer leaks no kernel capability, and, the
5272+
// point here, so the refusal happens with the overlay row
5273+
// still intact. A gate that answers 403 after
5274+
// `deleteMetaItem` has run would still be the bug.
5275+
// `isSystem` bypasses, as everywhere else.
5276+
const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined);
5277+
const held = new Set<string>(
5278+
Array.isArray(ctx?.systemPermissions) ? ctx!.systemPermissions : [],
5279+
);
5280+
if (!ctx?.isSystem && !held.has('manage_metadata')) {
5281+
res.status(403).json({
5282+
error: {
5283+
code: 'FORBIDDEN',
5284+
message: 'Resetting a metadata item requires the `manage_metadata` capability.',
5285+
},
5286+
});
5287+
return;
5288+
}
52555289
const p = await this.resolveProtocol(environmentId, req);
52565290
if (!(p as any).deleteMetaItem) {
52575291
res.status(501).json({
@@ -5578,6 +5612,42 @@ export class RestServer {
55785612
handler: async (req: any, res: any) => {
55795613
try {
55805614
const environmentId = isScoped ? req.params?.environmentId : undefined;
5615+
// [#7019] The compound-name twin of the gate #6603 put on
5616+
// `PUT /meta/:type/:name` — WORD FOR WORD the same
5617+
// mechanism, because it is word for word the same
5618+
// operation: one generic `saveMetaItem`, reached by a name
5619+
// spelled in two segments instead of one.
5620+
//
5621+
// Gating only the single-segment door left this one as a
5622+
// bypass of it, and that was measured rather than reasoned:
5623+
// with #6603's gate in place, the identical ADR-0106
5624+
// GET → edit a label → PUT still round-tripped a MASKED
5625+
// object schema back into the store through here, deleting
5626+
// the fields the caller was never allowed to see. Same
5627+
// caller, same object, same loss, one route over.
5628+
//
5629+
// Independently of masking, this door also served the older
5630+
// hole for EVERY metadata type: any authenticated session
5631+
// could clobber any metadata item.
5632+
//
5633+
// Gate FIRST — before the protocol is resolved — so an
5634+
// unauthorized caller cannot use the 501-vs-200 answer to
5635+
// probe which kernels implement saving, and so nothing is
5636+
// written before the refusal. `isSystem` bypasses, matching
5637+
// every other capability gate on the platform.
5638+
const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined);
5639+
const held = new Set<string>(
5640+
Array.isArray(ctx?.systemPermissions) ? ctx!.systemPermissions : [],
5641+
);
5642+
if (!ctx?.isSystem && !held.has('manage_metadata')) {
5643+
res.status(403).json({
5644+
error: {
5645+
code: 'FORBIDDEN',
5646+
message: 'Saving a metadata item requires the `manage_metadata` capability.',
5647+
},
5648+
});
5649+
return;
5650+
}
55815651
const p = await this.resolveProtocol(environmentId, req);
55825652
if (!p.saveMetaItem) {
55835653
res.status(501).json({ error: 'Save operation not supported by protocol implementation', code: 'NOT_IMPLEMENTED' });

packages/runtime/src/domains/error-passthrough.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,18 @@ describe('#3918 follow-up — deliberate per-route fallbacks are preserved', ()
141141
getService: resolve,
142142
getServiceAsync: async (name: string) => resolve(name),
143143
};
144-
const result: any = await new HttpDispatcher(kernel).dispatch(
144+
const dispatcher = new HttpDispatcher(kernel);
145+
// [#7019] The `/meta` PUT now demands the `manage_metadata` capability —
146+
// an authoring capability, not just a session. `dispatch()` re-resolves
147+
// the execution context from the auth / objectql services, and this stub
148+
// has no objectql, so the resolved caller would hold no capabilities and
149+
// be refused with a 403 before ever reaching the 501/400 fallback branch
150+
// this test pins. Only the caller's capability is stubbed; the fallback
151+
// mechanism and its expected statuses are unchanged.
152+
(dispatcher as any).timedResolveExecutionContext = async () => ({
153+
userId: 'u1', systemPermissions: ['manage_metadata'],
154+
});
155+
const result: any = await dispatcher.dispatch(
145156
'PUT', '/meta/object/widget', { name: 'widget' }, {}, {} as any,
146157
);
147158
return result.response;

0 commit comments

Comments
 (0)