Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
3eb8d9c
feat(cloud): add access request PKCE handshake for scoped API keys
balanza Oct 12, 2026
d0346b8
docs(readme): document cloud access request scoped-key flow
balanza Oct 12, 2026
c59f9ef
fix(cloud): unwrap {result} envelope for access request responses
balanza Oct 12, 2026
dd83282
fix(cloud): unwrap {result} envelope in access request status poll
balanza Oct 12, 2026
bf9267c
style(cloud): rustfmt line-wrap in access request poll
balanza Oct 12, 2026
4ed05eb
refactor(cloud): separate client construction from credential resolution
balanza Oct 14, 2026
e32b92e
fix(cloud): use refreshed OAuth identity for access negotiation
balanza Oct 14, 2026
fa2d338
docs(cloud): explain access OAuth identity and endpoint selection
balanza Oct 14, 2026
afdd5aa
feat(api): add compatible organization page reader
balanza Oct 22, 2026
52775e2
feat(cloud): select access organizations from OAuth memberships
balanza Oct 14, 2026
5f13ec9
feat(api): add typed caller-assignable role discovery
balanza Oct 14, 2026
51c2e9e
feat(cloud): expose validated assignable role discovery
balanza Oct 14, 2026
cbd667b
feat(cloud): send validated access role proposals
balanza Oct 14, 2026
a2954c3
fix(cloud): terminate access polling on terminal failures
balanza Oct 14, 2026
7fa67a0
feat(cloud): support browser-free access approval handoff
balanza Oct 14, 2026
52441ba
fix(cloud): honor separate access approval and claim deadlines
balanza Oct 14, 2026
b2e7090
feat(cloud): report validated access proposals and actual grants
balanza Oct 15, 2026
a7460ba
docs(access-request): describe the role discovery workflow
balanza Oct 21, 2026
4c286e4
feat(cli): gate the cloud access flow behind an experimental feature
balanza Oct 21, 2026
abeccc6
fixup! feat(cloud): add access request PKCE handshake for scoped API …
balanza Oct 22, 2026
8f8ba40
fixup! feat(cloud): add access request PKCE handshake for scoped API …
balanza Oct 22, 2026
742501c
fixup! feat(cloud): add access request PKCE handshake for scoped API …
balanza Oct 22, 2026
4322c21
fixup! feat(cloud): add access request PKCE handshake for scoped API …
balanza Oct 22, 2026
da2a2ad
fixup! feat(cloud): add access request PKCE handshake for scoped API …
balanza Oct 22, 2026
2e6ca7f
chore(ci): run tests and clippy with experimental access negotiation
balanza Oct 22, 2026
c108c42
fix(readme): restore the Managed Postgres section misplaced by the ac…
balanza Oct 22, 2026
9efe38e
fixup! fix(auth): require re-login for unusable saved OAuth tokens
balanza Oct 22, 2026
2c1902e
fixup! feat(cloud): add access request PKCE handshake for scoped API …
balanza Oct 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/test-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,16 @@ jobs:
- name: Clippy without default features
run: cargo clippy -p clickhousectl --all-targets --no-default-features -- -D warnings

# Clippy only compiles all features; these tests only run with the
# experimental access-negotiation feature enabled.
- name: Clippy with experimental access negotiation
run: cargo clippy -p clickhousectl --all-targets --features experimental-access-negotiation -- -D warnings

- name: Test
run: cargo test -p clickhousectl

- name: Test with experimental access negotiation
run: cargo test -p clickhousectl --features experimental-access-negotiation

- name: Verify install workflow path mapping
run: python3 scripts/tests/test_classify_install_integration.py
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,48 @@ clickhousectl cloud postgres certs get <postgres-id> --output ca.pem

Save any initial password and connection string, wait for `state=running`, then connect with `psql` using verified TLS. The [ClickHouse Managed Postgres guide](docs/guides/managed-postgres.md) covers that flow, configuration changes, read-only OAuth queries, and recovery. ClickHouse Managed Postgres commands are beta.

