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