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}")]
51 Invalid(String),
52 #[error("token expired")]
54 Expired,
55 #[error("permission denied: {action} on `{path}`")]
57 Forbidden {
58 path: String,
60 action: Action,
62 },
63}
64
65pub trait TokenVerifier: Send + Sync + 'static {
71 fn verify(&self, token: &str) -> Result<TokenClaims, AuthError>;
77}
78
79pub trait Validator: Send + Sync + 'static {
81 fn validate(
84 &self,
85 claims: &TokenClaims,
86 path: &str,
87 action: Action,
88 ) -> Result<(), AuthError>;
89}
90
91#[derive(Debug, Clone, Default)]
102pub struct PathValidator {
103 pub raw_prefix_match: bool,
106}
107
108impl PathValidator {
109 pub fn new() -> Self {
111 PathValidator::default()
112 }
113}
114
115impl Validator for PathValidator {
116 fn validate(
117 &self,
118 claims: &TokenClaims,
119 path: &str,
120 action: Action,
121 ) -> Result<(), AuthError> {
122 if claims.is_expired(now_epoch_seconds()) {
123 return Err(AuthError::Expired);
124 }
125 if !claims.has_permission(action.required_permission()) {
126 return Err(AuthError::Forbidden {
127 path: path.to_string(),
128 action,
129 });
130 }
131 let allowed = if self.raw_prefix_match {
132 claims
133 .allowed_paths
134 .iter()
135 .any(|prefix| path.starts_with(prefix.as_str()))
136 } else {
137 path_matches_any(path, &claims.allowed_paths)
138 };
139 if !allowed {
140 return Err(AuthError::Forbidden {
141 path: path.to_string(),
142 action,
143 });
144 }
145 Ok(())
146 }
147}
148
149fn path_matches_any(path: &str, prefixes: &[String]) -> bool {
155 let p = path.trim_start_matches('/');
156 prefixes.iter().any(|prefix| {
157 let q = prefix.trim_matches('/');
158 if q.is_empty() {
159 return true;
161 }
162 if p == q {
163 return true;
164 }
165 p.starts_with(q) && p.as_bytes().get(q.len()) == Some(&b'/')
166 })
167}
168
169fn now_epoch_seconds() -> i64 {
171 std::time::SystemTime::now()
172 .duration_since(std::time::UNIX_EPOCH)
173 .map(|d| d.as_secs() as i64)
174 .unwrap_or(0)
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 fn claims(perms: &[Permission], paths: &[&str], exp: Option<i64>) -> TokenClaims {
182 TokenClaims {
183 sub: "tester".into(),
184 exp,
185 permissions: perms.to_vec(),
186 allowed_paths: paths.iter().map(|s| s.to_string()).collect(),
187 }
188 }
189
190 #[test]
191 fn allows_segment_boundary_paths() {
192 let v = PathValidator::new();
193 let c = claims(&[Permission::Read], &["/docs"], None);
194 for path in ["/docs", "/docs/", "/docs/a.txt", "docs/a.txt"] {
195 assert!(v.validate(&c, path, Action::Read).is_ok(), "{path}");
196 }
197 }
198
199 #[test]
200 fn rejects_sibling_prefix_paths() {
201 let v = PathValidator::new();
202 let c = claims(&[Permission::Read], &["/docs"], None);
203 assert!(matches!(
204 v.validate(&c, "/docshop/x", Action::Read),
205 Err(AuthError::Forbidden { .. })
206 ));
207 }
208
209 #[test]
210 fn rejects_wrong_permission() {
211 let v = PathValidator::new();
212 let c = claims(&[Permission::Read], &["/docs"], None);
213 assert!(matches!(
214 v.validate(&c, "/docs/a", Action::Write),
215 Err(AuthError::Forbidden { .. })
216 ));
217 }
218
219 #[test]
220 fn rejects_expired() {
221 let v = PathValidator::new();
222 let c = claims(&[Permission::Read], &["/docs"], Some(1_000));
223 assert!(matches!(v.validate(&c, "/docs/a", Action::Read), Err(AuthError::Expired)));
224 }
225
226 #[test]
227 fn empty_allowed_paths_denies_all() {
228 let v = PathValidator::new();
229 let c = claims(&[Permission::Read], &[], None);
230 assert!(matches!(
231 v.validate(&c, "/anything", Action::Read),
232 Err(AuthError::Forbidden { .. })
233 ));
234 }
235
236 #[test]
237 fn root_prefix_grants_full_access() {
238 let v = PathValidator::new();
239 for root in ["/", "", "/ "] {
240 let c = claims(&[Permission::Read], &[root.trim()], None);
241 for path in ["/a.txt", "/deep/nested/file.bin", "/"] {
242 assert!(
243 v.validate(&c, path, Action::Read).is_ok(),
244 "root {root:?} should allow {path}"
245 );
246 }
247 }
248 }
249
250 #[test]
251 fn raw_prefix_mode_matches_directly() {
252 let mut v = PathValidator::new();
253 v.raw_prefix_match = true;
254 let c = claims(&[Permission::Read], &["/doc"], None);
255 assert!(v.validate(&c, "/docshop", Action::Read).is_ok());
256 }
257
258 #[test]
259 fn is_expired_boundary() {
260 let c = claims(&[], &[], Some(100));
261 assert!(!c.is_expired(50));
262 assert!(!c.is_expired(99));
263 assert!(c.is_expired(100));
264 assert!(c.is_expired(101));
265 }
266}