### Discover assignable roles

```bash
clickhousectl cloud access roles
clickhousectl cloud access roles --org <organization-id> --json
```

Discovery uses your refreshed OAuth user identity even when an API key is saved, with the same organization selectors and endpoint precedence as `cloud access request`. Human output identifies the selected organization and lists role IDs/names with policy effects and resource scopes. `--json` returns exactly the complete role array, including all ALLOW/DENY policies and optional SQL tags; an eligible empty menu is `[]`. SQL grant/revoke strings and unknown values remain opaque. Incomplete required discovery data fails before any menu is printed, rather than implying empty permissions or falling back to the administrative catalog. Discovery describes current authority, not a guarantee of later approval or a role recommendation.

### Request a scoped API key (access request)

`cloud access request` obtains a scoped API key through the control plane's PKCE approval handshake, so an agent can get a narrowly-scoped key that a human approves in a browser rather than reusing a long-lived one.

```bash
clickhousectl cloud access request
clickhousectl cloud access request --key-name agent-key --purpose "CI deploy" --tool claude
clickhousectl cloud access request --org <organization-id> --perms role:<role-uuid>,role:<another-role-uuid>
clickhousectl cloud access request --no-browser --json
```

Use IDs from `cloud access roles` to propose roles with `--perms role:<UUID>` (repeat the flag or separate tokens with commas). UUIDs are normalized to lowercase and deduplicated by identity in first-seen order; at most 100 distinct roles are allowed. Empty tokens, whitespace, wrong prefixes and malformed UUIDs fail before authentication or network access. Role names, raw permissions and resource overrides are not supported. Omit `--perms` for manual browser selection; an explicitly empty value is invalid. A proposal is not a grant: the server validates the whole proposal atomically, and the human's eventual selection may differ. The CLI neither filters rejected proposals nor retries a subset.

The command requires your OAuth user identity from `cloud auth login` and refreshes it when needed; API keys from flags, saved credentials, or environment variables are never used for negotiation. Missing or expired identity that cannot be refreshed fails with authentication exit code 4 and login guidance. It generates a PKCE verifier/challenge and a CSRF `state`, starts a loopback redirect server on `127.0.0.1`, and opens the approval URL (printed as a fallback). After you approve it, the CLI claims the issued key with the PKCE verifier and stores the key id and secret in `.clickhouse/credentials.json`. The secret is never printed or placed in a URL. If the browser cannot reach the loopback server, the CLI falls back to polling the request's status until it is approved. Pending approval waits until the server pending deadline or 15 minutes, whichever comes first. One final status check can still discover an already-approved request.

Use `--no-browser` when another person or device will open the approval link: it suppresses only automatic browser launch, not approval, the loopback listener or polling. The approval URL and progress always go to stderr, including with `--json`; JSON stdout contains only the final success object. Denied, expired and authentication failures stop the wait promptly without replacing saved credentials. Polling retries HTTP 429/5xx within the existing wait; other HTTP failures and malformed status responses terminate. An approved request uses its separate server claim deadline, even after pending expiry; servers missing deadline metadata are unsupported. Each access HTTP operation is bounded to 30 seconds, shortened by its phase deadline. Filing and claiming are not retried: a timeout may leave a filed request or a completed server claim without local credentials. Neither pending nor claim expiry is key expiry.

Successful requests report the organization and request IDs, the server-saved proposal, and the actual roles returned by the claim. JSON retains `status`, `keyId`, `key`, and `credentialsPath`, and adds `requestId`, `organizationId`, `requestedRoleIds`, and `grantedRoleIds`. With no proposal, `requestedRoleIds` is `[]` (manual selection); the human may trim or add roles, so actual grants need not match the proposal. Missing or invalid server role arrays fail before local credential installation; older servers without this reporting contract are unsupported.

