Skip to content

feat(cloud): access request flow for scoped API keys - #1132

Open
balanza wants to merge 28 commits into
mainfrom
feature-access-request
Open

balanza wants to merge 28 commits into
mainfrom
feature-access-request

Conversation

@balanza

@balanza balanza commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1131

What

Adds a cloud access flow to request scoped API keys with only the roles needed, instead of using keys with too many permissions.

How it works

  • You pick an organization (the CLI reads it from your OAuth login).
  • The CLI asks the API which roles you can ask for, and sends a proposal.
  • The request needs approval before a deadline, then you can claim the key.
  • Approval can happen without a browser, and polling stops on errors that will not recover.

Building with the feature

The flow sits behind an experimental cargo feature, so it is not in a normal build. To try it:

cargo build -p clickhousectl --features experimental-access-negotiation

Notes

  • New typed role discovery endpoints were added to the API library.
  • Tests cover the request builders and the real binary against a mock server.

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-negotiation Cargo feature that exposes cloud access roles and cloud access request for 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-name conflict 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_list and AssignableRole DTOs (separate from the admin organization_roles_get_list catalog), plus tests and OpenAPI-analyzer allowlisting.

Docs: README and authentication reference document the experimental commands, credential precedence, and --debug behavior.

Reviewed by Cursor Bugbot for commit e8c0bd0. Bugbot is set up for automated code reviews on this repo. Configure here.

@balanza
balanza requested a balanced review from Copilot October 22, 2026 14:28
@CLAassistant

CLAassistant commented Oct 22, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@balanza
balanza changed the base branch from main to local-idp October 22, 2026 14:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Secret-bearing claim redirects, callback parsing failures, unbounded discovery requests, and incompatible test fixtures remain unresolved.

7 open issues
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

Comment thread crates/clickhousectl/Cargo.toml Resolved
@balanza
balanza force-pushed the feature-access-request branch 3 times, most recently from f1cb9c1 to 9f89466 Compare October 22, 2026 20:31
@balanza balanza added the run-cloud-integration Run live Cloud integration tests for this PR head label Oct 22, 2026
@balanza
balanza deployed to cloud-integration October 22, 2026 20:34 — with GitHub Actions Active
@balanza
balanza force-pushed the feature-access-request branch from b279c13 to 90a2d9e Compare October 22, 2026 21:55
@balanza
balanza requested a balanced review from Copilot October 22, 2026 21:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread crates/clickhousectl/src/cloud/mod.rs Resolved
@balanza balanza added run-cloud-integration Run live Cloud integration tests for this PR head and removed run-cloud-integration Run live Cloud integration tests for this PR head labels Oct 22, 2026
@balanza
balanza deployed to cloud-integration October 22, 2026 22:32 — with GitHub Actions Active
@balanza
balanza marked this pull request as ready for review October 22, 2026 22:33

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e8c0bd0. Configure here.

@balanza
balanza changed the base branch from local-idp to main October 23, 2026 12:08
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.
@balanza
balanza force-pushed the feature-access-request branch from e8c0bd0 to 2c1902e Compare October 23, 2026 12:15

This branch was successfully deployed

1 active (outdated) deployment
cloud-integration — e8c0bd06 Deployed Oct 22, 2026 by balanza via Cloud integration tests #1427
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-cloud-integration Run live Cloud integration tests for this PR head

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cloud access request negotiation: role discovery, OAuth org selection and approval flow

3 participants