Skip to main content

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

1//! `admin users api-key issue`.
2//!
3//! `--if-absent` makes issuance idempotent per user and key name: a live key
4//! with that name that does not expire within `--renew-before-days` is
5//! reported (`issued: false`) instead of minting another. `--secret-file`
6//! writes the secret to a `0600` file so it never reaches stdout; a key whose
7//! secret file cannot be written is revoked, so a rerun with `--if-absent`
8//! issues a fresh one instead of reporting a key nobody holds.
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13use 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}