Skip to main content

systemprompt_cli/commands/admin/users/apikey/
mod.rs

1//! `admin users api-key` command tree.
2//!
3//! Mints, lists, and revokes `sp-live-` personal access tokens directly
4//! against the database — the same `ApiKeyService` path the gateway's
5//! browser-consent exchange lands on, usable before any admin HTTP session
6//! or external identity provider exists.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use anyhow::{Result, anyhow};
12use chrono::{DateTime, Utc};
13use clap::{Args, Subcommand};
14use serde::Serialize;
15use std::sync::Arc;
16use systemprompt_governance::authz::{AuthzHookContext, NullAuditSink, SubjectProviderSet};
17use systemprompt_identifiers::error::IdValidationError;
18use systemprompt_identifiers::{ApiKeyId, ScopeDimension, UserId};
19use systemprompt_models::attribution::ScopeBinding;
20use systemprompt_users::{ApiKeyLifetime, ApiKeyLimits, ApiKeyService, UserRepository};
21
22mod issue;
23
24pub use issue::IssueArgs;
25
26use crate::context::CommandContext;
27use crate::shared::CommandOutput;
28
29#[derive(Debug, Subcommand)]
30pub enum ApiKeyCommands {
31    #[command(about = "Issue a personal access token; the secret is printed once")]
32    Issue(IssueArgs),
33
34    #[command(about = "List a user's API keys")]
35    List(ListArgs),
36
37    #[command(about = "Revoke an API key")]
38    Revoke(RevokeArgs),
39}
40
41#[derive(Debug, Args)]
42pub struct ListArgs {
43    #[arg(long)]
44    pub user: UserId,
45}
46
47#[derive(Debug, Args)]
48pub struct RevokeArgs {
49    #[arg(long)]
50    pub user: UserId,
51
52    #[arg(long, value_parser = crate::shared::parse_api_key_id)]
53    pub id: ApiKeyId,
54}
55
56#[derive(Debug, thiserror::Error)]
57#[error("expected an RFC 3339 timestamp: {0}")]
58pub(super) struct Rfc3339Error(#[source] chrono::ParseError);
59
60pub(super) fn parse_rfc3339(raw: &str) -> Result<DateTime<Utc>, Rfc3339Error> {
61    DateTime::parse_from_rfc3339(raw)
62        .map(|dt| dt.with_timezone(&Utc))
63        .map_err(Rfc3339Error)
64}
65
66#[derive(Debug, thiserror::Error)]
67pub(super) enum ScopeArgError {
68    #[error("expected DIMENSION=VALUE")]
69    MissingSeparator,
70    #[error("scope value cannot be empty")]
71    EmptyValue,
72    #[error("invalid scope dimension: {0}")]
73    Dimension(#[source] IdValidationError),
74}
75
76pub(super) fn parse_scope_binding(raw: &str) -> Result<ScopeBinding, ScopeArgError> {
77    let (dimension, value) = raw.split_once('=').ok_or(ScopeArgError::MissingSeparator)?;
78    let value = value.trim();
79    if value.is_empty() {
80        return Err(ScopeArgError::EmptyValue);
81    }
82    Ok(ScopeBinding {
83        dimension: ScopeDimension::try_new(dimension.trim()).map_err(ScopeArgError::Dimension)?,
84        value: value.to_owned(),
85    })
86}
87
88#[derive(Debug, Serialize)]
89struct KeyRow {
90    id: ApiKeyId,
91    name: String,
92    key_prefix: String,
93    created_at: Option<DateTime<Utc>>,
94    last_used_at: Option<DateTime<Utc>>,
95    expires_at: Option<DateTime<Utc>>,
96    revoked_at: Option<DateTime<Utc>>,
97    #[serde(flatten)]
98    limits: ApiKeyLimits,
99    scopes: Vec<ScopeBinding>,
100}
101
102pub(super) async fn execute(cmd: ApiKeyCommands, ctx: &CommandContext) -> Result<CommandOutput> {
103    let pool = ctx.db_pool().await?;
104    let lifetime =
105        ApiKeyLifetime::from_max_days(ctx.app_context().await?.config().api_key_max_lifetime_days);
106    let service = ApiKeyService::new(Arc::new(UserRepository::new(&pool)), lifetime);
107    match cmd {
108        ApiKeyCommands::Issue(args) => {
109            if !args.scopes.is_empty() {
110                SubjectProviderSet::discover(&AuthzHookContext {
111                    pool: pool.pool(),
112                    sink: Arc::new(NullAuditSink),
113                })
114                .verify_scope_bindings(&args.user, &args.scopes)
115                .await?;
116            }
117            issue::execute(&service, args).await
118        },
119        ApiKeyCommands::List(args) => list(&service, &args).await,
120        ApiKeyCommands::Revoke(args) => revoke(&service, &args).await,
121    }
122}
123
124async fn list(service: &ApiKeyService, args: &ListArgs) -> Result<CommandOutput> {
125    let rows: Vec<KeyRow> = service
126        .list_for_user(&args.user)
127        .await?
128        .into_iter()
129        .map(|k| KeyRow {
130            id: k.id,
131            name: k.name,
132            key_prefix: k.key_prefix,
133            created_at: k.created_at,
134            last_used_at: k.last_used_at,
135            expires_at: k.expires_at,
136            revoked_at: k.revoked_at,
137            limits: k.limits,
138            scopes: k.scopes,
139        })
140        .collect();
141    Ok(CommandOutput::card_value("API Keys", &rows))
142}
143
144async fn revoke(service: &ApiKeyService, args: &RevokeArgs) -> Result<CommandOutput> {
145    let revoked = service.revoke(&args.id, &args.user).await?;
146    if revoked {
147        #[derive(Debug, Serialize)]
148        struct RevokedOutput {
149            id: ApiKeyId,
150            message: String,
151        }
152        let output = RevokedOutput {
153            id: args.id.clone(),
154            message: "API key revoked".to_owned(),
155        };
156        Ok(CommandOutput::card_value("API Key Revoked", &output))
157    } else {
158        Err(anyhow!(
159            "API key was not found for that user or is already revoked"
160        ))
161    }
162}