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 match auth {
57 Auth::Disabled => Self::Disabled,
58 Auth::Forge {
59 provider,
60 api_url,
61 cache_ttl,
62 rejection_ttl,
63 } => Self::Forge {
64 provider: *provider,
65 client: reqwest::Client::builder()
66 .user_agent(concat!("lfsx/", env!("CARGO_PKG_VERSION")))
67 .timeout(std::time::Duration::from_secs(10))
68 .build()
69 .expect("http client"),
70 api_url: api_url.clone(),
71 cache: Cache::new(*cache_ttl, *rejection_ttl),
72 identities: IdentityCache::new(*cache_ttl),
73 },
74 }
75 }
76
77 async fn permission(&self, headers: &HeaderMap, ns: &Namespace) -> Result<Permission, Error> {
78 let Self::Forge {
79 provider,
80 client,
81 api_url,
82 cache,
83 ..
84 } = self
85 else {
86 return Ok(Permission::Admin);
87 };
88
89 let token = credentials::token(headers).ok_or(Error::Unauthenticated)?;
90 if let Some(decision) = cache.get(&token, ns) {
91 return decision.into();
92 }
93
94 let outcome = match provider {
95 Provider::Github => github::permission(client, api_url, &token, ns).await,
96 Provider::Gitlab => gitlab::permission(client, api_url, &token, ns).await,
97 };
98 if let Some(decision) = Decision::of(&outcome) {
99 cache.insert(&token, ns, decision);
100 }
101
102 outcome
103 }
104}
105
106impl Authorizer {
107 pub async fn actor(&self, headers: &HeaderMap) -> Result<Actor, Error> {
108 let Self::Forge {
109 provider,
110 client,
111 api_url,
112 identities,
113 ..
114 } = self
115 else {
116 return Ok(Actor("anonymous".to_owned()));
117 };
118
119 let token = credentials::token(headers).ok_or(Error::Unauthenticated)?;
120 if let Some(login) = identities.get(&token) {
121 return Ok(Actor(login));
122 }
123
124 let login = match provider {
125 Provider::Github => github::login(client, api_url, &token).await?,
126 Provider::Gitlab => gitlab::login(client, api_url, &token).await?,
127 };
128 identities.insert(&token, &login);
129
130 Ok(Actor(login))
131 }
132}
133
134pub async fn authorize(
135 State(state): State<Shared>,
136 Path(params): Path<HashMap<String, String>>,
137 mut request: Request,
138 next: Next,
139) -> Result<Response, Error> {
140 let (Some(org), Some(repo)) = (params.get("org"), params.get("repo")) else {
141 return Err(Error::MalformedNamespace);
142 };
143 let ns = Namespace::new(org.as_str(), repo.as_str())?;
144
145 let permission = state.authorizer.permission(request.headers(), &ns).await?;
146 request.extensions_mut().insert(permission);
147 request.extensions_mut().insert(ns);
148
149 Ok(next.run(request).await)
150}
151
152#[cfg(test)]
153mod tests;