Skip to main content

lfsx_server/
auth.rs

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