Skip to content

Commit 623d008

Browse files
feat(rest): PUT /meta/:type/:name 要求 manage_metadata 能力 (#6603) (#7027)
* wip: #6603 manage_metadata gate on PUT /meta/:type/:name + tests * feat(rest): PUT /meta/:type/:name 要求 manage_metadata 能力 (#6603) * test(rest): 让既有 PUT /meta 路由机制测试持有 manage_metadata (#6603) 既有的 header 转发 / 收据信封 / 错误信封等路由机制单测都以「只有 session」的 调用方驱动 PUT /meta/:type/:name,新门落下后它们先吃 403。给这些 boot 桩加上 manage_metadata,测的仍是原来的机制。 同时在 rest-route-ledger 的该行记下这道门,与 _migrate-stored 的记法一致。 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fd74233 commit 623d008

9 files changed

Lines changed: 392 additions & 9 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
feat(rest): `PUT /api/v1/meta/:type/:name` 要求 `manage_metadata` 能力 (#6603)
6+
7+
**这是一次访问面收紧,线上可见。** 保存单个元数据项的这条路由此前只有
8+
`enforceAuth` —— 任何已认证会话都能写任意元数据项。现在它与隔壁的
9+
`POST /api/v1/meta/_migrate-stored` 用同一道门、同一套机制:调用方必须持有
10+
ADR-0066 D1 的 `manage_metadata` 能力,`isSystem` 照例放行。
11+
12+
## 谁开始吃 403,需要什么
13+
14+
**任何不持 `manage_metadata` 的已认证调用方**,对这条路由的 `PUT` 一律
15+
403 `FORBIDDEN`(匿名调用方仍先吃 `/meta` 伞下的 401,门是第二层)。
16+
平台自带的 `admin_full_access` 权限集本就带 `manage_metadata`,所以
17+
Studio / Setup 里的管理员与 CLI 的 dev admin **不受影响**;受影响的是
18+
自建集成、自建权限集,以及只持 `setup.access``organization_admin`
19+
20+
**要恢复写入:给该调用方的权限集加上 `manage_metadata`**(Setup →
21+
Permission Sets → `systemPermissions`),而不是绕过这条路由。
22+
23+
## 为什么必须收紧
24+
25+
ADR-0106 D1 会把调用方不可读的字段**整个**从服务出的对象 schema 里摘掉,
26+
而这条路由原样持久化收到的 body。于是一次最普通的
27+
GET → 改个 label → PUT,就把调用方**从来没被允许看见的字段删掉了**,
28+
整个交互过程中没有任何东西提示。GET-改-PUT 正是 AI agent 编写元数据的
29+
标准动作,原先这个动作会静默销毁它看不见的字段;现在它在写入时得到一个
30+
**响亮的 403**
31+
32+
同时这也关掉一个与掩码无关、更早就存在的洞:任何已认证会话都能覆写
33+
任意 schema。
34+
35+
## 尚未关闭的部分
36+
37+
本次只收紧这一条路由。同形的 `PUT /meta/:type/:section/:name`(复合名)
38+
与运行时 dispatcher 自己的 `/meta` PUT 仍无能力门,同一次往返丢失仍可经
39+
它们复现 —— 已另立 #7019 跟踪,不在本次范围内。
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#6603] `PUT /api/v1/meta/:type/:name` demands the `manage_metadata`
5+
* authoring capability (ADR-0066 D1) — the same gate, by the same mechanism,
6+
* that `POST /meta/_migrate-stored` already demands next door.
7+
*
8+
* ## What this suite exists to stop
9+
*
10+
* ADR-0106 D1 removes an unreadable field **whole** from a served object
11+
* schema, and this route persists the body it is handed. Until this gate, a
12+
* non-exempt caller's most ordinary sequence —
13+
*
14+
* 1. `GET /meta/object/account` → a schema with `salary_grade` and
15+
* `bonus_formula` absent (correct: that is the whole point of D1);
16+
* 2. edit something unrelated — a label;
17+
* 3. `PUT /meta/object/account` with that body,
18+
*
19+
* — stored the schema back MINUS the two fields, i.e. the caller deleted
20+
* exactly the fields they were never allowed to see, and nothing in the
21+
* exchange said so. The headline case below drives that real sequence against
22+
* a real store, so what is pinned is the DATA LOSS, not just a status code: a
23+
* gate that answers 403 after `saveMetaItem` has already run would still be
24+
* the bug, and would still pass a status-only assertion.
25+
*
26+
* The gate also closes a hole that has nothing to do with masking: before it,
27+
* any authenticated session could clobber any metadata item.
28+
*
29+
* ## Scope of what is pinned here
30+
*
31+
* THIS ROUTE ONLY. The same round trip is still reachable through the
32+
* compound-name save `PUT /meta/:type/:section/:name` (measured) and the
33+
* runtime dispatcher's own `/meta` PUT — filed as #7019, out of this change's
34+
* region. A reader who takes this suite as proof that the defect is closed
35+
* platform-wide has read more into it than it asserts.
36+
*
37+
* ## Rejection cases assert the ENVELOPE (ADR-0112)
38+
*
39+
* Every refusal here asserts `code` AND `status`, never a bare "it threw" —
40+
* this route answers by *sending* rather than throwing, so a throw-shaped
41+
* assertion could not tell "refused with the wrong envelope" from "did not
42+
* refuse at all".
43+
*/
44+
45+
import { describe, it, expect, vi } from 'vitest';
46+
import { FLS_CONTRACT_OBJECT } from '@objectstack/metadata-core/testing';
47+
import { RestServer } from './rest-server';
48+
49+
const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value));
50+
51+
/** The four fields `FLS_CONTRACT_OBJECT` declares, sorted. */
52+
const ALL_FIELDS = ['bonus_formula', 'id', 'name', 'salary_grade'];
53+
/** What the security double lets a restricted caller read. */
54+
const READABLE_TO_RESTRICTED = ['id', 'name'];
55+
56+
const SINGLE_PATH = '/api/v1/meta/:type/:name';
57+
58+
function mockServer() {
59+
return {
60+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
61+
use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
62+
};
63+
}
64+
65+
function mockRes() {
66+
const res: any = {
67+
statusCode: 200,
68+
json: vi.fn(function (this: any, body: any) { this._body = body; return this; }),
69+
send: vi.fn(),
70+
status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }),
71+
header: vi.fn(),
72+
};
73+
return res;
74+
}
75+
76+
interface BootOptions {
77+
/** The caller, as `resolveExecCtx` resolves it. `undefined` = anonymous. */
78+
context: Record<string, unknown> | undefined;
79+
/** What `security.getMetadataReadableFields` answers; omit for no security service. */
80+
readable?: readonly string[];
81+
/** Drop `saveMetaItem` from the protocol (the 501 kernel). */
82+
withoutSave?: boolean;
83+
}
84+
85+
/**
86+
* Boot the route over a protocol backed by a REAL in-memory store, so a GET →
87+
* edit → PUT sequence actually round-trips and the stored document can be
88+
* inspected after the write is refused.
89+
*/
90+
function boot(opts: BootOptions) {
91+
const stored: Record<string, any> = { account: copy(FLS_CONTRACT_OBJECT as unknown as Record<string, unknown>) };
92+
93+
const saveMetaItem = vi.fn(async ({ name, item }: any) => {
94+
stored[name] = copy(item);
95+
return { success: true, type: 'object', name };
96+
});
97+
98+
const protocol: any = {
99+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }),
100+
getMetaTypes: vi.fn().mockResolvedValue([]),
101+
getMetaItems: vi.fn(async () => Object.values(stored).map(copy)),
102+
// No `getMetaItemCached` — the uncached branch, so the read always
103+
// reflects the store rather than a fixture snapshot.
104+
getMetaItem: vi.fn(async ({ type, name }: any) => ({ type, name, item: copy(stored[name]), lock: 'none' })),
105+
findData: vi.fn().mockResolvedValue([]),
106+
getData: vi.fn().mockResolvedValue({}),
107+
createData: vi.fn().mockResolvedValue({ id: '1' }),
108+
updateData: vi.fn().mockResolvedValue({}),
109+
deleteData: vi.fn().mockResolvedValue({ success: true }),
110+
};
111+
if (!opts.withoutSave) protocol.saveMetaItem = saveMetaItem;
112+
113+
const security = opts.readable === undefined ? undefined : {
114+
getReadableFields: async () => [...opts.readable!],
115+
getMetadataReadableFields: async () => [...opts.readable!],
116+
};
117+
118+
const rest = new RestServer(
119+
mockServer() as any,
120+
protocol as any,
121+
{ api: { requireAuth: false } } as any,
122+
undefined, undefined, undefined, undefined, undefined, undefined, undefined,
123+
undefined, undefined, undefined, undefined, undefined, undefined, undefined,
124+
security ? (async () => security as any) : undefined,
125+
);
126+
(rest as any).resolveExecCtx = async () => opts.context;
127+
rest.registerRoutes();
128+
129+
const route = (method: string) => (rest as any).getRoutes().find(
130+
(r: any) => r.method === method && r.path === SINGLE_PATH,
131+
);
132+
133+
return {
134+
rest,
135+
saveMetaItem,
136+
/** Field names currently in the STORE (not in any response). */
137+
storedFields: () => Object.keys(stored.account.fields ?? {}).sort(),
138+
storedLabel: () => stored.account.label,
139+
get: async () => {
140+
const res = mockRes();
141+
await route('GET')!.handler({ params: { type: 'object', name: 'account' }, query: {}, headers: {} }, res);
142+
return { res, body: res.json.mock.calls.at(-1)?.[0] };
143+
},
144+
put: async (item: unknown) => {
145+
const res = mockRes();
146+
await route('PUT')!.handler(
147+
{ params: { type: 'object', name: 'account' }, query: {}, headers: {}, body: item },
148+
res,
149+
);
150+
return { res, body: res.json.mock.calls.at(-1)?.[0] };
151+
},
152+
};
153+
}
154+
155+
describe('#6603 — PUT /meta/:type/:name: the ADR-0106 GET → edit → PUT round trip', () => {
156+
it('refuses a restricted caller\'s round-trip write, and the masked fields SURVIVE in the store', async () => {
157+
const stack = boot({
158+
context: { userId: 'u_portal', systemPermissions: [] },
159+
readable: READABLE_TO_RESTRICTED,
160+
});
161+
162+
// 1. The read is masked — the premise. Asserted rather than assumed so
163+
// this case cannot go quietly green by the masking disappearing.
164+
const read = await stack.get();
165+
expect(Object.keys(read.body.item.fields).sort()).toEqual(READABLE_TO_RESTRICTED);
166+
expect(read.body.item.fields).not.toHaveProperty('salary_grade');
167+
expect(read.body.item.fields).not.toHaveProperty('bonus_formula');
168+
169+
// 2. The caller edits something unrelated and sends the body back.
170+
const edited = { ...copy(read.body.item), label: 'Account (renamed)' };
171+
172+
// 3. The write is refused — envelope, not just "it failed".
173+
const write = await stack.put(edited);
174+
expect(write.res.statusCode).toBe(403);
175+
expect(write.body).toMatchObject({ error: { code: 'FORBIDDEN' } });
176+
177+
// 4. THE POINT: nothing was written. A gate that 403s *after* the
178+
// store has already been overwritten is the failure mode worth
179+
// guarding, and a status-only assertion cannot see it.
180+
expect(stack.saveMetaItem).not.toHaveBeenCalled();
181+
expect(stack.storedFields()).toEqual(ALL_FIELDS);
182+
expect(stack.storedLabel()).toBe('Account');
183+
});
184+
185+
it('the refusal is the gate, not the masking: an UNRESTRICTED but uncapable caller is refused too', async () => {
186+
// Everything readable ⇒ no field would have been lost. The write is
187+
// still refused, because reason (2) — any authenticated session could
188+
// clobber any metadata item — is independent of ADR-0106.
189+
const stack = boot({
190+
context: { userId: 'u_staff', systemPermissions: [] },
191+
readable: ALL_FIELDS,
192+
});
193+
const read = await stack.get();
194+
expect(Object.keys(read.body.item.fields).sort()).toEqual(ALL_FIELDS);
195+
196+
const write = await stack.put({ ...copy(read.body.item), label: 'clobbered' });
197+
expect(write.res.statusCode).toBe(403);
198+
expect(write.body).toMatchObject({ error: { code: 'FORBIDDEN' } });
199+
expect(stack.storedLabel()).toBe('Account');
200+
});
201+
});
202+
203+
describe('#6603 — the gate itself', () => {
204+
it('fires BEFORE the protocol is probed, so 403-vs-501 leaks no kernel capability', async () => {
205+
const stack = boot({ context: { userId: 'u1', systemPermissions: [] }, withoutSave: true });
206+
const write = await stack.put({ name: 'account' });
207+
// An authorized caller would get 501 here. An unauthorized one must
208+
// not be able to tell the two kernels apart.
209+
expect(write.res.statusCode).toBe(403);
210+
expect(write.body).toMatchObject({ error: { code: 'FORBIDDEN' } });
211+
});
212+
213+
it('an anonymous caller never reaches the capability gate — 401 from the /meta umbrella', async () => {
214+
// Every `/meta` route inherits the anonymous-deny wrapper, so this gate
215+
// is the second layer rather than the only one.
216+
const stack = boot({ context: undefined });
217+
const write = await stack.put({ name: 'account' });
218+
expect(write.res.statusCode).toBe(401);
219+
expect(stack.saveMetaItem).not.toHaveBeenCalled();
220+
});
221+
222+
it('allows a caller holding `manage_metadata`', async () => {
223+
const stack = boot({ context: { userId: 'u_author', systemPermissions: ['manage_metadata'] } });
224+
const write = await stack.put({ name: 'account', label: 'Account', fields: {} });
225+
expect(write.res.statusCode).toBe(200);
226+
expect(stack.saveMetaItem).toHaveBeenCalledTimes(1);
227+
});
228+
229+
it('`isSystem` bypasses, matching every other capability gate on the platform', async () => {
230+
const stack = boot({ context: { isSystem: true } });
231+
const write = await stack.put({ name: 'account', label: 'Account', fields: {} });
232+
expect(write.res.statusCode).toBe(200);
233+
expect(stack.saveMetaItem).toHaveBeenCalledTimes(1);
234+
});
235+
236+
/**
237+
* MEASURED, and deliberately pinned as-is: the capability this gate demands
238+
* (`manage_metadata`) and the ADR-0106 D4 mask-exemption set
239+
* (`OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES` = `studio.access`,
240+
* `setup.access`) are DIFFERENT SETS. So holding a D4 exemption is not by
241+
* itself permission to write — and, in the other direction, passing this
242+
* gate is not by itself an exemption from the mask. What this route now
243+
* enforces is "a writer holds `manage_metadata`", which is NOT the same
244+
* sentence as the ruling's rationale, "a writer sees the whole schema";
245+
* the two coincide only because `admin_full_access` happens to carry both.
246+
* Recorded as #7020 — do not "fix" this matrix to match the rationale
247+
* without a ruling.
248+
*
249+
* In the permission sets the platform ships this never separates on the
250+
* write side: `admin_full_access` carries `manage_metadata` AND
251+
* `studio.access` AND `setup.access`, and it is the only shipped set with
252+
* `studio.access`. `organization_admin` carries `setup.access` without
253+
* `manage_metadata` — D4-exempt (so it never had the round-trip hazard) but
254+
* refused here, which is consistent with its own declaration that a tenant
255+
* does not mutate shared metadata.
256+
*/
257+
it.each([
258+
{ held: 'no capabilities at all', systemPermissions: [] as string[], status: 403 },
259+
{ held: '`studio.access` alone — D4-exempt, but not an authoring capability', systemPermissions: ['studio.access'], status: 403 },
260+
{ held: '`setup.access` alone — likewise; this is `organization_admin`', systemPermissions: ['setup.access'], status: 403 },
261+
{ held: '`manage_metadata` alone', systemPermissions: ['manage_metadata'], status: 200 },
262+
{ held: 'the shipped `admin_full_access` shape', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], status: 200 },
263+
])('$held → $status', async ({ systemPermissions, status }) => {
264+
const stack = boot({ context: { userId: 'u1', systemPermissions } });
265+
const write = await stack.put({ name: 'account', label: 'Account', fields: {} });
266+
expect(write.res.statusCode).toBe(status);
267+
});
268+
});
269+
270+
describe('#6603 — the exempt authoring caller is unaffected', () => {
271+
/**
272+
* A GUARD, not evidence: this case is green both before and after the gate
273+
* (a platform admin could always write, and being D4-exempt their read was
274+
* never masked, so their round trip was never lossy). It is here so a
275+
* future tightening of the gate cannot silently lock the platform
276+
* administrator out of the console's own schema designer.
277+
*/
278+
it('an `admin_full_access`-shaped caller round-trips losslessly', async () => {
279+
const stack = boot({
280+
context: { userId: 'u_admin', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'] },
281+
readable: READABLE_TO_RESTRICTED, // the service would restrict — D4 exemption outranks it
282+
});
283+
284+
const read = await stack.get();
285+
// D4 — exempt callers are served the UNMASKED schema.
286+
expect(Object.keys(read.body.item.fields).sort()).toEqual(ALL_FIELDS);
287+
288+
const write = await stack.put({ ...copy(read.body.item), label: 'Account (renamed)' });
289+
expect(write.res.statusCode).toBe(200);
290+
expect(stack.storedFields()).toEqual(ALL_FIELDS);
291+
expect(stack.storedLabel()).toBe('Account (renamed)');
292+
});
293+
});

packages/rest/src/rest-4xx-message-truncation.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,8 @@ function setup(protocolOverrides: Record<string, unknown> = {}) {
203203
protocol,
204204
{ api: { requireAuth: false } } as any,
205205
);
206-
(rest as any).resolveExecCtx = async () => ({ userId: 'u1' });
206+
// [#6603] this route now demands `manage_metadata` — an authoring capability, not just a session.
207+
(rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: ['manage_metadata'] });
207208
rest.registerRoutes();
208209
return rest;
209210
}

