Skip to main content

systemprompt_users/services/
api_key_service.rs

1//! API-key minting: prefixed secrets with stored hashes.
2//!
3//! A key's `purpose` is bound by its issuer (for example `workflow:<id>`) and
4//! is `None` for a person's own key.
5//!
6//! Every key passes through [`ApiKeyLifetime`]: with a ceiling configured, an
7//! omitted expiry becomes `now + max` and an expiry beyond it is refused with
8//! [`UserError::ApiKeyLifetimeExceeded`]. The ceiling also holds for keys
9//! issued before it was configured: [`ApiKeyService::verify`] refuses a key
10//! older than the ceiling, whatever its stored expiry.
11//!
12//! Copyright (c) systemprompt.io — Business Source License 1.1.
13//! See <https://systemprompt.io> for licensing details.
14
15use chrono::{DateTime, Utc};
16use rand::Rng;
17use sha2::{Digest, Sha256};
18use std::sync::Arc;
19use subtle::ConstantTimeEq;
20use systemprompt_identifiers::{ApiKeyId, UserId};
21use systemprompt_models::attribution::ScopeBinding;
22
23use crate::error::{Result, UserError};
24use crate::models::{ApiKeyLimits, NewApiKey, UserApiKey};
25use crate::repository::{CreateApiKeyParams, UserRepository};
26
27pub const API_KEY_PREFIX: &str = "sp-live-";
28const SECRET_BYTES: usize = 32;
29const PREFIX_ID_BYTES: usize = 6;
30const USAGE_TOUCH_INTERVAL: chrono::TimeDelta = chrono::TimeDelta::seconds(60);
31
32#[derive(Debug, Clone)]
33pub struct IssueApiKeyParams<'a> {
34    pub user_id: &'a UserId,
35    pub name: &'a str,
36    pub expires_at: Option<DateTime<Utc>>,
37    pub limits: &'a ApiKeyLimits,
38    pub scopes: &'a [ScopeBinding],
39    pub purpose: Option<&'a str>,
40}
41
42/// The lifetime ceiling applied to every issued key.
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44pub struct ApiKeyLifetime {
45    max_days: Option<u32>,
46}
47
48impl ApiKeyLifetime {
49    pub const fn unbounded() -> Self {
50        Self { max_days: None }
51    }
52
53    pub const fn from_max_days(max_days: Option<u32>) -> Self {
54        Self { max_days }
55    }
56
57    pub const fn max_days(self) -> Option<u32> {
58        self.max_days
59    }
60
61    pub fn bound(
62        self,
63        requested: Option<DateTime<Utc>>,
64        now: DateTime<Utc>,
65    ) -> Result<Option<DateTime<Utc>>> {
66        let Some(max_days) = self.max_days else {
67            return Ok(requested);
68        };
69        let ceiling = now + chrono::TimeDelta::days(i64::from(max_days));
70        match requested {
71            None => Ok(Some(ceiling)),
72            Some(at) if at > ceiling => Err(UserError::ApiKeyLifetimeExceeded {
73                requested: at,
74                max_days,
75            }),
76            Some(at) => Ok(Some(at)),
77        }
78    }
79
80    pub fn admits(self, created_at: Option<DateTime<Utc>>, now: DateTime<Utc>) -> bool {
81        let Some(max_days) = self.max_days else {
82            return true;
83        };
84        created_at.is_some_and(|at| now < at + chrono::TimeDelta::days(i64::from(max_days)))
85    }
86}
87
88/// Outcome of [`ApiKeyService::ensure`]: the reused live key, or a fresh one
89/// whose secret is available exactly once.
90#[derive(Debug)]
91pub enum EnsuredApiKey {
92    Existing(UserApiKey),
93    Issued(NewApiKey),
94}
95
96#[derive(Debug, Clone)]
97pub struct ApiKeyService {
98    repository: Arc<UserRepository>,
99    lifetime: ApiKeyLifetime,
100}
101
102impl ApiKeyService {
103    pub const fn new(repository: Arc<UserRepository>, lifetime: ApiKeyLifetime) -> Self {
104        Self {
105            repository,
106            lifetime,
107        }
108    }
109
110    pub async fn issue(&self, params: IssueApiKeyParams<'_>) -> Result<NewApiKey> {
111        let trimmed = params.name.trim();
112        if trimmed.is_empty() {
113            return Err(UserError::Validation(
114                "api key name must not be empty".into(),
115            ));
116        }
117
118        validate_limits(params.limits)?;
119        validate_scopes(params.scopes)?;
120        let expires_at = self.lifetime.bound(params.expires_at, Utc::now())?;
121
122        let id = ApiKeyId::generate();
123        let (secret, key_prefix, key_hash) = generate_secret();
124
125        let record = self
126            .repository
127            .create_api_key(CreateApiKeyParams {
128                id: &id,
129                user_id: params.user_id,
130                name: trimmed,
131                key_prefix: &key_prefix,
132                key_hash: &key_hash,
133                expires_at,
134                limits: params.limits,
135                scopes: params.scopes,
136                purpose: params.purpose,
137            })
138            .await?;
139
140        Ok(NewApiKey { record, secret })
141    }
142
143    pub async fn ensure(
144        &self,
145        params: IssueApiKeyParams<'_>,
146        renew_before: chrono::TimeDelta,
147    ) -> Result<EnsuredApiKey> {
148        let name = params.name.trim();
149        let now = Utc::now();
150        let renew_at = now + renew_before;
151        let existing = self
152            .repository
153            .list_api_keys_for_user(params.user_id)
154            .await?
155            .into_iter()
156            .find(|key| {
157                key.name == name
158                    && key.is_active(now)
159                    && self.lifetime.admits(key.created_at, renew_at)
160                    && key.expires_at.is_none_or(|at| at > renew_at)
161            });
162        match existing {
163            Some(key) => Ok(EnsuredApiKey::Existing(key)),
164            None => self.issue(params).await.map(EnsuredApiKey::Issued),
165        }
166    }
167
168    pub async fn verify(&self, presented_secret: &str) -> Result<Option<UserApiKey>> {
169        let Some(key_prefix) = extract_prefix(presented_secret) else {
170            return Ok(None);
171        };
172
173        let Some(record) = self
174            .repository
175            .find_active_api_key_by_prefix(&key_prefix)
176            .await?
177        else {
178            return Ok(None);
179        };
180
181        let now = Utc::now();
182        if !record.is_active(now) || !self.lifetime.admits(record.created_at, now) {
183            return Ok(None);
184        }
185
186        let presented_hash = hash_secret(presented_secret);
187        if presented_hash
188            .as_bytes()
189            .ct_eq(record.key_hash.as_bytes())
190            .into()
191        {
192            if usage_is_stale(record.last_used_at, Utc::now()) {
193                self.repository.touch_api_key_usage(&record.id).await?;
194            }
195            Ok(Some(record))
196        } else {
197            Ok(None)
198        }
199    }
200
201    pub async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<UserApiKey>> {
202        self.repository.list_api_keys_for_user(user_id).await
203    }
204
205    pub async fn revoke(&self, id: &ApiKeyId, user_id: &UserId) -> Result<bool> {
206        self.repository.revoke_api_key(id, user_id).await
207    }
208}
209
210fn validate_limits(limits: &ApiKeyLimits) -> Result<()> {
211    if limits
212        .model_allowlist
213        .as_ref()
214        .is_some_and(|models| models.is_empty() || models.iter().any(|m| m.trim().is_empty()))
215    {
216        return Err(UserError::Validation(
217            "model_allowlist must name at least one model, or be omitted".into(),
218        ));
219    }
220    if limits.budget_microdollars.is_some_and(|b| b < 0)
221        || limits.max_requests.is_some_and(|m| m < 0)
222    {
223        return Err(UserError::Validation(
224            "budget_microdollars and max_requests must not be negative".into(),
225        ));
226    }
227    let ceiling = limits.budget_microdollars.is_some() || limits.max_requests.is_some();
228    if ceiling && limits.request_window_seconds.is_none_or(|w| w <= 0) {
229        return Err(UserError::Validation(
230            "budget_microdollars and max_requests need a positive request_window_seconds".into(),
231        ));
232    }
233    Ok(())
234}
235
236fn validate_scopes(scopes: &[ScopeBinding]) -> Result<()> {
237    for (index, scope) in scopes.iter().enumerate() {
238        if scope.value.trim().is_empty() {
239            return Err(UserError::Validation(format!(
240                "scope {} must bind a non-empty value",
241                scope.dimension
242            )));
243        }
244        if scopes[..index]
245            .iter()
246            .any(|s| s.dimension == scope.dimension)
247        {
248            return Err(UserError::Validation(format!(
249                "scope {} is bound more than once",
250                scope.dimension
251            )));
252        }
253    }
254    Ok(())
255}
256
257fn generate_secret() -> (String, String, String) {
258    let mut raw = [0u8; SECRET_BYTES];
259    rand::rng().fill_bytes(&mut raw);
260    let encoded = hex::encode(raw);
261    let key_prefix = format!("{API_KEY_PREFIX}{}", &encoded[..PREFIX_ID_BYTES * 2]);
262    let secret = format!("{key_prefix}.{}", &encoded[PREFIX_ID_BYTES * 2..]);
263    let key_hash = hash_secret(&secret);
264    (secret, key_prefix, key_hash)
265}
266
267fn hash_secret(secret: &str) -> String {
268    let mut hasher = Sha256::new();
269    hasher.update(secret.as_bytes());
270    hex::encode(hasher.finalize())
271}
272
273fn extract_prefix(presented: &str) -> Option<String> {
274    if !presented.starts_with(API_KEY_PREFIX) {
275        return None;
276    }
277    let dot = presented.find('.')?;
278    Some(presented[..dot].to_string())
279}
280
281fn usage_is_stale(last_used_at: Option<DateTime<Utc>>, now: DateTime<Utc>) -> bool {
282    last_used_at.is_none_or(|at| now - at >= USAGE_TOUCH_INTERVAL)
283}