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