Skip to main content

perfgate_server/
auth.rs

1//! Authentication and authorization middleware.
2//!
3//! This module provides API key and JWT token validation for the baseline service.
4
5use 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/// JWT validation settings.
29#[derive(Clone)]
30pub struct JwtConfig {
31    secret: Vec<u8>,
32    issuer: Option<String>,
33    audience: Option<String>,
34}
35
36impl JwtConfig {
37    /// Creates an HS256 JWT configuration from raw secret bytes.
38    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    /// Sets the expected issuer claim.
47    pub fn issuer(mut self, issuer: impl Into<String>) -> Self {
48        self.issuer = Some(issuer.into());
49        self
50    }
51
52    /// Sets the expected audience claim.
53    pub fn audience(mut self, audience: impl Into<String>) -> Self {
54        self.audience = Some(audience.into());
55        self
56    }
57
58    /// Returns the configured secret bytes.
59    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/// Authentication state shared by middleware.
86#[derive(Clone)]
87pub struct AuthState {
88    /// In-memory API key store (for CLI-provided keys).
89    pub key_store: Arc<ApiKeyStore>,
90
91    /// Persistent key store (database-backed).
92    pub persistent_key_store: Option<Arc<dyn KeyStore>>,
93
94    /// Optional JWT validation settings.
95    pub jwt: Option<JwtConfig>,
96
97    /// OIDC provider registry (may contain zero or more providers).
98    pub oidc: OidcRegistry,
99}
100
101impl AuthState {
102    /// Creates auth state from a key store and optional JWT/OIDC configuration.
103    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    /// Adds a persistent key store for database-backed key validation.
113    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/// Authenticated user context extracted from requests.
120#[derive(Debug, Clone)]
121pub struct AuthContext {
122    /// API key information
123    pub api_key: ApiKey,
124
125    /// Source IP address
126    pub source_ip: Option<String>,
127}
128
129/// In-memory API key store for development and testing.
130#[derive(Debug, Default)]
131pub struct ApiKeyStore {
132    /// Keys indexed by key hash
133    keys: Arc<RwLock<HashMap<String, ApiKey>>>,
134}
135
136impl ApiKeyStore {
137    /// Creates a new empty key store.
138    pub fn new() -> Self {
139        Self {
140            keys: Arc::new(RwLock::new(HashMap::new())),
141        }
142    }
143
144    /// Adds an API key to the store.
145    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    /// Looks up an API key by its hash.
152    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    /// Removes an API key from the store.
159    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    /// Lists all API keys (without sensitive data).
166    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
177/// Hashes an API key for storage.
178fn 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    // Try the in-memory store first (CLI-provided keys)
231    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    // Try the persistent key store (database-backed keys)
243    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        // Apply benchmark pattern as regex
253        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    // Try static JWT config if available
291    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 we don't have OIDC providers, fail here.
301                // Otherwise, fall through to OIDC.
302                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    // Try OIDC providers if any are configured
319    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
369/// Authentication middleware.
370pub 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    // Skip auth for health endpoint
376    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
399/// Local-mode middleware that injects a synthetic admin auth context.
400///
401/// `perfgate serve` runs the server in single-user local mode with
402/// authentication disabled. Many handlers still depend on `AuthContext` for
403/// scope checks and audit metadata, so local mode synthesizes an admin context
404/// instead of skipping the extension entirely.
405pub 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
419/// Checks if the current auth context has the required scope, project access, and benchmark access.
420/// Returns an error response if the scope is not present, project mismatch, or benchmark restricted.
421pub 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    // 1. Check Scope
439    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    // 2. Check Project Isolation
457    // Global admins (those with Scope::Admin) can access any project.
458    // Otherwise, the key's project_id must match the requested project_id.
459    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    // 3. Check Benchmark Restriction
477    // If the key has a benchmark_regex, all accessed benchmarks must match it.
478    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        // Same project, correct scope -> OK
779        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        // Same project, wrong scope -> Forbidden
783        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        // Different project -> Forbidden
788        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        // Global admin can access ANY project
807        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        // Matches regex -> OK
828        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        // Does not match regex -> Forbidden
832        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        // No benchmark name provided (e.g. list operation) -> OK (scoping only applies to explicit access)
837        assert!(check_scope(Some(&ctx), "project-a", None, Scope::Read).is_ok());
838    }
839}