diff --git a/.changeset/23546-trail-doors-setup-access.md b/.changeset/23546-trail-doors-setup-access.md new file mode 100644 index 00000000000..335cc81f67f --- /dev/null +++ b/.changeset/23546-trail-doors-setup-access.md @@ -0,0 +1,37 @@ +--- +'@objectstack/rest': minor +'@objectstack/runtime': minor +--- + +fix(rest,runtime)!: the authoring-trail doors (`/meta` history, audit and diff, and package commits) stop admitting `setup.access`, so an organization admin is refused the environment's authoring trail + +Clause-②: yes (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +Organization admins hold `setup.access`. Under a wall (the `isolated` and `group` postures), these four doors handed every organization's admin the environment-wide authoring trail of items no organization authors (ADR-0131 C5): who saved each item and when, its superseded bodies, and every package's commit timeline. The data door on the same three tables (`sys_metadata_history`, `sys_metadata_audit`, `sys_metadata_commit`) already refused them (`requiredPermissions: ['studio.access']`, ADR-0131 D7). The doors now follow the same rule. The narrowing applies in every posture, `single` included. + +Each door, and the callers it now refuses: + +- **BREAKING** `GET /meta/:type/:name/history` (`@objectstack/rest`), for a caller holding `setup.access` and neither `studio.access` nor `manage_metadata`: was 200 with the item's version history; now `403 FORBIDDEN`. +- **BREAKING** `GET /meta/:type/:name/audit` (`@objectstack/rest`), for the same caller: was 200 with the item's audit rows; now `403 FORBIDDEN`. +- **BREAKING** `GET /meta/:type/:name/diff` (`@objectstack/rest`), for the same caller: was 200 with a diff of the item's stored versions; now `403 FORBIDDEN`. +- **BREAKING** `GET /packages/:id/commits` (`@objectstack/runtime`), for a caller holding `setup.access` without `studio.access`, **with or without `manage_metadata`**: was 200 with the package's commit timeline; now `403 PERMISSION_DENIED`. + - `manage_metadata` never admitted this door. A caller holding it alone was refused before and still is; a caller holding it beside `setup.access` was admitted only through `setup.access`. + - One such caller is a walled organization owner granted `manage_metadata` inside its own organization. It keeps history, audit and diff, and loses commits. + +Who is still admitted: + +- History, audit and diff admit a system caller, `studio.access` or `manage_metadata`, through their own predicate. +- The commits door admits a system caller or `studio.access`. +- Both capabilities are platform-scoped. Platform operators are not affected, and neither are metadata authors who hold `studio.access` (all four doors) or `manage_metadata` (history, audit and diff; commits never admitted it). + +Unchanged: + +- The draft switches keep their admission, `setup.access` included: `GET /meta/_drafts`, `?state=draft` and `?preview=draft`. +- `GET /packages`, `GET /packages/:id` and `GET /packages/:id/export` keep their read cohort (`studio.access` or `setup.access`). +- The refusal keeps its envelope and code at each door. Only the message changes: it now names the capabilities that admit the door. + +What changes for you. If an organization admin needs a metadata item's history, audit trail or package commits, grant `studio.access`. A `manage_metadata` holder without `studio.access` who read package commits through `setup.access` needs `studio.access` for them too. A client that offers History or Audit to a caller holding only `setup.access` should hide them for that caller. The Setup app's item editors do this in objectstack-ai/objectui#12475. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index fd0645e74d2..3201261d11e 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -227,13 +227,13 @@ const view = await client.meta.getItem('view', 'crm_lead.pipeline'); // Per-item draft lifecycle (ADR-0033) await client.meta.publishItem('object', 'account', { message: 'go live' }); await client.meta.rollbackItem('object', 'account', 3); -// Authoring-only, like getHistory and listDrafts: without studio.access, setup.access or manage_metadata → 403 FORBIDDEN +// The authoring trail, like getHistory and getAudit: without studio.access or manage_metadata → 403 FORBIDDEN (setup.access alone is refused) const diff = await client.meta.diffItem('object', 'account', { from: 2, to: 5 }); // Introspection & governance const bad = await client.meta.getDiagnostics({ severity: 'error' }); const refs = await client.meta.getReferences('object', 'account'); -// Authoring-only too, like diffItem: without studio.access, setup.access or manage_metadata → 403 FORBIDDEN +// The authoring trail too, like diffItem: without studio.access or manage_metadata → 403 FORBIDDEN (setup.access alone is refused) const trail = await client.meta.getAudit('object', 'account', { limit: 20 }); const tree = await client.meta.getBookTree('handbook'); diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 4d85111fe98..3b733ffa6a7 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -105,6 +105,7 @@ that silently does not happen. | 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, the field-level-security write gate over the caller's payload when one is supplied, and the ADR-0123 D2 no-active-organization wall | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | | 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` | | 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` | +| 8b | Authoring-trail reads admitted | rest, runtime | Get: a metadata item's version history, audit rows and stored-version diff (`GET /meta/:type/:name/history`, `/audit`, `/diff`) without `studio.access` or `manage_metadata`, and a package's commit timeline (`GET /packages/:id/commits`) without `studio.access`. Note: narrower than the schema-mask exemption the draft switches ask (`isObjectSchemaMaskExempt`): neither door admits `setup.access`, which organization admins hold | `packages/rest/src/rest-server.ts#mayReadAuthoringTrail`, `packages/runtime/src/domains/packages.ts#requireCommitTrailReadCapability` | | 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` | | 9b | The master-detail write check the `security` service serves (`checkControlledByParentWrite`, ADR-0055) answers `allow` | plugin-security | Get: `allow` for any record, with no master judged — the write path's own first exit, so a gate composing it admits exactly what the record's own system-context update would. Lose: nothing a caller reaches today: its two consumers, the `sys_attachment` and `sys_comment` parent gates, return before they ask for a system session | `packages/plugins/plugin-security/src/security-plugin.ts#checkControlledByParentWrite` | | 10 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous` | diff --git a/packages/metadata-core/src/objects/sys-metadata-audit.object.ts b/packages/metadata-core/src/objects/sys-metadata-audit.object.ts index 708d5c51897..878bcd06f58 100644 --- a/packages/metadata-core/src/objects/sys-metadata-audit.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata-audit.object.ts @@ -226,14 +226,15 @@ export const SysMetadataAuditObject = ObjectSchema.create({ // `'*'` grant — so without an object-level gate the generic data door would // serve every organization's rows of this table (the metadata-protection trail: actors, refused-write notes). D7 governs it by // object permission: `studio.access`, platform-scoped (no tenant - // administrator holds it by role) and admitted by every `/meta` door that - // serves this table (`/meta/:type/:name/history` and `/audit` admit it among - // `studio.access` / `setup.access` / `manage_metadata`; `/packages/:id/commits` - // among `studio.access` / `setup.access`). `requiredPermissions` is an - // AND-gate, so it names one capability all three doors admit — and not - // `setup.access`, which organization admins DO hold (plugin-security's - // `PLATFORM_ADMIN_ONLY_CAPABILITIES` excludes it on purpose). Platform code - // reads under the system context, which the gate does not stop. + // administrator holds it by role) and admitted by every door that serves + // this table's trail (`/meta/:type/:name/history`, `/audit` and `/diff` admit + // it beside `manage_metadata`; `/packages/:id/commits` admits it alone). + // `requiredPermissions` is an AND-gate, so it names the one capability all + // of those doors admit — and not `setup.access`, which organization admins + // DO hold (plugin-security's `PLATFORM_ADMIN_ONLY_CAPABILITIES` excludes it + // on purpose). [#23546] None of those doors admits `setup.access` either, so + // the data door and the trail doors refuse an organization admin alike. + // Platform code reads under the system context, which the gate does not stop. requiredPermissions: ['studio.access'], indexes: [ diff --git a/packages/metadata-core/src/objects/sys-metadata-commit.object.ts b/packages/metadata-core/src/objects/sys-metadata-commit.object.ts index 3622abfbecb..2fa45e7fc85 100644 --- a/packages/metadata-core/src/objects/sys-metadata-commit.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata-commit.object.ts @@ -144,14 +144,15 @@ export const SysMetadataCommitObject = ObjectSchema.create({ // `'*'` grant — so without an object-level gate the generic data door would // serve every organization's rows of this table (package commit ranges and their items). D7 governs it by // object permission: `studio.access`, platform-scoped (no tenant - // administrator holds it by role) and admitted by every `/meta` door that - // serves this table (`/meta/:type/:name/history` and `/audit` admit it among - // `studio.access` / `setup.access` / `manage_metadata`; `/packages/:id/commits` - // among `studio.access` / `setup.access`). `requiredPermissions` is an - // AND-gate, so it names one capability all three doors admit — and not - // `setup.access`, which organization admins DO hold (plugin-security's - // `PLATFORM_ADMIN_ONLY_CAPABILITIES` excludes it on purpose). Platform code - // reads under the system context, which the gate does not stop. + // administrator holds it by role) and admitted by every door that serves + // this table's trail (`/meta/:type/:name/history`, `/audit` and `/diff` admit + // it beside `manage_metadata`; `/packages/:id/commits` admits it alone). + // `requiredPermissions` is an AND-gate, so it names the one capability all + // of those doors admit — and not `setup.access`, which organization admins + // DO hold (plugin-security's `PLATFORM_ADMIN_ONLY_CAPABILITIES` excludes it + // on purpose). [#23546] None of those doors admits `setup.access` either, so + // the data door and the trail doors refuse an organization admin alike. + // Platform code reads under the system context, which the gate does not stop. requiredPermissions: ['studio.access'], indexes: [ diff --git a/packages/metadata-core/src/objects/sys-metadata-history.object.ts b/packages/metadata-core/src/objects/sys-metadata-history.object.ts index eb9ab5a4d58..111f8c85ee5 100644 --- a/packages/metadata-core/src/objects/sys-metadata-history.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata-history.object.ts @@ -182,14 +182,15 @@ export const SysMetadataHistoryObject = ObjectSchema.create({ // `'*'` grant — so without an object-level gate the generic data door would // serve every organization's rows of this table (every item version body). D7 governs it by // object permission: `studio.access`, platform-scoped (no tenant - // administrator holds it by role) and admitted by every `/meta` door that - // serves this table (`/meta/:type/:name/history` and `/audit` admit it among - // `studio.access` / `setup.access` / `manage_metadata`; `/packages/:id/commits` - // among `studio.access` / `setup.access`). `requiredPermissions` is an - // AND-gate, so it names one capability all three doors admit — and not - // `setup.access`, which organization admins DO hold (plugin-security's - // `PLATFORM_ADMIN_ONLY_CAPABILITIES` excludes it on purpose). Platform code - // reads under the system context, which the gate does not stop. + // administrator holds it by role) and admitted by every door that serves + // this table's trail (`/meta/:type/:name/history`, `/audit` and `/diff` admit + // it beside `manage_metadata`; `/packages/:id/commits` admits it alone). + // `requiredPermissions` is an AND-gate, so it names the one capability all + // of those doors admit — and not `setup.access`, which organization admins + // DO hold (plugin-security's `PLATFORM_ADMIN_ONLY_CAPABILITIES` excludes it + // on purpose). [#23546] None of those doors admits `setup.access` either, so + // the data door and the trail doors refuse an organization admin alike. + // Platform code reads under the system context, which the gate does not stop. requiredPermissions: ['studio.access'], indexes: [ diff --git a/packages/qa/dogfood/test/wall-authoring-trail-doors.dogfood.test.ts b/packages/qa/dogfood/test/wall-authoring-trail-doors.dogfood.test.ts new file mode 100644 index 00000000000..d573affe5a6 --- /dev/null +++ b/packages/qa/dogfood/test/wall-authoring-trail-doors.dogfood.test.ts @@ -0,0 +1,284 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#23546 · ADR-0131 D7] Under a wall the AUTHORING TRAIL doors refuse an +// organization admin, on a REAL booted stack. +// +// What this harness measured before the narrowing (objectstack `main` at +// `286eb9ef32`), under both walled postures: an organization's owner (rung +// `TENANT_ADMIN`, holding `setup.access`, `setup.write`, `manage_org_users` and +// `manage_mailboxes`, and neither `studio.access` nor `manage_metadata`) +// answered 200 with the operator's trail at all four doors: +// - `GET /meta/:type/:name/history` (5 events), `/audit` (5 rows) and +// `/diff` (the operator's superseded versions), served by `RestServer`; +// - `GET /packages/:id/commits` (2 commits), served by the runtime dispatcher. +// No organization authors that trail (ADR-0131 C5), and the data door on the +// same three tables already refused the same admin +// (`requiredPermissions: ['studio.access']`, ADR-0131 D7). +// +// Pinned here, under each walled posture: the organization admin gets 403 at +// all four doors (`FORBIDDEN` at the three `/meta` doors, `PERMISSION_DENIED` +// at the commits door), and no part of the trail is in the body. CONTROLS: an +// organization owner who differs from that admin by `studio.access` alone +// reads all four, as the platform operator does; and the draft switches +// (`GET /meta/_drafts`, `?state=draft`, `?preview=draft`) and the other three +// package reads answer the organization admin exactly as they did before. +// Under `single`: a `manage_metadata` holder reads history, audit and diff +// (CONTROL); the commits door, which never admitted `manage_metadata`, still +// refuses it (CONTROL: no door gains a capability); and a `setup.access`-only +// holder is refused all four, because the narrowing is not a posture branch. +// +// The boot is `multiTenant: 'posture-only'`, a real, non-degraded walled +// posture, copied from `wall-package-doors-refusal.dogfood.test.ts`. The +// harness declares the seeded admin as `OS_PLATFORM_OWNER_EMAIL`, so that admin +// is the platform operator. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crmStack from '@objectstack/example-crm'; +import { resolveUserAuthzGrants } from '@objectstack/core'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYS = { isSystem: true, positions: [], permissions: [] }; + +interface Engine { + find(object: string, options?: unknown): Promise>>; + insert(object: string, data: Record, options?: unknown): Promise; +} + +const set = (name: string, systemPermissions: string[]) => ({ name, label: name, objects: {}, systemPermissions }); + +const OP_PKG = 'com.t23546.operator'; +/** The operator's item: two published saves, then a pending draft. */ +const OP_SET = 't23546_set'; +const STUDIO_SET = 't23546_studio'; +const AUTHOR_SET = 't23546_author'; +const SETUP_SET = 't23546_setup'; +/** What the operator's pending draft of `OP_SET` grants: only the draft switches serve it. */ +const DRAFT_GRANTS = ['manage_users', 'manage_mailboxes']; + +const TRAIL = { + history: `/meta/permission/${OP_SET}/history`, + audit: `/meta/permission/${OP_SET}/audit`, + diff: `/meta/permission/${OP_SET}/diff`, + commits: `/packages/${OP_PKG}/commits`, +} as const; +type Door = keyof typeof TRAIL; +const DOORS = Object.keys(TRAIL) as Door[]; +const META_DOORS: readonly Door[] = ['history', 'audit', 'diff']; +/** The refusal code each door answers: `RestServer`'s explicit one, the dispatcher's derived one. */ +const REFUSAL_CODE: Record = { + history: 'FORBIDDEN', + audit: 'FORBIDDEN', + diff: 'FORBIDDEN', + commits: 'PERMISSION_DENIED', +}; + +/** How much trail an admitted answer carries: events, rows, the newest version, commits. */ +function trailSize(door: Door, body: any): number { + if (door === 'history' || door === 'audit') return Array.isArray(body?.events) ? body.events.length : -1; + if (door === 'diff') return typeof body?.toVersion === 'number' ? body.toVersion : -1; + return Array.isArray(body?.data?.commits) ? body.data.commits.length : -1; +} + +interface Harness { + stack: VerifyStack; + ql: Engine; + operator: string; + operatorId: string; + tokAdmin: string; + tokStudio: string; + tokAuthor: string; + tokSetup: string; + admin: string; + studio: string; + author: string; + setup: string; + orgs: Record<'admin' | 'studio' | 'author', string | undefined>; +} + +/** + * Boot, sign in the operator, and sign up four users. Under a wall the admin, + * the studio author and the metadata author each create an organization and + * own it. The operator creates `OP_PKG`, publishes `OP_SET` twice and leaves a + * third save pending as a draft. The studio author is granted `studio.access` + * and the metadata author `manage_metadata`, inside their own organization + * under a wall. Under `single` a fourth user is granted `setup.access` alone. + */ +async function boot(posture: 'isolated' | 'group' | 'single'): Promise { + const walled = posture !== 'single'; + if (walled) process.env.OS_TENANCY_POSTURE = posture; + else delete process.env.OS_TENANCY_POSTURE; + const stack = await bootStack(crmStack as never, walled ? { multiTenant: 'posture-only' } : {}); + const ql = (await stack.kernel.getServiceAsync('objectql')) as unknown as Engine; + const email = (who: string) => `t23546-${who}-${posture}@wall.test`; + const operator = await stack.signIn(); + const tokAdmin = await stack.signUp(email('admin')); + const tokStudio = await stack.signUp(email('studio')); + const tokAuthor = await stack.signUp(email('author')); + const tokSetup = await stack.signUp(email('setup')); + const orgs: Harness['orgs'] = { admin: undefined, studio: undefined, author: undefined }; + if (walled) { + for (const [who, tok] of [['admin', tokAdmin], ['studio', tokStudio], ['author', tokAuthor]] as const) { + const slug = `t23546-${who}-${posture}`; + const created = await stack.apiAs(tok, 'POST', '/auth/organization/create', { name: slug, slug }); + expect(created.status, await created.clone().text()).toBe(200); + orgs[who] = ((await created.json()) as { id: string }).id; + const active = await stack.apiAs(tok, 'POST', '/auth/organization/set-active', { organizationSlug: slug }); + expect(active.status, await active.clone().text()).toBe(200); + } + } + const idOf = async (address: string) => + String((await ql.find('sys_user', { where: { email: address }, limit: 1, context: SYS }))[0]?.id); + const ok = async (res: Response, status = 200) => expect(res.status, await res.clone().text()).toBe(status); + + await ok(await stack.apiAs(operator, 'POST', '/packages', { id: OP_PKG, name: 't23546 operator', version: '1.0.0', type: 'app' }), 201); + for (const [name, caps] of [[STUDIO_SET, ['studio.access']], [AUTHOR_SET, ['manage_metadata']], [SETUP_SET, ['setup.access']]] as const) { + await ok(await stack.apiAs(operator, 'PUT', `/meta/permission/${name}?package=${OP_PKG}`, set(name, [...caps]))); + } + await ok(await stack.apiAs(operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, set(OP_SET, []))); + await ok(await stack.apiAs(operator, 'POST', `/packages/${OP_PKG}/publish-drafts`, {})); + await ok(await stack.apiAs(operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, set(OP_SET, ['manage_users']))); + await ok(await stack.apiAs(operator, 'POST', `/packages/${OP_PKG}/publish-drafts`, {})); + await ok(await stack.apiAs(operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, set(OP_SET, DRAFT_GRANTS))); + + const h: Harness = { + stack, ql, operator, tokAdmin, tokStudio, tokAuthor, tokSetup, orgs, + operatorId: '', + admin: await idOf(email('admin')), + studio: await idOf(email('studio')), + author: await idOf(email('author')), + setup: await idOf(email('setup')), + }; + const grant = (user: string, permissionSet: string, org: string | undefined) => + ql.insert('sys_user_permission_set', { user_id: user, permission_set: permissionSet, ...(org ? { organization_id: org } : {}) }, + { context: { ...SYS, ...(org ? { tenantId: org } : {}) } }); + await grant(h.studio, STUDIO_SET, orgs.studio); + await grant(h.author, AUTHOR_SET, orgs.author); + if (!walled) await grant(h.setup, SETUP_SET, undefined); + const me = await stack.apiAs(operator, 'GET', '/auth/get-session'); + h.operatorId = String(((await me.json().catch(() => ({}))) as { user?: { id?: string } })?.user?.id ?? ''); + return h; +} + +const grantsOf = (h: Harness, user: string, org: string | undefined) => + resolveUserAuthzGrants(h.ql as never, user, org ? { tenantId: org } : {}); + +async function get(h: Harness, token: string, path: string): Promise<{ status: number; body: any; text: string }> { + const res = await h.stack.apiAs(token, 'GET', path); + const text = await res.clone().text(); + const body = await res.json().catch(() => null); + return { status: res.status, body, text }; +} + +/** A refusal: status, code, and nothing of the trail or the item in the body. */ +async function expectRefused(h: Harness, token: string, door: Door) { + const res = await get(h, token, TRAIL[door]); + expect({ status: res.status, code: res.body?.error?.code }, `${door}: ${res.text}`) + .toEqual({ status: 403, code: REFUSAL_CODE[door] }); + for (const leaked of [OP_SET, OP_PKG, '"events"', '"commits"', '"toVersion"', ...(h.operatorId ? [h.operatorId] : [])]) { + expect(res.text, `${door} carries ${leaked}`).not.toContain(leaked); + } +} + +/** An admission: 200 with the same trail the platform operator reads. */ +async function expectAdmitted(h: Harness, token: string, door: Door) { + const res = await get(h, token, TRAIL[door]); + expect(res.status, `${door}: ${res.text}`).toBe(200); + const operator = await get(h, h.operator, TRAIL[door]); + expect(trailSize(door, res.body), door).toBeGreaterThan(0); + expect(trailSize(door, res.body), door).toBe(trailSize(door, operator.body)); +} + +for (const posture of ['isolated', 'group'] as const) { + describe(`[#23546 · ADR-0131 D7] ${posture}: the authoring-trail doors refuse an organization admin`, () => { + let h: Harness; + const previous = process.env.OS_TENANCY_POSTURE; + + beforeAll(async () => { h = await boot(posture); }, 180_000); + afterAll(async () => { + await h?.stack?.stop?.(); + if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = previous; + }); + + it('guard the guard: a real walled posture; the admin holds setup.access and neither platform authoring capability; the studio author differs by studio.access alone', async () => { + const tenancy = await h.stack.kernel.getServiceAsync<{ posture: string; degraded: boolean }>('tenancy'); + expect(tenancy.posture).toBe(posture); + expect(tenancy.degraded).toBe(false); + const admin = await grantsOf(h, h.admin, h.orgs.admin); + expect(admin.systemPermissions).toContain('setup.access'); + expect(admin.systemPermissions).not.toContain('studio.access'); + expect(admin.systemPermissions).not.toContain('manage_metadata'); + expect(admin.posture).not.toBe('PLATFORM_ADMIN'); + const studio = await grantsOf(h, h.studio, h.orgs.studio); + expect(studio.posture).not.toBe('PLATFORM_ADMIN'); + const extra = [...studio.systemPermissions].filter((c) => !admin.systemPermissions.includes(c)); + expect(extra).toEqual(['studio.access']); + expect([...admin.systemPermissions].filter((c) => !studio.systemPermissions.includes(c))).toEqual([]); + expect(h.operatorId).not.toBe(''); + }); + + for (const door of DOORS) { + it(`${door}: the organization admin is refused, with none of the trail`, async () => { + await expectRefused(h, h.tokAdmin, door); + }); + + it(`${door}: CONTROL — the organization owner holding studio.access reads the trail the operator reads`, async () => { + await expectAdmitted(h, h.tokStudio, door); + }); + } + + it('CONTROL: the draft switches answer the organization admin as before — the pending draft listed, read and previewed', async () => { + const drafts = await get(h, h.tokAdmin, '/meta/_drafts'); + expect(drafts.status, drafts.text).toBe(200); + expect((drafts.body?.drafts ?? []).map((d: any) => `${d.type}/${d.name}`)).toContain(`permission/${OP_SET}`); + for (const switchName of ['state', 'preview'] as const) { + const res = await get(h, h.tokAdmin, `/meta/permission/${OP_SET}?${switchName}=draft`); + expect(res.status, res.text).toBe(200); + expect(res.body?.item?.systemPermissions, switchName).toEqual(DRAFT_GRANTS); + } + }); + + it('CONTROL: the other package reads still answer the organization admin — only the commits door narrowed', async () => { + for (const path of ['/packages', `/packages/${OP_PKG}`, `/packages/${OP_PKG}/export`]) { + const res = await get(h, h.tokAdmin, path); + expect(res.status, `${path}: ${res.text}`).toBe(200); + } + }); + }); +} + +describe('[#23546] single: the narrowing is not a posture branch, and no door gains a capability', () => { + let h: Harness; + const previous = process.env.OS_TENANCY_POSTURE; + + beforeAll(async () => { h = await boot('single'); }, 180_000); + afterAll(async () => { + await h?.stack?.stop?.(); + if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = previous; + }); + + it('guard the guard: the metadata author holds manage_metadata alone; the setup holder holds setup.access alone', async () => { + const author = await grantsOf(h, h.author, undefined); + expect([...author.systemPermissions].sort()).toEqual(['manage_metadata']); + expect(author.posture).not.toBe('PLATFORM_ADMIN'); + const setup = await grantsOf(h, h.setup, undefined); + expect([...setup.systemPermissions].sort()).toEqual(['setup.access']); + }); + + for (const door of META_DOORS) { + it(`${door}: CONTROL — a manage_metadata holder reads the trail the operator reads`, async () => { + await expectAdmitted(h, h.tokAuthor, door); + }); + } + + it('commits: CONTROL — the commits door never admitted manage_metadata, and still refuses it', async () => { + await expectRefused(h, h.tokAuthor, 'commits'); + }); + + for (const door of DOORS) { + it(`${door}: a setup.access-only holder is refused here too`, async () => { + await expectRefused(h, h.tokSetup, door); + }); + } +}); diff --git a/packages/rest/src/meta-history-diff-authoring-door.test.ts b/packages/rest/src/meta-history-diff-authoring-door.test.ts index 3f7f3bc901c..08f0bdbc07a 100644 --- a/packages/rest/src/meta-history-diff-authoring-door.test.ts +++ b/packages/rest/src/meta-history-diff-authoring-door.test.ts @@ -51,6 +51,15 @@ * one refusal (`refuseNonAuthoringCaller`), and every pin below runs over all * three. The draft saves here are made by the `author` caller, so the actor a * refusal must never carry is a real one. + * + * ## [#23546] The trail drops `setup.access` + * + * Ruling 6187280122 (letter A), amended by 6190477105: the three doors admit + * `isSystem`, `studio.access` and `manage_metadata`, and no longer + * `setup.access`, which organization admins hold. `GET /meta/_drafts` and the + * other draft switches keep `mayReadPendingDrafts`, so the `setupAdmin` caller + * below is refused the trail and still lists drafts. The walled pin on a real + * booted stack is `packages/qa/dogfood/test/wall-authoring-trail-doors.dogfood.test.ts`. */ import { describe, it, expect, afterEach, vi } from 'vitest'; @@ -74,11 +83,13 @@ const CALLERS = { member: { userId: 'u_member', systemPermissions: [] as string[] }, /** The three authoring capabilities `/meta/_drafts` admits, one each. */ studioBuilder: { userId: 'u_studio', systemPermissions: ['studio.access'] }, + /** [#23546] The organization admin's capability: `/meta/_drafts` admits it, the trail does not. */ setupAdmin: { userId: 'u_setup', systemPermissions: ['setup.access'] }, author: { userId: 'u_author', systemPermissions: ['manage_metadata'] }, } as const; type CallerName = keyof typeof CALLERS; -const BUILDERS = ['studioBuilder', 'setupAdmin', 'author'] as const satisfies readonly CallerName[]; +/** [#23546] The callers the trail admits: `/meta/_drafts`'s builders minus `setupAdmin`. */ +const BUILDERS = ['studioBuilder', 'author'] as const satisfies readonly CallerName[]; /** Of the builders, the one the app's save door admits: ruling 5856774816's author exemption. */ const SAVES_APPS: readonly CallerName[] = ['author']; @@ -406,16 +417,59 @@ describe('[#20378] /layers and ?layers=true are unchanged for the member — the }, 60_000); }); -describe('[#20378 · #20441] one predicate: /diff, /history and /audit refuse exactly the callers /meta/_drafts refuses', () => { - it('for each caller, the four doors agree', async () => { +describe('[#23546] a setup.access-only caller is refused /diff, /history and /audit, and still lists /meta/_drafts', () => { + for (const suffix of AUTHORING_DOORS) { + it(`${suffix}: 403 FORBIDDEN in the /meta/_drafts envelope, before any read; /meta/_drafts still answers 200`, async () => { + const { door, drafts, spies, resetSpies } = await boot(); + const listing = await drafts('setupAdmin'); + expect(listing.statusCode).toBe(200); + const refusal = await drafts('member'); + expect(envelope(refusal)).toEqual({ status: 403, code: 'FORBIDDEN' }); + for (const type of Object.keys(SUBJECTS) as SubjectType[]) { + const names = SUBJECTS[type]; + resetSpies(); + const answers = [ + await door('setupAdmin', suffix, type, names.published), + await door('setupAdmin', suffix, type, names.draftOnly), + await door('setupAdmin', suffix, type, names.missing), + ]; + for (const res of answers) { + expect(envelope(res), `${type}`).toEqual({ status: 403, code: 'FORBIDDEN' }); + expect(shape(res), `${type}`).toEqual(shape(refusal)); + for (const name of Object.values(names)) expect(text(res)).not.toContain(name); + for (const k of ANSWER_KEYS) expect(text(res)).not.toContain(k); + } + expect(answers[1].body).toEqual(answers[0].body); + expect(answers[2].body).toEqual(answers[0].body); + for (const [member, spy] of Object.entries(spies)) expect(spy, `${type} ${member}`).not.toHaveBeenCalled(); + } + // The control: the same door, the same item, a `studio.access` caller. + const builder = await door('studioBuilder', suffix, 'view', 'opportunity.pipeline'); + expect(builder.statusCode).toBe(200); + expect(spies[DOOR_READ[suffix]]).toHaveBeenCalled(); + }, 60_000); + } +}); + +describe('[#23546] the trail admits a strict subset of the callers /meta/_drafts admits', () => { + it('every caller the trail admits lists drafts; setup.access alone lists drafts and is refused the trail', async () => { const { door, drafts } = await boot(); - for (const who of ['member', ...BUILDERS] as const) { - const refused = (await drafts(who)).statusCode === 403; + const admitted: Record = {}; + for (const who of Object.keys(CALLERS) as CallerName[]) { + const lists = (await drafts(who)).statusCode === 200; + const reads: boolean[] = []; for (const suffix of AUTHORING_DOORS) { const res = await door(who, suffix, 'view', 'opportunity.pipeline'); - expect(res.statusCode === 403, `${who} ${suffix}`).toBe(refused); + expect([200, 403], `${who} ${suffix}`).toContain(res.statusCode); + reads.push(res.statusCode === 200); } + // The three doors share one refusal, so they agree for every caller. + expect(new Set(reads).size, who).toBe(1); + // The trail never admits a caller /meta/_drafts refuses. + if (reads[0]) expect(lists, who).toBe(true); + admitted[who] = reads[0]; } - expect((await drafts('member')).statusCode).toBe(403); + expect(admitted).toEqual({ system: true, member: false, studioBuilder: true, setupAdmin: false, author: true }); + expect((await drafts('setupAdmin')).statusCode).toBe(200); }, 60_000); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index da10d3841b4..0571f0b5617 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1852,10 +1852,49 @@ export function metaItemPackageBinding(raw: unknown): string | undefined { return typeof raw === 'string' && raw !== '' && raw !== 'all' ? raw : undefined; } +/** + * [#23546] The capabilities that admit a caller to the AUTHORING TRAIL — + * `GET /meta/:type/:name/history`, `/audit` and `/diff` — beside `isSystem`. + * + * Both are platform-scoped: plugin-security's `PLATFORM_ADMIN_ONLY_CAPABILITIES` + * lists them, and no organization administrator holds either by role. + * `setup.access` is left out on purpose. Organization administrators hold it, + * and under a wall it handed each organization's administrator the + * environment-wide trail of every item: who saved it, when, and the superseded + * bodies. No organization authors that trail (ADR-0131 C5), and the data door + * on the same three tables already refuses them + * (`requiredPermissions: ['studio.access']`, ADR-0131 D7). + */ +const AUTHORING_TRAIL_READ_CAPABILITIES: readonly string[] = ['studio.access', 'manage_metadata']; + +/** + * [#23546] May this caller read the AUTHORING TRAIL of a metadata item — its + * version history, its audit rows and the diff of its stored versions? The + * admission {@link refuseNonAuthoringCaller} asks, and nothing else asks it. + * + * A system caller, or a holder of one of + * {@link AUTHORING_TRAIL_READ_CAPABILITIES}, read from `systemPermissions` + * exactly as `isObjectSchemaMaskExempt` reads it. + * + * It is a strict subset of {@link mayReadPendingDrafts}: it admits nobody the + * draft predicate refuses, and it refuses the `setup.access`-only caller the + * draft predicate admits. The draft switches (`GET /meta/_drafts`, + * `?state=draft`, `?preview=draft`) keep `mayReadPendingDrafts`, unchanged. + * `meta-history-diff-authoring-door.test.ts` holds the two predicates to that + * relation, caller by caller. + */ +function mayReadAuthoringTrail(caller: unknown): boolean { + if (!caller || typeof caller !== 'object') return false; + const ctx = caller as { isSystem?: unknown; systemPermissions?: unknown }; + if (ctx.isSystem === true) return true; + return Array.isArray(ctx.systemPermissions) + && ctx.systemPermissions.some((p) => typeof p === 'string' && AUTHORING_TRAIL_READ_CAPABILITIES.includes(p)); +} + /** * [#20378 · #20441] THE AUTHORING-DOOR REFUSAL — ruling 5865708652 (letter B), * carried to `/audit` by triage's grade 5871509797. Sends it and answers `true` - * when {@link mayReadPendingDrafts} does not admit `caller`; answers `false`, + * when {@link mayReadAuthoringTrail} does not admit `caller`; answers `false`, * sending nothing, when it does. The `refuseRepeatedQueryParams` convention: * `if (refuseNonAuthoringCaller(ctx, res, …)) return;`. * @@ -1864,10 +1903,14 @@ export function metaItemPackageBinding(raw: unknown): string | undefined { * event is read: `/history` and `/diff` read `sys_metadata_history`, and * `/audit` reads `sys_metadata_audit`. Both logs record a DRAFT save exactly * as they record an active one, so they have no published-only answer to fall - * back to, and a caller who may not read pending drafts is refused as - * `GET /meta/_drafts` refuses them: 403 `FORBIDDEN`, the same nested - * envelope. The answer is the same for an item that exists, one that does - * not and a draft-only one, so the door is no existence oracle. + * back to, and a caller the trail does not admit is refused in + * `GET /meta/_drafts`'s shape: 403 `FORBIDDEN`, the same nested envelope. The + * answer is the same for an item that exists, one that does not and a + * draft-only one, so the door is no existence oracle. + * + * [#23546] The admission is {@link mayReadAuthoringTrail}, no longer + * {@link mayReadPendingDrafts}: the trail drops `setup.access`, and the draft + * switches keep it. * * `reading` names THE DOOR and never drafts: a refusal worded about drafts * would read as "this item has one". This is ONE function so the three doors @@ -1875,11 +1918,12 @@ export function metaItemPackageBinding(raw: unknown): string | undefined { * door's own name differs between them. */ function refuseNonAuthoringCaller(caller: unknown, res: any, reading: string): boolean { - if (mayReadPendingDrafts(caller)) return false; + if (mayReadAuthoringTrail(caller)) return false; res.status(403).json({ error: { code: 'FORBIDDEN', - message: `${reading} requires an authoring capability (studio.access, setup.access or manage_metadata).`, + message: `${reading} requires an authoring capability (studio.access or manage_metadata); ` + + 'setup.access, which organization administrators hold, does not admit the authoring trail.', }, }); return true; @@ -7557,8 +7601,8 @@ export class RestServer { // not read pending drafts, lists unpublished authoring work // (ADR-0106 D4: 「draft/preview reads are admin-gated // upstream」). The caller is asked - // {@link mayReadPendingDrafts} FIRST, and one it does not - // admit is refused exactly as `GET /meta/_drafts` refuses + // {@link mayReadAuthoringTrail} FIRST, and one it does not + // admit is refused in `GET /meta/_drafts`'s shape // (403 `FORBIDDEN`, the same nested envelope): before the // protocol is resolved (no 501-vs-200 probe), before the // query is parsed, before any item or event is read. The @@ -7569,6 +7613,9 @@ export class RestServer { // drafts: a refusal worded about drafts would read as // "this item has one". Whoever it admits reads exactly what // they read before, the per-caller refusal below included. + // [#23546] `setup.access` alone no longer admits it: under a + // wall it handed every organization administrator the + // environment-wide trail (see AUTHORING_TRAIL_READ_CAPABILITIES). // // Ruling 5856774816 (#20156) item 2 is narrowed for this // door and `/diff` only: `/layers` and `?layers=true` read @@ -7705,13 +7752,14 @@ export class RestServer { // withholding only the draft-save rows would hand a member // a pruned log that reads as a true, complete one, the // shape the ruling measured wrong. So the caller is asked - // {@link mayReadPendingDrafts} FIRST and refused by + // {@link mayReadAuthoringTrail} FIRST and refused by // {@link refuseNonAuthoringCaller} — the refusal `/history` // and `/diff` give, and `GET /meta/_drafts`'s shape — before // the protocol is resolved (no 501-vs-200 probe), before the // query is parsed, and before any item or event is read. // Whoever it admits reads exactly what they read before, - // the per-caller refusal below included. + // the per-caller refusal below included. [#23546] + // `setup.access` alone no longer admits it. // // `auditCtx` is this door's one caller resolution. const auditCtx = await this.resolveExecCtx(environmentId, req).catch(rethrowAuthzStoreUnavailable); @@ -8166,8 +8214,8 @@ export class RestServer { // so there is no exact published-only answer to fall back // to: the `/meta/_drafts` shape, not the draft switches' // "answer as if absent". The caller is asked - // {@link mayReadPendingDrafts} FIRST, and one it does not - // admit is refused exactly as `GET /meta/_drafts` refuses + // {@link mayReadAuthoringTrail} FIRST, and one it does not + // admit is refused in `GET /meta/_drafts`'s shape // (403 `FORBIDDEN`, the same nested envelope): before the // protocol is resolved, before the query is parsed, before // the mask posture, the current document or any version is @@ -8177,6 +8225,7 @@ export class RestServer { // door, never drafts. Whoever it admits reads exactly what // they read before: every per-caller gate below, and ruling // 5856774816's author exemption, still apply to them. + // [#23546] `setup.access` alone no longer admits it. // // Ruling 5856774816 (#20156) item 2 is narrowed for this // door and `/history` only: `/layers` and `?layers=true` diff --git a/packages/runtime/src/domains/packages-capability-gate.test.ts b/packages/runtime/src/domains/packages-capability-gate.test.ts index 85349231d3c..09a7af8afeb 100644 --- a/packages/runtime/src/domains/packages-capability-gate.test.ts +++ b/packages/runtime/src/domains/packages-capability-gate.test.ts @@ -12,6 +12,12 @@ * #6603 / #7019 on `/meta` writes), and the ADR-0106 D4 read set * (`studio.access` / `setup.access`) on every read. * + * [#23546] Except the commit history: ruling 6187280122 (letter A), amended by + * 6190477105, narrows `GET /packages/:id/commits` to `studio.access` (or + * `isSystem`), the data door's rule on `sys_metadata_commit`. A + * `setup.access`-only caller (the organization admin's shape) is refused it + * and still reads the other three (section 4b). + * * ## One handler, every transport * * Unlike `/meta` — whose REST and dispatcher transports are two SEPARATE handler @@ -271,18 +277,17 @@ describe('/packages discard-drafts — the destructive route refuses BEFORE any }); // ══════════════════════════════════════════════════════════════════════════════ -// 4. Read gate — `studio.access` / `setup.access` on every read +// 4. Read gate — `studio.access` / `setup.access` on every read but the commits // ══════════════════════════════════════════════════════════════════════════════ 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', () => { +describe('/packages — read gate: every read but the commit history 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(); @@ -326,6 +331,59 @@ describe('/packages — read gate: every read demands studio.access or setup.acc } }); +// ══════════════════════════════════════════════════════════════════════════════ +// 4b. [#23546] The commit history — `studio.access` (or `isSystem`) alone +// ══════════════════════════════════════════════════════════════════════════════ + +describe('[#23546] /packages/:id/commits — the commit trail demands studio.access; setup.access no longer reads it', () => { + const COMMITS = '/pkg-a/commits'; + + for (const [label, caps] of [ + ['zero-capability', []], + ['setup.access-only (the organization admin\'s capability)', ['setup.access']], + ['the organization admin\'s full grant (setup.access, setup.write, manage_org_users, manage_mailboxes)', ['setup.access', 'setup.write', 'manage_org_users', 'manage_mailboxes']], + ['WRITE-only (manage_metadata), which this door never admitted', ['manage_metadata']], + ['setup.access + manage_metadata', ['setup.access', 'manage_metadata']], + ] as const) { + it(`403 PERMISSION_DENIED for a ${label} caller, and listCommits never runs`, async () => { + const protocol = fullProtocol(); + const { dispatcher } = make({ protocol }); + const r = await dispatcher.handlePackages(COMMITS, 'GET', undefined, {}, authed([...caps])); + expect(r.response?.status).toBe(403); + expect(r.response?.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(protocol.listCommits).not.toHaveBeenCalled(); + }); + } + + it('200 for a studio.access caller, and listCommits runs', async () => { + const protocol = fullProtocol(); + const { dispatcher } = make({ protocol }); + const r = await dispatcher.handlePackages(COMMITS, 'GET', undefined, {}, authed(['studio.access'])); + expect(r.response?.status).toBe(200); + expect(protocol.listCommits).toHaveBeenCalledTimes(1); + }); + + it('200 for an isSystem caller, and listCommits runs', async () => { + const protocol = fullProtocol(); + const { dispatcher } = make({ protocol }); + const r = await dispatcher.handlePackages(COMMITS, 'GET', undefined, {}, system()); + expect(r.response?.status).toBe(200); + expect(protocol.listCommits).toHaveBeenCalledTimes(1); + }); + + it('the same setup.access-only caller still reads GET /packages, /:id and /:id/export — only the commits door narrowed', async () => { + const protocol = fullProtocol(); + const { dispatcher } = make({ protocol }); + for (const rc of READ_ROUTES) { + const r = await dispatcher.handlePackages(rc.path, 'GET', undefined, {}, authed(['setup.access'])); + expect(r.response?.status, rc.name).not.toBe(403); + expect(r.response?.status, rc.name).not.toBe(401); + } + const commits = await dispatcher.handlePackages(COMMITS, 'GET', undefined, {}, authed(['setup.access'])); + expect(commits.response?.status).toBe(403); + }); +}); + // ══════════════════════════════════════════════════════════════════════════════ // 5. The two cohorts are genuinely different (write ≠ read) // ══════════════════════════════════════════════════════════════════════════════ diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 20fc9b7cd2c..93319cef1c9 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -325,9 +325,9 @@ function requireManageMetadata(deps: DomainHandlerDeps, context: HttpProtocolCon * * 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 + * `GET /packages/:id` detail 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 * read-only ({@link OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES} = * `studio.access` / `setup.access`), or `isSystem`. The read cohort is not the * write cohort: an `organization_admin` holding `setup.access` (but not @@ -336,6 +336,8 @@ function requireManageMetadata(deps: DomainHandlerDeps, context: HttpProtocolCon * pinned in `packages-capability-gate.test.ts`, beside this file. (#7020 * unified the object-schema MASK exemption with the write gate; it did not * re-rule this cohort, which is why this site names the read-only half.) + * [#23546] `GET /packages/:id/commits` left this cohort: the commit trail has + * its own gate, {@link requireCommitTrailReadCapability}. * * 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 @@ -353,6 +355,45 @@ function requireReadCapability(deps: DomainHandlerDeps, context: HttpProtocolCon return null; } +/** + * [#23546] The READ gate of `GET /packages/:id/commits` alone — a package's + * commit trail (ADR-0067), read from `sys_metadata_commit`. + * + * It admits `studio.access` or `isSystem`. That is the data door's rule on the + * same table (`requiredPermissions: ['studio.access']`, ADR-0131 D7), and the + * `/meta` trail doors' rule (`GET /meta/:type/:name/history`, `/audit` and + * `/diff` in `RestServer`). + * + * `setup.access` is left out on purpose. Organization administrators hold it, + * and under a wall it handed each organization's administrator every + * package's commit trail: who published what, and when. No organization + * authors that trail (ADR-0131 C5). + * + * `manage_metadata` is not admitted either. This door's read cohort never + * admitted it, and narrowing a door does not widen it. The other three reads + * (`GET /packages`, `/packages/:id` and `/export`) keep + * {@link requireReadCapability}. Pinned in `packages-capability-gate.test.ts`, + * beside this file. + * + * Returns a 403 result to short-circuit on, or `null` to proceed. It runs + * BEFORE the protocol is resolved, as the read gate does. + */ +function requireCommitTrailReadCapability(deps: DomainHandlerDeps, context: HttpProtocolContext): HttpDispatcherResult | null { + const ec: any = context?.executionContext; + const held = new Set(ec?.systemPermissions ?? []); + if (!ec?.isSystem && !held.has('studio.access')) { + return { + handled: true, + response: deps.error( + 'Reading a package\'s commit history requires the `studio.access` capability; ' + + '`setup.access`, which organization administrators hold, does not admit it.', + 403, + ), + }; + } + return null; +} + /** * ADR-0070 READ-ONLY gate for the destructive `/packages` LIFECYCLE routes * (#7560). @@ -1926,7 +1967,9 @@ 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; + // [#23546] The commit trail's own gate: `studio.access`, not the + // package read cohort's `setup.access`. + const denied = requireCommitTrailReadCapability(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.listCommits === 'function') {