Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
267b5f5
Enforce OAuth read-only: block write commands, enhance 403 errors, up…
sdairs Apr 9, 2026
cf23133
Fix incorrect `cloud auth keys` reference in no-credentials error
sdairs Apr 9, 2026
6acf4d3
Exhaustive is_write_command tests covering every command variant
sdairs Apr 9, 2026
0364298
Link to API key creation docs in all auth error messages
sdairs Apr 9, 2026
01f364a
Hide --url flag unconditionally
sdairs Apr 9, 2026
9c9ca09
Add --oauth and --api-keys flags to logout for selective credential c…
sdairs Apr 9, 2026
50f0aeb
Add help text to logout explaining default clears all credentials
sdairs Apr 9, 2026
c9f2761
Simplify logout help: use after_help only, drop long_about
sdairs Apr 9, 2026
2548791
Remove duplicate agent context from logout help
sdairs Apr 9, 2026
1880e6d
Use CONTEXT FOR AGENTS only for logout after_help
sdairs Apr 9, 2026
fdcbbb4
Add API key creation docs link to cloud auth and login help
sdairs Apr 9, 2026
082b334
Remove redundant agent guidance from login help
sdairs Apr 9, 2026
62168a7
Change auth precedence: API keys before OAuth
sdairs Apr 9, 2026
39460ec
add signup command
kcmannem Apr 1, 2026
9e2162c
Merge pull request #78 from ClickHouse/cloud-auth-signup
sdairs Apr 9, 2026
5f56dfe
Add UTM tracking and agent context to signup command
sdairs Apr 9, 2026
0feb0e7
Use table output for auth status and bump version to 0.1.17
sdairs Apr 9, 2026
d3952ee
Update Cargo.lock for 0.1.17
sdairs Apr 9, 2026
585c586
Revert --url flag to debug-only visibility
sdairs Apr 9, 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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "clickhousectl"
version = "0.1.16"
version = "0.1.17"
edition = "2024"

[dependencies]
Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,19 +147,19 @@ Each named server has its own data directory, so servers are fully isolated from

## Authentication

Authenticate to ClickHouse Cloud using OAuth (browser-based) or API keys.
Authenticate to ClickHouse Cloud using OAuth (browser-based) or API keys. OAuth provides **read-only** access; API keys provide full **read/write** access.

### OAuth login (recommended)

> **Note:** Cloud OAuth requires a feature flag for your ClickHouse Cloud organization. Please reach out to support to request OAuth device flow auth for `clickhousectl`. You do not need this to use API keys generated from the SQL Console.
### OAuth login (read-only)

```bash
clickhousectl cloud auth login
```

This opens your browser for authentication via the OAuth device flow. Tokens are saved to `.clickhouse/tokens.json` (project-local).

### API key/secret
> **Note:** OAuth tokens provide **read-only** access. You can list and inspect resources (organizations, services, backups, etc.) but cannot create, modify, or delete them. For write operations, use API key authentication.

### API key/secret (required for write operations)

```bash
# Non-interactive (CI-friendly)
Expand All @@ -182,14 +182,16 @@ Or pass credentials directly via flags on any command:
clickhousectl cloud --api-key KEY --api-secret SECRET ...
```

Learn how to [create API keys](https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl).

### Auth status and logout

```bash
clickhousectl cloud auth status # Show current auth state
clickhousectl cloud auth status # Show current auth state (including read-only/read-write labels)
clickhousectl cloud auth logout # Clear all saved credentials (credentials.json & tokens.json)
```

Credential resolution order: CLI flags > OAuth tokens > `.clickhouse/credentials.json` > environment variables.
Credential resolution order: CLI flags > `.clickhouse/credentials.json` > environment variables > OAuth tokens.

## Cloud

Expand Down
8 changes: 7 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ CONTEXT FOR AGENTS:
1. Local: Install and interact with versions of ClickHouse to develop locally.
2. Cloud: Manage ClickHouse Cloud infrastructure and push local work to cloud.

Authentication: OAuth (`cloud auth login`) is read-only. For write operations (create, update,
delete), use API key auth: `cloud auth login --api-key X --api-secret Y`.

