holger-server-lib 0.6.7

Holger server library: config, wiring, gRPC service, Rust API
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};

use serde::{Deserialize, Serialize};

/// A coarse authorization role. Ordered least → most privileged (the derived
/// `Ord` follows declaration order: `Reader < Writer < Admin`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Role {
    /// Download + list only.
    Reader,
    /// + upload / create repos (everything `Reader` can, plus writes).
    Writer,
    /// + promote / delete / manage config.
    Admin,
}

impl Role {
    /// May this role write (upload/put) artifacts?
    pub fn can_write(self) -> bool {
        matches!(self, Role::Writer | Role::Admin)
    }
    /// May this role perform admin actions (promote/delete/manage)?
    pub fn can_admin(self) -> bool {
        matches!(self, Role::Admin)
    }
}

/// Auth configuration for holger-server.
///
/// `methods` is authentication (who are you); `roles`/`default_role` are
/// authorization (what may you do). Authorization is **declarative policy**:
/// it lives here in the RON config (git-tracked, reviewable, immutable in the
/// static/drift flavor), never in a mutable store.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthConfig {
    /// Which auth methods to accept. Empty = no auth (open access).
    pub methods: Vec<AuthMethodConfig>,

    /// Identity → role map. Identity is the OIDC `sub` claim or the mTLS client
    /// cert CN (the same string `AuthIdentity::subject` carries). Absent/empty
    /// (with no `default_role`) means **RBAC is off** — the server is
    /// authentication-only and any valid identity may write, preserving the
    /// pre-RBAC behaviour for existing configs.
    #[serde(default)]
    pub roles: HashMap<String, Role>,

    /// Role granted to an authenticated identity that has no explicit mapping.
    /// `None` + an empty `roles` map = RBAC off. When RBAC is on, an unmapped
    /// identity falls back to this (or `Reader` — least privilege — if unset).
    #[serde(default)]
    pub default_role: Option<Role>,
}

impl AuthConfig {
    /// RBAC is active iff any role policy is configured (an explicit mapping or
    /// a default role). When inactive the write path is authN-only.
    pub fn rbac_enabled(&self) -> bool {
        !self.roles.is_empty() || self.default_role.is_some()
    }

    /// Resolve the role for an authenticated `subject`. An explicit mapping wins;
    /// otherwise the configured `default_role`; otherwise `Reader` (least
    /// privilege — fail closed).
    pub fn role_for(&self, subject: &str) -> Role {
        self.roles
            .get(subject)
            .copied()
            .or(self.default_role)
            .unwrap_or(Role::Reader)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AuthMethodConfig {
    /// Validate OIDC Bearer tokens via userinfo endpoint.
    Oidc {
        /// OIDC issuer URL (e.g. http://mannequin:9999)
        issuer_url: String,
    },
    /// Validate client certificates (mTLS). Requires TLS with client auth.
    Mtls {
        /// Trusted CA PEM for client certs.
        ca_cert: String,
    },
}

impl Default for AuthConfig {
    fn default() -> Self {
        Self { methods: vec![], roles: HashMap::new(), default_role: None }
    }
}

/// Resolved identity from auth validation.
#[derive(Debug, Clone)]
pub struct AuthIdentity {
    pub subject: String,
    pub method: String,
}

/// Validate an incoming request's auth.
/// Returns Some(identity) if valid, None if no auth configured (open access).
/// Returns Err if auth is configured but credentials are invalid.
pub async fn validate_request(
    config: &AuthConfig,
    bearer_token: Option<&str>,
    client_cert_cn: Option<&str>,
) -> Result<Option<AuthIdentity>, AuthError> {
    if config.methods.is_empty() {
        return Ok(None); // No auth configured — open access
    }

    // Try each configured method
    for method in &config.methods {
        match method {
            AuthMethodConfig::Oidc { issuer_url } => {
                if let Some(token) = bearer_token {
                    match validate_oidc_token(issuer_url, token).await {
                        Ok(subject) => return Ok(Some(AuthIdentity {
                            subject,
                            method: "oidc".into(),
                        })),
                        Err(_) => continue,
                    }
                }
            }
            AuthMethodConfig::Mtls { .. } => {
                if let Some(cn) = client_cert_cn {
                    return Ok(Some(AuthIdentity {
                        subject: cn.to_string(),
                        method: "mtls".into(),
                    }));
                }
            }
        }
    }

    Err(AuthError::Unauthorized)
}

#[derive(Debug)]
pub enum AuthError {
    Unauthorized,
}

/// How long a validated `(issuer, token)` → `subject` result is trusted before
/// the `/userinfo` endpoint is consulted again. Short enough that a revoked
/// token stops working quickly; long enough that a burst of writes with one
/// token costs a single round-trip. Override with `HOLGER_OIDC_CACHE_TTL_SECS`.
const OIDC_CACHE_TTL: Duration = Duration::from_secs(120);

fn oidc_cache_ttl() -> Duration {
    std::env::var("HOLGER_OIDC_CACHE_TTL_SECS")
        .ok()
        .and_then(|v| v.trim().parse::<u64>().ok())
        .map(Duration::from_secs)
        .unwrap_or(OIDC_CACHE_TTL)
}

/// One shared `reqwest::Client` for all OIDC validations: a connection pool +
/// reused TLS sessions instead of a fresh client (and fresh TCP+TLS handshake)
/// per write. Built once, lazily.
fn oidc_client() -> &'static reqwest::Client {
    static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
    CLIENT.get_or_init(|| {
        reqwest::Client::builder()
            .pool_idle_timeout(Duration::from_secs(90))
            .timeout(Duration::from_secs(10))
            .build()
            .unwrap_or_else(|_| reqwest::Client::new())
    })
}

/// Cache of validated tokens. Key = `(issuer, blake3(token))` so the raw bearer
/// token is never held and keys can't collide into an auth bypass. Value =
/// `(subject, inserted_at)`; entries past the TTL are ignored and purged.
type OidcCacheKey = (String, [u8; 32]);
fn oidc_cache() -> &'static Mutex<HashMap<OidcCacheKey, (String, Instant)>> {
    static CACHE: OnceLock<Mutex<HashMap<OidcCacheKey, (String, Instant)>>> = OnceLock::new();
    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}

