systemprompt_cli/commands/admin/users/apikey/
mod.rs1use 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}