You can install the ClickHouse Agent Skills with:

`clickhousectl skills`
Expand All @@ -43,7 +46,10 @@ CONTEXT FOR AGENTS:
#[command(after_help = "\
CONTEXT FOR AGENTS:
Used for managing ClickHouse Cloud infrastructure. You need to have a ClickHouse Cloud account and be authenticated.
Agents should suggest the user to sign up at https://clickhouse.cloud, and then use `clickhousectl cloud auth login` to authenticate.
OAuth login (`cloud auth login`) is read-only — it can list and inspect resources but cannot create, modify, or delete.
For write operations, authenticate with API keys:
clickhousectl cloud auth login --api-key YOUR_KEY --api-secret YOUR_SECRET
If the user doesn't have an account, suggest `clickhousectl cloud auth signup` first.
Add --json to any cloud command for machine-readable output.
Typical workflow: `cloud auth login` → `cloud auth status` → `cloud org list` → `cloud service list`")]
Cloud(Box<CloudArgs>),
Expand Down
203 changes: 197 additions & 6 deletions src/cloud/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ pub enum AuthCommands {
/// Log in to ClickHouse Cloud
#[command(after_help = "\
CONTEXT FOR AGENTS:
Defaults to OAuth device flow (opens browser). Use --api-key and --api-secret for non-interactive login.
Agents should suggest the user to use the OAuth device flow, only using flags if explicitly asked, in CI or in fully autonomous environments.
Defaults to OAuth device flow (opens browser). OAuth tokens are READ-ONLY — they can list and
inspect resources but cannot create, modify, or delete.
For write operations (create, update, delete services, etc.), use --api-key and --api-secret.
Create API keys: https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl
Related: use `clickhousectl cloud auth status` to verify.")]
Login {
/// Log in by entering API key/secret interactively
Expand All @@ -48,10 +50,27 @@ CONTEXT FOR AGENTS:
#[arg(long)]
api_secret: Option<String>,
},
/// Log out and clear all saved credentials
Logout,
/// Log out and clear saved credentials
#[command(after_help = "\
CONTEXT FOR AGENTS:
With no flags, clears everything. Use --oauth to keep API keys, or --api-keys to keep OAuth tokens.")]
Logout {
/// Clear only OAuth tokens (keep API keys)
#[arg(long, conflicts_with = "api_keys")]
oauth: bool,

/// Clear only API keys (keep OAuth tokens)
#[arg(long, conflicts_with = "oauth")]
api_keys: bool,
},
/// Show current authentication status
Status,
/// Open the ClickHouse Cloud sign-up page in your browser
#[command(after_help = "\
CONTEXT FOR AGENTS:
Opens the ClickHouse Cloud sign-up page in the user's browser. This is an interactive flow —
it requires a human to complete sign-up in the browser. Do not use in fully autonomous or CI environments.")]
Signup,
}

