use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use uuid::Uuid;
use crate::errors::AppError;
use crate::repositories::{
AbacPolicy, MembershipRepository, OrgRepository, PolicyEffect, PolicyRepository, UserRepository,
};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PolicyContext {
#[serde(default)]
pub subject: HashMap<String, Value>,
#[serde(default)]
pub resource: HashMap<String, Value>,
#[serde(default)]
pub environment: HashMap<String, Value>,
}
impl PolicyContext {
pub fn new() -> Self {
Self::default()
}
pub fn with_subject(mut self, key: &str, value: Value) -> Self {
self.subject.insert(key.to_string(), value);
self
}
pub fn with_resource(mut self, key: &str, value: Value) -> Self {
self.resource.insert(key.to_string(), value);
self
}
pub fn with_environment(mut self, key: &str, value: Value) -> Self {
self.environment.insert(key.to_string(), value);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyEvaluationResult {
pub allowed: bool,
pub reason: Option<String>,
pub matched_policy_id: Option<Uuid>,
pub matched_policy_name: Option<String>,
pub used_rbac_fallback: bool,
}
impl PolicyEvaluationResult {
pub fn allowed_by_policy(policy: &AbacPolicy) -> Self {
Self {
allowed: true,
reason: Some(format!("Allowed by policy: {}", policy.name)),
matched_policy_id: Some(policy.id),
matched_policy_name: Some(policy.name.clone()),
used_rbac_fallback: false,
}
}
pub fn denied_by_policy(policy: &AbacPolicy) -> Self {
Self {
allowed: false,
reason: Some(format!("Denied by policy: {}", policy.name)),
matched_policy_id: Some(policy.id),
matched_policy_name: Some(policy.name.clone()),
used_rbac_fallback: false,
}
}
pub fn allowed_by_rbac(reason: &str) -> Self {
Self {
allowed: true,
reason: Some(reason.to_string()),
matched_policy_id: None,
matched_policy_name: None,
used_rbac_fallback: true,
}
}
pub fn denied_by_rbac(reason: &str) -> Self {
Self {
allowed: false,
reason: Some(reason.to_string()),
matched_policy_id: None,
matched_policy_name: None,
used_rbac_fallback: true,
}
}
}
pub struct PolicyService {
policy_repo: Arc<dyn PolicyRepository>,
user_repo: Arc<dyn UserRepository>,
org_repo: Arc<dyn OrgRepository>,
membership_repo: Arc<dyn MembershipRepository>,
}
impl PolicyService {
pub fn new(
policy_repo: Arc<dyn PolicyRepository>,
user_repo: Arc<dyn UserRepository>,
org_repo: Arc<dyn OrgRepository>,
membership_repo: Arc<dyn MembershipRepository>,
) -> Self {
Self {
policy_repo,
user_repo,
org_repo,
membership_repo,
}
}
pub async fn evaluate(
&self,
user_id: Uuid,
org_id: Uuid,
permission: &str,
context: Option<PolicyContext>,
) -> Result<PolicyEvaluationResult, AppError> {
let context = self.build_full_context(user_id, org_id, context).await?;
let policies = self
.policy_repo
.find_by_org_and_permission(org_id, permission)
.await?;
for policy in &policies {
if self.evaluate_policy(policy, &context) {
return Ok(match policy.effect {
PolicyEffect::Allow => PolicyEvaluationResult::allowed_by_policy(policy),
PolicyEffect::Deny => PolicyEvaluationResult::denied_by_policy(policy),
});
}
}
self.evaluate_rbac_fallback(user_id, org_id, permission)
.await
}
async fn build_full_context(
&self,
user_id: Uuid,
org_id: Uuid,
provided_context: Option<PolicyContext>,
) -> Result<PolicyContext, AppError> {
let mut context = provided_context.unwrap_or_default();
context
.subject
.insert("user_id".to_string(), Value::String(user_id.to_string()));
let (user_result, membership_result) = tokio::join!(
self.user_repo.find_by_id(user_id),
self.membership_repo.find_by_user_and_org(user_id, org_id)
);
if let Some(user) = user_result? {
context.subject.insert(
"is_system_admin".to_string(),
Value::Bool(user.is_system_admin),
);
if let Some(email) = &user.email {
context
.subject
.insert("email".to_string(), Value::String(email.clone()));
}
}
if let Some(membership) = membership_result? {
context.subject.insert(
"role".to_string(),
Value::String(membership.role.as_str().to_string()),
);
}
if !context.resource.contains_key("org_id") {
context
.resource
.insert("org_id".to_string(), Value::String(org_id.to_string()));
}
Ok(context)
}
fn evaluate_policy(&self, policy: &AbacPolicy, context: &PolicyContext) -> bool {
for (key, matcher) in &policy.conditions.subject {
let value = context.subject.get(key);
if !self.match_with_interpolation(matcher, value, context) {
return false;
}
}
for (key, matcher) in &policy.conditions.resource {
let value = context.resource.get(key);
if !self.match_with_interpolation(matcher, value, context) {
return false;
}
}
for (key, matcher) in &policy.conditions.environment {
let value = context.environment.get(key);
if !self.match_with_interpolation(matcher, value, context) {
return false;
}
}
true
}
fn match_with_interpolation(
&self,
matcher: &crate::repositories::AttributeMatcher,
value: Option<&Value>,
context: &PolicyContext,
) -> bool {
use crate::repositories::AttributeMatcher;
match matcher {
AttributeMatcher::Equals(expected) => {
let resolved = self.resolve_value(expected, context);
value.map(|v| *v == resolved).unwrap_or(false)
}
_ => matcher.matches(value),
}
}
fn resolve_value(&self, value: &Value, context: &PolicyContext) -> Value {
if let Some(s) = value.as_str() {
if s.starts_with("${") && s.ends_with('}') {
let path = &s[2..s.len() - 1];
if let Some(resolved) = self.get_context_value(path, context) {
return resolved.clone();
}
}
}
value.clone()
}
fn get_context_value<'a>(&self, path: &str, context: &'a PolicyContext) -> Option<&'a Value> {
let parts: Vec<&str> = path.split('.').collect();
if parts.len() != 2 {
return None;
}
let (section, key) = (parts[0], parts[1]);
match section {
"subject" => context.subject.get(key),
"resource" => context.resource.get(key),
"environment" => context.environment.get(key),
_ => None,
}
}
async fn evaluate_rbac_fallback(
&self,
user_id: Uuid,
org_id: Uuid,
permission: &str,
) -> Result<PolicyEvaluationResult, AppError> {
use crate::services::Permission;
if let Some(user) = self.user_repo.find_by_id(user_id).await? {
if user.is_system_admin {
return Ok(PolicyEvaluationResult::allowed_by_rbac(
"System admin has full access",
));
}
}
if self.org_repo.find_by_id(org_id).await?.is_none() {
return Ok(PolicyEvaluationResult::denied_by_rbac(
"Organization not found",
));
}
let membership = self
.membership_repo
.find_by_user_and_org(user_id, org_id)
.await?;
match membership {
Some(m) => {
if let Some(perm) = Permission::from_str(permission) {
if perm.is_allowed_for(m.role) {
Ok(PolicyEvaluationResult::allowed_by_rbac(&format!(
"Role '{}' has '{}' permission",
m.role.as_str(),
permission
)))
} else {
Ok(PolicyEvaluationResult::denied_by_rbac(&format!(
"Role '{}' does not have '{}' permission",
m.role.as_str(),
permission
)))
}
} else {
Ok(PolicyEvaluationResult::denied_by_rbac(&format!(
"Unknown permission: {}",
permission
)))
}
}
None => Ok(PolicyEvaluationResult::denied_by_rbac(
"Not a member of this organization",
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repositories::{AttributeMatcher, PolicyConditions};
#[test]
fn test_policy_context_builder() {
let context = PolicyContext::new()
.with_subject("user_id", Value::String("123".to_string()))
.with_subject("role", Value::String("admin".to_string()))
.with_resource("type", Value::String("project".to_string()))
.with_environment("time", Value::String("2024-01-01T12:00:00Z".to_string()));
assert_eq!(
context.subject.get("user_id"),
Some(&Value::String("123".to_string()))
);
assert_eq!(
context.resource.get("type"),
Some(&Value::String("project".to_string()))
);
}
#[test]
fn test_policy_evaluation_result() {
let org_id = Uuid::new_v4();
let policy = AbacPolicy::new(org_id, "Test", "project:delete", PolicyEffect::Allow);
let allowed = PolicyEvaluationResult::allowed_by_policy(&policy);
assert!(allowed.allowed);
assert_eq!(allowed.matched_policy_name, Some("Test".to_string()));
let denied = PolicyEvaluationResult::denied_by_policy(&policy);
assert!(!denied.allowed);
}
#[test]
fn test_attribute_matcher_in_policy() {
let matcher = AttributeMatcher::In(vec![
Value::String("admin".to_string()),
Value::String("owner".to_string()),
]);
assert!(matcher.matches(Some(&Value::String("admin".to_string()))));
assert!(!matcher.matches(Some(&Value::String("member".to_string()))));
}
#[test]
fn test_policy_conditions_builder() {
let conditions = PolicyConditions::new()
.with_subject(
"role",
AttributeMatcher::Equals(Value::String("admin".to_string())),
)
.with_resource(
"owner_id",
AttributeMatcher::Equals(Value::String("${subject.user_id}".to_string())),
);
assert!(conditions.subject.contains_key("role"));
assert!(conditions.resource.contains_key("owner_id"));
}
}