Skip to main content

lfsx_server/
auth.rs

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