Skip to main content

lfsx_server/
auth.rs

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