Repository navigation
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Secret-bearing claim redirects, callback parsing failures, unbounded discovery requests, and incompatible test fixtures remain unresolved.
7 open issues
Disable redirects for claim requests · New Buffer callback requests before parsing · New Apply timeouts to access-operation requests · New Update fixtures with complete auth configuration · New Persist auth configuration in refresh fixtures · New Run access-negotiation tests in CI · New Preserve credentials until renegotiation succeeds · New
What changed in this PR
Adds an experimental Cloud access workflow for obtaining human-approved, scoped API keys from an OAuth identity, supported by typed role discovery in the API library.
Changes:
- Adds role discovery, validated proposals, browser-free approval handoff, polling deadlines, and grant reporting.
- Persists OAuth issuer configuration and skips token refresh when higher-priority API credentials win.
- Adds workflow documentation and unit/mock-server tests.
| File | Description |
|---|---|
| skills-draft/clickhousectl-least-privilege/SKILL.md | Documents agent-driven role selection and approval. |
| README.md | Introduces access commands and authentication behavior. |
| docs/reference/authentication.md | Clarifies credential precedence and refresh behavior. |
| docs/development.md | Documents custom OAuth configuration and URL restrictions. |
| crates/clickhousectl/tests/telemetry_test.rs | Updates OAuth fixtures with saved configuration. |
| crates/clickhousectl/tests/cli_request_shape_test.rs | Adds authentication and access-flow subprocess tests. |
| crates/clickhousectl/src/main.rs | Validates access selectors and proposals before execution. |
| crates/clickhousectl/src/cloud/permissions.rs | Registers feature-gated access declarations. |
| crates/clickhousectl/src/cloud/mod.rs | Routes access commands through OAuth-only authentication. |
| crates/clickhousectl/src/cloud/client.rs | Adds access-specific client construction and request credentials. |
| crates/clickhousectl/src/cloud/cli.rs | Defines access commands and selector conflict checks. |
| crates/clickhousectl/src/cloud/auth.rs | Saves and validates OAuth configuration; disables OAuth redirects. |
| crates/clickhousectl/src/cloud/access.rs | Implements discovery, approval, claiming, and credential installation. |
| crates/clickhousectl/Cargo.toml | Adds the experimental feature gate. |
| crates/clickhouse-openapi-analyzer/src/config.rs | Allowlists the typed role-discovery helper. |
| crates/clickhouse-cloud-api/tests/models_test.rs | Tests assignable-role serialization and compatibility. |
| crates/clickhouse-cloud-api/tests/client_test.rs | Tests role discovery, authentication, and error handling. |
| crates/clickhouse-cloud-api/src/models/rbac.rs | Adds assignable-role and policy models. |
| crates/clickhouse-cloud-api/src/models.rs | Exports the new models. |
| crates/clickhouse-cloud-api/src/client/organizations.rs | Adds Bearer-only assignable-role discovery. |
| crates/clickhouse-cloud-api/README.md | Documents discovery contracts and required validation. |
Review effort: Balanced
f1cb9c1 to
9f89466
Compare
b279c13 to
90a2d9e
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The access client passes a token-loading Result where an Option is required, preventing feature-enabled compilation.
2 open issues
7 resolved since last review
Disable redirects for claim requests Persist auth configuration in refresh fixtures Update fixtures with complete auth configuration Apply timeouts to access-operation requests Buffer callback requests before parsing Preserve credentials until renegotiation succeeds Run access-negotiation tests in CI
Review effort: Balanced
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e8c0bd0. Configure here.
| // client carries the same 30-second access-operation bound; a | ||
| // server holding a response open must not stall either command. | ||
| Some(crate::cloud::access::OPERATION_TIMEOUT), | ||
| ) |
There was a problem hiding this comment.
OAuth token expires during approval wait
Medium Severity
for_access treats an OAuth token as fresh with only a 60-second buffer, then request snapshots that Bearer token for the whole approval handshake. A pending wait can last 15 minutes, so a still-accepted token can expire mid-poll or at claim. The next 401 ends the wait and leaves a filed or already-approved request unclaimed.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit e8c0bd0. Configure here.
Add `cloud access request`, which files a control-plane access request carrying a PKCE S256 challenge and a CSRF state, opens the approval URL in a browser, waits on a loopback redirect (with status polling as a fallback), claims the issued key with the PKCE verifier, and stores the key id + secret with the existing credential storage. The secret is never printed. Credentials, base URL, and organization are resolved through CloudClient pointed at a configurable control-plane base (default http://localhost:2023). CloudClient now carries the OAuth bearer token so bespoke control-plane requests can reuse the resolved identity.
The openapi control-plane app wraps every /v1 response as
{ "result": <payload>, "requestId": <string> }, so the access-request
handshake must read the payload from `result` instead of the top level.
Add a generic Envelope<T> and unwrap it in parse_response, and type the
file response's expiresAt as epoch-millis i64 (not a string). Add
envelope-deserialization tests for the file, poll, and claim payloads.
The poll fallback read AccessRequestStatus directly from the response body, which is
the {result,requestId,status} envelope, so it never matched an approved status and
polled until timeout. Unwrap Envelope<AccessRequestStatus> like the other calls.
Caught by a live end-to-end run against the local control plane.
Separate Cloud client construction from credential selection so access negotiation can choose its user identity explicitly. Keep ordinary command credential behavior intact while providing a reusable construction seam for the dedicated OAuth-only access flow.
Use refreshed user OAuth for access negotiation regardless of saved, flag or environment Basic credentials. Preserve explicit URL precedence over the saved OAuth endpoint and Cloud default. Fail with login guidance when OAuth is unavailable rather than falling back to Basic authentication; leave the PKCE handshake unchanged.
Explain the access flow’s OAuth identity requirement and endpoint precedence. Clarify that saved or explicitly supplied Basic credentials do not select the requester identity and that unusable OAuth requires login instead of fallback.
Resolve access organizations from complete OAuth memberships, with explicit ID, exact name and singleton-only automatic selection. Preserve legacy selectors and reject conflicting explicit selectors before authentication or network access using the actual command grammar. Fail closed on malformed, duplicate or cyclic page results without default-org fallback.
Add a Bearer-only SDK reader for caller-assignable roles on the existing roles route. Preserve complete policy effects, resources and SQL tags in dedicated transport DTOs while retaining the administrative client contract. Keep absent fields distinct from empty arrays under SDK conventions and document mandatory consumer validation before discovery output. Add offline request and model coverage.
Expose cloud access roles using refreshed OAuth and the existing complete organization resolver. Validate the entire role menu before any success output. Emit a direct full-policy JSON array or a human-readable organization and scope table, with honest labels for unknown values and no catalog fallback. Update parent help and documentation; cover output, auth, selector and malformed-menu behavior with offline tests. Live discovery remains a separate checkpoint.
Accept repeated or comma-separated role UUID tokens on access request. Reject malformed input before OAuth or network access, canonicalize UUID identity, preserve first occurrence order, and enforce the 100-role limit after deduplication. Send optional requestedRoleIds alongside the unchanged PKCE handshake so users can propose discovered roles without treating them as grants. Preserve omission for manual selection and fail server rejections without subset retries. Document the grammar and cover parser boundaries, canonical request bodies, pre-network errors, and credential preservation with offline tests.
Distinguish pending and retryable HTTP 429/5xx responses from denied, expired, authentication, malformed, and other terminal responses. Preserve the existing approval timeout and fail promptly instead of hiding persistent failures until that timeout. Sanitize polling and claim diagnostics so echoed claim codes, verifiers, and secret values cannot reach stderr. Reject empty claimed credentials before storage while keeping the existing claim result and deadline contracts unchanged. Cover status transitions and echo safety with offline tests.
Add request-scoped --no-browser while preserving automatic launch by default and keeping callback and polling approval paths active. Always send the approval URL and progress to stderr so JSON stdout remains one unchanged final success object. Document manual handoff and the deferred claim-deadline distinction. Exercise both approval paths, terminal failures, malformed claims, credential preservation, and echoed-secret safety using isolated offline subprocesses; use an injected fake launcher to prove browser suppression without opening a real browser.
Validate server epoch deadlines and reconcile authoritative status at the end of the pending wait. Treat callbacks only as wakeups, require a matching usable GET claim permit, and let already-approved requests claim beyond pending expiry without extending the claim window. Bound access HTTP operations to 30 seconds and remaining phase time, guard clock rollback with immutable monotonic ceilings, and never retry an ambiguous claim. Fail closed on missing deadline support and malformed filing/status envelopes before installing credentials. Preserve OAuth, PKCE, browser handoff and the existing four-field success output; cover boundaries, callback/poll parity, stalled transports and credential safety with offline tests.
Consume the server-saved proposal from filing and require status responses to preserve its canonical UUID identities and order. Reject absent, malformed, duplicate or inconsistent role arrays instead of fabricating compatibility values or silently filtering server data. Use only the successful claim's nonempty actual grants for reporting, allowing human additions and trims without imposing the proposal's size limit. Add organization, request, requested-role and granted-role fields to final output, prepare JSON before the single credential write, and preserve existing credential and OAuth behavior. Cover callback/poll parity, old-server failures and secret-safe credential preservation with offline tests and document the additive contract.
Compile the experimental access-negotiation flow (assignable-role discovery and the scoped-API-key PKCE approval handshake) only under --features experimental-access-negotiation, so published binaries carry none of it: the subcommand is absent from help and parsing in default builds, and the access-only client surface (RequestAuth, request_auth, for_access, from_access_tokens) is compiled out with it. The clickhouse-cloud-api assignable-role models, reader and analyzer allowlist entry stay ungated as general library capability, and the library tests are unchanged. The access section of cli_request_shape_test.rs moves into a cfg'd module and runs only when the feature is enabled (20 tests, all green either way; the default run compiles 624 tests instead of 644). Verified: help smoke in both builds; all four clippy configurations (default/no-default-features x feature off/on) clean with -D warnings; fmt clean; classifier inventory tests pass; only the pre-existing test_auth_config_lookup failure remains.
…keys fixup! feat(cloud): add access request PKCE handshake for scoped API keys The handshake client followed redirects. A 307 or 308 from the claim endpoint would replay the JSON body — the claim code and the secret PKCE code verifier — to another origin, and reqwest's cross-origin behavior of dropping the Authorization header does not protect those body fields. Reject redirects instead of forwarding secrets or replaying the claim POST: the handshake now builds its HTTP client with reqwest::redirect::Policy::none(), mirroring oauth_client() in auth.rs, whose form bodies carry credentials for the same reason. Covered by a unit test that points the claim endpoint's Location at a second mock server across every redirect status and asserts no request reaches it, and by a subprocess regression test that runs the real binary through a full approve-and-claim against redirecting mocks and asserts a failed claim, unchanged credential and token files, a single claim POST whose codeVerifier binds to the filed codeChallenge, and no requests to the redirect target or the approval URL.
…keys fixup! feat(cloud): add access request PKCE handshake for scoped API keys The loopback callback parsed whatever the first TCP read happened to deliver. A read need not contain the complete request line: a valid callback split midway through the state value parsed as a partial value, and wait_for_callback aborted approval with a terminal CSRF error instead of continuing to wait for the human. read_request_target now buffers bytes through the request-line terminator and only then extracts the request target, retaining a size limit: a line larger than MAX_REQUEST_LINE (8 KiB) with no terminator is treated as no target, so a client streaming an unbounded line cannot grow the buffer past a bound. Covered by a wait_for_approval regression test that writes a valid callback in two writes with a yield between, splitting the request line midway through the state value, and asserts approval proceeds; and by a helper test asserting an oversized unterminated line returns no target without waiting on more input. Both fail against the previous single-read implementation.
…keys Membership and assignable-role discovery call the typed API client directly, outside the handshake's operation() wrapper, and that client was built without a request timeout: a server that keeps a response open stalled either access command indefinitely. for_access now passes the 30-second access-operation bound to the client constructor, so every single access HTTP request - discovery through the typed client, and each handshake step - gives up after the same bound. OPERATION_TIMEOUT is shared (pub(super)) from access.rs so the two cannot drift, and from_access_tokens takes the timeout as a parameter. Covered at two layers, both mutation-verified: a unit test drives the client with a short bound against a mock that holds its response open and asserts the failure classifies as FailureKind::Timeout, and a subprocess test runs the real binary with the roles endpoint delayed longer than the bound and asserts the command exits within the bound instead of waiting out the mock (the mutation - no bound wired - failed at 91s; the fix passes at 31s).
…keys Every access-client test fixture omitted the now-required TokenStore.auth_config, so deserialization panicked before endpoint selection was tested, and the final case also omitted api_url, whose serde default was removed. The third case asserted a legacy fallback that no longer exists. Use complete records - api_url and auth_config always present - in all three cases, and test an explicitly saved default URL instead of the obsolete legacy fallback; rejection of incomplete records is already covered by the authentication tests. The test is renamed to access_client_uses_explicit_or_saved_api_base accordingly.
…keys The access refresh fixtures omitted the required saved auth_config, so load_tokens() rejected them before refresh; environment overrides intentionally cannot repair them. That broke the refresh expectations in access_roles_requires_and_refreshes_oauth_despite_basic_credentials, access_request_missing_or_unrefreshable_identity_never_falls_back_to_basic, and access_request_refreshes_oauth_despite_stored_basic_and_preserves_login_ environment. Persist each mock issuer URL and the test-client id in the records intended to reach refresh or empty-refresh-token handling, and keep only the unknown-host case a legacy record without auth_config - it is intentionally unusable, which no environment can repair, and its assertions depend on the file surviving. The feature-enabled subprocess suite runs green again.
The experimental access-negotiation feature gates the cloud access unit and subprocess tests, but no CI job executed them: test-cli.yml runs default-feature tests only, while msrv.yml only checks compilation with all features. Broken fixtures in the gated suite therefore went undetected. Keep the default-feature coverage and add cargo test with the feature enabled, plus an all-targets clippy run with it, since the default- feature clippy never sees the gated code.
…cess docs edit The 'Create ClickHouse Managed Postgres' section lost its code example and closing paragraph to a bad hunk placement: both were stranded at the end of the 'Debugging which credential source was used' section, while the new credential-precedence note sat under the Postgres heading. Restore the section intact, and move the note into the Debugging section where the stderr-note behavior it describes is documented.
for_access maps UnusableTokens to the re-login auth error 6326128 changed load_tokens() to return Result<Option<TokenStore>, UnusableTokens>, leaving the feature-gated for_access call site passing the Result where from_access_tokens expects the Option: the default-feature builds compiled, but the experimental-access-negotiation clippy job failed with E0308. Convert at the call site with the established OAUTH_LOGIN_REQUIRED mapping, matching resolve_auth's treatment of an unusable saved login.
e8c0bd0 to
2c1902e
Compare





Fixes #1131
What
Adds a
cloud accessflow to request scoped API keys with only the roles needed, instead of using keys with too many permissions.How it works
Building with the feature
The flow sits behind an experimental cargo feature, so it is not in a normal build. To try it:
Notes
Happy to split the API library part into its own PR if that is easier to review.
Note
High Risk
Introduces OAuth-gated PKCE negotiation, loopback handling, and local storage of newly issued API key secrets—security-critical authentication and credential paths.
Overview
Adds an opt-in
experimental-access-negotiationCargo feature that exposescloud access rolesandcloud access requestfor human-approved, narrowly scoped API keys instead of reusing broad credentials.CLI: Role discovery runs on refreshed OAuth only (ignores saved API keys), validates full policy payloads, and resolves org membership from paginated user orgs with stricter
--org/--org-id/--org-nameconflict checks. Access request implements a PKCE + loopback callback flow (poll fallback, no redirect following on claim, 30s operation bounds, secrets never logged) and writes the claimed key to.clickhouse/credentials.json. The flow is gated off default builds; CI runs clippy/tests with the feature enabled.API library: New Bearer-only
organization_assignable_roles_get_listandAssignableRoleDTOs (separate from the adminorganization_roles_get_listcatalog), plus tests and OpenAPI-analyzer allowlisting.Docs: README and authentication reference document the experimental commands, credential precedence, and
--debugbehavior.Reviewed by Cursor Bugbot for commit e8c0bd0. Bugbot is set up for automated code reviews on this repo. Configure here.