Skip to content

Commit 6973014

Browse files
fix(cloud-connection)!: bind/start, bind/poll and unbind require setup.access; GET /status serves the connection details only to a holder (#24167)
Fixes #24148 Clause-②: no (narrowing) The Console's Cloud Connection panel sits on the Setup app, which declares `requiredPermissions: ['setup.access']` (`packages/platform-objects/src/apps/setup.app.ts:47`). The routes behind that panel now apply the same permission, so the API refuses exactly who the page refuses. Bound by triage (`6229951531`); the one choice it left, `setup.access` over an explicit environment-admin check, is the seat's ruling on the claim (`6231718544`). This is a permission boundary, so it waits for the security / contract review the seat dispatches. Per the maintainer's disclosure ruling (2026-08-18, recorded on #9387), this PR states that the hole existed and names the pins; it carries no reproduction. ## What changed (`packages/cloud-connection/src/cloud-connection-plugin.ts`) - **`POST /api/v1/cloud-connection/bind/start`, `/bind/poll` and `/unbind`** refuse a signed-in caller who does not hold `setup.access`: `403`, code `PERMISSION_DENIED`, a message naming the capability, in the declared ADR-0112 envelope. The check sits right after the existing `401` and ADR-0069 held-session `403`, and before everything else: the `503 CLOUD_UNCONFIGURED` answer, the `device_code` validation, every control-plane call, and the stored-credential read and clear. The unparseable-body `400` still comes first, as before. - **`GET /api/v1/cloud-connection/status`** still answers every caller `200` (it stays session-less on purpose, so a probe can tell the plugin is mounted). It narrows what it SERVES: `bound`, `runtimeId` and `provider` only, unless the caller holds `setup.access`. A holder is also served `environmentId` and `connection`, so the panel's "Approved by" and "Organization" rows keep working for an administrator. It never refuses. No session, a session the ADR-0069 policy holds, and a session without the capability all get the narrowed answer. - **Why `PERMISSION_DENIED`, measured.** It is the standard catalog's 403 member (`HttpStatusErrorCodeMap[403]`). The ledger's admission rule sends a generic permission condition there rather than to a registered synonym, so nothing is minted and `packages/spec` is untouched. `FORBIDDEN` (what the install-local doors next door answer) is a #8211-waived pre-gate synonym, kept on the wire and never endorsed for new code. ### How the permission is read (mechanism assumption 3) `holdsSetupAccess` calls **`resolveAuthzContext`** (`@objectstack/core`, guarded by `check:authz-resolver`) and reads `systemPermissions`. This is the shape copied from `requireInstallCapability` / `resolveInstallPrincipal` in this package's `marketplace-install-local-plugin.ts`. `packages/services/service-datasource/src/admin-routes.ts` gates on the same resolver and field. It passes the same inputs those seams pass: the posture via `classifyAdmissionTenancyPosture` (async registry, so a broken `tenancy` service is loud), `userAuthGateSourceOf` and `apiKeyLifecyclePolicySourceOf`. No permission read is re-spelled. - **Identity does not move.** `resolveSession` is still the one session read for all seven session doors. The resolver is handed back that session read (no second read) plus the request headers, and its answer counts only when it resolved the SAME principal through the `session` credential. An API key sent beside a session resolves first inside the resolver, and its owner's grants never admit that session. So the change is a pure narrowing: no credential these routes refused becomes one they accept. - **Grants come from where the session came from.** Multi-tenant mode reads the environment's own kernel (`objectql`, `tenancy`); single-environment mode reads the host's. With no data engine there are no grants, so it fails closed. - **An outage is not a denial.** `AuthzStoreUnavailableError` is re-raised on the three doors, so the host's thrown-envelope render answers `503 SERVICE_UNAVAILABLE` instead of a `403` that reads like a real refusal. On `status` the details are withheld, the answer is still `200`, and the outage is logged at `warn`. ## Also changed (`packages/core`, test only) - `packages/core/src/security/authz-store-unavailable.test.ts`: one row in the [#13279] `TRANSPORT_LEDGER`, `'packages/cloud-connection/src/cloud-connection-plugin.ts': 'guarded'` (the plugin is a new `resolveAuthzContext` transport whose fail-closed catch re-raises through `isAuthzStoreUnavailableError`). ### Mechanism assumptions, measured on `origin/main` `52c04b360` 1. Holds. `resolveSession` returned only `{ userId, authGate? }`, and the three write doors checked only that it resolved. Its seven call sites map to these routes: `bind/start`, `bind/poll`, `unbind`, `install`, `installation`, `installed`, `org-packages`. `status` called none. 2. Holds. `status` answered `connection: data.connection ?? null`, the control plane's record verbatim, to every caller. 3. Holds. The check is `resolveAuthzContext(...).systemPermissions`, copied from `marketplace-install-local-plugin.ts` as described above. ## Pins New file: `packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts`. It has 26 cases across both resolution modes, driven through the routes `CloudConnectionPlugin` mounts (`start()` + `kernel:ready`). Grants are real permission-set rows served through the engine's `find`, honouring its `where`, with set bodies from the bound security catalog. Refusals assert `status` + `code` + the declared envelope (`BaseResponseSchema`, `envelopeViolations`, `ApiErrorSchema`), never a bare throw. - a signed-in caller without `setup.access` gets `403 PERMISSION_DENIED`, with one case per door (`bind/start`, `bind/poll`, `unbind`); no control-plane call, credential untouched; - CONTROL: a holder is served by all three doors as before. That includes a grant scoped to the session's active organization (the `organization_admin` shape); - a session without it is refused even when the request also carries a key whose owner holds it; - an unreadable permission store raises the outage, not a `403`; - `status`, anonymous: exactly `bound` / `runtimeId` / `provider`, no `connection`; - `status` gives the same narrowed answer to a caller signed in without the permission, to a held session, to a session with a key beside it, and during an outage; - CONTROL: `status` for a holder carries `connection` and `environmentId`. ### Ablations (each refusal once; both legs recorded) Each mutation went through `scripts/ablation-replace.mjs`. The anchor had to hit exactly once, the landing was verified on disk, and the restore was proven as blob == HEAD with an empty `git diff HEAD`. The suite imports the plugin from `src/` by relative path, so the mutation reaches the run without a build. Tree `a6745b5ae` and again at the final head `7f45419be`; plugin blob `44de963e5afa` both times. | Ablation | Mutation leg (pin suite) | Restore leg | |:---|:---|:---| | `bind/start`'s check short-circuited | red: 6 failed / 20 passed. Its door pin in both modes, plus the key-beside and outage cases (which drive `bind/start`) | blob == HEAD | | `bind/poll`'s check short-circuited | red: 2 failed. Its door pin, both modes | blob == HEAD | | `unbind`'s check short-circuited | red: 2 failed. Its door pin, both modes | blob == HEAD | | `status` serves the full answer unconditionally | red: 10 failed. Every narrowed-`status` case, both modes | blob == HEAD | | same-principal / `session`-credential condition removed | red: 4 failed. Both key-beside cases, both modes | blob == HEAD | After all five: `git status` clean, and the pin suite on HEAD shows 26 passed. ### Existing suites adjusted (subjects unchanged) - `cloud-connection-auth-gate.pin.test.ts` (ADR-0069): its users now hold `setup.access`, so a refusal there stays the gate's alone. The census case used to assert that `/status` reads no session. It now asserts that `/status` answers a held session `200` with the narrowed shape, which is the new contract. - `cloud-connection-plugin.test.ts`: anonymous `status` expectations are now the narrowed shape. The two cases whose subject is the resolved `environmentId` now sign in as a holder. - `error-envelope.conformance.test.ts`: the harness grants `setup.access` by default, and there are three new cases for the `403 PERMISSION_DENIED` envelope on the three doors. - `connection-credential-store.test.ts`: its `admin` holds `setup.access`. ## Docs `content/docs/deployment/cloud-connection.mdx`: - `#before-you-start`: the "known defect" sentence is replaced by the rule. Connect and disconnect take Setup Access, and a caller without it is refused `403` (`PERMISSION_DENIED`) before the server calls the cloud. - `#reference`: the `status` row now says what every caller gets and what a Setup Access holder also gets. ## Changeset `.changeset/24148-cloud-connection-setup-access.md` contains: - a `patch` bump, since no public surface widens; - the title `fix(cloud-connection)!:` and a **BREAKING** banner; - the line `Clause-②: no (narrowing)`; - exactly one ADR-0087 disposition marker, `not-required (no-migration-prescription)`. The category was decided by running both gates locally, and it matches the closest precedent, PR #24161's rest changeset. Gate readings: `check-adr-0087-registration` printed "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)", exit 0. `check-changeset-no-major` printed "This diff introduces no `major` bump.", exit 0. ## Verification (final tree) All readings below are from the final head, `7f45419be` (`git rev-parse --short HEAD`), unless a row says otherwise. - **Build of the dependency closure:** `pnpm --filter '@objectstack/cloud-connection^...' build`, then `pnpm --filter @objectstack/cloud-connection build`. Run after merging `origin/main` (`eda6f14b7`), lock verdict `command-exit 0`. - **Typecheck:** `pnpm --filter @objectstack/cloud-connection typecheck` (both programs) exits 0. `tsconfig.test.json --listFiles` reaches all 48 `*.test.ts` files, the new pin among them. - **Tests:** `pnpm --filter @objectstack/cloud-connection test` gives "Test Files 48 passed (48) · Tests 595 passed (595)". - **Lint:** `pnpm exec eslint --no-inline-config` over the 6 changed `.ts` files exits 0 with no output. The `.md` / `.mdx` files are "ignored because no matching configuration", exit 0. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, no paths. It derived 93 commands over the 8 changed paths, and all 93 ran on `7f45419be`, each exit captured before any pipe; all exited 0. Reconciliation `--ran` printed "93 derived famil(ies) accounted for — 93 run, 0 NOT-MEASURED (a DERIVED zero …)", exit 0. - **Roster gates outside the derivation:** I also ran the four roster gates whose roster sits under a path of mine, all exit 0. `check:authz-resolver` printed "single shared authorization resolver intact; both entry points delegate". `check:error-code-casing` printed OK. `check:filter-alias-parity` printed OK. `check-changeset-fixed` printed "in sync with 73 public workspace packages". - **Selected verdict lines:** - `check:slot-lookup`: "ratchet holds … none new". - `check:route-envelope`: exit 0. - `check:objectql-double-limit`: "conformance holds … none new". An earlier pass of the new pin's `find` double was refused here for ignoring `limit`; fixed in `7f45419be`. - `check:where-matcher`: "conformance holds". - `check:nul-bytes`: OK. - `check-adr-0087-registration` and `check-changeset-no-major`: as quoted above. - **Ablations, re-run on `7f45419be`:** the five ablations above were run again on the final head, with the plugin blob unchanged (`44de963e5afa`), and gave the same red legs and the same blob == HEAD restores. One of those reruns was malformed: a shell split mangled the principal-coherence anchor, so the suite failed to load ("no tests") instead of measuring. That reading is void, and the redo with the intact anchor reddened the same 4 cases. - **Round 3, `e9fe7fe9b` (the ledger row):** `Test Core (3/6)` on `47e78745d` was red in core's `test:repo`, at the [#13279] `⭐ SET EQUALITY` test only: 9 transports found in source against 8 in the ledger. Reproduced locally before the fix (1 failed, 82 passed), and green after (`pnpm --filter @objectstack/core run test:repo`: 6 files, 84 passed). None of core's five other repo tests names this plugin. The other packages' `test:repo` projects (types, objectql, rest, runtime, spec) and cloud-connection's suite (48 files, 595 passed) are green on `e9fe7fe9b`. The changeset gate demands no core entry, because the test file is not core's published output. - **Not run locally, left to CI:** in rounds 1 and 2, the `test:repo` vitest project (run in round 3, above); the CI type-check lanes (workspace, `./examples/*`, downstream-contract), and the path-scheduled CI jobs (Build Core, Dogfood, Build Docs). The workspace consumers of the plugin's type face (`packages/cli`, `examples/app-showcase`) were not type-checked locally. ## Acceptance notes - **`/install` hold-off, reported, not changed:** its handler still says "TODO: tighten to an explicit env-admin role check" and gates on a session alone. Its posture was ruled on #15353; that route, `installation`, `installed` and `org-packages` are untouched here. - **Mount log line:** the `kernel:ready` info line lists `{status,bind/start,bind/poll,install,installation,installed,org-packages}` and omits `unbind`. This is cosmetic text in a log; noted, not filed. - **Docs, beyond the two named spots:** the `#reference` table's "Sign-in" column still says "required" for the three doors. That is true but does not name Setup Access. The `#connect` failure table has no row for the new `403` message, which a panel user can only meet if their grant is revoked mid-session. Left as they are, to keep inside the claimed surface. - **Public type face:** the package-local structural `PluginContext` gained an optional `getKernel?()`, read only for the async `tenancy` lookup. This widens what `start(ctx)` accepts. The workspace consumers (`packages/cli` `serve.ts`, `examples/app-showcase`) were not type-checked locally; CI's TypeScript Type Check lanes cover them. --- _Generated by [Claude Code](https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7cda377 commit 6973014

9 files changed

Lines changed: 699 additions & 32 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/cloud-connection': minor
3+
---
4+
5+
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
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a runtime authorization narrowing at four of CloudConnectionPlugin's same-origin routes, not a metadata change: no spec key, export, option, config field or stored shape is removed, renamed or re-shaped, so `objectstack migrate meta` has nothing to rewrite; a caller who holds Setup Access is served exactly as before, and one who does not is refused or served less -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
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.
14+
15+
- **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.
16+
- **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.
17+
- **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.
18+
- **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.
19+
20+
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.

‎content/docs/deployment/cloud-connection.mdx‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,10 @@ the server reads as not connected until you connect it again.
7474
- *On the server*, you sign in with a user of this server. The Cloud
7575
Connection page is in the Setup app, which takes the **Setup Access**
7676
permission (`setup.access`); the default platform-administrator and
77-
organization-administrator permission sets carry it. A known defect in the
78-
connect and disconnect requests: today these requests check only that the
79-
caller is signed in, not Setup Access
80-
([#24148](https://github.com/objectstack-ai/objectstack/issues/24148));
81-
until that is fixed, give sign-in on this server only to people who may
82-
change its cloud connection. A session
77+
organization-administrator permission sets carry it. The connect and
78+
disconnect requests take the same permission: a signed-in user without
79+
Setup Access is refused with `403` (`PERMISSION_DENIED`) before the server
80+
calls the cloud. A session
8381
that the authentication policy is holding (an expired password, or
8482
multi-factor sign-in required but not yet set up) is refused with `403`
8583
until it is fixed.
@@ -213,7 +211,7 @@ The routes the Cloud Connection page uses, all on the server's own origin:
213211

214212
| Route | Sign-in | What it does |
215213
|:---|:---|:---|
216-
| `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). |
214+
| `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. |
217215
| `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`. |
218216
| `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. |
219217
| `POST /api/v1/cloud-connection/unbind` | required | Disconnects; answers `{ "revoked", "cleared" }`. |

‎packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
*
1111
* - `CloudConnectionPlugin` reads the session in ONE place
1212
* (`sessionFromAuthService`, under both resolution modes). Seven of its
13-
* eight doors call it; `/status` reads no session at all.
13+
* eight doors refuse on it; `/status` reads it only to decide what it
14+
* serves (#24148) and refuses no one.
1415
* - `MarketplaceInstallLocalPlugin` reads it in ONE place
1516
* (`resolveInstallPrincipal`), and all five doors answer a held session
1617
* through the same refusal (`refuseHeldSession`).
@@ -28,7 +29,12 @@
2829
* gate's `code` and `message`, and no effect fires (no control-plane call,
2930
* no credential cleared; no registry, schema, ledger, seed or delete write).
3031
* - UN-HELD: the same caller and grants, without a posture, are served.
31-
* - Anonymous is refused `401` as before, and `/status` is untouched.
32+
* - Anonymous is refused `401` as before, and `/status` answers a held
33+
* session `200` with its narrowed shape (#24148).
34+
*
35+
* Every test user holds `setup.access`, so a refusal below is the gate's alone
36+
* (#24148 makes three of the seven doors demand it; its own pins are
37+
* `cloud-connection-setup-access.pin.test.ts`).
3238
*/
3339

3440
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -156,11 +162,16 @@ async function mountCloudConnection(mode: 'single' | 'multi') {
156162
const credentialPath = join(dir, 'cloud-connection.json');
157163
const store = new ConnectionCredentialStore(credentialPath);
158164
store.write({ runtimeToken: 'oscc_pin', runtimeId: 'rt_1', environmentId: 'env-1' });
165+
// [#24148] The grants every test user holds (`setup.access` among them),
166+
// read by the three connection-changing doors through the shared resolver.
167+
const rows = grantRows();
168+
const objectql = bindCatalogFromTables({ find: async (object: string) => rows[object] ?? [] }, rows);
169+
const envKernelServices: Record<string, unknown> = { auth, objectql };
159170
const services: Record<string, unknown> = mode === 'single'
160-
? { auth }
171+
? { auth, objectql }
161172
: {
162173
'env-registry': { resolveByHostname: async () => ({ environmentId: 'env-1' }) },
163-
'kernel-manager': { getOrCreate: async () => ({ getServiceAsync: async () => auth }) },
174+
'kernel-manager': { getOrCreate: async () => ({ getServiceAsync: async (name: string) => envKernelServices[name] }) },
164175
};
165176
const ctx = {
166177
hook: (e: string, h: any) => hooks.set(e, h),
@@ -192,14 +203,18 @@ async function mountCloudConnection(mode: 'single' | 'multi') {
192203
}
193204

194205
describe('ADR-0069 at CloudConnectionPlugin', () => {
195-
it('census: eight routes, none allow-listed; seven resolve a session and /status resolves none', async () => {
196-
const { rawApp, auth } = await mountCloudConnection('single');
206+
it('census: eight routes, none allow-listed; seven refuse a held session and /status refuses none', async () => {
207+
const { rawApp } = await mountCloudConnection('single');
197208
const keys = [...rawApp.routes.keys()];
198209
expect(keys).toHaveLength(8);
199210
for (const key of keys) expect(isAuthGateAllowlisted(key.split(' ')[1]), key).toBe(false);
200211
expect(keys.filter((k) => k !== `GET ${CC}/status`).sort()).toEqual(CC_SESSION_DOORS.map((d) => d.route).sort());
201-
await rawApp.routes.get(`GET ${CC}/status`)!(makeC(`${ORIGIN}${CC}/status`, 'u_held_pw'));
202-
expect(auth.reads.count, '/status read a session').toBe(0);
212+
// [#24148] `/status` reads the session only to decide what it serves: a
213+
// held session is answered, with the shape served to a caller without
214+
// `setup.access` — never refused.
215+
const res = await rawApp.routes.get(`GET ${CC}/status`)!(makeC(`${ORIGIN}${CC}/status`, 'u_held_pw'));
216+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
217+
expect(Object.keys(res.payload.data).sort()).toEqual(['bound', 'provider', 'runtimeId']);
203218
});
204219

205220
for (const mode of ['single', 'multi'] as const) {

‎packages/cloud-connection/src/cloud-connection-plugin.test.ts‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
1313
import { CloudConnectionPlugin, createCloudConnectionPlugin } from './cloud-connection-plugin.js';
14+
import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js';
1415

1516
type Handler = (c: any) => Promise<Response | any>;
1617

@@ -59,6 +60,23 @@ const sessionAuth = (userId?: string) => ({
5960
api: { getSession: async () => (userId ? { user: { id: userId } } : null) },
6061
});
6162

63+
/**
64+
* [#24148] A data engine under which `userId` holds `setup.access` — what
65+
* `/status` serves its full answer behind. Real permission-set rows, read by
66+
* the shared resolver through `find`.
67+
*/
68+
const setupAccessEngine = (userId: string) => {
69+
const rows: Record<string, any[]> = {
70+
sys_user: [{ id: userId, email: `${userId}@example.test` }],
71+
sys_user_permission_set: [{ id: 'ups_1', user_id: userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }],
72+
sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }],
73+
};
74+
return bindCatalogFromTables({ find: async (object: string) => rows[object] ?? [] }, rows);
75+
};
76+
77+
/** [#24148] What `/status` serves a caller without `setup.access`. */
78+
const narrowed = (data: { bound: boolean; runtimeId: string | null }) => ({ ...data, provider: 'objectstack-cloud' });
79+
6280
beforeEach(() => {
6381
delete process.env.OS_ENVIRONMENT_ID;
6482
});
@@ -118,7 +136,7 @@ describe('multi-tenant mode (env-registry + per-env kernel auth)', () => {
118136
await new CloudConnectionPlugin({ controlPlaneUrl: 'http://cloud.test', controlPlaneApiKey: 'svc-key' }).start(ctx as any);
119137
await fireKernelReady();
120138
const res = await rawApp.routes.get('GET /api/v1/cloud-connection/status')!(makeC('https://t1.example.com/api/v1/cloud-connection/status'));
121-
expect(res.payload).toEqual({ success: true, data: { environmentId: ENV, runtimeId: null, bound: true, provider: 'objectstack-cloud', connection: null } });
139+
expect(res.payload).toEqual({ success: true, data: narrowed({ runtimeId: null, bound: true }) });
122140
});
123141

124142
it('status 404s for an unknown hostname', async () => {
@@ -200,7 +218,7 @@ describe('single-environment mode (host auth, fixed env id)', () => {
200218
await fireKernelReady();
201219
const res = await rawApp.routes.get('GET /api/v1/cloud-connection/status')!(makeC('http://localhost:3000/x'));
202220
expect(res.status).toBe(200);
203-
expect(res.payload.data).toEqual({ environmentId: null, runtimeId: null, bound: false, provider: 'objectstack-cloud', connection: null });
221+
expect(res.payload.data).toEqual(narrowed({ runtimeId: null, bound: false }));
204222
});
205223

206224
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)', () => {
223241
it("ignores the CLI's local sentinel env ids (env_local / proj_local)", async () => {
224242
process.env.OS_ENVIRONMENT_ID = 'env_local';
225243
const rawApp = makeRawApp();
226-
const { ctx, fireKernelReady } = makeCtx({ rawApp });
244+
// A caller holding `setup.access`, who is served the environment id.
245+
const { ctx, fireKernelReady } = makeCtx({ rawApp, services: { auth: sessionAuth('admin-1'), objectql: setupAccessEngine('admin-1') } });
227246
await new CloudConnectionPlugin({ singleEnvironment: true, controlPlaneUrl: 'http://cloud.test' }).start(ctx as any);
228247
await fireKernelReady();
229248
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)', () => {
236255
it('falls back to OS_ENVIRONMENT_ID when no config id is given', async () => {
237256
process.env.OS_ENVIRONMENT_ID = 'env-from-env-var';
238257
const rawApp = makeRawApp();
239-
const { ctx, fireKernelReady } = makeCtx({ rawApp, services: { auth: sessionAuth('admin-1') } });
258+
const { ctx, fireKernelReady } = makeCtx({ rawApp, services: { auth: sessionAuth('admin-1'), objectql: setupAccessEngine('admin-1') } });
240259
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ success: true, data: {} }), { status: 200 })));
241260
await new CloudConnectionPlugin({ singleEnvironment: true, controlPlaneUrl: 'http://cloud.test', controlPlaneApiKey: 'k' }).start(ctx as any);
242261
await fireKernelReady();

0 commit comments

Comments
 (0)