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 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 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
57pub(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
147fn 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 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 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}