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, Caller, 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        anonymous_read: bool,
52    },
53    Disabled,
54}
55
56impl Authorizer {
57    pub fn new(auth: &Auth) -> Self {
58        crate::tls::install_crypto_provider();
59
60        match auth {
61            Auth::Disabled => Self::Disabled,
62            Auth::Forge {
63                provider,
64                api_url,
65                cache_ttl,
66                rejection_ttl,
67                anonymous_read,
68            } => Self::Forge {
69                provider: *provider,
70                client: reqwest::Client::builder()
71                    .user_agent(concat!("lfsx/", env!("CARGO_PKG_VERSION")))
72                    .timeout(std::time::Duration::from_secs(10))
73                    .build()
74                    .expect("http client"),
75                api_url: api_url.clone(),
76                cache: Cache::new(*cache_ttl, *rejection_ttl),
77                identities: IdentityCache::new(*cache_ttl),
78                anonymous_read: *anonymous_read,
79            },
80        }
81    }
82
83    async fn permission(&self, headers: &HeaderMap, ns: &Namespace) -> Result<Permission, Error> {
84        let Self::Forge {
85            provider,
86            client,
87            api_url,
88            cache,
89            anonymous_read,
90            ..
91        } = self
92        else {
93            return Ok(Permission::Admin);
94        };
95
96        // A request with no credentials is the one an anonymous `git clone` makes.
97        // The forge already knows whether that should be allowed, so it is asked
98        // rather than refused outright, and the answer is cached under its own
99        // key so it can never be handed to somebody presenting a token.
100        let Some(token) = credentials::token(headers) else {
101            if !*anonymous_read {
102                return Err(Error::Unauthenticated);
103            }
104
105            if let Some(decision) = cache.get(Caller::Anonymous, ns) {
106                return decision.into();
107            }
108
109            let outcome = match provider {
110                Provider::Github => github::public(client, api_url, ns).await,
111                Provider::Gitlab => gitlab::public(client, api_url, ns).await,
112            };
113            if let Some(decision) = Decision::of(&outcome) {
114                cache.insert(Caller::Anonymous, ns, decision);
115            }
116
117            return outcome;
118        };
119
120        if let Some(decision) = cache.get(Caller::Token(&token), ns) {
121            return decision.into();
122        }
123
124        let outcome = match provider {
125            Provider::Github => github::permission(client, api_url, &token, ns).await,
126            Provider::Gitlab => gitlab::permission(client, api_url, &token, ns).await,
127        };
128        if let Some(decision) = Decision::of(&outcome) {
129            cache.insert(Caller::Token(&token), ns, decision);
130        }
131
132        outcome
133    }
134}
135
136impl Authorizer {
137    pub async fn actor(&self, headers: &HeaderMap) -> Result<Actor, Error> {
138        let Self::Forge {
139            provider,
140            client,
141            api_url,
142            identities,
143            ..
144        } = self
145        else {
146            return Ok(Actor("anonymous".to_owned()));
147        };
148
149        let token = credentials::token(headers).ok_or(Error::Unauthenticated)?;
150        if let Some(login) = identities.get(&token) {
151            return Ok(Actor(login));
152        }
153
154        let login = match provider {
155            Provider::Github => github::login(client, api_url, &token).await?,
156            Provider::Gitlab => gitlab::login(client, api_url, &token).await?,
157        };
158        identities.insert(&token, &login);
159
160        Ok(Actor(login))
161    }
162}
163
164pub async fn authorize(
165    State(state): State<Shared>,
166    Path(params): Path<HashMap<String, String>>,
167    mut request: Request,
168    next: Next,
169) -> Result<Response, Error> {
170    let (Some(org), Some(repo)) = (params.get("org"), params.get("repo")) else {
171        return Err(Error::MalformedNamespace);
172    };
173    let ns = Namespace::new(org.as_str(), repo.as_str())?;
174
175    let permission = state.authorizer.permission(request.headers(), &ns).await?;
176    request.extensions_mut().insert(permission);
177    request.extensions_mut().insert(ns);
178
179    Ok(next.run(request).await)
180}
181
182#[cfg(test)]
183mod tests;