Skip to main content

auths_cli/commands/
scim.rs

1//! SCIM provisioning server management commands.
2//!
3//! `serve`/`quickstart` run the in-workspace `auths-scim-server` library in-process
4//! (KERI/registry is authoritative — there is no provisioning database). Tenants are
5//! process-configured via flags, so `add-tenant`/`rotate-token` mint channel bearer
6//! tokens, `status` probes a running server, and `tenants` is an honest "no tenant
7//! registry in this model" error rather than a fake database stub.
8
9use std::net::SocketAddr;
10use std::path::PathBuf;
11
12use anyhow::{Context, Result};
13use clap::{Parser, Subcommand};
14
15use auths_scim_server::{ServeConfig, TenantBootstrap, run};
16
17use crate::commands::executable::ExecutableCommand;
18use crate::config::CliConfig;
19
20/// Manage the SCIM provisioning server.
21#[derive(Parser, Debug, Clone)]
22#[command(name = "scim", about = "SCIM 2.0 provisioning for agent identities")]
23pub struct ScimCommand {
24    #[command(subcommand)]
25    pub command: ScimSubcommand,
26}
27
28#[derive(Subcommand, Debug, Clone)]
29pub enum ScimSubcommand {
30    /// Start the SCIM provisioning server (in-process, KERI-authoritative).
31    Serve(ScimServeCommand),
32    /// Zero-config quickstart: generate a token and run a single-tenant server.
33    Quickstart(ScimQuickstartCommand),
34    /// Validate the full SCIM pipeline: create -> get -> patch -> delete.
35    TestConnection(ScimTestConnectionCommand),
36    /// List SCIM tenants (process-configured in this model).
37    Tenants(ScimTenantsCommand),
38    /// Mint a bearer token for an IdP provisioning channel.
39    AddTenant(ScimAddTenantCommand),
40    /// Mint a replacement bearer token to rotate a tenant's channel.
41    RotateToken(ScimRotateTokenCommand),
42    /// Probe a running SCIM server's health and discovery surface.
43    Status(ScimStatusCommand),
44}
45
46/// Start the SCIM provisioning server.
47#[derive(Parser, Debug, Clone)]
48pub struct ScimServeCommand {
49    /// Listen address.
50    #[arg(long, default_value = "0.0.0.0:8787")]
51    pub bind: SocketAddr,
52    /// Tenant id for the single-tenant bootstrap (matches the IdP's tenant).
53    #[arg(long)]
54    pub tenant: Option<String>,
55    /// Auths org prefix this tenant provisions into.
56    #[arg(long)]
57    pub org_prefix: Option<String>,
58    /// Bearer token authenticating the provisioning channel.
59    #[arg(long)]
60    pub token: Option<String>,
61    /// Org signing-key alias (default: derived `org-<slug>`).
62    #[arg(long)]
63    pub org_key: Option<String>,
64    /// Base URL used for SCIM `meta.location`.
65    #[arg(long)]
66    pub base_url: Option<String>,
67    /// Passphrase for the org signing key (single-host custody).
68    #[arg(long)]
69    pub passphrase: Option<String>,
70    /// Path to the Auths registry Git repository (default: `~/.auths`).
71    #[arg(long)]
72    pub registry_path: Option<PathBuf>,
73}
74
75/// Zero-config quickstart with copy-paste curl examples.
76#[derive(Parser, Debug, Clone)]
77pub struct ScimQuickstartCommand {
78    /// Listen address.
79    #[arg(long, default_value = "0.0.0.0:8787")]
80    pub bind: SocketAddr,
81    /// Auths org prefix to provision into (must already exist on this host).
82    #[arg(long)]
83    pub org_prefix: String,
84    /// Tenant id (defaults to `quickstart`).
85    #[arg(long, default_value = "quickstart")]
86    pub tenant: String,
87    /// Passphrase for the org signing key (single-host custody).
88    #[arg(long)]
89    pub passphrase: Option<String>,
90    /// Path to the Auths registry Git repository (default: `~/.auths`).
91    #[arg(long)]
92    pub registry_path: Option<PathBuf>,
93}
94
95/// Validate the full SCIM pipeline against a running server.
96#[derive(Parser, Debug, Clone)]
97pub struct ScimTestConnectionCommand {
98    /// Server URL.
99    #[arg(long, default_value = "http://localhost:8787")]
100    pub url: String,
101    /// Bearer token.
102    #[arg(long)]
103    pub token: String,
104}
105
106/// List SCIM tenants.
107#[derive(Parser, Debug, Clone)]
108pub struct ScimTenantsCommand {
109    /// Output as JSON.
110    #[arg(long)]
111    pub json: bool,
112}
113
114/// Mint a bearer token for an IdP provisioning channel.
115#[derive(Parser, Debug, Clone)]
116pub struct ScimAddTenantCommand {
117    /// Tenant name.
118    #[arg(long)]
119    pub name: String,
120    /// Auths org prefix this tenant provisions into.
121    #[arg(long)]
122    pub org_prefix: String,
123}
124
125/// Mint a replacement bearer token to rotate a tenant's channel.
126#[derive(Parser, Debug, Clone)]
127pub struct ScimRotateTokenCommand {
128    /// Tenant name.
129    #[arg(long)]
130    pub name: String,
131}
132
133/// Probe a running SCIM server.
134#[derive(Parser, Debug, Clone)]
135pub struct ScimStatusCommand {
136    /// Server URL.
137    #[arg(long, default_value = "http://localhost:8787")]
138    pub url: String,
139    /// Output as JSON.
140    #[arg(long)]
141    pub json: bool,
142}
143
144fn handle_scim(cmd: ScimCommand, ctx: &CliConfig) -> Result<()> {
145    match cmd.command {
146        ScimSubcommand::Serve(serve) => handle_serve(serve, ctx),
147        ScimSubcommand::Quickstart(qs) => handle_quickstart(qs, ctx),
148        ScimSubcommand::TestConnection(tc) => handle_test_connection(tc),
149        ScimSubcommand::Tenants(_) => handle_tenants(),
150        ScimSubcommand::AddTenant(at) => handle_add_tenant(&at.name, &at.org_prefix),
151        ScimSubcommand::RotateToken(rt) => handle_rotate_token(&rt.name),
152        ScimSubcommand::Status(st) => handle_status(st),
153    }
154}
155
156/// Resolve the registry home from the command flag, the global CLI config, or the
157/// server's `~/.auths` default (`None`).
158fn resolve_home(registry_path: Option<PathBuf>, ctx: &CliConfig) -> Option<PathBuf> {
159    registry_path.or_else(|| ctx.repo_path.clone())
160}
161
162fn handle_serve(cmd: ScimServeCommand, ctx: &CliConfig) -> Result<()> {
163    let tenant = match (cmd.tenant, cmd.org_prefix, cmd.token) {
164        (Some(tenant_id), Some(org_prefix), Some(bearer_token)) => Some(TenantBootstrap {
165            tenant_id,
166            org_prefix,
167            bearer_token,
168            org_key_alias: cmd.org_key,
169            base_url: cmd.base_url,
170        }),
171        (None, None, None) => {
172            println!("No tenant configured — running discovery-only; /Users rejects all callers.");
173            println!("Configure with --tenant <id> --org-prefix <E…> --token <bearer>.");
174            None
175        }
176        _ => anyhow::bail!(
177            "--tenant, --org-prefix, and --token must be set together (or all omitted for discovery-only)"
178        ),
179    };
180
181    println!("Starting SCIM server on {}...", cmd.bind);
182    serve(ServeConfig {
183        bind: cmd.bind.to_string(),
184        tenant,
185        home: resolve_home(cmd.registry_path, ctx),
186        passphrase: cmd.passphrase.unwrap_or_default(),
187    })
188}
189
190fn handle_quickstart(cmd: ScimQuickstartCommand, ctx: &CliConfig) -> Result<()> {
191    let token = format!("scim_test_{}", generate_token_b64());
192
193    println!();
194    println!("  Auths SCIM Quickstart");
195    println!();
196    println!("  Server:   http://{}", cmd.bind);
197    println!("  Tenant:   {}", cmd.tenant);
198    println!("  Org:      {}", cmd.org_prefix);
199    println!("  Token:    {}", token);
200    println!();
201    println!("  Try it (in another shell):");
202    println!(
203        "    curl -s -H \"Authorization: Bearer {token}\" http://{}/scim/v2/Users | jq",
204        cmd.bind
205    );
206    println!();
207    println!("    curl -s -X POST -H \"Authorization: Bearer {token}\" \\");
208    println!("      -H \"Content-Type: application/scim+json\" \\");
209    println!(
210        "      -d '{{\"schemas\":[\"urn:ietf:params:scim:schemas:core:2.0:User\"],\"userName\":\"deploy-bot\",\"externalId\":\"okta-1\"}}' \\"
211    );
212    println!("      http://{}/scim/v2/Users | jq", cmd.bind);
213    println!();
214    println!("  Press Ctrl+C to stop.");
215    println!();
216
217    serve(ServeConfig {
218        bind: cmd.bind.to_string(),
219        tenant: Some(TenantBootstrap {
220            tenant_id: cmd.tenant,
221            org_prefix: cmd.org_prefix,
222            bearer_token: token,
223            org_key_alias: None,
224            base_url: Some(format!("http://{}/scim/v2", cmd.bind)),
225        }),
226        home: resolve_home(cmd.registry_path, ctx),
227        passphrase: cmd.passphrase.unwrap_or_default(),
228    })
229}
230
231fn handle_tenants() -> Result<()> {
232    anyhow::bail!(
233        "SCIM tenants are process-configured (via `auths scim serve --tenant/--org-prefix/--token` \
234         or the SCIM_* env), not stored in a tenant database — the KEL is the source of truth, so \
235         there is no tenant registry to list. Use `auths scim status --url <server>` to probe a \
236         running server."
237    )
238}
239
240fn handle_add_tenant(name: &str, org_prefix: &str) -> Result<()> {
241    let token = format!("scim_{}", generate_token_b64());
242    println!("Minted a SCIM channel bearer token for tenant '{name}':");
243    println!();
244    println!("  {token}");
245    println!();
246    println!("Run the server with it:");
247    println!("  auths scim serve --tenant {name} --org-prefix {org_prefix} --token {token}");
248    println!();
249    println!("Configure your IdP (Okta/Entra) with this token as the SCIM bearer secret.");
250    println!("The token is hashed at rest by the server; store this plaintext securely now.");
251    Ok(())
252}
253
254fn handle_rotate_token(name: &str) -> Result<()> {
255    let token = format!("scim_{}", generate_token_b64());
256    println!("Minted a replacement SCIM bearer token for tenant '{name}':");
257    println!();
258    println!("  {token}");
259    println!();
260    println!("Swap it in by restarting the server with --token {token} and updating the IdP.");
261    println!("The previous token stops authenticating as soon as the server restarts.");
262    Ok(())
263}
264
265fn handle_status(cmd: ScimStatusCommand) -> Result<()> {
266    let result = block_on(probe_status(&cmd.url));
267    match result {
268        Ok(report) => {
269            if cmd.json {
270                println!("{}", serde_json::to_string_pretty(&report)?);
271            } else {
272                println!("SCIM server: {}", cmd.url);
273                println!(
274                    "  health:  {}",
275                    if report.healthy { "ok" } else { "unreachable" }
276                );
277                println!("  patch:   {}", report.patch_supported);
278                println!("  filter:  {}", report.filter_supported);
279            }
280            Ok(())
281        }
282        Err(e) => anyhow::bail!("could not reach SCIM server at {}: {e}", cmd.url),
283    }
284}
285
286/// A minimal status snapshot probed from discovery.
287#[derive(Debug, serde::Serialize)]
288struct StatusReport {
289    healthy: bool,
290    patch_supported: bool,
291    filter_supported: bool,
292}
293
294async fn probe_status(base_url: &str) -> Result<StatusReport> {
295    let client = build_http_client()?;
296    let healthy = client
297        .get(format!("{base_url}/health"))
298        .send()
299        .await
300        .map(|r| r.status().is_success())
301        .unwrap_or(false);
302
303    let spc: serde_json::Value = client
304        .get(format!("{base_url}/scim/v2/ServiceProviderConfig"))
305        .send()
306        .await
307        .context("ServiceProviderConfig request failed")?
308        .json()
309        .await
310        .context("ServiceProviderConfig was not valid JSON")?;
311
312    Ok(StatusReport {
313        healthy,
314        patch_supported: spc["patch"]["supported"].as_bool().unwrap_or(false),
315        filter_supported: spc["filter"]["supported"].as_bool().unwrap_or(false),
316    })
317}
318
319fn handle_test_connection(cmd: ScimTestConnectionCommand) -> Result<()> {
320    println!();
321    println!("  Testing SCIM connection to {}...", cmd.url);
322    println!();
323
324    match block_on(run_test_connection(&cmd.url, &cmd.token)) {
325        Ok(()) => {
326            println!("  All checks passed. Your SCIM server is ready.");
327            println!();
328        }
329        Err(e) => {
330            println!("  Connection test failed: {e}");
331            println!();
332        }
333    }
334    Ok(())
335}
336
337#[allow(clippy::disallowed_methods)] // CLI boundary: Utc::now() for test user naming
338async fn run_test_connection(base_url: &str, token: &str) -> Result<()> {
339    let client = build_http_client()?;
340    let auth = format!("Bearer {token}");
341
342    let start = std::time::Instant::now();
343    let resp = client
344        .post(format!("{base_url}/scim/v2/Users"))
345        .header("Authorization", &auth)
346        .header("Content-Type", "application/scim+json")
347        .json(&serde_json::json!({
348            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
349            "userName": format!("test-agent-{}", chrono::Utc::now().timestamp()),
350            "externalId": format!("scim-test-{}", chrono::Utc::now().timestamp()),
351            "displayName": "SCIM Test Agent"
352        }))
353        .send()
354        .await
355        .context("POST /Users failed")?;
356    let elapsed = start.elapsed();
357
358    if resp.status().as_u16() == 201 {
359        println!("  [PASS] POST /Users -> 201 Created ({elapsed:.0?})");
360    } else {
361        println!("  [FAIL] POST /Users -> {} ({elapsed:.0?})", resp.status());
362        return Ok(());
363    }
364
365    let body: serde_json::Value = resp.json().await?;
366    let id = body["id"].as_str().unwrap_or("unknown").to_string();
367    let did = body
368        .get("urn:ietf:params:scim:schemas:extension:auths:2.0:Agent")
369        .and_then(|ext| ext["identityDid"].as_str())
370        .unwrap_or("unknown");
371    println!("         Agent: {} (userName: {})", did, body["userName"]);
372
373    probe_step(&client, &auth, "GET /Users/{id}", |c| {
374        c.get(format!("{base_url}/scim/v2/Users/{id}"))
375    })
376    .await;
377
378    // PATCH active=false (soft-disable)
379    let start = std::time::Instant::now();
380    let resp = client
381        .patch(format!("{base_url}/scim/v2/Users/{id}"))
382        .header("Authorization", &auth)
383        .header("Content-Type", "application/scim+json")
384        .json(&serde_json::json!({
385            "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
386            "Operations": [{"op": "replace", "path": "active", "value": false}]
387        }))
388        .send()
389        .await?;
390    println!(
391        "  [{}] PATCH active=false -> {} ({:.0?})",
392        pass_fail(resp.status().is_success()),
393        resp.status(),
394        start.elapsed()
395    );
396
397    // DELETE (soft leaver) -> 204
398    let start = std::time::Instant::now();
399    let resp = client
400        .delete(format!("{base_url}/scim/v2/Users/{id}"))
401        .header("Authorization", &auth)
402        .send()
403        .await?;
404    println!(
405        "  [{}] DELETE /Users/{{id}} -> {} ({:.0?})",
406        pass_fail(resp.status().as_u16() == 204),
407        resp.status(),
408        start.elapsed()
409    );
410
411    // GET should now be 404
412    let start = std::time::Instant::now();
413    let resp = client
414        .get(format!("{base_url}/scim/v2/Users/{id}"))
415        .header("Authorization", &auth)
416        .send()
417        .await?;
418    println!(
419        "  [{}] GET /Users/{{id}} -> {} ({:.0?})",
420        pass_fail(resp.status().as_u16() == 404),
421        resp.status(),
422        start.elapsed()
423    );
424
425    println!();
426    Ok(())
427}
428
429async fn probe_step<F>(client: &reqwest::Client, auth: &str, label: &str, build: F)
430where
431    F: FnOnce(&reqwest::Client) -> reqwest::RequestBuilder,
432{
433    let start = std::time::Instant::now();
434    let outcome = build(client).header("Authorization", auth).send().await;
435    match outcome {
436        Ok(resp) => println!(
437            "  [{}] {label} -> {} ({:.0?})",
438            pass_fail(resp.status().is_success()),
439            resp.status(),
440            start.elapsed()
441        ),
442        Err(e) => println!("  [FAIL] {label} -> {e}"),
443    }
444}
445
446fn pass_fail(ok: bool) -> &'static str {
447    if ok { "PASS" } else { "FAIL" }
448}
449
450fn build_http_client() -> Result<reqwest::Client> {
451    reqwest::Client::builder()
452        .connect_timeout(std::time::Duration::from_secs(10))
453        .timeout(std::time::Duration::from_secs(30))
454        .user_agent(concat!("auths/", env!("CARGO_PKG_VERSION")))
455        .min_tls_version(reqwest::tls::Version::TLS_1_2)
456        .build()
457        .context("failed to build HTTP client")
458}
459
460/// Run a future to completion on a fresh runtime (the CLI's `main` is synchronous).
461fn block_on<F: std::future::Future>(fut: F) -> F::Output {
462    #[allow(clippy::expect_used)] // INVARIANT: tokio runtime creation failing is unrecoverable
463    let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
464    rt.block_on(fut)
465}
466
467/// Serve the in-process SCIM server until stopped.
468fn serve(config: ServeConfig) -> Result<()> {
469    block_on(run(config))
470}
471
472fn generate_token_b64() -> String {
473    use base64::Engine;
474    let mut bytes = [0u8; 32];
475    #[allow(clippy::expect_used)] // INVARIANT: system RNG failure is unrecoverable
476    ring::rand::SystemRandom::new()
477        .fill(&mut bytes)
478        .expect("random bytes");
479    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
480}
481
482impl ExecutableCommand for ScimCommand {
483    fn execute(&self, ctx: &CliConfig) -> Result<()> {
484        handle_scim(self.clone(), ctx)
485    }
486}
487
488use ring::rand::SecureRandom;