client_management_cli.rs (12609B)
1 //! OAuth2 Gateway CLI 2 //! 3 //! Command-line tool for managing OAuth2 Gateway clients. 4 //! 5 //! Set DATABASE_URL environment variable to connect to the database. 6 //! Usage: 7 //! client-mgmt client list 8 //! client-mgmt client show <client_id> 9 //! client-mgmt client create --client-id <id> --secret <secret> ... 10 //! client-mgmt client update <client_id> --webhook-url <url> 11 //! client-mgmt client delete <client_id> 12 13 use anyhow::{Context, Result}; 14 use clap::{Parser, Subcommand}; 15 use ini::Ini; 16 use oauth2_gateway::db; 17 use std::env; 18 use std::collections::HashSet; 19 20 #[derive(Parser, Debug)] 21 #[command(name = "client-mgmt")] 22 23 #[command(version)] 24 #[command(about = "OAuth2 Gateway client management CLI")] 25 #[command(after_help = "Environment variables:\n DATABASE_URL PostgreSQL connection string (required)")] 26 struct Args { 27 #[command(subcommand)] 28 command: Commands, 29 } 30 31 #[derive(Subcommand, Debug)] 32 enum Commands { 33 List, 34 35 Show { 36 client_id: String, 37 }, 38 39 Create { 40 /// Unique client identifier 41 #[arg(long)] 42 client_id: String, 43 44 /// Client secret (stored as hash) 45 #[arg(long)] 46 secret: String, 47 48 /// Webhook URL for notifications 49 #[arg(long)] 50 webhook_url: String, 51 52 /// Swiyu verifier base URL 53 #[arg(long)] 54 verifier_url: String, 55 56 /// Verifier management API path (default: /management/api/verifications) 57 #[arg(long)] 58 verifier_api_path: Option<String>, 59 60 /// Default redirect URI for OAuth2 flow 61 #[arg(long)] 62 redirect_uri: Option<String>, 63 64 /// Comma-separated list of accepted issuer DIDs 65 #[arg(long)] 66 accepted_issuer_dids: Option<String>, 67 }, 68 69 Update { 70 client_id: String, 71 72 #[arg(long)] 73 webhook_url: Option<String>, 74 75 #[arg(long)] 76 verifier_url: Option<String>, 77 78 #[arg(long)] 79 verifier_api_path: Option<String>, 80 81 #[arg(long)] 82 redirect_uri: Option<String>, 83 84 #[arg(long)] 85 accepted_issuer_dids: Option<String>, 86 }, 87 88 /// Sync clients from a configuration file 89 Sync { 90 /// Path to clients.conf file 91 config_file: String, 92 93 /// Remove clients not in config file 94 #[arg(long)] 95 prune: bool, 96 }, 97 98 /// Delete a client (WARNING: cascades to all sessions) 99 Delete { 100 client_id: String, 101 102 #[arg(long, short = 'y')] 103 yes: bool, 104 }, 105 } 106 107 #[tokio::main] 108 async fn main() -> Result<()> { 109 // Load .env (ignore if missing) 110 let _ = dotenvy::dotenv(); 111 112 let args = Args::parse(); 113 114 let database_url = env::var("DATABASE_URL") 115 .context("DATABASE_URL environment variable not set")?; 116 117 let pool = db::create_pool(&database_url) 118 .await 119 .context("Failed to connect to database")?; 120 121 match args.command { 122 Commands::List => cmd_list_clients(&pool).await?, 123 Commands::Show { client_id } => cmd_show_client(&pool,&client_id).await?, 124 Commands::Create { 125 client_id, 126 secret, 127 webhook_url, 128 verifier_url, 129 verifier_api_path, 130 redirect_uri, 131 accepted_issuer_dids, 132 } => { 133 cmd_create_client( 134 &pool, 135 &client_id, 136 &secret, 137 &webhook_url, 138 &verifier_url, 139 verifier_api_path.as_deref(), 140 redirect_uri.as_deref(), 141 accepted_issuer_dids.as_deref(), 142 ) 143 .await? 144 } 145 Commands::Update { 146 client_id, 147 webhook_url, 148 verifier_url, 149 verifier_api_path, 150 redirect_uri, 151 accepted_issuer_dids, 152 } => { 153 cmd_update_client( 154 &pool, 155 &client_id, 156 webhook_url.as_deref(), 157 verifier_url.as_deref(), 158 verifier_api_path.as_deref(), 159 redirect_uri.as_deref(), 160 accepted_issuer_dids.as_deref(), 161 ) 162 .await? 163 } 164 Commands::Sync { config_file, prune } => { 165 cmd_sync_clients(&pool, &config_file, prune).await? 166 } 167 Commands::Delete { client_id, yes } => { 168 cmd_delete_client(&pool, &client_id, yes).await? 169 } 170 } 171 172 Ok(()) 173 } 174 175 fn print_client_details(client: &db::clients::Client) { 176 use chrono::Local; 177 178 println!("{}", "=".repeat(60)); 179 println!("UUID: {}", client.id); 180 println!("Client ID: {}", client.client_id); 181 println!("Secret Hash: {}...", &client.secret_hash[..20.min(client.secret_hash.len())]); 182 println!("Webhook URL: {}", client.webhook_url); 183 println!("Verifier URL: {}", client.verifier_url); 184 println!("Verifier API Path: {}", client.verifier_management_api_path); 185 println!("Redirect URI: {}", client.redirect_uri.as_deref().unwrap_or("(not set)")); 186 println!("Accepted Issuer DIDs: {}", client.accepted_issuer_dids.as_deref().unwrap_or("(not set)")); 187 println!("Created: {}", client.created_at.with_timezone(&Local)); 188 println!("Updated: {}", client.updated_at.with_timezone(&Local)); 189 } 190 191 async fn cmd_list_clients(pool: &sqlx::PgPool) -> Result<()> { 192 let clients = db::clients::list_clients(pool).await?; 193 194 if clients.is_empty() { 195 println!("No clients registered."); 196 return Ok(()); 197 } 198 199 let total = clients.len(); 200 201 for (i, client) in clients.iter().enumerate() { 202 if i > 0 { 203 println!(); 204 } 205 print_client_details(&client); 206 } 207 208 println!(); 209 println!("{}", "=".repeat(60)); 210 println!("Total clients: {}", total); 211 212 Ok(()) 213 } 214 215 async fn cmd_show_client(pool: &sqlx::PgPool, client_id: &str) -> Result<()> { 216 let client = db::clients::get_client_by_id(pool, client_id) 217 .await? 218 .ok_or_else(|| anyhow::anyhow!("Client not found: {}", client_id))?; 219 220 print_client_details(&client); 221 222 Ok(()) 223 } 224 225 async fn cmd_create_client( 226 pool: &sqlx::PgPool, 227 client_id: &str, 228 secret: &str, 229 webhook_url: &str, 230 verifier_url: &str, 231 verifier_api_path: Option<&str>, 232 redirect_uri: Option<&str>, 233 accepted_issuer_dids: Option<&str>, 234 ) -> Result<()> { 235 let client = db::clients::register_client( 236 pool, 237 client_id, 238 secret, 239 webhook_url, 240 verifier_url, 241 verifier_api_path, 242 redirect_uri, 243 accepted_issuer_dids, 244 ) 245 .await 246 .context("Failed to create client")?; 247 248 println!("Client created successfully."); 249 println!(); 250 print_client_details(&client); 251 252 Ok(()) 253 } 254 255 async fn cmd_update_client( 256 pool: &sqlx::PgPool, 257 client_id: &str, 258 webhook_url: Option<&str>, 259 verifier_url: Option<&str>, 260 verifier_api_path: Option<&str>, 261 redirect_uri: Option<&str>, 262 accepted_issuer_dids: Option<&str>, 263 ) -> Result<()> { 264 if webhook_url.is_none() && verifier_url.is_none() && verifier_api_path.is_none() 265 && redirect_uri.is_none() && accepted_issuer_dids.is_none() { 266 anyhow::bail!("No fields to update. Specify at least one of: --webhook-url, --verifier-url, --verifier-api-path, --redirect-uri, --accepted-issuer-dids"); 267 } 268 269 let client = db::clients::get_client_by_id(pool, client_id) 270 .await? 271 .ok_or_else(|| anyhow::anyhow!("Client not found: {}", client_id))?; 272 273 let updated = db::clients::update_client( 274 pool, 275 client.id, 276 webhook_url, 277 verifier_url, 278 verifier_api_path, 279 redirect_uri, 280 accepted_issuer_dids, 281 ) 282 .await 283 .context("Failed to update client")?; 284 285 println!("Client updated successfully."); 286 println!(); 287 print_client_details(&updated); 288 289 Ok(()) 290 } 291 292 async fn cmd_delete_client(pool: &sqlx::PgPool, client_id: &str, skip_confirm: bool) -> Result<()> { 293 let client = db::clients::get_client_by_id(pool, client_id) 294 .await? 295 .ok_or_else(|| anyhow::anyhow!("Client not found: {}", client_id))?; 296 297 if !skip_confirm { 298 println!("WARNING: This will delete client '{}' and ALL associated data:", client_id); 299 println!(" - All sessions"); 300 println!(" - All tokens"); 301 println!(" - All pending webhooks"); 302 println!(); 303 print!("Type 'yes' to confirm: "); 304 305 use std::io::{self, Write}; 306 io::stdout().flush()?; 307 308 let mut input = String::new(); 309 io::stdin().read_line(&mut input)?; 310 311 if input.trim() != "yes" { 312 println!("Aborted."); 313 return Ok(()); 314 } 315 } 316 317 let deleted = db::clients::delete_client(pool, client.id).await?; 318 319 if deleted { 320 println!("Client '{}' deleted successfully.", client_id); 321 } else { 322 println!("Client not found (may have been deleted already)."); 323 } 324 325 Ok(()) 326 } 327 328 async fn cmd_sync_clients(pool: &sqlx::PgPool, config_file: &str, prune: bool) -> Result<()> { 329 println!("Loading clients from: {}", config_file); 330 331 let ini = Ini::load_from_file(config_file) 332 .context("Failed to load configuration file")?; 333 334 let mut synced_client_ids = HashSet::new(); 335 let mut created_count = 0; 336 let mut updated_count = 0; 337 338 for (section_name, properties) in ini.iter() { 339 let section_name = match section_name { 340 Some(name) => name, 341 None => continue, 342 }; 343 344 println!("\nProcessing section: [{}]", section_name); 345 346 let client_id = properties.get("client_id") 347 .ok_or_else(|| anyhow::anyhow!("Missing client_id in section [{}]", section_name))?; 348 let client_secret = properties.get("client_secret") 349 .ok_or_else(|| anyhow::anyhow!("Missing client_secret in section [{}]", section_name))?; 350 let webhook_url = properties.get("webhook_url") 351 .ok_or_else(|| anyhow::anyhow!("Missing webhook_url in section [{}]", section_name))?; 352 let verifier_url = properties.get("verifier_url") 353 .ok_or_else(|| anyhow::anyhow!("Missing verifier_url in section [{}]", section_name))?; 354 355 let verifier_api_path = properties.get("verifier_management_api_path"); 356 let redirect_uri = properties.get("redirect_uri"); 357 let accepted_issuer_dids = properties.get("accepted_issuer_dids"); 358 359 synced_client_ids.insert(client_id.to_string()); 360 361 let existing_client = db::clients::get_client_by_id(pool, client_id).await?; 362 363 match existing_client { 364 Some(existing) => { 365 println!(" Client '{}' already exists, updating...", client_id); 366 db::clients::update_client( 367 pool, 368 existing.id, 369 Some(webhook_url), 370 Some(verifier_url), 371 verifier_api_path, 372 redirect_uri, 373 accepted_issuer_dids, 374 ) 375 .await 376 .context(format!("Failed to update client '{}'", client_id))?; 377 updated_count += 1; 378 println!(" Updated client '{}'", client_id); 379 } 380 None => { 381 println!(" Creating new client '{}'...", client_id); 382 db::clients::register_client( 383 pool, 384 client_id, 385 client_secret, 386 webhook_url, 387 verifier_url, 388 verifier_api_path, 389 redirect_uri, 390 accepted_issuer_dids, 391 ) 392 .await 393 .context(format!("Failed to create client '{}'", client_id))?; 394 created_count += 1; 395 println!(" Created client '{}'", client_id); 396 } 397 } 398 } 399 400 if prune { 401 println!("\nPruning clients not in configuration file..."); 402 let all_clients = db::clients::list_clients(pool).await?; 403 let mut pruned_count = 0; 404 405 for client in all_clients { 406 if !synced_client_ids.contains(&client.client_id) { 407 println!(" Deleting client '{}'...", client.client_id); 408 db::clients::delete_client(pool, client.id).await?; 409 pruned_count += 1; 410 } 411 } 412 println!("Pruned {} client(s)", pruned_count); 413 } 414 415 println!("\nSync complete:"); 416 println!(" Created: {}", created_count); 417 println!(" Updated: {}", updated_count); 418 419 Ok(()) 420 }