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;