scim.rs
1 //! SCIM provisioning server management commands. 2 3 use std::net::SocketAddr; 4 use std::path::PathBuf; 5 6 use anyhow::{Context, Result}; 7 use clap::{Parser, Subcommand}; 8 9 use crate::commands::executable::ExecutableCommand; 10 use crate::config::CliConfig; 11 12 /// Manage the SCIM provisioning server. 13 #[derive(Parser, Debug, Clone)] 14 #[command(name = "scim", about = "SCIM 2.0 provisioning for agent identities")] 15 pub struct ScimCommand { 16 #[command(subcommand)] 17 pub command: ScimSubcommand, 18 } 19 20 #[derive(Subcommand, Debug, Clone)] 21 pub enum ScimSubcommand { 22 /// Start the SCIM provisioning server. 23 Serve(ScimServeCommand), 24 /// Zero-config quickstart: temp DB + test tenant + running server. 25 Quickstart(ScimQuickstartCommand), 26 /// Validate the full SCIM pipeline: create -> get -> patch -> delete. 27 TestConnection(ScimTestConnectionCommand), 28 /// List SCIM tenants. 29 Tenants(ScimTenantsCommand), 30 /// Generate a new bearer token for an IdP tenant. 31 AddTenant(ScimAddTenantCommand), 32 /// Rotate bearer token for an existing tenant. 33 RotateToken(ScimRotateTokenCommand), 34 /// Show SCIM sync state for debugging. 35 Status(ScimStatusCommand), 36 } 37 38 /// Start the SCIM provisioning server (production mode). 39 #[derive(Parser, Debug, Clone)] 40 pub struct ScimServeCommand { 41 /// Listen address. 42 #[arg(long, default_value = "0.0.0.0:3301")] 43 pub bind: SocketAddr, 44 /// PostgreSQL connection URL. 45 #[arg(long)] 46 pub database_url: String, 47 /// Path to the Auths registry Git repository. 48 #[arg(long)] 49 pub registry_path: Option<PathBuf>, 50 /// Log level. 51 #[arg(long, default_value = "info")] 52 pub log_level: String, 53 /// Enable test mode (auto-tenant, relaxed TLS). 54 #[arg(long)] 55 pub test_mode: bool, 56 } 57 58 /// Zero-config quickstart with copy-paste curl examples. 59 #[derive(Parser, Debug, Clone)] 60 pub struct ScimQuickstartCommand { 61 /// Listen address. 62 #[arg(long, default_value = "0.0.0.0:3301")] 63 pub bind: SocketAddr, 64 } 65 66 /// Validate the full SCIM pipeline against a running server. 67 #[derive(Parser, Debug, Clone)] 68 pub struct ScimTestConnectionCommand { 69 /// Server URL. 70 #[arg(long, default_value = "http://localhost:3301")] 71 pub url: String, 72 /// Bearer token. 73 #[arg(long)] 74 pub token: String, 75 } 76 77 /// List all SCIM tenants. 78 #[derive(Parser, Debug, Clone)] 79 pub struct ScimTenantsCommand { 80 /// PostgreSQL connection URL. 81 #[arg(long)] 82 pub database_url: String, 83 /// Output as JSON. 84 #[arg(long)] 85 pub json: bool, 86 } 87 88 /// Generate a new bearer token for an IdP tenant. 89 #[derive(Parser, Debug, Clone)] 90 pub struct ScimAddTenantCommand { 91 /// Tenant name. 92 #[arg(long)] 93 pub name: String, 94 /// PostgreSQL connection URL. 95 #[arg(long)] 96 pub database_url: String, 97 /// Token expiry duration (e.g., 90d, 365d). Omit for no expiry. 98 #[arg(long)] 99 pub expires_in: Option<String>, 100 } 101 102 /// Rotate bearer token for an existing tenant. 103 #[derive(Parser, Debug, Clone)] 104 pub struct ScimRotateTokenCommand { 105 /// Tenant name. 106 #[arg(long)] 107 pub name: String, 108 /// PostgreSQL connection URL. 109 #[arg(long)] 110 pub database_url: String, 111 /// Token expiry duration (e.g., 90d, 365d). 112 #[arg(long)] 113 pub expires_in: Option<String>, 114 } 115 116 /// Show SCIM sync state statistics. 117 #[derive(Parser, Debug, Clone)] 118 pub struct ScimStatusCommand { 119 /// PostgreSQL connection URL. 120 #[arg(long)] 121 pub database_url: String, 122 /// Output as JSON. 123 #[arg(long)] 124 pub json: bool, 125 } 126 127 fn handle_scim(cmd: ScimCommand) -> Result<()> { 128 match cmd.command { 129 ScimSubcommand::Serve(serve) => handle_serve(serve), 130 ScimSubcommand::Quickstart(qs) => handle_quickstart(qs), 131 ScimSubcommand::TestConnection(tc) => handle_test_connection(tc), 132 ScimSubcommand::Tenants(_) => { 133 println!("SCIM tenant listing requires database connection."); 134 println!("Run: auths-scim-server with DATABASE_URL set."); 135 Ok(()) 136 } 137 ScimSubcommand::AddTenant(_) => { 138 println!("Tenant management requires database connection."); 139 println!("Run: auths-scim-server with DATABASE_URL set."); 140 Ok(()) 141 } 142 ScimSubcommand::RotateToken(_) => { 143 println!("Token rotation requires database connection."); 144 println!("Run: auths-scim-server with DATABASE_URL set."); 145 Ok(()) 146 } 147 ScimSubcommand::Status(_) => { 148 println!("SCIM status requires database connection."); 149 println!("Run: auths-scim-server with DATABASE_URL set."); 150 Ok(()) 151 } 152 } 153 } 154 155 fn handle_serve(cmd: ScimServeCommand) -> Result<()> { 156 println!("Starting SCIM server..."); 157 println!(" Bind: {}", cmd.bind); 158 println!(" Database: {}", mask_url(&cmd.database_url)); 159 if let Some(ref path) = cmd.registry_path { 160 println!(" Registry: {}", path.display()); 161 } 162 println!(" Test mode: {}", cmd.test_mode); 163 println!(); 164 165 let mut child = std::process::Command::new("auths-scim-server") 166 .env("SCIM_LISTEN_ADDR", cmd.bind.to_string()) 167 .env("DATABASE_URL", &cmd.database_url) 168 .env("RUST_LOG", &cmd.log_level) 169 .env("AUTHS_SCIM_TEST", if cmd.test_mode { "1" } else { "0" }) 170 .spawn() 171 .context("Failed to start auths-scim-server. Is it installed?")?; 172 173 child.wait().context("Server exited with error")?; 174 Ok(()) 175 } 176 177 fn handle_quickstart(cmd: ScimQuickstartCommand) -> Result<()> { 178 let token = format!("scim_test_{}", generate_token_b64()); 179 180 println!(); 181 println!(" Auths SCIM Quickstart"); 182 println!(); 183 println!(" Server: http://{}", cmd.bind); 184 println!(" Tenant: quickstart"); 185 println!(" Token: {}", token); 186 println!(); 187 println!(" Try it now:"); 188 println!(" # List agents (empty)"); 189 println!(" curl -s -H \"Authorization: Bearer {}\" \\", token); 190 println!(" http://{}/Users | jq", cmd.bind); 191 println!(); 192 println!(" # Create an agent"); 193 println!( 194 " curl -s -X POST -H \"Authorization: Bearer {}\" \\", 195 token 196 ); 197 println!(" -H \"Content-Type: application/scim+json\" \\"); 198 println!( 199 " -d '{{\"schemas\":[\"urn:ietf:params:scim:schemas:core:2.0:User\"],\"userName\":\"my-agent\",\"displayName\":\"My First Agent\"}}' \\" 200 ); 201 println!(" http://{}/Users | jq", cmd.bind); 202 println!(); 203 println!(" Docs: https://docs.auths.dev/scim/quickstart"); 204 println!(" Press Ctrl+C to stop."); 205 println!(); 206 207 // In quickstart mode, use the auths-scim-server binary with test mode 208 let serve = ScimServeCommand { 209 bind: cmd.bind, 210 database_url: String::new(), // quickstart would use embedded DB 211 registry_path: None, 212 log_level: "info".into(), 213 test_mode: true, 214 }; 215 216 // For now, print guidance since quickstart requires embedded DB support 217 if serve.database_url.is_empty() { 218 println!(" Note: Quickstart requires DATABASE_URL to be set."); 219 println!(" Set DATABASE_URL env var or use `auths scim serve --database-url <url>`"); 220 } 221 222 Ok(()) 223 } 224 225 fn handle_test_connection(cmd: ScimTestConnectionCommand) -> Result<()> { 226 println!(); 227 println!(" Testing SCIM connection to {}...", cmd.url); 228 println!(); 229 230 let rt = tokio::runtime::Handle::try_current() 231 .ok() 232 .map(|_| None) 233 .unwrap_or_else(|| Some(tokio::runtime::Runtime::new().expect("tokio runtime"))); 234 235 let result = if let Some(ref rt) = rt { 236 rt.block_on(run_test_connection(&cmd.url, &cmd.token)) 237 } else { 238 tokio::task::block_in_place(|| { 239 tokio::runtime::Handle::current().block_on(run_test_connection(&cmd.url, &cmd.token)) 240 }) 241 }; 242 243 match result { 244 Ok(()) => { 245 println!(" All checks passed. Your SCIM server is ready."); 246 println!(); 247 } 248 Err(e) => { 249 println!(" Connection test failed: {}", e); 250 println!(); 251 } 252 } 253 254 Ok(()) 255 } 256 257 #[allow(clippy::disallowed_methods)] // CLI boundary: Utc::now() for test user naming 258 async fn run_test_connection(base_url: &str, token: &str) -> Result<()> { 259 #[allow(clippy::expect_used)] 260 let client = reqwest::Client::builder() 261 .connect_timeout(std::time::Duration::from_secs(10)) 262 .timeout(std::time::Duration::from_secs(30)) 263 .user_agent(concat!("auths/", env!("CARGO_PKG_VERSION"))) 264 .min_tls_version(reqwest::tls::Version::TLS_1_2) 265 .build() 266 .expect("failed to build HTTP client"); 267 let auth = format!("Bearer {}", token); 268 269 // POST /Users — create test agent 270 let start = std::time::Instant::now(); 271 let resp = client 272 .post(format!("{}/Users", base_url)) 273 .header("Authorization", &auth) 274 .header("Content-Type", "application/scim+json") 275 .json(&serde_json::json!({ 276 "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], 277 "userName": format!("test-agent-{}", chrono::Utc::now().timestamp()), 278 "displayName": "SCIM Test Agent" 279 })) 280 .send() 281 .await 282 .context("POST /Users failed")?; 283 let elapsed = start.elapsed(); 284 285 if resp.status().as_u16() == 201 { 286 println!(" [PASS] POST /Users -> 201 Created ({:.0?})", elapsed); 287 } else { 288 println!( 289 " [FAIL] POST /Users -> {} ({:.0?})", 290 resp.status(), 291 elapsed 292 ); 293 return Ok(()); 294 } 295 296 let body: serde_json::Value = resp.json().await?; 297 let id = body["id"].as_str().unwrap_or("unknown"); 298 let did = body 299 .get("urn:ietf:params:scim:schemas:extension:auths:2.0:Agent") 300 .and_then(|ext| ext["identityDid"].as_str()) 301 .unwrap_or("unknown"); 302 println!(" Agent: {} (userName: {})", did, body["userName"]); 303 304 // GET /Users/{id} 305 let start = std::time::Instant::now(); 306 let resp = client 307 .get(format!("{}/Users/{}", base_url, id)) 308 .header("Authorization", &auth) 309 .send() 310 .await?; 311 let elapsed = start.elapsed(); 312 println!( 313 " [{}] GET /Users/{{id}} -> {} ({:.0?})", 314 if resp.status().is_success() { 315 "PASS" 316 } else { 317 "FAIL" 318 }, 319 resp.status(), 320 elapsed 321 ); 322 323 // PATCH active=false 324 let start = std::time::Instant::now(); 325 let resp = client 326 .patch(format!("{}/Users/{}", base_url, id)) 327 .header("Authorization", &auth) 328 .header("Content-Type", "application/scim+json") 329 .json(&serde_json::json!({ 330 "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], 331 "Operations": [{"op": "Replace", "value": {"active": false}}] 332 })) 333 .send() 334 .await?; 335 let elapsed = start.elapsed(); 336 println!( 337 " [{}] PATCH active=false -> {} ({:.0?})", 338 if resp.status().is_success() { 339 "PASS" 340 } else { 341 "FAIL" 342 }, 343 resp.status(), 344 elapsed 345 ); 346 347 // DELETE /Users/{id} 348 let start = std::time::Instant::now(); 349 let resp = client 350 .delete(format!("{}/Users/{}", base_url, id)) 351 .header("Authorization", &auth) 352 .send() 353 .await?; 354 let elapsed = start.elapsed(); 355 println!( 356 " [{}] DELETE /Users/{{id}} -> {} ({:.0?})", 357 if resp.status().as_u16() == 204 { 358 "PASS" 359 } else { 360 "FAIL" 361 }, 362 resp.status(), 363 elapsed 364 ); 365 366 // GET /Users/{id} — should be 404 367 let start = std::time::Instant::now(); 368 let resp = client 369 .get(format!("{}/Users/{}", base_url, id)) 370 .header("Authorization", &auth) 371 .send() 372 .await?; 373 let elapsed = start.elapsed(); 374 println!( 375 " [{}] GET /Users/{{id}} -> {} ({:.0?})", 376 if resp.status().as_u16() == 404 { 377 "PASS" 378 } else { 379 "FAIL" 380 }, 381 resp.status(), 382 elapsed 383 ); 384 385 println!(); 386 Ok(()) 387 } 388 389 fn generate_token_b64() -> String { 390 use base64::Engine; 391 let mut bytes = [0u8; 32]; 392 ring::rand::SystemRandom::new() 393 .fill(&mut bytes) 394 .expect("random bytes"); 395 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) 396 } 397 398 fn mask_url(url: &str) -> String { 399 if let Some(at_pos) = url.find('@') 400 && let Some(scheme_end) = url.find("://") 401 { 402 return format!("{}://***@{}", &url[..scheme_end], &url[at_pos + 1..]); 403 } 404 url.to_string() 405 } 406 407 impl ExecutableCommand for ScimCommand { 408 fn execute(&self, _ctx: &CliConfig) -> Result<()> { 409 handle_scim(self.clone()) 410 } 411 } 412 413 use ring::rand::SecureRandom;