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
11impl ApiKeyPlugin {
16 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 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
61pub(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
154fn revived_permission_date(value: &str) -> bool {
158 alibi_core::utils::datetime::normalize_json_date(value).is_some()
159}
160
161pub(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 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 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
341pub(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
357pub(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
546pub(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
652pub(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}