Repository navigation
Commit 6973014
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
File tree
- .changeset
- content/docs/deployment
- packages
- cloud-connection/src
- core/src/security
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
74 | 74 | | |
75 | 75 | | |
76 | 76 | | |
77 | | - | |
78 | | - | |
79 | | - | |
80 | | - | |
81 | | - | |
82 | | - | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
83 | 81 | | |
84 | 82 | | |
85 | 83 | | |
| |||
213 | 211 | | |
214 | 212 | | |
215 | 213 | | |
216 | | - | |
| 214 | + | |
217 | 215 | | |
218 | 216 | | |
219 | 217 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
| 13 | + | |
| 14 | + | |
14 | 15 | | |
15 | 16 | | |
16 | 17 | | |
| |||
28 | 29 | | |
29 | 30 | | |
30 | 31 | | |
31 | | - | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
32 | 38 | | |
33 | 39 | | |
34 | 40 | | |
| |||
156 | 162 | | |
157 | 163 | | |
158 | 164 | | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
159 | 170 | | |
160 | | - | |
| 171 | + | |
161 | 172 | | |
162 | 173 | | |
163 | | - | |
| 174 | + | |
164 | 175 | | |
165 | 176 | | |
166 | 177 | | |
| |||
192 | 203 | | |
193 | 204 | | |
194 | 205 | | |
195 | | - | |
196 | | - | |
| 206 | + | |
| 207 | + | |
197 | 208 | | |
198 | 209 | | |
199 | 210 | | |
200 | 211 | | |
201 | | - | |
202 | | - | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
203 | 218 | | |
204 | 219 | | |
205 | 220 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
| 14 | + | |
14 | 15 | | |
15 | 16 | | |
16 | 17 | | |
| |||
59 | 60 | | |
60 | 61 | | |
61 | 62 | | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
62 | 80 | | |
63 | 81 | | |
64 | 82 | | |
| |||
118 | 136 | | |
119 | 137 | | |
120 | 138 | | |
121 | | - | |
| 139 | + | |
122 | 140 | | |
123 | 141 | | |
124 | 142 | | |
| |||
200 | 218 | | |
201 | 219 | | |
202 | 220 | | |
203 | | - | |
| 221 | + | |
204 | 222 | | |
205 | 223 | | |
206 | 224 | | |
| |||
223 | 241 | | |
224 | 242 | | |
225 | 243 | | |
226 | | - | |
| 244 | + | |
| 245 | + | |
227 | 246 | | |
228 | 247 | | |
229 | 248 | | |
| |||
236 | 255 | | |
237 | 256 | | |
238 | 257 | | |
239 | | - | |
| 258 | + | |
240 | 259 | | |
241 | 260 | | |
242 | 261 | | |
| |||
0 commit comments