Skip to content

feat(cloud): access request negotiation — roles, OAuth org selection and approval flow - #1130

Closed
balanza wants to merge 20 commits into
local-idpfrom
permission-negotiation-mvp-2
Closed

balanza wants to merge 20 commits into
local-idpfrom
permission-negotiation-mvp-2

Conversation

@balanza

@balanza balanza commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Implements the Cloud access request workflow end to end, stacked on #1082
(local-idp). From an OAuth login it discovers the caller-assignable roles,
proposes the least-privilege set for a task, and hands the proposal to a human
for approval:

  • Access request PKCE handshake for scoped API keys, including the
    {result} envelope handling and terminal-failure polling fixes
  • OAuth identity and endpoint selection for access negotiation, refreshed from
    the live token; complete organization selection from OAuth memberships
    (--org-id / --org-name, singleton auto-selection, cyclic-page protection)
  • Typed caller-assignable role discovery in clickhouse-cloud-api
    (organization_assignable_roles_get_list), with no administrative-catalog fallback
  • Validated role proposals, browser-free approval handoff, separate approval
    and claim deadlines, and validated-proposal/grant reporting
  • The whole flow is gated behind an experimental feature flag

Per-commit detail is in the individual commit messages; the branch is a clean
linear sequence on top of #1082.

Testing

  • cargo fmt --all
  • cargo clippy -p clickhousectl -- -D warnings
  • cargo test -p clickhousectl
  • cargo clippy -p clickhouse-cloud-api -p clickhouse-openapi-analyzer --all-targets -- -D warnings
  • cargo test -p clickhouse-cloud-api -p clickhouse-openapi-analyzer
  • cargo check -p clickhousectl --no-default-features + no-default-features clippy

Tracking issue: #1131

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.
Add a typed organization page reader accepting an opaque cursor.

Preserve the existing first-page API for callers while exposing pagination
metadata needed for complete OAuth membership selection.
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.
@CLAassistant

CLAassistant commented Oct 22, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@balanza
balanza requested a balanced review from Copilot October 22, 2026 14:09
@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 14:16 — with GitHub Actions Active

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

Credential-bearing redirects, runtime reliability issues, and incompatible test fixtures remain unresolved, while CI does not execute the experimental tests.

5 open issues
What changed in this PR

Adds experimental Cloud access negotiation for #1131, building on #1082’s custom OAuth support. The flow discovers assignable roles and obtains scoped API keys through human approval.

Changes:

  • Adds OAuth-only role discovery, organization selection, and validated role proposals.
  • Implements browser or manual approval handoff, bounded polling, separate claim deadlines, and grant reporting.
  • Adds typed API models, feature-gated tests, documentation, and certificate dependency updates.
File Description
skills-draft/​clickhousectl-least-privilege/​SKILL.md Adds agent guidance for least-privilege access requests.
README.md Documents access negotiation and credential behavior.
crates/​clickhousectl/​tests/​cli_request_shape_test.rs Adds feature-gated access workflow tests.
crates/​clickhousectl/​src/​main.rs Validates selectors and proposals before execution.
crates/​clickhousectl/​src/​cloud/​permissions.rs Registers access command authorization declarations.
crates/​clickhousectl/​src/​cloud/​mod.rs Routes access commands through OAuth-only handling.
crates/​clickhousectl/​src/​cloud/​client.rs Adds access-specific identity and endpoint resolution.
crates/​clickhousectl/​src/​cloud/​cli.rs Defines access commands and selector conflict checks.
crates/​clickhousectl/​src/​cloud/​access.rs Implements role discovery and the approval/claim flow.
crates/​clickhousectl/​Cargo.toml Adds the disabled-by-default experimental feature.
crates/​clickhouse-openapi-analyzer/​src/​config.rs Allowlists the distinct role-discovery helper.
crates/​clickhouse-cloud-api/​tests/​models_test.rs Tests assignable-role transport compatibility.
crates/​clickhouse-cloud-api/​tests/​client_test.rs Tests role-discovery authentication and responses.
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 caller validation.
Cargo.lock Updates certificate-related dependencies.

Review effort: Balanced

Comment on lines +520 to +521
let http = crate::http::client_builder()
.build()
# and the scoped-API-key PKCE approval handshake). Off by default so the
# published binary carries none of it; opt in locally with
# `--features experimental-access-negotiation`.
experimental-access-negotiation = []
Comment on lines +769 to +774
let mut buffer = [0u8; 2048];
let read = stream.read(&mut buffer).await.ok()?;
if read == 0 {
return None;
}
let text = String::from_utf8_lossy(&buffer[..read]);
url_override.unwrap_or(&tokens.api_url),
),
},
None,
Comment on lines +981 to +984
let mut value = serde_json::json!({
"access_token": "test-token", "refresh_token": "unused",
"expires_at": 4102444800_i64,
});
@balanza balanza closed this Oct 22, 2026
@balanza

balanza commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Wrong pr

This branch was successfully deployed

1 active deployment
cloud-integration — ea4f66c6 Deployed Oct 22, 2026 by balanza via Cloud integration tests #1398
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.

3 participants