Skip to main content

lfsx_server/
auth.rs

1mod cache;
2mod credentials;
3mod github;
4mod gitlab;
5
6use std::collections::HashMap;
7
8use axum::extract::{Path, Request, State};
9use axum::http::HeaderMap;
10use axum::middleware::Next;
11use axum::response::Response;
12
13use crate::config::{Auth, Provider};
14use crate::error::Error;
15use crate::namespace::Namespace;
16use crate::state::Shared;
17use cache::{Cache, Decision, IdentityCache};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Permission {
21    Read,
22    Write,
23    Admin,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Actor(pub String);
28
29impl Permission {
30    pub fn require_write(self) -> Result<(), Error> {
31        matches!(self, Self::Write | Self::Admin)
32            .then_some(())
33            .ok_or(Error::Forbidden)
34    }
35
36    pub fn require_admin(self) -> Result<(), Error> {
37        matches!(self, Self::Admin)
38            .then_some(())
39            .ok_or(Error::Forbidden)
40    }
41}
42
43pub enum Authorizer {
44    Forge {
45        provider: Provider,
46        client: reqwest::Client,
47        api_url: String,
48        cache: Cache,
49        identities: IdentityCache,
50    },
51    Disabled,
52}
53
54impl Authorizer {
55    pub fn new(auth: &Auth) -> Self {
56        crate::tls::install_crypto_provider();
57
58        match auth {
59            Auth::Disabled => Self::Disabled,
60            Auth::Forge {
61                provider,
62                api_url,
63                cache_ttl,
64                rejection_ttl,
65            } => Self::Forge {
66                provider: *provider,
67                client: reqwest::Client::builder()
68                    .user_agent(concat!("lfsx/", env!("CARGO_PKG_VERSION")))
69                    .timeout(std::time::Duration::from_secs(10))
70                    .build()
71                    .expect("http client"),
72                api_url: api_url.clone(),
73                cache: Cache::new(*cache_ttl, *rejection_ttl),
74                identities: IdentityCache::new(*cache_ttl),
75            },
76        }
77    }
78
79    async fn permission(&self, headers: &HeaderMap, ns: &Namespace) -> Result<Permission, Error> {
80        let Self::Forge {
81            provider,
82            client,
83            api_url,
84            cache,
85            ..
86        } = self
87        else {
88            return Ok(Permission::Admin);
89        };
90
91        let token = credentials::token(headers).ok_or(Error::Unauthenticated)?;
92        if let Some(decision) = cache.get(&token, ns) {
93            return decision.into();
94        }
95
96        let outcome = match provider {
97            Provider::Github => github::permission(client, api_url, &token, ns).await,
98            Provider::Gitlab => gitlab::permission(client, api_url, &token, ns).await,
99        };
100        if let Some(decision) = Decision::of(&outcome) {
101            cache.insert(&token, ns, decision);
102        }
103
104        outcome
105    }
106}
107
108impl Authorizer {
109    pub async fn actor(&self, headers: &HeaderMap) -> Result<Actor, Error> {
110        let Self::Forge {
111            provider,
112            client,
113            api_url,
114            identities,
115            ..
116        } = self
117        else {
118            return Ok(Actor("anonymous".to_owned()));
119        };
120
121        let token = credentials::token(headers).ok_or(Error::Unauthenticated)?;
122        if let Some(login) = identities.get(&token) {
123            return Ok(Actor(login));
124        }
125
126        let login = match provider {
127            Provider::Github => github::login(client, api_url, &token).await?,
128            Provider::Gitlab => gitlab::login(client, api_url, &token).await?,
129        };
130        identities.insert(&token, &login);
131
132        Ok(Actor(login))
133    }
134}
135
136pub async fn authorize(
137    State(state): State<Shared>,
138    Path(params): Path<HashMap<String, String>>,
139    mut request: Request,
140    next: Next,
141) -> Result<Response, Error> {
142    let (Some(org), Some(repo)) = (params.get("org"), params.get("repo")) else {
143        return Err(Error::MalformedNamespace);
144    };
145    let ns = Namespace::new(org.as_str(), repo.as_str())?;
146
147    let permission = state.authorizer.permission(request.headers(), &ns).await?;
148    request.extensions_mut().insert(permission);
149    request.extensions_mut().insert(ns);
150
151    Ok(next.run(request).await)
152}
153
154#[cfg(test)]
155mod tests;