Skip to main content

lfsx_server/
auth.rs

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