use async_graphql::Context;
use cedar_policy::{Context as CedarContext, Decision, EntityUid};
use super::context::GraphQLContextExt;
use crate::middleware::cedar::CedarAuthz;
use crate::middleware::token::Claims;
#[derive(Debug, thiserror::Error)]
pub enum CedarResolverError {
#[error("Cedar authorization is not configured for this schema")]
NotConfigured,
#[error("Cedar requires authenticated claims but the request was anonymous")]
Unauthenticated,
#[error("Invalid Cedar entity UID: {0}")]
InvalidUid(String),
#[error("Failed to build Cedar context: {0}")]
Context(String),
#[error("Cedar evaluation failed: {0}")]
Evaluation(String),
#[error("Forbidden: access denied by policy")]
Denied,
}
pub struct CedarResolverCheck<'ctx> {
cedar: &'ctx CedarAuthz,
claims: &'ctx Claims,
action_type: String,
action_id: String,
resource_type: String,
resource_id: String,
context_json: serde_json::Map<String, serde_json::Value>,
}
impl<'ctx> CedarResolverCheck<'ctx> {
pub fn for_context(ctx: &Context<'ctx>) -> Result<Self, CedarResolverError> {
let cedar = ctx
.data_opt::<CedarAuthz>()
.ok_or(CedarResolverError::NotConfigured)?;
let claims = ctx.claims().ok_or(CedarResolverError::Unauthenticated)?;
Ok(Self {
cedar,
claims,
action_type: "Action".to_string(),
action_id: String::new(),
resource_type: "Resource".to_string(),
resource_id: "default".to_string(),
context_json: serde_json::Map::new(),
})
}
pub fn with_action_type(mut self, ty: impl Into<String>) -> Self {
self.action_type = ty.into();
self
}
pub fn with_action(mut self, id: impl Into<String>) -> Self {
self.action_id = id.into();
self
}
pub fn with_resource_type(mut self, ty: impl Into<String>) -> Self {
self.resource_type = ty.into();
self
}
pub fn with_resource_id(mut self, id: impl Into<String>) -> Self {
self.resource_id = id.into();
self
}
pub fn with_context_attr(
mut self,
key: impl Into<String>,
value: impl Into<serde_json::Value>,
) -> Self {
self.context_json.insert(key.into(), value.into());
self
}
pub async fn authorize(self) -> Result<(), CedarResolverError> {
let principal = build_principal(self.claims)?;
let action: EntityUid = format!(r#"{}::"{}""#, self.action_type, self.action_id)
.parse()
.map_err(|e: cedar_policy::ParseErrors| {
CedarResolverError::InvalidUid(e.to_string())
})?;
let resource: EntityUid = format!(r#"{}::"{}""#, self.resource_type, self.resource_id)
.parse()
.map_err(|e: cedar_policy::ParseErrors| {
CedarResolverError::InvalidUid(e.to_string())
})?;
let context = if self.context_json.is_empty() {
CedarContext::empty()
} else {
CedarContext::from_json_value(
serde_json::Value::Object(self.context_json.clone()),
None,
)
.map_err(|e| CedarResolverError::Context(e.to_string()))?
};
let decision = self
.cedar
.authorize(&principal, &action, &resource, context, self.claims)
.await
.map_err(|e| CedarResolverError::Evaluation(e.to_string()))?;
match decision {
Decision::Allow => Ok(()),
Decision::Deny => Err(CedarResolverError::Denied),
}
}
}
fn build_principal(claims: &Claims) -> Result<EntityUid, CedarResolverError> {
let principal_str = if claims.is_user() {
format!(r#"User::"{}""#, claims.sub)
} else if claims.is_client() {
format!(r#"Client::"{}""#, claims.sub)
} else {
format!(r#"Principal::"{}""#, claims.sub)
};
principal_str
.parse()
.map_err(|e: cedar_policy::ParseErrors| CedarResolverError::InvalidUid(e.to_string()))
}