Skip to content
Merged
20 changes: 20 additions & 0 deletions .changeset/24148-cloud-connection-setup-access.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- 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 -->

**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.
12 changes: 5 additions & 7 deletions content/docs/deployment/cloud-connection.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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" }`. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand All @@ -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';
Expand Down Expand Up @@ -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<string, unknown> = { auth, objectql };
const services: Record<string, unknown> = 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),
Expand Down Expand Up @@ -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) {
Expand Down
27 changes: 23 additions & 4 deletions packages/cloud-connection/src/cloud-connection-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response | any>;

Expand Down Expand Up @@ -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<string, any[]> = {
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;
});
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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'));
Expand All @@ -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();
Expand Down
Loading
Loading