diff --git a/.changeset/24148-cloud-connection-setup-access.md b/.changeset/24148-cloud-connection-setup-access.md new file mode 100644 index 0000000000..8038e4b50f --- /dev/null +++ b/.changeset/24148-cloud-connection-setup-access.md @@ -0,0 +1,20 @@ +--- +'@objectstack/cloud-connection': minor +--- + +fix(cloud-connection)!: `bind/start`, `bind/poll` and `unbind` require `setup.access`, and `GET /status` serves the connection's details only to a caller who holds it + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +The Console's Cloud Connection page sits on the Setup app, which requires the Setup Access permission (`setup.access`). The routes behind that page did not ask for it: the three that change this runtime's cloud connection checked only that the caller was signed in, and `GET /api/v1/cloud-connection/status` served the connection record the control plane reports to every caller. Both now follow the page's rule. + +- **Refused now:** `POST /api/v1/cloud-connection/bind/start`, `POST /api/v1/cloud-connection/bind/poll` and `POST /api/v1/cloud-connection/unbind` answer a signed-in caller who does not hold `setup.access` with `403` and the standard `PERMISSION_DENIED` code, and the message names the capability. The refusal comes before any call to the control plane and before the stored credential is touched. What the caller holds is read through `resolveAuthzContext`, the platform's one authorization resolver, for the session these routes already read: a grant bound through a position, or scoped to the session's active organization, counts as it does everywhere else, and an API key sent beside the session lends its owner's grants to nobody. +- **Narrowed now:** `GET /api/v1/cloud-connection/status` still answers every caller `200`, so a probe can still tell that the plugin is mounted. A caller without `setup.access` (anonymous, signed in without it, or a session the ADR-0069 policy holds) is served `bound`, `runtimeId` and `provider` only. `environmentId` and `connection` are served to a caller who holds it. +- **Unchanged:** a caller who holds `setup.access` is served exactly as before, so the Console panel's connected view, its "Approved by" row included, keeps working for an administrator. An anonymous caller of the three connection-changing routes still gets `401`, and a held session still gets the ADR-0069 `403`. The other four session routes (`install`, `installation`, `installed`, `org-packages`) are not touched. +- **An unreadable permission store is not a denial.** When the grants cannot be read, the three connection-changing routes raise the platform's `SERVICE_UNAVAILABLE` outage (`503` through the host's error render) instead of answering a `403` that reads like a real refusal. `status` keeps answering, withholds the details, and logs the outage. + +What changes for you. Whoever manages this runtime's cloud connection needs Setup Access, which the default platform-administrator and organization-administrator permission sets already carry. A script that reads `connection` or `environmentId` from `GET /status` has to call it as a signed-in user who holds Setup Access. diff --git a/content/docs/deployment/cloud-connection.mdx b/content/docs/deployment/cloud-connection.mdx index b1d8d22c50..f5e38ef1fe 100644 --- a/content/docs/deployment/cloud-connection.mdx +++ b/content/docs/deployment/cloud-connection.mdx @@ -74,12 +74,10 @@ the server reads as not connected until you connect it again. - *On the server*, you sign in with a user of this server. The Cloud Connection page is in the Setup app, which takes the **Setup Access** permission (`setup.access`); the default platform-administrator and - organization-administrator permission sets carry it. A known defect in the - connect and disconnect requests: today these requests check only that the - caller is signed in, not Setup Access - ([#24148](https://github.com/objectstack-ai/objectstack/issues/24148)); - until that is fixed, give sign-in on this server only to people who may - change its cloud connection. A session + organization-administrator permission sets carry it. The connect and + disconnect requests take the same permission: a signed-in user without + Setup Access is refused with `403` (`PERMISSION_DENIED`) before the server + calls the cloud. A session that the authentication policy is holding (an expired password, or multi-factor sign-in required but not yet set up) is refused with `403` until it is fixed. @@ -213,7 +211,7 @@ The routes the Cloud Connection page uses, all on the server's own origin: | Route | Sign-in | What it does | |:---|:---|:---| -| `GET /api/v1/cloud-connection/status` | not required | `bound`, `runtimeId`, and `connection`, the connection as the cloud reports it. `bound` is `true` whenever the server holds a credential; `connection` is `null` when the cloud cannot be reached. Narrowing what unprivileged callers see is tracked in [#24148](https://github.com/objectstack-ai/objectstack/issues/24148). | +| `GET /api/v1/cloud-connection/status` | not required | Every caller gets `bound`, `runtimeId` and `provider`. A signed-in user with Setup Access (`setup.access`) also gets `environmentId` and `connection`, the connection as the cloud reports it (the approving account and the organization). `bound` is `true` whenever the server holds a credential; `connection` is `null` when the cloud cannot be reached. | | `POST /api/v1/cloud-connection/bind/start` | required | Asks the cloud for a device code; answers with `user_code`, `verification_uri`, `verification_uri_complete`, `interval`, `expires_in`. | | `POST /api/v1/cloud-connection/bind/poll` | required | Body `{ "device_code": "…" }`. Answers `{ "pending": true }` while the cloud waits for approval, and the connection once it is approved. | | `POST /api/v1/cloud-connection/unbind` | required | Disconnects; answers `{ "revoked", "cleared" }`. | diff --git a/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts b/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts index ee57748b85..2f824bc650 100644 --- a/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts +++ b/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts @@ -10,7 +10,8 @@ * * - `CloudConnectionPlugin` reads the session in ONE place * (`sessionFromAuthService`, under both resolution modes). Seven of its - * eight doors call it; `/status` reads no session at all. + * eight doors refuse on it; `/status` reads it only to decide what it + * serves (#24148) and refuses no one. * - `MarketplaceInstallLocalPlugin` reads it in ONE place * (`resolveInstallPrincipal`), and all five doors answer a held session * through the same refusal (`refuseHeldSession`). @@ -28,7 +29,12 @@ * gate's `code` and `message`, and no effect fires (no control-plane call, * no credential cleared; no registry, schema, ledger, seed or delete write). * - UN-HELD: the same caller and grants, without a posture, are served. - * - Anonymous is refused `401` as before, and `/status` is untouched. + * - Anonymous is refused `401` as before, and `/status` answers a held + * session `200` with its narrowed shape (#24148). + * + * Every test user holds `setup.access`, so a refusal below is the gate's alone + * (#24148 makes three of the seven doors demand it; its own pins are + * `cloud-connection-setup-access.pin.test.ts`). */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -156,11 +162,16 @@ async function mountCloudConnection(mode: 'single' | 'multi') { const credentialPath = join(dir, 'cloud-connection.json'); const store = new ConnectionCredentialStore(credentialPath); store.write({ runtimeToken: 'oscc_pin', runtimeId: 'rt_1', environmentId: 'env-1' }); + // [#24148] The grants every test user holds (`setup.access` among them), + // read by the three connection-changing doors through the shared resolver. + const rows = grantRows(); + const objectql = bindCatalogFromTables({ find: async (object: string) => rows[object] ?? [] }, rows); + const envKernelServices: Record = { auth, objectql }; const services: Record = mode === 'single' - ? { auth } + ? { auth, objectql } : { 'env-registry': { resolveByHostname: async () => ({ environmentId: 'env-1' }) }, - 'kernel-manager': { getOrCreate: async () => ({ getServiceAsync: async () => auth }) }, + 'kernel-manager': { getOrCreate: async () => ({ getServiceAsync: async (name: string) => envKernelServices[name] }) }, }; const ctx = { hook: (e: string, h: any) => hooks.set(e, h), @@ -192,14 +203,18 @@ async function mountCloudConnection(mode: 'single' | 'multi') { } describe('ADR-0069 at CloudConnectionPlugin', () => { - it('census: eight routes, none allow-listed; seven resolve a session and /status resolves none', async () => { - const { rawApp, auth } = await mountCloudConnection('single'); + it('census: eight routes, none allow-listed; seven refuse a held session and /status refuses none', async () => { + const { rawApp } = await mountCloudConnection('single'); const keys = [...rawApp.routes.keys()]; expect(keys).toHaveLength(8); for (const key of keys) expect(isAuthGateAllowlisted(key.split(' ')[1]), key).toBe(false); expect(keys.filter((k) => k !== `GET ${CC}/status`).sort()).toEqual(CC_SESSION_DOORS.map((d) => d.route).sort()); - await rawApp.routes.get(`GET ${CC}/status`)!(makeC(`${ORIGIN}${CC}/status`, 'u_held_pw')); - expect(auth.reads.count, '/status read a session').toBe(0); + // [#24148] `/status` reads the session only to decide what it serves: a + // held session is answered, with the shape served to a caller without + // `setup.access` — never refused. + const res = await rawApp.routes.get(`GET ${CC}/status`)!(makeC(`${ORIGIN}${CC}/status`, 'u_held_pw')); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(Object.keys(res.payload.data).sort()).toEqual(['bound', 'provider', 'runtimeId']); }); for (const mode of ['single', 'multi'] as const) { diff --git a/packages/cloud-connection/src/cloud-connection-plugin.test.ts b/packages/cloud-connection/src/cloud-connection-plugin.test.ts index b925e88150..28a58b77e7 100644 --- a/packages/cloud-connection/src/cloud-connection-plugin.test.ts +++ b/packages/cloud-connection/src/cloud-connection-plugin.test.ts @@ -11,6 +11,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { CloudConnectionPlugin, createCloudConnectionPlugin } from './cloud-connection-plugin.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; type Handler = (c: any) => Promise; @@ -59,6 +60,23 @@ const sessionAuth = (userId?: string) => ({ api: { getSession: async () => (userId ? { user: { id: userId } } : null) }, }); +/** + * [#24148] A data engine under which `userId` holds `setup.access` — what + * `/status` serves its full answer behind. Real permission-set rows, read by + * the shared resolver through `find`. + */ +const setupAccessEngine = (userId: string) => { + const rows: Record = { + sys_user: [{ id: userId, email: `${userId}@example.test` }], + sys_user_permission_set: [{ id: 'ups_1', user_id: userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }], + sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }], + }; + return bindCatalogFromTables({ find: async (object: string) => rows[object] ?? [] }, rows); +}; + +/** [#24148] What `/status` serves a caller without `setup.access`. */ +const narrowed = (data: { bound: boolean; runtimeId: string | null }) => ({ ...data, provider: 'objectstack-cloud' }); + beforeEach(() => { delete process.env.OS_ENVIRONMENT_ID; }); @@ -118,7 +136,7 @@ describe('multi-tenant mode (env-registry + per-env kernel auth)', () => { await new CloudConnectionPlugin({ controlPlaneUrl: 'http://cloud.test', controlPlaneApiKey: 'svc-key' }).start(ctx as any); await fireKernelReady(); const res = await rawApp.routes.get('GET /api/v1/cloud-connection/status')!(makeC('https://t1.example.com/api/v1/cloud-connection/status')); - expect(res.payload).toEqual({ success: true, data: { environmentId: ENV, runtimeId: null, bound: true, provider: 'objectstack-cloud', connection: null } }); + expect(res.payload).toEqual({ success: true, data: narrowed({ runtimeId: null, bound: true }) }); }); it('status 404s for an unknown hostname', async () => { @@ -200,7 +218,7 @@ describe('single-environment mode (host auth, fixed env id)', () => { await fireKernelReady(); const res = await rawApp.routes.get('GET /api/v1/cloud-connection/status')!(makeC('http://localhost:3000/x')); expect(res.status).toBe(200); - expect(res.payload.data).toEqual({ environmentId: null, runtimeId: null, bound: false, provider: 'objectstack-cloud', connection: null }); + expect(res.payload.data).toEqual(narrowed({ runtimeId: null, bound: false })); }); it('uses the configured environment id + the host kernel auth session', async () => { @@ -223,7 +241,8 @@ describe('single-environment mode (host auth, fixed env id)', () => { it("ignores the CLI's local sentinel env ids (env_local / proj_local)", async () => { process.env.OS_ENVIRONMENT_ID = 'env_local'; const rawApp = makeRawApp(); - const { ctx, fireKernelReady } = makeCtx({ rawApp }); + // A caller holding `setup.access`, who is served the environment id. + const { ctx, fireKernelReady } = makeCtx({ rawApp, services: { auth: sessionAuth('admin-1'), objectql: setupAccessEngine('admin-1') } }); await new CloudConnectionPlugin({ singleEnvironment: true, controlPlaneUrl: 'http://cloud.test' }).start(ctx as any); await fireKernelReady(); const res = await rawApp.routes.get('GET /api/v1/cloud-connection/status')!(makeC('http://localhost:3000/x')); @@ -236,7 +255,7 @@ describe('single-environment mode (host auth, fixed env id)', () => { it('falls back to OS_ENVIRONMENT_ID when no config id is given', async () => { process.env.OS_ENVIRONMENT_ID = 'env-from-env-var'; const rawApp = makeRawApp(); - const { ctx, fireKernelReady } = makeCtx({ rawApp, services: { auth: sessionAuth('admin-1') } }); + const { ctx, fireKernelReady } = makeCtx({ rawApp, services: { auth: sessionAuth('admin-1'), objectql: setupAccessEngine('admin-1') } }); vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ success: true, data: {} }), { status: 200 }))); await new CloudConnectionPlugin({ singleEnvironment: true, controlPlaneUrl: 'http://cloud.test', controlPlaneApiKey: 'k' }).start(ctx as any); await fireKernelReady(); diff --git a/packages/cloud-connection/src/cloud-connection-plugin.ts b/packages/cloud-connection/src/cloud-connection-plugin.ts index 41360dce04..44de963e5a 100644 --- a/packages/cloud-connection/src/cloud-connection-plugin.ts +++ b/packages/cloud-connection/src/cloud-connection-plugin.ts @@ -42,6 +42,22 @@ * the host kernel's own `auth` service. Routes degrade gracefully when the * id is not (yet) configured: status reports `bound:false`; the others 404. * + * ## Who may manage the connection — `setup.access` (#24148) + * + * The Console's Cloud Connection panel sits on the Setup app, which declares + * `requiredPermissions: ['setup.access']` (`@objectstack/platform-objects`, + * `apps/setup.app.ts`). The three doors that change the connection — + * `bind/start`, `bind/poll` and `unbind` — refuse exactly who that page + * refuses: a signed-in caller who does not hold `setup.access` gets `403 + * PERMISSION_DENIED` naming the capability, before any control-plane call. + * `status` stays session-less (it answers every caller, so a probe can tell + * the plugin is mounted) but narrows what it SERVES: the connection's details + * — who approved it, for which organization, in which environment — go only + * to a caller who passes the same check; everyone else is told `bound`, + * `runtimeId` and `provider`. What a caller holds is read through the + * platform's one authorization resolver (`resolveAuthzContext`), against the + * session this surface already read — see `holdsSetupAccess`. + * * ## Why a same-origin proxy at all * * The SPA cannot call the control plane from a tenant subdomain — that is a @@ -58,6 +74,13 @@ interface PluginContext { hook(event: string, handler: (...args: any[]) => any): void; getService(name: string): T; + /** + * The host kernel — read only for its ASYNC registry, which is what tells a + * `tenancy` service that was never registered apart from one that broke + * (`classifyAdmissionTenancyPosture`). Optional: a `KernelBase`-shaped host + * has no async registry, and keeps the quiet answer. + */ + getKernel?(): EnvironmentKernel | undefined; logger?: { info?: (msg: string) => void; /** @@ -80,7 +103,20 @@ interface Plugin { } import { hostname } from 'node:os'; -import { evaluateAuthGate, type AuthGate } from '@objectstack/core'; +import { + evaluateAuthGate, + type AuthGate, + // [#24148] The platform's ONE authorization resolver, and its loud outage + // brand — the same pair `marketplace-install-local-plugin.ts` reads in this + // package, so the capability a caller holds is never re-derived here. + resolveAuthzContext, + isAuthzStoreUnavailableError, + userAuthGateSourceOf, + apiKeyLifecyclePolicySourceOf, + classifyAdmissionTenancyPosture, + type TenancyPostureSource, +} from '@objectstack/core'; +import type { IAuthService, IObjectQLEngine } from '@objectstack/spec/contracts'; import { inProcessSessionReadInput } from '@objectstack/types'; import { ConnectionCredentialStore } from './connection-credential-store.js'; import { CLOUD_CONNECTION_UI_BUNDLE } from './cloud-connection-ui.js'; @@ -88,6 +124,49 @@ import { readJsonRequestBody } from './request-json-body.js'; const CLOUD_CONNECTION_PREFIX = '/api/v1/cloud-connection'; +/** + * [#24148] The capability the connection-changing doors demand, and the one the + * connection's details are served behind on `status`. + * + * It is the permission that gates the page this surface serves: the Cloud + * Connection panel lives on the Setup app, whose `requiredPermissions` is + * `['setup.access']` (`@objectstack/platform-objects`, `apps/setup.app.ts`). So + * the API refuses exactly who the page refuses, and no new role concept is + * introduced. ⛔ Not a new cloud-specific capability: inventing one would lock + * every existing administrator out of a page they can already open. + */ +const SETUP_ACCESS_CAPABILITY = 'setup.access'; + +/** + * [#24148] The refusal the three connection-changing doors give a signed-in + * caller who does not hold {@link SETUP_ACCESS_CAPABILITY}. + * + * `PERMISSION_DENIED` is the standard catalog's member for the condition: the + * ADR-0112 ledger sends a generic permission condition to the standard catalog + * rather than to a registered synonym (`error-code-ledger.zod.ts`, "Registering + * a new code"), so nothing is registered for it — and ⛔ not `FORBIDDEN`, which + * the install-local doors next door answer with: that spelling is a waived + * pre-gate synonym, kept on the wire and never endorsed for new code. The + * message names the capability, so the operator learns + * which grant they need rather than merely that they were refused. One literal + * for the three doors: they are one class (each changes this runtime's cloud + * connection), and a door added later cannot copy a weaker answer. + */ +const SETUP_ACCESS_REFUSAL = { + code: 'PERMISSION_DENIED', + status: 403, + message: `Managing this runtime's cloud connection requires the \`${SETUP_ACCESS_CAPABILITY}\` capability.`, +} as const; + +/** + * The slice of a kernel this surface reads services from: the environment's + * own kernel (multi-tenant, through `kernel-manager`) or the host's. + */ +interface EnvironmentKernel { + getServiceAsync?(name: string): Promise; + getService?(name: string): T; +} + /** * A signed-in caller of this surface, and the ADR-0069 posture its session * carries (`authGate`, present only for a session the policy holds). @@ -95,6 +174,18 @@ const CLOUD_CONNECTION_PREFIX = '/api/v1/cloud-connection'; interface ResolvedSession { userId: string; authGate?: AuthGate; + /** + * [#24148] What the shared resolver needs to judge what THIS session holds: + * the session read itself (handed back to the resolver, so it judges the + * session this door admitted rather than reading a second one), the `auth` + * service that answered it, and — multi-tenant — the environment's own + * kernel, whose grants are the ones that apply. + */ + readFrom: { + sessionData: unknown; + authService: IAuthService | undefined; + kernel?: EnvironmentKernel; + }; } /** @@ -214,7 +305,11 @@ export class CloudConnectionPlugin implements Plugin { manifest?.register?.(CLOUD_CONNECTION_UI_BUNDLE); } catch { /* no manifest service — headless kernel */ } - const sessionFromAuthService = async (authSvc: any, rawReq: Request): Promise => { + const sessionFromAuthService = async ( + authSvc: any, + rawReq: Request, + kernel?: EnvironmentKernel, + ): Promise => { const api = typeof authSvc?.getApi === 'function' ? await authSvc.getApi() : authSvc?.api ?? authSvc; // [#22258] The in-process session-read rule: a request carrying a // session cookie reads without renewal, because this route's @@ -233,7 +328,8 @@ export class CloudConnectionPlugin implements Plugin { // exemption (an empty path earns none, #7898). Nothing this plugin // mounts is a remediation or bootstrap route. const authGate = evaluateAuthGate(session?.user, ''); - return authGate ? { userId, authGate } : { userId }; + const readFrom: ResolvedSession['readFrom'] = { sessionData: session, authService: authSvc, ...(kernel ? { kernel } : {}) }; + return authGate ? { userId, authGate, readFrom } : { userId, readFrom }; }; /** @@ -263,13 +359,129 @@ export class CloudConnectionPlugin implements Plugin { try { authSvc = await kernel?.getServiceAsync?.('auth'); } catch { /* ignore */ } if (!authSvc) { try { authSvc = kernel?.getService?.('auth'); } catch { /* ignore */ } } if (!authSvc) return null; - return await sessionFromAuthService(authSvc, rawReq); + return await sessionFromAuthService(authSvc, rawReq, kernel ?? undefined); } catch { return null; } }; - // GET /status — is this runtime bound to a cloud account? + /** + * [#24148] Does the caller this surface admitted hold `setup.access`? + * + * ## Asked of the platform's one resolver, never re-derived + * + * `resolveAuthzContext` (`@objectstack/core`, guarded by + * `check:authz-resolver`) is the single place a request's grants are + * aggregated — `sys_member` → `sys_user_position` → permission sets, + * position-bound and user-bound alike, with a stale organization + * claim dropped under a walled posture. The install-local doors in + * this package and the datasource admin family gate on its + * `systemPermissions` the same way; this reads that field and nothing + * else. + * + * ## Judged on the session this surface already admitted + * + * The identity question stays where it was (`resolveSession`, the + * same read for all seven session doors), so WHO is admitted does + * not move — this only adds what they must hold. The resolver is + * handed that session read back (no second read) and the request's + * headers, and its answer counts only when it resolved the SAME + * principal through the SAME credential: an API key riding along + * resolves first inside the resolver, and its owner's grants are + * not this session's, so they never admit it. That keeps the change + * a pure narrowing — no credential this surface refused becomes one + * it accepts. + * + * ## Grants come from where the session came from + * + * Multi-tenant, the environment's own kernel answers (its `objectql` + * holds its grants, its `tenancy` its posture); single-environment, + * the host's. With no data engine there are no grants to hold, so + * the answer is `false` — fail closed. + * + * ## An outage is not a denial + * + * A permission store that could not be READ licenses no verdict, so + * `AuthzStoreUnavailableError` (from the resolver or the tenancy + * classification) is re-raised to the host's thrown-envelope render + * (`503 SERVICE_UNAVAILABLE`) rather than answered as "does not + * hold". Any other fault fails closed. + */ + const holdsSetupAccess = async (rawReq: Request, caller: ResolvedSession): Promise => { + const { sessionData, authService, kernel } = caller.readFrom; + let ql: IObjectQLEngine | undefined; + if (kernel) { + try { ql = await kernel.getServiceAsync?.('objectql'); } catch { /* not registered */ } + if (!ql) { try { ql = kernel.getService?.('objectql'); } catch { /* not registered */ } } + } else { + try { ql = ctx.getService('objectql'); } catch { /* no data engine */ } + } + // The posture is an authorization INPUT (the walled postures drop + // a stale organization claim). Read through the async registry + // only, so a `tenancy` service that BROKE is the loud outage and + // one never registered is the quiet `undefined`. + const tenancyKernel = kernel ?? ctx.getKernel?.(); + const tenancyPosture = tenancyKernel && typeof tenancyKernel.getServiceAsync === 'function' + ? await classifyAdmissionTenancyPosture(() => tenancyKernel.getServiceAsync!('tenancy')) + : undefined; + try { + const authz = await resolveAuthzContext({ + ql, + headers: rawReq.headers, + getSession: async () => sessionData, + tenancyPosture, + userAuthGates: userAuthGateSourceOf(authService), + apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService), + }); + if (authz.credential !== 'session' || authz.userId !== caller.userId) return false; + return Array.isArray(authz.systemPermissions) && authz.systemPermissions.includes(SETUP_ACCESS_CAPABILITY); + } catch (err) { + if (isAuthzStoreUnavailableError(err)) throw err; + return false; + } + }; + + /** [#24148] The one `403` the three connection-changing doors give a caller without `setup.access`. */ + const refuseWithoutSetupAccess = (c: any) => + c.json({ success: false, error: { code: SETUP_ACCESS_REFUSAL.code, message: SETUP_ACCESS_REFUSAL.message } }, SETUP_ACCESS_REFUSAL.status); + + /** + * [#24148] What `status` serves this caller. Everyone is told whether + * the runtime is bound, its runtime id and the provider — enough for + * a probe, and for the Console to pick its panel. The rest (the + * environment id and the control plane's `connection` record: who + * approved the bind, for which organization) is served only to a + * caller who holds `setup.access`, the permission the panel that + * shows it sits behind. + * + * `status` never refuses: no session, a session the ADR-0069 policy + * holds, and a session without the capability all get the narrowed + * answer, never a `401`/`403` — the route stays session-less in what + * it answers, and narrows only what it serves. For the same reason a + * permission store that cannot be read does not turn `status` into + * a `503`: the details are withheld (nothing was established that + * would license serving them) and the outage is logged, while the + * three connection-changing doors re-raise it. + */ + const servedStatus = async ( + environmentId: string | undefined, + rawReq: Request, + full: { environmentId: string | null; runtimeId: string | null; bound: boolean; provider: string; connection: unknown }, + ) => { + const caller = await resolveSession(environmentId ?? '', rawReq); + try { + if (caller && !caller.authGate && await holdsSetupAccess(rawReq, caller)) return full; + } catch (err) { + if (!isAuthzStoreUnavailableError(err)) throw err; + ctx.logger?.warn?.(`[CloudConnectionPlugin] status served without the connection details — ${err.message}`); + } + return { bound: full.bound, runtimeId: full.runtimeId, provider: full.provider }; + }; + + // GET /status — is this runtime bound to a cloud account? Answers + // every caller; what it SERVES narrows to `bound` / `runtimeId` / + // `provider` unless the caller holds `setup.access` (#24148, + // `servedStatus`). rawApp.get(`${CLOUD_CONNECTION_PREFIX}/status`, async (c: any) => { const environmentId = await resolveEnvironmentId(c); const stored = this.store.read(); @@ -277,15 +489,20 @@ export class CloudConnectionPlugin implements Plugin { if (!environmentId && !this.cfg.singleEnvironment) { return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404); } + const serve = async (full: Parameters[2]) => + c.json({ success: true, data: await servedStatus(environmentId, c.req.raw, full) }); // A single-env runtime with no env id and no credential is // simply not bound yet — valid state, not an error. if (!environmentId && !credential()) { - return c.json({ success: true, data: { environmentId: null, runtimeId: runtimeId ?? null, bound: false, provider: 'objectstack-cloud', connection: null } }); + return serve({ environmentId: null, runtimeId: runtimeId ?? null, bound: false, provider: 'objectstack-cloud', connection: null }); } // Prefer the real sys_cloud_connection from the control plane. // The oscc_ bearer self-identifies; service-key callers query // by environment_id (cloud-hosted) or runtime_id (claim). if (cloudUrl) { + // Built inside the `try`, served outside it: the fetch's + // fall-through must never swallow a fault of `servedStatus`. + let fromControlPlane: Parameters[2] | undefined; try { const qs = environmentId ? `?environment_id=${encodeURIComponent(environmentId)}` @@ -297,18 +514,19 @@ export class CloudConnectionPlugin implements Plugin { const json: any = await resp.json().catch(() => null); const data = json?.data ?? {}; const bound = Boolean(data.bound) || Boolean(credential()); - return c.json({ success: true, data: { + fromControlPlane = { environmentId: environmentId ?? null, runtimeId: data.runtime_id ?? runtimeId ?? null, bound, provider: 'objectstack-cloud', connection: data.connection ?? null, - } }); + }; } } catch { /* fall through to implicit */ } + if (fromControlPlane) return serve(fromControlPlane); } const bound = Boolean(credential()); - return c.json({ success: true, data: { environmentId: environmentId ?? null, runtimeId: runtimeId ?? null, bound, provider: 'objectstack-cloud', connection: null } }); + return serve({ environmentId: environmentId ?? null, runtimeId: runtimeId ?? null, bound, provider: 'objectstack-cloud', connection: null }); }); // POST /bind/start — begin a device-code bind. The runtime is the @@ -335,6 +553,9 @@ export class CloudConnectionPlugin implements Plugin { const session = await resolveSession(environmentId ?? '', c.req.raw); if (session?.authGate) return refuseHeldSession(c, session.authGate); if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment to connect a cloud account.' } }, 401); + // [#24148] Refused before any work, so a caller without the + // capability cannot learn whether a control plane is configured. + if (!(await holdsSetupAccess(c.req.raw, session))) return refuseWithoutSetupAccess(c); if (!cloudUrl) return c.json({ success: false, error: { code: 'CLOUD_UNCONFIGURED', message: 'No cloud control plane configured.' } }, 503); try { const resp = await fetch(`${cloudUrl}/api/v1/auth/device/code`, { @@ -394,6 +615,8 @@ export class CloudConnectionPlugin implements Plugin { const session = await resolveSession(environmentId ?? '', c.req.raw); if (session?.authGate) return refuseHeldSession(c, session.authGate); if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401); + // [#24148] This is the door that persists the binding. + if (!(await holdsSetupAccess(c.req.raw, session))) return refuseWithoutSetupAccess(c); if (!cloudUrl) return c.json({ success: false, error: { code: 'CLOUD_UNCONFIGURED', message: 'No cloud control plane configured.' } }, 503); const deviceCode = String(body?.device_code ?? body?.deviceCode ?? '').trim(); @@ -483,8 +706,8 @@ export class CloudConnectionPlugin implements Plugin { // POST /unbind — disconnect this runtime from the control plane. // Revokes the connection server-side (best-effort) and clears the - // local credential. Requires an environment session, mirroring - // bind/start. + // local credential. Requires an environment session holding + // `setup.access`, mirroring bind/start. rawApp.post(`${CLOUD_CONNECTION_PREFIX}/unbind`, async (c: any) => { const environmentId = await resolveEnvironmentId(c); if (!environmentId && !this.cfg.singleEnvironment) { @@ -493,6 +716,8 @@ export class CloudConnectionPlugin implements Plugin { const session = await resolveSession(environmentId ?? '', c.req.raw); if (session?.authGate) return refuseHeldSession(c, session.authGate); if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401); + // [#24148] Refused before the revoke and before the local clear. + if (!(await holdsSetupAccess(c.req.raw, session))) return refuseWithoutSetupAccess(c); // Revoke cloud-side FIRST (the oscc_ bearer self-identifies; // env-keyed bindings name the environment), then clear the diff --git a/packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts b/packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts new file mode 100644 index 0000000000..f4f7a015b1 --- /dev/null +++ b/packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts @@ -0,0 +1,344 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24148] The Cloud Connection page's permission, enforced at the routes + * behind it. + * + * The Console's Cloud Connection panel sits on the Setup app, which declares + * `requiredPermissions: ['setup.access']`. These pins hold the routes to the + * same rule, driven through the routes `CloudConnectionPlugin` really mounts + * (`start()` + `kernel:ready`), under both resolution modes: + * + * - `bind/start`, `bind/poll` and `unbind` refuse a signed-in caller who does + * not hold `setup.access` with `403 PERMISSION_DENIED` naming it, in the + * declared envelope, before any control-plane call and before the stored + * credential is touched — one case per door, so each door's check is held + * on its own; + * - a caller who holds it is served exactly as before (the control); + * - `GET /status` answers every caller, and serves `bound`, `runtimeId` and + * `provider` alone unless the caller holds `setup.access`; a holder is + * served the connection record too (the control). + * + * What a caller holds is read through `resolveAuthzContext`, so the grants + * below are real permission-set rows served through the engine's `find`, and + * the set bodies come from the bound security catalog — never a stubbed + * capability list, which would keep passing if the check read something else. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { hashApiKey, isAuthzStoreUnavailableError } from '@objectstack/core'; +import { ApiErrorSchema, BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api'; +import { CloudConnectionPlugin } from './cloud-connection-plugin.js'; +import { ConnectionCredentialStore } from './connection-credential-store.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; + +const CC = '/api/v1/cloud-connection'; +const ORIGIN = 'https://env.example.test'; + +/** Holds `setup.access` through an unscoped grant (the platform-admin shape). */ +const ADMIN = 'u_setup_admin'; +/** Holds `setup.access` through a grant scoped to the organization its session is active in (the organization-admin shape). */ +const ORG_ADMIN = 'u_org_admin'; +/** Signed in, holds a permission set, and the set carries no `setup.access`. */ +const MEMBER = 'u_member'; +/** Holds `setup.access`, but the ADR-0069 policy holds the session. */ +const HELD_ADMIN = 'u_held_admin'; + +const ORG = 'org_a'; +const SESSIONS: Record = { + [ADMIN]: {}, + [ORG_ADMIN]: { activeOrganizationId: ORG }, + [MEMBER]: {}, + [HELD_ADMIN]: { authGate: { code: 'PASSWORD_EXPIRED', message: 'You must change your password before continuing.' } }, +}; + +/** A key whose OWNER holds `setup.access`, presented beside someone else's session. */ +const ADMIN_KEY = 'osk_setup_admin_key_24148'; + +function grantRows(): Record { + return { + sys_user: Object.keys(SESSIONS).map((id) => ({ id, email: `${id}@example.test` })), + sys_member: [{ id: 'mem_1', user_id: ORG_ADMIN, organization_id: ORG, role: 'admin' }], + sys_user_position: [], + sys_position: [], + sys_metadata_activation: [], + sys_position_permission_set: [], + sys_user_permission_set: [ + { id: 'ups_admin', user_id: ADMIN, permission_set_id: 'ps_setup', permission_set: 'setup_admin', organization_id: null }, + { id: 'ups_org_admin', user_id: ORG_ADMIN, permission_set_id: 'ps_setup', permission_set: 'setup_admin', organization_id: ORG }, + { id: 'ups_held', user_id: HELD_ADMIN, permission_set_id: 'ps_setup', permission_set: 'setup_admin', organization_id: null }, + { id: 'ups_member', user_id: MEMBER, permission_set_id: 'ps_member', permission_set: 'member_basic', organization_id: null }, + ], + sys_permission_set: [ + { id: 'ps_setup', name: 'setup_admin', system_permissions: ['setup.access'] }, + { id: 'ps_member', name: 'member_basic', system_permissions: ['export_data'] }, + ], + sys_api_key: [{ id: 'key_1', key: hashApiKey(ADMIN_KEY), user_id: ADMIN }], + }; +} + +/** A better-auth session read keyed by the `x-test-user` header. */ +function sessionAuth() { + return { + api: { + getSession: async ({ headers }: { headers: Headers }) => { + const uid = headers?.get?.('x-test-user'); + if (!uid || !(uid in SESSIONS)) return null; + const { activeOrganizationId, authGate } = SESSIONS[uid]; + return { + user: { id: uid, ...(authGate ? { authGate } : {}) }, + session: { id: `sess_${uid}`, ...(activeOrganizationId ? { activeOrganizationId } : {}) }, + }; + }, + }, + }; +} + +/** + * A `find` over `rows` honouring the equality `where` the resolver reads with + * (a grant row belongs to the user it names, never to every caller) and the + * caller's `limit` — by presence, after the filter. + */ +function boundedFind(rows: Record) { + return async (object: string, options?: { where?: Record; limit?: number }) => { + const matched = (rows[object] ?? []).filter((row) => + Object.entries(options?.where ?? {}).every(([k, v]) => (v !== null && typeof v === 'object') || row[k] === v)); + return typeof options?.limit === 'number' ? matched.slice(0, options.limit) : matched; + }; +} + +/** Every read fails as an unreachable store's does. */ +const unreachableFind = async (): Promise => { + throw new Error('connect ECONNREFUSED 127.0.0.1:5432'); +}; + +/** The data engine the grants are read from. `outage` makes every read fail. */ +function grantsEngine(opts: { outage?: boolean } = {}) { + const rows = grantRows(); + return bindCatalogFromTables({ + find: opts.outage ? unreachableFind : boundedFind(rows), + update: async () => undefined, + }, rows); +} + +type Handler = (c: any) => Promise; + +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), + }; +} + +function makeC(path: string, opts: { user?: string; body?: unknown; apiKey?: string } = {}) { + const url = `${ORIGIN}${path}`; + const headers = new Headers(); + if (opts.user) headers.set('x-test-user', opts.user); + if (opts.apiKey) headers.set('x-api-key', opts.apiKey); + return { + req: { + url, + raw: new Request(url, { headers }), + header: (n: string) => headers.get(n) ?? undefined, + json: async () => opts.body ?? {}, + text: async () => JSON.stringify(opts.body ?? {}), + }, + json: (payload: any, status?: number) => ({ payload, status: status ?? 200 }), + }; +} + +/** What the stand-in control plane answers `status` with: a bound connection and its record. */ +const CONNECTION = { runtime_id: 'rt_1', organization_id: 'org_cloud_1', account_email: 'approver@example.test', name: 'Acme' }; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'cc-setup-access-')); + delete process.env.OS_ENVIRONMENT_ID; +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +async function mount(mode: 'single' | 'multi', opts: { outage?: boolean } = {}) { + const rawApp = makeRawApp(); + const hooks = new Map(); + const auth = sessionAuth(); + const objectql = grantsEngine(opts); + const credentialPath = join(dir, 'cloud-connection.json'); + new ConnectionCredentialStore(credentialPath).write({ runtimeToken: 'oscc_pin', runtimeId: 'rt_1', environmentId: 'env-1' }); + const envKernelServices: Record = { auth, objectql }; + const services: Record = mode === 'single' + ? { auth, objectql } + : { + 'env-registry': { resolveByHostname: async () => ({ environmentId: 'env-1' }) }, + 'kernel-manager': { getOrCreate: async () => ({ getServiceAsync: async (name: string) => envKernelServices[name] }) }, + }; + const ctx = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + if (name in services) return services[name]; + throw new Error(`service ${name} not registered`); + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + const fetchCalls: string[] = []; + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + fetchCalls.push(String(url)); + const path = new URL(String(url)).pathname; + const body = path.endsWith('/device/code') + ? { device_code: 'dc_1', user_code: 'ABCD-EFGH', verification_uri: 'http://cloud.test/device', interval: 5, expires_in: 600 } + : path.endsWith('/device/token') + ? { access_token: 'at_1' } + : path.endsWith('/cloud-connection/bind') + ? { success: true, data: { runtime_token: 'oscc_new', runtime_id: 'rt_1', connection: CONNECTION } } + : path.endsWith('/cloud-connection/status') + ? { success: true, data: { bound: true, runtime_id: 'rt_1', connection: CONNECTION } } + : { success: true, data: {} }; + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); + })); + await new CloudConnectionPlugin({ + controlPlaneUrl: 'http://cloud.test', + controlPlaneApiKey: 'svc-key', + credentialPath, + ...(mode === 'single' ? { singleEnvironment: true, environmentId: 'env-1' } : {}), + }).start(ctx as any); + await hooks.get('kernel:ready')?.(); + const drive = (route: string, path: string, c: Parameters[1] = {}) => rawApp.routes.get(route)!(makeC(path, c)); + return { drive, rawApp, fetchCalls, credentialPath }; +} + +/** The refusal: status and code first (the case's identity), then the declared ADR-0112 envelope. */ +function expectSetupAccessRefusal(res: { status: number; payload: any }, label: string) { + const shown = `${label}: ${JSON.stringify(res.payload)}`; + expect(res.status, shown).toBe(403); + expect(res.payload?.error?.code, shown).toBe('PERMISSION_DENIED'); + expect(res.payload?.error?.message, shown).toContain('`setup.access`'); + expect(BaseResponseSchema.safeParse(res.payload).success, shown).toBe(true); + expect(envelopeViolations(res.payload), shown).toEqual([]); + expect(ApiErrorSchema.safeParse(res.payload.error).success, shown).toBe(true); +} + +const DOORS = { + bindStart: { route: `POST ${CC}/bind/start`, path: `${CC}/bind/start`, body: {} }, + bindPoll: { route: `POST ${CC}/bind/poll`, path: `${CC}/bind/poll`, body: { device_code: 'dc_1' } }, + unbind: { route: `POST ${CC}/unbind`, path: `${CC}/unbind`, body: {} }, +} as const; + +const NARROWED_KEYS = ['bound', 'provider', 'runtimeId']; + +for (const mode of ['single', 'multi'] as const) { + describe(`[#24148] the connection-changing doors refuse a caller without setup.access (${mode}-environment)`, () => { + for (const [name, door] of Object.entries(DOORS)) { + it(`${door.route} refuses a signed-in caller without setup.access, before any control-plane call`, async () => { + const { drive, fetchCalls, credentialPath } = await mount(mode); + expectSetupAccessRefusal(await drive(door.route, door.path, { user: MEMBER, body: door.body }), name); + expect(fetchCalls, `${name} reached the control plane`).toEqual([]); + expect(new ConnectionCredentialStore(credentialPath).read()?.runtimeToken, `${name} touched the stored credential`).toBe('oscc_pin'); + }); + } + + it('CONTROL — a caller holding setup.access is served by all three doors as before', async () => { + const { drive, fetchCalls, credentialPath } = await mount(mode); + const start = await drive(DOORS.bindStart.route, DOORS.bindStart.path, { user: ADMIN }); + expect(start.status, JSON.stringify(start.payload)).toBe(200); + expect(start.payload.data.device_code).toBe('dc_1'); + + const poll = await drive(DOORS.bindPoll.route, DOORS.bindPoll.path, { user: ADMIN, body: DOORS.bindPoll.body }); + expect(poll.status, JSON.stringify(poll.payload)).toBe(200); + expect(poll.payload.success).toBe(true); + expect(poll.payload.data.runtime_token, 'the runtime bearer reached the browser').toBeUndefined(); + expect(new ConnectionCredentialStore(credentialPath).read()?.runtimeToken).toBe('oscc_new'); + + const unbind = await drive(DOORS.unbind.route, DOORS.unbind.path, { user: ADMIN }); + expect(unbind.status, JSON.stringify(unbind.payload)).toBe(200); + expect(unbind.payload.data).toMatchObject({ revoked: true, cleared: true }); + expect(new ConnectionCredentialStore(credentialPath).read()?.runtimeToken).toBe(''); + expect(fetchCalls.length).toBeGreaterThan(0); + }); + + it('CONTROL — a grant scoped to the organization the session is active in counts', async () => { + const { drive } = await mount(mode); + const res = await drive(DOORS.bindStart.route, DOORS.bindStart.path, { user: ORG_ADMIN }); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + }); + + it('refuses a session without setup.access even when the request also carries a key whose owner holds it', async () => { + const { drive, fetchCalls } = await mount(mode); + expectSetupAccessRefusal( + await drive(DOORS.bindStart.route, DOORS.bindStart.path, { user: MEMBER, apiKey: ADMIN_KEY }), + 'bind/start with a key beside the session', + ); + expect(fetchCalls).toEqual([]); + }); + + it('a permission store that cannot be read is an outage, not a 403', async () => { + const { rawApp, fetchCalls } = await mount(mode, { outage: true }); + const handler = rawApp.routes.get(DOORS.bindStart.route)!; + const outcome = await handler(makeC(DOORS.bindStart.path, { user: ADMIN })).then( + (res: any) => ({ answered: res }), + (err: unknown) => ({ thrown: err }), + ); + expect('thrown' in outcome && isAuthzStoreUnavailableError(outcome.thrown), JSON.stringify(outcome)).toBe(true); + expect(fetchCalls).toEqual([]); + }); + }); + + describe(`[#24148] GET /status narrows what it serves to a caller without setup.access (${mode}-environment)`, () => { + it('anonymous: answers 200 with exactly bound, runtimeId and provider — no connection', async () => { + const { drive } = await mount(mode); + const res = await drive(`GET ${CC}/status`, `${CC}/status`); + expect(res.status).toBe(200); + expect(res.payload.success).toBe(true); + expect(Object.keys(res.payload.data).sort()).toEqual(NARROWED_KEYS); + expect(res.payload.data).toEqual({ bound: true, runtimeId: 'rt_1', provider: 'objectstack-cloud' }); + }); + + it('signed in without setup.access: the same narrowed answer', async () => { + const { drive } = await mount(mode); + const res = await drive(`GET ${CC}/status`, `${CC}/status`, { user: MEMBER }); + expect(res.status).toBe(200); + expect(Object.keys(res.payload.data).sort()).toEqual(NARROWED_KEYS); + }); + + it('a session the ADR-0069 policy holds is answered, narrowed, never refused', async () => { + const { drive } = await mount(mode); + const res = await drive(`GET ${CC}/status`, `${CC}/status`, { user: HELD_ADMIN }); + expect(res.status).toBe(200); + expect(Object.keys(res.payload.data).sort()).toEqual(NARROWED_KEYS); + }); + + it('a key whose owner holds setup.access lends nothing to a session without it', async () => { + const { drive } = await mount(mode); + const res = await drive(`GET ${CC}/status`, `${CC}/status`, { user: MEMBER, apiKey: ADMIN_KEY }); + expect(Object.keys(res.payload.data).sort()).toEqual(NARROWED_KEYS); + }); + + it('a permission store that cannot be read withholds the details and still answers 200', async () => { + const { drive } = await mount(mode, { outage: true }); + const res = await drive(`GET ${CC}/status`, `${CC}/status`, { user: ADMIN }); + expect(res.status).toBe(200); + expect(Object.keys(res.payload.data).sort()).toEqual(NARROWED_KEYS); + }); + + it('CONTROL — a caller holding setup.access is served the connection record and the environment', async () => { + const { drive } = await mount(mode); + const res = await drive(`GET ${CC}/status`, `${CC}/status`, { user: ADMIN }); + expect(res.status).toBe(200); + expect(res.payload.data).toEqual({ + environmentId: 'env-1', + runtimeId: 'rt_1', + bound: true, + provider: 'objectstack-cloud', + connection: CONNECTION, + }); + }); + }); +} diff --git a/packages/cloud-connection/src/connection-credential-store.test.ts b/packages/cloud-connection/src/connection-credential-store.test.ts index d4eeeb5a52..a5b73c92d9 100644 --- a/packages/cloud-connection/src/connection-credential-store.test.ts +++ b/packages/cloud-connection/src/connection-credential-store.test.ts @@ -16,6 +16,7 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { ConnectionCredentialStore } from './connection-credential-store.js'; import { CloudConnectionPlugin } from './cloud-connection-plugin.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'ccs-')); }); @@ -55,7 +56,21 @@ function makeRawApp() { }; } const sessionAuth = (userId?: string) => ({ api: { getSession: async () => (userId ? { user: { id: userId } } : null) } }); -function makeCtx(rawApp: any, services: Record = {}) { +/** + * [#24148] The suites below sign in as `admin`, who must hold `setup.access` to + * reach the connection-changing doors (their subject is the credential, not the + * permission — that is `cloud-connection-setup-access.pin.test.ts`). Real + * permission-set rows, read by the shared resolver through `find`. + */ +const ADMIN_GRANTS: Record = { + sys_user_permission_set: [{ id: 'ups_1', user_id: 'admin', permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }], + sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }], +}; +function makeCtx(rawApp: any, explicit: Record = {}) { + const services: Record = { + objectql: bindCatalogFromTables({ find: async (object: string) => ADMIN_GRANTS[object] ?? [] }, ADMIN_GRANTS), + ...explicit, + }; const hooks = new Map(); return { ctx: { diff --git a/packages/cloud-connection/src/error-envelope.conformance.test.ts b/packages/cloud-connection/src/error-envelope.conformance.test.ts index 9751221909..598f58c02e 100644 --- a/packages/cloud-connection/src/error-envelope.conformance.test.ts +++ b/packages/cloud-connection/src/error-envelope.conformance.test.ts @@ -66,6 +66,7 @@ import { CloudConnectionPlugin } from './cloud-connection-plugin.js'; import { MarketplaceProxyPlugin } from './marketplace-proxy-plugin.js'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { RuntimeConfigPlugin } from './runtime-config-plugin.js'; +import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; // ── Shared harness ─────────────────────────────────────────────────────────── @@ -177,6 +178,12 @@ async function mountCloudConnection(opts: { environmentId?: string; userId?: string; resolvesEnvironment?: boolean; + /** + * [#24148] Whether the signed-in caller holds `setup.access`, which the + * connection-changing doors demand. Default: yes, so the exits below that + * are about something else reach the code they are about. + */ + setupAccess?: boolean; } = {}) { const rawApp = makeRawApp(); const hooks = new Map(); @@ -185,13 +192,22 @@ async function mountCloudConnection(opts: { getSession: async () => (opts.userId ? { user: { id: opts.userId }, session: {} } : null), }, }; + const grantRows: Record = opts.userId && opts.setupAccess !== false + ? { + sys_user_permission_set: [{ id: 'ups_1', user_id: opts.userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }], + sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }], + } + : {}; + const objectql = bindCatalogFromTables({ find: async (object: string) => grantRows[object] ?? [] }, grantRows); + const envKernelServices: Record = { auth, objectql }; const services: Record = { 'env-registry': { resolveByHostname: async () => (opts.resolvesEnvironment ? { environmentId: 'env-123' } : undefined), }, - 'kernel-manager': { getOrCreate: async () => ({ getServiceAsync: async () => auth }) }, + 'kernel-manager': { getOrCreate: async () => ({ getServiceAsync: async (name: string) => envKernelServices[name] }) }, auth, + objectql, manifest: { register: vi.fn() }, }; const ctx: any = { @@ -273,6 +289,20 @@ describe('plugin-route door — CloudConnectionPlugin error exits (#9267)', () = ); }); + /** + * [#24148] The three connection-changing doors refuse a signed-in caller + * without `setup.access` with the standard catalog's `PERMISSION_DENIED`, + * which `ApiErrorSchema` admits without a registration. + */ + for (const door of ['bind/start', 'bind/poll', 'unbind'] as const) { + it(`POST /${door} with a session lacking setup.access → 403 in the declared envelope`, async () => { + const routes = await mountCloudConnection({ resolvesEnvironment: true, userId: 'usr_1', setupAccess: false }); + const captured = await driveCloudConnection(routes, `POST ${CC}/${door}`, { body: { device_code: 'dc_1' } }); + expectDeclaredErrorEnvelope(captured, { status: 403, code: 'PERMISSION_DENIED' }); + expect(captured.body.error.message).toContain('`setup.access`'); + }); + } + it('POST /install without a session → 401 in the declared envelope', async () => { const routes = await mountCloudConnection({ resolvesEnvironment: true }); expectDeclaredErrorEnvelope( diff --git a/packages/core/src/security/authz-store-unavailable.test.ts b/packages/core/src/security/authz-store-unavailable.test.ts index 3b936a5d7c..53a9cf5651 100644 --- a/packages/core/src/security/authz-store-unavailable.test.ts +++ b/packages/core/src/security/authz-store-unavailable.test.ts @@ -187,6 +187,7 @@ const TRANSPORT_LEDGER: Record = { 'packages/services/service-storage/src/storage-service-plugin.ts': 'guarded', 'packages/plugins/plugin-sharing/src/sharing-plugin.ts': 'guarded', 'packages/cloud-connection/src/marketplace-install-local-plugin.ts': 'guarded', + 'packages/cloud-connection/src/cloud-connection-plugin.ts': 'guarded', }; /** Test scaffolding is not a transport — it drives the resolver, it does not authorize for anyone. */