Skip to content

Commit e0695b5

Browse files
Jack Qclaude
andauthored
fix(cloud-connection): gate the four mutating install-local routes on manage_metadata, drop the x-user-id fallback (#8976) (#9012)
* feat(cloud-connection): gate the four mutating install-local routes on manage_metadata (#8976) `requireAuthenticatedUser` asked one question — "is there a session?" — and it was the only check on `POST /api/v1/marketplace/install-local`, `DELETE …/install-local/:id`, `…/reseed-sample-data` and `…/purge-sample-data`. It also ended in a bare `x-user-id` header fallback. Measured through the composed plugin, to the point the state actually changes (`manifest.register()`, `objectql.syncSchemas()`, the on-disk ledger, `SeedLoaderService.load()`, `driver.delete()`), all three principal shapes were indistinguishable — 200 on every route, every effect fired: 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, and the first row completed a full schema-mutating install with `installedBy` recorded as a caller-chosen string. The four doors now resolve identity AND capability through `resolveAuthzContext` — the platform's single authorization resolver — and demand ADR-0066 D1's `manage_metadata`, the same key the `/meta` write doors carry (#6603, #8919). The `x-user-id` fallback is removed rather than mode-gated, matching the two sibling raw-route surfaces that made the same move (plugin-sharing, service-settings); it was the last `x-user-id` trust left in packages/ source. The mount stays unconditional (cloud#1287) — this is authorization on the routes, not un-mounting the plugin. Adds `marketplace-install-local-capability-enumeration.test.ts`, which derives the mutating routes from the plugin's own route table so a new ungated door fails the build, and annotates the two sibling suites whose names read as authorization coverage and are not — with an executable pointer, so the correction cannot rot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn * chore(changeset): document the install-local capability gate (#8976) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn * fix(cloud-connection): annotate the enumeration pin's headers literal for the tsc ledger (#8976) TS2345 under the package's own `tsc --noEmit`: the ternary widened to `{ 'x-user-id': string } | { 'x-user-id'?: undefined }`, which the `Record<string, string>` parameter refuses. check:type-check-debt measured it as +1 raw error against @objectstack/cloud-connection's frozen DEBT entry of 13. Fixing the error is the author's remedy; raising the entry is maintainer-only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn * fix(cloud-connection): type the new objectql lookup with the slot's contract (#8976) The install-local capability gate's `resolveInstallPrincipal` introduced one NEW service-lookup erasure in a file that is grandfathered for its existing sites only, so `check:slot-lookup` went red: erasure count grew 16 -> 17. The site is the split-declaration shape (#4251) in the resolveAuthzContext wiring: let ql: any; try { ql = ctx.getService('objectql'); } catch { /* no data engine */ } Typed with the slot's declared contract (`IObjectQLEngine`, the same spelling the two sibling surfaces named in the docblock use for this slot), which is what the ratchet's own message instructs. The grandfather count is NOT raised and `SLOT_LOOKUP_UNSWEPT` is untouched - raising a ratchet cap is a gate weakening and maintainer-only. The file's other 16 sites are left alone: they are #4251's batch work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6468df1 commit e0695b5

17 files changed

Lines changed: 892 additions & 75 deletions
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
---
2+
"@objectstack/cloud-connection": minor
3+
---
4+
5+
fix(cloud-connection): the four mutating `install-local` routes require the `manage_metadata` capability, and the `x-user-id` header fallback is gone (#8976)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) Four route handlers gain
8+
a capability gate, one identity resolver is replaced by the shared one, plus one
9+
new test file and a shared test fixture. No authorable property is added,
10+
renamed, retired or tombstoned, so there is no conversion to register. The
11+
behavioural change is that four package-install doors stop accepting callers who
12+
hold no authoring capability, and stop accepting a bare identity header. -->
13+
14+
**BREAKING for any integration that installs, uninstalls, reseeds or purges a
15+
local marketplace package with a principal holding no authoring capability — and
16+
for anything that identified itself to these routes with an `x-user-id` header.**
17+
Landing after the v17.0.0 cut, so it ships as `minor` under the lockstep
18+
launch-window convention.
19+
20+
`MarketplaceInstallLocalPlugin`'s `requireAuthenticatedUser` asked one question —
21+
"is there a session?" — and it was the only check on all four mutating routes:
22+
23+
- `POST /api/v1/marketplace/install-local` — accepts an **inline manifest**,
24+
hot-registers its objects into the shared registry, runs `syncSchemas()`
25+
against the shared database, writes the install ledger and runs seed data;
26+
- `DELETE /api/v1/marketplace/install-local/:manifestId`;
27+
- `POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data`;
28+
- `POST /api/v1/marketplace/install-local/:manifestId/purge-sample-data`.
29+
30+
It also ended in a fallback that trusted a bare **`x-user-id` request header**,
31+
commented as being "for cases where auth is disabled (e.g. test stubs)".
32+
33+
**Measured through the composed plugin, to the point the state actually changes**
34+
`manifest.register()`, `objectql.syncSchemas()`, the ledger file on disk,
35+
`SeedLoaderService.load()`, `driver.delete()`. All three principal shapes were
36+
indistinguishable, and every effect fired for every one of them:
37+
38+
| principal | install | reseed | purge | uninstall |
39+
|:--|:--|:--|:--|:--|
40+
| bare `x-user-id` header, **no session** | **200** | **200** | **200** | **200** |
41+
| authenticated, **no** `manage_metadata` | **200** | **200** | **200** | **200** |
42+
| authenticated, `manage_metadata` | 200 | 200 | 200 | 200 |
43+
44+
Nothing downstream refused any of it. The first row is the sharper half: with no
45+
session store consulted first, a caller who could reach the port completed a
46+
full schema-mutating install and had `installedBy` recorded as a string of their
47+
own choosing.
48+
49+
**Severity by deployment shape.** Metadata is environment-scoped rather than
50+
org-scoped, so Layer 0's tenant wall does not reach these writes: on the walled
51+
multi-org EE shape this is a cross-tenant write channel — any signed-up user of
52+
any customer organization could mutate the schema every other tenant runs on,
53+
and `organization_admin` deliberately withholds `manage_metadata` precisely
54+
because a tenant administrator is not supposed to. It also nullified the
55+
already-implemented cloud-side ruling that AI `build` be structurally closed on
56+
that shape: closing the build agent while this route stayed open closed the
57+
front door and left the loading dock unlocked. On a single-org self-host the
58+
severity is genuinely lower — every user is one tenant's — but "any employee
59+
with a login can alter the schema and run seed data" still contradicts the
60+
operator-action framing, and the header fallback admitted callers with no login
61+
at all. The measurements above are code-path measurements through a composed
62+
host, not an exploit demonstrated against a running deployment.
63+
64+
**The fix.** All four routes now resolve identity **and** capability through
65+
`resolveAuthzContext` — the platform's single authorization resolver
66+
(`@objectstack/core`) — and demand ADR-0066 D1's `manage_metadata`, the same key
67+
the `/meta` write doors carry (#6603, and #8919 for the promotion verbs). A
68+
caller with no resolvable principal gets `401 UNAUTHENTICATED`; an authenticated
69+
caller without the capability gets `403 FORBIDDEN` naming the capability they
70+
need. The refusal is issued before any work, so a refused caller cannot probe
71+
what is installed through a downstream error. Service and operator tokens are
72+
exempt exactly as elsewhere, with no special case: an API key resolves through
73+
the same resolver to its owner's real grants.
74+
75+
**The `x-user-id` fallback is removed, not mode-gated.** It carried no mode flag
76+
to gate it to, and it was the last `x-user-id` trust left in `packages/**`
77+
source — the two sibling raw-route surfaces that carried the identical line had
78+
it *removed* in favour of this same resolver rather than restricted
79+
(`plugin-sharing`'s share-link routes, `service-settings`' settings routes). The
80+
one first-party caller of these routes, `os package install`, signs in for a
81+
real better-auth session cookie and never sent the header.
82+
83+
The plugin's mount stays **unconditional** (cloud#1287 moved it out of the
84+
`marketplaceUrl` ternary so air-gapped boxes stop 404ing). This is authorization
85+
on the routes, not un-mounting the plugin.
86+
87+
**Anti-drift.** `marketplace-install-local-capability-enumeration.test.ts`
88+
derives the mutating routes from the plugin's own route table and compares them
89+
against a declared list, so a new mutating install-local route fails the build
90+
until it is enumerated and its refusal cases run. Each refusal asserts the
91+
ADR-0112 envelope (`code` **and** `status`) *and* that no registry, schema,
92+
ledger, seed or delete effect fired — a gate that answers 403 after
93+
`syncSchemas()` has run is still the bug.
94+
95+
Two existing suites whose names read as authorization coverage —
96+
`marketplace-install-local-posture-gate.test.ts` (the ADR-0120 D5e ceremony,
97+
which the caller satisfies from their own request body) and
98+
`marketplace-install-local-tenancy-posture.test.ts` (which selects a seeding
99+
path) — now open with an explicit statement of what they do **not** cover and
100+
name the file that does, backed by an assertion that the named file exists so
101+
the correction cannot rot into a wrong answer. Neither test was weakened.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#8976] The capable-operator principal every install-local fixture needs.
5+
*
6+
* ## Why this file exists
7+
*
8+
* Before #8976 the four mutating install-local routes admitted anyone with a
9+
* session, so a fixture that wanted to exercise install BEHAVIOUR only had to
10+
* hand the plugin an `auth` service that answered `getSession`. The routes now
11+
* demand ADR-0066 D1's `manage_metadata` authoring capability, resolved through
12+
* `resolveAuthzContext` — the platform's single authorization resolver — which
13+
* reads the caller's grants out of `sys_user_permission_set` /
14+
* `sys_permission_set` via the `objectql` service.
15+
*
16+
* So "a legitimate installer" is no longer expressible as a session alone, and
17+
* every fixture whose subject is something OTHER than authorization (bundle
18+
* normalization, the D5e ceremony, seeding, storage paths, healing…) needs its
19+
* principal upgraded from "logged in" to "logged in and allowed". This module is
20+
* that upgrade, in one place, so the grant shape cannot drift file by file.
21+
*
22+
* ## Deliberately real rows, not a short-circuit
23+
*
24+
* `installerGrantRows` returns actual permission-set rows rather than a
25+
* pre-computed capability list, and the fixtures serve them through the same
26+
* `find` the resolver calls in production. A fixture that instead stubbed the
27+
* capability directly would keep passing if the gate were rewired to read some
28+
* other aggregate — which is exactly the kind of green-over-nothing this card
29+
* was filed about.
30+
*
31+
* ⚠️ This is a fixture for suites that are NOT about authorization. The suite
32+
* that IS about authorization —
33+
* `marketplace-install-local-capability-enumeration.test.ts` — builds its own
34+
* principals, including the refused ones, on purpose: a shared "make me
35+
* allowed" helper has no business being in the file whose whole job is to prove
36+
* that some callers are not.
37+
*/
38+
39+
/** The default fixture user id — matches what the suites already asserted on. */
40+
export const INSTALLER_USER_ID = 'admin';
41+
42+
/**
43+
* The `sys_*` rows that make `userId` a holder of `manage_metadata`, shaped the
44+
* way `resolveAuthzContext` reads them (an UNSCOPED `sys_user_permission_set`
45+
* grant pointing at a `sys_permission_set` whose `system_permissions` carry the
46+
* capability — the shipped `admin_full_access` shape).
47+
*/
48+
export function installerGrantRows(userId: string = INSTALLER_USER_ID): Record<string, unknown[]> {
49+
return {
50+
sys_user: [{ id: userId, email: `${userId}@objectstack.test` }],
51+
sys_member: [],
52+
sys_user_position: [],
53+
sys_position: [],
54+
sys_position_permission_set: [],
55+
sys_user_permission_set: [
56+
{ id: 'ups_installer', user_id: userId, permission_set_id: 'ps_installer', organization_id: null },
57+
],
58+
sys_permission_set: [
59+
{
60+
id: 'ps_installer',
61+
name: 'admin_full_access',
62+
system_permissions: ['manage_metadata', 'studio.access', 'setup.access'],
63+
},
64+
],
65+
};
66+
}
67+
68+
/** The `auth` service shape the plugin resolves a session through. */
69+
export function installerAuthService(userId: string = INSTALLER_USER_ID) {
70+
return { api: { getSession: async () => ({ user: { id: userId }, session: {} }) } };
71+
}
72+
73+
/**
74+
* Wrap an existing `objectql` fake so the authorization tables answer from
75+
* {@link installerGrantRows} and EVERY other object falls through to whatever
76+
* the suite already wired.
77+
*
78+
* Wrapping rather than replacing is the point: these suites' engines carry
79+
* behaviour their own assertions depend on (seed lookups, ledger probes,
80+
* registry reads), and an authorization fixture that quietly took those over
81+
* would break the suites it is meant to leave alone. An engine with no `find`
82+
* at all gets one that answers only the grant tables.
83+
*/
84+
export function withInstallerGrants<T extends Record<string, any>>(
85+
engine: T,
86+
userId: string = INSTALLER_USER_ID,
87+
): T {
88+
const rows = installerGrantRows(userId);
89+
const inner = typeof engine?.find === 'function' ? engine.find.bind(engine) : undefined;
90+
return {
91+
...engine,
92+
find: async (object: string, options?: unknown) => {
93+
if (Object.prototype.hasOwnProperty.call(rows, object)) return rows[object];
94+
return inner ? inner(object, options) : [];
95+
},
96+
} as T;
97+
}

packages/cloud-connection/src/marketplace-install-local-bundle.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
1313
import { join } from 'node:path';
1414
import { tmpdir } from 'node:os';
1515
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
16+
import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js';
1617

1718
type Handler = (c: any) => Promise<any>;
1819

@@ -58,8 +59,8 @@ describe('install-local compiled-bundle normalization', () => {
5859
const rawApp = makeRawApp();
5960
const { ctx, fire } = makeCtx(rawApp, {
6061
manifest: { register },
61-
auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } },
62-
objectql: { syncSchemas: async () => undefined },
62+
auth: installerAuthService(),
63+
objectql: withInstallerGrants({ syncSchemas: async () => undefined }),
6364
});
6465
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
6566
await plugin.start(ctx as any);
@@ -90,8 +91,8 @@ describe('install-local compiled-bundle normalization', () => {
9091
const rawApp = makeRawApp();
9192
const { ctx, fire } = makeCtx(rawApp, {
9293
manifest: { register },
93-
auth: { api: { getSession: async () => ({ user: { id: 'admin' } }) } },
94-
objectql: { syncSchemas: async () => undefined },
94+
auth: installerAuthService(),
95+
objectql: withInstallerGrants({ syncSchemas: async () => undefined }),
9596
});
9697
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
9798
await plugin.start(ctx as any);

0 commit comments

Comments
 (0)