The API URL comes from `--url` when provided, otherwise the saved OAuth login environment, or the normal Cloud default (`https://api.clickhouse.cloud/v1`) for older tokens without a saved URL. Local development requires an explicit local URL at login or through `--url`; there is no implicit localhost default. Other Cloud commands retain their normal credential precedence, so a saved negotiated key remains available for subsequent operations. Optional `--purpose`, `--tool`, `--version`, and `--session-id` flags record agent context with the request.

Access organization selection uses your OAuth user's complete, paginated membership list, not the saved API key's organization. A single membership is selected automatically; zero or multiple memberships require action (no remembered default or prompt). Find IDs with `cloud org list --json` and select `cloud access request --org <ID>` (`--org-id` remains supported), or use exact `--org-name`. Conflicting selectors, including across command depths, are usage errors.

### Debugging which credential source was used

Pass `--debug` to a Cloud resource command to print the resolved credential source (and the API URL) to stderr before the command runs. This works with and without `--json`.

Cloud notices on stderr are best-effort: if their reader goes away, the operation still runs and reports its outcome through the exit status.

When environment credentials are configured but a credentials file or explicit CLI flags take precedence, clickhousectl prints a one-line note to stderr. `cloud auth status` also marks the environment credentials as configured but inactive and identifies the source that outranked them.

`--url` is an advanced debugging override for targeting a non-production Cloud control plane. Release builds accept the option but omit it from `--help`; development builds show it. For `cloud service query`, set `CLICKHOUSE_CLOUD_QUERY_HOST` separately to override the Query API host.

### Ingest with ClickPipes

Use ClickPipes to replicate database changes or load files into ClickHouse Cloud. Both examples require API-key authentication and a running ClickHouse destination.
Expand Down
20 changes: 20 additions & 0 deletions crates/clickhouse-cloud-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,26 @@ callers must supply the new fields or use `..Default::default()`. The API requir
CDC settings and table mapping fields to match the replication method; snapshot
requests omit them.

`organization_assignable_roles_get_list(organization_id)` uses a CLI user Bearer
token to read the complete, unpaginated `ApiResponse<Vec<AssignableRole>>` array
from `/v1/organizations/{organizationId}/roles`. Basic clients fail locally with
`Error::AuthMismatch`; the server verifies the actual CLI user and organization
authority. `organization_roles_get_list` retains its administrative catalog API.
There is no catalog fallback or heuristic choice between the auth-dependent DTOs.
The analyzer allowlists the distinct typed helper for this existing route.

Assignable roles retain identity strings (including non-v4 system UUIDs), role
type, all ALLOW/DENY policies, permission/resource strings and optional tags.
`grants` strings, including REVOKE statements, are opaque; no SQL parsing or
filtering occurs. This menu describes read-time authority, not an approval promise.

Transport DTOs follow the library's all-`Option` compatibility policy: missing or
null required fields remain `None`, distinct from explicit empty arrays. Consumers
**must validate** result, role id/name/type/policies, and each policy's
allowDeny/permissions/resources before human **or JSON** success output. Never
convert absent fields into an empty/safe permission claim. Optional tags and their
fields retain absence; unknown enum strings round-trip losslessly.

`openapi_key_get_list(organization_id, limit, cursor)` returns one page of API keys.
Migrate existing calls by passing `None, None` for the new arguments. The server
accepts limits from 1–250 and defaults to 250; continue with the response's
Expand Down
36 changes: 36 additions & 0 deletions crates/clickhouse-cloud-api/src/client/organizations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ impl Client {
}
Ok(serde_json::from_str(&body_text)?)
}

