Skip to content

Commit 5802887

Browse files
authored
Merge pull request #99 from ClickHouse/issue-98-oauth-read-only
Enforce OAuth read-only for cloud commands
2 parents 77cadcf + 585c586 commit 5802887

7 files changed

Lines changed: 387 additions & 75 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "clickhousectl"
3-
version = "0.1.16"
3+
version = "0.1.17"
44
edition = "2024"
55

66
[dependencies]

README.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -147,19 +147,19 @@ Each named server has its own data directory, so servers are fully isolated from
147147

148148
## Authentication
149149

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

152-
### OAuth login (recommended)
153-
154-
> **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.
152+
### OAuth login (read-only)
155153

156154
```bash
157155
clickhousectl cloud auth login
158156
```
159157

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

162-
### API key/secret
160+
> **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.
161+
162+
### API key/secret (required for write operations)
163163

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

185+
Learn how to [create API keys](https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl).
186+
185187
### Auth status and logout
186188

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

192-
Credential resolution order: CLI flags > OAuth tokens > `.clickhouse/credentials.json` > environment variables.
194+
Credential resolution order: CLI flags > `.clickhouse/credentials.json` > environment variables > OAuth tokens.
193195

194196
## Cloud
195197

src/cli.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ CONTEXT FOR AGENTS:
1919
1. Local: Install and interact with versions of ClickHouse to develop locally.
2020
2. Cloud: Manage ClickHouse Cloud infrastructure and push local work to cloud.
2121
22+
Authentication: OAuth (`cloud auth login`) is read-only. For write operations (create, update,
23+
delete), use API key auth: `cloud auth login --api-key X --api-secret Y`.
24+
2225
You can install the ClickHouse Agent Skills with:
2326
2427
`clickhousectl skills`
@@ -43,7 +46,10 @@ CONTEXT FOR AGENTS:
4346
#[command(after_help = "\
4447
CONTEXT FOR AGENTS:
4548
Used for managing ClickHouse Cloud infrastructure. You need to have a ClickHouse Cloud account and be authenticated.
46-
Agents should suggest the user to sign up at https://clickhouse.cloud, and then use `clickhousectl cloud auth login` to authenticate.
49+
OAuth login (`cloud auth login`) is read-only — it can list and inspect resources but cannot create, modify, or delete.
50+
For write operations, authenticate with API keys:
51+
clickhousectl cloud auth login --api-key YOUR_KEY --api-secret YOUR_SECRET
52+
If the user doesn't have an account, suggest `clickhousectl cloud auth signup` first.
4753
Add --json to any cloud command for machine-readable output.
4854
Typical workflow: `cloud auth login` → `cloud auth status` → `cloud org list` → `cloud service list`")]
4955
Cloud(Box<CloudArgs>),

src/cloud/cli.rs

Lines changed: 197 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ pub enum AuthCommands {
3232
/// Log in to ClickHouse Cloud
3333
#[command(after_help = "\
3434
CONTEXT FOR AGENTS:
35-
Defaults to OAuth device flow (opens browser). Use --api-key and --api-secret for non-interactive login.
36-
Agents should suggest the user to use the OAuth device flow, only using flags if explicitly asked, in CI or in fully autonomous environments.
35+
Defaults to OAuth device flow (opens browser). OAuth tokens are READ-ONLY — they can list and
36+
inspect resources but cannot create, modify, or delete.
37+
For write operations (create, update, delete services, etc.), use --api-key and --api-secret.
38+
Create API keys: https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl
3739
Related: use `clickhousectl cloud auth status` to verify.")]
3840
Login {
3941
/// Log in by entering API key/secret interactively
@@ -48,10 +50,27 @@ CONTEXT FOR AGENTS:
4850
#[arg(long)]
4951
api_secret: Option<String>,
5052
},
51-
/// Log out and clear all saved credentials
52-
Logout,
53+
/// Log out and clear saved credentials
54+
#[command(after_help = "\
55+
CONTEXT FOR AGENTS:
56+
With no flags, clears everything. Use --oauth to keep API keys, or --api-keys to keep OAuth tokens.")]
57+
Logout {
58+
/// Clear only OAuth tokens (keep API keys)
59+
#[arg(long, conflicts_with = "api_keys")]
60+
oauth: bool,
61+
62+
/// Clear only API keys (keep OAuth tokens)
63+
#[arg(long, conflicts_with = "oauth")]
64+
api_keys: bool,
65+
},
5366
/// Show current authentication status
5467
Status,
68+
/// Open the ClickHouse Cloud sign-up page in your browser
69+
#[command(after_help = "\
70+
CONTEXT FOR AGENTS:
71+
Opens the ClickHouse Cloud sign-up page in the user's browser. This is an interactive flow —
72+
it requires a human to complete sign-up in the browser. Do not use in fully autonomous or CI environments.")]
73+
Signup,
5574
}
5675

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

