1use axum::extract::FromRequestParts;
4use axum::http::request::Parts;
5use serde::{Deserialize, Serialize};
6
7use super::{AuthError, AuthenticatedCaller};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, utoipa::ToSchema)]
14#[serde(rename_all = "lowercase")]
15pub enum Scope {
16 Read,
18 Write,
20 Admin,
25}
26
27impl Scope {
28 pub fn is_satisfied_by(self, granted: &[Scope]) -> bool {
33 granted.iter().any(|s| *s >= self)
34 }
35}
36
37impl std::fmt::Display for Scope {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 match self {
40 Scope::Read => write!(f, "read"),
41 Scope::Write => write!(f, "write"),
42 Scope::Admin => write!(f, "admin"),
43 }
44 }
45}
46
47pub struct RequireScope(pub AuthenticatedCaller);
58
59impl RequireScope {
60 fn check(caller: &AuthenticatedCaller, required: Scope) -> Result<(), AuthError> {
62 if required.is_satisfied_by(&caller.scopes) {
63 Ok(())
64 } else {
65 Err(AuthError::InsufficientScope { required })
66 }
67 }
68}
69
70pub struct RequireRead(pub AuthenticatedCaller);
72
73impl<S> FromRequestParts<S> for RequireRead
74where
75 S: Send + Sync,
76{
77 type Rejection = AuthError;
78
79 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
80 let caller = AuthenticatedCaller::from_request_parts(parts, state).await?;
81 RequireScope::check(&caller, Scope::Read)?;
82 Ok(Self(caller))
83 }
84}
85
86pub struct RequireWrite(pub AuthenticatedCaller);
88
89impl<S> FromRequestParts<S> for RequireWrite
90where
91 S: Send + Sync,
92{
93 type Rejection = AuthError;
94
95 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
96 let caller = AuthenticatedCaller::from_request_parts(parts, state).await?;
97 RequireScope::check(&caller, Scope::Write)?;
98 Ok(Self(caller))
99 }
100}
101
102pub struct RequireAdmin(pub AuthenticatedCaller);
104
105impl<S> FromRequestParts<S> for RequireAdmin
106where
107 S: Send + Sync,
108{
109 type Rejection = AuthError;
110
111 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
112 let caller = AuthenticatedCaller::from_request_parts(parts, state).await?;
113 RequireScope::check(&caller, Scope::Admin)?;
114 Ok(Self(caller))
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn test_scope_ordering() {
124 assert!(Scope::Admin > Scope::Write);
125 assert!(Scope::Write > Scope::Read);
126 assert!(Scope::Admin > Scope::Read);
127 }
128
129 #[test]
130 fn test_scope_contains_same_level() {
131 assert!(Scope::Write.is_satisfied_by(&[Scope::Write]));
132 assert!(Scope::Read.is_satisfied_by(&[Scope::Read]));
133 assert!(Scope::Admin.is_satisfied_by(&[Scope::Admin]));
134 }
135
136 #[test]
137 fn test_scope_contains_higher_level() {
138 assert!(Scope::Write.is_satisfied_by(&[Scope::Admin]));
139 assert!(Scope::Read.is_satisfied_by(&[Scope::Admin]));
140 assert!(Scope::Read.is_satisfied_by(&[Scope::Write]));
141 }
142
143 #[test]
144 fn test_scope_rejects_lower_level() {
145 assert!(!Scope::Write.is_satisfied_by(&[Scope::Read]));
146 assert!(!Scope::Admin.is_satisfied_by(&[Scope::Write]));
147 assert!(!Scope::Admin.is_satisfied_by(&[Scope::Read]));
148 }
149
150 #[test]
151 fn test_scope_empty_grants_rejects_all() {
152 assert!(!Scope::Read.is_satisfied_by(&[]));
153 assert!(!Scope::Write.is_satisfied_by(&[]));
154 assert!(!Scope::Admin.is_satisfied_by(&[]));
155 }
156
157 #[test]
158 fn test_scope_check_with_caller() {
159 let caller = AuthenticatedCaller {
160 key_id: "test".to_string(),
161 scopes: vec![Scope::Read, Scope::Write],
162 tenant: crate::Tenant::default(),
163 };
164 assert!(RequireScope::check(&caller, Scope::Read).is_ok());
165 assert!(RequireScope::check(&caller, Scope::Write).is_ok());
166 assert!(RequireScope::check(&caller, Scope::Admin).is_err());
167 }
168
169 #[test]
170 fn scope_display_renders_each_level() {
171 assert_eq!(Scope::Read.to_string(), "read");
172 assert_eq!(Scope::Write.to_string(), "write");
173 assert_eq!(Scope::Admin.to_string(), "admin");
174 }
175}