lfsx-server 0.23.1

A fast, lightweight, secure Git LFS server
Documentation
mod cache;
mod credentials;
mod github;
mod gitlab;

use std::collections::HashMap;

use axum::extract::{Path, Request, State};
use axum::http::HeaderMap;
use axum::middleware::Next;
use axum::response::Response;

use crate::config::{Auth, Provider};
use crate::error::Error;
use crate::namespace::Namespace;
use crate::state::Shared;
use cache::{Cache, Decision, IdentityCache};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Permission {
    Read,
    Write,
    Admin,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Actor(pub String);

impl Permission {
    pub fn require_write(self) -> Result<(), Error> {
        matches!(self, Self::Write | Self::Admin)
            .then_some(())
            .ok_or(Error::Forbidden)
    }

    pub fn require_admin(self) -> Result<(), Error> {
        matches!(self, Self::Admin)
            .then_some(())
            .ok_or(Error::Forbidden)
    }
}

pub enum Authorizer {
    Forge {
        provider: Provider,
        client: reqwest::Client,
        api_url: String,
        cache: Cache,
        identities: IdentityCache,
    },
    Disabled,
}

impl Authorizer {
    pub fn new(auth: &Auth) -> Self {
        match auth {
            Auth::Disabled => Self::Disabled,
            Auth::Forge {
                provider,
                api_url,
                cache_ttl,
                rejection_ttl,
            } => Self::Forge {
                provider: *provider,
                client: reqwest::Client::builder()
                    .user_agent(concat!("lfsx/", env!("CARGO_PKG_VERSION")))
                    .timeout(std::time::Duration::from_secs(10))
                    .build()
                    .expect("http client"),
                api_url: api_url.clone(),
                cache: Cache::new(*cache_ttl, *rejection_ttl),
                identities: IdentityCache::new(*cache_ttl),
            },
        }
    }

    async fn permission(&self, headers: &HeaderMap, ns: &Namespace) -> Result<Permission, Error> {
        let Self::Forge {
            provider,
            client,
            api_url,
            cache,
            ..
        } = self
        else {
            return Ok(Permission::Admin);
        };

        let token = credentials::token(headers).ok_or(Error::Unauthenticated)?;
        if let Some(decision) = cache.get(&token, ns) {
            return decision.into();
        }

        let outcome = match provider {
            Provider::Github => github::permission(client, api_url, &token, ns).await,
            Provider::Gitlab => gitlab::permission(client, api_url, &token, ns).await,
        };
        if let Some(decision) = Decision::of(&outcome) {
            cache.insert(&token, ns, decision);
        }

        outcome
    }
}

impl Authorizer {
    pub async fn actor(&self, headers: &HeaderMap) -> Result<Actor, Error> {
        let Self::Forge {
            provider,
            client,
            api_url,
            identities,
            ..
        } = self
        else {
            return Ok(Actor("anonymous".to_owned()));
        };

        let token = credentials::token(headers).ok_or(Error::Unauthenticated)?;
        if let Some(login) = identities.get(&token) {
            return Ok(Actor(login));
        }

        let login = match provider {
            Provider::Github => github::login(client, api_url, &token).await?,
            Provider::Gitlab => gitlab::login(client, api_url, &token).await?,
        };
        identities.insert(&token, &login);

        Ok(Actor(login))
    }
}

pub async fn authorize(
    State(state): State<Shared>,
    Path(params): Path<HashMap<String, String>>,
    mut request: Request,
    next: Next,
) -> Result<Response, Error> {
    let (Some(org), Some(repo)) = (params.get("org"), params.get("repo")) else {
        return Err(Error::MalformedNamespace);
    };
    let ns = Namespace::new(org.as_str(), repo.as_str())?;

    let permission = state.authorizer.permission(request.headers(), &ns).await?;
    request.extensions_mut().insert(permission);
    request.extensions_mut().insert(ns);

    Ok(next.run(request).await)
}

#[cfg(test)]
mod tests;