Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions .changeset/install-local-capability-gate.md
Original file line numberDiff line numberDiff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) Four route handlers gain
a capability gate, one identity resolver is replaced by the shared one, plus one
new test file and a shared test fixture. No authorable property is added,
renamed, retired or tombstoned, so there is no conversion to register. The
behavioural change is that four package-install doors stop accepting callers who
hold no authoring capability, and stop accepting a bare identity header. -->

**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.
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown[]> {
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<T extends Record<string, any>>(
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;
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<any>;

Expand DownExpand Up@@ -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);
Expand DownExpand Up@@ -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);
Expand Down
Loading
Loading