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