use std::future::Future;
use rusty_gasket::auth::error::AuthError;
use rusty_gasket::auth::identity::Identity;
pub trait AuthzPolicy: Send + Sync + 'static {
fn authorize<'ctx>(
&'ctx self,
identity: Option<&'ctx Identity>,
resource: &'ctx str,
action: &'ctx str,
ctx: &'ctx AuthzContext,
) -> impl Future<Output = Result<AuthzDecision, AuthError>> + Send + 'ctx;
}
#[derive(Debug)]
#[non_exhaustive]
pub struct AuthzContext {
pub request_method: http::Method,
pub request_path: String,
}
impl AuthzContext {
#[must_use]
pub fn new(request_method: http::Method, request_path: impl Into<String>) -> Self {
Self {
request_method,
request_path: request_path.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum AuthzDecision {
Allow,
Deny {
reason: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ScopeMatchMode {
All,
Any,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ScopePolicy {
pub required_scopes: Vec<String>,
pub match_mode: ScopeMatchMode,
}
impl ScopePolicy {
#[must_use]
pub fn require_all<I, S>(scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
required_scopes: scopes.into_iter().map(Into::into).collect(),
match_mode: ScopeMatchMode::All,
}
}
#[must_use]
pub fn require_any<I, S>(scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
required_scopes: scopes.into_iter().map(Into::into).collect(),
match_mode: ScopeMatchMode::Any,
}
}
}
impl AuthzPolicy for ScopePolicy {
async fn authorize(
&self,
identity: Option<&Identity>,
_resource: &str,
_action: &str,
_ctx: &AuthzContext,
) -> Result<AuthzDecision, AuthError> {
let identity = match identity {
Some(id) => id,
None => {
return Ok(AuthzDecision::Deny {
reason: "Authentication required".to_string(),
});
}
};
let satisfied = match self.match_mode {
ScopeMatchMode::All => self
.required_scopes
.iter()
.all(|s| identity.scopes().contains(s)),
ScopeMatchMode::Any => self
.required_scopes
.iter()
.any(|s| identity.scopes().contains(s)),
};
if satisfied {
Ok(AuthzDecision::Allow)
} else {
let missing: Vec<&str> = self
.required_scopes
.iter()
.filter(|s| !identity.scopes().contains(s.as_str()))
.map(String::as_str)
.collect();
Ok(AuthzDecision::Deny {
reason: format!("Missing required scopes: {}", missing.join(", ")),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_identity(scopes: &[&str]) -> Identity {
Identity::builder("test-user", "test")
.scopes(scopes.iter().map(|s| (*s).to_string()))
.build()
}
fn make_ctx() -> AuthzContext {
AuthzContext::new(http::Method::GET, "/test")
}
#[tokio::test]
async fn scope_policy_all_satisfied() {
let policy = ScopePolicy::require_all(vec!["read".to_string(), "write".to_string()]);
let id = make_identity(&["read", "write", "admin"]);
let ctx = make_ctx();
let result = policy
.authorize(Some(&id), "resource", "action", &ctx)
.await
.expect("should not error");
assert_eq!(result, AuthzDecision::Allow);
}
#[tokio::test]
async fn scope_policy_all_missing_one() {
let policy = ScopePolicy::require_all(vec!["read".to_string(), "write".to_string()]);
let id = make_identity(&["read"]);
let ctx = make_ctx();
let result = policy
.authorize(Some(&id), "resource", "action", &ctx)
.await
.expect("policy check should not fail");
assert!(
matches!(result, AuthzDecision::Deny { .. }),
"missing scope should deny, got: {result:?}"
);
}
#[tokio::test]
async fn scope_policy_any_satisfied() {
let policy = ScopePolicy::require_any(vec!["admin".to_string(), "write".to_string()]);
let id = make_identity(&["write"]);
let ctx = make_ctx();
let result = policy
.authorize(Some(&id), "resource", "action", &ctx)
.await
.expect("should not error");
assert_eq!(result, AuthzDecision::Allow);
}
#[tokio::test]
async fn scope_policy_any_none_present() {
let policy = ScopePolicy::require_any(vec!["admin".to_string(), "write".to_string()]);
let id = make_identity(&["read"]);
let ctx = make_ctx();
let result = policy
.authorize(Some(&id), "resource", "action", &ctx)
.await
.expect("policy check should not fail");
assert!(
matches!(result, AuthzDecision::Deny { .. }),
"missing scope should deny, got: {result:?}"
);
}
#[tokio::test]
async fn scope_policy_no_identity() {
let policy = ScopePolicy::require_all(vec!["read".to_string()]);
let ctx = make_ctx();
let result = policy
.authorize(None, "resource", "action", &ctx)
.await
.expect("should return deny, not error");
assert_eq!(
result,
AuthzDecision::Deny {
reason: "Authentication required".to_string()
}
);
}
}