Skip to main content

lfsx_server/
auth.rs

1mod backoff;
2mod budget;
3mod cache;
4mod credentials;
5mod gitea;
6mod github;
7mod gitlab;
8
9use std::collections::HashMap;
10
11use axum::extract::{Path, Request, State};
12use axum::http::HeaderMap;
13use axum::middleware::Next;
14use axum::response::Response;
15
16use crate::config::{Auth, Provider};
17use crate::error::Error;
18use crate::namespace::Namespace;
19use crate::state::Shared;
20use budget::Budget;
21use cache::{Cache, Caller, Decision, IdentityCache};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Permission {
25    Read,
26    Write,
27    Admin,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Actor(pub String);
32
33impl Permission {
34    pub fn require_write(self) -> Result<(), Error> {
35        matches!(self, Self::Write | Self::Admin)
36            .then_some(())
37            .ok_or(Error::Forbidden)
38    }
39
40    pub fn require_admin(self) -> Result<(), Error> {
41        matches!(self, Self::Admin)
42            .then_some(())
43            .ok_or(Error::Forbidden)
44    }
45}
46
47pub enum Authorizer {
48    Forge {
49        provider: Provider,
50        client: reqwest::Client,
51        api_url: String,
52        // Boxed because this variant carries four sizeable things and the other
53        // carries nothing, so every `Authorizer` in the process would pay for the
54        // difference. The same reason `Backend::Bucket` boxes its handle.
55        cache: Box<Cache>,
56        identities: IdentityCache,
57        // Spent only on a lookup the caches could not answer, which is what
58        // makes it a ceiling on forge traffic rather than on requests: a push of
59        // two hundred objects under one token costs one.
60        budget: Budget,
61        anonymous_read: bool,
62    },
63    Disabled,
64}
65
66impl Authorizer {
67    pub fn new(auth: &Auth) -> Self {
68        crate::tls::install_crypto_provider();
69
70        match auth {
71            Auth::Disabled => Self::Disabled,
72            Auth::Forge {
73                provider,
74                api_url,
75                cache_ttl,
76                rejection_ttl,
77                lookup_budget,
78                anonymous_read,
79            } => Self::Forge {
80                provider: *provider,
81                client: reqwest::Client::builder()
82                    .user_agent(concat!("lfsx/", env!("CARGO_PKG_VERSION")))
83                    .timeout(std::time::Duration::from_secs(10))
84                    .build()
85                    .expect("http client"),
86                api_url: api_url.clone(),
87                cache: Box::new(Cache::new(*cache_ttl, *rejection_ttl)),
88                identities: IdentityCache::new(*cache_ttl),
89                budget: Budget::new(*lookup_budget),
90                anonymous_read: *anonymous_read,
91            },
92        }
93    }
94
95    async fn permission(&self, headers: &HeaderMap, ns: &Namespace) -> Result<Permission, Error> {
96        let Self::Forge {
97            provider,
98            client,
99            api_url,
100            cache,
101            budget,
102            anonymous_read,
103            ..
104        } = self
105        else {
106            return Ok(Permission::Admin);
107        };
108
109        // A request with no credentials is the one an anonymous `git clone` makes.
110        // The forge already knows whether that should be allowed, so it is asked
111        // rather than refused outright, and the answer is cached under its own
112        // key so it can never be handed to somebody presenting a token.
113        let Some(token) = credentials::token(headers) else {
114            if !*anonymous_read {
115                return Err(Error::Unauthenticated);
116            }
117
118            if let Some(decision) = cache.get(Caller::Anonymous, ns) {
119                return decision.into();
120            }
121
122            budget.afford()?;
123
124            let outcome = match provider {
125                Provider::Github => github::public(client, api_url, ns).await,
126                Provider::Gitlab => gitlab::public(client, api_url, ns).await,
127                Provider::Gitea => gitea::public(client, api_url, ns).await,
128            };
129            if let Some(decision) = Decision::of(&outcome) {
130                cache.insert(Caller::Anonymous, ns, decision);
131            }
132
133            return outcome;
134        };
135
136        if let Some(decision) = cache.get(Caller::Token(&token), ns) {
137            return decision.into();
138        }
139
140        // Only here, past both caches. Everything above this line was answered
141        // without asking anybody.
142        budget.afford()?;
143
144        let outcome = match provider {
145            Provider::Github => github::permission(client, api_url, &token, ns).await,
146            Provider::Gitlab => gitlab::permission(client, api_url, &token, ns).await,
147            Provider::Gitea => gitea::permission(client, api_url, &token, ns).await,
148        };
149        if let Some(decision) = Decision::of(&outcome) {
150            cache.insert(Caller::Token(&token), ns, decision);
151        }
152
153        outcome
154    }
155}
156
157impl Authorizer {
158    pub async fn actor(&self, headers: &HeaderMap) -> Result<Actor, Error> {
159        let Self::Forge {
160            provider,
161            client,
162            api_url,
163            identities,
164            budget,
165            ..
166        } = self
167        else {
168            return Ok(Actor("anonymous".to_owned()));
169        };
170
171        let token = credentials::token(headers).ok_or(Error::Unauthenticated)?;
172        if let Some(login) = identities.get(&token) {
173            return Ok(Actor(login));
174        }
175
176        budget.afford()?;
177
178        let login = match provider {
179            Provider::Github => github::login(client, api_url, &token).await?,
180            Provider::Gitlab => gitlab::login(client, api_url, &token).await?,
181            Provider::Gitea => gitea::login(client, api_url, &token).await?,
182        };
183        identities.insert(&token, &login);
184
185        Ok(Actor(login))
186    }
187}
188
189pub async fn authorize(
190    State(state): State<Shared>,
191    Path(params): Path<HashMap<String, String>>,
192    mut request: Request,
193    next: Next,
194) -> Result<Response, Error> {
195    let (Some(org), Some(repo)) = (params.get("org"), params.get("repo")) else {
196        return Err(Error::MalformedNamespace);
197    };
198    let ns = Namespace::new(org.as_str(), repo.as_str())?;
199
200    let permission = state.authorizer.permission(request.headers(), &ns).await?;
201    request.extensions_mut().insert(permission);
202    request.extensions_mut().insert(ns);
203
204    Ok(next.run(request).await)
205}
206
207#[cfg(test)]
208mod tests;