1mod backoff;
2mod budget;
3mod cache;
4mod credentials;
5mod gitea;
6mod github;
7mod gitlab;
8mod namespaces;
9
10use std::collections::HashMap;
11use std::sync::{Arc, RwLock};
12
13use axum::extract::{Path, Request, State};
14use axum::http::HeaderMap;
15use axum::middleware::Next;
16use axum::response::Response;
17
18use crate::config::{Auth, Provider};
19use crate::error::Error;
20use crate::namespace::Namespace;
21use crate::state::Shared;
22use budget::Budget;
23use cache::{Cache, Caller, Decision, IdentityCache};
24pub use namespaces::Namespaces;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Permission {
28 Read,
29 Write,
30 Admin,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Actor(pub String);
35
36impl Permission {
37 pub fn require_write(self) -> Result<(), Error> {
38 matches!(self, Self::Write | Self::Admin)
39 .then_some(())
40 .ok_or(Error::Forbidden)
41 }
42
43 pub fn require_admin(self) -> Result<(), Error> {
44 matches!(self, Self::Admin)
45 .then_some(())
46 .ok_or(Error::Forbidden)
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Access {
55 pub anonymous_read: bool,
56 pub restricted: Namespaces,
57 pub allowed: Option<Namespaces>,
58}
59
60pub enum Authorizer {
61 Forge {
62 provider: Provider,
63 client: reqwest::Client,
64 api_url: String,
65 cache: Box<Cache>,
69 identities: IdentityCache,
70 budget: Budget,
74 access: Arc<RwLock<Access>>,
75 app: Option<Box<github::app::App>>,
76 },
77 Disabled,
78}
79
80impl Authorizer {
81 pub fn new(auth: &Auth) -> Self {
82 crate::tls::install_crypto_provider();
83
84 match auth {
85 Auth::Disabled => Self::Disabled,
86 Auth::Forge {
87 provider,
88 api_url,
89 cache_ttl,
90 rejection_ttl,
91 lookup_budget,
92 anonymous_read,
93 restricted,
94 allowed,
95 github_app,
96 } => Self::Forge {
97 provider: *provider,
98 client: reqwest::Client::builder()
99 .user_agent(concat!("lfsx/", env!("CARGO_PKG_VERSION")))
100 .timeout(std::time::Duration::from_secs(10))
101 .build()
102 .expect("http client"),
103 api_url: api_url.clone(),
104 cache: Box::new(Cache::new(*cache_ttl, *rejection_ttl)),
105 identities: IdentityCache::new(*cache_ttl),
106 budget: Budget::new(*lookup_budget),
107 access: Arc::new(RwLock::new(Access {
108 anonymous_read: *anonymous_read,
109 restricted: restricted.clone(),
110 allowed: allowed.clone(),
111 })),
112 app: github_app.as_ref().map(|configured| {
113 Box::new(github::app::App::load(
114 &configured.app_id,
115 &configured.key_file,
116 *rejection_ttl,
117 ))
118 }),
119 },
120 }
121 }
122
123 pub fn access(&self) -> Option<Access> {
124 match self {
125 Self::Forge { access, .. } => Some(access.read().unwrap().clone()),
126 Self::Disabled => None,
127 }
128 }
129
130 pub fn set_access(&self, replacement: Access) {
131 if let Self::Forge { access, .. } = self {
132 *access.write().unwrap() = replacement;
133 }
134 }
135
136 pub(crate) async fn permission(
137 &self,
138 headers: &HeaderMap,
139 ns: &Namespace,
140 ) -> Result<Permission, Error> {
141 self.served(ns)?;
142 self.forge_permission(headers, ns).await
143 }
144
145 fn served(&self, ns: &Namespace) -> Result<(), Error> {
146 let Self::Forge { access, .. } = self else {
147 return Ok(());
148 };
149
150 match &access.read().unwrap().allowed {
151 Some(allowed) if !allowed.covers(ns) => Err(Error::NotServed),
152 _ => Ok(()),
153 }
154 }
155
156 #[tracing::instrument(skip_all, fields(namespace = %ns))]
157 pub(crate) async fn forge_permission(
158 &self,
159 headers: &HeaderMap,
160 ns: &Namespace,
161 ) -> Result<Permission, Error> {
162 let Self::Forge {
163 provider,
164 client,
165 api_url,
166 cache,
167 budget,
168 access,
169 app,
170 ..
171 } = self
172 else {
173 return Ok(Permission::Admin);
174 };
175
176 let (anonymous_read, writers_only) = {
177 let access = access.read().unwrap();
178 (access.anonymous_read, access.restricted.covers(ns))
179 };
180 let decided = |outcome: Result<Permission, Error>| {
181 if writers_only {
182 let permission = outcome?;
183 permission.require_write()?;
184 return Ok(permission);
185 }
186 outcome
187 };
188
189 let Some(token) = credentials::token(headers) else {
194 if !anonymous_read || writers_only {
201 return Err(Error::Unauthenticated);
202 }
203
204 if let Some(decision) = cache.get(Caller::Anonymous, ns) {
205 return decision.into();
206 }
207
208 budget.afford()?;
209
210 let outcome = match provider {
211 Provider::Github => github::public(client, api_url, app.as_deref(), ns).await,
212 Provider::Gitlab => gitlab::public(client, api_url, ns).await,
213 Provider::Gitea => gitea::public(client, api_url, ns).await,
214 };
215 if let Some(decision) = Decision::of(&outcome) {
216 cache.insert(Caller::Anonymous, ns, decision);
217 }
218
219 return outcome;
220 };
221
222 if let Some(decision) = cache.get(Caller::Token(&token), ns) {
223 return decided(decision.into());
224 }
225
226 budget.afford()?;
229
230 let outcome = match provider {
231 Provider::Github => github::permission(client, api_url, &token, ns).await,
232 Provider::Gitlab => gitlab::permission(client, api_url, &token, ns).await,
233 Provider::Gitea => gitea::permission(client, api_url, &token, ns).await,
234 };
235 if let Some(decision) = Decision::of(&outcome) {
236 cache.insert(Caller::Token(&token), ns, decision);
237 }
238
239 decided(outcome)
240 }
241}
242
243impl Authorizer {
244 #[tracing::instrument(skip_all)]
245 pub async fn actor(&self, headers: &HeaderMap) -> Result<Actor, Error> {
246 let Self::Forge {
247 provider,
248 client,
249 api_url,
250 identities,
251 budget,
252 ..
253 } = self
254 else {
255 return Ok(Actor("anonymous".to_owned()));
256 };
257
258 let token = credentials::token(headers).ok_or(Error::Unauthenticated)?;
259 if let Some(login) = identities.get(&token) {
260 return Ok(Actor(login));
261 }
262
263 budget.afford()?;
264
265 let login = match provider {
266 Provider::Github => github::login(client, api_url, &token).await?,
267 Provider::Gitlab => gitlab::login(client, api_url, &token).await?,
268 Provider::Gitea => gitea::login(client, api_url, &token).await?,
269 };
270 identities.insert(&token, &login);
271
272 Ok(Actor(login))
273 }
274}
275
276pub async fn authorize(
277 State(state): State<Shared>,
278 Path(params): Path<HashMap<String, String>>,
279 mut request: Request,
280 next: Next,
281) -> Result<Response, Error> {
282 let (Some(org), Some(repo)) = (params.get("org"), params.get("repo")) else {
283 return Err(Error::MalformedNamespace);
284 };
285 let ns = match params.get("forge") {
286 Some(forge) => Namespace::on(forge.as_str(), org.as_str(), repo.as_str())
287 .map_err(|_| Error::NotServed)?,
288 None => Namespace::new(org.as_str(), repo.as_str())?,
289 };
290
291 let permission = state
292 .authorizer_for(&ns)?
293 .permission(request.headers(), &ns)
294 .await?;
295 request.extensions_mut().insert(permission);
296 request.extensions_mut().insert(ns);
297
298 Ok(next.run(request).await)
299}
300
301#[cfg(test)]
302mod tests;