/ crates / auths-cli / src / commands / scim.rs
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;