Skip to main content

lfsx_server/
auth.rs

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