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