systemprompt_cli/commands/admin/users/apikey/
issue.rs1use std::io::Write;
14use std::path::{Path, PathBuf};
15
16use anyhow::{Context, Result, anyhow};
17use chrono::{DateTime, TimeDelta, Utc};
18use clap::Args;
19use serde::Serialize;
20use systemprompt_identifiers::{ApiKeyId, UserId};
21use systemprompt_models::attribution::ScopeBinding;
22use systemprompt_users::{
23 ApiKeyLimits, ApiKeyService, EnsuredApiKey, IssueApiKeyParams, NewApiKey, UserApiKey,
24};
25
26use super::{parse_rfc3339, parse_scope_binding};
27use crate::shared::CommandOutput;
28
29#[derive(Debug, Args)]
30pub struct IssueArgs {
31 #[arg(long)]
32 pub user: UserId,
33
34 #[arg(long)]
35 pub name: String,
36
37 #[arg(long, value_parser = parse_rfc3339)]
38 pub expires: Option<DateTime<Utc>>,
39
40 #[arg(long = "model", value_name = "MODEL_ID")]
41 pub models: Vec<String>,
42
43 #[arg(long, value_parser = clap::value_parser!(i64).range(0..))]
44 pub budget_microdollars: Option<i64>,
45
46 #[arg(long, value_parser = clap::value_parser!(i32).range(1..))]
47 pub max_requests: Option<i32>,
48
49 #[arg(long, value_parser = clap::value_parser!(i32).range(1..))]
50 pub window_seconds: Option<i32>,
51
52 #[arg(long = "scope", value_name = "DIMENSION=VALUE", value_parser = parse_scope_binding)]
53 pub scopes: Vec<ScopeBinding>,
54
55 #[arg(long)]
56 pub if_absent: bool,
57
58 #[arg(long, requires = "if_absent", default_value_t = 0)]
59 pub renew_before_days: u32,
60
61 #[arg(long, value_name = "PATH")]
62 pub secret_file: Option<PathBuf>,
63}
64
65impl IssueArgs {
66 #[must_use]
67 pub fn limits(&self) -> ApiKeyLimits {
68 ApiKeyLimits {
69 model_allowlist: (!self.models.is_empty()).then(|| self.models.clone()),
70 budget_microdollars: self.budget_microdollars,
71 max_requests: self.max_requests,
72 request_window_seconds: self.window_seconds,
73 }
74 }
75}
76
77#[derive(Debug, Serialize)]
78struct IssuedKeyOutput {
79 id: ApiKeyId,
80 user_id: UserId,
81 name: String,
82 key_prefix: String,
83 expires_at: Option<DateTime<Utc>>,
84 #[serde(flatten)]
85 limits: ApiKeyLimits,
86 scopes: Vec<ScopeBinding>,
87 issued: bool,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 secret: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 secret_file: Option<PathBuf>,
92 message: String,
93}
94
95pub(super) async fn execute(service: &ApiKeyService, args: IssueArgs) -> Result<CommandOutput> {
96 if args.name.trim().is_empty() {
97 return Err(anyhow!("Key name cannot be empty"));
98 }
99 let limits = args.limits();
100 let params = IssueApiKeyParams {
101 user_id: &args.user,
102 name: &args.name,
103 expires_at: args.expires,
104 limits: &limits,
105 scopes: &args.scopes,
106 purpose: None,
107 };
108 let ensured = if args.if_absent {
109 let renew_before = TimeDelta::days(i64::from(args.renew_before_days));
110 service.ensure(params, renew_before).await?
111 } else {
112 EnsuredApiKey::Issued(service.issue(params).await?)
113 };
114 match ensured {
115 EnsuredApiKey::Existing(record) => Ok(existing_output(record)),
116 EnsuredApiKey::Issued(issued) => {
117 issued_output(service, issued, args.secret_file.as_deref()).await
118 },
119 }
120}
121
122fn existing_output(record: UserApiKey) -> CommandOutput {
123 let output = IssuedKeyOutput {
124 id: record.id,
125 user_id: record.user_id,
126 name: record.name,
127 key_prefix: record.key_prefix,
128 expires_at: record.expires_at,
129 limits: record.limits,
130 scopes: record.scopes,
131 issued: false,
132 secret: None,
133 secret_file: None,
134 message: "A live key with this name already exists; nothing was issued".to_owned(),
135 };
136 CommandOutput::card_value("API Key Exists", &output)
137}
138
139async fn issued_output(
140 service: &ApiKeyService,
141 issued: NewApiKey,
142 secret_file: Option<&Path>,
143) -> Result<CommandOutput> {
144 let (secret, secret_file, message) = match secret_file {
145 Some(path) => {
146 if let Err(error) = write_secret_file(path, &issued.secret) {
147 service
148 .revoke(&issued.record.id, &issued.record.user_id)
149 .await
150 .context("revoke the key whose secret could not be written")?;
151 return Err(error);
152 }
153 (
154 None,
155 Some(path.to_path_buf()),
156 "Secret written to the secret file (mode 0600)".to_owned(),
157 )
158 },
159 None => (
160 Some(issued.secret),
161 None,
162 "Store the secret now — it is shown only once".to_owned(),
163 ),
164 };
165 let record = issued.record;
166 let output = IssuedKeyOutput {
167 id: record.id,
168 user_id: record.user_id,
169 name: record.name,
170 key_prefix: record.key_prefix,
171 expires_at: record.expires_at,
172 limits: record.limits,
173 scopes: record.scopes,
174 issued: true,
175 secret,
176 secret_file,
177 message,
178 };
179 Ok(CommandOutput::card_value("API Key Issued", &output))
180}
181
182fn write_secret_file(path: &Path, secret: &str) -> Result<()> {
183 let mut options = std::fs::OpenOptions::new();
184 options.write(true).create(true).truncate(true);
185 #[cfg(unix)]
186 {
187 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
188 options.mode(0o600);
189 let mut file = options
190 .open(path)
191 .with_context(|| format!("open secret file {}", path.display()))?;
192 file.set_permissions(std::fs::Permissions::from_mode(0o600))
193 .with_context(|| format!("restrict secret file {}", path.display()))?;
194 file.write_all(secret.as_bytes())
195 .with_context(|| format!("write secret file {}", path.display()))?;
196 }
197 #[cfg(not(unix))]
198 {
199 let mut file = options
200 .open(path)
201 .with_context(|| format!("open secret file {}", path.display()))?;
202 file.write_all(secret.as_bytes())
203 .with_context(|| format!("write secret file {}", path.display()))?;
204 }
205 Ok(())
206}