packages/rest/src/rest-5xx-message-sanitization.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ function mountRest(protocol: any) {
9898
protocol,
9999
{ api: { requireAuth: false, enableBatch: true } } as any,
100100
);
101-
(rest as any).resolveExecCtx = async () => ({ userId: 'u1' });
101+
// [#6603] this route now demands `manage_metadata` — an authoring capability, not just a session.
102+
(rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: ['manage_metadata'] });
102103
rest.registerRoutes();
103104
return rest;
104105
}

packages/rest/src/rest-meta-save-receipt-envelope.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ async function boot() {
8787

8888
const protocol = new ObjectStackProtocolImplementation(engine as any);
8989
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
90-
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
90+
// [#6603] this route now demands `manage_metadata` — an authoring capability, not just a session.
91+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user', systemPermissions: ['manage_metadata'] });
9192
rest.registerRoutes();
9293
const route = rest.getRoutes()
9394
.find((r: any) => r.method === 'PUT' && r.path === '/api/v1/meta/:type/:name');

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,8 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
157157
{ route: 'GET /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItem',
158158
responseSchema: 'GetMetaItemResponseSchema',
159159
note: '[#5950] answers BARE, so the named schema is the whole body. Filled now that meta-item-layered-route.test.ts parses BOTH branches of this mount (cached and uncached) against it — the uncached branch carries the ADR-0010 protection envelope this schema newly declares' },
160-
{ route: 'PUT /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem' },
160+
{ route: 'PUT /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem',
161+
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' },
161162
{ route: 'DELETE /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.deleteItem',
162163
note: 'REST-only: the dispatcher /meta branch has no DELETE handling — it falls into the read path' },
163164
{ route: 'GET /api/v1/meta/:type/:name/history', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getHistory',

0 commit comments

Comments
 (0)