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
33 changes: 33 additions & 0 deletions .changeset/packages-authz-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@objectstack/runtime": minor
"@objectstack/rest": minor
---

feat(runtime,rest): `/packages` 域补齐授权门 —— 写/破坏性路由要求 `manage_metadata`,读路由要求 D4 读集,全域匿名门 (#7033) (#7023)

`/packages` 是最后一个零授权判据的路由域:普查实测一个连 `userId` 都没有(身份解析为
`principalKind: 'guest'`)的调用方,对**破坏性**的 `POST /:id/discard-drafts`、整包
`GET /:id/export`(27 种 metadata)、`GET /packages`(id 枚举面)与 `POST /:id/publish-drafts`
一律得 **200** 并真的调进目标函数;而隔壁五个同族域(`/meta`、`/actions`、`/automation`、
`/ai`、`/security`)都带 `shouldDenyAnonymous` 匿名门。本次按维护者 2026-08-09 裁定补齐:

- **全域匿名门**:`shouldDenyAnonymous` 作为 `handlePackagesRequest` 的**第一条语句**,
在 ObjectQL registry 探测之前,使匿名调用方拿不到 401-vs-503 的部署指纹。
- **写 / 破坏性路由**(install / enable / disable / publish / publish-drafts /
discard-drafts / commit-revert / rollback / revert / adopt-orphans / duplicate /
manifest-PATCH / DELETE)要求 `manage_metadata` —— 与 #6603 / #7019 给 `/meta` 写面
落的同一道门、同一判据(「能写 schema 的人就该是能管理 package 的人」)。
- **读路由**(list / detail / commits / export)要求 ADR-0106 D4 读集
`OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES`(`studio.access` / `setup.access`)—— **引用
该常量,不复制**,使 package 读取的能力集不会与 metadata 掩码豁免集漂移。
- 门覆盖**两个 transport**:runtime dispatcher 域(`domains/packages.ts`)**与**
`@objectstack/rest` 直挂注册器(`package-routes.ts` 的 `refusePackageRequest`,
经 `RestServer.resolvePackageRouteExecutionContext` 解析与其余表面同一身份)。缺
resolver 时 REST 侧**失败即关**(401),不留裸露回退。所有门都在协议/服务解析**之前**
判,拒绝时不写不删(防「先删后拒」)。`isSystem`(不可从线上伪造)旁路,CORS `OPTIONS` 放行。

**盲区(明说,勿当已核):** `cloud` 仓在本会话与前序普查会话中**均未挂载**(`add_repo`
两次被拒),调用方普查**不覆盖该仓**。若 `cloud` 内存在直打 `/api/v1/packages/*` 或
dispatcher `/packages` 且今天不持 `manage_metadata` / D4 读集的生产调用方,本门可能将其
403 —— 落地后需在 `cloud` 补一次调用方普查复核。`#7020` 记录的「门能力集 ≠ D4 掩码豁免集」
对齐方向仍归维护者,本次不动。
14 changes: 14 additions & 0 deletions packages/qa/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
covers: ['automation:domains/automation.ts:anonymous-gate'],
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.' },
// #7033 / #7023 — the SIXTH dispatcher domain to join the baseline. `/packages`
// was the last routed domain with ZERO authorization predicates: a survey drove
// a guest-principal caller to a 200 on the DESTRUCTIVE `discard-drafts` and the
// whole-package `export`. Unlike `/meta` (two separate handler bodies — REST +
// dispatcher — which is why #6603's gate had to be re-added by #7019), every
// `/packages` transport (the dispatcher-plugin explicit mounts, the hono
// catch-all, and the legacy `HttpDispatcher.handlePackages` method) converges on
// ONE handler body, `handlePackagesRequest`, so a single domain-wide gate there
// covers them all.
{ id: 'anonymous-deny-packages', summary: 'anonymous-deny on the package-management surface (#7033 / #7023)', state: 'enforced',
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)',
proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts',
covers: ['packages:domains/packages.ts:anonymous-gate'],
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.' },

// ── #2992 / ADR-0096 D4 — latent execution surfaces (pre-wiring identity
// admission). Neither surface is reachable by a client today; these rows
Expand Down
14 changes: 14 additions & 0 deletions packages/qa/dogfood/test/authz-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray
re: /shouldDenyAnonymous\s*\(/g,
key: () => 'automation:domains/automation.ts:anonymous-gate',
},
// #7033 / #7023 — the /packages domain gate. Same GATE-pin shape: the key
// exists only while `handlePackagesRequest` still consults
// `shouldDenyAnonymous`. Delete the domain floor and the key vanishes → the
// covering `anonymous-deny-packages` row goes STALE → red CI.
{
file: 'packages/runtime/src/domains/packages.ts',
re: /shouldDenyAnonymous\s*\(/g,
key: () => 'packages:domains/packages.ts:anonymous-gate',
},

// Raw-hono standard /data routes — genuinely pattern-based: ANY new
// `rawApp.<verb>(`${prefix}/data...`)` → a new key → CI fails until a row covers it.
Expand Down Expand Up @@ -175,6 +184,11 @@ const HIGH_RISK = [
// surfaces proof (#5570).
'anonymous-deny-actions',
'anonymous-deny-automation',
// #7033 / #7023 — the package-management surface guards destructive writes
// (discard-drafts / publish-drafts / delete / rollback) and the whole-package
// export/enumeration read face through a sibling dispatcher entry point, the
// last routed domain that had no authorization at all.
'anonymous-deny-packages',
// #2948/#3003 — write-integrity face: without the strip, `readonly: true`
// is false compliance (declared ≠ enforced) and approval/status columns are
// one direct PATCH away from self-approval.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,47 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () =>
expect(r.status, 'no @objectstack/service-automation is installed on this boot').toBe(501);
});

// ── /packages (dispatcher-mounted; runtime domains/packages.ts) — #7033/#7023 ─
//
// The LAST routed domain to join the baseline. Like /automation, the gate is
// DOMAIN-WIDE and sits ahead of the ObjectQL registry probe, so the 401 an
// anonymous caller gets cannot be confused with the 503 "Package service not
// available" the domain answers when no registry is present. The `:id` is a
// deliberately non-existent package: the floor is the FIRST statement of
// `handlePackagesRequest`, so an anonymous request is refused before any
// package is looked up. These four were all measured answering 200 to a
// guest-principal caller before the fix — two of them DESTRUCTIVE.
it('anonymous GET /packages is denied (401) — the id enumeration face stays private', async () => {
const r = await anon('GET', '/packages');
expect(r.status, 'anonymous package listing must be 401').toBe(401);
});

it('anonymous GET /packages/:id/export is denied (401)', async () => {
const r = await anon('GET', '/packages/anon-probe-pkg/export');
expect(r.status, 'anonymous package export must be 401').toBe(401);
});

it('anonymous POST /packages/:id/discard-drafts is denied (401) — the destructive one', async () => {
const r = await anon('POST', '/packages/anon-probe-pkg/discard-drafts', {});
expect(r.status, 'anonymous draft discard must be 401').toBe(401);
});

it('anonymous POST /packages/:id/publish-drafts is denied (401) — the #7023 route', async () => {
const r = await anon('POST', '/packages/anon-probe-pkg/publish-drafts', {});
expect(r.status, 'anonymous draft publish must be 401').toBe(401);
});

it('an authenticated member reaches the packages domain — not 401 (the deny targets anonymity)', async () => {
// Teeth for the anonymous cases above: the same route, with a session,
// clears the auth floor. A plain member holds neither `studio.access` nor
// `setup.access`, so the ADR-0106 D4 read gate then answers 403 — which is
// exactly NOT 401, and proves the anonymous 401s are the floor's answer, not
// the capability gate's (drop the floor and the anonymous cases collapse
// onto the member's 403).
const r = await stack.apiAs(memberToken, 'GET', '/packages');
expect(r.status, 'authenticated package listing must clear the auth floor').not.toBe(401);
});

// ── one code, one message — two wrappers ───────────────────────────────
it('every denied surface answers the SAME code and message (the wrappers differ)', async () => {
const rest = await Promise.all([
Expand All @@ -248,6 +289,8 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () =>
anon('POST', `/automation/${FLOW}/trigger`, {}).then((r) => r.json()),
anon('GET', '/automation').then((r) => r.json()),
anon('DELETE', `/automation/${FLOW}`).then((r) => r.json()),
anon('GET', '/packages').then((r) => r.json()),
anon('POST', '/packages/anon-probe-pkg/discard-drafts', {}).then((r) => r.json()),
]);

// Each family is read in ITS OWN declared shape — no `??` chain across the
Expand Down
10 changes: 9 additions & 1 deletion packages/rest/src/direct-mount-base-follows-apipath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,15 @@ describe('#6306 — with `apiPath` set, the direct-mount routes follow it', () =

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

const ext = resolveRoute(table, 'GET', `${discovery.routes.datasources}/pg_main/external/tables`);
expect(ext, 'the advertised datasources base must be the base of the mounted family').toBeDefined();
Expand Down
10 changes: 8 additions & 2 deletions packages/rest/src/direct-mount-composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ export interface DirectMountComposition {
versionedBase: string;
/** The `protocol` slice the package routes read registry packages through. */
protocol?: PackageRoutesOptions['protocol'];
/**
* [#7033 / #7023] Resolves the caller's execution context for the package
* routes' authorization gate — the `RestServer`'s own resolver, so the
* capability check reads the same identity the rest of the surface does.
*/
resolveExecutionContext?: PackageRoutesOptions['resolveExecutionContext'];
/** ADR-0006 project scoping — mirrors the package routes under the scoped base. */
enableProjectScoping?: boolean;
/** `'auto'` (both bases) or `'required'` (scoped only). */
Expand All @@ -63,7 +69,7 @@ export interface DirectMountComposition {
* mounted on {@link DirectMountComposition.recorder}.
*/
export function mountAndRecordDirectRoutes(composition: DirectMountComposition): void {
const { server, recorder, ctx, versionedBase, protocol } = composition;
const { server, recorder, ctx, versionedBase, protocol, resolveExecutionContext } = composition;
const enableProjectScoping = composition.enableProjectScoping ?? false;
const projectResolution = composition.projectResolution ?? 'auto';

Expand All @@ -80,7 +86,7 @@ export function mountAndRecordDirectRoutes(composition: DirectMountComposition):
: [versionedBase];
for (const base of bases) {
recorder.recordDirectMountedRoutes(
registerPackageRoutes(server, packageService, base, { protocol }),
registerPackageRoutes(server, packageService, base, { protocol, resolveExecutionContext }),
);
}
ctx.logger.info('Package management routes registered');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,17 @@ function boot(opts: {
recorder: rest,
ctx: ctx as any,
versionedBase: opts.versionedBase,
// [#7033 / #7023] The package routes now carry an authorization gate;
// production wires its caller resolver here (via
// `RestServer.resolvePackageRouteExecutionContext`). This parity test pins
// mounted ⇒ advertised route PLACEMENT, not authz, so it stubs a capable
// caller — the advertised `GET /packages` URL then ANSWERS 200 the way an
// authorized caller reaches it in production, keeping this test's subject
// (does the advertised URL resolve and answer in the mounted table) intact.
// The gate itself is pinned in `package-envelope.conformance.test.ts`.
resolveExecutionContext: async () => ({
userId: 'u_pkg', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
}),
enableProjectScoping: opts.enableProjectScoping,
projectResolution: opts.projectResolution,
});
Expand Down
Loading
Loading