From 07122618fb201eba4fb7319c55407a6ca11d8786 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 12:09:37 +0000 Subject: [PATCH 1/2] wip: org-scoped presentation capability (#12702) --- .../org-scoped-presentation-capability.md | 41 ++++ packages/metadata-core/src/index.ts | 9 + .../src/meta-write-capability.test.ts | 200 ++++++++++++++++++ .../src/meta-write-capability.ts | 175 +++++++++++++++ .../meta-item-save-capability-gate.test.ts | 116 ++++++++++ ...-write-door-capability-enumeration.test.ts | 83 ++++++++ packages/rest/src/rest-route-ledger.ts | 9 +- packages/rest/src/rest-server.ts | 156 ++++++++++---- .../domains/meta-save-capability-gate.test.ts | 142 +++++++++++++ packages/runtime/src/domains/meta.ts | 53 ++++- packages/runtime/src/route-ledger.ts | 3 +- packages/spec/src/security/capabilities.ts | 19 ++ 12 files changed, 954 insertions(+), 52 deletions(-) create mode 100644 .changeset/org-scoped-presentation-capability.md create mode 100644 packages/metadata-core/src/meta-write-capability.test.ts create mode 100644 packages/metadata-core/src/meta-write-capability.ts diff --git a/.changeset/org-scoped-presentation-capability.md b/.changeset/org-scoped-presentation-capability.md new file mode 100644 index 0000000000..e3c8409b4e --- /dev/null +++ b/.changeset/org-scoped-presentation-capability.md @@ -0,0 +1,41 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-core": minor +"@objectstack/rest": minor +"@objectstack/runtime": minor +--- + +feat(security,meta): org-scoped presentation authoring capability `manage_org_presentation` (#12702) + +A tenant org admin in a walled posture can now be granted org-scoped authoring +of exactly the org-overridable presentation types (ADR-0005 tier A: view / +dashboard / report / translation / email_template today — the registry is the +authority) without holding platform-wide `manage_metadata` (maintainer +direction 2026-08-27, quoted in #12701). + +- **spec**: new curated `PLATFORM_CAPABILITIES` entry `manage_org_presentation` + (`scope: 'org'`), seeded into `sys_capability` at boot like its siblings. + Granted by NO shipped permission set — the SaaS operator grants it per + deployment, so existing postures (`single` included) are byte-unchanged by + its existence. +- **metadata-core**: new `metaWriteCapabilityVerdict` — the capability half of + the `/meta` write decision, beside the existing org-scope half + (`organizationIdForMetaWrite`). It admits `isSystem` and `manage_metadata` + exactly as before, and `manage_org_presentation` ONLY when the target type's + registry entry declares `allowOrgOverride: true` (registry-derived via + `declaresOrgOverride`, never a hand-written list) AND the session has an + active organization — the very organization the doors thread, so an admitted + write can only land org-scoped in the caller's own partition: never tier-B, + never env-wide, never another org's. +- **rest**: the four `/meta` item write doors (`PUT` save, `DELETE` reset, + `POST /publish`, `POST /rollback`) run the shared verdict. + `POST /meta/_migrate-stored` stays `manage_metadata`-only — an install-wide + rewrite is env-wide by definition. +- **runtime**: the dispatcher `/meta` `PUT` door runs the same shared verdict + (its `_migrate-stored` twin likewise stays `manage_metadata`-only). + +Refusals keep their transports' existing envelopes (REST `403 FORBIDDEN`, +dispatcher `403 PERMISSION_DENIED`); the tier-B refusal sentence is +byte-identical to before, and the tier-A sentences name the sanctioned path +without disclosing the caller's own grants (#7450). Platform `manage_metadata` +behaviour is unchanged on every door. diff --git a/packages/metadata-core/src/index.ts b/packages/metadata-core/src/index.ts index 1cafea127e..1746905f05 100644 --- a/packages/metadata-core/src/index.ts +++ b/packages/metadata-core/src/index.ts @@ -87,6 +87,15 @@ export * from './item-key-discriminators.js'; // so its behaviour is unchanged and there is no second copy to drift. export * from './meta-write-org-scope.js'; +// [#12702] The capability half of the same decision: which CALLERS a `/meta` +// item write door admits — `manage_metadata` as before, plus the org-scoped +// `manage_org_presentation` for org-overridable types written to the caller's +// own active organization. Sunk here by the same criterion as the scope half +// above: the doors live in `@objectstack/runtime` and `@objectstack/rest`, +// which share no other common home, and the predicate is registry-coupled +// (through `declaresOrgOverride`) so a second copy is forbidden drift. +export * from './meta-write-capability.js'; + // [#8707 / #10101] The shared platform-row organization resolver — sunk here // from `@objectstack/plugin-audit` per the maintainer ruling recorded on // cloud#1395 ("promoted to a shared resolver used by all three platform-row diff --git a/packages/metadata-core/src/meta-write-capability.test.ts b/packages/metadata-core/src/meta-write-capability.test.ts new file mode 100644 index 0000000000..bde6fc7149 --- /dev/null +++ b/packages/metadata-core/src/meta-write-capability.test.ts @@ -0,0 +1,200 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12702] `metaWriteCapabilityVerdict` — which CALLERS a `/meta` item write + * door admits. + * + * The contract under test (maintainer direction 2026-08-27, quoted in #12701): + * `manage_org_presentation` is a SUBSET key beside platform `manage_metadata` + * — it admits a write ONLY for a type whose registry entry declares + * `allowOrgOverride: true` AND a session with an active organization (the very + * organization the door threads). `manage_metadata` and `isSystem` behave + * exactly as before. + * + * ## Registry-derived, so the truth table is the REGISTRY's + * + * The tier-A membership cases iterate `DEFAULT_METADATA_TYPE_REGISTRY` rather + * than a hand-written five-type list (Prime Directive #8). The IDENTITY of the + * five org-overridable types is pinned elsewhere, on the protocol's own + * refusal (`protocol.org-scoped-write-refused.test.ts`) — this file pins that + * the verdict MOVES WITH the registry, whatever the registry says. + * + * ## Refusal messages are envelope halves, not the envelope + * + * The verdict returns a message; each DOOR supplies its own status/code + * (REST `403 FORBIDDEN`, dispatcher `403 PERMISSION_DENIED` — pinned in their + * own gate suites). What is pinned HERE about messages: + * - the tier-B sentence is BYTE-IDENTICAL to the pre-#12702 one (the + * platform's most common metadata refusal stays stable — the + * `single`-posture stability half of the card's acceptance); + * - the tier-A sentences name the sanctioned path (both capabilities) and + * never the caller's own grants (#7450: the message varies only on + * request/session-derived facts, so the same request shape answers the + * same sentence whatever the caller holds). + */ + +import { describe, it, expect } from 'vitest'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; +import { canonicalMetaUrlType } from '@objectstack/spec/shared'; +import { + METADATA_AUTHORING_CAPABILITY, + ORG_PRESENTATION_AUTHORING_CAPABILITY, + metaWriteCapabilityVerdict, + type MetaWriteOperation, +} from './meta-write-capability.js'; + +const ORG = 'org_a'; + +/** Shorthand: verdict for a caller shape against a canonical type. */ +function verdict(input: { + isSystem?: boolean; + held?: unknown; + type: string; + org?: string | undefined; + operation?: MetaWriteOperation; +}) { + return metaWriteCapabilityVerdict({ + ...(input.isSystem !== undefined ? { isSystem: input.isSystem } : {}), + systemPermissions: input.held ?? [], + canonicalType: input.type, + activeOrganizationId: input.org, + operation: input.operation ?? 'save', + }); +} + +describe('#12702 — the declaration cannot drift from the enforcement spelling', () => { + it('`manage_org_presentation` is a curated PLATFORM_CAPABILITIES entry with scope org', () => { + const declared = PLATFORM_CAPABILITIES.find( + (c) => c.name === ORG_PRESENTATION_AUTHORING_CAPABILITY, + ); + expect(declared).toBeDefined(); + expect(declared!.scope).toBe('org'); + }); + + it('`manage_metadata` stays the platform-scoped authoring capability', () => { + const declared = PLATFORM_CAPABILITIES.find( + (c) => c.name === METADATA_AUTHORING_CAPABILITY, + ); + expect(declared).toBeDefined(); + expect(declared!.scope).toBe('platform'); + }); +}); + +describe('#12702 — the unchanged paths: isSystem and manage_metadata', () => { + it('isSystem is admitted unconditionally — tier-B type, no organization', () => { + expect(verdict({ isSystem: true, type: 'object' })).toEqual({ allowed: true }); + }); + + it('manage_metadata is admitted for a tier-B type with no organization (env-wide, as today)', () => { + expect(verdict({ held: ['manage_metadata'], type: 'object' })).toEqual({ allowed: true }); + expect(verdict({ held: ['manage_metadata'], type: 'flow' })).toEqual({ allowed: true }); + }); + + it('manage_metadata is admitted for a tier-A type with and without an organization', () => { + expect(verdict({ held: ['manage_metadata'], type: 'view' })).toEqual({ allowed: true }); + expect(verdict({ held: ['manage_metadata'], type: 'view', org: ORG })).toEqual({ allowed: true }); + }); +}); + +describe('#12702 — org-scoped tier-A admission, derived from the registry', () => { + // The whole registry, both directions — no hand-written type list. A + // registry entry flipping `allowOrgOverride` moves this table the same day + // with nothing to keep in sync (and the five-type IDENTITY pin in + // metadata-protocol goes red, which is that pin working). + for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { + const expected = entry.allowOrgOverride === true; + it(`'${entry.type}' (allowOrgOverride: ${String(entry.allowOrgOverride ?? false)}) → holder with active org is ${expected ? 'ADMITTED' : 'REFUSED'}`, () => { + const out = verdict({ + held: [ORG_PRESENTATION_AUTHORING_CAPABILITY], + type: entry.type, + org: ORG, + }); + expect(out.allowed).toBe(expected); + }); + } + + it('a type with NO registry entry at all (runtime plugin type) is refused — same posture as boot hydration', () => { + const out = verdict({ held: [ORG_PRESENTATION_AUTHORING_CAPABILITY], type: 'agent_tool_custom', org: ORG }); + expect(out.allowed).toBe(false); + }); + + it('the boundary fold composes: a URL-only spelling folded through canonicalMetaUrlType is admitted', () => { + // `email_templates` is a URL-only spelling (`SINGULAR_TO_PLURAL` has no + // manifest key for it — the #10340 measurement). The doors fold BEFORE + // asking; this case pins that the folded spelling answers tier-A. + expect(canonicalMetaUrlType('email_templates')).toBe('email_template'); + const out = verdict({ + held: [ORG_PRESENTATION_AUTHORING_CAPABILITY], + type: canonicalMetaUrlType('email_templates'), + org: ORG, + }); + expect(out).toEqual({ allowed: true }); + }); +}); + +describe('#12702 — the walls: env-wide, foreign-scope, and no-capability shapes', () => { + it('tier-A with NO active organization is refused — the write would land env-wide', () => { + const out = verdict({ held: [ORG_PRESENTATION_AUTHORING_CAPABILITY], type: 'view' }); + expect(out.allowed).toBe(false); + if (!out.allowed) { + expect(out.message).toContain('`manage_metadata`'); + expect(out.message).toContain('active organization'); + expect(out.message).toContain('environment-wide'); + } + }); + + it("an empty-string organization is absent, not an organization (the conservative direction)", () => { + const out = verdict({ held: [ORG_PRESENTATION_AUTHORING_CAPABILITY], type: 'view', org: '' }); + expect(out.allowed).toBe(false); + }); + + it('holding nothing relevant is refused on every tier', () => { + expect(verdict({ held: [], type: 'view', org: ORG }).allowed).toBe(false); + expect(verdict({ held: ['setup.access', 'studio.access'], type: 'view', org: ORG }).allowed).toBe(false); + expect(verdict({ held: ['manage_org_users'], type: 'object', org: ORG }).allowed).toBe(false); + }); + + it('a non-array systemPermissions is nothing held, never a throw (fail closed)', () => { + expect(verdict({ held: undefined, type: 'view', org: ORG }).allowed).toBe(false); + expect(verdict({ held: 'manage_metadata', type: 'view', org: ORG }).allowed).toBe(false); + expect(verdict({ held: { has: () => true }, type: 'view', org: ORG }).allowed).toBe(false); + }); +}); + +describe('#12702 — refusal sentences (#7450: request-derived, never caller-derived)', () => { + it('tier-B keeps the pre-#12702 sentence BYTE-IDENTICAL — for every caller shape', () => { + const legacy = 'Saving a metadata item requires the `manage_metadata` capability.'; + const noCaps = verdict({ held: [], type: 'object', org: ORG }); + const holder = verdict({ held: [ORG_PRESENTATION_AUTHORING_CAPABILITY], type: 'object', org: ORG }); + expect(noCaps).toEqual({ allowed: false, message: legacy }); + // The SAME sentence for the org-presentation holder: the message varies + // on the request's tier, never on what this caller holds (#7450). + expect(holder).toEqual({ allowed: false, message: legacy }); + }); + + it('tier-A with an active org names BOTH sanctioned paths — identically for every refused caller shape', () => { + const noCaps = verdict({ held: [], type: 'view', org: ORG }); + const unrelated = verdict({ held: ['setup.access'], type: 'view', org: ORG }); + expect(noCaps.allowed).toBe(false); + if (!noCaps.allowed) { + expect(noCaps.message).toContain('`manage_metadata`'); + expect(noCaps.message).toContain('`manage_org_presentation`'); + expect(noCaps.message).toContain('active organization'); + } + expect(unrelated).toEqual(noCaps); + }); + + it('each door verb keeps its own pre-#12702 tier-B subject', () => { + const subject: Record = { + save: 'Saving a metadata item requires the `manage_metadata` capability.', + reset: 'Resetting a metadata item requires the `manage_metadata` capability.', + publish: 'Publishing a metadata item requires the `manage_metadata` capability.', + rollback: 'Rolling back a metadata item requires the `manage_metadata` capability.', + }; + for (const op of Object.keys(subject) as MetaWriteOperation[]) { + const out = verdict({ held: [], type: 'object', org: ORG, operation: op }); + expect(out).toEqual({ allowed: false, message: subject[op] }); + } + }); +}); diff --git a/packages/metadata-core/src/meta-write-capability.ts b/packages/metadata-core/src/meta-write-capability.ts new file mode 100644 index 0000000000..31ee77eefd --- /dev/null +++ b/packages/metadata-core/src/meta-write-capability.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12702] Which CALLERS a `/meta` item write door admits — the capability + * half of the decision whose SCOPE half lives next door in + * `meta-write-org-scope.ts`. + * + * ── The gap this closes ─────────────────────────────────────────────────── + * + * `manage_metadata` (ADR-0066 D1) was the only metadata-authoring capability, + * and it is `scope: 'platform'`: the one key that unlocks a tenant org admin's + * per-org tier-A overlays (view / dashboard / report / translation / + * email_template — ADR-0005) ALSO unlocks env-wide tier-B authoring (flows, + * objects — cross-tenant reach). So a single-DB SaaS operator in a walled + * posture could not let tenants customize presentation at all, even though the + * per-org overlay mechanism under the door is complete. Maintainer direction + * (2026-08-27, quoted verbatim in #12701): tenant org admins get org-scoped + * authoring of exactly the org-overridable types via a dedicated org-scoped + * capability; platform `manage_metadata` behaviour unchanged. + * + * ── The contract ────────────────────────────────────────────────────────── + * + * `manage_org_presentation` (declared `scope: 'org'` in `PLATFORM_CAPABILITIES`, + * `@objectstack/spec/security`) is a SUBSET key, not a re-keying: + * + * - `isSystem` and `manage_metadata` behave exactly as before — first, and + * unconditionally. + * - `manage_org_presentation` admits a write ONLY when BOTH hold: + * 1. the target type's registry entry declares `allowOrgOverride: true` + * ({@link declaresOrgOverride} — the SAME registry-derived predicate + * that decides the write's organization scope, so "the types this + * capability reaches" and "the types whose writes carry the caller's + * org" cannot drift; Prime Directive #8: never a hand-written list); + * 2. the session HAS an active organization — which is the organization + * the door will thread via {@link organizationIdForMetaWrite}. No + * active org ⇒ the write would land env-wide (`organization_id NULL`, + * visible to every tenant) ⇒ refused. A foreign organization is not + * expressible on these doors at all: both transports derive the + * organization from the caller's own session (REST `ctx.tenantId`, + * dispatcher `resolveActiveOrganizationId`), never from the request, + * and the save request is built field by field so the body cannot + * smuggle one. + * + * ── Why the predicate lives HERE ────────────────────────────────────────── + * + * Same criterion as `meta-write-org-scope.ts` one module over (#8805): the + * doors live in `@objectstack/runtime` (dispatcher `/meta` PUT) and + * `@objectstack/rest` (PUT / DELETE / publish / rollback), `runtime` depends on + * `rest` so neither can import from the other, and a second copy of a + * registry-coupled predicate is exactly what Prime Directive #8 forbids. This + * package is the one both already depend on. + * + * ── What deliberately does NOT consult this predicate ───────────────────── + * + * - `POST /meta/_migrate-stored` (both transports): an install-wide stored- + * metadata rewrite is env-wide by definition, so condition 2 can never + * hold — it stays `manage_metadata`-only. + * - Every non-`/meta` `manage_metadata` gate (automation flow authoring, + * package management, activation toggles, datasource admin): those are + * tier-B / platform surfaces; the org capability must not reach them. + * - The read path: org-overlay reads are scoped by + * `organizationIdForMetaRead` for EVERY caller class already and carry no + * capability gate (ADR-0106 masking is the read-side posture). + * + * ── Refusal messages (#7450) ────────────────────────────────────────────── + * + * A refusal names the capability that would admit ANY caller and says nothing + * about this one — the message varies only on REQUEST-derived facts (the + * type's registry tier) and the session's scope (active organization present + * or not), never on what the caller holds. For a type with no per-org channel + * the sentence is byte-identical to the pre-#12702 one: `manage_metadata` is + * the whole sanctioned path there, and the common refusal stays stable. + */ + +import { declaresOrgOverride } from './meta-write-org-scope.js'; + +/** ADR-0066 D1's platform-wide metadata authoring capability. */ +export const METADATA_AUTHORING_CAPABILITY = 'manage_metadata'; + +/** + * [#12702] The org-scoped presentation-authoring capability. Declared in + * `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`, `scope: 'org'`); + * `meta-write-capability.test.ts` pins this spelling to that declaration so + * the two cannot drift. + */ +export const ORG_PRESENTATION_AUTHORING_CAPABILITY = 'manage_org_presentation'; + +/** + * The `/meta` item write doors, by verb family. A closed set on purpose: the + * refusal sentence's subject is derived from it, so a new door states its verb + * here rather than minting free-form prose at the call site. + */ +export type MetaWriteOperation = 'save' | 'reset' | 'publish' | 'rollback'; + +/** The refusal sentence's subject, per door. Matches the pre-#12702 wording. */ +const OPERATION_SUBJECT: Record = { + save: 'Saving a metadata item', + reset: 'Resetting a metadata item', + publish: 'Publishing a metadata item', + rollback: 'Rolling back a metadata item', +}; + +export type MetaWriteCapabilityVerdict = + | { allowed: true } + | { allowed: false; message: string }; + +/** + * May this caller take this `/meta` item write? Returns the verdict and, on + * refusal, the message the door should answer with (the door supplies its own + * transport's status/code envelope: REST answers `403 FORBIDDEN`, the + * dispatcher `403 PERMISSION_DENIED` — both pre-existing spellings, pinned in + * their own gate suites). + * + * `canonicalType` MUST be the URL segment folded through + * `canonicalMetaUrlType` — the boundary folds, the layers below read the + * canonical singular (`metadata-url-spelling.ts`; the #10340 measurement in + * `meta-write-org-scope.ts` is why this is not optional). + * + * `activeOrganizationId` MUST be the same value the door threads into + * {@link organizationIdForMetaWrite} (REST `ctx.tenantId`, dispatcher + * `resolveActiveOrganizationId`) — one resolution feeding authorization AND + * scope, the single-resolution shape the REST doors already carry (#8919). + */ +export function metaWriteCapabilityVerdict(input: { + isSystem?: boolean; + /** The caller's `systemPermissions`; tolerant of a non-array (treated as none held). */ + systemPermissions?: unknown; + /** CANONICAL singular metadata type — fold the URL segment BEFORE asking. */ + canonicalType: string; + /** The caller's own active organization — the org the door will thread. */ + activeOrganizationId: string | undefined; + operation: MetaWriteOperation; +}): MetaWriteCapabilityVerdict { + if (input.isSystem === true) return { allowed: true }; + const held = new Set( + Array.isArray(input.systemPermissions) + ? input.systemPermissions.filter((p): p is string => typeof p === 'string') + : [], + ); + if (held.has(METADATA_AUTHORING_CAPABILITY)) return { allowed: true }; + + const orgOverridable = declaresOrgOverride(input.canonicalType); + // '' is treated as absent, exactly as `orgScopedWriteRefusal`'s falsy check + // reads it — the conservative direction (refuse rather than admit). + const scopedToOwnOrg = typeof input.activeOrganizationId === 'string' + && input.activeOrganizationId.length > 0; + + if (held.has(ORG_PRESENTATION_AUTHORING_CAPABILITY) && orgOverridable && scopedToOwnOrg) { + return { allowed: true }; + } + + const subject = OPERATION_SUBJECT[input.operation]; + if (!orgOverridable) { + // Byte-identical to the pre-#12702 sentence: for a type with no + // per-org overlay channel, `manage_metadata` IS the whole sanctioned + // path, and the platform's most common metadata refusal stays stable. + return { + allowed: false, + message: `${subject} requires the \`manage_metadata\` capability.`, + }; + } + if (!scopedToOwnOrg) { + return { + allowed: false, + message: `${subject} requires the \`manage_metadata\` capability. ` + + `\`manage_org_presentation\` admits only a write scoped to the session's active organization, ` + + `and this session has none — the write would land environment-wide.`, + }; + } + return { + allowed: false, + message: `${subject} requires the \`manage_metadata\` capability, or \`manage_org_presentation\` ` + + `for an org-overridable type written org-scoped to the session's active organization.`, + }; +} diff --git a/packages/rest/src/meta-item-save-capability-gate.test.ts b/packages/rest/src/meta-item-save-capability-gate.test.ts index 463e1282d2..47d6d18000 100644 --- a/packages/rest/src/meta-item-save-capability-gate.test.ts +++ b/packages/rest/src/meta-item-save-capability-gate.test.ts @@ -291,3 +291,119 @@ describe('#6603 — the exempt authoring caller is unaffected', () => { expect(stack.storedLabel()).toBe('Account (renamed)'); }); }); + +/** + * [#12702] `manage_org_presentation` on this door — the org-scoped + * presentation capability. A subset key beside `manage_metadata`: admitted + * ONLY for a type whose registry entry declares `allowOrgOverride: true` AND a + * session with an active organization (`ctx.tenantId` — the very value the + * door threads as the write's organization). Both directions pinned: the + * tier-A admission with the threaded organization ASSERTED, and the tier-B / + * env-wide / foreign-scope refusals with the protocol never entered. + */ +describe('#12702 — PUT /meta/:type/:name: `manage_org_presentation`, org-scoped tier-A admission', () => { + const ORG = 'org_a'; + + /** A lean boot for driving the door with arbitrary `:type` params. */ + function bootDoor(context: Record | undefined) { + const saveMetaItem = vi.fn(async ({ type, name }: any) => ({ success: true, type, name })); + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn().mockResolvedValue({ type: 'view', name: 'org_grid', item: {}, lock: 'none' }), + findData: vi.fn().mockResolvedValue([]), + getData: vi.fn().mockResolvedValue({}), + createData: vi.fn().mockResolvedValue({ id: '1' }), + updateData: vi.fn().mockResolvedValue({}), + deleteData: vi.fn().mockResolvedValue({ success: true }), + saveMetaItem, + }; + const rest = new RestServer( + mockServer() as any, + protocol as any, + { api: { requireAuth: false } } as any, + ); + (rest as any).resolveExecCtx = async () => context; + rest.registerRoutes(); + const route = (rest as any).getRoutes().find( + (r: any) => r.method === 'PUT' && r.path === SINGLE_PATH, + ); + return { + saveMetaItem, + put: async (type: string, name: string, item: unknown, query: Record = {}) => { + const res = mockRes(); + await route!.handler({ params: { type, name }, query, headers: {}, body: item }, res); + return { res, body: res.json.mock.calls.at(-1)?.[0] }; + }, + }; + } + + const HOLDER = { userId: 'u_orgadmin', systemPermissions: ['manage_org_presentation'], tenantId: ORG }; + + it('admits an org-scoped tier-A save, threaded to the caller\'s OWN organization', async () => { + const stack = bootDoor(HOLDER); + const write = await stack.put('view', 'org_grid', { name: 'org_grid', label: 'Org Grid' }); + expect(write.res.statusCode).toBe(200); + expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); + // The threading IS the wall: the only organization an admitted write + // can carry is the caller's own active one. + expect(stack.saveMetaItem.mock.calls[0][0]).toMatchObject({ + type: 'view', name: 'org_grid', organizationId: ORG, + }); + }); + + it('[#10340] the URL-only spelling is folded BEFORE the verdict — `email_templates` is tier-A here too', async () => { + const stack = bootDoor(HOLDER); + const write = await stack.put('email_templates', 'welcome', { name: 'welcome', subject: 'Hi' }); + expect(write.res.statusCode).toBe(200); + expect(stack.saveMetaItem.mock.calls[0][0]).toMatchObject({ + type: 'email_templates', name: 'welcome', organizationId: ORG, + }); + }); + + it.each([ + ['object'], + ['flow'], + ])('refuses the SAME holder a tier-B `%s` write — nothing is written', async (type) => { + const stack = bootDoor(HOLDER); + const write = await stack.put(type, 'account', { label: 'x' }); + expect(write.res.statusCode).toBe(403); + expect(write.body).toMatchObject({ error: { code: 'FORBIDDEN' } }); + // The tier-B sentence is byte-identical to the pre-#12702 one: the + // message varies on the request's tier, never on the caller's own + // grants (#7450). + expect(write.body.error.message).toBe('Saving a metadata item requires the `manage_metadata` capability.'); + expect(stack.saveMetaItem).not.toHaveBeenCalled(); + }); + + it('refuses the SAME holder a tier-A write when the session has NO active organization — env-wide is walled', async () => { + const stack = bootDoor({ userId: 'u_orgadmin', systemPermissions: ['manage_org_presentation'] }); + const write = await stack.put('view', 'org_grid', { name: 'org_grid' }); + expect(write.res.statusCode).toBe(403); + expect(write.body).toMatchObject({ error: { code: 'FORBIDDEN' } }); + expect(String(write.body.error.message)).toContain('active organization'); + expect(stack.saveMetaItem).not.toHaveBeenCalled(); + }); + + it('a foreign organization is not expressible: query/body-smuggled organization ids do not move the threading', async () => { + const stack = bootDoor(HOLDER); + const write = await stack.put( + 'view', 'org_grid', + { name: 'org_grid', organization_id: 'org_b', organizationId: 'org_b' }, + { organizationId: 'org_b' }, + ); + expect(write.res.statusCode).toBe(200); + // The save request is built field by field from named `req` values: + // the write still carries the CALLER's organization. + expect(stack.saveMetaItem.mock.calls[0][0]).toMatchObject({ organizationId: ORG }); + }); + + it('control: `manage_metadata` with no active organization still saves a view env-wide, as today', async () => { + const stack = bootDoor({ userId: 'u_author', systemPermissions: ['manage_metadata'] }); + const write = await stack.put('view', 'org_grid', { name: 'org_grid' }); + expect(write.res.statusCode).toBe(200); + expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); + expect(stack.saveMetaItem.mock.calls[0][0].organizationId).toBeUndefined(); + }); +}); diff --git a/packages/rest/src/meta-write-door-capability-enumeration.test.ts b/packages/rest/src/meta-write-door-capability-enumeration.test.ts index a906c0db14..076976ee6e 100644 --- a/packages/rest/src/meta-write-door-capability-enumeration.test.ts +++ b/packages/rest/src/meta-write-door-capability-enumeration.test.ts @@ -243,6 +243,12 @@ function boot(context: Record | undefined, composition: Composi return { rest, calls, + /** + * [#12702] The protocol double itself, so a case can assert WHAT an + * admitted call carried (the threaded organization), not only that it + * happened. + */ + protocol, /** * Every mutating `/meta` route the composed server actually registers — * on EVERY base it registered one on (see {@link META_SEGMENT}). @@ -529,3 +535,80 @@ describe('#8919 — the two new gates refuse BEFORE the protocol is probed', () expect(res.json.mock.calls.at(-1)?.[0]).toMatchObject({ error: { code: 'FORBIDDEN' } }); }); }); + +/** + * [#12702] `manage_org_presentation` across the door set — the org-scoped + * presentation capability, run against EVERY enumerated door rather than one. + * + * The four ITEM doors (save / reset / publish / rollback) share one verdict + * (`metaWriteCapabilityVerdict`, `@objectstack/metadata-core`): beside + * `manage_metadata` they admit `manage_org_presentation`, ONLY for a type + * whose registry entry declares `allowOrgOverride: true` AND a session with an + * active organization — which is the organization each door threads, so an + * admitted write can only land in the caller's own org partition. + * `_migrate-stored` is the deliberate exclusion: an install-wide rewrite is + * env-wide by definition, so the org condition can never hold there. + */ +describe('#12702 — `manage_org_presentation`: org-scoped tier-A admission, per door', () => { + const ORG = 'org_a'; + const ORG_ADMIN = { userId: 'u_orgadmin', systemPermissions: ['manage_org_presentation'], tenantId: ORG }; + const ORG_ADMIN_NO_ORG = { userId: 'u_orgadmin', systemPermissions: ['manage_org_presentation'] }; + + /** The doors the org capability may open — everything but the install-wide rewrite. */ + const ITEM_DOORS = DOORS.filter((d) => d.protocolMethod !== 'migrateStoredMetadata'); + const MIGRATE_DOOR = DOORS.find((d) => d.protocolMethod === 'migrateStoredMetadata')!; + + /** The same door, addressed at a tier-A type (`view` declares allowOrgOverride). */ + const asView = (door: Door): Door => ({ + ...door, + params: { ...door.params, type: 'view', name: 'org_grid' }, + body: door.protocolMethod === 'saveMetaItem' + ? { name: 'org_grid', label: 'Org Grid' } + : door.body, + }); + + it.each(ITEM_DOORS.map((d) => [d.label, d] as const))( + '%s → an org-active holder is admitted for a tier-A type, threaded to their OWN organization', + async (_label, door) => { + const stack = boot(ORG_ADMIN); + const out = await stack.knock(asView(door)); + expect(out.status).not.toBe(403); + expect(out.status).not.toBe(401); + expect(stack.calls[door.protocolMethod]).toBe(1); + // The threading IS the wall: the only organization an admitted + // write can carry is the caller's own active one. + const request = (stack.protocol[door.protocolMethod] as any).mock.calls[0][0]; + expect(request).toMatchObject({ organizationId: ORG }); + }, + ); + + it.each(ITEM_DOORS.map((d) => [d.label, d] as const))( + '%s → the SAME holder is refused a tier-B write (`object`), protocol never reached', + async (_label, door) => { + const stack = boot(ORG_ADMIN); + const out = await stack.knock(door); // the table's own params: type 'object' + expect(out.status).toBe(403); + expect(out.body).toMatchObject({ error: { code: 'FORBIDDEN' } }); + expect(stack.calls[door.protocolMethod]).toBe(0); + }, + ); + + it.each(ITEM_DOORS.map((d) => [d.label, d] as const))( + '%s → the SAME holder with NO active organization is refused a tier-A write — env-wide is walled', + async (_label, door) => { + const stack = boot(ORG_ADMIN_NO_ORG); + const out = await stack.knock(asView(door)); + expect(out.status).toBe(403); + expect(out.body).toMatchObject({ error: { code: 'FORBIDDEN' } }); + expect(stack.calls[door.protocolMethod]).toBe(0); + }, + ); + + it(`${MIGRATE_DOOR.label} → stays \`manage_metadata\`-only for an org-active holder (env-wide by definition)`, async () => { + const stack = boot(ORG_ADMIN); + const out = await stack.knock(MIGRATE_DOOR); + expect(out.status).toBe(403); + expect(out.body).toMatchObject({ error: { code: 'FORBIDDEN' } }); + expect(stack.calls[MIGRATE_DOOR.protocolMethod]).toBe(0); + }); +}); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 9bb933f302..eb3d6cfd51 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -172,15 +172,16 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ responseSchema: 'GetMetaItemResponseSchema', note: '[#5950] answers BARE, so the named schema is the whole body. Filled now that meta-item-layered-route.test.ts parses BOTH branches of this mount (cached and uncached) against it — the uncached branch carries the ADR-0010 protection envelope this schema newly declares' }, { route: 'PUT /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem', - note: '[#6603] gated on `manage_metadata` (ADR-0066 D1), same mechanism as POST /meta/_migrate-stored — a session alone is no longer enough. The write-side answer to ADR-0106 D1: a masked read PUT back verbatim used to delete the fields the caller could not see' }, + note: '[#6603] gated on `manage_metadata` (ADR-0066 D1), same mechanism as POST /meta/_migrate-stored — a session alone is no longer enough. The write-side answer to ADR-0106 D1: a masked read PUT back verbatim used to delete the fields the caller could not see. [#12702] the gate is the shared `metaWriteCapabilityVerdict`: `manage_org_presentation` is also admitted, ONLY for an `allowOrgOverride: true` type written org-scoped to the caller\'s own active organization' }, { route: 'DELETE /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.deleteItem', - note: 'REST-only: the dispatcher /meta branch has no DELETE handling — it falls into the read path. [#7019] gated on `manage_metadata` (ADR-0066 D1), same mechanism as the PUT twins — but NOT for the ADR-0106 reason: nothing is masked or round-tripped here, this discards a customization overlay outright, and `?dropStorage=true` takes the object table with it' }, + note: 'REST-only: the dispatcher /meta branch has no DELETE handling — it falls into the read path. [#7019] gated on `manage_metadata` (ADR-0066 D1), same mechanism as the PUT twins — but NOT for the ADR-0106 reason: nothing is masked or round-tripped here, this discards a customization overlay outright, and `?dropStorage=true` takes the object table with it. [#12702] same shared verdict as the PUT door: an admitted `manage_org_presentation` reset threads the caller\'s own organization, so the only row it can discard is their own org\'s overlay' }, { route: 'GET /api/v1/meta/:type/:name/history', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getHistory', note: 'REST-only: the dispatcher /meta branch swallows /history as a compound name and 404s' }, { route: 'GET /api/v1/meta/:type/:name/audit', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getAudit' }, { route: 'POST /api/v1/meta/:type/:name/publish', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.publishItem', - note: 'per-item ADR-0033 publish; packages.publishDrafts remains the package-scoped flow' }, - { route: 'POST /api/v1/meta/:type/:name/rollback', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.rollbackItem' }, + note: 'per-item ADR-0033 publish; packages.publishDrafts remains the package-scoped flow. [#12702] gated by the shared `metaWriteCapabilityVerdict`: `manage_org_presentation` is also admitted for an org-scoped tier-A promotion — the second half of the save→publish loop, promoting only the caller\'s own org partition' }, + { route: 'POST /api/v1/meta/:type/:name/rollback', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.rollbackItem', + note: '[#12702] gated by the shared `metaWriteCapabilityVerdict`: `manage_org_presentation` is also admitted for an org-scoped tier-A rollback, restoring only a version of the caller\'s own org overlay' }, { route: 'GET /api/v1/meta/:type/:name/diff', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.diffItem' }, // [#7526] The two routes that were ledgered in `runtime/src/route-ledger.ts` // and implemented in the dispatcher, but which no registrar ever mounted — diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index b6a58e42b3..e241a20b6d 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -41,6 +41,12 @@ import { // `metadata-core`. organizationIdForMetaRead, organizationIdForMetaWrite, + // [#12702] The capability half of the same decision, from the same home: + // `manage_metadata` as before, plus `manage_org_presentation` for + // org-overridable types written org-scoped to the caller's own active + // organization. One predicate for every `/meta` item write door on both + // transports — never a REST-local restatement. + metaWriteCapabilityVerdict, } from '@objectstack/metadata-core'; import { RouteManager, type RouteEntry } from './route-manager.js'; // [#6877] Query-parameter multiplicity. `IHttpRequest.query` declares @@ -4235,6 +4241,13 @@ export class RestServer { // capability, and a canonicalization rewrite is // authoring; `isSystem` bypasses, matching every other // capability gate on the platform. + // + // [#12702] Deliberately NOT `metaWriteCapabilityVerdict`: + // an install-wide stored-metadata rewrite is env-wide by + // definition, so `manage_org_presentation`'s "org-scoped + // to the caller's own active organization" condition can + // never hold here. `manage_metadata`-only, unchanged — + // do not copy the item doors' acceptance in. const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined); const held = new Set( Array.isArray(ctx?.systemPermissions) ? ctx!.systemPermissions : [], @@ -5589,18 +5602,36 @@ export class RestServer { // ADR-0066 D1's authoring capability and saving a metadata // item is authoring; `isSystem` bypasses, matching every // other capability gate on the platform. + // + // [#12702] The gate is the shared `metaWriteCapabilityVerdict` + // (`@objectstack/metadata-core`, beside the org-scope + // predicate this door already runs): beside `manage_metadata` + // it admits `manage_org_presentation`, ONLY for a type whose + // registry entry declares `allowOrgOverride: true` AND a + // session with an active organization — `ctx.tenantId`, the + // very value `organizationIdForMetaWrite` threads below, so + // an admitted write can only land org-scoped in the caller's + // own partition: never env-wide, never another org's. const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined); - const held = new Set( - Array.isArray(ctx?.systemPermissions) ? ctx!.systemPermissions : [], - ); - if (!ctx?.isSystem && !held.has('manage_metadata')) { - res.status(403).json({ - error: { - code: 'FORBIDDEN', - message: 'Saving a metadata item requires the `manage_metadata` capability.', - }, + { + const verdict = metaWriteCapabilityVerdict({ + isSystem: ctx?.isSystem === true, + systemPermissions: ctx?.systemPermissions, + // [#10340] Folded at the boundary — the verdict and + // the scope decision below read one spelling. + canonicalType: canonicalMetaUrlType(req.params.type), + activeOrganizationId: ctx?.tenantId, + operation: 'save', }); - return; + if (!verdict.allowed) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: verdict.message, + }, + }); + return; + } } const p = await this.resolveProtocol(environmentId, req); if (!p.saveMetaItem) { @@ -5803,18 +5834,34 @@ export class RestServer { // still intact. A gate that answers 403 after // `deleteMetaItem` has run would still be the bug. // `isSystem` bypasses, as everywhere else. + // + // [#12702] Same shared verdict as the PUT door. On THIS + // verb the org condition is also what bounds the blast + // radius: an admitted org-presentation reset threads the + // caller's own organization, and `orgId` selects the + // overlay repository — so the only row such a caller can + // discard is their own org's overlay, never the env-wide + // one (see the [#8805] comment below). `?dropStorage=true` + // is `object`-only, and `object` is not org-overridable, + // so the org capability can never reach it. const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined); - const held = new Set( - Array.isArray(ctx?.systemPermissions) ? ctx!.systemPermissions : [], - ); - if (!ctx?.isSystem && !held.has('manage_metadata')) { - res.status(403).json({ - error: { - code: 'FORBIDDEN', - message: 'Resetting a metadata item requires the `manage_metadata` capability.', - }, + { + const verdict = metaWriteCapabilityVerdict({ + isSystem: ctx?.isSystem === true, + systemPermissions: ctx?.systemPermissions, + canonicalType: canonicalMetaUrlType(req.params.type), + activeOrganizationId: ctx?.tenantId, + operation: 'reset', }); - return; + if (!verdict.allowed) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: verdict.message, + }, + }); + return; + } } const p = await this.resolveProtocol(environmentId, req); if (!p.deleteMetaItem) { @@ -6133,18 +6180,33 @@ export class RestServer { // which kernels implement publishing, and so nothing is promoted // before the refusal. `isSystem` bypasses, matching every other // capability gate on the platform. + // + // [#12702] Same shared verdict as the save door, because + // promotion is the second half of the save→publish loop: a + // caller admitted to author an org-scoped draft must be able + // to promote it, and the SAME conditions bound what a + // promotion can reach — `promoteDraftForPublish` resolves + // the draft through `getOverlayRepo(orgId)`, so an admitted + // org-presentation publish promotes only the caller's own + // org partition. const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined); - const held = new Set( - Array.isArray(ctx?.systemPermissions) ? ctx!.systemPermissions : [], - ); - if (!ctx?.isSystem && !held.has('manage_metadata')) { - res.status(403).json({ - error: { - code: 'FORBIDDEN', - message: 'Publishing a metadata item requires the `manage_metadata` capability.', - }, + { + const verdict = metaWriteCapabilityVerdict({ + isSystem: ctx?.isSystem === true, + systemPermissions: ctx?.systemPermissions, + canonicalType: canonicalMetaUrlType(req.params.type), + activeOrganizationId: ctx?.tenantId, + operation: 'publish', }); - return; + if (!verdict.allowed) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: verdict.message, + }, + }); + return; + } } const p = await this.resolveProtocol(environmentId, req); if (!p.publishMetaItem) { @@ -6312,18 +6374,32 @@ export class RestServer { // Gate FIRST — before the protocol is resolved — so 403-vs-501 // leaks no kernel capability and nothing is restored before the // refusal. `isSystem` bypasses, as everywhere else. + // + // [#12702] Same shared verdict as the sibling doors. The + // org condition bounds this verb too: `rollbackMetaItem` + // resolves the row AND its history through the organization + // (see the [#8805] comment below), so an admitted + // org-presentation rollback restores only a version of the + // caller's own org overlay — the env-wide row and its + // history stay out of reach. const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined); - const held = new Set( - Array.isArray(ctx?.systemPermissions) ? ctx!.systemPermissions : [], - ); - if (!ctx?.isSystem && !held.has('manage_metadata')) { - res.status(403).json({ - error: { - code: 'FORBIDDEN', - message: 'Rolling back a metadata item requires the `manage_metadata` capability.', - }, + { + const verdict = metaWriteCapabilityVerdict({ + isSystem: ctx?.isSystem === true, + systemPermissions: ctx?.systemPermissions, + canonicalType: canonicalMetaUrlType(req.params.type), + activeOrganizationId: ctx?.tenantId, + operation: 'rollback', }); - return; + if (!verdict.allowed) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: verdict.message, + }, + }); + return; + } } const p = await this.resolveProtocol(environmentId, req); if (!(p as any).rollbackMetaItem) { diff --git a/packages/runtime/src/domains/meta-save-capability-gate.test.ts b/packages/runtime/src/domains/meta-save-capability-gate.test.ts index 2f1bb8acf6..7cde5b7b6b 100644 --- a/packages/runtime/src/domains/meta-save-capability-gate.test.ts +++ b/packages/runtime/src/domains/meta-save-capability-gate.test.ts @@ -225,6 +225,26 @@ describe('#7019 — dispatcher PUT /meta/:type/:name: the capability gate', () = expect(saveItem).not.toHaveBeenCalled(); }); + it('holding `manage_org_presentation` alone is not enough for an OBJECT save — tier-B stays walled', async () => { + // The org-scoped presentation capability (#12702) reaches ONLY types + // whose registry entry declares `allowOrgOverride: true`; `object` + // does not, so this caller is exactly as refused as a capability-less + // one — full matrix in the #12702 describe below. + const stack = boot(); + + const res = await stack.dispatcher.handleMetadata( + '/object/account', + ctx({ userId: 'u_orgadmin', systemPermissions: ['manage_org_presentation'] }), + 'PUT', + { label: 'clobbered' }, + ); + + expect(res.response?.status).toBe(403); + expect(res.response?.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(stack.saveMetaItem).not.toHaveBeenCalled(); + expect(stack.storedLabel()).toBe('Account'); + }); + it('leaves the READ path alone — a capability-less caller can still GET', async () => { // This card gates WRITES. The read side has its own posture (ADR-0106 // masking); turning this into a blanket `/meta` gate would be a @@ -243,3 +263,125 @@ describe('#7019 — dispatcher PUT /meta/:type/:name: the capability gate', () = expect(res.response?.status).not.toBe(403); }); }); + +/** + * [#12702] `manage_org_presentation` — the org-scoped presentation capability + * on THIS transport. A subset key beside `manage_metadata`: admitted ONLY for + * a type whose registry entry declares `allowOrgOverride: true` AND a session + * with an active organization, and the admitted write is threaded to exactly + * that organization — the same single resolution feeding authorization and + * scope. Both directions pinned: the tier-A admission (with the threaded + * organization asserted, not assumed) and the tier-B / env-wide refusals. + */ +describe('#12702 — dispatcher PUT: `manage_org_presentation`, org-scoped tier-A admission', () => { + /** + * The `boot()` double above, plus an auth service whose session carries an + * active organization — the value `deps.resolveActiveOrganizationId` reads, + * which the gate and the write threading now share. + */ + function bootOrg(activeOrganizationId?: string) { + const saveMetaItem = vi.fn(async ({ type, name }: any) => ({ success: true, type, name })); + const protocol = { saveMetaItem }; + const auth = activeOrganizationId === undefined ? null : { + api: { getSession: async () => ({ session: { activeOrganizationId } }) }, + }; + const kernel = { + context: { + getService: (n: string) => (n === 'protocol' ? protocol : n === 'auth' ? auth : null), + }, + } as any; + return { dispatcher: new HttpDispatcher(kernel), saveMetaItem }; + } + + const HOLDER = { userId: 'u_orgadmin', systemPermissions: ['manage_org_presentation'] }; + + it('admits an org-scoped tier-A save, threaded to the caller\'s OWN organization', async () => { + const stack = bootOrg('org_a'); + + const res = await stack.dispatcher.handleMetadata( + '/view/org_grid', ctx(HOLDER), 'PUT', { name: 'org_grid', label: 'Org Grid' }, + ); + + expect(res.response?.status).toBe(200); + expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); + // The threading IS the wall: the only organization an admitted write + // can carry is the caller's own active one. + expect(stack.saveMetaItem.mock.calls[0][0]).toMatchObject({ + type: 'view', name: 'org_grid', organizationId: 'org_a', + }); + }); + + it('[#10340] the URL-only spelling is folded BEFORE the verdict — `email_templates` is tier-A here too', async () => { + const stack = bootOrg('org_a'); + + const res = await stack.dispatcher.handleMetadata( + '/email_templates/welcome', ctx(HOLDER), 'PUT', { name: 'welcome', subject: 'Hi' }, + ); + + expect(res.response?.status).toBe(200); + // The request type stays the RAW segment (the protocol folds it + // itself); only the verdict and the scope argument read the fold. + expect(stack.saveMetaItem.mock.calls[0][0]).toMatchObject({ + type: 'email_templates', name: 'welcome', organizationId: 'org_a', + }); + }); + + it.each([ + ['object', '/object/account'], + ['flow', '/flow/order_followup'], + ])('refuses the SAME holder a tier-B `%s` write — org active or not, nothing is written', async (_t, path) => { + const stack = bootOrg('org_a'); + + const res = await stack.dispatcher.handleMetadata( + path, ctx(HOLDER), 'PUT', { label: 'x' }, + ); + + expect(res.response?.status).toBe(403); + expect(res.response?.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(stack.saveMetaItem).not.toHaveBeenCalled(); + }); + + it('refuses the SAME holder a tier-A write when the session has NO active organization — env-wide is walled', async () => { + const stack = bootOrg(undefined); + + const res = await stack.dispatcher.handleMetadata( + '/view/org_grid', ctx(HOLDER), 'PUT', { name: 'org_grid', label: 'Org Grid' }, + ); + + expect(res.response?.status).toBe(403); + expect(res.response?.body?.error?.code).toBe('PERMISSION_DENIED'); + // The message names the sanctioned path and the scope fact — never the + // caller's own grants (#7450). + expect(String(res.response?.body?.error?.message ?? '')).toContain('active organization'); + expect(stack.saveMetaItem).not.toHaveBeenCalled(); + }); + + it('a foreign organization is not expressible: a body-smuggled organization_id does not move the threading', async () => { + const stack = bootOrg('org_a'); + + const res = await stack.dispatcher.handleMetadata( + '/view/org_grid', ctx(HOLDER), 'PUT', + { name: 'org_grid', label: 'Org Grid', organization_id: 'org_b', organizationId: 'org_b' }, + ); + + expect(res.response?.status).toBe(200); + // `item` is data, never a channel (the request is built field by + // field): the write still carries the CALLER's organization. + expect(stack.saveMetaItem.mock.calls[0][0]).toMatchObject({ organizationId: 'org_a' }); + }); + + it('control: `manage_metadata` with no active organization still saves a view env-wide, as today', async () => { + const stack = bootOrg(undefined); + + const res = await stack.dispatcher.handleMetadata( + '/view/org_grid', + ctx({ userId: 'u_author', systemPermissions: ['manage_metadata'] }), + 'PUT', + { name: 'org_grid', label: 'Org Grid' }, + ); + + expect(res.response?.status).toBe(200); + expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); + expect(stack.saveMetaItem.mock.calls[0][0].organizationId).toBeUndefined(); + }); +}); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index c3e7a63cc2..33286fccd9 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -29,6 +29,11 @@ import { // [#8805] Moved to `metadata-core` so the REST `/meta` write doors decide // this the same way rather than through a second copy. Behaviour unchanged. organizationIdForMetaWrite, + // [#12702] The capability half of the same decision, from the same home + // and for the same no-second-copy reason: `manage_metadata` as before, + // plus `manage_org_presentation` for org-overridable types written + // org-scoped to the caller's own active organization. + metaWriteCapabilityVerdict, } from '@objectstack/metadata-core'; import { buildApiError } from '../error-envelope.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; @@ -442,14 +447,39 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // refusal. `manage_metadata` is ADR-0066 D1's authoring capability; // engine self-invocation (`isSystem`) bypasses, matching // `actionPermissionError` and the migrate-stored gate below. + // + // [#12702] The gate is the shared `metaWriteCapabilityVerdict` + // (`@objectstack/metadata-core` — the same home as the org-scope + // predicate below, for the same no-second-copy reason): beside + // `manage_metadata` it admits `manage_org_presentation`, ONLY for + // a type whose registry entry declares `allowOrgOverride: true` + // AND a session with an active organization — which is exactly the + // organization `organizationIdForMetaWrite` threads below, so an + // admitted write can only land org-scoped in the caller's own + // partition, never env-wide and never another org's. The active + // organization is resolved HERE, once, and reused by the write + // threading below — authorization and scope read one value (the + // single-resolution shape the REST doors carry, #8919). Resolving + // it is a session read, not a protocol probe: the 403-vs-501 + // discipline above is untouched. const ec: any = _context.executionContext; - if (!ec?.isSystem && !new Set(ec?.systemPermissions ?? []).has('manage_metadata')) { + // [#10503] Folded at the boundary, once — the verdict and the + // scope decision below must read the same spelling. + const canonicalType = canonicalMetaUrlType(type); + const activeOrganizationId = await deps.resolveActiveOrganizationId(_context); + const verdict = metaWriteCapabilityVerdict({ + isSystem: ec?.isSystem === true, + systemPermissions: ec?.systemPermissions, + canonicalType, + activeOrganizationId, + operation: 'save', + }); + if (!verdict.allowed) { + // `deps.error(msg, 403)` derives the code from the status — + // `PERMISSION_DENIED`, this transport's pinned spelling. return { handled: true, - response: deps.error( - 'Saving a metadata item requires the `manage_metadata` capability.', - 403, - ), + response: deps.error(verdict.message, 403), }; } @@ -482,7 +512,10 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // for why the predicate is the static registry flag and not // `isOverlayAllowed` — and, since #8805, why it lives there: // the REST `/meta` write doors run the same one. - const activeOrganizationId = await deps.resolveActiveOrganizationId(_context); + // + // [#12702] `activeOrganizationId` is the ONE resolution the + // capability gate above already made — scope and + // authorization read the same value by construction. // // [#10503] The segment is FOLDED before the scope decision // — the correction #10340 landed for the REST `/meta` @@ -511,7 +544,7 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // pre-folds would hide a drift between them from the // protocol's own tests. const organizationId = organizationIdForMetaWrite( - canonicalMetaUrlType(type), activeOrganizationId, + canonicalType, activeOrganizationId, ); // [#10888] Server-stated face: this branch answers through // `deps.errorFromThrown`, which carries the refusal's @@ -831,6 +864,12 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // `manage_metadata` is the ADR-0066 D1 capability for authoring and // publishing metadata, which is exactly what a rewrite is; engine // self-invocation (`isSystem`) bypasses, matching `actionPermissionError`. + // + // [#12702] Deliberately NOT `metaWriteCapabilityVerdict`: an + // install-wide stored-metadata rewrite is env-wide by definition, so + // `manage_org_presentation`'s "org-scoped to the caller's own active + // organization" condition can never hold here. `manage_metadata`-only, + // unchanged — do not copy the item doors' acceptance in. const ec: any = _context.executionContext; if (!ec?.isSystem && !new Set(ec?.systemPermissions ?? []).has('manage_metadata')) { return { diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 8a40141c8c..59d59a9acc 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -348,7 +348,8 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'GET /meta/types', domain: '/meta', disposition: 'server-only', note: 'richer types listing consumed by Studio tooling directly; client uses GET /meta' }, { route: 'GET /meta/:type', domain: '/meta', disposition: 'sdk', client: 'meta.getItems' }, { route: 'GET /meta/:type/:name', domain: '/meta', disposition: 'sdk', client: 'meta.getItem' }, - { route: 'PUT /meta/:type/:name', domain: '/meta', disposition: 'sdk', client: 'meta.saveItem' }, + { route: 'PUT /meta/:type/:name', domain: '/meta', disposition: 'sdk', client: 'meta.saveItem', + note: '[#7019] gated on `manage_metadata` (ADR-0066 D1) — the dispatcher transport of the REST save door. [#12702] the gate is the shared `metaWriteCapabilityVerdict` (`@objectstack/metadata-core`): `manage_org_presentation` is also admitted, ONLY for an `allowOrgOverride: true` type written org-scoped to the caller\'s own active organization; refusals answer 403 `PERMISSION_DENIED`, this transport\'s pinned spelling' }, { route: 'GET /meta/:type/:name/published', domain: '/meta', disposition: 'sdk', client: 'meta.getPublished' }, { route: 'GET /meta/_drafts', domain: '/meta', disposition: 'sdk', client: 'meta.listDrafts' }, { route: 'POST /meta/_migrate-stored', domain: '/meta', disposition: 'sdk', client: 'meta.migrateStored', diff --git a/packages/spec/src/security/capabilities.ts b/packages/spec/src/security/capabilities.ts index 8ed760d664..a69735b590 100644 --- a/packages/spec/src/security/capabilities.ts +++ b/packages/spec/src/security/capabilities.ts @@ -41,6 +41,25 @@ export const PLATFORM_CAPABILITIES: readonly PlatformCapability[] = [ { name: 'manage_users', label: 'Manage Users', description: 'Create, edit, and deactivate users across the platform.', scope: 'platform' }, { name: 'manage_org_users', label: 'Manage Organization Users', description: 'Manage members within the caller’s organization.', scope: 'org' }, { name: 'manage_metadata', label: 'Manage Metadata', description: 'Author and publish object/view/flow and other metadata.', scope: 'platform' }, + // [#12702] The org-scoped SUBSET key beside `manage_metadata` — presentation + // authoring authority bounded to the caller's own organization. It admits + // `/meta` item writes ONLY when the target type's registry entry declares + // `allowOrgOverride: true` (ADR-0005 tier A: view / dashboard / report / + // translation / email_template today — the REGISTRY is the authority, this + // comment merely names today's members) AND the write is org-scoped to the + // session's active organization. Never a tier-B reach, never an env-wide + // (`organization_id NULL`) write, never another organization's partition — + // enforced by `metaWriteCapabilityVerdict` (`@objectstack/metadata-core`), + // the one predicate every `/meta` write door runs. It exists so a + // walled-posture (single-DB SaaS) operator can let a tenant org admin + // customize presentation overlays without handing them platform-wide + // `manage_metadata`, whose reach includes env-wide tier-B authoring + // (maintainer direction 2026-08-27, quoted in #12701). Deliberately granted + // by NO shipped permission set: the operator grants it per deployment, so + // existing postures — `single` included — are byte-unchanged by its + // existence. ⛔ Never add it to `PLATFORM_ADMIN_ONLY_CAPABILITIES` + // (`plugin-security`): it is precisely NOT a platform-admin marker. + { name: 'manage_org_presentation', label: 'Manage Organization Presentation', description: 'Author per-organization presentation overlays — the metadata types whose registry entry declares allowOrgOverride — scoped to the caller’s own organization.', scope: 'org' }, { name: 'manage_platform_settings', label: 'Manage Platform Settings', description: 'Configure global platform settings (mail, storage, AI, licensing, …) and platform-only Setup pages.', scope: 'platform' }, { name: 'setup.access', label: 'Setup Access', description: 'Enter the Setup app shell.', scope: 'platform' }, // [Finding-1] The write counterpart to `setup.access`: saving changes to From 51bf9893c1dbd2328a9d9b581b00d95932775629 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 12:34:22 +0000 Subject: [PATCH 2/2] wip: ledger notes, changeset, seeder-test index derivation (#12702) --- .../src/bootstrap-system-capabilities.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts b/packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts index 45ace035e8..d2600538a5 100644 --- a/packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts @@ -1140,7 +1140,13 @@ describe('#11518 — a page that could not fit the answer must not report "absen expect(page).toHaveLength(CURATED_NAMES.length); const namesOnThePage = new Set(page.map((r) => r.name)); expect(namesOnThePage.size, 'two names are missing from a page that is full').toBe(CURATED_NAMES.length - 2); - for (const lost of [CURATED_NAMES[6], CURATED_NAMES[7]]) { + // [#12702] The LAST TWO curated names, derived — the two org rows' ids + // sort ahead of every `cap_*` id, so under `ORDER BY id ASC` it is always + // the tail of the curated list that falls off a `length`-capped page. This + // read hard-coded `[6]`/`[7]` while the list had eight entries, and went + // red the day the list grew — the count belongs to the list, never to + // prose (this file's own #8919-era rule, applied to indices). + for (const lost of CURATED_NAMES.slice(-2)) { expect(namesOnThePage.has(lost)).toBe(false); expect(ql.rows.some((r: any) => r.name === lost && r.managed_by === 'platform')).toBe(true); }