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