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