Skip to main content

alibi_plugins/api_key/
handlers.rs

1mod metadata_sort;
2
3use super::ApiKeyPlugin;
4use super::types::{
5    ApiKeyView, CreateKeyRequest, CreateKeyResponse, DeleteKeyRequest, ListKeysQuery,
6    ListKeysResponse, UpdateKeyRequest,
7};
8use crate::helpers;
9use alibi_core::{ApiKey, AuthContext, AuthResult, CreateApiKey, UpdateApiKey};
10
11impl ApiKeyPlugin {
12    /// Create a key on behalf of `body.user_id` from trusted server code.
13    /// Organization configurations still require the user's organization permission.
14    ///
15    /// # Errors
16    ///
17    /// Returns errors from input validation, permission checks, or API-key storage.
18    pub async fn create_key(
19        &self,
20        ctx: &AuthContext<impl alibi_core::AuthSchema>,
21        body: &CreateKeyRequest,
22    ) -> AuthResult<CreateKeyResponse> {
23        use validator::Validate as _;
24        body.validate()
25            .map_err(|error| alibi_core::AuthError::Validation(error.to_string()))?;
26        let user_id = body
27            .user_id
28            .as_deref()
29            .filter(|id| !id.is_empty())
30            .ok_or_else(|| super::api_key_error(super::ApiKeyErrorCode::UnauthorizedSession))?;
31        create_key_for_user(body, user_id, self, ctx, None).await
32    }
33
34    /// Update a key on behalf of `body.user_id` from trusted server code.
35    /// The caller must authorize access before invoking this server-only method.
36    ///
37    /// # Errors
38    ///
39    /// Returns errors from input validation, permission checks, or API-key storage.
40    pub async fn update_key(
41        &self,
42        ctx: &AuthContext<impl alibi_core::AuthSchema>,
43        body: &UpdateKeyRequest,
44    ) -> AuthResult<ApiKeyView> {
45        use validator::Validate as _;
46        body.validate()
47            .map_err(|error| alibi_core::AuthError::Validation(error.to_string()))?;
48        let user_id = body
49            .user_id
50            .as_deref()
51            .filter(|id| !id.is_empty())
52            .ok_or_else(|| super::api_key_error(super::ApiKeyErrorCode::UnauthorizedSession))?;
53        update_key_for_user(body, user_id, self, ctx).await
54    }
55}
56
57/// Check whether `key_permissions` (JSON object mapping role->actions) covers
58/// all of the `required_permissions`.
59///
60/// Mirrors the TypeScript `role(apiKeyPermissions).authorize(permissions)`
61/// implementation. Required actions must be a subset of the API key's actions
62/// for each resource/role.
63pub(super) fn check_permissions(
64    key_permissions_json: &str,
65    required: &serde_json::Value,
66) -> AuthResult<bool> {
67    use alibi_core::utils::json::{JsValue, parse_value};
68    let Some(required_map) = required.as_object().filter(|map| !map.is_empty()) else {
69        return Ok(false);
70    };
71    let Ok(permitted) = parse_value(key_permissions_json) else {
72        return Ok(false);
73    };
74    if !json_truthy(&permitted) {
75        return Ok(false);
76    }
77    for (resource, requested) in required_map {
78        let allowed = permitted.get(resource).or_else(|| {
79            let index = resource.parse::<usize>().ok()?;
80            (index.to_string() == *resource)
81                .then(|| permitted.as_array()?.get(index))
82                .flatten()
83        });
84        let Some(allowed) = allowed.filter(|value| json_truthy(value)) else {
85            return Ok(false);
86        };
87        let (actions, any) = if let Some(actions) = requested.as_array() {
88            (actions.as_slice(), false)
89        } else if let Some(request) = requested.as_object() {
90            let Some(actions) = request.get("actions").and_then(serde_json::Value::as_array) else {
91                return Ok(false);
92            };
93            (
94                actions.as_slice(),
95                request.get("connector").and_then(serde_json::Value::as_str) == Some("OR"),
96            )
97        } else {
98            return Err(alibi_core::AuthError::internal(
99                "Invalid access control request",
100            ));
101        };
102        if actions.is_empty() {
103            return Ok(false);
104        }
105        let mut authorized = false;
106        for action in actions {
107            let admitted = if let Some(action) = action.as_str() {
108                match allowed {
109                    JsValue::Array(values) => values.iter().any(|value| {
110                        value
111                            .as_str()
112                            .is_some_and(|value| value == action && !revived_permission_date(value))
113                    }),
114                    JsValue::String(value) if !revived_permission_date(value) => {
115                        value.contains(action)
116                    }
117                    JsValue::Null
118                    | JsValue::Bool(_)
119                    | JsValue::Number(_)
120                    | JsValue::Date(_)
121                    | JsValue::Object(_)
122                    | JsValue::String(_) => {
123                        return Err(alibi_core::AuthError::internal(
124                            "Stored permission actions do not support includes",
125                        ));
126                    }
127                }
128            } else {
129                false
130            };
131            if any && admitted {
132                authorized = true;
133                break;
134            }
135            if !any && !admitted {
136                return Ok(false);
137            }
138            authorized |= admitted;
139        }
140        if !authorized {
141            return Ok(false);
142        }
143    }
144    Ok(true)
145}
146
147// safeJSONParse revives this exact ISO-shaped string into a Date before role
148// authorization. A Date does not provide String.includes and is not equal to a
149// requested string when nested inside an action array.
150fn revived_permission_date(value: &str) -> bool {
151    alibi_core::utils::datetime::normalize_json_date(value).is_some()
152}
153
154pub(crate) async fn create_key_core(
155    body: &CreateKeyRequest,
156    user_id: impl AsRef<str>,
157    plugin: &ApiKeyPlugin,
158    ctx: &AuthContext<impl alibi_core::AuthSchema>,
159    request: Option<&alibi_core::AuthRequest>,
160) -> AuthResult<CreateKeyResponse> {
161    _ = plugin.resolve_configuration(body.config_id.as_deref())?;
162    if body.refill_amount.is_some()
163        || body.refill_interval.is_some()
164        || body.rate_limit_max.is_some()
165        || body.rate_limit_time_window.is_some()
166        || body.rate_limit_enabled.is_some()
167        || body.permissions.is_some()
168        || body.remaining.is_some()
169    {
170        return Err(super::api_key_error(
171            super::ApiKeyErrorCode::ServerOnlyProperty,
172        ));
173    }
174    if body.user_id.is_some() {
175        return Err(super::api_key_error(
176            super::ApiKeyErrorCode::UnauthorizedSession,
177        ));
178    }
179    create_key_for_user(body, user_id.as_ref(), plugin, ctx, request).await
180}
181
182#[expect(
183    clippy::too_many_lines,
184    reason = "Keep key policy checks, hook callbacks, and persistence in their observable request order"
185)]
186pub(super) async fn create_key_for_user(
187    body: &CreateKeyRequest,
188    user_id: &str,
189    plugin: &ApiKeyPlugin,
190    ctx: &AuthContext<impl alibi_core::AuthSchema>,
191    request: Option<&alibi_core::AuthRequest>,
192) -> AuthResult<CreateKeyResponse> {
193    let config = plugin.resolve_configuration(body.config_id.as_deref())?;
194    let reference_id = match config.references {
195        super::ApiKeyReferences::User => user_id.to_owned(),
196        super::ApiKeyReferences::Organization => {
197            let organization_id = body
198                .organization_id
199                .as_deref()
200                .filter(|id| !id.is_empty())
201                .ok_or_else(|| {
202                    super::api_key_error(super::ApiKeyErrorCode::OrganizationIdRequired)
203                })?;
204            helpers::require_org_api_key_permission(ctx, user_id, organization_id, "create")
205                .await?;
206            organization_id.to_owned()
207        }
208    };
209
210    ApiKeyPlugin::validate_metadata(config, body.metadata.as_ref())?;
211    ApiKeyPlugin::validate_refill(
212        body.refill_interval.filter(|value| *value != 0.0),
213        body.refill_amount.filter(|value| *value != 0.0),
214    )?;
215    let effective_expires_in = ApiKeyPlugin::validate_expires_in(config, body.expires_in)?;
216    ApiKeyPlugin::validate_prefix(config, body.prefix.as_deref())?;
217    ApiKeyPlugin::validate_name(config, body.name.as_deref(), true)?;
218
219    plugin.maybe_delete_expired(ctx).await?;
220    let (full_key, hash, start) = if let Some(generator) = &config.custom_key_generator {
221        let full_key = generator
222            .generate_key(&super::ApiKeyGenerationOptions {
223                length: config.key_length,
224                prefix: body
225                    .prefix
226                    .as_deref()
227                    .filter(|prefix| !prefix.is_empty())
228                    .or(config.prefix.as_deref()),
229            })
230            .await?;
231        let hash = if config.disable_key_hashing {
232            full_key.clone()
233        } else {
234            ApiKeyPlugin::hash_key(&full_key)
235        };
236        let start = ApiKeyPlugin::starting_characters(&full_key, config.starting_characters_length);
237        (full_key, hash, start)
238    } else {
239        ApiKeyPlugin::generate_key(config, body.prefix.as_deref())?
240    };
241    let dynamic_permissions = match &config.default_permissions_callback {
242        Some(callback) => Some(
243            callback
244                .default_permissions(
245                    &reference_id,
246                    &super::ApiKeyCallbackContext::new(request, ctx, &config.config_id),
247                )
248                .await?,
249        ),
250        None => None,
251    };
252    let input = CreateApiKey {
253        reference_id,
254        config_id: config.config_id.clone(),
255        name: body.name.clone(),
256        prefix: body.prefix.clone().or_else(|| config.prefix.clone()),
257        key_hash: hash,
258        start: config.store_starting_characters.then_some(start),
259        expires_at: expiration_date(effective_expires_in)?,
260        remaining: body.remaining,
261        rate_limit_enabled: body.rate_limit_enabled.unwrap_or(config.rate_limit.enabled),
262        rate_limit_time_window: body
263            .rate_limit_time_window
264            .or(Some(config.rate_limit.time_window)),
265        rate_limit_max: body.rate_limit_max.or(Some(config.rate_limit.max_requests)),
266        refill_interval: body.refill_interval,
267        refill_amount: body.refill_amount,
268        permissions: body
269            .permissions
270            .as_ref()
271            .or(dynamic_permissions.as_ref())
272            .or(config.default_permissions.as_ref())
273            .map(alibi_core::utils::json::to_string)
274            .transpose()?,
275        // Source supplies explicit JSON null to its JSON-column adapter when
276        // metadata is absent or falsy, preserving "null" rather than SQL NULL.
277        metadata: Some(alibi_core::utils::json::to_string(
278            body.metadata
279                .as_ref()
280                .filter(|value| json_truthy(value))
281                .unwrap_or(&alibi_core::utils::json::JsValue::Null),
282        )?),
283        enabled: true,
284    };
285    let api_key = config.create_stored_key(ctx, input).await?;
286    let mut api_key = ApiKeyView::from(&api_key);
287    // Upstream returns supplied falsy metadata at creation, but stores null.
288    api_key.metadata = body
289        .metadata
290        .as_ref()
291        .map(alibi_core::utils::json::JsValue::to_json_value)
292        .transpose()?;
293    Ok(CreateKeyResponse {
294        key: full_key,
295        api_key,
296    })
297}
298
299fn json_truthy(value: &alibi_core::utils::json::JsValue) -> bool {
300    use alibi_core::utils::json::JsValue;
301    match value {
302        JsValue::Null => false,
303        JsValue::Bool(value) => *value,
304        JsValue::Number(value) => *value != 0.0 && !value.is_nan(),
305        JsValue::String(value) => !value.is_empty(),
306        JsValue::Date(_) | JsValue::Array(_) | JsValue::Object(_) => true,
307    }
308}
309
310#[expect(
311    clippy::as_conversions,
312    clippy::cast_possible_truncation,
313    clippy::cast_precision_loss,
314    reason = "JavaScript-compatible numbers deliberately retain IEEE754 rounding and guarded integer coercion at the wire or adapter boundary"
315)]
316fn expiration_date(seconds: Option<f64>) -> AuthResult<Option<String>> {
317    let Some(seconds) = seconds.filter(|seconds| *seconds != 0.0 && !seconds.is_nan()) else {
318        return Ok(None);
319    };
320    let milliseconds = chrono::Utc::now().timestamp_millis() as f64 + seconds * 1000.0;
321    if !milliseconds.is_finite() || milliseconds.abs() > 8_640_000_000_000_000.0 {
322        return Err(alibi_core::AuthError::internal("Invalid Date"));
323    }
324    let date = chrono::DateTime::from_timestamp_millis(milliseconds.trunc() as i64)
325        .ok_or_else(|| alibi_core::AuthError::internal("Invalid Date"))?;
326    Ok(Some(
327        date.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
328    ))
329}
330
331pub(crate) async fn get_key_core(
332    id: &str,
333    config_id: Option<&str>,
334    user_id: impl AsRef<str>,
335    plugin: &ApiKeyPlugin,
336    ctx: &AuthContext<impl alibi_core::AuthSchema>,
337) -> AuthResult<ApiKeyView> {
338    let config = plugin.resolve_configuration(config_id)?;
339    let api_key = helpers::get_owned_api_key(ctx, config, id, user_id.as_ref(), "read").await?;
340    plugin.maybe_delete_expired(ctx).await?;
341    Ok(ApiKeyView::from(&api_key))
342}
343
344pub(crate) async fn list_keys_core(
345    user_id: impl AsRef<str>,
346    query: &ListKeysQuery,
347    plugin: &ApiKeyPlugin,
348    ctx: &AuthContext<impl alibi_core::AuthSchema>,
349) -> AuthResult<ListKeysResponse> {
350    let organization_id = query.organization_id.as_deref().filter(|id| !id.is_empty());
351    if let Some(organization_id) = organization_id {
352        helpers::require_org_api_key_permission(ctx, user_id.as_ref(), organization_id, "read")
353            .await?;
354    }
355    let reference_id = query
356        .organization_id
357        .as_deref()
358        .unwrap_or_else(|| user_id.as_ref());
359    let references = if organization_id.is_some() {
360        super::ApiKeyReferences::Organization
361    } else {
362        super::ApiKeyReferences::User
363    };
364    let config_id = query.config_id.as_deref().filter(|id| !id.is_empty());
365    if config_id.is_some() {
366        _ = plugin.resolve_configuration(config_id)?;
367    }
368
369    let keys = if let Some(config_id) = config_id {
370        plugin
371            .resolve_configuration(Some(config_id))?
372            .list_stored_keys(
373                ctx,
374                reference_id,
375                query.sort_by.as_deref(),
376                query.sort_direction.as_deref(),
377            )
378            .await?
379    } else {
380        plugin
381            .list_storage_keys(
382                ctx,
383                reference_id,
384                query.sort_by.as_deref(),
385                query.sort_direction.as_deref(),
386            )
387            .await?
388    };
389    let mut views: Vec<ApiKeyView> = keys
390        .iter()
391        .filter(|key| {
392            let key_references = plugin
393                .configurations
394                .iter()
395                .find(|config| super::config_id_matches(&key.config_id, &config.config_id))
396                .map(|config| config.references)
397                .unwrap_or_default();
398            key.reference_id == reference_id
399                && key_references == references
400                && config_id.is_none_or(|id| super::config_id_matches(&key.config_id, id))
401        })
402        .map(ApiKeyView::from)
403        .collect();
404
405    let total = views.len();
406    if let Some(offset) = query.offset {
407        views = views.split_off(offset.min(views.len()));
408    }
409    if let Some(limit) = query.limit {
410        views.truncate(limit);
411    }
412    plugin.maybe_delete_expired(ctx).await?;
413    Ok(ListKeysResponse {
414        api_keys: views,
415        total,
416        limit: query.limit,
417        offset: query.offset,
418    })
419}
420
421fn compare_numbers(left: Option<f64>, right: Option<f64>) -> std::cmp::Ordering {
422    match (left, right) {
423        (Some(left), Some(right)) => left.total_cmp(&right),
424        (None, None) => std::cmp::Ordering::Equal,
425        (None, Some(_)) => std::cmp::Ordering::Less,
426        (Some(_), None) => std::cmp::Ordering::Greater,
427    }
428}
429
430fn compare_strings(
431    left: Option<&str>,
432    right: Option<&str>,
433    from_database: bool,
434) -> std::cmp::Ordering {
435    match (left, right) {
436        (Some(left), Some(right)) if !from_database => {
437            left.encode_utf16().cmp(right.encode_utf16())
438        }
439        _ => left.cmp(&right),
440    }
441}
442
443fn compare_metadata(left: Option<&str>, right: Option<&str>) -> AuthResult<std::cmp::Ordering> {
444    use alibi_core::utils::{
445        javascript::string_to_number,
446        json::{JsValue, parse_value},
447    };
448    let left = left.map(parse_value).transpose()?.unwrap_or(JsValue::Null);
449    let right = right.map(parse_value).transpose()?.unwrap_or(JsValue::Null);
450    match (left.is_null(), right.is_null()) {
451        (true, true) => return Ok(std::cmp::Ordering::Equal),
452        (true, false) => return Ok(std::cmp::Ordering::Less),
453        (false, true) => return Ok(std::cmp::Ordering::Greater),
454        (false, false) => {}
455    }
456    let primitive = |value: JsValue| match value {
457        JsValue::Array(_) | JsValue::Object(_) => value
458            .coerce_string()
459            .map(JsValue::String)
460            .map_err(alibi_core::AuthError::internal),
461        value => Ok(value),
462    };
463    let left = primitive(left)?;
464    let right = primitive(right)?;
465    if let (JsValue::String(left), JsValue::String(right)) = (&left, &right) {
466        return Ok(compare_strings(Some(left), Some(right), false));
467    }
468    let number = |value: &JsValue| match value {
469        JsValue::Null => Some(0.0),
470        JsValue::Bool(value) => Some(f64::from(*value)),
471        JsValue::Number(value) => Some(*value),
472        JsValue::String(value) => string_to_number(value),
473        JsValue::Date(value) => string_to_number(&value.timestamp_millis().to_string()),
474        JsValue::Array(_) | JsValue::Object(_) => None,
475    };
476    Ok(number(&left)
477        .zip(number(&right))
478        .and_then(|(left, right)| left.partial_cmp(&right))
479        .unwrap_or(std::cmp::Ordering::Equal))
480}
481
482pub(super) fn sort_keys(
483    keys: &mut [ApiKey],
484    sort_by: &str,
485    direction: Option<&str>,
486    from_database: bool,
487) -> AuthResult<()> {
488    if sort_by == "metadata" && !from_database {
489        return metadata_sort::sort(keys, direction);
490    }
491    keys.sort_by(|left, right| {
492        let a = ApiKeyView::from(left);
493        let b = ApiKeyView::from(right);
494        let strings = |left, right| compare_strings(left, right, from_database);
495        let ordering = match sort_by {
496            "key" => strings(Some(&left.key_hash), Some(&right.key_hash)),
497            "id" => strings(Some(&a.id), Some(&b.id)),
498            "name" => strings(a.name.as_deref(), b.name.as_deref()),
499            "start" => strings(a.start.as_deref(), b.start.as_deref()),
500            "prefix" => strings(a.prefix.as_deref(), b.prefix.as_deref()),
501            "referenceId" => strings(Some(&a.reference_id), Some(&b.reference_id)),
502            "configId" => strings(Some(&a.config_id), Some(&b.config_id)),
503            "permissions" => strings(left.permissions.as_deref(), right.permissions.as_deref()),
504            "metadata" => strings(left.metadata.as_deref(), right.metadata.as_deref()),
505            "enabled" => a.enabled.cmp(&b.enabled),
506            "rateLimitEnabled" => a.rate_limit_enabled.cmp(&b.rate_limit_enabled),
507            "createdAt" => a.created_at.cmp(&b.created_at),
508            "updatedAt" => a.updated_at.cmp(&b.updated_at),
509            "expiresAt" => a.expires_at.cmp(&b.expires_at),
510            "lastRequest" => a.last_request.cmp(&b.last_request),
511            "lastRefillAt" => a.last_refill_at.cmp(&b.last_refill_at),
512            "remaining" => compare_numbers(a.remaining, b.remaining),
513            "requestCount" => compare_numbers(a.request_count, b.request_count),
514            "rateLimitMax" => compare_numbers(a.rate_limit_max, b.rate_limit_max),
515            "rateLimitTimeWindow" => {
516                compare_numbers(a.rate_limit_time_window, b.rate_limit_time_window)
517            }
518            "refillAmount" => compare_numbers(a.refill_amount, b.refill_amount),
519            "refillInterval" => compare_numbers(a.refill_interval, b.refill_interval),
520            _ => std::cmp::Ordering::Equal,
521        };
522        if direction == Some("desc") {
523            ordering.reverse()
524        } else {
525            ordering
526        }
527    });
528    Ok(())
529}
530
531pub(crate) async fn update_key_core(
532    body: &UpdateKeyRequest,
533    user_id: impl AsRef<str>,
534    plugin: &ApiKeyPlugin,
535    ctx: &AuthContext<impl alibi_core::AuthSchema>,
536) -> AuthResult<ApiKeyView> {
537    if body
538        .user_id
539        .as_deref()
540        .is_some_and(|id| !id.is_empty() && id != user_id.as_ref())
541    {
542        return Err(super::api_key_error(
543            super::ApiKeyErrorCode::UnauthorizedSession,
544        ));
545    }
546    if body.refill_amount.is_some()
547        || body.refill_interval.is_some()
548        || body.rate_limit_max.is_some()
549        || body.rate_limit_time_window.is_some()
550        || body.rate_limit_enabled.is_some()
551        || body.remaining.is_some()
552        || body.permissions.is_some()
553    {
554        return Err(super::api_key_error(
555            super::ApiKeyErrorCode::ServerOnlyProperty,
556        ));
557    }
558    update_key_for_user(body, user_id.as_ref(), plugin, ctx).await
559}
560
561pub(super) async fn update_key_for_user(
562    body: &UpdateKeyRequest,
563    user_id: &str,
564    plugin: &ApiKeyPlugin,
565    ctx: &AuthContext<impl alibi_core::AuthSchema>,
566) -> AuthResult<ApiKeyView> {
567    let config = plugin.resolve_configuration(body.config_id.as_deref())?;
568    _ = helpers::get_owned_api_key(ctx, config, &body.key_id, user_id, "update").await?;
569    ApiKeyPlugin::validate_name(config, body.name.as_deref(), false)?;
570
571    let expires_at = match body.expires_in {
572        None => None,
573        Some(_) if config.key_expiration.disable_custom_expires_time => {
574            return Err(super::api_key_error(
575                super::ApiKeyErrorCode::KeyDisabledExpiration,
576            ));
577        }
578        Some(None) => Some(None),
579        Some(Some(seconds)) => {
580            let validated = ApiKeyPlugin::validate_expires_in(config, Some(seconds))?;
581            Some(expiration_date(validated)?)
582        }
583    };
584    let metadata = body.metadata.as_ref().filter(|_| config.enable_metadata);
585    if let Some(metadata) = metadata
586        && !(metadata.is_null() || metadata.is_object() || metadata.is_array())
587    {
588        return Err(super::api_key_error(
589            super::ApiKeyErrorCode::InvalidMetadataType,
590        ));
591    }
592    ApiKeyPlugin::validate_refill(body.refill_interval, body.refill_amount)?;
593    if body.name.is_none()
594        && body.enabled.is_none()
595        && expires_at.is_none()
596        && metadata.is_none()
597        && body.remaining.is_none()
598        && body.refill_amount.is_none()
599        && body.refill_interval.is_none()
600        && body.rate_limit_enabled.is_none()
601        && body.rate_limit_time_window.is_none()
602        && body.rate_limit_max.is_none()
603        && body.permissions.is_none()
604    {
605        return Err(super::api_key_error(
606            super::ApiKeyErrorCode::NoValuesToUpdate,
607        ));
608    }
609    let update = UpdateApiKey {
610        name: body.name.clone(),
611        enabled: body.enabled,
612        remaining: body.remaining,
613        rate_limit_enabled: body.rate_limit_enabled,
614        rate_limit_time_window: body.rate_limit_time_window,
615        rate_limit_max: body.rate_limit_max,
616        refill_interval: body.refill_interval,
617        refill_amount: body.refill_amount,
618        permissions: body
619            .permissions
620            .as_ref()
621            .map(alibi_core::utils::json::to_string)
622            .transpose()?,
623        metadata: metadata
624            .map(alibi_core::utils::json::to_string)
625            .transpose()?,
626        expires_at,
627        ..Default::default()
628    };
629    let updated = config.update_stored_key(ctx, &body.key_id, update).await?;
630    plugin.maybe_delete_expired(ctx).await?;
631    Ok(ApiKeyView::from(&updated))
632}
633
634pub(crate) async fn delete_key_core(
635    body: &DeleteKeyRequest,
636    user_id: impl AsRef<str>,
637    plugin: &ApiKeyPlugin,
638    ctx: &AuthContext<impl alibi_core::AuthSchema>,
639) -> AuthResult<serde_json::Value> {
640    let config = plugin.resolve_configuration(body.config_id.as_deref())?;
641    let key =
642        helpers::get_owned_api_key(ctx, config, &body.key_id, user_id.as_ref(), "delete").await?;
643    config.remove_key(ctx, &key).await?;
644    plugin.maybe_delete_expired(ctx).await?;
645    Ok(serde_json::json!({ "success": true }))
646}