176+
impl CloudCommands {
177+
/// Returns true if this command performs a write/mutating operation.
178+
/// OAuth (Bearer) auth is read-only and cannot execute write commands.
179+
///
180+
/// Every variant is explicitly matched — no wildcards — so the compiler
181+
/// will error when a new command is added, forcing the developer to
182+
/// classify it as read or write.
183+
pub fn is_write_command(&self) -> bool {
184+
match self {
185+
CloudCommands::Auth { .. } => false,
186+
CloudCommands::Org { command } => match command {
187+
OrgCommands::List => false,
188+
OrgCommands::Get { .. } => false,
189+
OrgCommands::Prometheus { .. } => false,
190+
OrgCommands::Usage { .. } => false,
191+
OrgCommands::Update { .. } => true,
192+
},
193+
CloudCommands::Service { command } => match command {
194+
ServiceCommands::List { .. } => false,
195+
ServiceCommands::Get { .. } => false,
196+
ServiceCommands::Client { .. } => false,
197+
ServiceCommands::Prometheus { .. } => false,
198+
ServiceCommands::Create { .. } => true,
199+
ServiceCommands::Delete { .. } => true,
200+
ServiceCommands::Start { .. } => true,
201+
ServiceCommands::Stop { .. } => true,
202+
ServiceCommands::Update { .. } => true,
203+
ServiceCommands::Scale { .. } => true,
204+
ServiceCommands::ResetPassword { .. } => true,
205+
ServiceCommands::QueryEndpoint { command } => match command {
206+
QueryEndpointCommands::Get { .. } => false,
207+
QueryEndpointCommands::Create { .. } => true,
208+
QueryEndpointCommands::Delete { .. } => true,
209+
},
210+
ServiceCommands::PrivateEndpoint { command } => match command {
211+
PrivateEndpointCommands::Create { .. } => true,
212+
PrivateEndpointCommands::GetConfig { .. } => false,
213+
},
214+
ServiceCommands::BackupConfig { command } => match command {
215+
BackupConfigCommands::Get { .. } => false,
216+
BackupConfigCommands::Update { .. } => true,
217+
},
218+
},
219+
CloudCommands::Backup { command } => match command {
220+
BackupCommands::List { .. } => false,
221+
BackupCommands::Get { .. } => false,
222+
},
223+
CloudCommands::Member { command } => match command {
224+
MemberCommands::List { .. } => false,
225+
MemberCommands::Get { .. } => false,
226+
MemberCommands::Update { .. } => true,
227+
MemberCommands::Remove { .. } => true,
228+
},
229+
CloudCommands::Invitation { command } => match command {
230+
InvitationCommands::List { .. } => false,
231+
InvitationCommands::Get { .. } => false,
232+
InvitationCommands::Create { .. } => true,
233+
InvitationCommands::Delete { .. } => true,
234+
},
235+
CloudCommands::Key { command } => match command {
236+
KeyCommands::List { .. } => false,
237+
KeyCommands::Get { .. } => false,
238+
KeyCommands::Create { .. } => true,
239+
KeyCommands::Update { .. } => true,
240+
KeyCommands::Delete { .. } => true,
241+
},
242+
CloudCommands::Activity { command } => match command {
243+
ActivityCommands::List { .. } => false,
244+
ActivityCommands::Get { .. } => false,
245+
},
246+
}
247+
}
248+
}
249+
155250
#[derive(Subcommand)]
156251
pub enum OrgCommands {
157252
/// List organizations
@@ -1405,4 +1500,100 @@ mod tests {
14051500
Err(err) => assert!(err.to_string().contains("expected YYYY-MM-DD")),
14061501
}
14071502
}
1503+
1504+
/// Helper to assert a command parsed from CLI args is classified correctly.
1505+
fn assert_write(args: &[&str], expected: bool) {
1506+
let cli = Cli::try_parse_from(args).unwrap();
1507+
let Commands::Cloud(cloud_args) = cli.command else {
1508+
panic!("expected cloud command");
1509+
};
1510+
assert_eq!(
1511+
cloud_args.command.is_write_command(),
1512+
expected,
1513+
"wrong classification for: {}",
1514+
args.join(" ")
1515+
);
1516+
}
1517+
1518+
#[test]
1519+
fn is_write_command_read_only_commands() {
1520+
// Org reads
1521+
assert_write(&["clickhousectl", "cloud", "org", "list"], false);
1522+
assert_write(&["clickhousectl", "cloud", "org", "get", "org-1"], false);
1523+
assert_write(&["clickhousectl", "cloud", "org", "prometheus", "org-1"], false);
1524+
assert_write(&["clickhousectl", "cloud", "org", "usage", "org-1", "--from-date", "2025-01-01", "--to-date", "2025-01-31"], false);
1525+
1526+
// Service reads
1527+
assert_write(&["clickhousectl", "cloud", "service", "list"], false);
1528+
assert_write(&["clickhousectl", "cloud", "service", "get", "svc-1"], false);
1529+
assert_write(&["clickhousectl", "cloud", "service", "client", "--id", "svc-1"], false);
1530+
assert_write(&["clickhousectl", "cloud", "service", "prometheus", "svc-1"], false);
1531+
1532+
// Backup reads
1533+
assert_write(&["clickhousectl", "cloud", "backup", "list", "svc-1"], false);
1534+
assert_write(&["clickhousectl", "cloud", "backup", "get", "svc-1", "bk-1"], false);
1535+
1536+
// Backup config read
1537+
assert_write(&["clickhousectl", "cloud", "service", "backup-config", "get", "svc-1"], false);
1538+
1539+
// Member reads
1540+
assert_write(&["clickhousectl", "cloud", "member", "list"], false);
1541+
assert_write(&["clickhousectl", "cloud", "member", "get", "usr-1"], false);
1542+
1543+
// Invitation reads
1544+
assert_write(&["clickhousectl", "cloud", "invitation", "list"], false);
1545+
assert_write(&["clickhousectl", "cloud", "invitation", "get", "inv-1"], false);
1546+
1547+
// Key reads
1548+
assert_write(&["clickhousectl", "cloud", "key", "list"], false);
1549+
assert_write(&["clickhousectl", "cloud", "key", "get", "key-1"], false);
1550+
1551+
// Activity reads
1552+
assert_write(&["clickhousectl", "cloud", "activity", "list"], false);
1553+
assert_write(&["clickhousectl", "cloud", "activity", "get", "act-1"], false);
1554+
1555+
// Query endpoint read
1556+
assert_write(&["clickhousectl", "cloud", "service", "query-endpoint", "get", "svc-1"], false);
1557+
1558+
// Private endpoint read
1559+
assert_write(&["clickhousectl", "cloud", "service", "private-endpoint", "get-config", "svc-1"], false);
1560+
}
1561+
1562+
#[test]
1563+
fn is_write_command_destructive_commands() {
1564+
// Org write
1565+
assert_write(&["clickhousectl", "cloud", "org", "update", "org-1", "--name", "new"], true);
1566+
1567+
// Service writes
1568+
assert_write(&["clickhousectl", "cloud", "service", "create", "--name", "s", "--provider", "aws", "--region", "us-east-1"], true);
1569+
assert_write(&["clickhousectl", "cloud", "service", "delete", "svc-1"], true);
1570+
assert_write(&["clickhousectl", "cloud", "service", "start", "svc-1"], true);
1571+
assert_write(&["clickhousectl", "cloud", "service", "stop", "svc-1"], true);
1572+
assert_write(&["clickhousectl", "cloud", "service", "update", "svc-1", "--name", "new"], true);
1573+
assert_write(&["clickhousectl", "cloud", "service", "scale", "svc-1", "--num-replicas", "2"], true);
1574+
assert_write(&["clickhousectl", "cloud", "service", "reset-password", "svc-1"], true);
1575+
1576+
// Backup config write
1577+
assert_write(&["clickhousectl", "cloud", "service", "backup-config", "update", "svc-1", "--backup-period-hours", "12"], true);
1578+
1579+
// Member writes
1580+
assert_write(&["clickhousectl", "cloud", "member", "update", "usr-1", "--role-id", "r1"], true);
1581+
assert_write(&["clickhousectl", "cloud", "member", "remove", "usr-1"], true);
1582+
1583+
// Invitation writes
1584+
assert_write(&["clickhousectl", "cloud", "invitation", "create", "--email", "a@b.com", "--role-id", "r1"], true);
1585+
assert_write(&["clickhousectl", "cloud", "invitation", "delete", "inv-1"], true);
1586+
1587+
// Key writes
1588+
assert_write(&["clickhousectl", "cloud", "key", "create", "--name", "k"], true);
1589+
assert_write(&["clickhousectl", "cloud", "key", "update", "key-1", "--name", "new"], true);
1590+
assert_write(&["clickhousectl", "cloud", "key", "delete", "key-1"], true);
1591+
1592+
// Query endpoint writes
1593+
assert_write(&["clickhousectl", "cloud", "service", "query-endpoint", "create", "svc-1"], true);
1594+
assert_write(&["clickhousectl", "cloud", "service", "query-endpoint", "delete", "svc-1"], true);
1595+
1596+
// Private endpoint write
1597+
assert_write(&["clickhousectl", "cloud", "service", "private-endpoint", "create", "svc-1", "--endpoint-id", "ep-1"], true);
1598+
}
14081599
}

0 commit comments

Comments
 (0)