1use axum::{
6 Json,
7 extract::{Request, State},
8 http::{HeaderMap, StatusCode, header},
9 middleware::Next,
10 response::IntoResponse,
11};
12use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, errors::ErrorKind};
13pub use perfgate_types::baseline_service::auth::{
14 ApiKey, JwtClaims, Role, Scope, validate_key_format,
15};
16use perfgate_types::error::AuthError;
17use sha2::{Digest, Sha256};
18use std::collections::HashMap;
19use std::sync::Arc;
20use tokio::sync::RwLock;
21use tracing::warn;
22
23use crate::metrics;
24use crate::models::ApiError;
25use crate::oidc::OidcRegistry;
26use crate::storage::KeyStore;
27
28#[derive(Clone)]
30pub struct JwtConfig {
31 secret: Vec<u8>,
32 issuer: Option<String>,
33 audience: Option<String>,
34}
35
36impl JwtConfig {
37 pub fn hs256(secret: impl Into<Vec<u8>>) -> Self {
39 Self {
40 secret: secret.into(),
41 issuer: None,
42 audience: None,
43 }
44 }
45
46 pub fn issuer(mut self, issuer: impl Into<String>) -> Self {
48 self.issuer = Some(issuer.into());
49 self
50 }
51
52 pub fn audience(mut self, audience: impl Into<String>) -> Self {
54 self.audience = Some(audience.into());
55 self
56 }
57
58 pub fn secret_bytes(&self) -> &[u8] {
60 &self.secret
61 }
62
63 fn validation(&self) -> Validation {
64 let mut validation = Validation::new(Algorithm::HS256);
65 if let Some(issuer) = &self.issuer {
66 validation.set_issuer(&[issuer.as_str()]);
67 }
68 if let Some(audience) = &self.audience {
69 validation.set_audience(&[audience.as_str()]);
70 }
71 validation
72 }
73}
74
75impl std::fmt::Debug for JwtConfig {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 f.debug_struct("JwtConfig")
78 .field("secret", &"<redacted>")
79 .field("issuer", &self.issuer)
80 .field("audience", &self.audience)
81 .finish()
82 }
83}
84
85#[derive(Clone)]
87pub struct AuthState {
88 pub key_store: Arc<ApiKeyStore>,
90
91 pub persistent_key_store: Option<Arc<dyn KeyStore>>,
93
94 pub jwt: Option<JwtConfig>,
96
97 pub oidc: OidcRegistry,
99}
100
101impl AuthState {
102 pub fn new(key_store: Arc<ApiKeyStore>, jwt: Option<JwtConfig>, oidc: OidcRegistry) -> Self {
104 Self {
105 key_store,
106 persistent_key_store: None,
107 jwt,
108 oidc,
109 }
110 }
111
112 pub fn with_persistent_key_store(mut self, store: Arc<dyn KeyStore>) -> Self {
114 self.persistent_key_store = Some(store);
115 self
116 }
117}
118
119#[derive(Debug, Clone)]
121pub struct AuthContext {
122 pub api_key: ApiKey,
124
125 pub source_ip: Option<String>,
127}
128
129#[derive(Debug, Default)]
131pub struct ApiKeyStore {
132 keys: Arc<RwLock<HashMap<String, ApiKey>>>,
134}
135
136impl ApiKeyStore {
137 pub fn new() -> Self {
139 Self {
140 keys: Arc::new(RwLock::new(HashMap::new())),
141 }
142 }
143
144 pub async fn add_key(&self, key: ApiKey, raw_key: &str) {
146 let hash = hash_api_key(raw_key);
147 let mut keys = self.keys.write().await;
148 keys.insert(hash, key);
149 }
150
151 pub async fn get_key(&self, raw_key: &str) -> Option<ApiKey> {
153 let hash = hash_api_key(raw_key);
154 let keys = self.keys.read().await;
155 keys.get(&hash).cloned()
156 }
157
158 pub async fn remove_key(&self, raw_key: &str) -> bool {
160 let hash = hash_api_key(raw_key);
161 let mut keys = self.keys.write().await;
162 keys.remove(&hash).is_some()
163 }
164
165 pub async fn list_keys(&self) -> Vec<ApiKey> {
167 let keys = self.keys.read().await;
168 keys.values().cloned().collect()
169 }
170}
171
172enum Credentials {
173 ApiKey(String),
174 Jwt(String),
175}
176
177fn hash_api_key(key: &str) -> String {
179 let mut hasher = Sha256::new();
180 hasher.update(key.as_bytes());
181 format!("{:x}", hasher.finalize())
182}
183
184fn extract_credentials(headers: &HeaderMap) -> Option<Credentials> {
185 let auth_header = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
186
187 if let Some(key) = auth_header.strip_prefix("Bearer ") {
188 return Some(Credentials::ApiKey(key.to_string()));
189 }
190
191 if let Some(token) = auth_header.strip_prefix("Token ") {
192 return Some(Credentials::Jwt(token.to_string()));
193 }
194
195 None
196}
197
198fn source_ip(headers: &HeaderMap) -> Option<String> {
199 headers
200 .get("X-Forwarded-For")
201 .and_then(|v| v.to_str().ok())
202 .map(ToOwned::to_owned)
203}
204
205fn unauthorized(message: &str) -> (StatusCode, Json<ApiError>) {
206 (
207 StatusCode::UNAUTHORIZED,
208 Json(ApiError::unauthorized(message)),
209 )
210}
211
212fn auth_failure(reason: &'static str, message: &str) -> (StatusCode, Json<ApiError>) {
213 metrics::record_auth_failure(reason);
214 unauthorized(message)
215}
216
217async fn authenticate_api_key(
218 auth_state: &AuthState,
219 api_key_str: &str,
220 headers: &HeaderMap,
221) -> Result<AuthContext, (StatusCode, Json<ApiError>)> {
222 validate_key_format(api_key_str).map_err(|_| {
223 warn!(
224 key_prefix = &api_key_str[..10.min(api_key_str.len())],
225 "Invalid API key format"
226 );
227 auth_failure("invalid_api_key_format", "Invalid API key format")
228 })?;
229
230 if let Some(api_key) = auth_state.key_store.get_key(api_key_str).await {
232 if api_key.is_expired() {
233 warn!(key_id = %api_key.id, "API key expired");
234 return Err(auth_failure("expired_api_key", "API key has expired"));
235 }
236 return Ok(AuthContext {
237 api_key,
238 source_ip: source_ip(headers),
239 });
240 }
241
242 if let Some(persistent) = &auth_state.persistent_key_store
244 && let Ok(Some(record)) = persistent.validate_key(api_key_str).await
245 {
246 let mut api_key = ApiKey::new(
247 record.id.clone(),
248 record.description.clone(),
249 record.project.clone(),
250 record.role,
251 );
252 api_key.benchmark_regex = record.pattern.clone();
254 api_key.expires_at = record.expires_at;
255 api_key.created_at = record.created_at;
256
257 return Ok(AuthContext {
258 api_key,
259 source_ip: source_ip(headers),
260 });
261 }
262
263 warn!(
264 key_prefix = &api_key_str[..10.min(api_key_str.len())],
265 "Invalid API key"
266 );
267 Err(auth_failure("invalid_api_key", "Invalid API key"))
268}
269
270fn validate_jwt(token: &str, config: &JwtConfig) -> Result<JwtClaims, AuthError> {
271 let validation = config.validation();
272
273 decode::<JwtClaims>(
274 token,
275 &DecodingKey::from_secret(config.secret_bytes()),
276 &validation,
277 )
278 .map(|data| data.claims)
279 .map_err(|error| match error.kind() {
280 ErrorKind::ExpiredSignature => AuthError::ExpiredToken,
281 _ => AuthError::InvalidToken(error.to_string()),
282 })
283}
284
285async fn authenticate_jwt(
286 auth_state: &AuthState,
287 token: &str,
288 headers: &HeaderMap,
289) -> Result<AuthContext, (StatusCode, Json<ApiError>)> {
290 if let Some(config) = &auth_state.jwt {
292 match validate_jwt(token, config) {
293 Ok(claims) => {
294 return Ok(AuthContext {
295 api_key: api_key_from_jwt_claims(&claims),
296 source_ip: source_ip(headers),
297 });
298 }
299 Err(e) => {
300 if !auth_state.oidc.has_providers() {
303 match &e {
304 AuthError::ExpiredToken => warn!("Expired JWT token"),
305 AuthError::InvalidToken(_) => warn!("Invalid JWT token"),
306 _ => {}
307 }
308 let reason = match &e {
309 AuthError::ExpiredToken => "expired_jwt",
310 _ => "invalid_jwt",
311 };
312 return Err(auth_failure(reason, &e.to_string()));
313 }
314 }
315 }
316 }
317
318 if auth_state.oidc.has_providers() {
320 match auth_state.oidc.validate_token(token).await {
321 Ok(api_key) => {
322 return Ok(AuthContext {
323 api_key,
324 source_ip: source_ip(headers),
325 });
326 }
327 Err(e) => {
328 match &e {
329 AuthError::ExpiredToken => warn!("Expired OIDC token"),
330 AuthError::InvalidToken(msg) => warn!("Invalid OIDC token: {}", msg),
331 _ => {}
332 }
333 let reason = match &e {
334 AuthError::ExpiredToken => "expired_oidc",
335 _ => "invalid_oidc",
336 };
337 return Err(auth_failure(reason, &e.to_string()));
338 }
339 }
340 }
341
342 warn!("JWT token received but no JWT or OIDC authentication is configured");
343 Err(auth_failure(
344 "jwt_unconfigured",
345 "JWT/OIDC authentication is not configured",
346 ))
347}
348
349fn api_key_from_jwt_claims(claims: &JwtClaims) -> ApiKey {
350 ApiKey {
351 id: format!("jwt:{}", claims.sub),
352 name: format!("JWT {}", claims.sub),
353 project_id: claims.project_id.clone(),
354 scopes: claims.scopes.clone(),
355 role: Role::from_scopes(&claims.scopes),
356 benchmark_regex: None,
357 expires_at: Some(
358 chrono::DateTime::<chrono::Utc>::from_timestamp(claims.exp as i64, 0)
359 .unwrap_or_else(chrono::Utc::now),
360 ),
361 created_at: claims
362 .iat
363 .and_then(|iat| chrono::DateTime::<chrono::Utc>::from_timestamp(iat as i64, 0))
364 .unwrap_or_else(chrono::Utc::now),
365 last_used_at: None,
366 }
367}
368
369pub async fn auth_middleware(
371 State(auth_state): State<AuthState>,
372 mut request: Request,
373 next: Next,
374) -> Result<impl IntoResponse, (StatusCode, Json<ApiError>)> {
375 if request.uri().path() == "/health" {
377 return Ok(next.run(request).await);
378 }
379
380 let auth_ctx = match extract_credentials(request.headers()) {
381 Some(Credentials::ApiKey(api_key)) => {
382 authenticate_api_key(&auth_state, &api_key, request.headers()).await?
383 }
384 Some(Credentials::Jwt(token)) => {
385 authenticate_jwt(&auth_state, &token, request.headers()).await?
386 }
387 None => {
388 metrics::record_auth_failure("missing_credentials");
389 warn!("Missing authentication header");
390 return Err(unauthorized("Missing authentication header"));
391 }
392 };
393
394 request.extensions_mut().insert(auth_ctx);
395
396 Ok(next.run(request).await)
397}
398
399pub async fn local_mode_auth_middleware(mut request: Request, next: Next) -> impl IntoResponse {
406 let auth_ctx = AuthContext {
407 api_key: ApiKey::new(
408 "local-mode".to_string(),
409 "Local Mode".to_string(),
410 "local".to_string(),
411 Role::Admin,
412 ),
413 source_ip: source_ip(request.headers()),
414 };
415 request.extensions_mut().insert(auth_ctx);
416 next.run(request).await
417}
418
419pub fn check_scope(
422 auth_ctx: Option<&AuthContext>,
423 project_id: &str,
424 benchmark: Option<&str>,
425 scope: Scope,
426) -> Result<(), (StatusCode, Json<ApiError>)> {
427 let ctx = match auth_ctx {
428 Some(ctx) => ctx,
429 None => {
430 metrics::record_auth_failure("missing_auth_context");
431 return Err((
432 StatusCode::UNAUTHORIZED,
433 Json(ApiError::unauthorized("Authentication required")),
434 ));
435 }
436 };
437
438 if !ctx.api_key.has_scope(scope) {
440 warn!(
441 key_id = %ctx.api_key.id,
442 required_scope = %scope,
443 actual_role = %ctx.api_key.role,
444 "Insufficient permissions: scope mismatch"
445 );
446 metrics::record_auth_failure("insufficient_scope");
447 return Err((
448 StatusCode::FORBIDDEN,
449 Json(ApiError::forbidden(&format!(
450 "Requires '{}' permission",
451 scope
452 ))),
453 ));
454 }
455
456 if !ctx.api_key.has_scope(Scope::Admin) && ctx.api_key.project_id != project_id {
460 warn!(
461 key_id = %ctx.api_key.id,
462 key_project = %ctx.api_key.project_id,
463 requested_project = %project_id,
464 "Insufficient permissions: project isolation violation"
465 );
466 metrics::record_auth_failure("project_mismatch");
467 return Err((
468 StatusCode::FORBIDDEN,
469 Json(ApiError::forbidden(&format!(
470 "Key is restricted to project '{}'",
471 ctx.api_key.project_id
472 ))),
473 ));
474 }
475
476 if let (Some(regex_str), Some(bench)) = (&ctx.api_key.benchmark_regex, benchmark) {
479 let regex = regex::Regex::new(regex_str).map_err(|e| {
480 warn!(key_id = %ctx.api_key.id, regex = %regex_str, error = %e, "Invalid benchmark regex in API key");
481 metrics::record_auth_failure("invalid_benchmark_regex");
482 (
483 StatusCode::INTERNAL_SERVER_ERROR,
484 Json(ApiError::internal_error("Invalid security configuration")),
485 )
486 })?;
487
488 if !regex.is_match(bench) {
489 warn!(
490 key_id = %ctx.api_key.id,
491 benchmark = %bench,
492 regex = %regex_str,
493 "Insufficient permissions: benchmark restriction violation"
494 );
495 metrics::record_auth_failure("benchmark_restricted");
496 return Err((
497 StatusCode::FORBIDDEN,
498 Json(ApiError::forbidden(&format!(
499 "Key is restricted to benchmarks matching '{}'",
500 regex_str
501 ))),
502 ));
503 }
504 }
505
506 Ok(())
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 use axum::{Extension, Router, routing::get};
513 use jsonwebtoken::{Header, encode};
514 use perfgate_types::baseline_service::auth::generate_api_key;
515 use tower::ServiceExt;
516 use uselesskey::{Factory, HmacFactoryExt, HmacSpec, Seed};
517 use uselesskey_jsonwebtoken::JwtKeyExt;
518
519 fn test_jwt_config() -> JwtConfig {
520 let seed = Seed::from_env_value("perfgate-server-auth-tests").unwrap();
521 let factory = Factory::deterministic(seed);
522 let fixture = factory.hmac("jwt-auth", HmacSpec::hs256());
523 JwtConfig::hs256(fixture.secret_bytes())
524 .issuer("perfgate-tests")
525 .audience("perfgate")
526 }
527
528 fn create_test_claims(scopes: Vec<Scope>, exp: u64) -> JwtClaims {
529 JwtClaims {
530 sub: "ci-bot".to_string(),
531 project_id: "project-1".to_string(),
532 scopes,
533 exp,
534 iat: Some(chrono::Utc::now().timestamp() as u64),
535 iss: Some("perfgate-tests".to_string()),
536 aud: Some("perfgate".to_string()),
537 }
538 }
539
540 fn create_test_token(claims: &JwtClaims) -> String {
541 let seed = Seed::from_env_value("perfgate-server-auth-tests").unwrap();
542 let factory = Factory::deterministic(seed);
543 let fixture = factory.hmac("jwt-auth", HmacSpec::hs256());
544 encode(&Header::default(), claims, &fixture.encoding_key()).unwrap()
545 }
546
547 fn auth_test_router(auth_state: AuthState) -> Router {
548 Router::new()
549 .route(
550 "/protected",
551 get(|Extension(auth_ctx): Extension<AuthContext>| async move {
552 auth_ctx.api_key.id
553 }),
554 )
555 .layer(axum::middleware::from_fn_with_state(
556 auth_state,
557 auth_middleware,
558 ))
559 }
560
561 fn local_auth_test_router() -> Router {
562 Router::new()
563 .route(
564 "/protected",
565 get(|Extension(auth_ctx): Extension<AuthContext>| async move {
566 auth_ctx.api_key.role.to_string()
567 }),
568 )
569 .layer(axum::middleware::from_fn(local_mode_auth_middleware))
570 }
571
572 #[tokio::test]
573 async fn test_api_key_store() {
574 let store = ApiKeyStore::new();
575 let raw_key = generate_api_key(false);
576 let key = ApiKey::new(
577 "key-1".to_string(),
578 "Test Key".to_string(),
579 "project-1".to_string(),
580 Role::Contributor,
581 );
582
583 store.add_key(key.clone(), &raw_key).await;
584
585 let retrieved = store.get_key(&raw_key).await;
586 assert!(retrieved.is_some());
587 let retrieved = retrieved.unwrap();
588 assert_eq!(retrieved.id, "key-1");
589 assert_eq!(retrieved.role, Role::Contributor);
590
591 let keys = store.list_keys().await;
592 assert_eq!(keys.len(), 1);
593
594 let removed = store.remove_key(&raw_key).await;
595 assert!(removed);
596
597 let retrieved = store.get_key(&raw_key).await;
598 assert!(retrieved.is_none());
599 }
600
601 #[tokio::test]
602 async fn test_auth_middleware_accepts_api_key() {
603 let store = Arc::new(ApiKeyStore::new());
604 let key = "pg_test_abcdefghijklmnopqrstuvwxyz123456";
605 store
606 .add_key(
607 ApiKey::new(
608 "api-key-1".to_string(),
609 "API Key".to_string(),
610 "project-1".to_string(),
611 Role::Viewer,
612 ),
613 key,
614 )
615 .await;
616
617 let response = auth_test_router(AuthState::new(store, None, Default::default()))
618 .oneshot(
619 Request::builder()
620 .uri("/protected")
621 .header(header::AUTHORIZATION, format!("Bearer {}", key))
622 .body(axum::body::Body::empty())
623 .unwrap(),
624 )
625 .await
626 .unwrap();
627
628 assert_eq!(response.status(), StatusCode::OK);
629 }
630
631 #[tokio::test]
632 async fn test_auth_middleware_records_missing_and_invalid_credentials() {
633 let auth_state = AuthState::new(Arc::new(ApiKeyStore::new()), None, Default::default());
634
635 let missing = auth_test_router(auth_state.clone())
636 .oneshot(
637 Request::builder()
638 .uri("/protected")
639 .body(axum::body::Body::empty())
640 .unwrap(),
641 )
642 .await
643 .unwrap();
644 assert_eq!(missing.status(), StatusCode::UNAUTHORIZED);
645
646 let invalid = auth_test_router(auth_state)
647 .oneshot(
648 Request::builder()
649 .uri("/protected")
650 .header(header::AUTHORIZATION, "Bearer invalid")
651 .body(axum::body::Body::empty())
652 .unwrap(),
653 )
654 .await
655 .unwrap();
656 assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED);
657 }
658
659 #[tokio::test]
660 async fn test_auth_middleware_rejects_expired_in_memory_api_key() {
661 let store = Arc::new(ApiKeyStore::new());
662 let key = "pg_test_abcdefghijklmnopqrstuvwxyz123456";
663 let mut api_key = ApiKey::new(
664 "api-key-expired".to_string(),
665 "Expired API Key".to_string(),
666 "project-1".to_string(),
667 Role::Viewer,
668 );
669 api_key.expires_at = Some(chrono::Utc::now() - chrono::Duration::minutes(1));
670 store.add_key(api_key, key).await;
671
672 let response = auth_test_router(AuthState::new(store, None, Default::default()))
673 .oneshot(
674 Request::builder()
675 .uri("/protected")
676 .header(header::AUTHORIZATION, format!("Bearer {}", key))
677 .body(axum::body::Body::empty())
678 .unwrap(),
679 )
680 .await
681 .unwrap();
682
683 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
684 }
685
686 #[tokio::test]
687 async fn test_auth_middleware_accepts_jwt_token() {
688 let claims = create_test_claims(
689 vec![Scope::Read, Scope::Promote],
690 (chrono::Utc::now() + chrono::Duration::minutes(5)).timestamp() as u64,
691 );
692 let token = create_test_token(&claims);
693
694 let response = auth_test_router(AuthState::new(
695 Arc::new(ApiKeyStore::new()),
696 Some(test_jwt_config()),
697 Default::default(),
698 ))
699 .oneshot(
700 Request::builder()
701 .uri("/protected")
702 .header(header::AUTHORIZATION, format!("Token {}", token))
703 .body(axum::body::Body::empty())
704 .unwrap(),
705 )
706 .await
707 .unwrap();
708
709 assert_eq!(response.status(), StatusCode::OK);
710 }
711
712 #[tokio::test]
713 async fn test_auth_middleware_rejects_jwt_when_unconfigured() {
714 let claims = create_test_claims(
715 vec![Scope::Read],
716 (chrono::Utc::now() + chrono::Duration::minutes(5)).timestamp() as u64,
717 );
718 let token = create_test_token(&claims);
719
720 let response = auth_test_router(AuthState::new(
721 Arc::new(ApiKeyStore::new()),
722 None,
723 Default::default(),
724 ))
725 .oneshot(
726 Request::builder()
727 .uri("/protected")
728 .header(header::AUTHORIZATION, format!("Token {}", token))
729 .body(axum::body::Body::empty())
730 .unwrap(),
731 )
732 .await
733 .unwrap();
734
735 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
736 }
737
738 #[tokio::test]
739 async fn test_local_mode_auth_middleware_injects_admin_context() {
740 let response = local_auth_test_router()
741 .oneshot(
742 Request::builder()
743 .uri("/protected")
744 .body(axum::body::Body::empty())
745 .unwrap(),
746 )
747 .await
748 .unwrap();
749
750 assert_eq!(response.status(), StatusCode::OK);
751 }
752
753 #[test]
754 fn test_hash_api_key() {
755 let key = "pg_live_test123456789012345678901234567890";
756 let hash1 = hash_api_key(key);
757 let hash2 = hash_api_key(key);
758
759 assert_eq!(hash1, hash2);
760
761 let different_hash = hash_api_key("pg_live_different1234567890123456789012");
762 assert_ne!(hash1, different_hash);
763 }
764
765 #[test]
766 fn test_check_scope_project_isolation() {
767 let key = ApiKey::new(
768 "k1".to_string(),
769 "n1".to_string(),
770 "project-a".to_string(),
771 Role::Contributor,
772 );
773 let ctx = AuthContext {
774 api_key: key,
775 source_ip: None,
776 };
777
778 assert!(check_scope(Some(&ctx), "project-a", None, Scope::Write).is_ok());
780 assert!(check_scope(Some(&ctx), "project-a", None, Scope::Read).is_ok());
781
782 let res = check_scope(Some(&ctx), "project-a", None, Scope::Delete);
784 assert!(res.is_err());
785 assert_eq!(res.unwrap_err().0, StatusCode::FORBIDDEN);
786
787 let res = check_scope(Some(&ctx), "project-b", None, Scope::Read);
789 assert!(res.is_err());
790 assert_eq!(res.unwrap_err().0, StatusCode::FORBIDDEN);
791 }
792
793 #[test]
794 fn test_check_scope_global_admin() {
795 let key = ApiKey::new(
796 "k1".to_string(),
797 "admin".to_string(),
798 "any-project".to_string(),
799 Role::Admin,
800 );
801 let ctx = AuthContext {
802 api_key: key,
803 source_ip: None,
804 };
805
806 assert!(check_scope(Some(&ctx), "project-a", None, Scope::Read).is_ok());
808 assert!(check_scope(Some(&ctx), "project-b", None, Scope::Delete).is_ok());
809 assert!(check_scope(Some(&ctx), "other", None, Scope::Admin).is_ok());
810 }
811
812 #[test]
813 fn test_check_scope_benchmark_restriction() {
814 let mut key = ApiKey::new(
815 "k1".to_string(),
816 "n1".to_string(),
817 "project-a".to_string(),
818 Role::Contributor,
819 );
820 key.benchmark_regex = Some("^web-.*$".to_string());
821
822 let ctx = AuthContext {
823 api_key: key,
824 source_ip: None,
825 };
826
827 assert!(check_scope(Some(&ctx), "project-a", Some("web-auth"), Scope::Read).is_ok());
829 assert!(check_scope(Some(&ctx), "project-a", Some("web-api"), Scope::Write).is_ok());
830
831 let res = check_scope(Some(&ctx), "project-a", Some("worker-job"), Scope::Read);
833 assert!(res.is_err());
834 assert_eq!(res.unwrap_err().0, StatusCode::FORBIDDEN);
835
836 assert!(check_scope(Some(&ctx), "project-a", None, Scope::Read).is_ok());
838 }
839}