Skip to main content

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