@@ -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 ) ]
156251pub 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