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#[derive(Debug, Deserialize, Validate)]
145#[serde(rename_all = "camelCase")]
146pub(crate) struct DeleteKeyRequest {
147    #[serde(default, deserialize_with = "present")]
148    pub config_id: Option<String>,
149    pub key_id: String,
150}
151
152/// Query parameters accepted by `GET /api-key/list`.
153#[derive(Debug, Default)]
154pub(crate) struct ListKeysQuery {
155    pub config_id: Option<String>,
156    pub organization_id: Option<String>,
157    pub limit: Option<usize>,
158    pub offset: Option<usize>,
159    pub sort_by: Option<String>,
160    pub sort_direction: Option<String>,
161}
162
163impl ListKeysQuery {
164    #[expect(
165        clippy::as_conversions,
166        clippy::cast_possible_truncation,
167        clippy::cast_sign_loss,
168        reason = "JavaScript-compatible numbers deliberately retain IEEE754 rounding and guarded integer coercion at the wire or adapter boundary"
169    )]
170    pub(crate) fn from_request(req: &AuthRequest) -> Result<Self, AuthResponse> {
171        let number = |key: &str| -> Result<Option<usize>, AuthResponse> {
172            let Some(value) = req.query.get(key) else {
173                return Ok(None);
174            };
175            let parsed = if value.trim().is_empty() {
176                0.0
177            } else {
178                value.trim().parse::<f64>().map_err(|_error| {
179                    query_error(key, "Invalid input: expected number, received NaN")
180                })?
181            };
182            if !parsed.is_finite() {
183                return Err(query_error(
184                    key,
185                    if parsed.is_nan() {
186                        "Invalid input: expected number, received NaN"
187                    } else {
188                        "Invalid input: expected number, received number"
189                    },
190                ));
191            }
192            if parsed.fract() != 0.0 {
193                return Err(query_error(
194                    key,
195                    "Invalid input: expected int, received number",
196                ));
197            }
198            if parsed < 0.0 {
199                return Err(query_error(key, "Too small: expected number to be >=0"));
200            }
201            Ok(Some(parsed as usize))
202        };
203        if let Some(direction) = req.query.get("sortDirection")
204            && !matches!(direction.as_str(), "asc" | "desc")
205        {
206            return Err(query_error(
207                "sortDirection",
208                "Invalid option: expected one of \"asc\"|\"desc\"",
209            ));
210        }
211        Ok(Self {
212            config_id: req.query.get("configId").cloned(),
213            organization_id: req.query.get("organizationId").cloned(),
214            limit: number("limit")?,
215            offset: number("offset")?,
216            sort_by: req.query.get("sortBy").cloned(),
217            sort_direction: req.query.get("sortDirection").cloned(),
218        })
219    }
220}
221
222/// Paginated API key response; absent pagination parameters are omitted.
223#[derive(Debug, Serialize)]
224pub(crate) struct ListKeysResponse {
225    #[serde(rename = "apiKeys")]
226    pub api_keys: Vec<ApiKeyView>,
227    pub total: usize,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub limit: Option<usize>,
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub offset: Option<usize>,
232}
233
234/// Newly issued API key. The plaintext key is only returned during creation.
235#[derive(Debug, Serialize, Deserialize)]
236pub struct CreateKeyResponse {
237    /// Plaintext secret to deliver to the key holder.
238    pub key: String,
239    /// Stored public key attributes, without the secret hash.
240    #[serde(flatten)]
241    pub api_key: ApiKeyView,
242}
243
244/// Result of trusted forced expiration cleanup. Store failures are logged.
245#[derive(Debug, Serialize, Deserialize)]
246pub struct DeleteExpiredApiKeysResponse {
247    pub success: bool,
248    pub error: Option<String>,
249}
250
251/// Parse with the DTO schema and retain the field path in upstream validation errors.
252pub(crate) fn parse_api_key_body<T>(request: &AuthRequest) -> Result<T, AuthResponse>
253where
254    T: serde::de::DeserializeOwned + Validate,
255{
256    let input: JsValue = request
257        .body_as_json()
258        .map_err(|_error| validation_response("body", "Invalid JSON"))?;
259    parse_api_key_value(&input)
260}
261
262pub(super) fn parse_api_key_value<T>(input: &JsValue) -> Result<T, AuthResponse>
263where
264    T: serde::de::DeserializeOwned + Validate,
265{
266    let body: T = serde_path_to_error::deserialize(input.clone()).map_err(|error| {
267        let mut path = error.path().to_string().replace('[', ".").replace(']', "");
268        if path == "." {
269            path.clear();
270        }
271        let detail = error.inner().to_string();
272        let input_number = {
273            let mut value = Some(input);
274            for segment in error.path() {
275                value = value.and_then(|current| match segment {
276                    serde_path_to_error::Segment::Seq { index } => current.as_array()?.get(*index),
277                    serde_path_to_error::Segment::Map { key } => current.get(key),
278                    serde_path_to_error::Segment::Enum { variant } => current.get(variant),
279                    serde_path_to_error::Segment::Unknown => None,
280                });
281            }
282            value.and_then(JsValue::as_f64)
283        };
284        let nonfinite_input = input_number.filter(|number| !number.is_finite());
285        let message = detail
286            .strip_prefix("missing field `")
287            .and_then(|rest| rest.split('`').next())
288            .map_or_else(
289                || {
290                    if let Some((received, expected)) = detail
291                        .strip_prefix("invalid type: ")
292                        .and_then(|detail| detail.split_once(", expected "))
293                    {
294                        let expected = expected.split(" at line ").next().unwrap_or(expected);
295                        let expected = match expected {
296                            "a string" => "string",
297                            "a boolean" => "boolean",
298                            "f64" => "number",
299                            "a sequence" => "array",
300                            "a map" => "record",
301                            _ => "object",
302                        };
303                        let received = nonfinite_input.map_or_else(
304                            || {
305                                if received.starts_with("string") {
306                                    "string"
307                                } else if received.starts_with("integer")
308                                    || received.starts_with("floating point")
309                                {
310                                    "number"
311                                } else if received.starts_with("boolean") {
312                                    "boolean"
313                                } else {
314                                    match received {
315                                        "sequence" => "array",
316                                        "map" => "object",
317                                        value => value,
318                                    }
319                                }
320                            },
321                            |number| {
322                                if number.is_sign_negative() {
323                                    "-Infinity"
324                                } else {
325                                    "Infinity"
326                                }
327                            },
328                        );
329                        format!("Invalid input: expected {expected}, received {received}")
330                    } else if detail.starts_with("number out of range") && nonfinite_input.is_some()
331                    {
332                        format!(
333                            "Invalid input: expected number, received {}",
334                            if nonfinite_input.is_some_and(f64::is_sign_negative) {
335                                "-Infinity"
336                            } else {
337                                "Infinity"
338                            }
339                        )
340                    } else {
341                        detail.clone()
342                    }
343                },
344                |field| {
345                    field.clone_into(&mut path);
346                    "Invalid input: expected string, received undefined".to_owned()
347                },
348            );
349        let location = if path.is_empty() {
350            "body".to_owned()
351        } else {
352            format!("body.{path}")
353        };
354        validation_response(&location, &message)
355    })?;
356    body.validate()
357        .map_err(|error| alibi_core::validation_error_response(&error))?;
358    Ok(body)
359}
360
361fn numeric_errors(
362    fields: &[(&'static str, Option<f64>, Option<f64>)],
363) -> validator::ValidationErrors {
364    let mut errors = validator::ValidationErrors::new();
365    for &(field, value, minimum) in fields {
366        let Some(value) = value else {
367            continue;
368        };
369        let message = if value.is_finite() {
370            minimum
371                .filter(|minimum| value < *minimum)
372                .map(|minimum| format!("Too small: expected number to be >={minimum}"))
373        } else {
374            Some(format!(
375                "Invalid input: expected number, received {}",
376                if value.is_nan() {
377                    "NaN"
378                } else if value.is_sign_negative() {
379                    "-Infinity"
380                } else {
381                    "Infinity"
382                }
383            ))
384        };
385        if let Some(message) = message {
386            let mut error = validator::ValidationError::new("range");
387            error.message = Some(message.into());
388            errors.add(field, error);
389        }
390    }
391    errors
392}
393
394/// Deserialize an explicitly supplied value without treating null as an absent field.
395fn present<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
396where
397    D: Deserializer<'de>,
398    T: Deserialize<'de>,
399{
400    T::deserialize(deserializer).map(Some)
401}
402
403fn coerced_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
404where
405    D: Deserializer<'de>,
406{
407    fn js_string(value: &JsValue) -> String {
408        match value {
409            JsValue::String(value) => value.clone(),
410            JsValue::Array(values) => values
411                .iter()
412                .map(|value_2| {
413                    if value_2.is_null() {
414                        String::new()
415                    } else {
416                        js_string(value_2)
417                    }
418                })
419                .collect::<Vec<_>>()
420                .join(","),
421            JsValue::Object(_) => "[object Object]".into(),
422            JsValue::Number(number) => ryu_js::Buffer::new().format(*number).to_owned(),
423            JsValue::Null => "null".into(),
424            JsValue::Bool(value) => value.to_string(),
425        }
426    }
427    JsValue::deserialize(deserializer).map(|value| Some(js_string(&value)))
428}
429
430fn validate_prefix(prefix: &str) -> Result<(), validator::ValidationError> {
431    if !prefix.is_empty()
432        && prefix
433            .bytes()
434            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
435    {
436        Ok(())
437    } else {
438        let mut error = validator::ValidationError::new("regex");
439        error.message = Some(
440            "Invalid prefix format, must be alphanumeric and contain only underscores and hyphens."
441                .into(),
442        );
443        Err(error)
444    }
445}
446
447fn query_error(field: &str, message: &str) -> AuthResponse {
448    validation_response(&format!("query.{field}"), message)
449}
450
451fn validation_response(location: &str, message: &str) -> AuthResponse {
452    let body = serde_json::json!({
453        "code": "VALIDATION_ERROR", "message": format!("[{location}] {message}")
454    });
455    AuthResponse::text(400, body.to_string()).with_header("content-type", "application/json")
456}