From 7c34fb0e6d47ffcd23af774adcd72b92d6abaae6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 14:31:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(runtime,rest):=20/packages=20=E5=9F=9F?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=8E=88=E6=9D=83=E9=97=A8=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=86=99=E8=A6=81=20manage=5Fmetadata=E3=80=81?= =?UTF-8?q?=E8=AF=BB=E8=A6=81=20D4=20=E8=AF=BB=E9=9B=86=E3=80=81=E5=85=A8?= =?UTF-8?q?=E5=9F=9F=E5=8C=BF=E5=90=8D=E9=97=A8,=E4=B8=A4=20transport=20?= =?UTF-8?q?=E5=90=84=E4=B8=80=E4=BB=BD=20(#7033)=20(#7023)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/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 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- .changeset/packages-authz-gate.md | 33 ++ .../dogfood/test/authz-conformance.matrix.ts | 14 + .../qa/dogfood/test/authz-conformance.test.ts | 14 + ...se-anonymous-deny-surfaces.dogfood.test.ts | 43 +++ .../direct-mount-base-follows-apipath.test.ts | 10 +- packages/rest/src/direct-mount-composition.ts | 10 +- ...ry-advertised-direct-mounts.parity.test.ts | 11 + .../src/package-envelope.conformance.test.ts | 166 ++++++++- .../package-routes-query-multiplicity.test.ts | 14 +- packages/rest/src/package-routes.ts | 85 ++++- packages/rest/src/rest-api-plugin.ts | 3 + packages/rest/src/rest-server.ts | 15 + .../dispatcher-validation-error.real.test.ts | 11 +- .../src/dispatcher-validation-error.test.ts | 15 +- .../src/domain-handler-registry.test.ts | 23 +- .../src/domains/error-passthrough.test.ts | 17 +- .../domains/packages-capability-gate.test.ts | 340 ++++++++++++++++++ packages/runtime/src/domains/packages.ts | 112 ++++++ packages/runtime/src/http-dispatcher.test.ts | 72 ++-- 19 files changed, 965 insertions(+), 43 deletions(-) create mode 100644 .changeset/packages-authz-gate.md create mode 100644 packages/runtime/src/domains/packages-capability-gate.test.ts diff --git a/.changeset/packages-authz-gate.md b/.changeset/packages-authz-gate.md new file mode 100644 index 0000000000..33eda02701 --- /dev/null +++ b/.changeset/packages-authz-gate.md @@ -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 掩码豁免集」 +对齐方向仍归维护者,本次不动。 diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index a543bc8daa..2c852ea507 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -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 diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index a29db19a86..af3df295b8 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -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.(`${prefix}/data...`)` → a new key → CI fails until a row covers it. @@ -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. diff --git a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts index 50f2695133..abd5942f76 100644 --- a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts @@ -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([ @@ -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 diff --git a/packages/rest/src/direct-mount-base-follows-apipath.test.ts b/packages/rest/src/direct-mount-base-follows-apipath.test.ts index acd9e1c12a..da9629e13e 100644 --- a/packages/rest/src/direct-mount-base-follows-apipath.test.ts +++ b/packages/rest/src/direct-mount-base-follows-apipath.test.ts @@ -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(); diff --git a/packages/rest/src/direct-mount-composition.ts b/packages/rest/src/direct-mount-composition.ts index 38566c0746..0406675f92 100644 --- a/packages/rest/src/direct-mount-composition.ts +++ b/packages/rest/src/direct-mount-composition.ts @@ -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). */ @@ -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'; @@ -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'); diff --git a/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts b/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts index c475147c04..7e2d2d45c2 100644 --- a/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts +++ b/packages/rest/src/discovery-advertised-direct-mounts.parity.test.ts @@ -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, }); diff --git a/packages/rest/src/package-envelope.conformance.test.ts b/packages/rest/src/package-envelope.conformance.test.ts index 8cc0702df0..8aec8621ca 100644 --- a/packages/rest/src/package-envelope.conformance.test.ts +++ b/packages/rest/src/package-envelope.conformance.test.ts @@ -35,7 +35,7 @@ * spread — which is what makes `unwrapResponse` able to return it. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api'; import type { RouteHandler } from '@objectstack/spec/contracts'; import { registerPackageRoutes } from './package-routes.js'; @@ -67,7 +67,17 @@ function mount(svc: Svc, options: any = {}) { listen: async () => {}, close: async () => {}, } as any; - registerPackageRoutes(server, svc as any, '/api/v1', options); + // [#7033 / #7023] The package routes now carry an authorization gate. These + // envelope cases are about RESPONSE SHAPE, so the caller is stubbed to clear + // the gate (holding both the read and write capability); a test can override + // `resolveExecutionContext` to exercise the gate itself. The gate itself is + // pinned in the `packages authz` describe at the bottom of this file. + registerPackageRoutes(server, svc as any, '/api/v1', { + resolveExecutionContext: async () => ({ + userId: 'u_pkg', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + }), + ...options, + }); return routes; } @@ -387,3 +397,155 @@ describe('packages envelope (#3843) — error bodies', () => { }); }); +// ══════════════════════════════════════════════════════════════════════════════ +// packages authz (#7033 / #7023) — the REST TRANSPORT's own gate +// ══════════════════════════════════════════════════════════════════════════════ +// +// `/packages` has TWO HTTP transports: the runtime dispatcher domain +// (`runtime/src/domains/packages.ts`, pinned in `packages-capability-gate.test.ts`) +// AND this `@objectstack/rest` direct-mount registrar — a SEPARATE handler body +// which, in the production stack, registers FIRST, so for the four routes it +// declares (`POST /publish`, `GET /`, `GET /:id`, `DELETE /:id`) it is the +// transport production actually serves. Its gate (`refusePackageRequest`) +// therefore needs its OWN pins — gating only the dispatcher would leave these +// four open, the exact one-transport gap #6603/#7019 paid for on `/meta`. +// +// Reverse check for this block: delete `refusePackageRequest`'s body and every +// case below goes red. The `mount` helper above injects a gate-clearing caller +// (the envelope suites want response SHAPE, not the gate), so these cases mount +// their OWN resolver — anonymous, zero-cap, wrong-cohort, right-cohort, system, +// and the fail-closed (no resolver) path. +// +// Rejections assert `status` AND `code` (ADR-0112). This direct-mount surface +// DECLARES the wrapped BaseResponseSchema envelope — every body goes through +// sendOk/sendError, and `check:route-envelope` pins the module at zero +// hand-written bodies — so BOTH refusals are wrapped: the 401 anonymous floor is +// `{ success:false, error:{ code:'UNAUTHENTICATED' } }` and the 403 capability +// refusal `{ success:false, error:{ code:'FORBIDDEN' } }` (the sibling `/meta` +// REST gate's code, in this surface's own wrapper). +describe('packages authz (#7033 / #7023) — REST transport gate', () => { + /** A service whose four methods are spies, so "the target never ran" is an + * assertion, not an inference. */ + const spySvc = () => ({ + publish: vi.fn(async () => ({ success: true })), + list: vi.fn(async () => [{ id: 'com.acme.crm', manifest: MANIFEST }]), + get: vi.fn(async () => ({ id: 'com.acme.crm', manifest: MANIFEST })), + delete: vi.fn(async () => ({ success: true })), + }); + /** Mount with an EXPLICIT resolver (pass `undefined` to prove fail-closed). */ + const gated = (svc: any, resolver: any) => mount(svc, { resolveExecutionContext: resolver }); + const asCaller = (caps: string[]) => async () => ({ userId: 'u_portal', systemPermissions: caps }); + const asSystem = () => async () => ({ isSystem: true }); + const anonResolver = async () => undefined; // guest — no identity resolved at all + + type RouteShape = { method: string; path: string; body?: any; req?: Record; spy: (s: any) => any }; + const PUBLISH: RouteShape = { method: 'POST', path: `${PKGS}/publish`, body: { manifest: MANIFEST, metadata: { author: 'acme' } }, spy: (s) => s.publish }; + const DELETE_ONE: RouteShape = { method: 'DELETE', path: `${PKGS}/:id`, req: { params: { id: 'com.acme.crm' }, query: { version: '1.0.0' } }, spy: (s) => s.delete }; + const LIST: RouteShape = { method: 'GET', path: PKGS, spy: (s) => s.list }; + const GET_ONE: RouteShape = { method: 'GET', path: `${PKGS}/:id`, req: { params: { id: 'com.acme.crm' } }, spy: (s) => s.get }; + const WRITE = [PUBLISH, DELETE_ONE]; + const READ = [LIST, GET_ONE]; + const label = (r: RouteShape) => `${r.method} ${r.path}`; + const reqOf = (r: RouteShape) => ({ ...(r.body !== undefined ? { body: r.body } : {}), ...(r.req ?? {}) }); + + // ── the domain-wide anonymous floor — every route, both cohorts ── + for (const r of [...WRITE, ...READ]) { + it(`401s an anonymous ${label(r)} (UNAUTHENTICATED) and never touches the service`, async () => { + const svc = spySvc(); + const c = await drive(gated(svc, anonResolver), r.method, r.path, reqOf(r)); + expect(c.status).toBe(401); + // Wrapped BaseResponseSchema envelope — this surface's declared shape. + expect(c.body.success).toBe(false); + expect(c.body.error.code).toBe('UNAUTHENTICATED'); + expect(r.spy(svc)).not.toHaveBeenCalled(); + }); + + it(`fails CLOSED (401), not open, when no resolver is wired on ${label(r)}`, async () => { + const svc = spySvc(); + const c = await drive(gated(svc, undefined), r.method, r.path, reqOf(r)); + expect(c.status).toBe(401); + expect(r.spy(svc)).not.toHaveBeenCalled(); + }); + } + + // ── write cohort: `manage_metadata`, exactly the /meta write key ── + for (const r of WRITE) { + it(`403s a zero-capability caller on ${label(r)} (nested FORBIDDEN) and the service never runs`, async () => { + const svc = spySvc(); + const c = await drive(gated(svc, asCaller([])), r.method, r.path, reqOf(r)); + expect(c.status).toBe(403); + expect(c.body.error.code).toBe('FORBIDDEN'); + expect(c.body.error.message).toContain('manage_metadata'); + expect(r.spy(svc)).not.toHaveBeenCalled(); + }); + + it(`403s a READ-only caller (studio.access + setup.access) on ${label(r)} — the write key is a different set`, async () => { + const svc = spySvc(); + const c = await drive(gated(svc, asCaller(['studio.access', 'setup.access'])), r.method, r.path, reqOf(r)); + expect(c.status).toBe(403); + expect(c.body.error.code).toBe('FORBIDDEN'); + expect(r.spy(svc)).not.toHaveBeenCalled(); + }); + + it(`lets a manage_metadata caller through on ${label(r)}`, async () => { + const svc = spySvc(); + const c = await drive(gated(svc, asCaller(['manage_metadata'])), r.method, r.path, reqOf(r)); + expect(c.status).not.toBe(403); + expect(c.status).not.toBe(401); + expect(r.spy(svc)).toHaveBeenCalled(); + }); + + it(`lets an isSystem caller through on ${label(r)}`, async () => { + const svc = spySvc(); + const c = await drive(gated(svc, asSystem()), r.method, r.path, reqOf(r)); + expect(c.status).not.toBe(403); + expect(c.status).not.toBe(401); + expect(r.spy(svc)).toHaveBeenCalled(); + }); + } + + // ── read cohort: the ADR-0106 D4 set `studio.access` / `setup.access` ── + for (const r of READ) { + it(`403s a zero-capability caller on ${label(r)} (nested FORBIDDEN) and the service never runs`, async () => { + const svc = spySvc(); + const c = await drive(gated(svc, asCaller([])), r.method, r.path, reqOf(r)); + expect(c.status).toBe(403); + expect(c.body.error.code).toBe('FORBIDDEN'); + expect(c.body.error.message).toContain('studio.access'); + expect(r.spy(svc)).not.toHaveBeenCalled(); + }); + + it(`403s a WRITE-only caller (manage_metadata) on ${label(r)} — the read cohort is a different set`, async () => { + const svc = spySvc(); + const c = await drive(gated(svc, asCaller(['manage_metadata'])), r.method, r.path, reqOf(r)); + expect(c.status).toBe(403); + expect(c.body.error.code).toBe('FORBIDDEN'); + expect(r.spy(svc)).not.toHaveBeenCalled(); + }); + + it(`lets a studio.access caller read ${label(r)}`, async () => { + const svc = spySvc(); + const c = await drive(gated(svc, asCaller(['studio.access'])), r.method, r.path, reqOf(r)); + expect(c.status).not.toBe(403); + expect(c.status).not.toBe(401); + expect(r.spy(svc)).toHaveBeenCalled(); + }); + + it(`lets a setup.access caller read ${label(r)}`, async () => { + const svc = spySvc(); + const c = await drive(gated(svc, asCaller(['setup.access'])), r.method, r.path, reqOf(r)); + expect(c.status).not.toBe(403); + expect(c.status).not.toBe(401); + expect(r.spy(svc)).toHaveBeenCalled(); + }); + + it(`lets an isSystem caller read ${label(r)}`, async () => { + const svc = spySvc(); + const c = await drive(gated(svc, asSystem()), r.method, r.path, reqOf(r)); + expect(c.status).not.toBe(403); + expect(c.status).not.toBe(401); + expect(r.spy(svc)).toHaveBeenCalled(); + }); + } +}); + diff --git a/packages/rest/src/package-routes-query-multiplicity.test.ts b/packages/rest/src/package-routes-query-multiplicity.test.ts index 7665400d57..67193d31ad 100644 --- a/packages/rest/src/package-routes-query-multiplicity.test.ts +++ b/packages/rest/src/package-routes-query-multiplicity.test.ts @@ -84,7 +84,19 @@ function harness(options: { protocol?: boolean } = {}) { listen: async () => {}, close: async () => {}, } as any; - registerPackageRoutes(server, svc as any, '/api/v1', opts); + // [#7033 / #7023] The package routes now carry an authorization gate that runs + // BEFORE the `?version=` multiplicity check these cases pin. GET is a read + // route and DELETE a write route, so the caller is stubbed to hold BOTH the + // read set (`studio.access` / `setup.access`) and the write key + // (`manage_metadata`) — every case here then reaches the multiplicity rule it + // is named after. The gate itself is pinned in + // `package-envelope.conformance.test.ts`'s `packages authz` describe. + registerPackageRoutes(server, svc as any, '/api/v1', { + resolveExecutionContext: async () => ({ + userId: 'u_pkg', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + }), + ...opts, + }); const drive = async (method: 'GET' | 'DELETE', query: Record): Promise => { const handler = routes.get(`${method}:${PKGS}/:id`); diff --git a/packages/rest/src/package-routes.ts b/packages/rest/src/package-routes.ts index 4cd9dc777e..324ed66006 100644 --- a/packages/rest/src/package-routes.ts +++ b/packages/rest/src/package-routes.ts @@ -1,11 +1,77 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { IHttpServer } from '@objectstack/core'; +import { IHttpServer, shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE } from '@objectstack/core'; +import { OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES } from '@objectstack/metadata-core'; import type { PackageService } from '@objectstack/service-package'; // The declared envelope is written in ONE place for the whole platform (#3973). import { sendOk, sendError } from '@objectstack/types'; import { mountDirectRoutes, type DirectMountedRoute } from './direct-mount.js'; +/** + * [#7033 / #7023] The authorization gate for the REST package transport. + * + * `/packages` had TWO HTTP transports and both were ungated: the runtime + * dispatcher domain (`packages/runtime/src/domains/packages.ts`) AND this + * `@objectstack/rest` direct-mount registrar — which registers FIRST in the + * production stack (first-match-wins, see the module note above), so for the + * three routes both declare (`GET /packages`, `GET /packages/:id`, + * `DELETE /packages/:id`) THIS transport is the one production actually serves. + * Gating only the dispatcher would leave those routes open — the exact + * one-transport gap #6603/#7019 paid for on `/meta`. + * + * Same ruled policy as the dispatcher (maintainer, 2026-08-09): a domain-wide + * anonymous floor, `manage_metadata` for state-changing routes + * (`POST /packages/publish`, `DELETE /packages/:id`), and the ADR-0106 D4 read + * set (`studio.access` / `setup.access`) for reads (`GET /packages`, + * `GET /packages/:id`). The public MARKETPLACE browse is a different surface + * (`/marketplace/packages`, MarketplaceProxyPlugin) — these `/api/v1/packages` + * routes are management, so denying anonymous here strands no public browse. + * + * The caller context is resolved through {@link PackageRoutesOptions.resolveExecutionContext}, + * which the composition wires to the `RestServer`'s own resolver (the SAME + * resolution the `/meta` REST gate uses). When it is absent the gate FAILS + * CLOSED (401) rather than open — an ungated fallback is the very hole this + * closes. `isSystem` is never settable from the wire; CORS `OPTIONS` passes. + * + * Returns `true` when the response was already sent (the caller must `return`). + */ +async function refusePackageRequest( + options: PackageRoutesOptions, + req: any, + res: any, + kind: 'read' | 'write', +): Promise { + const ctx = options.resolveExecutionContext + ? await options.resolveExecutionContext(req).catch(() => undefined) + : undefined; + // Anonymous-deny floor. This direct-mount surface DECLARES the wrapped + // BaseResponseSchema envelope — every other body here goes through + // sendOk/sendError, and `check:route-envelope` pins this module at ZERO + // hand-written bodies — so the 401 is emitted through the SAME shared + // `sendError`, not the flat `ANONYMOUS_DENY_BODY` the `/data`+`/meta` + // `enforceAuth` seam writes. The shared DECISION (`shouldDenyAnonymous`) and + // semantics (status / code / message) are reused; only the wrapper is this + // surface's own (ADR-0112's two live envelopes, read per the seam you called). + // `isSystem` is never settable from the wire; CORS `OPTIONS` passes. + if (shouldDenyAnonymous({ userId: ctx?.userId, isSystem: ctx?.isSystem, method: req?.method })) { + sendError(res, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE); + return true; + } + const held = new Set(Array.isArray(ctx?.systemPermissions) ? ctx.systemPermissions : []); + const allowed = ctx?.isSystem || (kind === 'write' + ? held.has('manage_metadata') + : OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES.some((c) => held.has(c))); + if (!allowed) { + // Same wrapped envelope, one FORBIDDEN code, message per cohort — the sibling + // `/meta` REST capability gate's shape, built through the shared `sendError`. + sendError(res, 403, 'FORBIDDEN', kind === 'write' + ? 'Managing packages requires the `manage_metadata` capability.' + : 'Reading packages requires the `studio.access` or `setup.access` capability.'); + return true; + } + return false; +} + /** * The outcome of reading a query parameter that this API declares as * single-valued. `ok: false` carries the multiplicity so the refusal can say @@ -89,6 +155,19 @@ export interface PackageRoutesOptions { cleanups: Array<{ name: string; success: boolean; removed: number; error?: string }>; }>; }; + /** + * [#7033 / #7023] Resolve the caller's execution context for a package route + * request. Wired by the composition to the `RestServer`'s own resolver (the + * SAME identity/RBAC resolution the `/meta` REST gate uses), so the capability + * gate here reads the same `systemPermissions` the rest of the surface does. + * Absent ⇒ the gate fails CLOSED (401). Never resolves an `isSystem` context + * from inbound HTTP. + */ + resolveExecutionContext?: (req: any) => Promise<{ + userId?: string | null; + isSystem?: boolean; + systemPermissions?: string[]; + } | undefined>; } /** @@ -167,6 +246,7 @@ export function registerPackageRoutes( metadata: { summary: 'Publish a package to the marketplace registry', tags: ['packages'] }, handler: async (req, res) => { try { + if (await refusePackageRequest(options, req, res, 'write')) return; const { manifest, metadata } = req.body || {}; if (!manifest || !metadata) { @@ -206,6 +286,7 @@ export function registerPackageRoutes( metadata: { summary: 'List packages (registry + published)', tags: ['packages'] }, handler: async (_req, res) => { try { + if (await refusePackageRequest(options, _req, res, 'read')) return; // Merge two sources: // 1. Registry packages (in-memory, loaded at boot via defineStack/AppPlugin) // 2. Database packages (published via POST /packages) @@ -264,6 +345,7 @@ export function registerPackageRoutes( metadata: { summary: 'Get a package by id', tags: ['packages'] }, handler: async (req, res) => { try { + if (await refusePackageRequest(options, req, res, 'read')) return; const packageId = req.params.id; const requested = readSingleQueryValue(req.query?.version); if (!requested.ok) { @@ -309,6 +391,7 @@ export function registerPackageRoutes( metadata: { summary: 'Delete a package', tags: ['packages'] }, handler: async (req, res) => { try { + if (await refusePackageRequest(options, req, res, 'write')) return; const packageId = req.params.id; // Refused BEFORE the branch below, because the branch below is exactly // what a repeated `?version=` silently changed (#6307): the truthiness of diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index 54905236dc..191bf31a56 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -409,6 +409,9 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { ctx, versionedBase, protocol, + // [#7033 / #7023] The package routes' authorization gate reads + // the SAME identity resolution the rest of the surface does. + resolveExecutionContext: (req) => restServer.resolvePackageRouteExecutionContext(req), enableProjectScoping, projectResolution, }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 7e24f0354b..aed1d2eb7a 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2345,6 +2345,21 @@ export class RestServer { return pending; } + /** + * [#7033 / #7023] Resolve a caller's execution context for a DIRECT-MOUNT + * package route (`@objectstack/rest`'s `registerPackageRoutes`), which does + * not run inside a `registerXxxEndpoints` handler and so cannot reach the + * private {@link resolveExecCtx} on its own. The package gate reads the + * SAME identity/RBAC resolution the `/meta` REST gate does — never a second + * source — so the two capability cohorts cannot drift. `environmentId` comes + * from the scoped route param (`/environments/:environmentId/packages`) when + * present, `undefined` for the unscoped mount. + */ + resolvePackageRouteExecutionContext(req: any): Promise { + const environmentId = req?.params?.environmentId ?? undefined; + return this.resolveExecCtx(environmentId, req).catch(() => undefined); + } + /** * [ADR-0046 §6.7] The audience-evaluation view of the caller for book/doc * gating. `permissionSets` resolves through the security service's diff --git a/packages/runtime/src/dispatcher-validation-error.real.test.ts b/packages/runtime/src/dispatcher-validation-error.real.test.ts index 39be9f6930..ed09d544de 100644 --- a/packages/runtime/src/dispatcher-validation-error.real.test.ts +++ b/packages/runtime/src/dispatcher-validation-error.real.test.ts @@ -62,7 +62,16 @@ async function publishDrafts(thrown: unknown) { const resolve = (name: string) => name === 'protocol' ? protocol : name === 'objectql' ? objectql : undefined; const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n) }; - const result: any = await new HttpDispatcher(kernel).dispatch( + const dispatcher = new HttpDispatcher(kernel); + // [#7033 / #7023] publish-drafts now demands `manage_metadata` on top of the + // anonymous floor. This suite pins the ERROR-MAPPING exit (a real + // ValidationError → 400 + fields[]), so the caller must clear the write gate; + // otherwise every case stops at the 401/403. Only the caller's capability is + // stubbed — the mapping under test is unchanged. + (dispatcher as any).timedResolveExecutionContext = async () => ({ + userId: 'u1', systemPermissions: ['manage_metadata'], + }); + const result: any = await dispatcher.dispatch( 'POST', '/packages/demo/publish-drafts', {}, {}, {} as any, ); return result.response; diff --git a/packages/runtime/src/dispatcher-validation-error.test.ts b/packages/runtime/src/dispatcher-validation-error.test.ts index 56a4d0bca3..e5286e24cb 100644 --- a/packages/runtime/src/dispatcher-validation-error.test.ts +++ b/packages/runtime/src/dispatcher-validation-error.test.ts @@ -71,7 +71,20 @@ function makeDispatcher(publishError: unknown) { getService: resolve, getServiceAsync: async (name: string) => resolve(name), }; - return new HttpDispatcher(kernel); + const dispatcher = new HttpDispatcher(kernel); + // [#7033 / #7023] `POST /packages/:id/publish-drafts` now demands the + // `manage_metadata` capability on top of the anonymous floor. These cases + // are about ERROR MAPPING (a returned VALIDATION_FAILED becomes a 400 with + // fields[], an ordinary throw keeps its 500), so the caller must clear the + // write gate to reach the mapping under test — otherwise every case stops at + // the 401/403 gate. `dispatch()` re-resolves the context and this stub has no + // auth/objectql identity source, so the resolved caller carries no + // capabilities; only that is stubbed, the mapping and expected statuses are + // unchanged. + (dispatcher as any).timedResolveExecutionContext = async () => ({ + userId: 'u1', systemPermissions: ['manage_metadata'], + }); + return dispatcher; } async function publishPackage(publishError: unknown) { diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index ae65c94527..f06bd64cf7 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -506,22 +506,37 @@ describe('HttpDispatcher extracted domains (PR-5: packages)', () => { }; } + // [#7033 / #7023] `/packages` now carries an anonymous-deny floor plus + // per-route capability predicates. These cases are about ROUTING and the + // registry pre-check (which status a duplicate / missing-id / no-registry + // answer returns), so the caller must clear the gate; `dispatch()` re-resolves + // identity off the mock kernel, which has no capability source, so it is + // stubbed to a caller holding both the read and write capability. (The 503 + // pre-check runs before the capability gates, so it needs only a session — + // this caller provides that too.) + const withPkgCaller = (d: HttpDispatcher) => { + (d as any).timedResolveExecutionContext = async () => ({ + userId: 'u_pkg', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + }); + return d; + }; + it('GET /packages lists packages from the ObjectQL registry', async () => { const objectql = qlWithRegistry(); - const result = await makeDispatcher({ objectql }).dispatch('GET', '/packages', undefined, {}, {} as any); + const result = await withPkgCaller(makeDispatcher({ objectql })).dispatch('GET', '/packages', undefined, {}, {} as any); expect(result.response?.status).toBe(200); expect(result.response?.body?.data?.total).toBe(1); }); it('responds 503 when no ObjectQL registry is available', async () => { const objectql = { find: vi.fn(), getObjects: vi.fn() }; // no .registry → getObjectQL returns null - const result = await makeDispatcher({ objectql }).dispatch('GET', '/packages', undefined, {}, {} as any); + const result = await withPkgCaller(makeDispatcher({ objectql })).dispatch('GET', '/packages', undefined, {}, {} as any); expect(result.response?.status).toBe(503); }); it('POST /packages rejects a duplicate id with 409 unless ?overwrite=true (data-loss footgun guard)', async () => { const objectql = qlWithRegistry({ getPackage: vi.fn().mockReturnValue({ id: 'pkg-a' }) }); - const dispatcher = makeDispatcher({ objectql }); + const dispatcher = withPkgCaller(makeDispatcher({ objectql })); const dup = await dispatcher.dispatch('POST', '/packages', { id: 'pkg-a', name: 'A' }, {}, {} as any); expect(dup.response?.status).toBe(409); const forced = await dispatcher.dispatch('POST', '/packages', { id: 'pkg-a', name: 'A' }, { overwrite: 'true' }, {} as any); @@ -530,7 +545,7 @@ describe('HttpDispatcher extracted domains (PR-5: packages)', () => { it('POST /packages without an id is rejected with 400', async () => { const objectql = qlWithRegistry(); - const result = await makeDispatcher({ objectql }).dispatch('POST', '/packages', { name: 'no-id' }, {}, {} as any); + const result = await withPkgCaller(makeDispatcher({ objectql })).dispatch('POST', '/packages', { name: 'no-id' }, {}, {} as any); expect(result.response?.status).toBe(400); }); }); diff --git a/packages/runtime/src/domains/error-passthrough.test.ts b/packages/runtime/src/domains/error-passthrough.test.ts index cc26bc46fb..1afc305d27 100644 --- a/packages/runtime/src/domains/error-passthrough.test.ts +++ b/packages/runtime/src/domains/error-passthrough.test.ts @@ -71,7 +71,22 @@ async function dispatchWith(method: string, path: string, err: unknown, body: an getService: resolve, getServiceAsync: async (name: string) => resolve(name), }; - const result: any = await new HttpDispatcher(kernel).dispatch(method, path, body, {}, {} as any); + const dispatcher = new HttpDispatcher(kernel); + // [#7033 / #7023] `/packages` now carries per-route capability predicates on + // top of the anonymous floor: `manage_metadata` for the PATCH write path and + // the `studio.access` / `setup.access` read set for the GET commits path. + // These cases are about ERROR MAPPING (does a domain 404 / a ValidationError + // 400 / an ordinary 500 survive), so the caller must clear the gate to reach + // the error path each one pins. `dispatch()` re-resolves the 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 mapping under test. Only the caller's capabilities are stubbed; + // every mechanism and expected status below is unchanged. (The /meta and /ui + // routes in the loop need no capability, and holding extra ones never rejects.) + (dispatcher as any).timedResolveExecutionContext = async () => ({ + userId: 'u1', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + }); + const result: any = await dispatcher.dispatch(method, path, body, {}, {} as any); return result.response; } diff --git a/packages/runtime/src/domains/packages-capability-gate.test.ts b/packages/runtime/src/domains/packages-capability-gate.test.ts new file mode 100644 index 0000000000..e16700d08b --- /dev/null +++ b/packages/runtime/src/domains/packages-capability-gate.test.ts @@ -0,0 +1,340 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/packages` — authorization posture (#7033 / #7023). + * + * The `/packages` domain shipped with ZERO authorization predicates: a survey + * drove a guest-principal caller to a 200 on the DESTRUCTIVE `discard-drafts` + * and the whole-package `export`, while its five sibling dispatcher domains + * (/meta, /actions, /automation, /ai, /security) all carried the + * anonymous-deny floor. The maintainer's 2026-08-09 ruling: a domain-wide + * anonymous floor, `manage_metadata` on every state-changing route (mirroring + * #6603 / #7019 on `/meta` writes), and the ADR-0106 D4 read set + * (`studio.access` / `setup.access`) on every read. + * + * ## One handler, every transport + * + * Unlike `/meta` — whose REST and dispatcher transports are two SEPARATE handler + * bodies, which is why #6603's REST gate had to be re-added on the dispatcher + * side by #7019 — every `/packages` transport (the dispatcher-plugin explicit + * mounts, the `@objectstack/hono` catch-all, and the legacy + * `HttpDispatcher.handlePackages` method) converges on ONE body, + * `handlePackagesRequest`. So the gate placed there covers them all, and driving + * `handlePackages()` here exercises the identical gate the HTTP path runs. + * + * The driver is the one the sibling gate tests use + * (`meta-migrate-stored.test.ts`, `anonymous-gate-actions-automation.test.ts`): + * `HttpDispatcher` over a fake kernel whose services are `vi.fn()` doubles, so + * "the target function never ran" is an assertion, not an inference. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +// ── caller contexts ────────────────────────────────────────────────────────── +const ctx = (executionContext?: any): any => ({ + request: {}, + environmentId: 'platform', + ...(executionContext ? { executionContext } : {}), +}); +/** No identity resolved at all — the `principalKind: 'guest'` shape. */ +const anon = () => ctx(); +/** Identity resolved but sessionless (no `userId`) — also anonymous. */ +const anonResolved = () => ctx({ isSystem: false, positions: [], permissions: [], systemPermissions: [] }); +/** Authenticated, holding exactly `caps`. */ +const authed = (caps: string[] = []) => ctx({ userId: 'u_portal', isSystem: false, systemPermissions: caps }); +/** Engine self-invocation — never settable from the wire. */ +const system = () => ctx({ isSystem: true }); + +// ── fake kernel ────────────────────────────────────────────────────────────── +function make(overrides: { protocol?: any; metadata?: any; registry?: any } = {}) { + const registry = overrides.registry ?? { + getAllPackages: vi.fn().mockReturnValue([{ id: 'pkg-a', status: 'active' }]), + getPackage: vi.fn().mockReturnValue({ id: 'pkg-a', manifest: { id: 'pkg-a', name: 'A' } }), + installPackage: vi.fn().mockImplementation((m: any) => ({ id: m.id, manifest: m })), + enablePackage: vi.fn().mockReturnValue({ id: 'pkg-a' }), + disablePackage: vi.fn().mockReturnValue({ id: 'pkg-a' }), + uninstallPackage: vi.fn().mockReturnValue(true), + updatePackageManifest: vi.fn().mockReturnValue({ id: 'pkg-a' }), + }; + const objectql = { registry }; + const kernel: any = { + context: { + getService: (name: string) => + name === 'objectql' ? objectql + : name === 'protocol' ? (overrides.protocol ?? null) + : name === 'metadata' ? (overrides.metadata ?? null) + : null, + }, + }; + return { dispatcher: new HttpDispatcher(kernel), registry }; +} + +/** A protocol double carrying every method the package routes call. */ +function fullProtocol() { + return { + publishPackageDrafts: vi.fn().mockResolvedValue({ success: true, publishedCount: 0, published: [], failed: [] }), + discardPackageDrafts: vi.fn().mockResolvedValue({ success: true, discardedCount: 2 }), + listCommits: vi.fn().mockResolvedValue([{ id: 'c1' }]), + revertCommit: vi.fn().mockResolvedValue({ success: true }), + rollbackToPackageCommit: vi.fn().mockResolvedValue({ success: true }), + reassignOrphanedMetadata: vi.fn().mockResolvedValue({ reassigned: 0 }), + duplicatePackage: vi.fn().mockResolvedValue({ package: { id: 'pkg-b' } }), + updatePackage: vi.fn().mockResolvedValue({ package: { manifest: { id: 'pkg-a' } } }), + deletePackage: vi.fn().mockResolvedValue({ deletedCount: 1 }), + getMetaItems: vi.fn().mockResolvedValue({ items: [] }), + }; +} + +// ══════════════════════════════════════════════════════════════════════════════ +// 1. Domain-wide anonymous floor — every route, before the registry probe +// ══════════════════════════════════════════════════════════════════════════════ + +describe('/packages — anonymous-deny floor (#7033/#7023)', () => { + it('401s an anonymous GET /packages and never reads the registry', async () => { + const { dispatcher, registry } = make(); + const r = await dispatcher.handlePackages('/', 'GET', undefined, {}, anon()); + expect(r.response?.status).toBe(401); + expect(r.response?.body?.error?.code).toBe('UNAUTHENTICATED'); + expect(registry.getAllPackages).not.toHaveBeenCalled(); + }); + + it('401s the sessionless (resolved-but-no-user) shape too', async () => { + const { dispatcher, registry } = make(); + const r = await dispatcher.handlePackages('/', 'GET', undefined, {}, anonResolved()); + expect(r.response?.status).toBe(401); + expect(registry.getAllPackages).not.toHaveBeenCalled(); + }); + + it('401s an anonymous DESTRUCTIVE discard-drafts and never calls the protocol', async () => { + const protocol = fullProtocol(); + const { dispatcher } = make({ protocol }); + const r = await dispatcher.handlePackages('/pkg-a/discard-drafts', 'POST', {}, {}, anon()); + expect(r.response?.status).toBe(401); + expect(protocol.discardPackageDrafts).not.toHaveBeenCalled(); + }); + + it('answers the anonymous floor BEFORE the 503 registry probe — no 401-vs-503 fingerprint', async () => { + // Registry ABSENT: an authenticated caller gets 503, but an anonymous one + // must still get 401, so the difference cannot reveal whether the package + // service is mounted. + const objectqlNoRegistry = { find: vi.fn() }; // no `.registry` + const { dispatcher } = make({ registry: undefined }); + // rebuild kernel with a registry-less objectql + const kernel: any = { context: { getService: (n: string) => (n === 'objectql' ? objectqlNoRegistry : null) } }; + const d = new HttpDispatcher(kernel); + expect((await d.handlePackages('/', 'GET', undefined, {}, anon())).response?.status).toBe(401); + expect((await d.handlePackages('/', 'GET', undefined, {}, authed(['studio.access']))).response?.status).toBe(503); + void dispatcher; + }); + + it('does not turn a CORS preflight (OPTIONS) into a 401 — it passes the floor', async () => { + const { dispatcher } = make(); + const r = await dispatcher.handlePackages('/', 'OPTIONS', undefined, {}, anon()); + // No OPTIONS route matches → not handled; crucially NOT a 401. + expect(r.response?.status).not.toBe(401); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 2. Write gate — `manage_metadata` on every state-changing route +// ══════════════════════════════════════════════════════════════════════════════ + +type WriteCase = { name: string; path: string; method: string; body?: any; query?: any; target: (p: any, r: any) => any }; +const WRITE_ROUTES: WriteCase[] = [ + // `overwrite` so the allow-path clears the 409 duplicate guard (the shared + // registry double answers `getPackage` truthy for any id); the write gate + // runs FIRST, so the deny cases still 403/401 before this is consulted. + { name: 'POST /packages (install)', path: '/', method: 'POST', body: { manifest: { id: 'pkg-new', name: 'n', version: '1.0.0' } }, query: { overwrite: 'true' }, target: (_p, r) => r.installPackage }, + { name: 'PATCH /:id/enable', path: '/pkg-a/enable', method: 'PATCH', target: (_p, r) => r.enablePackage }, + { name: 'PATCH /:id/disable', path: '/pkg-a/disable', method: 'PATCH', target: (_p, r) => r.disablePackage }, + { name: 'POST /:id/publish-drafts', path: '/pkg-a/publish-drafts', method: 'POST', target: (p) => p.publishPackageDrafts }, + { name: 'POST /:id/discard-drafts', path: '/pkg-a/discard-drafts', method: 'POST', target: (p) => p.discardPackageDrafts }, + { name: 'POST /:id/commits/:c/revert', path: '/pkg-a/commits/c1/revert', method: 'POST', target: (p) => p.revertCommit }, + { name: 'POST /:id/rollback', path: '/pkg-a/rollback', method: 'POST', body: { commitId: 'c1' }, target: (p) => p.rollbackToPackageCommit }, + { name: 'POST /:id/adopt-orphans', path: '/pkg-a/adopt-orphans', method: 'POST', target: (p) => p.reassignOrphanedMetadata }, + { name: 'POST /:id/duplicate', path: '/pkg-a/duplicate', method: 'POST', body: { targetPackageId: 'pkg-b' }, target: (p) => p.duplicatePackage }, + { name: 'PATCH /:id (manifest)', path: '/pkg-a', method: 'PATCH', body: { name: 'renamed' }, target: (p) => p.updatePackage }, + { name: 'DELETE /:id', path: '/pkg-a', method: 'DELETE', target: (p) => p.deletePackage }, +]; + +describe('/packages — write gate: every state-changing route demands `manage_metadata`', () => { + for (const wc of WRITE_ROUTES) { + it(`403s a zero-capability caller on ${wc.name} and the target never runs`, async () => { + const protocol = fullProtocol(); + const { dispatcher, registry } = make({ protocol }); + const r = await dispatcher.handlePackages(wc.path, wc.method, wc.body ?? {}, wc.query ?? {}, authed([])); + expect(r.response?.status).toBe(403); + expect(wc.target(protocol, registry)).not.toHaveBeenCalled(); + }); + + it(`403s a READ-capability-only caller on ${wc.name} — studio.access is not the write key`, async () => { + const protocol = fullProtocol(); + const { dispatcher, registry } = make({ protocol }); + const r = await dispatcher.handlePackages(wc.path, wc.method, wc.body ?? {}, wc.query ?? {}, authed(['studio.access', 'setup.access'])); + expect(r.response?.status).toBe(403); + expect(wc.target(protocol, registry)).not.toHaveBeenCalled(); + }); + + it(`lets a manage_metadata caller through on ${wc.name}`, async () => { + const protocol = fullProtocol(); + const { dispatcher, registry } = make({ protocol }); + const r = await dispatcher.handlePackages(wc.path, wc.method, wc.body ?? {}, wc.query ?? {}, authed(['manage_metadata'])); + expect(r.response?.status).not.toBe(403); + expect(r.response?.status).not.toBe(401); + expect(wc.target(protocol, registry)).toHaveBeenCalled(); + }); + + it(`lets an isSystem caller through on ${wc.name}`, async () => { + const protocol = fullProtocol(); + const { dispatcher, registry } = make({ protocol }); + const r = await dispatcher.handlePackages(wc.path, wc.method, wc.body ?? {}, wc.query ?? {}, system()); + expect(r.response?.status).not.toBe(403); + expect(wc.target(protocol, registry)).toHaveBeenCalled(); + }); + } + + // The publish route that needs the metadata service (not the protocol). + it('403s POST /:id/publish (metadata-service publish) and never calls publishPackage', async () => { + const metadata = { publishPackage: vi.fn().mockResolvedValue({ success: true }) }; + const { dispatcher } = make({ metadata }); + const r = await dispatcher.handlePackages('/pkg-a/publish', 'POST', {}, {}, authed([])); + expect(r.response?.status).toBe(403); + expect(metadata.publishPackage).not.toHaveBeenCalled(); + }); + + it('403s POST /:id/revert (metadata-service revert) and never calls revertPackage', async () => { + const metadata = { revertPackage: vi.fn().mockResolvedValue(undefined) }; + const { dispatcher } = make({ metadata }); + const r = await dispatcher.handlePackages('/pkg-a/revert', 'POST', {}, {}, authed([])); + expect(r.response?.status).toBe(403); + expect(metadata.revertPackage).not.toHaveBeenCalled(); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 3. `discard-drafts` — the "delete first, refuse second" shape, pinned directly +// ══════════════════════════════════════════════════════════════════════════════ + +describe('/packages discard-drafts — the destructive route refuses BEFORE any delete', () => { + /** A protocol whose discard mutates a stand-in draft store (the "库"). */ + function discardWithStore() { + const store = { drafts: ['app.hr:account', 'app.hr:salary_review'] }; + const discardPackageDrafts = vi.fn(async () => { + const n = store.drafts.length; + store.drafts = []; + return { success: true, discardedCount: n }; + }); + return { store, discardPackageDrafts }; + } + + it('a zero-capability caller gets 403 and NOT ONE draft is deleted', async () => { + const { store, discardPackageDrafts } = discardWithStore(); + const { dispatcher } = make({ protocol: { discardPackageDrafts } }); + const r = await dispatcher.handlePackages('/app.hr/discard-drafts', 'POST', {}, {}, authed([])); + expect(r.response?.status).toBe(403); + // The load-bearing assertion: the delete never ran, so the drafts survive. + expect(discardPackageDrafts).not.toHaveBeenCalled(); + expect(store.drafts).toEqual(['app.hr:account', 'app.hr:salary_review']); + }); + + it('an anonymous caller gets 401 and NOT ONE draft is deleted', async () => { + const { store, discardPackageDrafts } = discardWithStore(); + const { dispatcher } = make({ protocol: { discardPackageDrafts } }); + const r = await dispatcher.handlePackages('/app.hr/discard-drafts', 'POST', {}, {}, anon()); + expect(r.response?.status).toBe(401); + expect(discardPackageDrafts).not.toHaveBeenCalled(); + expect(store.drafts).toEqual(['app.hr:account', 'app.hr:salary_review']); + }); + + it('a manage_metadata caller DOES discard (the gate is not over-blocking)', async () => { + const { store, discardPackageDrafts } = discardWithStore(); + const { dispatcher } = make({ protocol: { discardPackageDrafts } }); + const r = await dispatcher.handlePackages('/app.hr/discard-drafts', 'POST', {}, {}, authed(['manage_metadata'])); + expect(r.response?.status).toBe(200); + expect(discardPackageDrafts).toHaveBeenCalledTimes(1); + expect(store.drafts).toEqual([]); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 4. Read gate — `studio.access` / `setup.access` on every read +// ══════════════════════════════════════════════════════════════════════════════ + +type ReadCase = { name: string; path: string; target: (p: any, r: any) => any }; +const READ_ROUTES: ReadCase[] = [ + { name: 'GET /packages (enumeration)', path: '/', target: (_p, r) => r.getAllPackages }, + { name: 'GET /:id (detail)', path: '/pkg-a', target: (_p, r) => r.getPackage }, + { name: 'GET /:id/commits', path: '/pkg-a/commits', target: (p) => p.listCommits }, + { name: 'GET /:id/export', path: '/pkg-a/export', target: (p) => p.getMetaItems }, +]; + +describe('/packages — read gate: every read demands studio.access or setup.access', () => { + for (const rc of READ_ROUTES) { + it(`403s a zero-capability caller on ${rc.name} and the target never runs`, async () => { + const protocol = fullProtocol(); + const { dispatcher, registry } = make({ protocol }); + const r = await dispatcher.handlePackages(rc.path, 'GET', undefined, {}, authed([])); + expect(r.response?.status).toBe(403); + expect(rc.target(protocol, registry)).not.toHaveBeenCalled(); + }); + + it(`403s a WRITE-only caller (manage_metadata) on ${rc.name} — the read cohort is a different set`, async () => { + const protocol = fullProtocol(); + const { dispatcher, registry } = make({ protocol }); + const r = await dispatcher.handlePackages(rc.path, 'GET', undefined, {}, authed(['manage_metadata'])); + expect(r.response?.status).toBe(403); + expect(rc.target(protocol, registry)).not.toHaveBeenCalled(); + }); + + it(`lets a studio.access caller read ${rc.name}`, async () => { + const protocol = fullProtocol(); + const { dispatcher } = make({ protocol }); + const r = await dispatcher.handlePackages(rc.path, 'GET', undefined, {}, authed(['studio.access'])); + expect(r.response?.status).not.toBe(403); + expect(r.response?.status).not.toBe(401); + }); + + it(`lets a setup.access caller read ${rc.name}`, async () => { + const protocol = fullProtocol(); + const { dispatcher } = make({ protocol }); + const r = await dispatcher.handlePackages(rc.path, 'GET', undefined, {}, authed(['setup.access'])); + expect(r.response?.status).not.toBe(403); + expect(r.response?.status).not.toBe(401); + }); + + it(`lets an isSystem caller read ${rc.name}`, async () => { + const protocol = fullProtocol(); + const { dispatcher } = make({ protocol }); + const r = await dispatcher.handlePackages(rc.path, 'GET', undefined, {}, system()); + expect(r.response?.status).not.toBe(403); + expect(r.response?.status).not.toBe(401); + }); + } +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 5. The two cohorts are genuinely different (write ≠ read) +// ══════════════════════════════════════════════════════════════════════════════ + +describe('/packages — the write cohort and the read cohort do not collapse into one', () => { + it('a setup.access-only caller (org-admin shape) may READ but not PUBLISH', async () => { + const protocol = fullProtocol(); + const { dispatcher } = make({ protocol }); + const read = await dispatcher.handlePackages('/', 'GET', undefined, {}, authed(['setup.access'])); + expect(read.response?.status).toBe(200); + const write = await dispatcher.handlePackages('/pkg-a/publish-drafts', 'POST', {}, {}, authed(['setup.access'])); + expect(write.response?.status).toBe(403); + expect(protocol.publishPackageDrafts).not.toHaveBeenCalled(); + }); + + it('a manage_metadata-only caller may PUBLISH but not READ the export', async () => { + const protocol = fullProtocol(); + const { dispatcher } = make({ protocol }); + const write = await dispatcher.handlePackages('/pkg-a/publish-drafts', 'POST', {}, {}, authed(['manage_metadata'])); + expect(write.response?.status).toBe(200); + const read = await dispatcher.handlePackages('/pkg-a/export', 'GET', undefined, {}, authed(['manage_metadata'])); + expect(read.response?.status).toBe(403); + expect(protocol.getMetaItems).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index e1a2789bf1..b0e8482ee3 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -11,6 +11,13 @@ import { CoreServiceName } from '@objectstack/spec/system'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +import { + shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, +} from '@objectstack/core'; +// [#7033 / #7023] The read gate reuses the SAME "builder" capability set the +// object-schema mask exempts (ADR-0106 D4) — REFERENCED, never re-spelled, so +// the package-read cohort cannot drift from the metadata mask's exemption. +import { OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES } from '@objectstack/metadata-core'; import { organizationIdForMetaWrite } from '../meta-write-org-scope.js'; import { setPackageDisabled } from '../package-state-store.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; @@ -24,6 +31,70 @@ export function createPackagesDomain(deps: DomainHandlerDeps): DomainRoute { }; } +/** + * ADR-0066 D1 WRITE gate for the `/packages` domain (#7033 / #7023). + * + * Every state-changing package route — install, enable/disable, publish, + * publish-drafts, discard-drafts, the ADR-0067 commit revert / rollback / + * revert family, adopt-orphans, duplicate, manifest PATCH and DELETE — demands + * the same `manage_metadata` capability the sibling `/meta` writes carry: + * #6603 gated the REST `PUT /meta/:type/:name` and #7019 its dispatcher + * transport, and `POST /meta/_migrate-stored` gates on the same key. Promoting + * a whole package's drafts to active, discarding them, deleting the package or + * rolling it back are metadata-authoring writes of the same class, so they + * carry the same capability. (The reason `manage_metadata` and not the D4 read + * set: this is ADR-0066 D1's authoring capability; the maintainer's 2026-08-09 + * ruling — "whoever can write schema is who may manage packages" — mirrors + * #6603's judgement verbatim. #7020 tracks whether the two sets should align.) + * + * Returns a 403 result to short-circuit on, or `null` to proceed. Engine + * self-invocation (`isSystem`, never settable from the wire) bypasses, exactly + * as the migrate-stored gate does. Callers MUST run this BEFORE resolving the + * protocol/metadata service AND before mutating the registry, so (a) an + * unauthorized caller cannot use the 501-vs-200 answer to fingerprint which + * primitives the deployment supports, and (b) nothing is written or deleted + * before the refusal — "delete first, refuse second" is the worst shape here. + */ +function requireManageMetadata(deps: DomainHandlerDeps, context: HttpProtocolContext): HttpDispatcherResult | null { + const ec: any = context?.executionContext; + if (!ec?.isSystem && !new Set(ec?.systemPermissions ?? []).has('manage_metadata')) { + return { + handled: true, + response: deps.error('Managing packages requires the `manage_metadata` capability.', 403), + }; + } + return null; +} + +/** + * ADR-0106 D4 READ gate for the `/packages` domain (#7033 / #7023). + * + * Package reads disclose authored metadata — the `GET /packages` id + * ENUMERATION face (the first step of the survey's attack chain), the + * `GET /packages/:id` detail, the `GET /packages/:id/commits` history and the + * `GET /packages/:id/export` whole-package export (27 metadata types) — so each + * requires one of the two "builder" capabilities the object-schema mask exempts + * ({@link OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES} = `studio.access` / + * `setup.access`), or `isSystem`. The read cohort is deliberately BROADER than + * the write cohort: an `organization_admin` holding `setup.access` (but not + * `manage_metadata`) may inspect a package yet not publish or delete it. + * + * Returns a 403 result to short-circuit on, or `null` to proceed. Callers MUST + * run this BEFORE reading, so the answer never leaks the package inventory to a + * caller outside the cohort. + */ +function requireReadCapability(deps: DomainHandlerDeps, context: HttpProtocolContext): HttpDispatcherResult | null { + const ec: any = context?.executionContext; + const held = new Set(ec?.systemPermissions ?? []); + if (!ec?.isSystem && !OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES.some((c) => held.has(c))) { + return { + handled: true, + response: deps.error('Reading packages requires the `studio.access` or `setup.access` capability.', 403), + }; + } + return null; +} + /** * Handles Package Management requests * @@ -52,6 +123,30 @@ export function createPackagesDomain(deps: DomainHandlerDeps): DomainRoute { */ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: string, method: string, body: any, query: any, _context: HttpProtocolContext): Promise { const m = method.toUpperCase(); + + // [#7033 / #7023] Anonymous-deny floor for the WHOLE /packages domain. + // The same ADR-0056 D2 baseline (#3963) its five sibling dispatcher domains + // — /meta, /actions, /automation, /ai, /security — already carry and this + // one lacked: a survey drove a credential-less caller (identity resolved to + // `principalKind: 'guest'`) straight to a 200 on the destructive + // discard-drafts and the whole-package export. FIRST statement, ahead of + // the ObjectQL registry probe below, so an anonymous caller is answered 401 + // before the 503 "Package service not available" and cannot use the + // 401-vs-503 difference to fingerprint whether the package service is + // mounted (the same ordering rationale the /automation gate records). + // `isSystem` (never settable from the wire) and CORS `OPTIONS` preflights + // pass. Gating the DOMAIN rather than each route is what keeps a newly added + // package route from arriving ungated. + { + const ec: any = _context?.executionContext; + if (shouldDenyAnonymous({ userId: ec?.userId, isSystem: ec?.isSystem, method: m })) { + return { + handled: true, + response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), + }; + } + } + const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); // Try to get SchemaRegistry from the ObjectQL service @@ -66,6 +161,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin try { // GET /packages → list packages if (parts.length === 0 && m === 'GET') { + const denied = requireReadCapability(deps, _context); if (denied) return denied; let packages = registry.getAllPackages(); // Apply optional filters if (query?.status) { @@ -83,6 +179,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // reads) AND the durable `sys_packages` table. Fall back to the bare // registry write only when the protocol service/method is unavailable. if (parts.length === 0 && m === 'POST') { + const denied = requireManageMetadata(deps, _context); if (denied) return denied; const manifest = body.manifest || body; const pkgId = typeof manifest?.id === 'string' ? manifest.id.trim() : ''; // A package id is mandatory — without one the install cannot be keyed. @@ -118,6 +215,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // PATCH /packages/:id/enable if (parts.length === 2 && parts[1] === 'enable' && m === 'PATCH') { + const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); const pkg = registry.enablePackage(id); if (!pkg) return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; @@ -131,6 +229,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // PATCH /packages/:id/disable if (parts.length === 2 && parts[1] === 'disable' && m === 'PATCH') { + const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); const pkg = registry.disablePackage(id); if (!pkg) return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; @@ -144,6 +243,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // POST /packages/:id/publish → publish package metadata if (parts.length === 2 && parts[1] === 'publish' && m === 'POST') { + const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); const metadataService = await deps.getService(_context, CoreServiceName.enum.metadata); if (metadataService && typeof (metadataService as any).publishPackage === 'function') { @@ -159,6 +259,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // reuses the per-item publish primitive) — no metadata service // dependency, unlike /publish above. if (parts.length === 2 && parts[1] === 'publish-drafts' && m === 'POST') { + const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof (protocol as any).publishPackageDrafts === 'function') { @@ -354,6 +455,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // physical tables are untouched. Routes through the sys_metadata // path (no metadata-service dependency, unlike /revert below). if (parts.length === 2 && parts[1] === 'discard-drafts' && m === 'POST') { + const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof (protocol as any).discardPackageDrafts === 'function') { @@ -376,6 +478,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // GET /packages/:id/commits → the commit timeline (newest-first). if (parts.length === 2 && parts[1] === 'commits' && m === 'GET') { + const denied = requireReadCapability(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof (protocol as any).listCommits === 'function') { @@ -397,6 +500,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // (ADR-0067). Created artifacts are soft-removed, edited ones are // restored to their pre-commit version; the revert is itself a commit. if (parts.length === 4 && parts[1] === 'commits' && parts[3] === 'revert' && m === 'POST') { + const denied = requireManageMetadata(deps, _context); if (denied) return denied; const commitId = decodeURIComponent(parts[2]); const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof (protocol as any).revertCommit === 'function') { @@ -418,6 +522,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // POST /packages/:id/rollback body { commitId } → roll the package // back THROUGH every commit newer than `commitId` (ADR-0067). if (parts.length === 2 && parts[1] === 'rollback' && m === 'POST') { + const denied = requireManageMetadata(deps, _context); if (denied) return denied; const protocol = await deps.resolveService(_context, 'protocol'); if (protocol && typeof (protocol as any).rollbackToPackageCommit === 'function') { if (!body?.commitId) { @@ -440,6 +545,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // POST /packages/:id/revert → revert package to last published state if (parts.length === 2 && parts[1] === 'revert' && m === 'POST') { + const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); const metadataService = await deps.getService(_context, CoreServiceName.enum.metadata); if (metadataService && typeof (metadataService as any).revertPackage === 'function') { @@ -452,6 +558,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // GET /packages/:id/export → assemble a portable manifest from // sys_metadata overlay rows bound to this package (offline export). if (parts.length === 2 && parts[1] === 'export' && m === 'GET') { + const denied = requireReadCapability(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); const manifest = await assemblePackageManifest(deps, id, registry, _context); if (!manifest) { @@ -464,6 +571,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // null / 'sys_metadata') metadata INTO this base (ADR-0070 D5 migration; // lets the env retire the "Local / Custom" scope once it has no orphans). if (parts.length === 2 && parts[1] === 'adopt-orphans' && m === 'POST') { + const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); const protocol = await deps.resolveService(_context, 'protocol'); if (!protocol || typeof (protocol as any).reassignOrphanedMetadata !== 'function') { @@ -486,6 +594,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // package, re-namespacing objects + rewriting references (ADR-0070 D4 // "duplicate base"). Body { targetPackageId, targetName?, targetNamespace? }. if (parts.length === 2 && parts[1] === 'duplicate' && m === 'POST') { + const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); const protocol = await deps.resolveService(_context, 'protocol'); if (!protocol || typeof (protocol as any).duplicatePackage !== 'function') { @@ -513,6 +622,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // GET /packages/:id → get package if (parts.length === 1 && m === 'GET') { + const denied = requireReadCapability(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); const pkg = registry.getPackage(id); if (!pkg) return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; @@ -525,6 +635,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // `id` / `scope` / `type` are identity/structure and are NOT editable // here. Body accepts the fields flat or under a `manifest` wrapper. if (parts.length === 1 && m === 'PATCH') { + const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); const src = (body?.manifest && typeof body.manifest === 'object' ? body.manifest : body) ?? {}; const patch: { name?: string; description?: string; version?: string } = {}; @@ -563,6 +674,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // default. `?keepData=true` preserves object tables (metadata-only // delete). Use case: "I don't want this package anymore." if (parts.length === 1 && m === 'DELETE') { + const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); const registryRemoved = registry.uninstallPackage(id); diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 137edf7d87..1218c47a45 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -48,6 +48,20 @@ const AUTHED_CALLER = () => ({ request: {}, executionContext: { userId: 'u_test' */ const METADATA_AUTHOR = () => ({ request: {}, executionContext: { userId: 'u1', systemPermissions: ['manage_metadata'] } }) as any; +/** + * [#7033 / #7023] The same move again for the `/packages` domain, which now + * carries an anonymous-deny floor plus per-route capability predicates + * (`manage_metadata` for every state-changing route, `studio.access` / + * `setup.access` for every read). The package tests below are about ROUTING and + * ERROR MAPPING — which protocol/metadata method a path reaches, which status a + * miss returns — and were only ever anonymous incidentally, so without a caller + * they would now all stop at the 401 floor before reaching the behaviour each + * one is named after. This caller holds ALL three capabilities so a single + * context clears both the write and the read gate; the gates themselves are + * pinned in `domains/packages-capability-gate.test.ts`. + */ +const PKG_ADMIN = () => ({ request: {}, executionContext: { userId: 'u_pkg_admin', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'] } }) as any; + describe('HttpDispatcher', () => { let kernel: ObjectKernel; let dispatcher: HttpDispatcher; @@ -1446,7 +1460,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handlePackages('/com.acme.crm/publish', 'POST', { publishedBy: 'admin' }, {}, { request: {} }); + const result = await dispatcher.handlePackages('/com.acme.crm/publish', 'POST', { publishedBy: 'admin' }, {}, PKG_ADMIN()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); expect(mockMetadata.publishPackage).toHaveBeenCalledWith('com.acme.crm', { publishedBy: 'admin' }); @@ -1467,7 +1481,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handlePackages('/com.acme.crm/revert', 'POST', {}, {}, { request: {} }); + const result = await dispatcher.handlePackages('/com.acme.crm/revert', 'POST', {}, {}, PKG_ADMIN()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); expect(mockMetadata.revertPackage).toHaveBeenCalledWith('com.acme.crm'); @@ -1483,7 +1497,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handlePackages('/crm/publish', 'POST', {}, {}, { request: {} }); + const result = await dispatcher.handlePackages('/crm/publish', 'POST', {}, {}, PKG_ADMIN()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(503); }); @@ -1504,7 +1518,7 @@ describe('HttpDispatcher', () => { 'PATCH', { name: ' Acme CRM v2 ', version: '1.2.0' }, {}, - { request: {} }, + PKG_ADMIN(), ); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); @@ -1524,7 +1538,7 @@ describe('HttpDispatcher', () => { return null; }); - await dispatcher.handlePackages('/a.b', 'PATCH', { manifest: { description: 'hi' } }, {}, { request: {} }); + await dispatcher.handlePackages('/a.b', 'PATCH', { manifest: { description: 'hi' } }, {}, PKG_ADMIN()); expect(updatePackage).toHaveBeenCalledWith({ packageId: 'a.b', patch: { description: 'hi' } }); }); @@ -1547,7 +1561,7 @@ describe('HttpDispatcher', () => { 'POST', { manifest: { id: 'com.acme.new', name: 'New', version: '0.1.0', type: 'app' } }, {}, - { request: {} }, + PKG_ADMIN(), ); expect(result.response?.status).toBe(201); expect(mockRegistry.getPackage).toHaveBeenCalledWith('com.acme.new'); @@ -1571,7 +1585,7 @@ describe('HttpDispatcher', () => { 'POST', { manifest: { id: 'com.acme.crm', name: 'Clobber', version: '9.9.9' } }, {}, - { request: {} }, + PKG_ADMIN(), ); expect(result.response?.status).toBe(409); // The existing manifest must NOT be overwritten. @@ -1597,7 +1611,7 @@ describe('HttpDispatcher', () => { 'POST', { manifest: { id: 'com.acme.crm', name: 'Upgraded', version: '2.0.0' } }, { overwrite: 'true' }, - { request: {} }, + PKG_ADMIN(), ); expect(result.response?.status).toBe(201); expect(installPackage).toHaveBeenCalled(); @@ -1619,7 +1633,7 @@ describe('HttpDispatcher', () => { 'POST', { manifest: { name: 'No Id' } }, {}, - { request: {} }, + PKG_ADMIN(), ); expect(result.response?.status).toBe(400); expect(mockRegistry.installPackage).not.toHaveBeenCalled(); @@ -1630,7 +1644,7 @@ describe('HttpDispatcher', () => { if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } }); return null; }); - const result = await dispatcher.handlePackages('/a.b', 'PATCH', {}, {}, { request: {} }); + const result = await dispatcher.handlePackages('/a.b', 'PATCH', {}, {}, PKG_ADMIN()); expect(result.response?.status).toBe(400); }); @@ -1639,7 +1653,7 @@ describe('HttpDispatcher', () => { if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } }); return null; }); - const result = await dispatcher.handlePackages('/a.b', 'PATCH', { version: '1.2' }, {}, { request: {} }); + const result = await dispatcher.handlePackages('/a.b', 'PATCH', { version: '1.2' }, {}, PKG_ADMIN()); expect(result.response?.status).toBe(400); }); @@ -1651,7 +1665,7 @@ describe('HttpDispatcher', () => { return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]), updatePackageManifest } }); return null; }); - const result = await dispatcher.handlePackages('/nope', 'PATCH', { name: 'x' }, {}, { request: {} }); + const result = await dispatcher.handlePackages('/nope', 'PATCH', { name: 'x' }, {}, PKG_ADMIN()); expect(updatePackageManifest).toHaveBeenCalledWith('nope', { name: 'x' }); expect(result.response?.status).toBe(404); }); @@ -1666,7 +1680,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handlePackages('/app.edu/publish-drafts', 'POST', {}, {}, { request: {} }); + const result = await dispatcher.handlePackages('/app.edu/publish-drafts', 'POST', {}, {}, PKG_ADMIN()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); @@ -1691,7 +1705,7 @@ describe('HttpDispatcher', () => { const trigger = vi.fn().mockResolvedValue(undefined); (kernel as any).context.trigger = trigger; - const result = await dispatcher.handlePackages('/com.example.ops/publish-drafts', 'POST', {}, {}, { request: {} }); + const result = await dispatcher.handlePackages('/com.example.ops/publish-drafts', 'POST', {}, {}, PKG_ADMIN()); expect(result.response?.status).toBe(200); expect(trigger).toHaveBeenCalledWith( @@ -1712,7 +1726,7 @@ describe('HttpDispatcher', () => { const trigger = vi.fn().mockResolvedValue(undefined); (kernel as any).context.trigger = trigger; - await dispatcher.handlePackages('/app.empty/publish-drafts', 'POST', {}, {}, { request: {} }); + await dispatcher.handlePackages('/app.empty/publish-drafts', 'POST', {}, {}, PKG_ADMIN()); expect(trigger).not.toHaveBeenCalled(); }); @@ -1723,7 +1737,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handlePackages('/app.edu/publish-drafts', 'POST', {}, {}, { request: {} }); + const result = await dispatcher.handlePackages('/app.edu/publish-drafts', 'POST', {}, {}, PKG_ADMIN()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(501); }); @@ -1740,7 +1754,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handlePackages('/app.edu/commits', 'GET', {}, {}, { request: {} }); + const result = await dispatcher.handlePackages('/app.edu/commits', 'GET', {}, {}, PKG_ADMIN()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); @@ -1756,7 +1770,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handlePackages('/app.edu/commits/cmt_1/revert', 'POST', { actor: 'ai:claude' }, {}, { request: {} }); + const result = await dispatcher.handlePackages('/app.edu/commits/cmt_1/revert', 'POST', { actor: 'ai:claude' }, {}, PKG_ADMIN()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); @@ -1771,7 +1785,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handlePackages('/app.edu/rollback', 'POST', { commitId: 'c1' }, {}, { request: {} }); + const result = await dispatcher.handlePackages('/app.edu/rollback', 'POST', { commitId: 'c1' }, {}, PKG_ADMIN()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); @@ -1785,7 +1799,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handlePackages('/app.edu/rollback', 'POST', {}, {}, { request: {} }); + const result = await dispatcher.handlePackages('/app.edu/rollback', 'POST', {}, {}, PKG_ADMIN()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(400); }); @@ -1796,7 +1810,7 @@ describe('HttpDispatcher', () => { if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } }); return null; }); - const result = await dispatcher.handlePackages('/app.edu/commits', 'GET', {}, {}, { request: {} }); + const result = await dispatcher.handlePackages('/app.edu/commits', 'GET', {}, {}, PKG_ADMIN()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(501); }); @@ -1834,7 +1848,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handlePackages('/com.workspace/publish-drafts', 'POST', {}, {}, { request: {} }); + const result = await dispatcher.handlePackages('/com.workspace/publish-drafts', 'POST', {}, {}, PKG_ADMIN()); expect(result.response?.status).toBe(200); const seedApplied = (result.response as any)?.body?.data?.seedApplied; @@ -1875,7 +1889,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handlePackages('/app.production_management/publish-drafts', 'POST', {}, {}, { request: {} }); + const result = await dispatcher.handlePackages('/app.production_management/publish-drafts', 'POST', {}, {}, PKG_ADMIN()); expect(result.response?.status).toBe(200); expect(getMetaItems).toHaveBeenCalledWith(expect.objectContaining({ type: 'app', packageId: 'app.production_management' })); @@ -1914,7 +1928,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handlePackages('/app.acct/publish-drafts', 'POST', {}, {}, { request: {} }); + const result = await dispatcher.handlePackages('/app.acct/publish-drafts', 'POST', {}, {}, PKG_ADMIN()); expect(result.response?.status).toBe(200); expect(saveMetaItem).toHaveBeenCalledTimes(1); @@ -1938,7 +1952,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handlePackages('/app.edu/publish-drafts', 'POST', {}, {}, { request: {} }); + const result = await dispatcher.handlePackages('/app.edu/publish-drafts', 'POST', {}, {}, PKG_ADMIN()); // The draft publish itself succeeded — the flip failure is surfaced, not fatal. expect(result.response?.status).toBe(200); @@ -1970,7 +1984,7 @@ describe('HttpDispatcher', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { - const result = await dispatcher.handlePackages('/app.edu/publish-drafts', 'POST', {}, {}, { request: {} }); + const result = await dispatcher.handlePackages('/app.edu/publish-drafts', 'POST', {}, {}, PKG_ADMIN()); // Unchanged contract: the drafts ARE published, so this still 200s // and still carries the machine-readable `unhideError`. @@ -2025,7 +2039,7 @@ describe('HttpDispatcher', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { - const result = await dispatcher.handlePackages('/app.partial/publish-drafts', 'POST', {}, {}, { request: {} }); + const result = await dispatcher.handlePackages('/app.partial/publish-drafts', 'POST', {}, {}, PKG_ADMIN()); // The loop stopped at `gamma` — `delta` was never attempted. expect(result.response?.status).toBe(200); @@ -2086,7 +2100,7 @@ describe('HttpDispatcher', () => { }); const manifest = { id: 'app.demo', name: 'Demo', version: '1.0.0', type: 'application' }; - const result = await dispatcher.handlePackages('', 'POST', { manifest, settings: { a: 1 } }, {}, { request: {} }); + const result = await dispatcher.handlePackages('', 'POST', { manifest, settings: { a: 1 } }, {}, PKG_ADMIN()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(201); @@ -2108,7 +2122,7 @@ describe('HttpDispatcher', () => { }); const manifest = { id: 'app.fb', name: 'FB', version: '1.0.0', type: 'application' }; - const result = await dispatcher.handlePackages('', 'POST', { manifest }, {}, { request: {} }); + const result = await dispatcher.handlePackages('', 'POST', { manifest }, {}, PKG_ADMIN()); expect(result.response?.status).toBe(201); expect(mockRegistry.installPackage).toHaveBeenCalledWith(manifest, undefined);