use crate::claims::{Permission, TokenClaims};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Read,
Write,
}
impl Action {
pub fn required_permission(self) -> Permission {
match self {
Action::Read => Permission::Read,
Action::Write => Permission::Write,
}
}
}
impl std::fmt::Display for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Action::Read => write!(f, "read"),
Action::Write => write!(f, "write"),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("missing or malformed bearer token")]
MissingToken,
#[error("invalid token: {0}")]
Invalid(String),
#[error("token expired")]
Expired,
#[error("permission denied: {action} on `{path}`")]
Forbidden {
path: String,
action: Action,
},
}
pub trait TokenVerifier: Send + Sync + 'static {
fn verify(&self, token: &str) -> Result<TokenClaims, AuthError>;
}
pub trait Validator: Send + Sync + 'static {
fn validate(
&self,
claims: &TokenClaims,
path: &str,
action: Action,
) -> Result<(), AuthError>;
}
#[derive(Debug, Clone, Default)]
pub struct PathValidator {
pub raw_prefix_match: bool,
}
impl PathValidator {
pub fn new() -> Self {
PathValidator::default()
}
}
impl Validator for PathValidator {
fn validate(
&self,
claims: &TokenClaims,
path: &str,
action: Action,
) -> Result<(), AuthError> {
if claims.is_expired(now_epoch_seconds()) {
return Err(AuthError::Expired);
}
if !claims.has_permission(action.required_permission()) {
return Err(AuthError::Forbidden {
path: path.to_string(),
action,
});
}
let allowed = if self.raw_prefix_match {
claims
.allowed_paths
.iter()
.any(|prefix| path.starts_with(prefix.as_str()))
} else {
path_matches_any(path, &claims.allowed_paths)
};
if !allowed {
return Err(AuthError::Forbidden {
path: path.to_string(),
action,
});
}
Ok(())
}
}
fn path_matches_any(path: &str, prefixes: &[String]) -> bool {
let p = path.trim_start_matches('/');
prefixes.iter().any(|prefix| {
let q = prefix.trim_matches('/');
if q.is_empty() {
return true;
}
if p == q {
return true;
}
p.starts_with(q) && p.as_bytes().get(q.len()) == Some(&b'/')
})
}
fn now_epoch_seconds() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn claims(perms: &[Permission], paths: &[&str], exp: Option<i64>) -> TokenClaims {
TokenClaims {
sub: "tester".into(),
exp,
permissions: perms.to_vec(),
allowed_paths: paths.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn allows_segment_boundary_paths() {
let v = PathValidator::new();
let c = claims(&[Permission::Read], &["/docs"], None);
for path in ["/docs", "/docs/", "/docs/a.txt", "docs/a.txt"] {
assert!(v.validate(&c, path, Action::Read).is_ok(), "{path}");
}
}
#[test]
fn rejects_sibling_prefix_paths() {
let v = PathValidator::new();
let c = claims(&[Permission::Read], &["/docs"], None);
assert!(matches!(
v.validate(&c, "/docshop/x", Action::Read),
Err(AuthError::Forbidden { .. })
));
}
#[test]
fn rejects_wrong_permission() {
let v = PathValidator::new();
let c = claims(&[Permission::Read], &["/docs"], None);
assert!(matches!(
v.validate(&c, "/docs/a", Action::Write),
Err(AuthError::Forbidden { .. })
));
}
#[test]
fn rejects_expired() {
let v = PathValidator::new();
let c = claims(&[Permission::Read], &["/docs"], Some(1_000));
assert!(matches!(v.validate(&c, "/docs/a", Action::Read), Err(AuthError::Expired)));
}
#[test]
fn empty_allowed_paths_denies_all() {
let v = PathValidator::new();
let c = claims(&[Permission::Read], &[], None);
assert!(matches!(
v.validate(&c, "/anything", Action::Read),
Err(AuthError::Forbidden { .. })
));
}
#[test]
fn root_prefix_grants_full_access() {
let v = PathValidator::new();
for root in ["/", "", "/ "] {
let c = claims(&[Permission::Read], &[root.trim()], None);
for path in ["/a.txt", "/deep/nested/file.bin", "/"] {
assert!(
v.validate(&c, path, Action::Read).is_ok(),
"root {root:?} should allow {path}"
);
}
}
}
#[test]
fn raw_prefix_mode_matches_directly() {
let mut v = PathValidator::new();
v.raw_prefix_match = true;
let c = claims(&[Permission::Read], &["/doc"], None);
assert!(v.validate(&c, "/docshop", Action::Read).is_ok());
}
#[test]
fn is_expired_boundary() {
let c = claims(&[], &[], Some(100));
assert!(!c.is_expired(50));
assert!(!c.is_expired(99));
assert!(c.is_expired(100));
assert!(c.is_expired(101));
}
}