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