Skip to main content

systemprompt_cli/commands/admin/users/
apikey.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_identifiers::error::IdValidationError;
17use systemprompt_identifiers::{ApiKeyId, ScopeDimension, UserId};
18use systemprompt_models::attribution::ScopeBinding;
19use systemprompt_security::authz::{AuthzHookContext, NullAuditSink, SubjectProviderSet};
20use systemprompt_users::{ApiKeyLimits, ApiKeyService, IssueApiKeyParams, UserRepository};
21
22use crate::context::CommandContext;
23use crate::shared::CommandOutput;
24
25#[derive(Debug, Subcommand)]
26pub enum ApiKeyCommands {
27    #[command(about = "Issue a personal access token; the secret is printed once")]
28    Issue(IssueArgs),
29
30    #[command(about = "List a user's API keys")]
31    List(ListArgs),
32
33    #[command(about = "Revoke an API key")]
34    Revoke(RevokeArgs),
35}
36
37#[derive(Debug, Args)]
38pub struct IssueArgs {
39    #[arg(long)]
40    pub user: UserId,
41
42    #[arg(long)]
43    pub name: String,
44
45    #[arg(long, value_parser = parse_rfc3339)]
46    pub expires: Option<DateTime<Utc>>,
47
48    #[arg(long = "model", value_name = "MODEL_ID")]
49    pub models: Vec<String>,
50
51    #[arg(long, value_parser = clap::value_parser!(i64).range(0..))]
52    pub budget_microdollars: Option<i64>,
53
54    #[arg(long, value_parser = clap::value_parser!(i32).range(1..))]
55    pub max_requests: Option<i32>,
56
57    #[arg(long, value_parser = clap::value_parser!(i32).range(1..))]
58    pub window_seconds: Option<i32>,
59
60    #[arg(long = "scope", value_name = "DIMENSION=VALUE", value_parser = parse_scope_binding)]
61    pub scopes: Vec<ScopeBinding>,
62}
63
64impl IssueArgs {
65    #[must_use]
66    pub fn limits(&self) -> ApiKeyLimits {
67        ApiKeyLimits {
68            model_allowlist: (!self.models.is_empty()).then(|| self.models.clone()),
69            budget_microdollars: self.budget_microdollars,
70            max_requests: self.max_requests,
71            request_window_seconds: self.window_seconds,
72        }
73    }
74}
75
76#[derive(Debug, Args)]
77pub struct ListArgs {
78    #[arg(long)]
79    pub user: UserId,
80}
81
82#[derive(Debug, Args)]
83pub struct RevokeArgs {
84    #[arg(long)]
85    pub user: UserId,
86
87    #[arg(long, value_parser = crate::shared::parse_api_key_id)]
88    pub id: ApiKeyId,
89}
90
91#[derive(Debug, thiserror::Error)]
92#[error("expected an RFC 3339 timestamp: {0}")]
93struct Rfc3339Error(#[source] chrono::ParseError);
94
95fn parse_rfc3339(raw: &str) -> Result<DateTime<Utc>, Rfc3339Error> {
96    DateTime::parse_from_rfc3339(raw)
97        .map(|dt| dt.with_timezone(&Utc))
98        .map_err(Rfc3339Error)
99}
100
101#[derive(Debug, thiserror::Error)]
102enum ScopeArgError {
103    #[error("expected DIMENSION=VALUE")]
104    MissingSeparator,
105    #[error("scope value cannot be empty")]
106    EmptyValue,
107    #[error("invalid scope dimension: {0}")]
108    Dimension(#[source] IdValidationError),
109}
110
111fn parse_scope_binding(raw: &str) -> Result<ScopeBinding, ScopeArgError> {
112    let (dimension, value) = raw.split_once('=').ok_or(ScopeArgError::MissingSeparator)?;
113    let value = value.trim();
114    if value.is_empty() {
115        return Err(ScopeArgError::EmptyValue);
116    }
117    Ok(ScopeBinding {
118        dimension: ScopeDimension::try_new(dimension.trim()).map_err(ScopeArgError::Dimension)?,
119        value: value.to_owned(),
120    })
121}
122
123#[derive(Debug, Serialize)]
124struct IssuedKeyOutput {
125    id: ApiKeyId,
126    user_id: UserId,
127    name: String,
128    expires_at: Option<DateTime<Utc>>,
129    #[serde(flatten)]
130    limits: ApiKeyLimits,
131    scopes: Vec<ScopeBinding>,
132    secret: String,
133    message: String,
134}
135
136#[derive(Debug, Serialize)]
137struct KeyRow {
138    id: ApiKeyId,
139    name: String,
140    key_prefix: String,
141    created_at: Option<DateTime<Utc>>,
142    last_used_at: Option<DateTime<Utc>>,
143    expires_at: Option<DateTime<Utc>>,
144    revoked_at: Option<DateTime<Utc>>,
145    #[serde(flatten)]
146    limits: ApiKeyLimits,
147    scopes: Vec<ScopeBinding>,
148}
149
150pub(super) async fn execute(cmd: ApiKeyCommands, ctx: &CommandContext) -> Result<CommandOutput> {
151    let pool = ctx.db_pool().await?;
152    let service = ApiKeyService::new(Arc::new(UserRepository::new(&pool)));
153    match cmd {
154        ApiKeyCommands::Issue(args) => {
155            if !args.scopes.is_empty() {
156                SubjectProviderSet::discover(&AuthzHookContext {
157                    pool: pool.pool(),
158                    sink: Arc::new(NullAuditSink),
159                })
160                .verify_scope_bindings(&args.user, &args.scopes)
161                .await?;
162            }
163            issue(&service, args).await
164        },
165        ApiKeyCommands::List(args) => list(&service, &args).await,
166        ApiKeyCommands::Revoke(args) => revoke(&service, &args).await,
167    }
168}
169
170async fn issue(service: &ApiKeyService, args: IssueArgs) -> Result<CommandOutput> {
171    if args.name.trim().is_empty() {
172        return Err(anyhow!("Key name cannot be empty"));
173    }
174    let issued = service
175        .issue(IssueApiKeyParams {
176            user_id: &args.user,
177            name: &args.name,
178            expires_at: args.expires,
179            limits: &args.limits(),
180            scopes: &args.scopes,
181        })
182        .await?;
183    let output = IssuedKeyOutput {
184        id: issued.record.id.clone(),
185        user_id: issued.record.user_id.clone(),
186        name: issued.record.name.clone(),
187        expires_at: issued.record.expires_at,
188        limits: issued.record.limits,
189        scopes: issued.record.scopes,
190        secret: issued.secret,
191        message: "Store the secret now — it is shown only once".to_owned(),
192    };
193    Ok(CommandOutput::card_value("API Key Issued", &output))
194}
195
196async fn list(service: &ApiKeyService, args: &ListArgs) -> Result<CommandOutput> {
197    let rows: Vec<KeyRow> = service
198        .list_for_user(&args.user)
199        .await?
200        .into_iter()
201        .map(|k| KeyRow {
202            id: k.id,
203            name: k.name,
204            key_prefix: k.key_prefix,
205            created_at: k.created_at,
206            last_used_at: k.last_used_at,
207            expires_at: k.expires_at,
208            revoked_at: k.revoked_at,
209            limits: k.limits,
210            scopes: k.scopes,
211        })
212        .collect();
213    Ok(CommandOutput::card_value("API Keys", &rows))
214}
215
216async fn revoke(service: &ApiKeyService, args: &RevokeArgs) -> Result<CommandOutput> {
217    let revoked = service.revoke(&args.id, &args.user).await?;
218    if revoked {
219        #[derive(Debug, Serialize)]
220        struct RevokedOutput {
221            id: ApiKeyId,
222            message: String,
223        }
224        let output = RevokedOutput {
225            id: args.id.clone(),
226            message: "API key revoked".to_owned(),
227        };
228        Ok(CommandOutput::card_value("API Key Revoked", &output))
229    } else {
230        Err(anyhow!(
231            "API key was not found for that user or is already revoked"
232        ))
233    }
234}