/// Get organization details
pub async fn organization_get(
&self,
Expand Down Expand Up @@ -654,6 +655,41 @@ impl Client {
Ok(serde_json::from_str(&body_text)?)
}

/// Discover the complete, unpaginated caller-assignable role array.
///
/// Requires Bearer authentication; the server verifies the token's CLI user
/// identity and current organization authority. This is a read-time snapshot,
/// not an approval guarantee. Never falls back to the administrative catalog.
///
/// Transport fields tolerate missing/null values. Callers must validate the
/// required discovery fields before human or JSON success output: absent
/// result/policies/permissions/resources are not empty permission guarantees.
pub async fn organization_assignable_roles_get_list(
&self,
organization_id: &str,
) -> Result<ApiResponse<Vec<AssignableRole>>, Error> {
if !matches!(&self.auth, super::Auth::Bearer { .. }) {
return Err(Error::AuthMismatch(
"organization_assignable_roles_get_list requires a CLI user Bearer token".into(),
));
}
let path = format!("/v1/organizations/{organization_id}/roles");
let req = self.request(reqwest::Method::GET, &path);
let resp = req.send().await?;
let status = resp.status();
let body_text = resp.text().await?;
if !status.is_success() {
return Err(Error::Api {
status: status.as_u16(),
message: serde_json::from_str::<ApiResponse<serde_json::Value>>(&body_text)
.ok()
.and_then(|r| r.error)
.unwrap_or(body_text.clone()),
});
}
Ok(serde_json::from_str(&body_text)?)
}

/// List all available roles for an organization
pub async fn organization_roles_get_list(
&self,
Expand Down
7 changes: 4 additions & 3 deletions crates/clickhouse-cloud-api/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,10 @@ pub use query_api_endpoints::{
};
pub use quotas::{OrganizationQuota, OrganizationQuotaQuotacode, OrganizationQuotaScope};
pub use rbac::{
RBACPolicy, RBACPolicyAllowdeny, RBACPolicyCreateRequest, RBACPolicyCreateRequestAllowdeny,
RBACPolicyTags, RBACPolicyTagsResponse, RBACPolicyTagsRolev2, RBACRole, RBACRoleType,
RoleCreateRequest, RoleUpdateRequest,
AssignableRole, AssignableRolePolicy, AssignableRolePolicyTags, RBACPolicy,
RBACPolicyAllowdeny, RBACPolicyCreateRequest, RBACPolicyCreateRequestAllowdeny, RBACPolicyTags,
RBACPolicyTagsResponse, RBACPolicyTagsRolev2, RBACRole, RBACRoleType, RoleCreateRequest,
RoleUpdateRequest,
};
pub use saved_queries::{PublicSavedQuery, PublicSavedQueryListItem, PublicSavedQueryRequest};
pub use scim::{
Expand Down
40 changes: 40 additions & 0 deletions crates/clickhouse-cloud-api/src/models/rbac.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,46 @@ pub struct RBACRole {
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}

/// A caller-assignable role from verified CLI user discovery, not the administrative catalog.
///
/// Fields remain optional for transport compatibility. Before presenting a successful
/// discovery result, callers must require `id`, `name`, `type`, `policies`, and each
/// policy's effect, permissions and resources. Absence is not an empty permission set.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct AssignableRole {
/// Opaque identity string, including system identifiers that are not UUID v4.
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub r#type: Option<RBACRoleType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policies: Option<Vec<AssignableRolePolicy>>,
}

/// A complete policy in an assignable role; no effects or resource scopes are filtered.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct AssignableRolePolicy {
#[serde(rename = "allowDeny", skip_serializing_if = "Option::is_none")]
pub allow_deny: Option<RBACPolicyAllowdeny>,
#[serde(skip_serializing_if = "Option::is_none")]
pub permissions: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resources: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<AssignableRolePolicyTags>,
}

/// Meaningful policy tags. SQL statements are opaque, including GRANT and REVOKE text.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct AssignableRolePolicyTags {
#[serde(skip_serializing_if = "Option::is_none")]
pub grants: Option<Vec<String>>,
#[serde(rename = "roleV2", skip_serializing_if = "Option::is_none")]
pub role_v2: Option<RBACPolicyTagsRolev2>,
}

/// `RoleCreateRequest` from the ClickHouse Cloud API.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct RoleCreateRequest {
Expand Down
Loading
Loading