Skip to content

Commit 3fe1de3

Browse files
committed
feat(runtime,rest): /packages 域补齐授权门 —— 写要 manage_metadata、读要 D4 读集、全域匿名门,两 transport 各一份 (#7033) (#7023)
`/packages` 是最后一个零授权判据的路由域:普查实测一个 guest-principal 调用方对破坏性 discard-drafts、整包 export、GET /packages 枚举面与 publish-drafts 一律 200 并真的调进目标函数。按维护者 2026-08-09 裁定补齐,两 transport 各落一份门, 门都在协议/服务解析之前(拒绝时不写不删,防「先删后拒」,也防 501-vs-200 指纹)。 dispatcher 侧(runtime/domains/packages.ts): - shouldDenyAnonymous 作为 handlePackagesRequest 第一条语句(全域匿名门,401 在 registry 503 之前); - requireManageMetadata 门写/破坏性路由(install/enable/disable/publish/ publish-drafts/discard-drafts/commit-revert/rollback/revert/adopt-orphans/ duplicate/manifest-PATCH/DELETE); - requireReadCapability 门读路由(list/detail/commits/export),引用 OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES(studio.access/setup.access),不复制。 REST direct-mount 侧(rest/package-routes.ts refusePackageRequest,经 RestServer.resolvePackageRouteExecutionContext 与其余表面同一身份解析): - 覆盖 POST /publish、GET /、GET /:id、DELETE /:id 四条;缺 resolver 失败即关(401); - 该面 DECLARES 的是 wrapped BaseResponseSchema 信封,故 401/403 均经共享 sendError 发出(check:route-envelope 把本模块钉在零手写 body),不是 flat ANONYMOUS_DENY_BODY。 pin:dispatcher 每路由×每档(零能力 403 且目标 spy 未进、读/写 cohort 互不越界、 isSystem 旁路、匿名 401、discard-drafts 直接读库断言草稿未删)在 domains/packages-capability-gate.test.ts;REST 每路由×每档在 package-envelope.conformance.test.ts 的 `packages authz` describe。反向验证:两 transport 禁用门后其拒绝类 pin 全红(方向如预期)。authz-conformance matrix + probe + high-risk 加 anonymous-deny-packages 一行;dogfood showcase(dispatcher 服务 /packages,无 package 服务)加四条匿名 401 + member teeth。 盲区:cloud 仓未挂载(add_repo 两次被拒),调用方普查不覆盖该仓,门可能 403 掉未普查到的 cloud 调用方 —— 落地后需在 cloud 补一次普查。#7020 记录的门能力集≠D4 掩码豁免集对齐方向仍归维护者。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx
1 parent 3fc2e48 commit 3fe1de3

19 files changed

Lines changed: 965 additions & 43 deletions

.changeset/packages-authz-gate.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/runtime": minor
3+
"@objectstack/rest": minor
4+
---
5+
6+
feat(runtime,rest): `/packages` 域补齐授权门 —— 写/破坏性路由要求 `manage_metadata`,读路由要求 D4 读集,全域匿名门 (#7033) (#7023)
7+
8+
`/packages` 是最后一个零授权判据的路由域:普查实测一个连 `userId` 都没有(身份解析为
9+
`principalKind: 'guest'`)的调用方,对**破坏性**`POST /:id/discard-drafts`、整包
10+
`GET /:id/export`(27 种 metadata)、`GET /packages`(id 枚举面)与 `POST /:id/publish-drafts`
11+
一律得 **200** 并真的调进目标函数;而隔壁五个同族域(`/meta``/actions``/automation`
12+
`/ai``/security`)都带 `shouldDenyAnonymous` 匿名门。本次按维护者 2026-08-09 裁定补齐:
13+
14+
- **全域匿名门**:`shouldDenyAnonymous` 作为 `handlePackagesRequest`**第一条语句**,
15+
在 ObjectQL registry 探测之前,使匿名调用方拿不到 401-vs-503 的部署指纹。
16+
- **写 / 破坏性路由**(install / enable / disable / publish / publish-drafts /
17+
discard-drafts / commit-revert / rollback / revert / adopt-orphans / duplicate /
18+
manifest-PATCH / DELETE)要求 `manage_metadata` —— 与 #6603 / #7019`/meta` 写面
19+
落的同一道门、同一判据(「能写 schema 的人就该是能管理 package 的人」)。
20+
- **读路由**(list / detail / commits / export)要求 ADR-0106 D4 读集
21+
`OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES`(`studio.access` / `setup.access`)—— **引用
22+
该常量,不复制**,使 package 读取的能力集不会与 metadata 掩码豁免集漂移。
23+
- 门覆盖**两个 transport**:runtime dispatcher 域(`domains/packages.ts`)****
24+
`@objectstack/rest` 直挂注册器(`package-routes.ts``refusePackageRequest`,
25+
`RestServer.resolvePackageRouteExecutionContext` 解析与其余表面同一身份)。缺
26+
resolver 时 REST 侧**失败即关**(401),不留裸露回退。所有门都在协议/服务解析**之前**
27+
判,拒绝时不写不删(防「先删后拒」)。`isSystem`(不可从线上伪造)旁路,CORS `OPTIONS` 放行。
28+
29+
**盲区(明说,勿当已核):** `cloud` 仓在本会话与前序普查会话中**均未挂载**(`add_repo`
30+
两次被拒),调用方普查**不覆盖该仓**。若 `cloud` 内存在直打 `/api/v1/packages/*`
31+
dispatcher `/packages` 且今天不持 `manage_metadata` / D4 读集的生产调用方,本门可能将其
32+
403 —— 落地后需在 `cloud` 补一次调用方普查复核。`#7020` 记录的「门能力集 ≠ D4 掩码豁免集」
33+
对齐方向仍归维护者,本次不动。

packages/qa/dogfood/test/authz-conformance.matrix.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,20 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
106106
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
107107
covers: ['automation:domains/automation.ts:anonymous-gate'],
108108
note: 'Ungated, an anonymous caller could start real flow runs (`POST /:name/trigger`), read the full flow inventory (`GET /automation`), and DEREGISTER a registered flow (`DELETE /:name` → `{deleted:true}`) — the destructive one, which #5519 did not originally record. Gating the DOMAIN rather than each route is what keeps a newly added automation route from arriving ungated. Engine-internal triggers (record-change, schedule) never speak HTTP and are untouched.' },
109+
// #7033 / #7023 — the SIXTH dispatcher domain to join the baseline. `/packages`
110+
// was the last routed domain with ZERO authorization predicates: a survey drove
111+
// a guest-principal caller to a 200 on the DESTRUCTIVE `discard-drafts` and the
112+
// whole-package `export`. Unlike `/meta` (two separate handler bodies — REST +
113+
// dispatcher — which is why #6603's gate had to be re-added by #7019), every
114+
// `/packages` transport (the dispatcher-plugin explicit mounts, the hono
115+
// catch-all, and the legacy `HttpDispatcher.handlePackages` method) converges on
116+
// ONE handler body, `handlePackagesRequest`, so a single domain-wide gate there
117+
// covers them all.
118+
{ id: 'anonymous-deny-packages', summary: 'anonymous-deny on the package-management surface (#7033 / #7023)', state: 'enforced',
119+
enforcement: 'runtime/domains/packages.ts handlePackagesRequest — shouldDenyAnonymous DOMAIN-WIDE as the handler\'s FIRST statement, ahead of the ObjectQL registry probe so the 401-vs-503 difference cannot fingerprint whether the package service is mounted; per-route capability predicates run after this floor — `manage_metadata` for every state-changing route (install / enable / disable / publish / publish-drafts / discard-drafts / commit-revert / rollback / revert / adopt-orphans / duplicate / manifest-PATCH / DELETE), and the ADR-0106 D4 read set (`studio.access` / `setup.access`) for every read (list / detail / commits / export)',
120+
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
121+
covers: ['packages:domains/packages.ts:anonymous-gate'],
122+
note: 'Ungated, a guest-principal caller reached the whole domain: `GET /packages` (the id ENUMERATION face — first step of the chain), `GET /packages/:id/export` (27 metadata types read whole), and — destructively — `POST /packages/:id/discard-drafts` (drop every pending draft) and `POST /packages/:id/publish-drafts` (promote every draft to active + load seed rows + flip ADR-0045 visibility). Gating the DOMAIN rather than each route keeps a newly added package route from arriving ungated. Engine-internal / SDK internal calls never enter this HTTP handler. The per-route capability gates are unit-pinned in runtime/domains/packages-capability-gate.test.ts.' },
109123

110124
// ── #2992 / ADR-0096 D4 — latent execution surfaces (pre-wiring identity
111125
// admission). Neither surface is reachable by a client today; these rows

packages/qa/dogfood/test/authz-conformance.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,15 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray
6464
re: /shouldDenyAnonymous\s*\(/g,
6565
key: () => 'automation:domains/automation.ts:anonymous-gate',
6666
},
67+
// #7033 / #7023 — the /packages domain gate. Same GATE-pin shape: the key
68+
// exists only while `handlePackagesRequest` still consults
69+
// `shouldDenyAnonymous`. Delete the domain floor and the key vanishes → the
70+
// covering `anonymous-deny-packages` row goes STALE → red CI.
71+
{
72+
file: 'packages/runtime/src/domains/packages.ts',
73+
re: /shouldDenyAnonymous\s*\(/g,
74+
key: () => 'packages:domains/packages.ts:anonymous-gate',
75+
},
6776

6877
// Raw-hono standard /data routes — genuinely pattern-based: ANY new
6978
// `rawApp.<verb>(`${prefix}/data...`)` → a new key → CI fails until a row covers it.
@@ -175,6 +184,11 @@ const HIGH_RISK = [
175184
// surfaces proof (#5570).
176185
'anonymous-deny-actions',
177186
'anonymous-deny-automation',
187+
// #7033 / #7023 — the package-management surface guards destructive writes
188+
// (discard-drafts / publish-drafts / delete / rollback) and the whole-package
189+
// export/enumeration read face through a sibling dispatcher entry point, the
190+
// last routed domain that had no authorization at all.
191+
'anonymous-deny-packages',
178192
// #2948/#3003 — write-integrity face: without the strip, `readonly: true`
179193
// is false compliance (declared ≠ enforced) and approval/status columns are
180194
// one direct PATCH away from self-approval.

packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,47 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () =>
237237
expect(r.status, 'no @objectstack/service-automation is installed on this boot').toBe(501);
238238
});
239239

240+
// ── /packages (dispatcher-mounted; runtime domains/packages.ts) — #7033/#7023 ─
241+
//
242+
// The LAST routed domain to join the baseline. Like /automation, the gate is
243+
// DOMAIN-WIDE and sits ahead of the ObjectQL registry probe, so the 401 an
244+
// anonymous caller gets cannot be confused with the 503 "Package service not
245+
// available" the domain answers when no registry is present. The `:id` is a
246+
// deliberately non-existent package: the floor is the FIRST statement of
247+
// `handlePackagesRequest`, so an anonymous request is refused before any
248+
// package is looked up. These four were all measured answering 200 to a
249+
// guest-principal caller before the fix — two of them DESTRUCTIVE.
250+
it('anonymous GET /packages is denied (401) — the id enumeration face stays private', async () => {
251+
const r = await anon('GET', '/packages');
252+
expect(r.status, 'anonymous package listing must be 401').toBe(401);
253+
});
254+
255+
it('anonymous GET /packages/:id/export is denied (401)', async () => {
256+
const r = await anon('GET', '/packages/anon-probe-pkg/export');
257+
expect(r.status, 'anonymous package export must be 401').toBe(401);
258+
});
259+
260+
it('anonymous POST /packages/:id/discard-drafts is denied (401) — the destructive one', async () => {
261+
const r = await anon('POST', '/packages/anon-probe-pkg/discard-drafts', {});
262+
expect(r.status, 'anonymous draft discard must be 401').toBe(401);
263+
});
264+
265+
it('anonymous POST /packages/:id/publish-drafts is denied (401) — the #7023 route', async () => {
266+
const r = await anon('POST', '/packages/anon-probe-pkg/publish-drafts', {});
267+
expect(r.status, 'anonymous draft publish must be 401').toBe(401);
268+
});
269+
270+
it('an authenticated member reaches the packages domain — not 401 (the deny targets anonymity)', async () => {
271+
// Teeth for the anonymous cases above: the same route, with a session,
272+
// clears the auth floor. A plain member holds neither `studio.access` nor
273+
// `setup.access`, so the ADR-0106 D4 read gate then answers 403 — which is
274+
// exactly NOT 401, and proves the anonymous 401s are the floor's answer, not
275+
// the capability gate's (drop the floor and the anonymous cases collapse
276+
// onto the member's 403).
277+
const r = await stack.apiAs(memberToken, 'GET', '/packages');
278+
expect(r.status, 'authenticated package listing must clear the auth floor').not.toBe(401);
279+
});
280+
240281
// ── one code, one message — two wrappers ───────────────────────────────
241282
it('every denied surface answers the SAME code and message (the wrappers differ)', async () => {
242283
const rest = await Promise.all([
@@ -248,6 +289,8 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () =>
248289
anon('POST', `/automation/${FLOW}/trigger`, {}).then((r) => r.json()),
249290
anon('GET', '/automation').then((r) => r.json()),
250291
anon('DELETE', `/automation/${FLOW}`).then((r) => r.json()),
292+
anon('GET', '/packages').then((r) => r.json()),
293+
anon('POST', '/packages/anon-probe-pkg/discard-drafts', {}).then((r) => r.json()),
251294
]);
252295

253296
// Each family is read in ITS OWN declared shape — no `??` chain across the

packages/rest/src/direct-mount-base-follows-apipath.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,15 @@ describe('#6306 — with `apiPath` set, the direct-mount routes follow it', () =
226226

227227
const pkg = resolveRoute(table, 'GET', discovery.routes.packages);
228228
expect(pkg, 'the advertised packages URL must be mounted').toBeDefined();
229-
expect((await drive(pkg!)).statusCode).toBe(200);
229+
// [#7033 / #7023] `GET /packages` is now authz-gated, and this real plugin
230+
// boot wires the production caller resolver
231+
// (`RestServer.resolvePackageRouteExecutionContext`) with no auth service in
232+
// the ctx — so the anonymous discovery probe resolves to no identity and the
233+
// gate answers 401. That still proves the advertised URL is MOUNTED and its
234+
// handler runs at the moved base (a routing miss would have failed the
235+
// `toBeDefined()` above); the base-placement subject of this pin is unchanged.
236+
// The gate itself is pinned in `package-envelope.conformance.test.ts`.
237+
expect((await drive(pkg!)).statusCode).toBe(401);
230238

231239
const ext = resolveRoute(table, 'GET', `${discovery.routes.datasources}/pg_main/external/tables`);
232240
expect(ext, 'the advertised datasources base must be the base of the mounted family').toBeDefined();

packages/rest/src/direct-mount-composition.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ export interface DirectMountComposition {
5252
versionedBase: string;
5353
/** The `protocol` slice the package routes read registry packages through. */
5454
protocol?: PackageRoutesOptions['protocol'];
55+
/**
56+
* [#7033 / #7023] Resolves the caller's execution context for the package
57+
* routes' authorization gate — the `RestServer`'s own resolver, so the
58+
* capability check reads the same identity the rest of the surface does.
59+
*/
60+
resolveExecutionContext?: PackageRoutesOptions['resolveExecutionContext'];
5561
/** ADR-0006 project scoping — mirrors the package routes under the scoped base. */
5662
enableProjectScoping?: boolean;
5763
/** `'auto'` (both bases) or `'required'` (scoped only). */
@@ -63,7 +69,7 @@ export interface DirectMountComposition {
6369
* mounted on {@link DirectMountComposition.recorder}.
6470
*/
6571
export function mountAndRecordDirectRoutes(composition: DirectMountComposition): void {
66-
const { server, recorder, ctx, versionedBase, protocol } = composition;
72+
const { server, recorder, ctx, versionedBase, protocol, resolveExecutionContext } = composition;
6773
const enableProjectScoping = composition.enableProjectScoping ?? false;
6874
const projectResolution = composition.projectResolution ?? 'auto';
6975

@@ -80,7 +86,7 @@ export function mountAndRecordDirectRoutes(composition: DirectMountComposition):
8086
: [versionedBase];
8187
for (const base of bases) {
8288
recorder.recordDirectMountedRoutes(
83-
registerPackageRoutes(server, packageService, base, { protocol }),
89+
registerPackageRoutes(server, packageService, base, { protocol, resolveExecutionContext }),
8490
);
8591
}
8692
ctx.logger.info('Package management routes registered');

packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,17 @@ function boot(opts: {
135135
recorder: rest,
136136
ctx: ctx as any,
137137
versionedBase: opts.versionedBase,
138+
// [#7033 / #7023] The package routes now carry an authorization gate;
139+
// production wires its caller resolver here (via
140+
// `RestServer.resolvePackageRouteExecutionContext`). This parity test pins
141+
// mounted ⇒ advertised route PLACEMENT, not authz, so it stubs a capable
142+
// caller — the advertised `GET /packages` URL then ANSWERS 200 the way an
143+
// authorized caller reaches it in production, keeping this test's subject
144+
// (does the advertised URL resolve and answer in the mounted table) intact.
145+
// The gate itself is pinned in `package-envelope.conformance.test.ts`.
146+
resolveExecutionContext: async () => ({
147+
userId: 'u_pkg', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
148+
}),
138149
enableProjectScoping: opts.enableProjectScoping,
139150
projectResolution: opts.projectResolution,
140151
});

0 commit comments

Comments
 (0)