Skip to main content

alibi_core/store/stateless/
api_keys.rs

1//! Instance-local API-key rows. Each Source write phase locks independently.
2use crate::store::ApiKeyStore;
3use crate::store::stateless::StatelessStore;
4use crate::{
5    ApiKey, ApiKeyStartText, AuthError, AuthResult, ConsumeApiKeyResult, CreateApiKey, UpdateApiKey,
6};
7use async_trait::async_trait;
8use chrono::{DateTime, Utc};
9
10#[async_trait]
11impl ApiKeyStore for StatelessStore {
12    async fn create_api_key(&self, input: CreateApiKey) -> AuthResult<ApiKey> {
13        let now = Utc::now().to_rfc3339();
14        let start = input.start.map(|start| match start.storage_text() {
15            ApiKeyStartText::Utf8(text) => Ok(text),
16            ApiKeyStartText::Wtf8(_) => Err(AuthError::internal(
17                "Unpaired UTF-16 API-key starting characters cannot be represented by the native String schema",
18            )),
19        }).transpose()?;
20        let row = ApiKey {
21            id: uuid::Uuid::new_v4().to_string(),
22            name: input.name,
23            start,
24            prefix: input.prefix,
25            key_hash: input.key_hash,
26            reference_id: input.reference_id,
27            config_id: input.config_id,
28            refill_interval: input.refill_interval,
29            refill_amount: input.refill_amount,
30            last_refill_at: None,
31            enabled: input.enabled,
32            rate_limit_enabled: input.rate_limit_enabled,
33            rate_limit_time_window: input.rate_limit_time_window,
34            rate_limit_max: input.rate_limit_max,
35            request_count: Some(0.0),
36            remaining: input.remaining,
37            last_request: None,
38            expires_at: validated_date(input.expires_at)?,
39            created_at: now.clone(),
40            updated_at: now,
41            permissions: input.permissions,
42            metadata: input.metadata,
43        };
44        _ = self.lock()?.api_keys.insert(row.id.clone(), row.clone());
45        Ok(row)
46    }
47    async fn get_api_key_by_id(&self, id: &str) -> AuthResult<Option<ApiKey>> {
48        Ok(self.lock()?.api_keys.get(id).cloned())
49    }
50    async fn get_api_key_by_hash(&self, hash: &str) -> AuthResult<Option<ApiKey>> {
51        Ok(self
52            .lock()?
53            .api_keys
54            .values()
55            .find(|row| row.key_hash == hash)
56            .cloned())
57    }
58    async fn list_api_keys_by_reference(&self, reference_id: &str) -> AuthResult<Vec<ApiKey>> {
59        Ok(self
60            .lock()?
61            .api_keys
62            .values()
63            .filter(|row| row.reference_id == reference_id)
64            .cloned()
65            .collect())
66    }
67    async fn update_api_key(&self, id: &str, mut update: UpdateApiKey) -> AuthResult<ApiKey> {
68        // Validate before writing: a bad later field cannot leave a partial update.
69        update.expires_at = update.expires_at.map(validated_date).transpose()?;
70        update.last_request = update.last_request.map(validated_date).transpose()?;
71        update.last_refill_at = update.last_refill_at.map(validated_date).transpose()?;
72        let mut state = self.lock()?;
73        let row = state.api_keys.get_mut(id).ok_or_else(missing_key)?;
74        macro_rules! optional {
75            ($($field:ident),*) => { $(if let Some(value) = update.$field { row.$field = Some(value); })* };
76        }
77        optional!(
78            name,
79            remaining,
80            rate_limit_time_window,
81            rate_limit_max,
82            refill_interval,
83            refill_amount,
84            permissions,
85            metadata,
86            request_count
87        );
88        macro_rules! direct {
89            ($($field:ident),*) => { $(if let Some(value) = update.$field { row.$field = value; })* };
90        }
91        direct!(
92            enabled,
93            rate_limit_enabled,
94            expires_at,
95            last_request,
96            last_refill_at
97        );
98        row.updated_at = Utc::now().to_rfc3339();
99        Ok(row.clone())
100    }
101    async fn delete_api_key(&self, id: &str) -> AuthResult<()> {
102        _ = self.lock()?.api_keys.shift_remove(id);
103        Ok(())
104    }
105    async fn delete_expired_api_keys(&self) -> AuthResult<usize> {
106        let mut state = self.lock()?;
107        let now = Utc::now();
108        let before = state.api_keys.len();
109        // All stored dates were validated at the write boundary.
110        state.api_keys.retain(|_, row| {
111            row.expires_at
112                .as_deref()
113                .is_none_or(|value| stored_date(value).is_ok_and(|date| date >= now))
114        });
115        Ok(before - state.api_keys.len())
116    }
117    async fn consume_api_key_usage(
118        &self,
119        id: &str,
120        global_rate_limit_enabled: bool,
121    ) -> AuthResult<ConsumeApiKeyResult> {
122        let mut state = self.lock()?;
123        let stored = state.api_keys.get_mut(id).ok_or_else(missing_key)?;
124        let mut row = stored.clone();
125        let observed = row.clone();
126        if observed.remaining.is_some() && !consume_remaining(&mut row, &observed, Utc::now())? {
127            return Ok(ConsumeApiKeyResult::UsageExhausted);
128        }
129        // Commit the combined operation only on success or policy denial.
130        loop {
131            let observed = row.clone();
132            match consume_rate(&mut row, &observed, global_rate_limit_enabled, Utc::now())? {
133                RateClaim::Denied(delay) => {
134                    *stored = row;
135                    return Ok(ConsumeApiKeyResult::RateLimited {
136                        try_again_in: delay,
137                    });
138                }
139                RateClaim::Written => break,
140                RateClaim::Retry => {}
141            }
142        }
143        row.updated_at = Utc::now().to_rfc3339();
144        *stored = row.clone();
145        Ok(ConsumeApiKeyResult::Allowed(Box::new(row)))
146    }
147
148    async fn consume_api_key_usage_from_snapshot(
149        &self,
150        observed: &ApiKey,
151        global_rate_limit_enabled: bool,
152    ) -> AuthResult<ConsumeApiKeyResult> {
153        let mut row = if observed.remaining.is_some() {
154            let mut state = self.lock()?;
155            let Some(current) = state.api_keys.get_mut(&observed.id) else {
156                return Ok(ConsumeApiKeyResult::UsageExhausted);
157            };
158            if !consume_remaining(current, observed, Utc::now())? {
159                return Ok(ConsumeApiKeyResult::UsageExhausted);
160            }
161            current.clone()
162        } else {
163            observed.clone()
164        };
165        loop {
166            let mut state = self.lock()?;
167            let Some(current) = state.api_keys.get_mut(&row.id) else {
168                return Err(invalid_key());
169            };
170            match consume_rate(current, &row, global_rate_limit_enabled, Utc::now())? {
171                RateClaim::Denied(delay) => {
172                    return Ok(ConsumeApiKeyResult::RateLimited {
173                        try_again_in: delay,
174                    });
175                }
176                RateClaim::Written => break,
177                RateClaim::Retry => row = current.clone(),
178            }
179        }
180        // A separate final write returns genuine current fields, never the snapshot.
181        let mut state = self.lock()?;
182        let current = state.api_keys.get_mut(&row.id).ok_or_else(invalid_key)?;
183        current.updated_at = Utc::now().to_rfc3339();
184        Ok(ConsumeApiKeyResult::Allowed(Box::new(current.clone())))
185    }
186}
187
188#[expect(
189    clippy::as_conversions,
190    clippy::cast_precision_loss,
191    reason = "Match JavaScript millisecond arithmetic"
192)]
193fn consume_remaining(
194    current: &mut ApiKey,
195    observed: &ApiKey,
196    now: DateTime<Utc>,
197) -> AuthResult<bool> {
198    if let (Some(interval), Some(amount)) = (observed.refill_interval, observed.refill_amount)
199        && interval != 0.0
200        && amount != 0.0
201        && (now.timestamp_millis()
202            - stored_date(
203                observed
204                    .last_refill_at
205                    .as_deref()
206                    .unwrap_or(&observed.created_at),
207            )?
208            .timestamp_millis()) as f64
209            > interval
210        && same_date(
211            current.last_refill_at.as_deref(),
212            observed.last_refill_at.as_deref(),
213        )?
214    {
215        current.remaining = Some(amount - 1.0);
216        current.last_refill_at = Some(now.to_rfc3339());
217        return Ok(true);
218    }
219    if let Some(remaining) = current.remaining
220        && remaining > 0.0
221    {
222        current.remaining = Some(remaining - 1.0);
223        return Ok(true);
224    }
225    Ok(false)
226}
227
228enum RateClaim {
229    Written,
230    Retry,
231    Denied(f64),
232}
233
234#[expect(
235    clippy::as_conversions,
236    clippy::cast_precision_loss,
237    reason = "Match JavaScript millisecond arithmetic and Date TimeClip"
238)]
239fn consume_rate(
240    current: &mut ApiKey,
241    observed: &ApiKey,
242    enabled: bool,
243    now: DateTime<Utc>,
244) -> AuthResult<RateClaim> {
245    if !enabled || !observed.rate_limit_enabled {
246        current.last_request = Some(now.to_rfc3339());
247        return Ok(RateClaim::Written);
248    }
249    let (Some(window), Some(max)) = (observed.rate_limit_time_window, observed.rate_limit_max)
250    else {
251        return Ok(RateClaim::Written);
252    };
253    let previous = observed
254        .last_request
255        .as_deref()
256        .map(stored_date)
257        .transpose()?;
258    let current_previous = current
259        .last_request
260        .as_deref()
261        .map(stored_date)
262        .transpose()?;
263    let elapsed = previous.map(|last| (now.timestamp_millis() - last.timestamp_millis()) as f64);
264    let window_start = (now.timestamp_millis() as f64 - window).trunc();
265    if !window_start.is_finite() || window_start.abs() > 8_640_000_000_000_000.0 {
266        return Err(AuthError::internal(
267            "Invalid API key rate-limit window date",
268        ));
269    }
270    let guard = current_previous.map(|date| date.timestamp_millis() as f64);
271    match elapsed {
272        None if current_previous.is_none() => current.request_count = Some(1.0),
273        Some(elapsed) if elapsed > window && guard.is_some_and(|last| last <= window_start) => {
274            current.request_count = Some(1.0);
275        }
276        Some(elapsed) if elapsed <= window => {
277            if observed.request_count.unwrap_or(0.0) >= max {
278                return Ok(RateClaim::Denied((window - elapsed).ceil()));
279            }
280            if !guard.is_some_and(|last| last > window_start)
281                || current.request_count.unwrap_or(0.0) >= max
282            {
283                return Ok(RateClaim::Retry);
284            }
285            // Memory incrementOne treats null as zero. SQL NULL arithmetic is separate.
286            current.request_count = Some(current.request_count.unwrap_or(0.0) + 1.0);
287        }
288        _ => return Ok(RateClaim::Retry),
289    }
290    current.last_request = Some(now.to_rfc3339());
291    Ok(RateClaim::Written)
292}
293
294fn same_date(left: Option<&str>, right: Option<&str>) -> AuthResult<bool> {
295    match (left, right) {
296        (None, None) => Ok(true),
297        (Some(left), Some(right)) => {
298            Ok(stored_date(left)?.timestamp_millis() == stored_date(right)?.timestamp_millis())
299        }
300        _ => Ok(false),
301    }
302}
303fn validated_date(value: Option<String>) -> AuthResult<Option<String>> {
304    value
305        .map(|value| stored_date(&value).map(|_| value))
306        .transpose()
307}
308fn stored_date(value: &str) -> AuthResult<DateTime<Utc>> {
309    DateTime::parse_from_rfc3339(value)
310        .map(|date| date.with_timezone(&Utc))
311        .map_err(|error| AuthError::internal(format!("Invalid stored API key date: {error}")))
312}
313fn missing_key() -> AuthError {
314    AuthError::not_found("API Key not found")
315}
316const fn invalid_key() -> AuthError {
317    AuthError::Upstream {
318        status: 401,
319        code: "INVALID_API_KEY",
320        message: "Invalid API key.",
321    }
322}