Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/meta-write-doors-manage-metadata-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
"@objectstack/rest": minor
"@objectstack/runtime": minor
---

feat(rest,runtime): 元数据写入的其余三扇门同样要求 `manage_metadata` 能力 (#7019)

**这是一次访问面收紧,线上可见。** #6603 只给 `PUT /api/v1/meta/:type/:name`
一条路由落了 `manage_metadata` 门,而同一个写操作还有另外三扇门没有门。本次
把它们补齐,用的是**同一道门、同一套机制**(各自照抄所在文件的既有先例):

- `PUT /api/v1/meta/:type/:section/:name` —— 复合名保存(`@objectstack/rest`);
- `DELETE /api/v1/meta/:type/:name` —— 重置为构件默认值(`@objectstack/rest`);
- 运行时 dispatcher 自己的 `/meta` PUT —— 同一操作的**第二条传输**(`@objectstack/runtime`)。

## 谁开始吃 403,需要什么

**任何不持 `manage_metadata` 的已认证调用方**,对上述三条路径的写入一律 403
(匿名调用方仍先吃 `/meta` 伞下的 401,能力门是第二层)。`isSystem`(引擎自调)
照例放行。平台自带的 `admin_full_access` 权限集本就带 `manage_metadata`,所以
Studio / Setup 里的管理员与 CLI 的 dev admin **不受影响**;受影响的是自建集成、
自建权限集,以及只持 `setup.access` 的 `organization_admin`。

**要恢复写入:给该调用方的权限集加上 `manage_metadata`**(Setup →
Permission Sets → `systemPermissions`),而不是绕过这些路由。

## 为什么必须收紧

两条**各自独立成立**的理由:

1. **ADR-0106 的读写不对称。** D1 会把调用方不可读的字段**整个**从服务出的对象
schema 里摘掉,而这些路由原样持久化收到的 body。#6603 落地后**实测**:同一次
GET → 改个 label → PUT 的字段丢失,经复合名这扇门可原样复现 —— 缺陷没有被修复,
只是换了一扇门。本次复测的前后对照:

```
加门前: compound PUT status : 200 | saveMetaItem calls : 1 | STORE after PUT : id, name
加门后: compound PUT status : 403 | saveMetaItem calls : 0 | STORE after PUT : bonus_formula, id, name, salary_grade
```

2. **一个与掩码无关、更早就存在的洞:** 任何已认证会话都能覆写(或重置)任意
元数据项。`DELETE` 这条尤其是这个理由而**不是**掩码理由 —— 它不往返、不掩码,
只是把定制覆盖层整个丢掉,`?dropStorage=true` 还会连对象的物理表一起拆掉。

三处门都落在解析 protocol **之前**,所以未授权调用方无法用 501-vs-200 指纹探测
内核能力,且拒绝时**什么都没写、什么都没删**。

## 不在本次范围

只收紧写入面;读路径的姿态(ADR-0106 掩码)不变。#7020 记录的「门要求的能力集
与 D4 掩码豁免集不是同一个集合」仍然成立,本次不替维护者选对齐方向。
331 changes: 331 additions & 0 deletions packages/rest/src/meta-compound-save-and-reset-capability-gate.test.ts

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/rest/src/rest-route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
{ route: 'PUT /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem',
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' },
{ route: 'DELETE /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.deleteItem',
note: 'REST-only: the dispatcher /meta branch has no DELETE handling — it falls into the read path' },
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' },
{ route: 'GET /api/v1/meta/:type/:name/history', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getHistory',
note: 'REST-only: the dispatcher /meta branch swallows /history as a compound name and 404s' },
{ route: 'GET /api/v1/meta/:type/:name/audit', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getAudit' },
Expand All @@ -171,7 +171,7 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
{ route: 'GET /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItem',
note: 'compound names pass through getItem unencoded (URL-pinned in client.test.ts); only deleteItem encodes' },
{ route: 'PUT /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem',
note: 'compound names pass through saveItem unencoded (URL-pinned in client.test.ts)' },
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' },

// ── ui ────────────────────────────────────────────────────────────────────
{ route: 'GET /api/v1/ui/view/:object/:type', family: 'ui', source: 'route-manager', disposition: 'sdk', client: 'meta.getView',
Expand Down
70 changes: 70 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5252,6 +5252,40 @@ export class RestServer {
handler: async (req: any, res: any) => {
try {
const environmentId = isScoped ? req.params?.environmentId : undefined;
// [#7019] Same gate, same mechanism as the `PUT` twins —
// but the argument for it is NOT the ADR-0106 round trip,
// and saying so matters. Nothing is masked here and nothing
// is round-tripped: this route discards a customization
// overlay outright, so before this gate an authenticated
// session holding no authoring capability at all could
// reset any customized metadata item in the deployment to
// its artifact default — and with `?dropStorage=true`, drop
// the object's physical table with it.
//
// It belongs with the two PUTs because deleting a
// customization is authoring it (ADR-0066 D1), and because
// the fix is the same four lines — not because it is the
// same argument.
//
// Gate FIRST — before the protocol is resolved — so the
// 501-vs-200 answer leaks no kernel capability, and, the
// point here, so the refusal happens with the overlay row
// still intact. A gate that answers 403 after
// `deleteMetaItem` has run would still be the bug.
// `isSystem` bypasses, as everywhere else.
const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined);
const held = new Set<string>(
Array.isArray(ctx?.systemPermissions) ? ctx!.systemPermissions : [],
);
if (!ctx?.isSystem && !held.has('manage_metadata')) {
res.status(403).json({
error: {
code: 'FORBIDDEN',
message: 'Resetting a metadata item requires the `manage_metadata` capability.',
},
});
return;
}
const p = await this.resolveProtocol(environmentId, req);
if (!(p as any).deleteMetaItem) {
res.status(501).json({
Expand Down Expand Up @@ -5578,6 +5612,42 @@ export class RestServer {
handler: async (req: any, res: any) => {
try {
const environmentId = isScoped ? req.params?.environmentId : undefined;
// [#7019] The compound-name twin of the gate #6603 put on
// `PUT /meta/:type/:name` — WORD FOR WORD the same
// mechanism, because it is word for word the same
// operation: one generic `saveMetaItem`, reached by a name
// spelled in two segments instead of one.
//
// Gating only the single-segment door left this one as a
// bypass of it, and that was measured rather than reasoned:
// with #6603's gate in place, the identical ADR-0106
// GET → edit a label → PUT still round-tripped a MASKED
// object schema back into the store through here, deleting
// the fields the caller was never allowed to see. Same
// caller, same object, same loss, one route over.
//
// Independently of masking, this door also served the older
// hole for EVERY metadata type: any authenticated session
// could clobber any metadata item.
//
// Gate FIRST — before the protocol is resolved — so an
// unauthorized caller cannot use the 501-vs-200 answer to
// probe which kernels implement saving, and so nothing is
// written before the refusal. `isSystem` bypasses, matching
// every other capability gate on the platform.
const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined);
const held = new Set<string>(
Array.isArray(ctx?.systemPermissions) ? ctx!.systemPermissions : [],
);
if (!ctx?.isSystem && !held.has('manage_metadata')) {
res.status(403).json({
error: {
code: 'FORBIDDEN',
message: 'Saving a metadata item requires the `manage_metadata` capability.',
},
});
return;
}
const p = await this.resolveProtocol(environmentId, req);
if (!p.saveMetaItem) {
res.status(501).json({ error: 'Save operation not supported by protocol implementation', code: 'NOT_IMPLEMENTED' });
Expand Down
13 changes: 12 additions & 1 deletion packages/runtime/src/domains/error-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,18 @@ describe('#3918 follow-up — deliberate per-route fallbacks are preserved', ()
getService: resolve,
getServiceAsync: async (name: string) => resolve(name),
};
const result: any = await new HttpDispatcher(kernel).dispatch(
const dispatcher = new HttpDispatcher(kernel);
// [#7019] The `/meta` PUT now demands the `manage_metadata` capability —
// an authoring capability, not just a session. `dispatch()` re-resolves
// the execution context from the auth / objectql services, and this stub
// has no objectql, so the resolved caller would hold no capabilities and
// be refused with a 403 before ever reaching the 501/400 fallback branch
// this test pins. Only the caller's capability is stubbed; the fallback
// mechanism and its expected statuses are unchanged.
(dispatcher as any).timedResolveExecutionContext = async () => ({
userId: 'u1', systemPermissions: ['manage_metadata'],
});
const result: any = await dispatcher.dispatch(
'PUT', '/meta/object/widget', { name: 'widget' }, {}, {} as any,
);
return result.response;
Expand Down
Loading
Loading