Repository navigation
fix(cloud-connection)!: bind/start, bind/poll and unbind require setup.access; GET /status serves the connection details only to a holder - #24167
Conversation
…p.access; status serves connection details only to a holder WIP: implementation; pins follow. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
…d status Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
…e outage; changeset and docs Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
…setup.access Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
…oud-connection-setup-gate
…mit; the outage double reads nothing Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
📓 Docs Drift CheckThis PR changes 1 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin fbaaddccb41a4f293b9108365ab5648f02976ba0 && git checkout fbaaddccb41a4f293b9108365ab5648f02976ba0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7cda377c17626047ea4a76dbfd14a6671f089fd7 e9fe7fe9bc75ea20cc85345c161d85ad79d9f5d0 && git checkout -B drift-repro 7cda377c17626047ea4a76dbfd14a6671f089fd7 && git merge --no-ff e9fe7fe9bc75ea20cc85345c161d85ad79d9f5d0
node scripts/docs-audit/affected-docs.mjs --json 7cda377c17626047ea4a76dbfd14a6671f089fd7
|
Contract reviewServed-tier: Inputs: card #24148 (body and its three comments: triage ① Derived judgmentsLine numbers are the plugin at the head. The boundary as built:
② Semver levelThe changeset
Gate verdicts on the head, as read in this act (39 check-runs): 25 ③ Boundary flags
Implemented-by: VERDICT: FAIL — on ② alone: the changeset grades a BREAKING Generated by Claude Code |
…the launch-window convention Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
Contract reviewServed-tier: Round 2, on the head that answers record What moved between the heads. ① Derived judgmentsLine numbers are the plugin at this head (unchanged from
② Semver levelThe changeset
Gate verdicts on this head, as read in this act (40 check-runs): 30 ③ Boundary flags
Implemented-by: VERDICT: PASS — the one defect record Generated by Claude Code |
席位核对 — ACCEPT ·
|
…t in the #13279 ledger Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
Contract reviewServed-tier: Round 3, on the head that moved off What moved. ① Derived judgments
② Semver levelThe changeset
Gate verdicts on this head, as read in this act (47 check-runs, two event runs overlapping): 38 ③ Boundary flags
Implemented-by: VERDICT: PASS — the head's one change is a truthful Generated by Claude Code |
席位核对 — ACCEPT (head moved) ·
|
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.accessover 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/polland/unbindrefuse a signed-in caller who does not holdsetup.access:403, codePERMISSION_DENIED, a message naming the capability, in the declared ADR-0112 envelope. The check sits right after the existing401and ADR-0069 held-session403, and before everything else: the503 CLOUD_UNCONFIGUREDanswer, thedevice_codevalidation, every control-plane call, and the stored-credential read and clear. The unparseable-body400still comes first, as before.GET /api/v1/cloud-connection/statusstill answers every caller200(it stays session-less on purpose, so a probe can tell the plugin is mounted). It narrows what it SERVES:bound,runtimeIdandprovideronly, unless the caller holdssetup.access. A holder is also servedenvironmentIdandconnection, 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.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 andpackages/specis untouched.FORBIDDEN(what the install-local doors next door answer) is a [finding]@objectstack/restregisters four generic synonyms the standard catalog already covers (CONFLICT,NOT_FOUND,FORBIDDEN,INTERNAL) — contract call, not a cleanup #8211-waived pre-gate synonym, kept on the wire and never endorsed for new code.How the permission is read (mechanism assumption 3)
holdsSetupAccesscallsresolveAuthzContext(@objectstack/core, guarded bycheck:authz-resolver) and readssystemPermissions. This is the shape copied fromrequireInstallCapability/resolveInstallPrincipalin this package'smarketplace-install-local-plugin.ts.packages/services/service-datasource/src/admin-routes.tsgates on the same resolver and field. It passes the same inputs those seams pass: the posture viaclassifyAdmissionTenancyPosture(async registry, so a brokentenancyservice is loud),userAuthGateSourceOfandapiKeyLifecyclePolicySourceOf. No permission read is re-spelled.resolveSessionis 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 thesessioncredential. 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.objectql,tenancy); single-environment mode reads the host's. With no data engine there are no grants, so it fails closed.AuthzStoreUnavailableErroris re-raised on the three doors, so the host's thrown-envelope render answers503 SERVICE_UNAVAILABLEinstead of a403that reads like a real refusal. Onstatusthe details are withheld, the answer is still200, and the outage is logged atwarn.Also changed (
packages/core, test only)packages/core/src/security/authz-store-unavailable.test.ts: one row in the [[finding] a permission-store read failure resolves as an AUTHENTICATED caller holding ZERO capabilities — the package door answers 403 FORBIDDEN, byte-identical to a genuine capability denial #13279]TRANSPORT_LEDGER,'packages/cloud-connection/src/cloud-connection-plugin.ts': 'guarded'(the plugin is a newresolveAuthzContexttransport whose fail-closed catch re-raises throughisAuthzStoreUnavailableError).Mechanism assumptions, measured on
origin/main52c04b360resolveSessionreturned 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.statuscalled none.statusansweredconnection: data.connection ?? null, the control plane's record verbatim, to every caller.resolveAuthzContext(...).systemPermissions, copied frommarketplace-install-local-plugin.tsas 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 routesCloudConnectionPluginmounts (start()+kernel:ready). Grants are real permission-set rows served through the engine'sfind, honouring itswhere, with set bodies from the bound security catalog. Refusals assertstatus+code+ the declared envelope (BaseResponseSchema,envelopeViolations,ApiErrorSchema), never a bare throw.setup.accessgets403 PERMISSION_DENIED, with one case per door (bind/start,bind/poll,unbind); no control-plane call, credential untouched;organization_adminshape);403;status, anonymous: exactlybound/runtimeId/provider, noconnection;statusgives 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;statusfor a holder carriesconnectionandenvironmentId.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 emptygit diff HEAD. The suite imports the plugin fromsrc/by relative path, so the mutation reaches the run without a build. Treea6745b5aeand again at the final head7f45419be; plugin blob44de963e5afaboth times.bind/start's check short-circuitedbind/start)bind/poll's check short-circuitedunbind's check short-circuitedstatusserves the full answer unconditionallystatuscase, both modessession-credential condition removedAfter all five:
git statusclean, 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 holdsetup.access, so a refusal there stays the gate's alone. The census case used to assert that/statusreads no session. It now asserts that/statusanswers a held session200with the narrowed shape, which is the new contract.cloud-connection-plugin.test.ts: anonymousstatusexpectations are now the narrowed shape. The two cases whose subject is the resolvedenvironmentIdnow sign in as a holder.error-envelope.conformance.test.ts: the harness grantssetup.accessby default, and there are three new cases for the403 PERMISSION_DENIEDenvelope on the three doors.connection-credential-store.test.ts: itsadminholdssetup.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 refused403(PERMISSION_DENIED) before the server calls the cloud.#reference: thestatusrow now says what every caller gets and what a Setup Access holder also gets.Changeset
.changeset/24148-cloud-connection-setup-access.mdcontains:patchbump, since no public surface widens;fix(cloud-connection)!:and a BREAKING banner;Clause-②: no (narrowing);not-required (no-migration-prescription). The category was decided by running both gates locally, and it matches the closest precedent, PR fix(rest, core)!: the anonymous public-form submit enforces the form's ownrequired; the emptiness rule moves to @objectstack/core #24161's rest changeset.Gate readings:
check-adr-0087-registrationprinted "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-majorprinted "This diff introduces nomajorbump.", exit 0.Verification (final tree)
All readings below are from the final head,
7f45419be(git rev-parse --short HEAD), unless a row says otherwise.pnpm --filter '@objectstack/cloud-connection^...' build, thenpnpm --filter @objectstack/cloud-connection build. Run after mergingorigin/main(eda6f14b7), lock verdictcommand-exit 0.pnpm --filter @objectstack/cloud-connection typecheck(both programs) exits 0.tsconfig.test.json --listFilesreaches all 48*.test.tsfiles, the new pin among them.pnpm --filter @objectstack/cloud-connection testgives "Test Files 48 passed (48) · Tests 595 passed (595)".pnpm exec eslint --no-inline-configover the 6 changed.tsfiles exits 0 with no output. The.md/.mdxfiles are "ignored because no matching configuration", exit 0.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 on7f45419be, each exit captured before any pipe; all exited 0. Reconciliation--ranprinted "93 derived famil(ies) accounted for — 93 run, 0 NOT-MEASURED (a DERIVED zero …)", exit 0.check:authz-resolverprinted "single shared authorization resolver intact; both entry points delegate".check:error-code-casingprinted OK.check:filter-alias-parityprinted OK.check-changeset-fixedprinted "in sync with 73 public workspace packages".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'sfinddouble was refused here for ignoringlimit; fixed in7f45419be.check:where-matcher: "conformance holds".check:nul-bytes: OK.check-adr-0087-registrationandcheck-changeset-no-major: as quoted above.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.e9fe7fe9b(the ledger row):Test Core (3/6)on47e78745dwas red in core'stest:repo, at the [[finding] a permission-store read failure resolves as an AUTHENTICATED caller holding ZERO capabilities — the package door answers 403 FORBIDDEN, byte-identical to a genuine capability denial #13279]⭐ SET EQUALITYtest 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:repoprojects (types, objectql, rest, runtime, spec) and cloud-connection's suite (48 files, 595 passed) are green one9fe7fe9b. The changeset gate demands no core entry, because the test file is not core's published output.test:repovitest 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
/installhold-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 cloud-connection: the marketplace-install route supplies notenancyPosturetoresolveAuthzContext— an ex-member's org-stamped API key is admitted #15353; that route,installation,installedandorg-packagesare untouched here.kernel:readyinfo line lists{status,bind/start,bind/poll,install,installation,installed,org-packages}and omitsunbind. This is cosmetic text in a log; noted, not filed.#referencetable's "Sign-in" column still says "required" for the three doors. That is true but does not name Setup Access. The#connectfailure table has no row for the new403message, which a panel user can only meet if their grant is revoked mid-session. Left as they are, to keep inside the claimed surface.PluginContextgained an optionalgetKernel?(), read only for the asynctenancylookup. This widens whatstart(ctx)accepts. The workspace consumers (packages/cliserve.ts,examples/app-showcase) were not type-checked locally; CI's TypeScript Type Check lanes cover them.Generated by Claude Code