Skip to main content

alibi_plugins/api_key/
types.rs

1use super::ApiKeyPermissions;
2use alibi_core::error::codes;
3use alibi_core::utils::json::JsValue;
4pub(crate) use alibi_core::wire::ApiKeyView;
5use alibi_core::{AuthRequest, AuthResponse};
6use serde::{Deserialize, Deserializer, Serialize};
7use validator::Validate;
8
9/// API key creation parameters for HTTP and trusted server callers.
10#[serde_with::skip_serializing_none]
11#[derive(Debug, Default, Deserialize, Serialize)]
12#[serde(rename_all = "camelCase")]
13pub struct CreateKeyRequest {
14    /// Configuration to use, or the default configuration when absent.
15    #[serde(default, deserialize_with = "present")]
16    pub config_id: Option<String>,
17    /// User authorizing a server-side creation; HTTP clients must omit this field.
18    #[serde(default, deserialize_with = "coerced_string")]
19    pub user_id: Option<String>,
20    /// Organization owning the key when the configuration references organizations.
21    #[serde(default, deserialize_with = "coerced_string")]
22    pub organization_id: Option<String>,
23    /// Display name for the key.
24    #[serde(default, deserialize_with = "present")]
25    pub name: Option<String>,
26    /// Prefix prepended to the generated key.
27    #[serde(default, deserialize_with = "present")]
28    pub prefix: Option<String>,
29    /// Lifetime in seconds; null or absence uses the configured default.
30    pub expires_in: Option<f64>,
31    /// Remaining uses, available only to trusted server callers.
32    pub remaining: Option<f64>,
33    /// Enable rate limiting for this key, available only to server callers.
34    #[serde(default, deserialize_with = "present")]
35    pub rate_limit_enabled: Option<bool>,
36    /// Rate limit window in milliseconds, available only to server callers.
37    #[serde(default, deserialize_with = "present")]
38    pub rate_limit_time_window: Option<f64>,
39    /// Requests per window, available only to server callers.
40    #[serde(default, deserialize_with = "present")]
41    pub rate_limit_max: Option<f64>,
42    /// Refill interval in milliseconds, available only to server callers.
43    #[serde(default, deserialize_with = "present")]
44    pub refill_interval: Option<f64>,
45    /// Uses restored per refill, available only to server callers.
46    #[serde(default, deserialize_with = "present")]
47    pub refill_amount: Option<f64>,
48    /// Resource permissions, available only to server callers.
49    #[serde(default, deserialize_with = "present")]
50    pub permissions: Option<ApiKeyPermissions>,
51    /// Optional metadata; preservation of null matches the upstream wire contract.
52    #[serde(default, deserialize_with = "present")]
53    pub metadata: Option<JsValue>,
54}
55
56/// API key updates for HTTP and trusted server callers.
57#[serde_with::skip_serializing_none]
58#[derive(Debug, Default, Deserialize, Serialize)]
59#[serde(rename_all = "camelCase")]
60pub struct UpdateKeyRequest {
61    /// Configuration used to locate the key.
62    #[serde(default, deserialize_with = "present")]
63    pub config_id: Option<String>,
64    /// Identifier of the key to update.
65    pub key_id: String,
66    /// User authorizing a server-side update, or the current session user over HTTP.
67    #[serde(default, deserialize_with = "coerced_string")]
68    pub user_id: Option<String>,
69    /// Replacement display name.
70    #[serde(default, deserialize_with = "present")]
71    pub name: Option<String>,
72    /// Whether the key can authenticate requests.
73    #[serde(default, deserialize_with = "present")]
74    pub enabled: Option<bool>,
75    /// Replacement remaining uses, available only to server callers.
76    #[serde(default, deserialize_with = "present")]
77    pub remaining: Option<f64>,
78    /// Enable rate limiting, available only to server callers.
79    #[serde(default, deserialize_with = "present")]
80    pub rate_limit_enabled: Option<bool>,
81    /// Replacement rate limit window in milliseconds, available only to server callers.
82    #[serde(default, deserialize_with = "present")]
83    pub rate_limit_time_window: Option<f64>,
84    /// Replacement requests per window, available only to server callers.
85    #[serde(default, deserialize_with = "present")]
86    pub rate_limit_max: Option<f64>,
87    /// Replacement refill interval in milliseconds, available only to server callers.
88    #[serde(default, deserialize_with = "present")]
89    pub refill_interval: Option<f64>,
90    /// Replacement uses per refill, available only to server callers.
91    #[serde(default, deserialize_with = "present")]
92    pub refill_amount: Option<f64>,
93    /// Replacement resource permissions; null clears permissions. Server callers only.
94    #[serde(default, with = "::serde_with::rust::double_option")]
95    pub permissions: Option<Option<ApiKeyPermissions>>,
96    /// Replacement metadata; null clears metadata when metadata is enabled.
97    #[serde(default, deserialize_with = "present")]
98    pub metadata: Option<JsValue>,
99    /// Absent leaves expiration unchanged, null clears expiration, and a value sets seconds from now.
100    #[serde(default, with = "::serde_with::rust::double_option")]
101    pub expires_in: Option<Option<f64>>,
102}
103
104impl Validate for CreateKeyRequest {
105    fn validate(&self) -> Result<(), validator::ValidationErrors> {
106        let mut errors = numeric_errors(&[
107            ("expiresIn", self.expires_in, Some(1.0)),
108            ("remaining", self.remaining, Some(0.0)),
109            ("refillAmount", self.refill_amount, Some(1.0)),
110            ("refillInterval", self.refill_interval, None),
111            ("rateLimitTimeWindow", self.rate_limit_time_window, None),
112            ("rateLimitMax", self.rate_limit_max, None),
113        ]);
114        if let Some(prefix) = self.prefix.as_deref()
115            && let Err(error) = validate_prefix(prefix)
116        {
117            errors.add("prefix", error);
118        }
119        if errors.is_empty() {
120            Ok(())
121        } else {
122            Err(errors)
123        }
124    }
125}
126
127impl Validate for UpdateKeyRequest {
128    fn validate(&self) -> Result<(), validator::ValidationErrors> {
129        let errors = numeric_errors(&[
130            ("expiresIn", self.expires_in.flatten(), Some(1.0)),
131            ("remaining", self.remaining, Some(1.0)),
132            ("refillAmount", self.refill_amount, None),
133            ("refillInterval", self.refill_interval, None),
134            ("rateLimitTimeWindow", self.rate_limit_time_window, None),
135            ("rateLimitMax", self.rate_limit_max, None),
136        ]);
137        if errors.is_empty() {
138            Ok(())
139        } else {
140            Err(errors)
141        }
142    }
143}
144
145/// Parameters for deleting one API key as an authenticated caller.
146#[serde_with::skip_serializing_none]
147#[derive(Debug, Deserialize, Serialize, Validate)]
148#[serde(rename_all = "camelCase")]
149pub struct DeleteKeyRequest {
150    #[serde(default, deserialize_with = "present")]
151    pub config_id: Option<String>,
152    pub key_id: String,
153}
154
155/// Query parameters accepted by `GET /api-key/list`.
156#[serde_with::skip_serializing_none]
157#[derive(Debug, Default, Serialize, Deserialize)]
158#[serde(rename_all = "camelCase")]
159pub struct ListKeysQuery {
160    pub config_id: Option<String>,
161    pub organization_id: Option<String>,
162    pub limit: Option<usize>,
163    pub offset: Option<usize>,
164    pub sort_by: Option<String>,
165    pub sort_direction: Option<String>,
166}
167
168impl ListKeysQuery {
169    #[expect(
170        clippy::as_conversions,
171        clippy::cast_possible_truncation,
172        clippy::cast_sign_loss,
173        reason = "JavaScript-compatible numbers deliberately retain IEEE754 rounding and guarded integer coercion at the wire or adapter boundary"
174    )]
175    pub(crate) fn from_request(req: &AuthRequest) -> Result<Self, AuthResponse> {
176        let number = |key: &str| -> Result<Option<usize>, AuthResponse> {
177            let Some(value) = req.query.get(key) else {
178                return Ok(None);
179            };
180            let parsed = if value.trim().is_empty() {
181                0.0
182            } else {
183                value.trim().parse::<f64>().map_err(|_error| {
184                    query_error(key, "Invalid input: expected number, received NaN")
185                })?
186            };
187            if !parsed.is_finite() {
188                return Err(query_error(
189                    key,
190                    if parsed.is_nan() {
191                        "Invalid input: expected number, received NaN"
192                    } else {
193                        "Invalid input: expected number, received number"
194                    },
195                ));
196            }
197            if parsed.fract() != 0.0 {
198                return Err(query_error(
199                    key,
200                    "Invalid input: expected int, received number",
201                ));
202            }
203            if parsed < 0.0 {
204                return Err(query_error(key, "Too small: expected number to be >=0"));
205            }
206            Ok(Some(parsed as usize))
207        };
208        if let Some(direction) = req.query.get("sortDirection")
209            && !matches!(direction.as_str(), "asc" | "desc")
210        {
211            return Err(query_error(
212                "sortDirection",
213                "Invalid option: expected one of \"asc\"|\"desc\"",
214            ));
215        }
216        Ok(Self {
217            config_id: req.query.get("configId").cloned(),
218            organization_id: req.query.get("organizationId").cloned(),
219            limit: number("limit")?,
220            offset: number("offset")?,
221            sort_by: req.query.get("sortBy").cloned(),
222            sort_direction: req.query.get("sortDirection").cloned(),
223        })
224    }
225}
226
227/// Paginated API key response; absent pagination parameters are omitted.
228#[derive(Debug, Serialize, Deserialize)]
229pub struct ListKeysResponse {
230    #[serde(rename = "apiKeys")]
231    pub api_keys: Vec<ApiKeyView>,
232    pub total: usize,
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub limit: Option<usize>,
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub offset: Option<usize>,
237}
238
239/// Newly issued API key. The plaintext key is only returned during creation.
240#[derive(Debug, Serialize, Deserialize)]
241pub struct CreateKeyResponse {
242    /// Plaintext secret to deliver to the key holder.
243    pub key: String,
244    /// Stored public key attributes, without the secret hash.
245    #[serde(flatten)]
246    pub api_key: ApiKeyView,
247}
248
249/// Result of trusted forced expiration cleanup. Store failures are logged.
250#[derive(Debug, Serialize, Deserialize)]
251pub struct DeleteExpiredApiKeysResponse {
252    pub success: bool,
253    pub error: Option<String>,
254}
255
256/// Parse with the DTO schema and retain the field path in upstream validation errors.
257pub(crate) fn parse_api_key_body<T>(request: &AuthRequest) -> Result<T, AuthResponse>
258where
259    T: serde::de::DeserializeOwned + Validate,
260{
261    let input: JsValue = request
262        .body_as_json()
263        .map_err(|_error| validation_response("body", "Invalid JSON"))?;
264    parse_api_key_value(&input)
265}
266
267pub(super) fn parse_api_key_value<T>(input: &JsValue) -> Result<T, AuthResponse>
268where
269    T: serde::de::DeserializeOwned + Validate,
270{
271    let body: T = serde_path_to_error::deserialize(input.clone()).map_err(|error| {
272        let mut path = error.path().to_string().replace('[', ".").replace(']', "");
273        if path == "." {
274            path.clear();
275        }
276        let detail = error.inner().to_string();
277        let input_number = {
278            let mut value = Some(input);
279            for segment in error.path() {
280                value = value.and_then(|current| match segment {
281                    serde_path_to_error::Segment::Seq { index } => current.as_array()?.get(*index),
282                    serde_path_to_error::Segment::Map { key } => current.get(key),
283                    serde_path_to_error::Segment::Enum { variant } => current.get(variant),
284                    serde_path_to_error::Segment::Unknown => None,
285                });
286            }
287            value.and_then(JsValue::as_f64)
288        };
289        let nonfinite_input = input_number.filter(|number| !number.is_finite());
290        let message = detail
291            .strip_prefix("missing field `")
292            .and_then(|rest| rest.split('`').next())
293            .map_or_else(
294                || {
295                    if let Some((received, expected)) = detail
296                        .strip_prefix("invalid type: ")
297                        .and_then(|detail| detail.split_once(", expected "))
298                    {
299                        let expected = expected.split(" at line ").next().unwrap_or(expected);
300                        let expected = match expected {
301                            "a string" => "string",
302                            "a boolean" => "boolean",
303                            "f64" => "number",
304                            "a sequence" => "array",
305                            "a map" => "record",
306                            _ => "object",
307                        };
308                        let received = nonfinite_input.map_or_else(
309                            || {
310                                if received.starts_with("string") {
311                                    "string"
312                                } else if received.starts_with("integer")
313                                    || received.starts_with("floating point")
314                                {
315                                    "number"
316                                } else if received.starts_with("boolean") {
317                                    "boolean"
318                                } else {
319                                    match received {
320                                        "sequence" => "array",
321                                        "map" => "object",
322                                        value => value,
323                                    }
324                                }
325                            },
326                            |number| {
327                                if number.is_sign_negative() {
328                                    "-Infinity"
329                                } else {
330                                    "Infinity"
331                                }
332                            },
333                        );
334                        format!("Invalid input: expected {expected}, received {received}")
335                    } else if detail.starts_with("number out of range") && nonfinite_input.is_some()
336                    {
337                        format!(
338                            "Invalid input: expected number, received {}",
339                            if nonfinite_input.is_some_and(f64::is_sign_negative) {
340                                "-Infinity"
341                            } else {
342                                "Infinity"
343                            }
344                        )
345                    } else {
346                        detail.clone()
347                    }
348                },
349                |field| {
350                    field.clone_into(&mut path);
351                    "Invalid input: expected string, received undefined".to_owned()
352                },
353            );
354        let location = if path.is_empty() {
355            "body".to_owned()
356        } else {
357            format!("body.{path}")
358        };
359        validation_response(&location, &message)
360    })?;
361    body.validate()
362        .map_err(|error| alibi_core::validation_error_response(&error))?;
363    Ok(body)
364}
365
366fn numeric_errors(
367    fields: &[(&'static str, Option<f64>, Option<f64>)],
368) -> validator::ValidationErrors {
369    let mut errors = validator::ValidationErrors::new();
370    for &(field, value, minimum) in fields {
371        let Some(value) = value else {
372            continue;
373        };
374        let message = if value.is_finite() {
375            minimum
376                .filter(|minimum| value < *minimum)
377                .map(|minimum| format!("Too small: expected number to be >={minimum}"))
378        } else {
379            Some(format!(
380                "Invalid input: expected number, received {}",
381                if value.is_nan() {
382                    "NaN"
383                } else if value.is_sign_negative() {
384                    "-Infinity"
385                } else {
386                    "Infinity"
387                }
388            ))
389        };
390        if let Some(message) = message {
391            let mut error = validator::ValidationError::new("range");
392            error.message = Some(message.into());
393            errors.add(field, error);
394        }
395    }
396    errors
397}
398
399/// Deserialize an explicitly supplied value without treating null as an absent field.
400fn present<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
401where
402    D: Deserializer<'de>,
403    T: Deserialize<'de>,
404{
405    T::deserialize(deserializer).map(Some)
406}
407
408fn coerced_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
409where
410    D: Deserializer<'de>,
411{
412    fn js_string(value: &JsValue) -> String {
413        match value {
414            JsValue::String(value) => value.clone(),
415            JsValue::Date(_) => value.coerce_string().unwrap_or_default(),
416            JsValue::Array(values) => values
417                .iter()
418                .map(|value_2| {
419                    if value_2.is_null() {
420                        String::new()
421                    } else {
422                        js_string(value_2)
423                    }
424                })
425                .collect::<Vec<_>>()
426                .join(","),
427            JsValue::Object(_) => "[object Object]".into(),
428            JsValue::Number(number) => ryu_js::Buffer::new().format(*number).to_owned(),
429            JsValue::Null => "null".into(),
430            JsValue::Bool(value) => value.to_string(),
431        }
432    }
433    JsValue::deserialize(deserializer).map(|value| Some(js_string(&value)))
434}
435
436fn validate_prefix(prefix: &str) -> Result<(), validator::ValidationError> {
437    if !prefix.is_empty()
438        && prefix
439            .bytes()
440            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
441    {
442        Ok(())
443    } else {
444        let mut error = validator::ValidationError::new("regex");
445        error.message = Some(
446            "Invalid prefix format, must be alphanumeric and contain only underscores and hyphens."
447                .into(),
448        );
449        Err(error)
450    }
451}
452
453fn query_error(field: &str, message: &str) -> AuthResponse {
454    validation_response(&format!("query.{field}"), message)
455}
456
457fn validation_response(location: &str, message: &str) -> AuthResponse {
458    let body = serde_json::json!({
459        "code": codes::VALIDATION_ERROR, "message": format!("[{location}] {message}")
460    });
461    AuthResponse::text(400, body.to_string()).with_header("content-type", "application/json")
462}