1use crate::claims::{Permission, TokenClaims};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Action {
13 Read,
15 Write,
17}
18
19impl Action {
20 pub fn required_permission(self) -> Permission {
22 match self {
23 Action::Read => Permission::Read,
24 Action::Write => Permission::Write,
25 }
26 }
27}
28
29impl std::fmt::Display for Action {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 match self {
32 Action::Read => write!(f, "read"),
33 Action::Write => write!(f, "write"),
34 }
35 }
36}
37
38#[derive(Debug, thiserror::Error)]
44pub enum AuthError {
45 #[error("missing or malformed bearer token")]
47 MissingToken,
48 #[error("invalid token: {0}")]
50 Invalid(String),
51 #[error("token expired")]
53 Expired,
54 #[error("permission denied: {action} on `{path}`")]
56 Forbidden {
57 path: String,
59 action: Action,
61 },
62}
63
64pub trait TokenVerifier: Send + Sync + 'static {
70 fn verify(&self, token: &str) -> Result<TokenClaims, AuthError>;
76}
77
78pub trait Validator: Send + Sync + 'static {
80 fn validate(
83 &self,
84 claims: &TokenClaims,
85 path: &str,
86 action: Action,
87 ) -> Result<(), AuthError>;
88}
89
90#[derive(Debug, Clone, Default)]
103pub struct PathValidator {
104 pub raw_prefix_match: bool,
107}
108
109impl PathValidator {
110 pub fn new() -> Self {
112 PathValidator::default()
113 }
114}
115
116impl Validator for PathValidator {
117 fn validate(
118 &self,
119 claims: &TokenClaims,
120 path: &str,
121 action: Action,
122 ) -> Result<(), AuthError> {
123 if claims.is_expired(now_epoch_seconds()) {
124 return Err(AuthError::Expired);
125 }
126 if !claims.has_permission(action.required_permission()) {
127 return Err(AuthError::Forbidden {
128 path: path.to_string(),
129 action,
130 });
131 }
132 let allowed = if self.raw_prefix_match {
133 claims
134 .allowed_paths
135 .iter()
136 .any(|prefix| path.starts_with(prefix.as_str()))
137 } else {
138 path_matches_any(path, &claims.allowed_paths)
139 };
140 if !allowed {
141 return Err(AuthError::Forbidden {
142 path: path.to_string(),
143 action,
144 });
145 }
146 Ok(())
147 }
148}
149
150fn path_matches_any(path: &str, prefixes: &[String]) -> bool {
156 let p = path.trim_start_matches('/');
157 prefixes.iter().any(|prefix| {
158 let q = prefix.trim_matches('/');
159 if q.is_empty() {
160 return true;
162 }
163 if p == q {
164 return true;
165 }
166 p.starts_with(q) && p.as_bytes().get(q.len()) == Some(&b'/')
167 })
168}
169
170fn now_epoch_seconds() -> i64 {
172 std::time::SystemTime::now()
173 .duration_since(std::time::UNIX_EPOCH)
174 .map(|d| d.as_secs() as i64)
175 .unwrap_or(0)
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 fn claims(perms: &[Permission], paths: &[&str], exp: Option<i64>) -> TokenClaims {
183 TokenClaims {
184 sub: "tester".into(),
185 exp,
186 permissions: perms.to_vec(),
187 allowed_paths: paths.iter().map(|s| s.to_string()).collect(),
188 }
189 }
190
191 #[test]
192 fn allows_segment_boundary_paths() {
193 let v = PathValidator::new();
194 let c = claims(&[Permission::Read], &["/docs"], None);
195 for path in ["/docs", "/docs/", "/docs/a.txt", "docs/a.txt"] {
196 assert!(v.validate(&c, path, Action::Read).is_ok(), "{path}");
197 }
198 }
199
200 #[test]
201 fn rejects_sibling_prefix_paths() {
202 let v = PathValidator::new();
203 let c = claims(&[Permission::Read], &["/docs"], None);
204 assert!(matches!(
205 v.validate(&c, "/docshop/x", Action::Read),
206 Err(AuthError::Forbidden { .. })
207 ));
208 }
209
210 #[test]
211 fn rejects_wrong_permission() {
212 let v = PathValidator::new();
213 let c = claims(&[Permission::Read], &["/docs"], None);
214 assert!(matches!(
215 v.validate(&c, "/docs/a", Action::Write),
216 Err(AuthError::Forbidden { .. })
217 ));
218 }
219
220 #[test]
221 fn rejects_expired() {
222 let v = PathValidator::new();
223 let c = claims(&[Permission::Read], &["/docs"], Some(1_000));
224 assert!(matches!(v.validate(&c, "/docs/a", Action::Read), Err(AuthError::Expired)));
225 }
226
227 #[test]
228 fn empty_allowed_paths_denies_all() {
229 let v = PathValidator::new();
230 let c = claims(&[Permission::Read], &[], None);
231 assert!(matches!(
232 v.validate(&c, "/anything", Action::Read),
233 Err(AuthError::Forbidden { .. })
234 ));
235 }
236
237 #[test]
238 fn root_prefix_grants_full_access() {
239 let v = PathValidator::new();
240 for root in ["/", "", "/ "] {
241 let c = claims(&[Permission::Read], &[root.trim()], None);
242 for path in ["/a.txt", "/deep/nested/file.bin", "/"] {
243 assert!(
244 v.validate(&c, path, Action::Read).is_ok(),
245 "root {root:?} should allow {path}"
246 );
247 }
248 }
249 }
250
251 #[test]
252 fn raw_prefix_mode_matches_directly() {
253 let mut v = PathValidator::new();
254 v.raw_prefix_match = true;
255 let c = claims(&[Permission::Read], &["/doc"], None);
256 assert!(v.validate(&c, "/docshop", Action::Read).is_ok());
257 }
258
259 #[test]
260 fn is_expired_boundary() {
261 let c = claims(&[], &[], Some(100));
262 assert!(!c.is_expired(50));
263 assert!(!c.is_expired(99));
264 assert!(c.is_expired(100));
265 assert!(c.is_expired(101));
266 }
267}