fn token_digest(token: &str) -> [u8; 32] {
    *blake3::hash(token.as_bytes()).as_bytes()
}

/// Validate an OIDC Bearer token, returning its `subject` (`sub` claim).
///
/// Fast path: a cached, unexpired result for this `(issuer, token)` returns
/// without any network call. Slow path (cache miss/expiry): one `/userinfo`
/// round-trip on the shared pooled client, then the result is cached for the TTL.
async fn validate_oidc_token(issuer_url: &str, token: &str) -> Result<String, AuthError> {
    let ttl = oidc_cache_ttl();
    let key: OidcCacheKey = (issuer_url.to_string(), token_digest(token));

    // Fast path — cache hit within the TTL skips the round-trip.
    if let Ok(cache) = oidc_cache().lock() {
        if let Some((subject, inserted)) = cache.get(&key) {
            if inserted.elapsed() < ttl {
                return Ok(subject.clone());
            }
        }
    }

    let userinfo_url = format!("{}/userinfo", issuer_url.trim_end_matches('/'));
    let resp = oidc_client()
        .get(&userinfo_url)
        .header("Authorization", format!("Bearer {}", token))
        .send()
        .await
        .map_err(|_| AuthError::Unauthorized)?;

    if !resp.status().is_success() {
        return Err(AuthError::Unauthorized);
    }

    let body: serde_json::Value = resp.json().await.map_err(|_| AuthError::Unauthorized)?;
    let subject = body
        .get("sub")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .ok_or(AuthError::Unauthorized)?;

    // Cache the fresh result; opportunistically purge expired entries so the map
    // can't grow without bound under a churn of distinct tokens.
    if let Ok(mut cache) = oidc_cache().lock() {
        cache.retain(|_, (_, inserted)| inserted.elapsed() < ttl);
        cache.insert(key, (subject.clone(), Instant::now()));
    }

    Ok(subject)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::Arc;
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    use tokio::net::TcpListener;

    /// A throwaway `/userinfo` that always says `sub=alice` and counts how many
    /// TCP connections (i.e. real validations) it served. Returns the issuer URL.
    async fn spawn_userinfo(hits: Arc<AtomicUsize>) -> String {
        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = listener.local_addr().unwrap();
        tokio::spawn(async move {
            while let Ok((mut sock, _)) = listener.accept().await {
                hits.fetch_add(1, Ordering::SeqCst);
                let mut buf = [0u8; 1024];
                let _ = sock.read(&mut buf).await; // drain request (best-effort)
                let body = br#"{"sub":"alice"}"#;
                let head = format!(
                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
                    body.len()
                );
                let _ = sock.write_all(head.as_bytes()).await;
                let _ = sock.write_all(body).await;
                let _ = sock.flush().await;
            }
        });
        format!("http://{addr}")
    }

    /// W1: the second validation of the same `(issuer, token)` is served from the
    /// cache — the `/userinfo` endpoint is hit exactly once, not per write.
    #[tokio::test]
    async fn oidc_validation_caches_and_skips_repeat_userinfo_call() {
        let hits = Arc::new(AtomicUsize::new(0));
        // Unique token so this test's cache entry can't be touched by others
        // sharing the process-global cache.
        let token = "tok-w1-cache-test-7f3a9c";
        let issuer = spawn_userinfo(hits.clone()).await;

        let s1 = validate_oidc_token(&issuer, token).await.expect("first validate ok");
        let s2 = validate_oidc_token(&issuer, token).await.expect("second validate ok");

        assert_eq!(s1, "alice");
        assert_eq!(s2, "alice");
        assert_eq!(
            hits.load(Ordering::SeqCst),
            1,
            "second validation must come from cache — /userinfo should be hit once, not twice"
        );
    }

    /// A token the cache has never seen still triggers a real `/userinfo` call
    /// (the cache is keyed per-token, so a different token is not a hit).
    #[tokio::test]
    async fn oidc_distinct_token_is_not_a_cache_hit() {
        let hits = Arc::new(AtomicUsize::new(0));
        let issuer = spawn_userinfo(hits.clone()).await;

        let _ = validate_oidc_token(&issuer, "tok-w1-distinct-A-11").await.expect("A ok");
        let _ = validate_oidc_token(&issuer, "tok-w1-distinct-B-22").await.expect("B ok");

        assert_eq!(
            hits.load(Ordering::SeqCst),
            2,
            "two distinct tokens must each validate against /userinfo"
        );
    }

    // ── W2: role policy (authorization) ──────────────────────────────────────

    #[test]
    fn role_privilege_ordering_and_capabilities() {
        assert!(Role::Reader < Role::Writer && Role::Writer < Role::Admin);
        assert!(!Role::Reader.can_write());
        assert!(Role::Writer.can_write());
        assert!(Role::Admin.can_write());
        assert!(!Role::Writer.can_admin());
        assert!(Role::Admin.can_admin());
    }

    #[test]
    fn rbac_is_off_until_policy_is_configured() {
        // No roles, no default → RBAC inactive (authN-only, back-compat).
        let open = AuthConfig::default();
        assert!(!open.rbac_enabled());

        // A single mapping turns it on.
        let mut roles = HashMap::new();
        roles.insert("alice".to_string(), Role::Writer);
        let cfg = AuthConfig { methods: vec![], roles, default_role: None };
        assert!(cfg.rbac_enabled());

        // A default role alone also turns it on.
        let cfg2 = AuthConfig { methods: vec![], roles: HashMap::new(), default_role: Some(Role::Reader) };
        assert!(cfg2.rbac_enabled());
    }

    #[test]
    fn role_resolution_explicit_then_default_then_least_privilege() {
        let mut roles = HashMap::new();
        roles.insert("alice".to_string(), Role::Admin);
        roles.insert("bot".to_string(), Role::Writer);
        let cfg = AuthConfig { methods: vec![], roles, default_role: Some(Role::Reader) };

        // Explicit mapping wins.
        assert_eq!(cfg.role_for("alice"), Role::Admin);
        assert_eq!(cfg.role_for("bot"), Role::Writer);
        // Unmapped identity falls back to default_role.
        assert_eq!(cfg.role_for("stranger"), Role::Reader);

        // With no default_role, an unmapped identity is least-privilege Reader.
        let cfg_nodef = AuthConfig {
            methods: vec![],
            roles: HashMap::from([("bot".to_string(), Role::Writer)]),
            default_role: None,
        };
        assert_eq!(cfg_nodef.role_for("stranger"), Role::Reader);
        assert!(!cfg_nodef.role_for("stranger").can_write(), "unmapped identity cannot write");
    }
}