Skip to main content

alibi_plugins/api_key/
types.rs

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