Skip to main content

lfsx_server/
auth.rs

1mod cache;
2mod credentials;
3mod github;
4mod gitlab;
5
6use std::collections::HashMap;
7
8use axum::extract::{Path, Request, State};
9use axum::http::HeaderMap;
10use axum::middleware::Next;
11use axum::response::Response;
12
13use crate::config::{Auth, Provider};
14use crate::error::Error;
15use crate::namespace::Namespace;
16use crate::state::Shared;
17use cache::{Cache, Decision, IdentityCache};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Permission {
21    Read,
22    Write,
23    Admin,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Actor(pub String);
28
29impl Permission {
30    pub fn require_write(self) -> Result<(), Error> {
31        matches!(self, Self::Write | Self::Admin)
32            .then_some(())
33            .ok_or(Error::Forbidden)
34    }
35
36    pub fn require_admin(self) -> Result<(), Error> {
37        matches!(self, Self::Admin)
38            .then_some(())
39            .ok_or(Error::Forbidden)
40    }
41}
42
43pub enum Authorizer {
44    Forge {
45        provider: Provider,
46        client: reqwest::Client,
47        api_url: String,
48        cache: Cache,
49        identities: IdentityCache,
50    },
51    Disabled,
52}
53
54impl Authorizer {
55    pub fn new(auth: &Auth) -> Self {
56        match auth {
57            Auth::Disabled => Self::Disabled,
58            Auth::Forge {
59                provider,
60                api_url,
61                cache_ttl,
62                rejection_ttl,
63            } => Self::Forge {
64                provider: *provider,
65                client: reqwest::Client::builder()
66                    .user_agent(concat!("lfsx/", env!("CARGO_PKG_VERSION")))
67                    .timeout(std::time::Duration::from_secs(10))
68                    .build()
69                    .expect("http client"),
70                api_url: api_url.clone(),
71                cache: Cache::new(*cache_ttl, *rejection_ttl),
72                identities: IdentityCache::new(*cache_ttl),
73            },
74        }
75    }
76
77    async fn permission(&self, headers: &HeaderMap, ns: &Namespace) -> Result<Permission, Error> {
78        let Self::Forge {
79            provider,
80            client,
81            api_url,
82            cache,
83            ..
84        } = self
85        else {
86            return Ok(Permission::Admin);
87        };
88
89        let token = credentials::token(headers).ok_or(Error::Unauthenticated)?;
90        if let Some(decision) = cache.get(&token, ns) {
91            return decision.into();
92        }
93
94        let outcome = match provider {
95            Provider::Github => github::permission(client, api_url, &token, ns).await,
96            Provider::Gitlab => gitlab::permission(client, api_url, &token, ns).await,
97        };
98        if let Some(decision) = Decision::of(&outcome) {
99            cache.insert(&token, ns, decision);
100        }
101
102        outcome
103    }
104}
105
106impl Authorizer {
107    pub async fn actor(&self, headers: &HeaderMap) -> Result<Actor, Error> {
108        let Self::Forge {
109            provider,
110            client,
111            api_url,
112            identities,
113            ..
114        } = self
115        else {
116            return Ok(Actor("anonymous".to_owned()));
117        };
118
119        let token = credentials::token(headers).ok_or(Error::Unauthenticated)?;
120        if let Some(login) = identities.get(&token) {
121            return Ok(Actor(login));
122        }
123
124        let login = match provider {
125            Provider::Github => github::login(client, api_url, &token).await?,
126            Provider::Gitlab => gitlab::login(client, api_url, &token).await?,
127        };
128        identities.insert(&token, &login);
129
130        Ok(Actor(login))
131    }
132}
133
134pub async fn authorize(
135    State(state): State<Shared>,
136    Path(params): Path<HashMap<String, String>>,
137    mut request: Request,
138    next: Next,
139) -> Result<Response, Error> {
140    let (Some(org), Some(repo)) = (params.get("org"), params.get("repo")) else {
141        return Err(Error::MalformedNamespace);
142    };
143    let ns = Namespace::new(org.as_str(), repo.as_str())?;
144
145    let permission = state.authorizer.permission(request.headers(), &ns).await?;
146    request.extensions_mut().insert(permission);
147    request.extensions_mut().insert(ns);
148
149    Ok(next.run(request).await)
150}
151
152#[cfg(test)]
153mod tests;