#[derive(Args)]
Expand Down Expand Up @@ -82,8 +101,9 @@ pub enum CloudCommands {
/// Manage authentication (OAuth login, API keys)
#[command(after_help = "\
CONTEXT FOR AGENTS:
Use `login --api-key X --api-secret Y` for non-interactive auth.
Default `login` opens a browser (not agent-friendly).
Use `login --api-key X --api-secret Y` for full read/write access.
Default `login` opens a browser for OAuth (read-only access only — cannot create, modify, or delete resources).
Create API keys: https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl
`logout` clears all saved credentials (OAuth tokens and API keys).
Related: `clickhousectl cloud org list` to verify credentials work.")]
Auth {
Expand All @@ -108,6 +128,7 @@ CONTEXT FOR AGENTS:
CONTEXT FOR AGENTS:
Most commands need a service ID — get it from `clickhousectl cloud service list`.
Org ID is auto-detected if you have only one org; otherwise pass --org-id.
Write commands (create, delete, start, stop, update, scale) require API key auth — OAuth is read-only.
Use `client` to open a clickhouse-client session to a service.
Related: `clickhousectl cloud org list` for org IDs.")]
Service {
Expand Down Expand Up @@ -152,6 +173,80 @@ CONTEXT FOR AGENTS:
},
}

impl CloudCommands {
/// Returns true if this command performs a write/mutating operation.
/// OAuth (Bearer) auth is read-only and cannot execute write commands.
///
/// Every variant is explicitly matched — no wildcards — so the compiler
/// will error when a new command is added, forcing the developer to
/// classify it as read or write.
pub fn is_write_command(&self) -> bool {
match self {
CloudCommands::Auth { .. } => false,
CloudCommands::Org { command } => match command {
OrgCommands::List => false,
OrgCommands::Get { .. } => false,
OrgCommands::Prometheus { .. } => false,
OrgCommands::Usage { .. } => false,
OrgCommands::Update { .. } => true,
},
CloudCommands::Service { command } => match command {
ServiceCommands::List { .. } => false,
ServiceCommands::Get { .. } => false,
ServiceCommands::Client { .. } => false,
ServiceCommands::Prometheus { .. } => false,
ServiceCommands::Create { .. } => true,
ServiceCommands::Delete { .. } => true,
ServiceCommands::Start { .. } => true,
ServiceCommands::Stop { .. } => true,
ServiceCommands::Update { .. } => true,
ServiceCommands::Scale { .. } => true,
ServiceCommands::ResetPassword { .. } => true,
ServiceCommands::QueryEndpoint { command } => match command {
QueryEndpointCommands::Get { .. } => false,
QueryEndpointCommands::Create { .. } => true,
QueryEndpointCommands::Delete { .. } => true,
},
ServiceCommands::PrivateEndpoint { command } => match command {
PrivateEndpointCommands::Create { .. } => true,
PrivateEndpointCommands::GetConfig { .. } => false,
},
ServiceCommands::BackupConfig { command } => match command {
BackupConfigCommands::Get { .. } => false,
BackupConfigCommands::Update { .. } => true,
},
},
CloudCommands::Backup { command } => match command {
BackupCommands::List { .. } => false,
BackupCommands::Get { .. } => false,
},
CloudCommands::Member { command } => match command {
MemberCommands::List { .. } => false,
MemberCommands::Get { .. } => false,
MemberCommands::Update { .. } => true,
MemberCommands::Remove { .. } => true,
},
CloudCommands::Invitation { command } => match command {
InvitationCommands::List { .. } => false,
InvitationCommands::Get { .. } => false,
InvitationCommands::Create { .. } => true,
InvitationCommands::Delete { .. } => true,
},
CloudCommands::Key { command } => match command {
KeyCommands::List { .. } => false,
KeyCommands::Get { .. } => false,
KeyCommands::Create { .. } => true,
KeyCommands::Update { .. } => true,
KeyCommands::Delete { .. } => true,
},
CloudCommands::Activity { command } => match command {
ActivityCommands::List { .. } => false,
ActivityCommands::Get { .. } => false,
},
}
}
}

#[derive(Subcommand)]
pub enum OrgCommands {
/// List organizations
Expand Down Expand Up @@ -1405,4 +1500,100 @@ mod tests {
Err(err) => assert!(err.to_string().contains("expected YYYY-MM-DD")),
}
}

/// Helper to assert a command parsed from CLI args is classified correctly.
fn assert_write(args: &[&str], expected: bool) {
let cli = Cli::try_parse_from(args).unwrap();
let Commands::Cloud(cloud_args) = cli.command else {
panic!("expected cloud command");
};
assert_eq!(
cloud_args.command.is_write_command(),
expected,
"wrong classification for: {}",
args.join(" ")
);
}

#[test]
fn is_write_command_read_only_commands() {
// Org reads
assert_write(&["clickhousectl", "cloud", "org", "list"], false);
assert_write(&["clickhousectl", "cloud", "org", "get", "org-1"], false);
assert_write(&["clickhousectl", "cloud", "org", "prometheus", "org-1"], false);
assert_write(&["clickhousectl", "cloud", "org", "usage", "org-1", "--from-date", "2025-01-01", "--to-date", "2025-01-31"], false);

// Service reads
assert_write(&["clickhousectl", "cloud", "service", "list"], false);
assert_write(&["clickhousectl", "cloud", "service", "get", "svc-1"], false);
assert_write(&["clickhousectl", "cloud", "service", "client", "--id", "svc-1"], false);
assert_write(&["clickhousectl", "cloud", "service", "prometheus", "svc-1"], false);

// Backup reads
assert_write(&["clickhousectl", "cloud", "backup", "list", "svc-1"], false);
assert_write(&["clickhousectl", "cloud", "backup", "get", "svc-1", "bk-1"], false);

// Backup config read
assert_write(&["clickhousectl", "cloud", "service", "backup-config", "get", "svc-1"], false);

// Member reads
assert_write(&["clickhousectl", "cloud", "member", "list"], false);
assert_write(&["clickhousectl", "cloud", "member", "get", "usr-1"], false);

// Invitation reads
assert_write(&["clickhousectl", "cloud", "invitation", "list"], false);
assert_write(&["clickhousectl", "cloud", "invitation", "get", "inv-1"], false);

// Key reads
assert_write(&["clickhousectl", "cloud", "key", "list"], false);
assert_write(&["clickhousectl", "cloud", "key", "get", "key-1"], false);

// Activity reads
assert_write(&["clickhousectl", "cloud", "activity", "list"], false);
assert_write(&["clickhousectl", "cloud", "activity", "get", "act-1"], false);

// Query endpoint read
assert_write(&["clickhousectl", "cloud", "service", "query-endpoint", "get", "svc-1"], false);

// Private endpoint read
assert_write(&["clickhousectl", "cloud", "service", "private-endpoint", "get-config", "svc-1"], false);
}

#[test]
fn is_write_command_destructive_commands() {
// Org write
assert_write(&["clickhousectl", "cloud", "org", "update", "org-1", "--name", "new"], true);

// Service writes
assert_write(&["clickhousectl", "cloud", "service", "create", "--name", "s", "--provider", "aws", "--region", "us-east-1"], true);
assert_write(&["clickhousectl", "cloud", "service", "delete", "svc-1"], true);
assert_write(&["clickhousectl", "cloud", "service", "start", "svc-1"], true);
assert_write(&["clickhousectl", "cloud", "service", "stop", "svc-1"], true);
assert_write(&["clickhousectl", "cloud", "service", "update", "svc-1", "--name", "new"], true);
assert_write(&["clickhousectl", "cloud", "service", "scale", "svc-1", "--num-replicas", "2"], true);
assert_write(&["clickhousectl", "cloud", "service", "reset-password", "svc-1"], true);

// Backup config write
assert_write(&["clickhousectl", "cloud", "service", "backup-config", "update", "svc-1", "--backup-period-hours", "12"], true);

// Member writes
assert_write(&["clickhousectl", "cloud", "member", "update", "usr-1", "--role-id", "r1"], true);
assert_write(&["clickhousectl", "cloud", "member", "remove", "usr-1"], true);

// Invitation writes
assert_write(&["clickhousectl", "cloud", "invitation", "create", "--email", "a@b.com", "--role-id", "r1"], true);
assert_write(&["clickhousectl", "cloud", "invitation", "delete", "inv-1"], true);

// Key writes
assert_write(&["clickhousectl", "cloud", "key", "create", "--name", "k"], true);
assert_write(&["clickhousectl", "cloud", "key", "update", "key-1", "--name", "new"], true);
assert_write(&["clickhousectl", "cloud", "key", "delete", "key-1"], true);

// Query endpoint writes
assert_write(&["clickhousectl", "cloud", "service", "query-endpoint", "create", "svc-1"], true);
assert_write(&["clickhousectl", "cloud", "service", "query-endpoint", "delete", "svc-1"], true);

// Private endpoint write
assert_write(&["clickhousectl", "cloud", "service", "private-endpoint", "create", "svc-1", "--endpoint-id", "ep-1"], true);
}
}
Loading
Loading