Skip to main content

auths_cli/commands/
scim.rs

1//! SCIM provisioning server management commands.
2
3use std::net::SocketAddr;
4use std::path::PathBuf;
5
6use anyhow::{Context, Result};
7use clap::{Parser, Subcommand};
8
9use crate::commands::executable::ExecutableCommand;
10use 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")]
15pub struct ScimCommand {
16    #[command(subcommand)]
17    pub command: ScimSubcommand,
18}
19
20#[derive(Subcommand, Debug, Clone)]
21pub 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)]
40pub 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)]
60pub 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)]
68pub 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)]
79pub 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)]
90pub 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)]
104pub 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)]
118pub 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
127fn 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
155fn 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
177fn 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
225fn 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
258async 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
389fn 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
398fn 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
407impl ExecutableCommand for ScimCommand {
408    fn execute(&self, _ctx: &CliConfig) -> Result<()> {
409        handle_scim(self.clone())
410    }
411}
412
413use ring::rand::SecureRandom;