diff --git a/.changeset/install-local-capability-gate.md b/.changeset/install-local-capability-gate.md new file mode 100644 index 0000000000..d18aad7734 --- /dev/null +++ b/.changeset/install-local-capability-gate.md @@ -0,0 +1,101 @@ +--- +"@objectstack/cloud-connection": minor +--- + +fix(cloud-connection): the four mutating `install-local` routes require the `manage_metadata` capability, and the `x-user-id` header fallback is gone (#8976) + + + +**BREAKING for any integration that installs, uninstalls, reseeds or purges a +local marketplace package with a principal holding no authoring capability — and +for anything that identified itself to these routes with an `x-user-id` header.** +Landing after the v17.0.0 cut, so it ships as `minor` under the lockstep +launch-window convention. + +`MarketplaceInstallLocalPlugin`'s `requireAuthenticatedUser` asked one question — +"is there a session?" — and it was the only check on all four mutating routes: + +- `POST /api/v1/marketplace/install-local` — accepts an **inline manifest**, + hot-registers its objects into the shared registry, runs `syncSchemas()` + against the shared database, writes the install ledger and runs seed data; +- `DELETE /api/v1/marketplace/install-local/:manifestId`; +- `POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data`; +- `POST /api/v1/marketplace/install-local/:manifestId/purge-sample-data`. + +It also ended in a fallback that trusted a bare **`x-user-id` request header**, +commented as being "for cases where auth is disabled (e.g. test stubs)". + +**Measured through the composed plugin, to the point the state actually changes** +— `manifest.register()`, `objectql.syncSchemas()`, the ledger file on disk, +`SeedLoaderService.load()`, `driver.delete()`. All three principal shapes were +indistinguishable, and every effect fired for every one of them: + +| principal | install | reseed | purge | uninstall | +|:--|:--|:--|:--|:--| +| bare `x-user-id` header, **no session** | **200** | **200** | **200** | **200** | +| authenticated, **no** `manage_metadata` | **200** | **200** | **200** | **200** | +| authenticated, `manage_metadata` | 200 | 200 | 200 | 200 | + +Nothing downstream refused any of it. The first row is the sharper half: with no +session store consulted first, a caller who could reach the port completed a +full schema-mutating install and had `installedBy` recorded as a string of their +own choosing. + +**Severity by deployment shape.** Metadata is environment-scoped rather than +org-scoped, so Layer 0's tenant wall does not reach these writes: on the walled +multi-org EE shape this is a cross-tenant write channel — any signed-up user of +any customer organization could mutate the schema every other tenant runs on, +and `organization_admin` deliberately withholds `manage_metadata` precisely +because a tenant administrator is not supposed to. It also nullified the +already-implemented cloud-side ruling that AI `build` be structurally closed on +that shape: closing the build agent while this route stayed open closed the +front door and left the loading dock unlocked. On a single-org self-host the +severity is genuinely lower — every user is one tenant's — but "any employee +with a login can alter the schema and run seed data" still contradicts the +operator-action framing, and the header fallback admitted callers with no login +at all. The measurements above are code-path measurements through a composed +host, not an exploit demonstrated against a running deployment. + +**The fix.** All four routes now resolve identity **and** capability through +`resolveAuthzContext` — the platform's single authorization resolver +(`@objectstack/core`) — and demand ADR-0066 D1's `manage_metadata`, the same key +the `/meta` write doors carry (#6603, and #8919 for the promotion verbs). A +caller with no resolvable principal gets `401 UNAUTHENTICATED`; an authenticated +caller without the capability gets `403 FORBIDDEN` naming the capability they +need. The refusal is issued before any work, so a refused caller cannot probe +what is installed through a downstream error. Service and operator tokens are +exempt exactly as elsewhere, with no special case: an API key resolves through +the same resolver to its owner's real grants. + +**The `x-user-id` fallback is removed, not mode-gated.** It carried no mode flag +to gate it to, and it was the last `x-user-id` trust left in `packages/**` +source — the two sibling raw-route surfaces that carried the identical line had +it *removed* in favour of this same resolver rather than restricted +(`plugin-sharing`'s share-link routes, `service-settings`' settings routes). The +one first-party caller of these routes, `os package install`, signs in for a +real better-auth session cookie and never sent the header. + +The plugin's mount stays **unconditional** (cloud#1287 moved it out of the +`marketplaceUrl` ternary so air-gapped boxes stop 404ing). This is authorization +on the routes, not un-mounting the plugin. + +**Anti-drift.** `marketplace-install-local-capability-enumeration.test.ts` +derives the mutating routes from the plugin's own route table and compares them +against a declared list, so a new mutating install-local route fails the build +until it is enumerated and its refusal cases run. Each refusal asserts the +ADR-0112 envelope (`code` **and** `status`) *and* that no registry, schema, +ledger, seed or delete effect fired — a gate that answers 403 after +`syncSchemas()` has run is still the bug. + +Two existing suites whose names read as authorization coverage — +`marketplace-install-local-posture-gate.test.ts` (the ADR-0120 D5e ceremony, +which the caller satisfies from their own request body) and +`marketplace-install-local-tenancy-posture.test.ts` (which selects a seeding +path) — now open with an explicit statement of what they do **not** cover and +name the file that does, backed by an assertion that the named file exists so +the correction cannot rot into a wrong answer. Neither test was weakened. diff --git a/packages/cloud-connection/src/install-local-principal.fixtures.ts b/packages/cloud-connection/src/install-local-principal.fixtures.ts new file mode 100644 index 0000000000..c79212fa43 --- /dev/null +++ b/packages/cloud-connection/src/install-local-principal.fixtures.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8976] The capable-operator principal every install-local fixture needs. + * + * ## Why this file exists + * + * Before #8976 the four mutating install-local routes admitted anyone with a + * session, so a fixture that wanted to exercise install BEHAVIOUR only had to + * hand the plugin an `auth` service that answered `getSession`. The routes now + * demand ADR-0066 D1's `manage_metadata` authoring capability, resolved through + * `resolveAuthzContext` — the platform's single authorization resolver — which + * reads the caller's grants out of `sys_user_permission_set` / + * `sys_permission_set` via the `objectql` service. + * + * So "a legitimate installer" is no longer expressible as a session alone, and + * every fixture whose subject is something OTHER than authorization (bundle + * normalization, the D5e ceremony, seeding, storage paths, healing…) needs its + * principal upgraded from "logged in" to "logged in and allowed". This module is + * that upgrade, in one place, so the grant shape cannot drift file by file. + * + * ## Deliberately real rows, not a short-circuit + * + * `installerGrantRows` returns actual permission-set rows rather than a + * pre-computed capability list, and the fixtures serve them through the same + * `find` the resolver calls in production. A fixture that instead stubbed the + * capability directly would keep passing if the gate were rewired to read some + * other aggregate — which is exactly the kind of green-over-nothing this card + * was filed about. + * + * ⚠️ This is a fixture for suites that are NOT about authorization. The suite + * that IS about authorization — + * `marketplace-install-local-capability-enumeration.test.ts` — builds its own + * principals, including the refused ones, on purpose: a shared "make me + * allowed" helper has no business being in the file whose whole job is to prove + * that some callers are not. + */ + +/** The default fixture user id — matches what the suites already asserted on. */ +export const INSTALLER_USER_ID = 'admin'; + +/** + * The `sys_*` rows that make `userId` a holder of `manage_metadata`, shaped the + * way `resolveAuthzContext` reads them (an UNSCOPED `sys_user_permission_set` + * grant pointing at a `sys_permission_set` whose `system_permissions` carry the + * capability — the shipped `admin_full_access` shape). + */ +export function installerGrantRows(userId: string = INSTALLER_USER_ID): Record { + return { + sys_user: [{ id: userId, email: `${userId}@objectstack.test` }], + sys_member: [], + sys_user_position: [], + sys_position: [], + sys_position_permission_set: [], + sys_user_permission_set: [ + { id: 'ups_installer', user_id: userId, permission_set_id: 'ps_installer', organization_id: null }, + ], + sys_permission_set: [ + { + id: 'ps_installer', + name: 'admin_full_access', + system_permissions: ['manage_metadata', 'studio.access', 'setup.access'], + }, + ], + }; +} + +/** The `auth` service shape the plugin resolves a session through. */ +export function installerAuthService(userId: string = INSTALLER_USER_ID) { + return { api: { getSession: async () => ({ user: { id: userId }, session: {} }) } }; +} + +/** + * Wrap an existing `objectql` fake so the authorization tables answer from + * {@link installerGrantRows} and EVERY other object falls through to whatever + * the suite already wired. + * + * Wrapping rather than replacing is the point: these suites' engines carry + * behaviour their own assertions depend on (seed lookups, ledger probes, + * registry reads), and an authorization fixture that quietly took those over + * would break the suites it is meant to leave alone. An engine with no `find` + * at all gets one that answers only the grant tables. + */ +export function withInstallerGrants>( + engine: T, + userId: string = INSTALLER_USER_ID, +): T { + const rows = installerGrantRows(userId); + const inner = typeof engine?.find === 'function' ? engine.find.bind(engine) : undefined; + return { + ...engine, + find: async (object: string, options?: unknown) => { + if (Object.prototype.hasOwnProperty.call(rows, object)) return rows[object]; + return inner ? inner(object, options) : []; + }, + } as T; +} diff --git a/packages/cloud-connection/src/marketplace-install-local-bundle.test.ts b/packages/cloud-connection/src/marketplace-install-local-bundle.test.ts index ba55a16023..0bca5d6971 100644 --- a/packages/cloud-connection/src/marketplace-install-local-bundle.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-bundle.test.ts @@ -13,6 +13,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; type Handler = (c: any) => Promise; @@ -58,8 +59,8 @@ describe('install-local compiled-bundle normalization', () => { const rawApp = makeRawApp(); const { ctx, fire } = makeCtx(rawApp, { manifest: { register }, - auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } }, - objectql: { syncSchemas: async () => undefined }, + auth: installerAuthService(), + objectql: withInstallerGrants({ syncSchemas: async () => undefined }), }); const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); await plugin.start(ctx as any); @@ -90,8 +91,8 @@ describe('install-local compiled-bundle normalization', () => { const rawApp = makeRawApp(); const { ctx, fire } = makeCtx(rawApp, { manifest: { register }, - auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } }, - objectql: { syncSchemas: async () => undefined }, + auth: installerAuthService(), + objectql: withInstallerGrants({ syncSchemas: async () => undefined }), }); const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); await plugin.start(ctx as any); diff --git a/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts b/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts new file mode 100644 index 0000000000..7566f7f6c8 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts @@ -0,0 +1,369 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8976] EVERY mutating `install-local` route demands the ADR-0066 D1 + * `manage_metadata` authoring capability — and no route can be added to this + * family without answering the question. + * + * ## THIS is the file that answers "is install-local gated?" + * + * Two sibling files in this package have names that sound like they answer it + * and do not — see the "not this file" docblocks now standing in + * `marketplace-install-local-posture-gate.test.ts` (a data-shape ceremony the + * CALLER satisfies from their own request body) and + * `marketplace-install-local-tenancy-posture.test.ts` (which seeding path runs). + * Both were green the entire time the door was open. If you are auditing + * authorization on this surface, this file and the enumeration below are the + * evidence; a green neighbour is not. + * + * ## What was measured before the gate landed + * + * Three principal shapes driven through the composed plugin to the point the + * state actually changes — `manifest.register()` (shared registry), + * `objectql.syncSchemas()` (DDL against the shared database), the ledger file on + * disk, `SeedLoaderService.load()` (rows written), `driver.delete()` (rows + * removed). All three were INDISTINGUISHABLE: + * + * principal install reseed purge uninstall + * bare `x-user-id` header, NO session 200 200 200 200 + * authenticated, no capability 200 200 200 200 + * authenticated, `manage_metadata` 200 200 200 200 + * + * Every effect fired for every shape; nothing downstream refused. The first row + * is the one worth restating: an UNAUTHENTICATED caller who could reach the port + * completed a full schema-mutating install and had `installedBy` recorded as the + * string they chose, because `requireAuthenticatedUser` ended in a bare + * `x-user-id` header fallback. + * + * ## Why an enumeration and not four more assertions + * + * The same reason as the `/meta` precedent (#8919, + * `meta-write-door-capability-enumeration.test.ts`): a gate held by repetition + * drifts the moment someone adds a fifth route by copying whichever neighbour + * was nearest. `derives every mutating route the plugin mounts` builds the door + * list from the raw app's OWN route table, so a new mutating route fails here on + * the day it is added — before anyone has to notice it lacks a gate. + * + * ⚠️ The `GET` listing is deliberately NOT in this family. It is a read, and + * this card's ruling is about the four mutating doors; its own posture is a + * separate question tracked separately, and silently folding it in here would + * decide it by accident. + * + * ## Rejection cases assert the ENVELOPE (ADR-0112) AND the absence of effect + * + * `code` AND `status`, never a bare "it failed" — these handlers answer by + * RETURNING a response, so a throw-shaped assertion could not tell "refused with + * the wrong envelope" from "did not refuse at all". Each refusal also asserts + * that no registry, schema, ledger, seed or delete effect fired: a gate that + * answers 403 after `syncSchemas()` has already run is still the bug. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const seedCalls: unknown[] = []; +vi.mock('@objectstack/runtime', () => ({ + SeedLoaderService: class { + async load(request: unknown) { + seedCalls.push(request); + return { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; + } + }, + recordSeedOutcome: vi.fn(), +})); + +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; + +const ROUTE_BASE = '/api/v1/marketplace/install-local'; + +type Handler = (c: any) => Promise; + +/** + * The raw Hono app the plugin mounts on — and the route table the anti-drift + * assertion reads back. Keys are `" "`, which is what makes + * "everything the plugin registered" enumerable rather than recited. + */ +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; +} + +/** The three principal shapes the ruling names, as the resolver sees them. */ +type Shape = 'header-only' | 'member' | 'org-admin' | 'capable'; + +/** + * Rows the shared authz resolver (`resolveAuthzContext`) reads to aggregate + * `systemPermissions`. Building the grant out of REAL `sys_user_permission_set` + * + `sys_permission_set` rows rather than handing the plugin a pre-baked + * capability list is what keeps this test honest about the resolution path: a + * gate wired to a different aggregate would not see these. + */ +function grantRows(shape: Shape): Record { + const sets: Record = { + 'header-only': [], + member: [], + // The tenant org-admin shape: real capabilities, none of them authoring. + // `organization_admin` deliberately withholds `manage_metadata`. + 'org-admin': ['setup.access', 'setup.write', 'manage_org_users'], + capable: ['manage_metadata', 'studio.access', 'setup.access'], + }; + const held = sets[shape]; + return { + sys_user: [{ id: `usr_${shape}`, email: `${shape}@acme.test` }], + sys_member: [], + sys_user_position: [], + sys_position: [], + sys_position_permission_set: [], + sys_user_permission_set: held.length + ? [{ id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1', organization_id: null }] + : [], + sys_permission_set: held.length + ? [{ id: 'ps1', name: shape === 'capable' ? 'admin_full_access' : 'organization_admin', system_permissions: held }] + : [], + }; +} + +const APP = { + id: 'com.acme.gated', + namespace: 'gated', + version: '1.0.0', + objects: [{ name: 'widget', fields: { code: { type: 'text' } } }], + data: [{ object: 'widget', records: [{ id: 'w1', code: 'a' }, { id: 'w2', code: 'b' }] }], +}; + +const LEDGER_FILE = 'com.acme.gated.json'; + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-gate-')); seedCalls.length = 0; }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); + +/** + * Compose the plugin the way the kernel does — `start()` + the `kernel:ready` + * hook — over a real ledger directory, and hand back both the route table and + * every effect observer the refusal cases need. + */ +async function mount(shape: Shape, storageDir: string) { + const register = vi.fn(async () => undefined); + const syncSchemas = vi.fn(async () => undefined); + const driverDelete = vi.fn(async () => true); + const rawApp = makeRawApp(); + const hooks = new Map(); + + // The header-only shape has NO session: `getSession` resolves nothing, which + // is exactly the state the old `x-user-id` tail used to rescue. + const sessionUser = shape === 'header-only' ? null : { id: `usr_${shape}` }; + const rows = grantRows(shape); + + const services: Record = { + manifest: { register }, + auth: { api: { getSession: async () => (sessionUser ? { user: sessionUser, session: {} } : null) } }, + objectql: { syncSchemas, find: async (object: string) => rows[object] ?? [] }, + metadata: { getObject: async () => ({ name: 'widget', fields: {} }) }, + driver: { delete: driverDelete }, + }; + const ctx: any = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + registerService: () => undefined, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir }); + await plugin.start(ctx); + await hooks.get('kernel:ready')?.(); + // `kernel:ready` registers the plugin's own Setup nav bundle; the effect + // counters must start from AFTER that so a refusal case cannot be fooled by + // boot-time activity it never caused. + register.mockClear(); + return { rawApp, register, syncSchemas, driverDelete }; +} + +function makeC(body: unknown, headers: Record, manifestId?: string) { + const h = new Headers(headers); + return { + req: { + url: `http://localhost:3000${ROUTE_BASE}`, + raw: new Request('http://localhost:3000/x', { headers: h }), + header: (n: string) => h.get(n) ?? undefined, + json: async () => body, + param: () => manifestId, + }, + json: (payload: any, status?: number) => ({ payload, status: status ?? 200 }), + }; +} + +/** + * One mutating install-local door: how to address it, and what state changing + * looks like once the gate lets it through. `effectsFired` is what makes the + * "nothing was mutated" half of each refusal checkable — a status code alone + * cannot tell a refusal from a refusal issued too late. + */ +interface Door { + readonly label: string; + readonly route: string; + readonly body?: unknown; + /** Does this door need an existing install to act on? */ + readonly needsInstalled: boolean; + readonly effectsFired: (o: Awaited>, storageDir: string) => number; +} + +const DOORS: readonly Door[] = [ + { + label: 'POST /install-local (inline manifest → registry + syncSchemas + ledger + seed)', + route: `POST ${ROUTE_BASE}`, + body: { manifest: APP }, + needsInstalled: false, + effectsFired: (o, d) => + o.register.mock.calls.length + + o.syncSchemas.mock.calls.length + + seedCalls.length + + (existsSync(join(d, LEDGER_FILE)) ? 1 : 0), + }, + { + label: 'DELETE /install-local/:manifestId (removes the ledger entry)', + route: `DELETE ${ROUTE_BASE}/:manifestId`, + needsInstalled: true, + // The install this acts on is already on disk, so the effect is its + // DISAPPEARANCE — inverted deliberately, because "0 effects" has to mean + // "nothing changed" for every door or the shared assertion below is a lie. + effectsFired: (_o, d) => (existsSync(join(d, LEDGER_FILE)) ? 0 : 1), + }, + { + label: 'POST /install-local/:manifestId/reseed-sample-data (writes rows)', + route: `POST ${ROUTE_BASE}/:manifestId/reseed-sample-data`, + body: {}, + needsInstalled: true, + effectsFired: () => seedCalls.length, + }, + { + label: 'POST /install-local/:manifestId/purge-sample-data (deletes rows)', + route: `POST ${ROUTE_BASE}/:manifestId/purge-sample-data`, + body: {}, + needsInstalled: true, + effectsFired: (o) => o.driverDelete.mock.calls.length, + }, +]; + +/** Pre-install as a capable operator so the three follow-on doors have a target. */ +async function seedInstall(storageDir: string) { + const setup = await mount('capable', storageDir); + const install = setup.rawApp.routes.get(`POST ${ROUTE_BASE}`)!; + const res = await install(makeC({ manifest: APP }, {})); + expect(res.payload.success).toBe(true); + expect(existsSync(join(storageDir, LEDGER_FILE))).toBe(true); +} + +async function knock(shape: Shape, door: Door, storageDir: string) { + if (door.needsInstalled) await seedInstall(storageDir); + const mounted = await mount(shape, storageDir); + // AFTER the mount, deliberately. `kernel:ready` rehydrates the ledger and + // the rehydrate-time healer re-runs the bundled datasets, so a counter reset + // before mounting attributes a BOOT-time seed to the request under test — + // measured: it made the reseed refusal cases read as "the gate let a write + // through" when the gate had refused correctly and the plugin had simply + // booted. The observation window is the request, not the process. + seedCalls.length = 0; + const handler = mounted.rawApp.routes.get(door.route)!; + const headers: Record = shape === 'header-only' ? { 'x-user-id': 'attacker' } : {}; + const res = await handler(makeC(door.body ?? {}, headers, 'com.acme.gated')); + return { res, effects: door.effectsFired(mounted, storageDir) }; +} + +describe('#8976 — the mutating install-local doors are enumerated, not recited', () => { + it('derives every mutating route the plugin mounts (the anti-drift assertion)', async () => { + // THE POINT OF THIS FILE. A new mutating install-local route fails here + // until it is enumerated above — at which point its refusal cases below + // run and the author learns whether it carries the gate. A door added + // without one can no longer arrive silently. + const { rawApp } = await mount('capable', dir); + const mounted = Array.from(rawApp.routes.keys()) + .filter((k) => !k.startsWith('GET ')) + .sort(); + expect(mounted).toEqual(DOORS.map((d) => d.route).sort()); + }); + + it('mounts the read listing too — so the filter above is a CHOICE, not an empty set', async () => { + // Without this, a refactor that stopped mounting the GET would leave the + // assertion above passing while silently proving less than it claims. + const { rawApp } = await mount('capable', dir); + expect(rawApp.routes.has(`GET ${ROUTE_BASE}`)).toBe(true); + }); +}); + +describe('#8976 — a header-only caller is refused 401 and changes nothing', () => { + it.each(DOORS.map((d) => [d.label, d] as const))( + '%s', + async (_label, door) => { + // The regression this pins: `requireAuthenticatedUser` used to end in + // `c.req.header('x-user-id')`, so this exact request completed. + const { res, effects } = await knock('header-only', door, dir); + expect(res.status).toBe(401); + expect(res.payload).toMatchObject({ error: { code: 'UNAUTHENTICATED' } }); + expect(effects).toBe(0); + }, + ); +}); + +describe('#8976 — an authenticated caller without the capability is refused 403', () => { + it.each(DOORS.map((d) => [d.label, d] as const))( + '%s → plain member', + async (_label, door) => { + const { res, effects } = await knock('member', door, dir); + expect(res.status).toBe(403); + expect(res.payload).toMatchObject({ error: { code: 'FORBIDDEN' } }); + expect(res.payload.error.message).toContain('manage_metadata'); + expect(effects).toBe(0); + }, + ); + + it.each(DOORS.map((d) => [d.label, d] as const))( + '%s → tenant org-admin (setup.access / setup.write / manage_org_users)', + async (_label, door) => { + // The cross-tenant edge, stated as a test. Metadata is + // environment-scoped, so Layer 0's tenant wall does not reach these + // writes; `organization_admin` deliberately withholds + // `manage_metadata` precisely so a tenant administrator cannot mutate + // the schema every other tenant runs on. Setup-app capabilities are + // NOT authoring capabilities — the same separation #6603/#7020 pinned + // on the `/meta` doors. + const { res, effects } = await knock('org-admin', door, dir); + expect(res.status).toBe(403); + expect(res.payload).toMatchObject({ error: { code: 'FORBIDDEN' } }); + expect(effects).toBe(0); + }, + ); +}); + +describe('#8976 — the control: a `manage_metadata` holder still gets through', () => { + it.each(DOORS.map((d) => [d.label, d] as const))( + '%s', + async (_label, door) => { + const { res, effects } = await knock('capable', door, dir); + expect(res.status).toBe(200); + expect(res.payload.success).toBe(true); + expect(effects).toBeGreaterThan(0); + }, + ); + + it('records the VERIFIED principal as `installedBy`, never a caller-supplied string', async () => { + // The old header path wrote whatever the caller sent. The identity on the + // ledger row is now the one the shared resolver verified. + const { rawApp } = await mount('capable', dir); + const install = rawApp.routes.get(`POST ${ROUTE_BASE}`)!; + await install(makeC({ manifest: APP }, { 'x-user-id': 'attacker' })); + const entry = JSON.parse(readFileSync(join(dir, LEDGER_FILE), 'utf8')); + expect(entry.installedBy).toBe('usr_capable'); + expect(entry.installedBy).not.toBe('attacker'); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-conflict.test.ts b/packages/cloud-connection/src/marketplace-install-local-conflict.test.ts index 4d5e24602c..19938e091a 100644 --- a/packages/cloud-connection/src/marketplace-install-local-conflict.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-conflict.test.ts @@ -15,6 +15,7 @@ import { mkdtempSync, rmSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; type Handler = (c: any) => Promise; @@ -37,8 +38,8 @@ function makeCtx(rawApp: any, registry: any[]) { const hooks = new Map(); const services: Record = { manifest: { register: (m: any) => registry.push({ manifest: m }) }, - auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } }, - objectql: { syncSchemas: async () => undefined, registry: { getAllPackages: () => registry } }, + auth: installerAuthService(), + objectql: withInstallerGrants({ syncSchemas: async () => undefined, registry: { getAllPackages: () => registry } }), metadata: {}, }; return { diff --git a/packages/cloud-connection/src/marketplace-install-local-corrupt-ledger.test.ts b/packages/cloud-connection/src/marketplace-install-local-corrupt-ledger.test.ts index 430c250a61..5a93cfe7de 100644 --- a/packages/cloud-connection/src/marketplace-install-local-corrupt-ledger.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-corrupt-ledger.test.ts @@ -50,6 +50,7 @@ vi.mock('@objectstack/spec/data', () => ({ })); import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; import { LocalManifestSource } from './local-manifest-source.js'; type Handler = (c: any) => Promise; @@ -68,11 +69,11 @@ function makeCtx(rawApp: any) { const hooks = new Map(); const services: Record = { manifest: { register: vi.fn() }, - objectql: { syncSchemas: async () => undefined, find: vi.fn(async () => [{ id: 'x' }]) }, + objectql: withInstallerGrants({ syncSchemas: async () => undefined, find: vi.fn(async () => [{ id: 'x' }]) }), metadata: {}, // #5426's two handlers authenticate first; without this they answer 401 // and never reach the ledger read under test. - auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } }, + auth: installerAuthService(), }; return { ctx: { diff --git a/packages/cloud-connection/src/marketplace-install-local-heal.test.ts b/packages/cloud-connection/src/marketplace-install-local-heal.test.ts index 91f95fe3d3..f116b62219 100644 --- a/packages/cloud-connection/src/marketplace-install-local-heal.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-heal.test.ts @@ -39,6 +39,7 @@ vi.mock('@objectstack/spec/data', () => ({ })); import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; import { LocalManifestSource } from './local-manifest-source.js'; import { recordSeedOutcome } from '@objectstack/runtime'; @@ -100,11 +101,11 @@ const MANIFEST = { function makeServices(findRows: Record) { return { manifest: { register: vi.fn() }, - auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } }, - objectql: { + auth: installerAuthService(), + objectql: withInstallerGrants({ syncSchemas: async () => undefined, find: vi.fn(async (object: string) => findRows[object] ?? []), - }, + }), metadata: {}, driver: { delete: vi.fn(async () => true) }, }; diff --git a/packages/cloud-connection/src/marketplace-install-local-offline-degradation.test.ts b/packages/cloud-connection/src/marketplace-install-local-offline-degradation.test.ts index 699795fd9e..07c147aee0 100644 --- a/packages/cloud-connection/src/marketplace-install-local-offline-degradation.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-offline-degradation.test.ts @@ -29,6 +29,7 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; type Handler = (c: any) => Promise; @@ -47,8 +48,8 @@ function makeCtx(rawApp: any) { const services: Record = { 'http-server': { getRawApp: () => rawApp }, manifest: { register: vi.fn() }, - auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } }, - objectql: { syncSchemas: async () => undefined, find: vi.fn(async () => []) }, + auth: installerAuthService(), + objectql: withInstallerGrants({ syncSchemas: async () => undefined, find: vi.fn(async () => []) }), metadata: {}, }; return { diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 917a96da9f..cea9588af1 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -42,6 +42,7 @@ */ import type { Plugin, PluginContext } from '@objectstack/core'; +import { resolveAuthzContext } from '@objectstack/core'; import { resolveTenancyPosture, collectGlobalUniques, @@ -66,10 +67,39 @@ import { } from './local-manifest-source.js'; import { ConnectionCredentialStore } from './connection-credential-store.js'; import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js'; -import type { IHttpServer } from '@objectstack/spec/contracts'; +import type { IHttpServer, IObjectQLEngine } from '@objectstack/spec/contracts'; const ROUTE_BASE = '/api/v1/marketplace/install-local'; +/** + * [#8976] The capability every MUTATING install-local route demands. + * + * `manage_metadata` is ADR-0066 D1's authoring capability and the SAME key the + * platform's other metadata-write doors already require — `PUT`/`DELETE` + * `/api/v1/meta/:type/:name`, `POST /meta/_migrate-stored`, and since #8919 the + * publish/rollback promotion verbs. These four routes are a metadata-write door + * by every measure that matters: `POST` hot-registers an inline manifest's + * objects into the shared registry and then runs `syncSchemas()` against the + * shared database. Declaring them operator-grade while enforcing "anyone with a + * login" is the declared-≠-enforced gap this closes. + * + * ⛔ Not a new install-specific capability. Inventing one would leave every + * existing operator unable to install until an administrator granted a key that + * did not exist yesterday, and would split "may author metadata" from "may + * install metadata" — a product decision nobody has made. The shipped + * `admin_full_access` set carries `manage_metadata` + * (`PLATFORM_ADMIN_ONLY_CAPABILITIES`, plugin-security), so platform operators + * pass unchanged; `organization_admin` deliberately does NOT carry it, which is + * precisely the cross-tenant edge this closes on the walled multi-org shape. + * + * Service / operator tokens are exempt EXACTLY as elsewhere, with no special + * case here: an API key resolves through `resolveAuthzContext`'s key path to its + * owner's real grants, so a key whose owner holds `manage_metadata` passes this + * gate and one whose owner does not is refused — the same answer the `/meta` + * doors give the same credential. + */ +const INSTALL_LOCAL_CAPABILITY = 'manage_metadata'; + /** * A ledger read failure in the thrower's own words (#5413 / #5426). * @@ -451,10 +481,17 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }; private handleInstall = async (c: any, ctx: PluginContext): Promise => { - const userId = await this.requireAuthenticatedUser(c, ctx); - if (!userId) { - return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Authentication required to install packages.' } }, 401); - } + // [#8976] The sharpest of the four: this door accepts an INLINE manifest + // and turns it into `syncSchemas()` against the shared database. + // `admission`, not `gate` — `gate` is taken further down by the ADR-0120 + // D5e global-unique CEREMONY, which is a data-shape question the caller + // answers about their own manifest. Two different words for two + // different gates: one decides who may knock, the other what they may + // bring. Conflating them is how the ceremony came to look like an + // authorization test in the first place. + const admission = await this.requireInstallCapability(c, ctx, 'Installing a package'); + if (!admission.ok) return admission.response; + const userId = admission.userId; let body: any = {}; try { body = await c.req.json(); } catch { /* empty body */ } @@ -777,10 +814,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }; private handleUninstall = async (c: any, ctx: PluginContext): Promise => { - const userId = await this.requireAuthenticatedUser(c, ctx); - if (!userId) { - return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Authentication required.' } }, 401); - } + const admission = await this.requireInstallCapability(c, ctx, 'Uninstalling a package'); + if (!admission.ok) return admission.response; const manifestId = String(c.req.param?.('manifestId') ?? c.req.params?.manifestId ?? '').trim(); if (!manifestId) { return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: 'manifestId path param required.' } }, 400); @@ -947,10 +982,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * rule as install seed path). */ private handleReseed = async (c: any, ctx: PluginContext): Promise => { - const userId = await this.requireAuthenticatedUser(c, ctx); - if (!userId) { - return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Authentication required.' } }, 401); - } + const admission = await this.requireInstallCapability(c, ctx, 'Reseeding sample data'); + if (!admission.ok) return admission.response; const manifestId = String(c.req.param?.('manifestId') ?? c.req.params?.manifestId ?? '').trim(); if (!manifestId) { return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: 'manifestId path param required.' } }, 400); @@ -1030,10 +1063,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * datasets are removed. Already-deleted rows count as `skipped`. */ private handlePurge = async (c: any, ctx: PluginContext): Promise => { - const userId = await this.requireAuthenticatedUser(c, ctx); - if (!userId) { - return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Authentication required.' } }, 401); - } + const admission = await this.requireInstallCapability(c, ctx, 'Purging sample data'); + if (!admission.ok) return admission.response; const manifestId = String(c.req.param?.('manifestId') ?? c.req.params?.manifestId ?? '').trim(); if (!manifestId) { return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: 'manifestId path param required.' } }, 400); @@ -1287,10 +1318,17 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }; /** - * Best-effort active-org resolution. Reads the better-auth session - * (same path as requireAuthenticatedUser) and returns - * `session.activeOrganizationId`, falling back to the user's first - * org membership. + * Best-effort active-org resolution. Reads the better-auth session directly + * and returns `session.activeOrganizationId`, falling back to the user's + * first org membership. + * + * ⚠️ [#8976] This is a SCOPING read, not an authorization one — which org's + * rows a seed lands in, asked only after {@link requireInstallCapability} + * has already admitted the caller. It deliberately does NOT go through + * `resolveAuthzContext`: the tenant a request is acting in is a different + * question from the grants it holds, and the resolver's `tenantId` is the + * session's active org with no membership fallback. Never promote this into + * an admission check — it answers `null` for perfectly authorized callers. */ private resolveActiveOrgId = async (c: any, ctx: PluginContext): Promise => { if (!c?.req?.raw?.headers) return null; @@ -1317,27 +1355,156 @@ export class MarketplaceInstallLocalPlugin implements Plugin { return null; }; - private requireAuthenticatedUser = async (c: any, ctx: PluginContext): Promise => { + /** + * [#8976] The ONE authorization decision every mutating install-local route + * makes — identity AND capability, resolved together, in one place. + * + * ## What this replaced, and what was measured through it + * + * The predecessor (`requireAuthenticatedUser`) asked one question — "is + * there a session?" — and answered `yes` to two principals it should not + * have. Driven through the composed plugin to the point the state actually + * changes, all three shapes below returned `200` and were INDISTINGUISHABLE: + * `manifest.register()` ran, `objectql.syncSchemas()` ran against the shared + * database, the ledger file landed on disk, and the seed loader wrote rows. + * + * principal install reseed purge uninstall + * bare `x-user-id` header, NO session 200 200 200 200 + * authenticated, no capability 200 200 200 200 + * authenticated, `manage_metadata` 200 200 200 200 + * + * Nothing downstream refused any of it. That is the whole finding: these are + * not routes whose gate is weak, they are routes with no authorization at + * all beyond "you are somebody". + * + * ## Why `resolveAuthzContext`, not a second session read + * + * It is the platform's SINGLE source of truth for turning an inbound request + * into an authorization envelope (`@objectstack/core`, guarded by + * `check:single-authz-resolver`), and it resolves API key / session / OAuth + * and the whole `sys_member` → `sys_user_position` → permission-set + * aggregation that a hand-rolled `getSession` read cannot. Two sibling + * raw-route surfaces already made exactly this move for exactly this bug — + * `SettingsServicePlugin` and `SharingServicePlugin` both replaced spoofable + * header identity with this call — so this is the third instance of a + * settled pattern, not a new one. The `getSession` bridge below is the same + * shape `SettingsServicePlugin` passes. + * + * ## The `x-user-id` fallback is GONE, deliberately + * + * The old tail was `const xUserId = c?.req?.header?.('x-user-id'); if + * (xUserId) return String(xUserId);` — commented "for cases where auth is + * disabled (e.g. test stubs)". Measured (first row of the table above): with + * no session store consulted first, a caller who could reach the port sent + * one header and completed a full schema-mutating install, with `installedBy` + * recorded as whatever string they chose. A test-stub convenience that is + * also a production admission path is not a dev-mode feature, and there is + * no mode flag on it to gate. It was the LAST `x-user-id` trust left in + * `packages/**` source; the two surfaces that carried the same line had it + * removed rather than mode-gated (`plugin-sharing`, `service-settings`), and + * the one first-party caller of these routes — `os package install` — signs + * in for a real better-auth cookie and never sends the header. + * + * Fails CLOSED on every unresolvable input: no raw headers, no `objectql`, + * a throwing resolver — all yield `null`, i.e. 401. An `objectql`-less + * embedding could not have held a permission set to be judged on anyway, and + * could not have run `syncSchemas()` either. + */ + private resolveInstallPrincipal = async ( + c: any, + ctx: PluginContext, + ): Promise<{ userId: string; systemPermissions: string[] } | null> => { + const headers = c?.req?.raw?.headers; + if (!headers) return null; try { - // Mirror `hono-plugin.ts` resolveCtx: pull the better-auth `api` - // off the auth service and call `getSession({ headers })`. The - // earlier guess `c.get('auth').session` is wrong — AuthPlugin - // does not pre-populate the Hono context. - const authService: any = ctx.getService('auth'); - let api: any = authService?.api; - if (!api && typeof authService?.getApi === 'function') { - api = await authService.getApi(); - } - if (api?.getSession && c?.req?.raw?.headers) { - const session = await api.getSession({ headers: c.req.raw.headers }); - const userId = session?.user?.id ?? null; - if (userId) return String(userId); - } - } catch { /* ignore — fall through */ } - // Header fallback for cases where auth is disabled (e.g. test stubs) - const xUserId = c?.req?.header?.('x-user-id'); - if (xUserId) return String(xUserId); - return null; + // The `objectql` slot's declared contract, not `any` (#4127/#4251): + // this lookup is NEW code, so it carries the contract rather than + // riding the file's grandfathered entry in + // `scripts/slot-lookup-baseline.json`. Same spelling the two sibling + // surfaces named above use for this slot (`plugin-sharing`, + // `service-settings`) — the full engine seen whole, of which the + // resolver reads only `find`. + let ql: IObjectQLEngine | undefined; + try { ql = ctx.getService('objectql'); } catch { /* no data engine */ } + + // The better-auth session bridge — resolved lazily and defensively, + // exactly as the previous implementation did, then handed to the + // shared resolver instead of being trusted on its own. + const getSession = async (h: any) => { + try { + const authService: any = ctx.getService('auth'); + let api: any = authService?.api; + if (!api && typeof authService?.getApi === 'function') { + api = await authService.getApi(); + } + return await api?.getSession?.({ headers: h }); + } catch { + return undefined; + } + }; + + const authz = await resolveAuthzContext({ ql, headers, getSession }); + if (!authz.userId) return null; + return { + userId: String(authz.userId), + systemPermissions: Array.isArray(authz.systemPermissions) ? authz.systemPermissions : [], + }; + } catch { + return null; + } + }; + + /** + * [#8976] The shared refusal for the four mutating routes: 401 when nobody + * is authenticated, 403 when somebody is but holds no authoring capability, + * otherwise the acting `userId`. + * + * One helper rather than four inline copies, on purpose. The four doors are + * one class — install, uninstall, reseed, purge all mutate installed + * metadata or its data — and the failure this fixes is precisely a route + * family whose members drifted apart on authorization. A single seam means a + * fifth route added here cannot get a WEAKER answer by copying the wrong + * neighbour, and `install-local-capability-enumeration.test.ts` fails the + * build if one arrives that does not call this at all. + * + * `action` names the verb in the 403 so the operator learns which grant they + * need, not merely that they were refused. The refusal is issued BEFORE any + * work — before the manifest is parsed, before the ledger is read — so a + * refused caller cannot use timing or a downstream error to probe what is + * installed. + */ + private requireInstallCapability = async ( + c: any, + ctx: PluginContext, + action: string, + ): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> => { + const principal = await this.resolveInstallPrincipal(c, ctx); + if (!principal) { + return { + ok: false, + response: c.json({ + success: false, + error: { code: 'UNAUTHENTICATED', message: 'Authentication required.' }, + }, 401), + }; + } + if (!principal.systemPermissions.includes(INSTALL_LOCAL_CAPABILITY)) { + ctx.logger?.warn?.( + `[MarketplaceInstallLocal] refused ${action} for ${principal.userId} — ` + + `missing the \`${INSTALL_LOCAL_CAPABILITY}\` capability`, + ); + return { + ok: false, + response: c.json({ + success: false, + error: { + code: 'FORBIDDEN', + message: `${action} requires the \`${INSTALL_LOCAL_CAPABILITY}\` capability.`, + }, + }, 403), + }; + } + return { ok: true, userId: principal.userId }; }; /** diff --git a/packages/cloud-connection/src/marketplace-install-local-posture-gate.test.ts b/packages/cloud-connection/src/marketplace-install-local-posture-gate.test.ts index c529006210..9bcb3f656c 100644 --- a/packages/cloud-connection/src/marketplace-install-local-posture-gate.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-posture-gate.test.ts @@ -1,8 +1,28 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [ADR-0120 D5e] The `isolated`-posture install gate for installation-wide - * (`'global'`) uniques, exercised at the real install seam. + * ⛔ [#8976] THIS FILE IS NOT AN AUTHORIZATION TEST. Do not read it as one. + * + * The word "gate" here means the ADR-0120 D5e CONFIRMATION CEREMONY — a + * question about the manifest's data shape — and the caller answers it + * THEMSELVES, by putting `confirmGlobalUniques` in their own request body (see + * "confirming records the attestation…" below, and the `confirmGlobalUniques: + * true` in almost every case here). A check whose verdict is read out of the + * request being checked can say nothing whatsoever about who may call the + * route. This file was green for the entire period in which + * `POST /install-local` admitted a caller with no session at all, on a bare + * `x-user-id` header, straight through to `syncSchemas()` against the shared + * database. + * + * Auditing whether install-local is gated? The file that answers that is + * `marketplace-install-local-capability-enumeration.test.ts` — every mutating + * route, derived from the plugin's own route table, against three principals. + * The final `describe` in this file asserts that companion still exists, so + * this pointer cannot quietly rot into a wrong answer. + * + * [ADR-0120 D5e] What this file DOES pin: the `isolated`-posture install gate + * for installation-wide (`'global'`) uniques, exercised at the real install + * seam. * * What is being pinned, and why each half matters: * @@ -24,10 +44,11 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; import { LocalManifestSource } from './local-manifest-source.js'; type Handler = (c: any) => Promise; @@ -127,8 +148,8 @@ async function mountInstall(storageDir: string) { const rawApp = makeRawApp(); const { ctx, fire } = makeCtx(rawApp, { manifest: { register }, - auth: { api: { getSession: async () => ({ user: { id: 'usr_installer' } }) } }, - objectql: { syncSchemas }, + auth: installerAuthService('usr_installer'), + objectql: withInstallerGrants({ syncSchemas }, 'usr_installer'), }); const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir }); await plugin.start(ctx as any); @@ -366,3 +387,21 @@ describe('ADR-0120 D5e — the gate is NOT a boot-time check (#4884)', () => { expect(warned).not.toContain('installation-wide unique'); }); }); + +/** + * [#8976] The scope disclaimer at the top of this file, made EXECUTABLE. + * + * A prose pointer to "the file that actually pins authorization" is worth + * exactly as much as its target's continued existence, and nothing warns you + * when a rename or a delete turns it into a confident wrong answer — which is + * the precise failure this whole card is about: a green test that reads as + * coverage it does not provide. So the pointer is asserted, not merely written. + * If the enumeration pin moves, this goes red and the sentence above gets + * corrected instead of quietly lying to the next auditor. + */ +describe('#8976 — what this file does NOT cover', () => { + it('points at the companion that DOES pin authorization, and it still exists', () => { + expect(existsSync(new URL('./marketplace-install-local-capability-enumeration.test.ts', import.meta.url))) + .toBe(true); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-reseed.test.ts b/packages/cloud-connection/src/marketplace-install-local-reseed.test.ts index 035d30bb2a..9f095c6acf 100644 --- a/packages/cloud-connection/src/marketplace-install-local-reseed.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-reseed.test.ts @@ -32,6 +32,7 @@ vi.mock('@objectstack/spec/data', () => ({ })); import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; type Handler = (c: any) => Promise; @@ -79,8 +80,8 @@ function makeC(body: any, manifestId?: string) { const SERVICES = () => ({ manifest: { register: vi.fn() }, - auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } }, - objectql: { syncSchemas: async () => undefined }, + auth: installerAuthService(), + objectql: withInstallerGrants({ syncSchemas: async () => undefined }), metadata: {}, }); diff --git a/packages/cloud-connection/src/marketplace-install-local-seed-lookup.test.ts b/packages/cloud-connection/src/marketplace-install-local-seed-lookup.test.ts index 2a9bbf94f7..8c1fdd9c93 100644 --- a/packages/cloud-connection/src/marketplace-install-local-seed-lookup.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-seed-lookup.test.ts @@ -27,6 +27,7 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; type Handler = (c: any) => Promise; @@ -203,8 +204,8 @@ describe('marketplace install — seed lookup resolution', () => { // The real wiring: manifest.register → ql.registerApp → engine // registry ONLY. Nothing reaches the metadata service. manifest: { register: (m: any) => engine.registerApp(m) }, - auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } }, - objectql: engine, + auth: installerAuthService(), + objectql: withInstallerGrants(engine), metadata: { getObject: vi.fn(async () => undefined), list: vi.fn(async () => []) }, }); const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); diff --git a/packages/cloud-connection/src/marketplace-install-local-state-machine-exempt.test.ts b/packages/cloud-connection/src/marketplace-install-local-state-machine-exempt.test.ts index 463a68f2ee..e9ace9c825 100644 --- a/packages/cloud-connection/src/marketplace-install-local-state-machine-exempt.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-state-machine-exempt.test.ts @@ -27,6 +27,7 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; type Handler = (c: any) => Promise; @@ -222,8 +223,8 @@ describe('marketplace install — state_machine initialStates exemption (#3433)' const rawApp = makeRawApp(); const { ctx, fire } = makeCtx(rawApp, { manifest: { register: (m: any) => engine.registerApp(m) }, - auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } }, - objectql: engine, + auth: installerAuthService(), + objectql: withInstallerGrants(engine), metadata: { getObject: vi.fn(async () => undefined), list: vi.fn(async () => []) }, }); const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); diff --git a/packages/cloud-connection/src/marketplace-install-local-storage-dir.test.ts b/packages/cloud-connection/src/marketplace-install-local-storage-dir.test.ts index 10b7f55fe7..0b33542547 100644 --- a/packages/cloud-connection/src/marketplace-install-local-storage-dir.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-storage-dir.test.ts @@ -28,6 +28,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; import { DEFAULT_INSTALLED_PACKAGES_DIR } from './local-manifest-source.js'; type Handler = (c: any) => Promise; @@ -46,8 +47,8 @@ function makeCtx(rawApp: any) { const hooks = new Map(); const services: Record = { manifest: { register: vi.fn() }, - auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } }, - objectql: { syncSchemas: async () => undefined }, + auth: installerAuthService(), + objectql: withInstallerGrants({ syncSchemas: async () => undefined }), }; return { ctx: { diff --git a/packages/cloud-connection/src/marketplace-install-local-tenancy-posture.test.ts b/packages/cloud-connection/src/marketplace-install-local-tenancy-posture.test.ts index 532ed69a68..244f78585d 100644 --- a/packages/cloud-connection/src/marketplace-install-local-tenancy-posture.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-tenancy-posture.test.ts @@ -1,5 +1,19 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // +// ⛔ [#8976] THIS FILE IS NOT AN AUTHORIZATION TEST. Do not read it as one. +// +// "tenancy posture" here selects WHICH SEEDING PATH runs — inline versus the +// per-org replay — and how the resulting rows are scoped. It never asks who the +// caller is or what they may do; a caller with no session at all reached every +// assertion in this file unchanged, which is exactly what #8976 measured and +// closed. `postureEnforcesWall` is about DATA scope, not admission. +// +// Auditing whether install-local is gated? The file that answers that is +// `marketplace-install-local-capability-enumeration.test.ts` — every mutating +// route, derived from the plugin's own route table, against three principals. +// The final `describe` in this file asserts that companion still exists, so +// this pointer cannot quietly rot into a wrong answer. +// // #5262 — the marketplace local-install plugin's two seeding decisions ask // whether an organization wall is IN FORCE, never the demoted // `OS_MULTI_ORG_ENABLED` boolean. @@ -30,7 +44,7 @@ // this package use) so the assertions can read what the seeder was ASKED to do. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -48,6 +62,7 @@ vi.mock('@objectstack/spec/data', () => ({ })); import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; import { LocalManifestSource } from './local-manifest-source.js'; type Handler = (c: any) => Promise; @@ -112,11 +127,11 @@ const MANIFEST = { /** No `sys_organization` rows and no active org on the request. */ const SERVICES = (findRows: Record = {}) => ({ manifest: { register: vi.fn() }, - auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } }, - objectql: { + auth: installerAuthService(), + objectql: withInstallerGrants({ syncSchemas: async () => undefined, find: vi.fn(async (object: string) => findRows[object] ?? []), - }, + }), metadata: {}, driver: { delete: vi.fn(async () => true) }, }); @@ -293,3 +308,21 @@ describe('#5262 — rehydrate heal leaves walled deployments to the per-org repl expect(loadCalls).toHaveLength(1); }); }); + +/** + * [#8976] The scope disclaimer at the top of this file, made EXECUTABLE. + * + * A prose pointer to "the file that actually pins authorization" is worth + * exactly as much as its target's continued existence, and nothing warns you + * when a rename or a delete turns it into a confident wrong answer — which is + * the precise failure this whole card is about: a green test that reads as + * coverage it does not provide. So the pointer is asserted, not merely written. + * If the enumeration pin moves, this goes red and the sentence above gets + * corrected instead of quietly lying to the next auditor. + */ +describe('#8976 — what this file does NOT cover', () => { + it('points at the companion that DOES pin authorization, and it still exists', () => { + expect(existsSync(new URL('./marketplace-install-local-capability-enumeration.test.ts', import.meta.url))) + .toBe(true); + }); +}); diff --git a/packages/cloud-connection/src/runtime-config-install-local-derivation.test.ts b/packages/cloud-connection/src/runtime-config-install-local-derivation.test.ts index b1d2fe0350..1e2bc84787 100644 --- a/packages/cloud-connection/src/runtime-config-install-local-derivation.test.ts +++ b/packages/cloud-connection/src/runtime-config-install-local-derivation.test.ts @@ -45,6 +45,7 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { RuntimeConfigPlugin, type RuntimeConfigPluginConfig } from './runtime-config-plugin.js'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; import { MarketplaceProxyPlugin } from './marketplace-proxy-plugin.js'; interface RouteRecord { method: string; path: string } @@ -90,8 +91,8 @@ async function startOn(app: unknown, plugin: { start(ctx: any): Promise }) const services: Record = { 'http.server': { getRawApp: () => app }, manifest: { register() {} }, - auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } }, - objectql: { syncSchemas: async () => undefined }, + auth: installerAuthService(), + objectql: withInstallerGrants({ syncSchemas: async () => undefined }), }; const ctx: any = { logger: { info() {}, warn: (m: unknown) => { warnings.push(String(m)); }, error() {} }, diff --git a/packages/cloud-connection/src/runtime-config-marketplace-derivation.test.ts b/packages/cloud-connection/src/runtime-config-marketplace-derivation.test.ts index bb74593813..533e2fa132 100644 --- a/packages/cloud-connection/src/runtime-config-marketplace-derivation.test.ts +++ b/packages/cloud-connection/src/runtime-config-marketplace-derivation.test.ts @@ -47,6 +47,7 @@ import { tmpdir } from 'node:os'; import { RuntimeConfigPlugin, type RuntimeConfigPluginConfig } from './runtime-config-plugin.js'; import { MarketplaceProxyPlugin } from './marketplace-proxy-plugin.js'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; interface RouteRecord { method: string; path: string } @@ -91,8 +92,8 @@ async function startOn(app: unknown, plugin: { start(ctx: any): Promise }) const services: Record = { 'http.server': { getRawApp: () => app }, manifest: { register() {} }, - auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } }, - objectql: { syncSchemas: async () => undefined }, + auth: installerAuthService(), + objectql: withInstallerGrants({ syncSchemas: async () => undefined }), }; const ctx: any = { logger: { info() {}, warn: (m: unknown) => { warnings.push(String(m)); }, error() {} },