From 38c168e8e55da6cd504af4674b9da0177c63c16c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 19:56:53 +0000 Subject: [PATCH 1/8] test(rest,runtime,dogfood): both import doors read the walled-catalog verdict the metadata save door reads Pins for the import route and the MCP import tool under the walled tenancy postures: a caller below the platform operator is refused with 403 PERMISSION_DENIED and the save door's sentence for an object, before the federation service is looked up; the operator and the open posture import as before; a tenancy service that cannot answer refuses 503. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg --- ...ort-walled-catalog-refusal.dogfood.test.ts | 243 ++++++++++++++++++ ...ernal-datasource-routes-auth-guard.test.ts | 152 +++++++++++ .../src/domains/mcp-federation-bridge.test.ts | 111 ++++++++ 3 files changed, 506 insertions(+) create mode 100644 packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts diff --git a/packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts b/packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts new file mode 100644 index 00000000000..a9dc1a4e45d --- /dev/null +++ b/packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts @@ -0,0 +1,243 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 D3 · D12 items 7–8 · D11] Under a walled tenancy posture an import +// of a remote table is a write to the environment's metadata catalog, so it is +// the platform operator's at both import doors, on a REAL booted stack: +// +// - `POST /api/v1/datasources/:name/external/tables/:remote/import`, and +// - the MCP `import_object` tool. +// +// Both doors read the walled-catalog verdict (`walledCatalogWriteVerdict`, +// `@objectstack/metadata-core`) the metadata save door reads, after the +// federation family's capability rule and before the federation service is +// looked up. Pinned here under `group`, the posture where every organization +// shares the environment's catalog: an organization's administrator, holding +// both federation capabilities inside that organization and below the platform +// operator, is refused at each door with `403 PERMISSION_DENIED` and the save +// door's own sentence for an object, and nothing is saved. CONTROLS: the +// platform operator imports at both doors under the same wall; and under +// `single` the same kind of administrator, who is not the platform operator, +// imports at both doors as before. +// +// The status and the sentence are the save door's. The code is this family's +// spelling of a 403, `PERMISSION_DENIED`, the standard-catalog member; the REST +// `/meta` door answers the grandfathered synonym of that member +// (`STANDARD_SYNONYM_WAIVERS`, ADR-0112 D3), which keeps a wire value and does +// not endorse the spelling for new code. +// +// The verify harness does not mount the federation service or the MCP server, +// so this file mounts both, as `objectstack dev` and `serve` do. The showcase's +// external datasource names a cwd-relative SQLite file, and every dogfood file +// runs in its own temporary working directory (`per-file-cwd.setup.ts`), so the +// remote tables `onEnable` provisions here are this file's own. The +// datasource's package is read-only, so an import lands in its customization +// package, which under a wall only the platform operator creates: the operator +// ensures it first, so a refusal below is the wall's and never the landing's. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { onEnable } from '@objectstack/example-showcase'; +import { resolveUserAuthzGrants } from '@objectstack/core'; +import { ExternalDatasourceServicePlugin } from '@objectstack/service-datasource'; +import { MCPServerPlugin } from '@objectstack/mcp'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; +import { ensurePair } from './customization-pair.js'; + +const SYS = { isSystem: true } as const; + +/** The showcase's external datasource and its package (`examples/app-showcase`, `manifest.id`). */ +const DATASOURCE = 'showcase_external'; +const BASE = 'com.example.showcase'; + +const importPath = (remote: string) => `/datasources/${DATASOURCE}/external/tables/${remote}/import`; + +/** A runtime-authored set carrying both federation capabilities. */ +const IMPORTER_SET = 'federation_importer'; +const IMPORTER_CAPABILITIES = ['manage_metadata', 'manage_platform_settings']; + +/** A JSON-RPC MCP request over Streamable-HTTP (JSON response mode). */ +const mcpBody = (method: string, params?: unknown, id = 1) => + ({ jsonrpc: '2.0', id, method, ...(params !== undefined ? { params } : {}) }); + +interface Harness { + stack: VerifyStack; + ql: any; + operator: string; + tokA: string; + userA: string; + orgA?: string; +} + +async function boot(posture: 'group' | 'single'): Promise { + const walled = posture !== 'single'; + if (walled) process.env.OS_TENANCY_POSTURE = posture; + else delete process.env.OS_TENANCY_POSTURE; + // Provision the remote tables, exactly as `os dev` does at boot (the harness + // imports only the stack's default export, so `onEnable` never runs on its own). + await onEnable({ logger: { info() {}, warn() {} } } as never); + const stack = await bootShowcase({ + ...(walled ? { multiTenant: 'posture-only' as const } : {}), + extraPlugins: [new ExternalDatasourceServicePlugin(), new MCPServerPlugin()], + } as never); + const ql: any = await stack.kernel.getServiceAsync('objectql'); + const operator = await stack.signIn(); + const email = `fed-import-a-${posture}@wall.test`; + const tokA = await stack.signUp(email); + const h: Harness = { stack, ql, operator, tokA, userA: '' }; + if (walled) { + const slug = `fed-import-a-${posture}`; + const created = await stack.apiAs(tokA, 'POST', '/auth/organization/create', { name: slug, slug }); + expect(created.status, await created.clone().text()).toBe(200); + h.orgA = ((await created.json()) as { id: string }).id; + const active = await stack.apiAs(tokA, 'POST', '/auth/organization/set-active', { organizationSlug: slug }); + expect(active.status, await active.clone().text()).toBe(200); + } + h.userA = String((await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id); + // The operator creates the customization package an import over this + // datasource lands in, and authors the set A holds (inside org A under a wall). + await ensurePair(stack, operator, BASE); + const saved = await stack.apiAs(operator, 'PUT', `/meta/permission/${IMPORTER_SET}`, { + name: IMPORTER_SET, label: IMPORTER_SET, objects: {}, systemPermissions: [...IMPORTER_CAPABILITIES], + }); + expect(saved.status, await saved.clone().text()).toBe(200); + await ql.insert( + 'sys_user_permission_set', + { user_id: h.userA, permission_set: IMPORTER_SET, ...(h.orgA ? { organization_id: h.orgA } : {}) }, + { context: { ...SYS, ...(h.orgA ? { tenantId: h.orgA } : {}) } }, + ); + return h; +} + +/** The REST import's answer: status, `error.code`, `error.message` and whether a definition came back. */ +async function importOverRest(h: Harness, token: string, remote: string, name: string) { + const res = await h.stack.apiAs(token, 'POST', importPath(remote), { name }); + const json: any = await res.json().catch(() => ({})); + return { status: res.status, code: json?.error?.code, message: String(json?.error?.message ?? ''), data: json?.data, json }; +} + +/** The MCP `import_object` answer: the tool result's error envelope, or its object. */ +async function importOverMcp(h: Harness, token: string, remote: string, name: string) { + const res = await h.stack.api('/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // The transport requires the client to accept both content types. + Accept: 'application/json, text/event-stream', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(mcpBody('tools/call', { name: 'import_object', arguments: { datasource: DATASOURCE, remoteName: remote, name } })), + }); + expect(res.status, `tools/call import_object: ${await res.clone().text()}`).toBe(200); + const rpc: any = await res.json(); + expect(rpc.error, `tools/call import_object JSON-RPC error: ${JSON.stringify(rpc.error)}`).toBeUndefined(); + const text = rpc.result?.content?.[0]?.text; + expect(typeof text, 'the tool result carries text content').toBe('string'); + const parsed = JSON.parse(text); + return { isError: rpc.result?.isError === true, error: parsed?.error, object: parsed?.object }; +} + +/** What the platform operator reads at `GET /meta/object/:name`. */ +async function storedStatus(h: Harness, name: string): Promise { + return (await h.stack.apiAs(h.operator, 'GET', `/meta/object/${name}`)).status; +} + +describe('[ADR-0131 D12 items 7–8] group: an import is a write to the environment\'s catalog, and it is the platform operator\'s at both import doors', () => { + let h: Harness; + const previous = process.env.OS_TENANCY_POSTURE; + + beforeAll(async () => { h = await boot('group'); }, 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; A holds both federation capabilities in org A and is not the platform operator', async () => { + const tenancy = await h.stack.kernel.getServiceAsync<{ posture: string; degraded: boolean }>('tenancy'); + expect(tenancy.posture).toBe('group'); + expect(tenancy.degraded).toBe(false); + const a = await resolveUserAuthzGrants(h.ql as never, h.userA, { tenantId: h.orgA }); + expect(a.systemPermissions).toEqual(expect.arrayContaining(IMPORTER_CAPABILITIES)); + expect(a.posture).not.toBe('PLATFORM_ADMIN'); + }); + + it('REST: A\'s import is refused with the save door\'s status and sentence for an object, and nothing is saved', async () => { + const name = 'showcase_custom_wall_rest_cust'; + const imported = await importOverRest(h, h.tokA, 'customers', name); + expect({ status: imported.status, code: imported.code }, JSON.stringify(imported.json)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(imported.message).toContain('\'group\' tenancy posture'); + expect(imported.message).toContain('ADR-0131 D3, D12 items 7–8, D11'); + expect(imported.data, 'a refused import answers no definition').toBeUndefined(); + + // The metadata save door refuses A the same object with the same status and + // the same sentence: one verdict. + const savedByA = await h.stack.apiAs(h.tokA, 'PUT', `/meta/object/${name}`, { + name, label: 'By A', fields: { title: { type: 'text', label: 'Title' } }, + }); + const saveBody: any = await savedByA.json().catch(() => ({})); + expect(savedByA.status, JSON.stringify(saveBody)).toBe(imported.status); + expect(saveBody?.error?.message).toBe(imported.message); + + expect(await storedStatus(h, name)).toBe(404); + }); + + it('MCP: A\'s `import_object` is refused with the same code and sentence, and nothing is saved', async () => { + const name = 'showcase_custom_wall_mcp_cust'; + const rest = await importOverRest(h, h.tokA, 'customers', 'showcase_custom_wall_rest_probe'); + const imported = await importOverMcp(h, h.tokA, 'customers', name); + expect(imported.isError, JSON.stringify(imported)).toBe(true); + expect({ code: imported.error?.code, status: imported.error?.status }).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + expect(imported.error?.message).toBe(rest.message); + expect(imported.object).toBeUndefined(); + expect(await storedStatus(h, name)).toBe(404); + }); + + it('control: the platform operator imports at both doors under the same wall, and each object is saved', async () => { + const restName = 'showcase_custom_wall_op_rest'; + const rest = await importOverRest(h, h.operator, 'customers', restName); + expect(rest.status, JSON.stringify(rest.json)).toBe(201); + expect(rest.data?.object?.name).toBe(restName); + expect(await storedStatus(h, restName)).toBe(200); + + const mcpName = 'showcase_custom_wall_op_mcp'; + const mcp = await importOverMcp(h, h.operator, 'orders', mcpName); + expect(mcp.isError, JSON.stringify(mcp)).toBe(false); + expect(mcp.object?.name).toBe(mcpName); + expect(await storedStatus(h, mcpName)).toBe(200); + }); +}); + +describe('[ADR-0131 D11] single: the wall is not enforced, and an administrator below the platform operator imports at both doors as before', () => { + 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: no wall, and A holds both federation capabilities and is not the platform operator', async () => { + const tenancy = await h.stack.kernel.getServiceAsync<{ posture: string; defaultOrgId(): Promise }>('tenancy'); + expect(tenancy.posture).toBe('single'); + // The organization a `single` session is active in: the Default + // Organization, which an organization-less grant is stamped with. + const a = await resolveUserAuthzGrants(h.ql as never, h.userA, { tenantId: (await tenancy.defaultOrgId()) ?? undefined }); + expect(a.systemPermissions).toEqual(expect.arrayContaining(IMPORTER_CAPABILITIES)); + expect(a.posture).not.toBe('PLATFORM_ADMIN'); + }); + + it('control: A imports over REST and over MCP, and each object is saved', async () => { + const restName = 'showcase_custom_open_rest'; + const rest = await importOverRest(h, h.tokA, 'customers', restName); + expect(rest.status, JSON.stringify(rest.json)).toBe(201); + expect(await storedStatus(h, restName)).toBe(200); + + const mcpName = 'showcase_custom_open_mcp'; + const mcp = await importOverMcp(h, h.tokA, 'orders', mcpName); + expect(mcp.isError, JSON.stringify(mcp)).toBe(false); + expect(mcp.object?.name).toBe(mcpName); + expect(await storedStatus(h, mcpName)).toBe(200); + }); +}); diff --git a/packages/rest/src/external-datasource-routes-auth-guard.test.ts b/packages/rest/src/external-datasource-routes-auth-guard.test.ts index b0caaa7314f..5655d668d57 100644 --- a/packages/rest/src/external-datasource-routes-auth-guard.test.ts +++ b/packages/rest/src/external-datasource-routes-auth-guard.test.ts @@ -86,11 +86,13 @@ import { describe, it, expect, vi } from 'vitest'; import { hashApiKey } from '@objectstack/core'; +import { walledCatalogWriteVerdict } from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; // The relative import carries its `.js` extension (see the note in // `direct-mount-introspection.test.ts`): under `moduleResolution: nodenext` an // extension-less one does not resolve and every symbol it names becomes `any`. import { createRestApiPlugin } from './rest-api-plugin.js'; +import { registerExternalDatasourceRoutes } from './external-datasource-routes.js'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; const BASE = '/api/v1'; @@ -605,3 +607,153 @@ describe('[#9901] the family requires a capability above authentication', () => expect(service.importObject).not.toHaveBeenCalled(); }); }); + +// ── [ADR-0131 D3 · D12 items 7–8 · D11] the import is a write to the environment's catalog ── +// +// Under a walled tenancy posture (`isolated`, `group`) every organization shares +// the environment's metadata catalog, and every write to it is the platform +// operator's: the one predicate is `walledCatalogWriteVerdict` +// (`@objectstack/metadata-core`), which the `/meta` save door asks after its +// capability gate. An import saves an object into that catalog, so the import +// route asks the same verdict, right after this family's capability gate and +// before the service is looked up. Inert under `single` and with no `tenancy` +// service; the platform operator rung and `isSystem` are admitted. +// +// The registrar is mounted directly here, over a resolver that states the +// caller's ADR-0095 posture rung, because the rung is what the verdict reads +// and the plugin-boot fixture above derives none. The posture is read off the +// registrar's own kernel, the one its `external-datasource` lookup reads. + +const IMPORT_ROUTE = FAMILY.find((r) => r.call === 'importObject')!; +const LIST_ROUTE = FAMILY.find((r) => r.call === 'listRemoteTables')!; + +/** The kernel's rejection for a service nobody registered, branded as `PluginLoader.getService` brands it. */ +const notRegistered = (name: string) => + Object.assign(new Error(`Service '${name}' not found`), { + __objectstackServiceNotRegistered: true, + code: 'SERVICE_NOT_REGISTERED', + serviceName: name, + }); + +function mountWithTenancy(opts: { + /** The `tenancy` service the kernel answers, `'none'` for no registration, `'failing'` for one that cannot answer. */ + tenancy: { posture: string } | 'none' | 'failing'; + /** The caller's ADR-0095 posture rung. */ + posture?: string; + grants?: readonly string[]; +}) { + const server = createRecordingServer(); + const service = federationServiceSpies(); + const lookups: string[] = []; + const kernel = { + getServiceAsync: async (name: string) => { + lookups.push(`kernel:${name}`); + if (name !== 'tenancy' || opts.tenancy === 'none') throw notRegistered(name); + if (opts.tenancy === 'failing') throw new Error('the tenancy store did not answer'); + return opts.tenancy; + }, + }; + const ctx = { + getService: vi.fn((name: string) => { + lookups.push(name); + if (name === 'external-datasource') return service; + throw notRegistered(name); + }), + getKernel: () => kernel, + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + const grants = opts.grants ?? FULL_GRANT; + registerExternalDatasourceRoutes(server as any, ctx as any, BASE, { + resolveExecutionContext: async () => ({ + userId: 'u_federation', + systemPermissions: [...grants], + ...(opts.posture !== undefined ? { posture: opts.posture } : {}), + }), + }); + return { table: server.table, service, lookups }; +} + +describe('[ADR-0131 D12 items 7–8] under a walled tenancy posture the import is the platform operator\'s, like every write to the environment\'s catalog', () => { + for (const posture of ['group', 'isolated'] as const) { + it(`${posture}: an organization administrator holding both federation capabilities is refused the import 403 PERMISSION_DENIED, before the service is looked up`, async () => { + const { table, service, lookups } = mountWithTenancy({ tenancy: { posture }, posture: 'TENANT_ADMIN' }); + + const { statusCode, body } = await call(table, IMPORT_ROUTE, {}); + + expect(statusCode).toBe(403); + expect(body?.success).toBe(false); + expect(body?.error?.code).toBe('PERMISSION_DENIED'); + expect(body?.error?.message).toContain(`'${posture}' tenancy posture`); + expect(body?.error?.message).toContain('ADR-0131 D3, D12 items 7–8, D11'); + expect(service.importObject).not.toHaveBeenCalled(); + expect(lookups).not.toContain('external-datasource'); + }); + } + + it('the refusal is the walled verdict the metadata save door answers for an object, not a sentence of this family\'s own', async () => { + const { table } = mountWithTenancy({ tenancy: { posture: 'group' }, posture: 'TENANT_ADMIN' }); + + const { body } = await call(table, IMPORT_ROUTE, {}); + + const verdict = walledCatalogWriteVerdict({ type: 'object', tenancyPosture: 'group', callerPosture: 'TENANT_ADMIN' }); + expect(verdict.allowed).toBe(false); + expect(body?.error?.message).toBe(verdict.allowed ? undefined : verdict.message); + }); + + it('the capability gate still answers first: a caller lacking the write capability is told which capability, not the posture', async () => { + const { table, service } = mountWithTenancy({ + tenancy: { posture: 'group' }, posture: 'TENANT_ADMIN', grants: [READ_CAPABILITY], + }); + + const { statusCode, body } = await call(table, IMPORT_ROUTE, {}); + + expect(statusCode).toBe(403); + expect(body?.error?.code).toBe('PERMISSION_DENIED'); + expect(body?.error?.message).toContain(WRITE_CAPABILITY); + expect(body?.error?.message).not.toContain('tenancy posture'); + expect(service.importObject).not.toHaveBeenCalled(); + }); + + it('a read writes no catalog item: the same organization administrator still lists the remote tables under a wall', async () => { + const { table, service } = mountWithTenancy({ tenancy: { posture: 'group' }, posture: 'TENANT_ADMIN' }); + + const { statusCode, body } = await call(table, LIST_ROUTE, {}); + + expect(statusCode).toBe(LIST_ROUTE.ok); + expect(body?.success).toBe(true); + expect(service.listRemoteTables).toHaveBeenCalledWith(DS, { schema: undefined }); + }); + + it('control: the platform operator imports under a wall', async () => { + const { table, service } = mountWithTenancy({ tenancy: { posture: 'group' }, posture: 'PLATFORM_ADMIN' }); + + const { statusCode, body } = await call(table, IMPORT_ROUTE, {}); + + expect(statusCode).toBe(IMPORT_ROUTE.ok); + expect(body?.success).toBe(true); + expect(service.importObject).toHaveBeenCalledWith(DS, 'customers', {}); + }); + + it('control: under `single`, and with no tenancy service at all, the same organization administrator imports as before', async () => { + for (const tenancy of [{ posture: 'single' }, 'none'] as const) { + const { table, service } = mountWithTenancy({ tenancy, posture: 'TENANT_ADMIN' }); + + const { statusCode, body } = await call(table, IMPORT_ROUTE, {}); + + expect(statusCode, JSON.stringify(tenancy)).toBe(IMPORT_ROUTE.ok); + expect(body?.success, JSON.stringify(tenancy)).toBe(true); + expect(service.importObject, JSON.stringify(tenancy)).toHaveBeenCalledWith(DS, 'customers', {}); + } + }); + + it('a tenancy service that is registered and cannot answer refuses 503 SERVICE_UNAVAILABLE, never a permit', async () => { + const { table, service } = mountWithTenancy({ tenancy: 'failing', posture: 'TENANT_ADMIN' }); + + const { statusCode, body } = await call(table, IMPORT_ROUTE, {}); + + expect(statusCode).toBe(503); + expect(body?.success).toBe(false); + expect(body?.error?.code).toBe('SERVICE_UNAVAILABLE'); + expect(service.importObject).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/domains/mcp-federation-bridge.test.ts b/packages/runtime/src/domains/mcp-federation-bridge.test.ts index d28945966de..9bfee988dd9 100644 --- a/packages/runtime/src/domains/mcp-federation-bridge.test.ts +++ b/packages/runtime/src/domains/mcp-federation-bridge.test.ts @@ -13,10 +13,18 @@ import { describe, it, expect } from 'vitest'; import { FEDERATION_READ_CAPABILITY, FEDERATION_WRITE_CAPABILITY } from '@objectstack/rest'; +import { walledCatalogWriteVerdict } from '@objectstack/metadata-core'; import { buildMcpBridge } from './mcp.js'; import { resolveExecutionContext } from '../security/resolve-execution-context.js'; import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; +/** + * The dispatcher's loud lookup (`DomainHandlerDeps.resolveServiceOrLoud`) on a + * host that registers no `tenancy` service: `undefined`, the supported + * composition, read as `single`. + */ +const noTenancyService = async () => undefined; + function bridgeFor(systemPermissions: string[], service: unknown) { const lookups: string[] = []; const deps = { @@ -24,6 +32,7 @@ function bridgeFor(systemPermissions: string[], service: unknown) { lookups.push(name); return name === 'external-datasource' ? service : undefined; }, + resolveServiceOrLoud: noTenancyService, }; const context = { environmentId: 'env_1', executionContext: { userId: 'u_1', systemPermissions } }; return { bridge: buildMcpBridge(deps as never, context as never), lookups }; @@ -147,6 +156,7 @@ function bridgeOver(executionContext: unknown, service: unknown) { lookups.push(name); return name === 'external-datasource' ? service : undefined; }, + resolveServiceOrLoud: noTenancyService, }; const context = { environmentId: 'env_1', executionContext }; return { bridge: buildMcpBridge(deps as never, context as never), lookups }; @@ -217,3 +227,104 @@ describe('the federation pair rides the bridge only for an envelope that can cal expect(typeof bridge.importObject).toBe('undefined'); }); }); + +// ── [ADR-0131 D3 · D12 items 7–8 · D11] `import_object` is a write to the environment's catalog ── +// +// The REST import route's twin (`external-datasource-routes-auth-guard.test.ts` +// pins the route): under a walled tenancy posture every write to the +// environment's metadata catalog is the platform operator's, and an import saves +// an object into it. So `import_object` asks `walledCatalogWriteVerdict` after +// the family's capability rule and before the service is looked up, reading the +// posture the way this transport's other walled doors read it +// (`readEffectiveTenancyPosture`). `list_remote_tables` writes nothing and is +// not asked. + +function bridgeWithTenancy(opts: { + /** The `tenancy` service the dispatcher's loud lookup answers, `'none'` for none, `'failing'` for one that cannot answer. */ + tenancy: { posture: string } | 'none' | 'failing'; + /** The caller's ADR-0095 posture rung. */ + posture?: string; +}) { + const lookups: string[] = []; + const service = fakeService(); + const deps = { + resolveService: async (_ctx: unknown, name: string) => { + lookups.push(name); + return name === 'external-datasource' ? service : undefined; + }, + resolveServiceOrLoud: async (_ctx: unknown, name: string) => { + lookups.push(`loud:${name}`); + if (name !== 'tenancy' || opts.tenancy === 'none') return undefined; + if (opts.tenancy === 'failing') throw new Error('the tenancy store did not answer'); + return opts.tenancy; + }, + }; + const executionContext = { + userId: 'u_1', + systemPermissions: [FEDERATION_READ_CAPABILITY, FEDERATION_WRITE_CAPABILITY], + ...(opts.posture !== undefined ? { posture: opts.posture } : {}), + }; + const context = { environmentId: 'env_1', executionContext }; + return { bridge: buildMcpBridge(deps as never, context as never), lookups, service }; +} + +describe('[ADR-0131 D12 items 7–8] under a walled tenancy posture `import_object` is the platform operator\'s, like every write to the environment\'s catalog', () => { + for (const posture of ['group', 'isolated'] as const) { + it(`${posture}: an organization administrator holding both federation capabilities is refused 403 PERMISSION_DENIED, before the service is looked up`, async () => { + const { bridge, lookups, service } = bridgeWithTenancy({ tenancy: { posture }, posture: 'TENANT_ADMIN' }); + + const refused = await bridge.importObject('erp', 'Products', {}).then( + () => undefined, + (err: unknown) => err as { code?: unknown; status?: unknown; message?: string }, + ); + + expect(refused, 'the import is refused').toBeDefined(); + expect({ code: refused?.code, status: refused?.status }).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + expect(refused?.message).toContain(`'${posture}' tenancy posture`); + expect(refused?.message).toContain('ADR-0131 D3, D12 items 7–8, D11'); + expect(lookups).not.toContain('external-datasource'); + expect(service.calls).toEqual([]); + }); + } + + it('the refusal is the walled verdict the metadata save door answers for an object', async () => { + const { bridge } = bridgeWithTenancy({ tenancy: { posture: 'group' }, posture: 'TENANT_ADMIN' }); + + const verdict = walledCatalogWriteVerdict({ type: 'object', tenancyPosture: 'group', callerPosture: 'TENANT_ADMIN' }); + expect(verdict.allowed).toBe(false); + await expect(bridge.importObject('erp', 'Products', {})).rejects.toMatchObject({ + message: verdict.allowed ? undefined : verdict.message, + }); + }); + + it('a read writes no catalog item: the same organization administrator still lists the remote tables under a wall', async () => { + const { bridge, service } = bridgeWithTenancy({ tenancy: { posture: 'group' }, posture: 'TENANT_ADMIN' }); + + await expect(bridge.listRemoteTables('erp', {})).resolves.toEqual([{ name: 'Products', columnCount: 6 }]); + expect(service.calls).toEqual([['listRemoteTables', 'erp', {}]]); + }); + + it('control: the platform operator imports under a wall', async () => { + const { bridge, service } = bridgeWithTenancy({ tenancy: { posture: 'group' }, posture: 'PLATFORM_ADMIN' }); + + await expect(bridge.importObject('erp', 'Products', { writable: true })).resolves.toMatchObject({ name: 'products' }); + expect(service.calls).toEqual([['importObject', 'erp', 'Products', { writable: true }]]); + }); + + it('control: under `single`, and with no tenancy service at all, the same organization administrator imports as before', async () => { + for (const tenancy of [{ posture: 'single' }, 'none'] as const) { + const { bridge, service } = bridgeWithTenancy({ tenancy, posture: 'TENANT_ADMIN' }); + + await expect(bridge.importObject('erp', 'Products', {}), JSON.stringify(tenancy)).resolves.toMatchObject({ name: 'products' }); + expect(service.calls, JSON.stringify(tenancy)).toEqual([['importObject', 'erp', 'Products', {}]]); + } + }); + + it('a tenancy service that is registered and cannot answer refuses 503 SERVICE_UNAVAILABLE, never a permit', async () => { + const { bridge, service, lookups } = bridgeWithTenancy({ tenancy: 'failing', posture: 'TENANT_ADMIN' }); + + await expect(bridge.importObject('erp', 'Products', {})).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + expect(lookups).not.toContain('external-datasource'); + expect(service.calls).toEqual([]); + }); +}); From 77e91080d409b3ad2d66d5db77a15a5e71a203bc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 20:01:53 +0000 Subject: [PATCH 2/8] fix(rest,runtime)!: both import doors read the walled-catalog verdict the metadata save door reads Under the isolated and group tenancy postures every organization shares the environment's metadata catalog, and a write to it is the platform operator's (ADR-0131 D3, D12 items 7-8, D11). Importing a remote table saves an object into that catalog, so the REST import route and the MCP import_object tool now ask walledCatalogWriteVerdict, the verdict the metadata save door asks, after the federation capability rule and before the federation service is looked up. A refusal answers 403 PERMISSION_DENIED with the verdict's sentence for an object; a tenancy service that cannot answer refuses 503. The REST route reads the posture off its own kernel, the MCP tool through the dispatcher's one posture read. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg --- .../walled-import-doors-catalog-verdict.md | 18 ++++ .../docs/data-modeling/odata-datasources.mdx | 5 +- content/docs/deployment/tenancy-modes.mdx | 6 +- .../src/meta-write-capability.ts | 9 +- .../rest/src/external-datasource-routes.ts | 96 ++++++++++++++++++- packages/runtime/src/domains/mcp.ts | 37 ++++++- ...a-core__src__meta-write-capability.ts.json | 2 +- 7 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 .changeset/walled-import-doors-catalog-verdict.md diff --git a/.changeset/walled-import-doors-catalog-verdict.md b/.changeset/walled-import-doors-catalog-verdict.md new file mode 100644 index 00000000000..8b4399dc155 --- /dev/null +++ b/.changeset/walled-import-doors-catalog-verdict.md @@ -0,0 +1,18 @@ +--- +'@objectstack/rest': patch +'@objectstack/runtime': patch +--- + +fix(rest,runtime)!: both import doors read the walled-catalog verdict the metadata save door reads + +Clause-②: no (narrowing) — under the `isolated` and `group` tenancy postures, importing a remote table as an object (`POST /api/v1/datasources/:name/external/tables/:remote/import`, and the MCP `import_object` tool) is a write to the environment's metadata catalog, and it is the platform operator's, as every other write to that catalog is. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at both import doors (`@objectstack/rest`, `@objectstack/runtime`) under a walled tenancy posture. + +- **The rule.** Under `isolated` and `group` every organization shares the environment's metadata catalog, and a write to it is admitted only for the platform operator and for the engine's own writes (ADR-0131 D3, D12 items 7–8, D11). An import saves an object into that catalog, so both import doors ask the walled-catalog verdict the metadata save door asks (`walledCatalogWriteVerdict`, `@objectstack/metadata-core`), after the federation capability rule and before the federation service is looked up. +- **The answer.** A refused import answers `403 PERMISSION_DENIED` with the verdict's sentence for an object, the one the metadata save door answers, naming the posture. Nothing is introspected and nothing is saved. A `tenancy` service that is registered and cannot answer refuses the import `503 SERVICE_UNAVAILABLE`. +- **Unchanged.** The platform operator imports under a wall as before. Under `single`, and on a deployment with no `tenancy` service, every caller holding both federation capabilities imports as before. Listing remote tables, drafting an object and validating are unchanged, and the federation capability rule answers first, as before. +- **Who moves.** On a walled deployment, an import by a caller below the platform operator is refused; the platform operator performs it. diff --git a/content/docs/data-modeling/odata-datasources.mdx b/content/docs/data-modeling/odata-datasources.mdx index 84dfe9ffb08..7e2b3dfc869 100644 --- a/content/docs/data-modeling/odata-datasources.mdx +++ b/content/docs/data-modeling/odata-datasources.mdx @@ -145,7 +145,10 @@ What each piece does on this driver: datasource page lists its entity sets on the **Tables** tab. **Import** saves one, and **Import selected** saves several, referenced entity sets first. An MCP client whose token carries `metadata:write` reaches the same import through - `list_remote_tables` and `import_object`. The draft maps each EDM type to a field + `list_remote_tables` and `import_object`. An import saves an object into the + environment's metadata, so under the `group` and `isolated` tenancy postures only + the platform operator imports, as only the platform operator saves any other + metadata there ([tenancy modes](/docs/deployment/tenancy-modes)). The draft maps each EDM type to a field type, makes the key the `id`, and writes `columnMap` for every property whose name is not a snake_case field name (`CategoryID` becomes `category_id`). EDM types with no field type, such as `Edm.Duration` or a service's own enum, are listed for review. An diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx index a47151eb353..88baa6a6fcd 100644 --- a/content/docs/deployment/tenancy-modes.mdx +++ b/content/docs/deployment/tenancy-modes.mdx @@ -67,8 +67,10 @@ and the engine's own writes (seeders, installers, migrations) write it. Holding `manage_metadata` inside an organization does not admit a write: the `/meta` write doors (save, reset, publish, rollback), the install-wide stored rewrite (`POST /meta/_migrate-stored`), every `/packages` write, -`install-local` install, upgrade and uninstall, and the `/automation` -definition writes refuse such a caller `403`, naming the posture. +`install-local` install, upgrade and uninstall, the `/automation` +definition writes, and the import of a remote table as an object +(`POST /datasources/:name/external/tables/:remote/import` and the MCP +`import_object` tool) refuse such a caller `403`, naming the posture. - **Headquarters IT** authors the environment's metadata as the platform operator: declare its administrators in `OS_PLATFORM_OWNER_EMAIL`. A diff --git a/packages/metadata-core/src/meta-write-capability.ts b/packages/metadata-core/src/meta-write-capability.ts index 004176b1016..4a6c94e069d 100644 --- a/packages/metadata-core/src/meta-write-capability.ts +++ b/packages/metadata-core/src/meta-write-capability.ts @@ -45,7 +45,8 @@ * Under a wall, the doors that write the environment's metadata ask a second * predicate after their capability gate, {@link walledCatalogWriteVerdict}: * the `/meta` item write doors, `POST /meta/_migrate-stored`, the `/packages` - * write doors, the `/automation` definition writes and `install-local`. + * write doors, the `/automation` definition writes, `install-local` and the + * external-table import (the REST route and the MCP `import_object` tool). * * ── Refusal messages (#7450) ────────────────────────────────────────────── * @@ -181,8 +182,10 @@ export const PLATFORM_OPERATOR_POSTURE = 'PLATFORM_ADMIN'; * `/meta` item write doors on both transports (save, reset, publish, * rollback), `POST /meta/_migrate-stored` on both transports, the `/packages` * write doors, the `/automation` definition writes, - * `install-local` (install, upgrade, uninstall) and the permission-set clone - * door. (The security plugin's data door on the catalog objects asked it too, + * `install-local` (install, upgrade, uninstall), the permission-set clone + * door, and the external-table import on both of its doors (the REST route and + * the MCP `import_object` tool, which save an `object` into the catalog). (The + * security plugin's data door on the catalog objects asked it too, * until those objects retired — ADR-0131 D3, C3 stage 8: no `sys_position`, * `sys_permission_set` or `sys_capability` write reaches the engine, so a * position, set or capability is written through the `/meta` doors above.) diff --git a/packages/rest/src/external-datasource-routes.ts b/packages/rest/src/external-datasource-routes.ts index 2bc42898c7e..3c00c76ff95 100644 --- a/packages/rest/src/external-datasource-routes.ts +++ b/packages/rest/src/external-datasource-routes.ts @@ -8,15 +8,24 @@ import { ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, + AuthzStoreUnavailableError, + classifyAdmissionTenancyPosture, + effectiveTenancyPosture, + isAuthzStoreUnavailableError, type AuthGate, type PluginContext, + type TenancyPostureSource, } from '@objectstack/core'; +// [ADR-0131 D3 · D12 items 7–8 · D11] The walled refusal every door that writes +// the environment's metadata asks, the metadata save door among them. +import { walledCatalogWriteVerdict } from '@objectstack/metadata-core'; import type { ErrorCode } from '@objectstack/spec/api'; import type { IExternalDatasourceService, IHttpServer, SchemaValidationReport, } from '@objectstack/spec/contracts'; +import type { TenancyPosture } from '@objectstack/spec/security'; // The declared envelope is written in ONE place for the whole platform (#3973). // [#22719] …and so is the read of a sandboxed hook's refusal sentence. import { sendOk, sendError, sandboxBusinessMessage } from '@objectstack/types'; @@ -133,6 +142,14 @@ export interface ExternalDatasourceRoutesOptions { * only for a session the policy holds; the floor refuses on it. */ authGate?: AuthGate; + /** + * [ADR-0131 D3 · D12 items 7–8] The caller's ADR-0095 posture rung + * (`ExecutionContext.posture`), the field the metadata save door hands the + * walled refusal. Read by the import route only: under a walled tenancy + * posture the platform operator rung is what admits a write to the + * environment's catalog. + */ + posture?: unknown; } | undefined>; } @@ -266,6 +283,34 @@ export function registerExternalDatasourceRoutes( ): readonly DirectMountedRoute[] { const ext = `${basePath}/datasources/:name/external`; + /** + * [ADR-0131 D3 · D12 items 7–8 · D11] The deployment's EFFECTIVE tenancy + * posture, for the walled refusal {@link refuseFederationRequest} asks of a + * route that saves a catalog item. Read per request, off this registrar's own + * kernel: the one its `external-datasource` lookup reads, so the posture + * judged and the service that writes come from the same kernel. Through the + * shared classification (`classifyAdmissionTenancyPosture`): no `tenancy` + * service is the supported composition and answers `undefined`, inert as + * `single` is, while one that is registered and cannot answer raises + * `AuthzStoreUnavailableError`, never a permit. A host with no async + * accessor (`KernelBase`-shaped) supports no service factories, so "not + * registered" is the only fault its synchronous lookup reports, and it is + * read as that same composition. + */ + const tenancyPostureInForce = async (): Promise => { + const kernel = (typeof ctx.getKernel === 'function' ? ctx.getKernel() : undefined) as + | { getServiceAsync?: (name: string) => Promise } + | undefined; + if (kernel && typeof kernel.getServiceAsync === 'function') { + return classifyAdmissionTenancyPosture(() => kernel.getServiceAsync!('tenancy')); + } + try { + return effectiveTenancyPosture(ctx.getService('tenancy')); + } catch { + return undefined; + } + }; + /** * [#9686 authentication, #9901 capability] The admission floor for this whole * family. Answers `401 UNAUTHENTICATED`, `403 PERMISSION_DENIED` or, for a @@ -380,14 +425,36 @@ export function registerExternalDatasourceRoutes( * be a door no route walks through, so it is REMOVED rather than kept — a * spare lower gate is exactly what a future un-ruled route could silently * adopt. + * + * ## [ADR-0131 D3 · D12 items 7–8 · D11] …and the walled refusal on a call that saves a catalog item + * + * Under a walled tenancy posture (`isolated`, `group`) every organization + * shares the environment's metadata catalog, and every write to it is the + * platform operator's: an organization's administrator holding both + * federation capabilities is still below that rung. A route that SAVES an item + * into the catalog names the item's type in `catalogWrite`, and this guard + * then asks `walledCatalogWriteVerdict` (`@objectstack/metadata-core`) after + * the capability rule, with the caller's posture rung and `isSystem` off the + * same resolution: the verdict the metadata save door asks, never restated + * here. Inert under `single` and with no `tenancy` service. The import names + * `'object'`, the type it saves. Asked before the service lookup like + * everything above, so the refusal precedes the remote introspection and the + * write. Answered `403 PERMISSION_DENIED`, this family's spelling of a 403, + * with the verdict's sentence; a posture that could not be read answers the + * outage's `503`, never a permit. + * + * `isSystem` reaches the verdict as it reaches it at every walled door: it is + * the verdict's own admission, and it is never resolved from inbound HTTP, so + * no wire caller takes it. */ const refuseFederationRequest = async ( req: any, res: any, kind: FederationCallKind, + catalog: { catalogWrite?: string } = {}, ): Promise => { let authz: - | { userId?: string | null; isSystem?: boolean; systemPermissions?: string[]; authGate?: AuthGate } + | { userId?: string | null; isSystem?: boolean; systemPermissions?: string[]; authGate?: AuthGate; posture?: unknown } | undefined; try { authz = await options.resolveExecutionContext?.(req); @@ -430,6 +497,26 @@ export function registerExternalDatasourceRoutes( sendError(res, 403, 'PERMISSION_DENIED', refusal); return true; } + if (catalog.catalogWrite !== undefined) { + let posture: TenancyPosture | undefined; + try { + posture = await tenancyPostureInForce(); + } catch (err) { + const outage = isAuthzStoreUnavailableError(err) ? err : new AuthzStoreUnavailableError('tenancy', err); + sendError(res, outage.status, outage.code, outage.message); + return true; + } + const walled = walledCatalogWriteVerdict({ + type: catalog.catalogWrite, + tenancyPosture: posture, + isSystem: authz?.isSystem === true, + callerPosture: authz?.posture, + }); + if (!walled.allowed) { + sendError(res, 403, 'PERMISSION_DENIED', walled.message); + return true; + } + } return false; }; @@ -629,12 +716,17 @@ export function registerExternalDatasourceRoutes( // A 'write' that answers with the generated definition — the remote table's // structure — so it requires the read capability as well as the write one // (2026-11-03 ruling; `federationCapabilityRefusal`). + // + // [ADR-0131 D3 · D12 items 7–8 · D11] …and it saves an `object` into the + // environment's metadata catalog, so under a walled tenancy posture it is + // the platform operator's, refused as the metadata save door refuses any + // other catalog write (`refuseFederationRequest`'s `catalogWrite`). { method: 'POST', path: `${ext}/tables/:remote/import`, metadata: { summary: 'Import a remote table as a federated object', tags: ['datasources'] }, handler: async (req: any, res: any) => { - if (await refuseFederationRequest(req, res, 'write')) return; + if (await refuseFederationRequest(req, res, 'write', { catalogWrite: 'object' })) return; const svc = externalService(); if (!svc?.importObject) return unavailable(res); try { diff --git a/packages/runtime/src/domains/mcp.ts b/packages/runtime/src/domains/mcp.ts index b48873389b9..abf84dcdb49 100644 --- a/packages/runtime/src/domains/mcp.ts +++ b/packages/runtime/src/domains/mcp.ts @@ -19,6 +19,10 @@ import { federationCapabilityRefusal, type FederationCallKind, } from '@objectstack/rest'; +// [ADR-0131 D3 · D12 items 7–8 · D11] The walled refusal every door that writes +// the environment's metadata asks, read through this transport's one posture read. +import { walledCatalogWriteVerdict } from '@objectstack/metadata-core'; +import { readEffectiveTenancyPosture } from './activation-gate.js'; import { buildApiError } from '../error-envelope.js'; import * as actionExec from '../action-execution.js'; import { actionInvocationFailure, recordActionInvocation, withActionSurface } from '../action-invocation-audit.js'; @@ -948,12 +952,18 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon // need the read capability, and an envelope holding only the write one // can pass neither — it is carried neither, as a `metadata:write`-only // grant is. + // + // [ADR-0131 D3 · D12 items 7–8 · D11] The import saves an `object` into + // the environment's metadata catalog, so it names that type and + // {@link federationService} asks the walled refusal for it, as the REST + // route does. The listing stays a pure capability decision: the posture + // read is per call, and the pair is listed where a call of it can pass. ...(federationPairCallable(ec) ? { listRemoteTables: async (datasource: string, opts: { schema?: string }) => (await federationService(deps, context, ec, 'read')).listRemoteTables(datasource, opts), importObject: async (datasource: string, remoteName: string, opts: { name?: string; writable?: boolean }) => - (await federationService(deps, context, ec, 'write')).importObject(datasource, remoteName, opts), + (await federationService(deps, context, ec, 'write', { catalogWrite: 'object' })).importObject(datasource, remoteName, opts), } : {}), }; @@ -998,17 +1008,42 @@ function federationPairCallable(ec: any): boolean { * so `import_object` — which answers with the generated definition — needs * the read capability as well as the write one, and its refusal is the * route's sentence. + * + * [ADR-0131 D3 · D12 items 7–8 · D11] A call that saves an item into the + * environment's metadata catalog names its type in `catalogWrite`, and is then + * asked the walled refusal after the capability rule and before the service + * lookup, as the REST import route asks it: `walledCatalogWriteVerdict` + * (`@objectstack/metadata-core`), the verdict the metadata save door asks, + * never restated. Under a walled tenancy posture only the platform operator + * rung (or `isSystem`) is admitted, whatever the caller holds; inert under + * `single` and with no `tenancy` service. The posture is this transport's one + * read ({@link readEffectiveTenancyPosture}, which every walled door of the + * dispatcher reads), so a `tenancy` service that cannot answer raises the + * outage's `503`, never a permit. Refused `403 PERMISSION_DENIED` with the + * verdict's sentence, the route's answer. */ async function federationService( deps: DomainHandlerDeps, context: HttpProtocolContext, ec: any, kind: FederationCallKind, + catalog: { catalogWrite?: string } = {}, ): Promise { const refusal = federationCapabilityRefusal(kind, heldFederationCapabilities(ec)); if (refusal !== undefined) { throw Object.assign(new Error(refusal), { code: 'PERMISSION_DENIED', status: 403 }); } + if (catalog.catalogWrite !== undefined) { + const walled = walledCatalogWriteVerdict({ + type: catalog.catalogWrite, + tenancyPosture: await readEffectiveTenancyPosture(deps, context), + isSystem: ec?.isSystem === true, + callerPosture: ec?.posture, + }); + if (!walled.allowed) { + throw Object.assign(new Error(walled.message), { code: 'PERMISSION_DENIED', status: 403 }); + } + } let service: IExternalDatasourceService | undefined; try { service = await deps.resolveService(context, 'external-datasource', context.environmentId); diff --git a/scripts/adr-anchors/packages__metadata-core__src__meta-write-capability.ts.json b/scripts/adr-anchors/packages__metadata-core__src__meta-write-capability.ts.json index de1e8511907..41d3dca3ab8 100644 --- a/scripts/adr-anchors/packages__metadata-core__src__meta-write-capability.ts.json +++ b/scripts/adr-anchors/packages__metadata-core__src__meta-write-capability.ts.json @@ -3,5 +3,5 @@ "adrs": [ "ADR-0131" ], - "invariant": "`walledCatalogWriteVerdict` is the ONE predicate for ADR-0131's walled refusal (D3 for the catalog, D12 items 7–8 and D11 for every environment-metadata type): under `group` / `isolated` a write of the environment's metadata, whatever its type, is admitted only for `isSystem` or the platform operator rung, never for an organization-scoped `manage_metadata` holder, because one organization's write would change what every other organization resolves. It judges every type alike, so no door pre-filters on the written type before asking it: the `/meta` write doors and `POST /meta/_migrate-stored` on both transports, every `/packages` write door, the `/automation` definition writes, `install-local` and the security plugin's data door ask it; `single` is inert." + "invariant": "`walledCatalogWriteVerdict` is the ONE predicate for ADR-0131's walled refusal (D3 for the catalog, D12 items 7–8 and D11 for every environment-metadata type): under `group` / `isolated` a write of the environment's metadata, whatever its type, is admitted only for `isSystem` or the platform operator rung, never for an organization-scoped `manage_metadata` holder, because one organization's write would change what every other organization resolves. It judges every type alike, so no door pre-filters on the written type before asking it: the `/meta` write doors and `POST /meta/_migrate-stored` on both transports, every `/packages` write door, the `/automation` definition writes, `install-local`, the external-table import on both of its doors (the REST route and the MCP `import_object` tool) and the security plugin's data door ask it; `single` is inert." } From aef5268fa1fc527022289e8248dd113a4d131656 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 20:10:56 +0000 Subject: [PATCH 3/8] test(dogfood): the MCP import pin presents an OAuth agent granted metadata:write and actions:execute The MCP federation pair is registered only for an OAuth agent granted `metadata:write`, and an agent holds its user's system capabilities only under `actions:execute`. Each MCP call in the composed-stack pin now presents a real access token with both scopes, consented from a session active in the organization the agent acts in, so the refusal and both controls run the `import_object` tool itself. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg --- ...ort-walled-catalog-refusal.dogfood.test.ts | 128 ++++++++++++++++-- 1 file changed, 114 insertions(+), 14 deletions(-) diff --git a/packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts b/packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts index a9dc1a4e45d..7974c8e9ea3 100644 --- a/packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts @@ -26,7 +26,12 @@ // not endorse the spelling for new code. // // The verify harness does not mount the federation service or the MCP server, -// so this file mounts both, as `objectstack dev` and `serve` do. The showcase's +// so this file mounts both, as `objectstack dev` and `serve` do. The MCP pair is +// registered only for an OAuth agent granted `metadata:write`, and an agent holds +// its user's system capabilities only under `actions:execute`, so each MCP call +// below presents a REAL access token with both scopes (DCR → authorize with the +// MCP resource → consent → token), consented from a session active in the +// organization the agent then acts in. The showcase's // external datasource names a cwd-relative SQLite file, and every dogfood file // runs in its own temporary working directory (`per-file-cwd.setup.ts`), so the // remote tables `onEnable` provisions here are this file's own. The @@ -34,6 +39,7 @@ // package, which under a wall only the platform operator creates: the operator // ensures it first, so a refusal below is the wall's and never the landing's. +import { createHash, randomBytes } from 'node:crypto'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { onEnable } from '@objectstack/example-showcase'; import { resolveUserAuthzGrants } from '@objectstack/core'; @@ -55,6 +61,14 @@ const importPath = (remote: string) => `/datasources/${DATASOURCE}/external/tabl const IMPORTER_SET = 'federation_importer'; const IMPORTER_CAPABILITIES = ['manage_metadata', 'manage_platform_settings']; +/** The operator's dev credentials and A's password. */ +const OPERATOR = { email: 'admin@objectos.ai', password: 'admin123' }; +const PASSWORD_A = 'Fed-Import-Pass-123'; + +/** An agent that may author metadata and holds its user's system capabilities. */ +const AGENT_SCOPE = 'openid profile email offline_access metadata:write actions:execute'; +const REDIRECT_URI = 'http://127.0.0.1:33419/callback'; + /** A JSON-RPC MCP request over Streamable-HTTP (JSON response mode). */ const mcpBody = (method: string, params?: unknown, id = 1) => ({ jsonrpc: '2.0', id, method, ...(params !== undefined ? { params } : {}) }); @@ -66,12 +80,82 @@ interface Harness { tokA: string; userA: string; orgA?: string; + /** OAuth access tokens for the MCP door, scoped {@link AGENT_SCOPE}. */ + agentOperator: string; + agentA: string; +} + +/** + * A real OAuth access token for the MCP resource: DCR, a password sign-in, the + * organization made active on that session when one is named (the consent binds + * the session's active organization), authorize, consent, token. + */ +async function mintAgentToken( + stack: VerifyStack, + who: { email: string; password: string }, + organizationSlug?: string, +): Promise { + const authSvc: any = await stack.kernel.getServiceAsync('auth'); + const resource = authSvc.getMcpResourceUrl(); + const reg = await stack.api('/auth/oauth2/register', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + client_name: 'federation import probe', redirect_uris: [REDIRECT_URI], grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], token_endpoint_auth_method: 'none', application_type: 'native', scope: AGENT_SCOPE, + }), + }); + const regBody: any = await reg.json(); + expect(reg.status, JSON.stringify(regBody)).toBe(201); + const signin = await stack.api('/auth/sign-in/email', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(who), + }); + expect(signin.status, await signin.clone().text()).toBe(200); + const cookie = ((signin.headers as any).getSetCookie() as string[]).map((c) => c.split(';')[0]).join('; '); + if (organizationSlug) { + const active = await stack.api('/auth/organization/set-active', { + method: 'POST', + headers: { 'content-type': 'application/json', cookie, origin: 'http://localhost:3000' }, + body: JSON.stringify({ organizationSlug }), + }); + expect(active.status, await active.clone().text()).toBe(200); + } + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + const qs = new URLSearchParams({ + client_id: regBody.client_id, redirect_uri: REDIRECT_URI, response_type: 'code', scope: AGENT_SCOPE, state: 'st', + code_challenge: challenge, code_challenge_method: 'S256', resource, + }); + const az = await stack.api(`/auth/oauth2/authorize?${qs}`, { headers: { cookie }, redirect: 'manual' }); + const loc = az.headers.get('location') ?? ''; + let code = new URL(loc, 'http://localhost:3000').searchParams.get('code'); + if (!code) { + const consent = await stack.api('/auth/oauth2/consent', { + method: 'POST', + headers: { 'content-type': 'application/json', cookie, origin: 'http://localhost:3000' }, + body: JSON.stringify({ accept: true, oauth_query: loc.slice(loc.indexOf('?')) }), + }); + const cb: any = await consent.json().catch(() => null); + code = new URL(cb?.redirect_uri ?? cb?.url).searchParams.get('code'); + } + const tok = await stack.api('/auth/oauth2/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', code: code!, redirect_uri: REDIRECT_URI, client_id: regBody.client_id, code_verifier: verifier, resource, + }).toString(), + }); + const tj: any = await tok.json(); + expect(tok.status, JSON.stringify(tj)).toBe(200); + return String(tj.access_token); } async function boot(posture: 'group' | 'single'): Promise { const walled = posture !== 'single'; if (walled) process.env.OS_TENANCY_POSTURE = posture; else delete process.env.OS_TENANCY_POSTURE; + // The OAuth authorization server the MCP door's access tokens come from. + process.env.OS_OIDC_PROVIDER_ENABLED = 'true'; // Provision the remote tables, exactly as `os dev` does at boot (the harness // imports only the stack's default export, so `onEnable` never runs on its own). await onEnable({ logger: { info() {}, warn() {} } } as never); @@ -82,10 +166,10 @@ async function boot(posture: 'group' | 'single'): Promise { const ql: any = await stack.kernel.getServiceAsync('objectql'); const operator = await stack.signIn(); const email = `fed-import-a-${posture}@wall.test`; - const tokA = await stack.signUp(email); - const h: Harness = { stack, ql, operator, tokA, userA: '' }; + const tokA = await stack.signUp(email, PASSWORD_A); + const h: Harness = { stack, ql, operator, tokA, userA: '', agentOperator: '', agentA: '' }; + const slug = `fed-import-a-${posture}`; if (walled) { - const slug = `fed-import-a-${posture}`; const created = await stack.apiAs(tokA, 'POST', '/auth/organization/create', { name: slug, slug }); expect(created.status, await created.clone().text()).toBe(200); h.orgA = ((await created.json()) as { id: string }).id; @@ -105,6 +189,8 @@ async function boot(posture: 'group' | 'single'): Promise { { user_id: h.userA, permission_set: IMPORTER_SET, ...(h.orgA ? { organization_id: h.orgA } : {}) }, { context: { ...SYS, ...(h.orgA ? { tenantId: h.orgA } : {}) } }, ); + h.agentOperator = await mintAgentToken(stack, OPERATOR); + h.agentA = await mintAgentToken(stack, { email, password: PASSWORD_A }, walled ? slug : undefined); return h; } @@ -132,10 +218,26 @@ async function importOverMcp(h: Harness, token: string, remote: string, name: st expect(rpc.error, `tools/call import_object JSON-RPC error: ${JSON.stringify(rpc.error)}`).toBeUndefined(); const text = rpc.result?.content?.[0]?.text; expect(typeof text, 'the tool result carries text content').toBe('string'); - const parsed = JSON.parse(text); + let parsed: any; + try { + parsed = JSON.parse(text); + } catch { + parsed = undefined; + } + expect(parsed, `the tool result is a JSON envelope: ${text}`).toBeTypeOf('object'); return { isError: rpc.result?.isError === true, error: parsed?.error, object: parsed?.object }; } +/** The process environment this file sets, read before a boot and put back after it. */ +const ENV_KEYS = ['OS_TENANCY_POSTURE', 'OS_OIDC_PROVIDER_ENABLED'] as const; +const snapshotEnv = () => Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])) as Record; +function restoreEnv(snapshot: Record): void { + for (const k of ENV_KEYS) { + if (snapshot[k] === undefined) delete process.env[k]; + else process.env[k] = snapshot[k]; + } +} + /** What the platform operator reads at `GET /meta/object/:name`. */ async function storedStatus(h: Harness, name: string): Promise { return (await h.stack.apiAs(h.operator, 'GET', `/meta/object/${name}`)).status; @@ -143,13 +245,12 @@ async function storedStatus(h: Harness, name: string): Promise { describe('[ADR-0131 D12 items 7–8] group: an import is a write to the environment\'s catalog, and it is the platform operator\'s at both import doors', () => { let h: Harness; - const previous = process.env.OS_TENANCY_POSTURE; + const previous = snapshotEnv(); beforeAll(async () => { h = await boot('group'); }, 180_000); afterAll(async () => { await h?.stack?.stop?.(); - if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; - else process.env.OS_TENANCY_POSTURE = previous; + restoreEnv(previous); }); it('guard the guard: a real walled posture; A holds both federation capabilities in org A and is not the platform operator', async () => { @@ -184,7 +285,7 @@ describe('[ADR-0131 D12 items 7–8] group: an import is a write to the environm it('MCP: A\'s `import_object` is refused with the same code and sentence, and nothing is saved', async () => { const name = 'showcase_custom_wall_mcp_cust'; const rest = await importOverRest(h, h.tokA, 'customers', 'showcase_custom_wall_rest_probe'); - const imported = await importOverMcp(h, h.tokA, 'customers', name); + const imported = await importOverMcp(h, h.agentA, 'customers', name); expect(imported.isError, JSON.stringify(imported)).toBe(true); expect({ code: imported.error?.code, status: imported.error?.status }).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); expect(imported.error?.message).toBe(rest.message); @@ -200,7 +301,7 @@ describe('[ADR-0131 D12 items 7–8] group: an import is a write to the environm expect(await storedStatus(h, restName)).toBe(200); const mcpName = 'showcase_custom_wall_op_mcp'; - const mcp = await importOverMcp(h, h.operator, 'orders', mcpName); + const mcp = await importOverMcp(h, h.agentOperator, 'orders', mcpName); expect(mcp.isError, JSON.stringify(mcp)).toBe(false); expect(mcp.object?.name).toBe(mcpName); expect(await storedStatus(h, mcpName)).toBe(200); @@ -209,13 +310,12 @@ describe('[ADR-0131 D12 items 7–8] group: an import is a write to the environm describe('[ADR-0131 D11] single: the wall is not enforced, and an administrator below the platform operator imports at both doors as before', () => { let h: Harness; - const previous = process.env.OS_TENANCY_POSTURE; + const previous = snapshotEnv(); 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; + restoreEnv(previous); }); it('guard the guard: no wall, and A holds both federation capabilities and is not the platform operator', async () => { @@ -235,7 +335,7 @@ describe('[ADR-0131 D11] single: the wall is not enforced, and an administrator expect(await storedStatus(h, restName)).toBe(200); const mcpName = 'showcase_custom_open_mcp'; - const mcp = await importOverMcp(h, h.tokA, 'orders', mcpName); + const mcp = await importOverMcp(h, h.agentA, 'orders', mcpName); expect(mcp.isError, JSON.stringify(mcp)).toBe(false); expect(mcp.object?.name).toBe(mcpName); expect(await storedStatus(h, mcpName)).toBe(200); From 5746dcf6802e6b578311340de8f0386d338474ef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 20:22:38 +0000 Subject: [PATCH 4/8] docs(permissions): the walled environment-metadata row names both import doors Row 55b of the system-context census now lists the import of a remote table as an object, on the REST import route and on the MCP `import_object` tool, among the writes the walled refusal judges, and anchors the two symbols that read the caller's elevation for it. The site register is regenerated. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg --- content/docs/permissions/system-context.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 5c3f0736cf3..9b742cd8010 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -202,7 +202,7 @@ One of the largest consumers of the flag; | 54 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `refusePackageRequest` | | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `requireManageMetadata`, `requireReadCapability` | | 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `refuseUngrantedActivationWrite`, `refuseUngrantedActivationAuthoring` | -| 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), and at the permission-set clone door. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under `single` the refusal is inert for every caller | `walledCatalogWriteVerdict`, `walledCatalogRefusal`, `refuseWalledCatalogPackageWrite`, `refuseWalledFlowDefinitionWrite` | +| 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), at the permission-set clone door, and at the import of a remote table as an object (the REST import route and the MCP `import_object` tool). Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under `single` the refusal is inert for every caller | `walledCatalogWriteVerdict`, `walledCatalogRefusal`, `refuseWalledCatalogPackageWrite`, `refuseWalledFlowDefinitionWrite`, `registerExternalDatasourceRoutes`, `federationService` | | 57 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes, and the elevated self-triggered start and the start-record check at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A) — and starting, through the same doors, a flow whose `requiredPermissions` the caller does not hold (ruling record 6377848497, letter A). And starting, through the same doors, a flow on a record key the caller's own scope does not deliver, which every other caller is refused with the action door's `404 RECORD_NOT_FOUND` before the run exists (and `400 VALIDATION_FAILED` for a key that is not one id, or for an elevated start that names no object). The three doors ask one check, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `mayReadRunState`, `refuseUngrantedFlowWrite`, `isRunStarterOrRunStateReader`, `refuseUngrantedRunLifecycleWrite`, `flowStartRefusal`, `refuseUnreadableFlowStartRecords` | | 57b | Connector credential door's capability and posture gates pass | runtime | Get: listing, setting and clearing the environment's stored connector credentials (`/automation/connectors/credential-refs`) without `manage_metadata`, and under the `group` / `isolated` postures, where the door refuses every other caller `403 PERMISSION_DENIED` naming the posture, the platform operator included, because the store is environment-level. The bypass is the in-process caller's: inbound HTTP never carries the flag. Get nothing more about the value: no answer of this door carries it | `refuseUngrantedConnectorCredentialAccess`, `refuseWalledConnectorCredentialAccess` | | 57c | Connector connection door's capability gate passes | runtime | Get: reading, beginning, completing (the OAuth callback) and revoking an organization's connection of an `oauth2` `authorization_code` connector instance (`/automation/connectors/:name/connection`, `/automation/connectors/oauth/callback`, ADR-0143 D5) without `manage_metadata`. No posture refuses it: a connection belongs to its organization. The bypass is the in-process caller's: inbound HTTP never carries the flag. Get nothing more: no answer of this door carries a token, and the callback's `state` is still bound to the user and organization that began the connect | `refuseUngrantedConnectorConnectionAccess` | @@ -614,7 +614,7 @@ and CI refuses one that differs from what the tree produces. | `packages/plugins/plugin-webhooks/src/declared-webhooks.ts#bindDeclaredWebhookNameGuard` | 1 | 59 | | `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | 1 | 59 | | `packages/rest/src/data-action-routes.ts#importUndoWriteContext` | 1 | 30c | -| `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes` | 1 | 52 | +| `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes` | 2 | 52, 55b | | `packages/rest/src/import-prepare.ts#importControlOptOutRefusal` | 1 | 30b | | `packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes` | 1 | 50 | | `packages/rest/src/meta-item-history-routes.ts#registerMetaItemHistoryRoutes` | 2 | 50 | @@ -643,6 +643,7 @@ and CI refuses one that differs from what the tree produces. | `packages/runtime/src/domains/automation.ts#refuseWalledConnectorCredentialAccess` | 1 | 57b | | `packages/runtime/src/domains/automation.ts#refuseWalledFlowDefinitionWrite` | 1 | 55b | | `packages/runtime/src/domains/i18n.ts#handleI18nRequest` | 1 | 52 | +| `packages/runtime/src/domains/mcp.ts#federationService` | 1 | 55b | | `packages/runtime/src/domains/mcp.ts#handleMcpRequest` | 1 | 53 | | `packages/runtime/src/domains/meta.ts#handleMetadataRequest` | 5 | 50, 52 | | `packages/runtime/src/domains/packages.ts#handlePackagesRequest` | 1 | 52 | From 9db3f59e42da85e4aca5bda0981be750e91cdd2d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 21:09:43 +0000 Subject: [PATCH 5/8] test(rest): the write-route refusal ledger models a deployment with no tenancy service The ledger's context answers every service with a stand-in whose methods all reject, and a stand-in for `tenancy` reads as a walled posture. The import door now asks the walled-catalog verdict, so under that stand-in the import was answered before the federation service the ledger reads. The context now registers no `tenancy` service, as on a deployment that is not walled, and the import reaches the federation service as each other row does. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg --- ...rest-write-route-hook-refusal-sentence.ledger.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts b/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts index 6cbb82c23d5..9ebaf5182ae 100644 --- a/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts +++ b/packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts @@ -177,7 +177,14 @@ function boot(error: () => Error, protocol: Record = {}): Booted { server, recorder: rest, ctx: { - getService: (name: string) => rejecting(`service:${name}`, calls, error), + // No `tenancy` service is registered, as on a deployment that is not + // walled: the import door's walled-catalog verdict is then inert, + // and the import reaches the federation service whose refusal this + // ledger reads. A rejecting stand-in would read as a walled posture. + getService: (name: string) => { + if (name === 'tenancy') throw new Error(`service '${name}' is not registered`); + return rejecting(`service:${name}`, calls, error); + }, logger: { debug() {}, info() {}, warn() {}, error() {} }, } as any, versionedBase: '/api/v1', From 9b7ff08b48a345693baeeb6e269411d1f92cc092 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 22:49:18 +0000 Subject: [PATCH 6/8] test(rest,dogfood): the catalog refresh reads the walled-catalog verdict the metadata save door reads Under `isolated` and `group`, a catalog refresh rewrites the datasource's cached `external_catalog` snapshot, an item of the environment's shared metadata catalog, so it is the platform operator's. The pins: an organization administrator holding both federation capabilities is refused 403 PERMISSION_DENIED with the save door's sentence for that type, before the federation service is looked up, and nothing is stored; the capability rule answers first; a tenancy service that cannot answer refuses 503. Controls: the platform operator refreshes under a wall, and under `single` and with no tenancy service the same administrator refreshes as before. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg --- ...ort-walled-catalog-refusal.dogfood.test.ts | 72 ++++++++++++++-- ...ernal-datasource-routes-auth-guard.test.ts | 82 +++++++++++++++++++ 2 files changed, 147 insertions(+), 7 deletions(-) diff --git a/packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts b/packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts index 7974c8e9ea3..d3e8924f0b7 100644 --- a/packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts @@ -7,17 +7,21 @@ // - `POST /api/v1/datasources/:name/external/tables/:remote/import`, and // - the MCP `import_object` tool. // -// Both doors read the walled-catalog verdict (`walledCatalogWriteVerdict`, +// The catalog refresh, `POST /api/v1/datasources/:name/external/refresh-catalog`, +// rewrites the datasource's cached `external_catalog` snapshot, an item of the +// same catalog, so it is the platform operator's there too. +// +// Each door reads the walled-catalog verdict (`walledCatalogWriteVerdict`, // `@objectstack/metadata-core`) the metadata save door reads, after the // federation family's capability rule and before the federation service is // looked up. Pinned here under `group`, the posture where every organization // shares the environment's catalog: an organization's administrator, holding // both federation capabilities inside that organization and below the platform // operator, is refused at each door with `403 PERMISSION_DENIED` and the save -// door's own sentence for an object, and nothing is saved. CONTROLS: the -// platform operator imports at both doors under the same wall; and under -// `single` the same kind of administrator, who is not the platform operator, -// imports at both doors as before. +// door's own sentence for the type written, and nothing is saved. CONTROLS: the +// platform operator imports at both doors and refreshes the catalog under the +// same wall; and under `single` the same kind of administrator, who is not the +// platform operator, does each as before. // // The status and the sentence are the save door's. The code is this family's // spelling of a 403, `PERMISSION_DENIED`, the standard-catalog member; the REST @@ -56,6 +60,9 @@ const DATASOURCE = 'showcase_external'; const BASE = 'com.example.showcase'; const importPath = (remote: string) => `/datasources/${DATASOURCE}/external/tables/${remote}/import`; +const REFRESH_PATH = `/datasources/${DATASOURCE}/external/refresh-catalog`; +/** The `external_catalog` item a refresh of the datasource stores (`_catalog`). */ +const CATALOG = `${DATASOURCE}_catalog`; /** A runtime-authored set carrying both federation capabilities. */ const IMPORTER_SET = 'federation_importer'; @@ -238,12 +245,28 @@ function restoreEnv(snapshot: Record): void { } } +/** The catalog refresh's answer: status, `error.code`, `error.message` and the snapshot when one came back. */ +async function refreshOverRest(h: Harness, token: string) { + const res = await h.stack.apiAs(token, 'POST', REFRESH_PATH, {}); + const json: any = await res.json().catch(() => ({})); + return { status: res.status, code: json?.error?.code, message: String(json?.error?.message ?? ''), data: json?.data, json }; +} + +/** Whether the datasource's catalog snapshot is stored: in the metadata registry, and as a `sys_metadata` row. */ +async function catalogStored(h: Harness): Promise<{ registry: boolean; rows: number }> { + const metadata: any = await h.stack.kernel.getServiceAsync('metadata'); + const item = await metadata.get('external_catalog', CATALOG); + const found: any = await h.ql.find('sys_metadata', { where: { type: 'external_catalog', name: CATALOG }, context: SYS }); + const rows = Array.isArray(found) ? found : Array.isArray(found?.records) ? found.records : []; + return { registry: item !== undefined && item !== null, rows: rows.length }; +} + /** What the platform operator reads at `GET /meta/object/:name`. */ async function storedStatus(h: Harness, name: string): Promise { return (await h.stack.apiAs(h.operator, 'GET', `/meta/object/${name}`)).status; } -describe('[ADR-0131 D12 items 7–8] group: an import is a write to the environment\'s catalog, and it is the platform operator\'s at both import doors', () => { +describe('[ADR-0131 D12 items 7–8] group: an import and a catalog refresh are writes to the environment\'s catalog, and they are the platform operator\'s at every door', () => { let h: Harness; const previous = snapshotEnv(); @@ -293,6 +316,34 @@ describe('[ADR-0131 D12 items 7–8] group: an import is a write to the environm expect(await storedStatus(h, name)).toBe(404); }); + it('REST: A\'s catalog refresh is refused with the save door\'s status and sentence for an external catalog, and nothing is stored', async () => { + expect(await catalogStored(h), 'no snapshot is stored before the refresh').toEqual({ registry: false, rows: 0 }); + + const refreshed = await refreshOverRest(h, h.tokA); + expect({ status: refreshed.status, code: refreshed.code }, JSON.stringify(refreshed.json)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(refreshed.message).toContain('\'group\' tenancy posture'); + expect(refreshed.message).toContain('ADR-0131 D3, D12 items 7–8, D11'); + expect(refreshed.data, 'a refused refresh answers no snapshot').toBeUndefined(); + + // The metadata save door refuses A the same item with the same status and + // the same sentence: one verdict. + const savedByA = await h.stack.apiAs(h.tokA, 'PUT', `/meta/external_catalog/${CATALOG}`, { + name: CATALOG, datasource: DATASOURCE, snapshotAt: new Date().toISOString(), tables: [], + }); + const saveBody: any = await savedByA.json().catch(() => ({})); + expect(savedByA.status, JSON.stringify(saveBody)).toBe(refreshed.status); + expect(saveBody?.error?.message).toBe(refreshed.message); + + expect(await catalogStored(h)).toEqual({ registry: false, rows: 0 }); + }); + + it('control: the platform operator refreshes the catalog under the same wall, and the snapshot is stored', async () => { + const refreshed = await refreshOverRest(h, h.operator); + expect(refreshed.status, JSON.stringify(refreshed.json)).toBe(200); + expect(refreshed.data?.catalog?.name).toBe(CATALOG); + expect(await catalogStored(h)).toEqual({ registry: true, rows: 1 }); + }); + it('control: the platform operator imports at both doors under the same wall, and each object is saved', async () => { const restName = 'showcase_custom_wall_op_rest'; const rest = await importOverRest(h, h.operator, 'customers', restName); @@ -308,7 +359,7 @@ describe('[ADR-0131 D12 items 7–8] group: an import is a write to the environm }); }); -describe('[ADR-0131 D11] single: the wall is not enforced, and an administrator below the platform operator imports at both doors as before', () => { +describe('[ADR-0131 D11] single: the wall is not enforced, and an administrator below the platform operator imports at both doors and refreshes the catalog as before', () => { let h: Harness; const previous = snapshotEnv(); @@ -340,4 +391,11 @@ describe('[ADR-0131 D11] single: the wall is not enforced, and an administrator expect(mcp.object?.name).toBe(mcpName); expect(await storedStatus(h, mcpName)).toBe(200); }); + + it('control: A refreshes the catalog, and the snapshot is stored', async () => { + const refreshed = await refreshOverRest(h, h.tokA); + expect(refreshed.status, JSON.stringify(refreshed.json)).toBe(200); + expect(refreshed.data?.catalog?.name).toBe(CATALOG); + expect(await catalogStored(h)).toEqual({ registry: true, rows: 1 }); + }); }); diff --git a/packages/rest/src/external-datasource-routes-auth-guard.test.ts b/packages/rest/src/external-datasource-routes-auth-guard.test.ts index 5655d668d57..8d0d8a6880f 100644 --- a/packages/rest/src/external-datasource-routes-auth-guard.test.ts +++ b/packages/rest/src/external-datasource-routes-auth-guard.test.ts @@ -757,3 +757,85 @@ describe('[ADR-0131 D12 items 7–8] under a walled tenancy posture the import i expect(service.importObject).not.toHaveBeenCalled(); }); }); + +// The catalog refresh is the family's other write: it rewrites the datasource's +// cached `external_catalog` snapshot, an item of the same environment-wide +// catalog, so it answers to the same walled verdict for that type, in the same +// order of checks. + +const REFRESH_ROUTE = FAMILY.find((r) => r.call === 'refreshCatalog')!; + +describe('[ADR-0131 D12 items 7–8] under a walled tenancy posture the catalog refresh is the platform operator\'s, like every write to the environment\'s catalog', () => { + for (const posture of ['group', 'isolated'] as const) { + it(`${posture}: an organization administrator holding both federation capabilities is refused the refresh 403 PERMISSION_DENIED, before the service is looked up`, async () => { + const { table, service, lookups } = mountWithTenancy({ tenancy: { posture }, posture: 'TENANT_ADMIN' }); + + const { statusCode, body } = await call(table, REFRESH_ROUTE, {}); + + expect(statusCode).toBe(403); + expect(body?.success).toBe(false); + expect(body?.error?.code).toBe('PERMISSION_DENIED'); + expect(body?.error?.message).toContain(`'${posture}' tenancy posture`); + expect(body?.error?.message).toContain('ADR-0131 D3, D12 items 7–8, D11'); + expect(service.refreshCatalog).not.toHaveBeenCalled(); + expect(lookups).not.toContain('external-datasource'); + }); + } + + it('the refusal is the walled verdict the metadata save door answers for an external catalog', async () => { + const { table } = mountWithTenancy({ tenancy: { posture: 'group' }, posture: 'TENANT_ADMIN' }); + + const { body } = await call(table, REFRESH_ROUTE, {}); + + const verdict = walledCatalogWriteVerdict({ type: 'external_catalog', tenancyPosture: 'group', callerPosture: 'TENANT_ADMIN' }); + expect(verdict.allowed).toBe(false); + expect(body?.error?.message).toBe(verdict.allowed ? undefined : verdict.message); + }); + + it('the capability gate still answers first: a caller lacking the write capability is told which capability, not the posture', async () => { + const { table, service } = mountWithTenancy({ + tenancy: { posture: 'group' }, posture: 'TENANT_ADMIN', grants: [READ_CAPABILITY], + }); + + const { statusCode, body } = await call(table, REFRESH_ROUTE, {}); + + expect(statusCode).toBe(403); + expect(body?.error?.code).toBe('PERMISSION_DENIED'); + expect(body?.error?.message).toContain(WRITE_CAPABILITY); + expect(body?.error?.message).not.toContain('tenancy posture'); + expect(service.refreshCatalog).not.toHaveBeenCalled(); + }); + + it('control: the platform operator refreshes under a wall', async () => { + const { table, service } = mountWithTenancy({ tenancy: { posture: 'group' }, posture: 'PLATFORM_ADMIN' }); + + const { statusCode, body } = await call(table, REFRESH_ROUTE, {}); + + expect(statusCode).toBe(REFRESH_ROUTE.ok); + expect(body?.success).toBe(true); + expect(service.refreshCatalog).toHaveBeenCalledWith(DS); + }); + + it('control: under `single`, and with no tenancy service at all, the same organization administrator refreshes as before', async () => { + for (const tenancy of [{ posture: 'single' }, 'none'] as const) { + const { table, service } = mountWithTenancy({ tenancy, posture: 'TENANT_ADMIN' }); + + const { statusCode, body } = await call(table, REFRESH_ROUTE, {}); + + expect(statusCode, JSON.stringify(tenancy)).toBe(REFRESH_ROUTE.ok); + expect(body?.success, JSON.stringify(tenancy)).toBe(true); + expect(service.refreshCatalog, JSON.stringify(tenancy)).toHaveBeenCalledWith(DS); + } + }); + + it('a tenancy service that is registered and cannot answer refuses the refresh 503 SERVICE_UNAVAILABLE, never a permit', async () => { + const { table, service } = mountWithTenancy({ tenancy: 'failing', posture: 'TENANT_ADMIN' }); + + const { statusCode, body } = await call(table, REFRESH_ROUTE, {}); + + expect(statusCode).toBe(503); + expect(body?.success).toBe(false); + expect(body?.error?.code).toBe('SERVICE_UNAVAILABLE'); + expect(service.refreshCatalog).not.toHaveBeenCalled(); + }); +}); From 773ab2630d69a7a4553ee2cc7995650c3583ab19 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 23:48:03 +0000 Subject: [PATCH 7/8] test(dogfood): the refresh pin reads a stored snapshot wherever the environment holds it A refresh persists its snapshot through `metadata.register`, which writes the metadata service's registry and each writable database loader the deployment has. The pin now reads "stored" as either, so the controls do not depend on which loaders a boot registers, and the refusal pin still requires neither. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg --- ...ort-walled-catalog-refusal.dogfood.test.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts b/packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts index d3e8924f0b7..b0423f48fa0 100644 --- a/packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/external-import-walled-catalog-refusal.dogfood.test.ts @@ -252,13 +252,18 @@ async function refreshOverRest(h: Harness, token: string) { return { status: res.status, code: json?.error?.code, message: String(json?.error?.message ?? ''), data: json?.data, json }; } -/** Whether the datasource's catalog snapshot is stored: in the metadata registry, and as a `sys_metadata` row. */ -async function catalogStored(h: Harness): Promise<{ registry: boolean; rows: number }> { +/** + * Whether the datasource's catalog snapshot is stored anywhere the environment + * reads it: the metadata service's registry, or a `sys_metadata` row (the + * refresh persists through `metadata.register`, which writes the registry and + * every writable database loader the deployment has). + */ +async function catalogStored(h: Harness): Promise { const metadata: any = await h.stack.kernel.getServiceAsync('metadata'); const item = await metadata.get('external_catalog', CATALOG); const found: any = await h.ql.find('sys_metadata', { where: { type: 'external_catalog', name: CATALOG }, context: SYS }); const rows = Array.isArray(found) ? found : Array.isArray(found?.records) ? found.records : []; - return { registry: item !== undefined && item !== null, rows: rows.length }; + return (item !== undefined && item !== null) || rows.length > 0; } /** What the platform operator reads at `GET /meta/object/:name`. */ @@ -317,7 +322,7 @@ describe('[ADR-0131 D12 items 7–8] group: an import and a catalog refresh are }); it('REST: A\'s catalog refresh is refused with the save door\'s status and sentence for an external catalog, and nothing is stored', async () => { - expect(await catalogStored(h), 'no snapshot is stored before the refresh').toEqual({ registry: false, rows: 0 }); + expect(await catalogStored(h), 'no snapshot is stored before the refresh').toBe(false); const refreshed = await refreshOverRest(h, h.tokA); expect({ status: refreshed.status, code: refreshed.code }, JSON.stringify(refreshed.json)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); @@ -334,14 +339,14 @@ describe('[ADR-0131 D12 items 7–8] group: an import and a catalog refresh are expect(savedByA.status, JSON.stringify(saveBody)).toBe(refreshed.status); expect(saveBody?.error?.message).toBe(refreshed.message); - expect(await catalogStored(h)).toEqual({ registry: false, rows: 0 }); + expect(await catalogStored(h)).toBe(false); }); it('control: the platform operator refreshes the catalog under the same wall, and the snapshot is stored', async () => { const refreshed = await refreshOverRest(h, h.operator); expect(refreshed.status, JSON.stringify(refreshed.json)).toBe(200); expect(refreshed.data?.catalog?.name).toBe(CATALOG); - expect(await catalogStored(h)).toEqual({ registry: true, rows: 1 }); + expect(await catalogStored(h)).toBe(true); }); it('control: the platform operator imports at both doors under the same wall, and each object is saved', async () => { @@ -396,6 +401,6 @@ describe('[ADR-0131 D11] single: the wall is not enforced, and an administrator const refreshed = await refreshOverRest(h, h.tokA); expect(refreshed.status, JSON.stringify(refreshed.json)).toBe(200); expect(refreshed.data?.catalog?.name).toBe(CATALOG); - expect(await catalogStored(h)).toEqual({ registry: true, rows: 1 }); + expect(await catalogStored(h)).toBe(true); }); }); From 7b06b726acef4b630ceaffa3a03984542244d3b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 00:32:01 +0000 Subject: [PATCH 8/8] fix(rest)!: the catalog refresh reads the walled-catalog verdict the metadata save door reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under `isolated` and `group` every organization shares the environment's metadata catalog, and a write to it is the platform operator's (ADR-0131 D3, D12 items 7–8, D11). A refresh of a federated datasource's catalog saves its `external_catalog` snapshot into that catalog, so `POST /datasources/:name/external/refresh-catalog` now asks the walled-catalog verdict for that type, as the import doors ask it for an object: after the federation capability rule, before the federation service is looked up, `403 PERMISSION_DENIED` with the save door's sentence, and `503 SERVICE_UNAVAILABLE` when a registered tenancy service cannot answer. The operator, `single` and a deployment with no tenancy service refresh as before. The changeset, the tenancy-modes and OData datasource pages, census row 55b and the verdict's door lists name the door. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg --- .../walled-import-doors-catalog-verdict.md | 16 ++++++++-------- content/docs/data-modeling/odata-datasources.mdx | 3 ++- content/docs/deployment/tenancy-modes.mdx | 6 ++++-- content/docs/permissions/system-context.mdx | 2 +- .../metadata-core/src/meta-write-capability.ts | 10 ++++++---- packages/rest/src/external-datasource-routes.ts | 11 +++++++++-- ...data-core__src__meta-write-capability.ts.json | 2 +- 7 files changed, 31 insertions(+), 19 deletions(-) diff --git a/.changeset/walled-import-doors-catalog-verdict.md b/.changeset/walled-import-doors-catalog-verdict.md index 8b4399dc155..e227e33e029 100644 --- a/.changeset/walled-import-doors-catalog-verdict.md +++ b/.changeset/walled-import-doors-catalog-verdict.md @@ -3,16 +3,16 @@ '@objectstack/runtime': patch --- -fix(rest,runtime)!: both import doors read the walled-catalog verdict the metadata save door reads +fix(rest,runtime)!: the import doors and the catalog refresh read the walled-catalog verdict the metadata save door reads -Clause-②: no (narrowing) — under the `isolated` and `group` tenancy postures, importing a remote table as an object (`POST /api/v1/datasources/:name/external/tables/:remote/import`, and the MCP `import_object` tool) is a write to the environment's metadata catalog, and it is the platform operator's, as every other write to that catalog is. +Clause-②: no (narrowing) — under the `isolated` and `group` tenancy postures, importing a remote table as an object (`POST /api/v1/datasources/:name/external/tables/:remote/import`, and the MCP `import_object` tool) and refreshing a federated datasource's cached catalog (`POST /api/v1/datasources/:name/external/refresh-catalog`) are writes to the environment's metadata catalog, and they are the platform operator's, as every other write to that catalog is. Metadata-protocol: none - + -**BREAKING** accept-set narrowing at both import doors (`@objectstack/rest`, `@objectstack/runtime`) under a walled tenancy posture. +**BREAKING** accept-set narrowing at both import doors and at the catalog refresh (`@objectstack/rest`, `@objectstack/runtime`) under a walled tenancy posture. -- **The rule.** Under `isolated` and `group` every organization shares the environment's metadata catalog, and a write to it is admitted only for the platform operator and for the engine's own writes (ADR-0131 D3, D12 items 7–8, D11). An import saves an object into that catalog, so both import doors ask the walled-catalog verdict the metadata save door asks (`walledCatalogWriteVerdict`, `@objectstack/metadata-core`), after the federation capability rule and before the federation service is looked up. -- **The answer.** A refused import answers `403 PERMISSION_DENIED` with the verdict's sentence for an object, the one the metadata save door answers, naming the posture. Nothing is introspected and nothing is saved. A `tenancy` service that is registered and cannot answer refuses the import `503 SERVICE_UNAVAILABLE`. -- **Unchanged.** The platform operator imports under a wall as before. Under `single`, and on a deployment with no `tenancy` service, every caller holding both federation capabilities imports as before. Listing remote tables, drafting an object and validating are unchanged, and the federation capability rule answers first, as before. -- **Who moves.** On a walled deployment, an import by a caller below the platform operator is refused; the platform operator performs it. +- **The rule.** Under `isolated` and `group` every organization shares the environment's metadata catalog, and a write to it is admitted only for the platform operator and for the engine's own writes (ADR-0131 D3, D12 items 7–8, D11). An import saves an object into that catalog, and a catalog refresh saves the datasource's `external_catalog` snapshot there, so each of those doors asks the walled-catalog verdict the metadata save door asks (`walledCatalogWriteVerdict`, `@objectstack/metadata-core`) for the type it writes, after the federation capability rule and before the federation service is looked up. +- **The answer.** A refused call answers `403 PERMISSION_DENIED` with the verdict's sentence for the type written, the one the metadata save door answers, naming the posture. Nothing is introspected and nothing is saved. A `tenancy` service that is registered and cannot answer refuses the call `503 SERVICE_UNAVAILABLE`. +- **Unchanged.** The platform operator imports and refreshes under a wall as before. Under `single`, and on a deployment with no `tenancy` service, every caller holding both federation capabilities imports and refreshes as before. Listing remote tables, drafting an object and validating are unchanged, and the federation capability rule answers first, as before. +- **Who moves.** On a walled deployment, an import or a catalog refresh by a caller below the platform operator is refused; the platform operator performs it. diff --git a/content/docs/data-modeling/odata-datasources.mdx b/content/docs/data-modeling/odata-datasources.mdx index 7e2b3dfc869..c867554ff6d 100644 --- a/content/docs/data-modeling/odata-datasources.mdx +++ b/content/docs/data-modeling/odata-datasources.mdx @@ -148,7 +148,8 @@ What each piece does on this driver: `list_remote_tables` and `import_object`. An import saves an object into the environment's metadata, so under the `group` and `isolated` tenancy postures only the platform operator imports, as only the platform operator saves any other - metadata there ([tenancy modes](/docs/deployment/tenancy-modes)). The draft maps each EDM type to a field + metadata there ([tenancy modes](/docs/deployment/tenancy-modes)). A refresh of the + datasource's catalog saves its snapshot there too, so the same holds for it. The draft maps each EDM type to a field type, makes the key the `id`, and writes `columnMap` for every property whose name is not a snake_case field name (`CategoryID` becomes `category_id`). EDM types with no field type, such as `Edm.Duration` or a service's own enum, are listed for review. An diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx index 88baa6a6fcd..945afe28c78 100644 --- a/content/docs/deployment/tenancy-modes.mdx +++ b/content/docs/deployment/tenancy-modes.mdx @@ -68,9 +68,11 @@ and the engine's own writes (seeders, installers, migrations) write it. Holding write doors (save, reset, publish, rollback), the install-wide stored rewrite (`POST /meta/_migrate-stored`), every `/packages` write, `install-local` install, upgrade and uninstall, the `/automation` -definition writes, and the import of a remote table as an object +definition writes, the import of a remote table as an object (`POST /datasources/:name/external/tables/:remote/import` and the MCP -`import_object` tool) refuse such a caller `403`, naming the posture. +`import_object` tool), and the refresh of a federated datasource's cached +catalog (`POST /datasources/:name/external/refresh-catalog`) refuse such a +caller `403`, naming the posture. - **Headquarters IT** authors the environment's metadata as the platform operator: declare its administrators in `OS_PLATFORM_OWNER_EMAIL`. A diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 6de4f74f864..1c4cf1d456b 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -203,7 +203,7 @@ One of the largest consumers of the flag; | 54 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `refusePackageRequest` | | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `requireManageMetadata`, `requireReadCapability` | | 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `refuseUngrantedActivationWrite`, `refuseUngrantedActivationAuthoring` | -| 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), at the permission-set clone door, and at the import of a remote table as an object (the REST import route and the MCP `import_object` tool). Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under `single` the refusal is inert for every caller | `walledCatalogWriteVerdict`, `walledCatalogRefusal`, `refuseWalledCatalogPackageWrite`, `refuseWalledFlowDefinitionWrite`, `registerExternalDatasourceRoutes`, `federationService` | +| 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), at the permission-set clone door, at the import of a remote table as an object (the REST import route and the MCP `import_object` tool), and at the refresh of a federated datasource's cached catalog. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under `single` the refusal is inert for every caller | `walledCatalogWriteVerdict`, `walledCatalogRefusal`, `refuseWalledCatalogPackageWrite`, `refuseWalledFlowDefinitionWrite`, `registerExternalDatasourceRoutes`, `federationService` | | 57 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes, and the elevated self-triggered start and the start-record check at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A) — and starting, through the same doors, a flow whose `requiredPermissions` the caller does not hold (ruling record 6377848497, letter A). And starting, through the same doors, a flow on a record key the caller's own scope does not deliver, which every other caller is refused with the action door's `404 RECORD_NOT_FOUND` before the run exists (and `400 VALIDATION_FAILED` for a key that is not one id, or for an elevated start that names no object). The three doors ask one check, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `mayReadRunState`, `refuseUngrantedFlowWrite`, `isRunStarterOrRunStateReader`, `refuseUngrantedRunLifecycleWrite`, `flowStartRefusal`, `refuseUnreadableFlowStartRecords` | | 57b | Connector credential door's capability and posture gates pass | runtime | Get: listing, setting and clearing the environment's stored connector credentials (`/automation/connectors/credential-refs`) without `manage_metadata`, and under the `group` / `isolated` postures, where the door refuses every other caller `403 PERMISSION_DENIED` naming the posture, the platform operator included, because the store is environment-level. The bypass is the in-process caller's: inbound HTTP never carries the flag. Get nothing more about the value: no answer of this door carries it | `refuseUngrantedConnectorCredentialAccess`, `refuseWalledConnectorCredentialAccess` | | 57c | Connector connection door's capability gate passes | runtime | Get: reading, beginning, completing (the OAuth callback) and revoking an organization's connection of an `oauth2` `authorization_code` connector instance (`/automation/connectors/:name/connection`, `/automation/connectors/oauth/callback`, ADR-0143 D5) without `manage_metadata`. No posture refuses it: a connection belongs to its organization. The bypass is the in-process caller's: inbound HTTP never carries the flag. Get nothing more: no answer of this door carries a token, and the callback's `state` is still bound to the user and organization that began the connect | `refuseUngrantedConnectorConnectionAccess` | diff --git a/packages/metadata-core/src/meta-write-capability.ts b/packages/metadata-core/src/meta-write-capability.ts index 4a6c94e069d..821158c4f42 100644 --- a/packages/metadata-core/src/meta-write-capability.ts +++ b/packages/metadata-core/src/meta-write-capability.ts @@ -45,8 +45,9 @@ * Under a wall, the doors that write the environment's metadata ask a second * predicate after their capability gate, {@link walledCatalogWriteVerdict}: * the `/meta` item write doors, `POST /meta/_migrate-stored`, the `/packages` - * write doors, the `/automation` definition writes, `install-local` and the - * external-table import (the REST route and the MCP `import_object` tool). + * write doors, the `/automation` definition writes, `install-local`, the + * external-table import (the REST route and the MCP `import_object` tool) and + * the federated catalog refresh. * * ── Refusal messages (#7450) ────────────────────────────────────────────── * @@ -183,8 +184,9 @@ export const PLATFORM_OPERATOR_POSTURE = 'PLATFORM_ADMIN'; * rollback), `POST /meta/_migrate-stored` on both transports, the `/packages` * write doors, the `/automation` definition writes, * `install-local` (install, upgrade, uninstall), the permission-set clone - * door, and the external-table import on both of its doors (the REST route and - * the MCP `import_object` tool, which save an `object` into the catalog). (The + * door, the external-table import on both of its doors (the REST route and + * the MCP `import_object` tool, which save an `object` into the catalog), and + * the federated catalog refresh (which saves an `external_catalog`). (The * security plugin's data door on the catalog objects asked it too, * until those objects retired — ADR-0131 D3, C3 stage 8: no `sys_position`, * `sys_permission_set` or `sys_capability` write reaches the engine, so a diff --git a/packages/rest/src/external-datasource-routes.ts b/packages/rest/src/external-datasource-routes.ts index 3c00c76ff95..4b22d83bedd 100644 --- a/packages/rest/src/external-datasource-routes.ts +++ b/packages/rest/src/external-datasource-routes.ts @@ -437,7 +437,8 @@ export function registerExternalDatasourceRoutes( * the capability rule, with the caller's posture rung and `isSystem` off the * same resolution: the verdict the metadata save door asks, never restated * here. Inert under `single` and with no `tenancy` service. The import names - * `'object'`, the type it saves. Asked before the service lookup like + * `'object'`, the type it saves; the catalog refresh names `'external_catalog'`, + * the type of the snapshot it saves. Asked before the service lookup like * everything above, so the refusal precedes the remote introspection and the * write. Answered `403 PERMISSION_DENIED`, this family's spelling of a 403, * with the verdict's sentence; a posture that could not be read answers the @@ -747,12 +748,18 @@ export function registerExternalDatasourceRoutes( // A 'write' that answers with the snapshot — every remote table and column // — so it requires the read capability as well as the write one // (2026-11-03 ruling; `federationCapabilityRefusal`). + // + // [ADR-0131 D3 · D12 items 7–8 · D11] …and it saves the datasource's + // `external_catalog` snapshot into the environment's metadata catalog, so + // under a walled tenancy posture it is the platform operator's, refused as + // the metadata save door refuses any other catalog write + // (`refuseFederationRequest`'s `catalogWrite`). { method: 'POST', path: `${ext}/refresh-catalog`, metadata: { summary: 'Refresh the external datasource catalog snapshot', tags: ['datasources'] }, handler: async (req: any, res: any) => { - if (await refuseFederationRequest(req, res, 'write')) return; + if (await refuseFederationRequest(req, res, 'write', { catalogWrite: 'external_catalog' })) return; const svc = externalService(); if (!svc?.refreshCatalog) return unavailable(res); try { diff --git a/scripts/adr-anchors/packages__metadata-core__src__meta-write-capability.ts.json b/scripts/adr-anchors/packages__metadata-core__src__meta-write-capability.ts.json index 41d3dca3ab8..b267c5c7db4 100644 --- a/scripts/adr-anchors/packages__metadata-core__src__meta-write-capability.ts.json +++ b/scripts/adr-anchors/packages__metadata-core__src__meta-write-capability.ts.json @@ -3,5 +3,5 @@ "adrs": [ "ADR-0131" ], - "invariant": "`walledCatalogWriteVerdict` is the ONE predicate for ADR-0131's walled refusal (D3 for the catalog, D12 items 7–8 and D11 for every environment-metadata type): under `group` / `isolated` a write of the environment's metadata, whatever its type, is admitted only for `isSystem` or the platform operator rung, never for an organization-scoped `manage_metadata` holder, because one organization's write would change what every other organization resolves. It judges every type alike, so no door pre-filters on the written type before asking it: the `/meta` write doors and `POST /meta/_migrate-stored` on both transports, every `/packages` write door, the `/automation` definition writes, `install-local`, the external-table import on both of its doors (the REST route and the MCP `import_object` tool) and the security plugin's data door ask it; `single` is inert." + "invariant": "`walledCatalogWriteVerdict` is the ONE predicate for ADR-0131's walled refusal (D3 for the catalog, D12 items 7–8 and D11 for every environment-metadata type): under `group` / `isolated` a write of the environment's metadata, whatever its type, is admitted only for `isSystem` or the platform operator rung, never for an organization-scoped `manage_metadata` holder, because one organization's write would change what every other organization resolves. It judges every type alike, so no door pre-filters on the written type before asking it: the `/meta` write doors and `POST /meta/_migrate-stored` on both transports, every `/packages` write door, the `/automation` definition writes, `install-local`, the external-table import on both of its doors (the REST route and the MCP `import_object` tool), the federated catalog refresh and the security plugin's data door ask it; `single` is inert." }