From 2f55a5c8ac137046bc54a57dba33f6887d4ab461 Mon Sep 17 00:00:00 2001 From: mo khan Date: Tue, 15 Jul 2025 16:37:08 -0600 Subject: refactor: extract a separate module for cedar --- src/authorization/authorizer.rs | 15 +++ src/authorization/cedar/authorizer.rs | 183 ++++++++++++++++++++++++++++++++++ src/authorization/cedar/entities.rs | 145 +++++++++++++++++++++++++++ src/authorization/cedar/mod.rs | 6 ++ src/authorization/cedar_authorizer.rs | 183 ---------------------------------- src/authorization/entities.rs | 145 --------------------------- src/authorization/mod.rs | 7 +- src/authorization/server.rs | 3 +- 8 files changed, 353 insertions(+), 334 deletions(-) create mode 100644 src/authorization/cedar/authorizer.rs create mode 100644 src/authorization/cedar/entities.rs create mode 100644 src/authorization/cedar/mod.rs delete mode 100644 src/authorization/cedar_authorizer.rs delete mode 100644 src/authorization/entities.rs diff --git a/src/authorization/authorizer.rs b/src/authorization/authorizer.rs index 62733585..d5363c84 100644 --- a/src/authorization/authorizer.rs +++ b/src/authorization/authorizer.rs @@ -3,3 +3,18 @@ use envoy_types::ext_authz::v3::pb::CheckRequest; pub trait Authorizer: std::fmt::Debug + std::marker::Sync + std::marker::Send + 'static { fn authorize(&self, request: CheckRequest) -> bool; } + +#[derive(Debug)] +pub struct DefaultAuthorizer {} + +impl Default for DefaultAuthorizer { + fn default() -> Self { + Self {} + } +} + +impl Authorizer for DefaultAuthorizer { + fn authorize(&self, _request: CheckRequest) -> bool { + true + } +} diff --git a/src/authorization/cedar/authorizer.rs b/src/authorization/cedar/authorizer.rs new file mode 100644 index 00000000..c207467f --- /dev/null +++ b/src/authorization/cedar/authorizer.rs @@ -0,0 +1,183 @@ +use crate::authorization::authorizer::Authorizer; +use std::fs; +use std::str::FromStr; + +#[derive(Debug)] +pub struct CedarAuthorizer { + authorizer: cedar_policy::Authorizer, + entities: cedar_policy::Entities, + policies: cedar_policy::PolicySet, +} + +impl CedarAuthorizer { + pub fn new( + policies: cedar_policy::PolicySet, + entities: cedar_policy::Entities, + ) -> CedarAuthorizer { + CedarAuthorizer { + policies, + entities, + authorizer: cedar_policy::Authorizer::new(), + } + } + + pub fn new_from(path: &std::path::Path, entities: cedar_policy::Entities) -> CedarAuthorizer { + Self::new( + Self::load_from(path).unwrap_or_else(|e| { + tracing::error!( + path = ?path, + error = %e, + "Failed to load Cedar policies, using empty policy set" + ); + cedar_policy::PolicySet::default() + }), + entities, + ) + } + + fn load_from( + path: &std::path::Path, + ) -> Result> { + if !path.exists() { + return Ok(cedar_policy::PolicySet::default()); + } + + if path.is_file() && path.extension().is_some_and(|ext| ext == "cedar") { + let content = fs::read_to_string(path)?; + return Ok(cedar_policy::PolicySet::from_str(&content)?); + } + + if !path.is_dir() { + return Ok(cedar_policy::PolicySet::default()); + } + + let mut policies = cedar_policy::PolicySet::new(); + for entry in fs::read_dir(path)? { + policies.merge(&Self::load_from(&entry?.path())?, true)?; + } + Ok(policies) + } + + fn map_from( + &self, + http_request: envoy_types::pb::envoy::service::auth::v3::attribute_context::HttpRequest, + ) -> Result> { + let principal = self.principal_from(&http_request)?; + let permission = self.permission_from(&http_request)?; + let resource = self.resource_from(&http_request)?; + let context = self.context_from(http_request)?; + + Ok(cedar_policy::Request::new( + principal, permission, resource, context, None, + )?) + } + + fn principal_from( + &self, + http_request: &envoy_types::pb::envoy::service::auth::v3::attribute_context::HttpRequest, + ) -> Result> { + let subject = http_request + .headers + .get("x-jwt-claim-sub") + .map_or("", |v| v); + + Ok(cedar_policy::EntityUid::from_type_name_and_id( + cedar_policy::EntityTypeName::from_str("User")?, + cedar_policy::EntityId::from_str(subject)?, + )) + } + + fn permission_from( + &self, + http_request: &envoy_types::pb::envoy::service::auth::v3::attribute_context::HttpRequest, + ) -> Result> { + Ok(cedar_policy::EntityUid::from_type_name_and_id( + cedar_policy::EntityTypeName::from_str("Action")?, + cedar_policy::EntityId::from_str(&http_request.method)?, + )) + } + + fn resource_from( + &self, + http_request: &envoy_types::pb::envoy::service::auth::v3::attribute_context::HttpRequest, + ) -> Result> { + Ok(cedar_policy::EntityUid::from_type_name_and_id( + cedar_policy::EntityTypeName::from_str("Resource")?, + cedar_policy::EntityId::from_str(&http_request.path)?, + )) + } + + fn context_from( + &self, + http_request: envoy_types::pb::envoy::service::auth::v3::attribute_context::HttpRequest, + ) -> Result> { + let mut items = std::collections::HashMap::new(); + + items.insert("host".to_string(), self.safe_string(&http_request.host)); + items.insert("method".to_string(), self.safe_string(&http_request.method)); + items.insert("path".to_string(), self.safe_string(&http_request.path)); + + Ok(cedar_policy::Context::from_pairs( + items.into_iter().collect::>(), + )?) + } + + fn safe_string(&self, item: &str) -> cedar_policy::RestrictedExpression { + cedar_policy::RestrictedExpression::new_string(item.to_string()) + } +} + +impl Default for CedarAuthorizer { + fn default() -> Self { + Self::new_from( + std::path::Path::new("./etc/authzd"), + cedar_policy::Entities::empty(), + ) + } +} + +impl Authorizer for CedarAuthorizer { + fn authorize(&self, request: envoy_types::ext_authz::v3::pb::CheckRequest) -> bool { + let http_request = match request + .attributes + .as_ref() + .and_then(|attr| attr.request.as_ref()) + .and_then(|req| req.http.as_ref()) + { + Some(http) => http, + None => return false, + }; + + match self.map_from(http_request.clone()) { + Ok(cedar_request) => { + let response = + self.authorizer + .is_authorized(&cedar_request, &self.policies, &self.entities); + + let decision = response.decision(); + + tracing::info!( + decision = ?decision, + diagnostics = ?response.diagnostics(), + principal = %cedar_request.principal().unwrap(), + action = %cedar_request.action().unwrap(), + resource = %cedar_request.resource().unwrap(), + host = %http_request.host, + method = %http_request.method, + path = %http_request.path, + "http" + ); + + matches!(decision, cedar_policy::Decision::Allow) + } + Err(e) => { + tracing::error!( + error = %e, + path = %http_request.path, + "Failed to create Cedar request" + ); + false + } + } + } +} diff --git a/src/authorization/cedar/entities.rs b/src/authorization/cedar/entities.rs new file mode 100644 index 00000000..050f6f26 --- /dev/null +++ b/src/authorization/cedar/entities.rs @@ -0,0 +1,145 @@ +use crate::gitlab::Api; +use serde::Serialize; +use std::collections::HashSet; +use std::future::Future; +use std::pin::Pin; + +type BoxFuture<'a, T> = Pin + 'a>>; + +// Cedar entity structures +// Note: We define custom types instead of using cedar_policy::Entity directly because: +// 1. Cedar's Entity type is for runtime use, not JSON serialization +// 2. These types ensure our JSON output matches Cedar's expected format exactly +// 3. The #[serde(rename)] attributes handle Cedar's specific field naming requirements +#[derive(Debug, Serialize)] +pub struct CedarEntity { + pub uid: CedarUid, + pub attrs: serde_json::Value, + pub parents: Vec, +} + +#[derive(Debug, Serialize)] +pub struct CedarUid { + #[serde(rename = "type")] + pub entity_type: String, + pub id: String, +} + +#[derive(Debug, Serialize)] +pub struct CedarParent { + #[serde(rename = "type")] + pub parent_type: String, + pub id: String, +} + +pub struct EntitiesRepository { + api: Api, +} + +impl EntitiesRepository { + pub fn new(api: Api) -> EntitiesRepository { + EntitiesRepository { api } + } + + pub async fn all( + &self, + project_path: String, + ) -> Result, Box> { + let mut entities = Vec::new(); + let mut groups = HashSet::new(); + + let project = self.api.get_project(&project_path).await?; + + entities.push(CedarEntity { + uid: CedarUid { + entity_type: "Project".to_string(), + id: project.id.to_string(), + }, + attrs: serde_json::json!({ + "name": project.name, + "path": project.path, + "full_path": format!("{}/{}", project.namespace.full_path, project.path), + }), + parents: if project.namespace.kind == "group" { + vec![CedarParent { + parent_type: "Group".to_string(), + id: project.namespace.id.to_string(), + }] + } else { + vec![] + }, + }); + + for member in self.api.get_project_members(project.id).await? { + if member.state == "active" { + entities.push(CedarEntity { + uid: CedarUid { + entity_type: "User".to_string(), + id: member.id.to_string(), + }, + attrs: serde_json::json!({ + "username": member.username, + "access_level": member.access_level, + }), + parents: vec![], + }); + } + } + + if project.namespace.kind == "group" { + self.fetch_hierarchy(project.namespace.id, &mut entities, &mut groups) + .await?; + } + + Ok(entities) + } + + /// Validates that the entities can be parsed by Cedar + pub fn is_valid(entities: &[CedarEntity]) -> Result<(), Box> { + let json = serde_json::to_string(entities)?; + cedar_policy::Entities::from_json_str(&json, None)?; + Ok(()) + } + + fn fetch_hierarchy<'a>( + &'a self, + group_id: u64, + entities: &'a mut Vec, + groups: &'a mut HashSet, + ) -> BoxFuture<'a, Result<(), Box>> { + Box::pin(async move { + if groups.contains(&group_id) { + return Ok(()); + } + + groups.insert(group_id); + + let group = self.api.get_group(group_id).await?; + + let parents = if let Some(parent_id) = group.parent_id { + self.fetch_hierarchy(parent_id, entities, groups).await?; + vec![CedarParent { + parent_type: "Group".to_string(), + id: parent_id.to_string(), + }] + } else { + vec![] + }; + + entities.push(CedarEntity { + uid: CedarUid { + entity_type: "Group".to_string(), + id: group.id.to_string(), + }, + attrs: serde_json::json!({ + "name": group.name, + "path": group.path, + "full_path": group.full_path, + }), + parents, + }); + + Ok(()) + }) + } +} diff --git a/src/authorization/cedar/mod.rs b/src/authorization/cedar/mod.rs new file mode 100644 index 00000000..cf59ee55 --- /dev/null +++ b/src/authorization/cedar/mod.rs @@ -0,0 +1,6 @@ +pub mod authorizer; +pub mod entities; + +pub use authorizer::CedarAuthorizer; +pub use entities::CedarEntity; +pub use entities::EntitiesRepository; diff --git a/src/authorization/cedar_authorizer.rs b/src/authorization/cedar_authorizer.rs deleted file mode 100644 index 0d30ee77..00000000 --- a/src/authorization/cedar_authorizer.rs +++ /dev/null @@ -1,183 +0,0 @@ -use super::authorizer::Authorizer; -use std::fs; -use std::str::FromStr; - -#[derive(Debug)] -pub struct CedarAuthorizer { - authorizer: cedar_policy::Authorizer, - entities: cedar_policy::Entities, - policies: cedar_policy::PolicySet, -} - -impl CedarAuthorizer { - pub fn new( - policies: cedar_policy::PolicySet, - entities: cedar_policy::Entities, - ) -> CedarAuthorizer { - CedarAuthorizer { - policies, - entities, - authorizer: cedar_policy::Authorizer::new(), - } - } - - pub fn new_from(path: &std::path::Path, entities: cedar_policy::Entities) -> CedarAuthorizer { - Self::new( - Self::load_from(path).unwrap_or_else(|e| { - tracing::error!( - path = ?path, - error = %e, - "Failed to load Cedar policies, using empty policy set" - ); - cedar_policy::PolicySet::default() - }), - entities, - ) - } - - fn load_from( - path: &std::path::Path, - ) -> Result> { - if !path.exists() { - return Ok(cedar_policy::PolicySet::default()); - } - - if path.is_file() && path.extension().is_some_and(|ext| ext == "cedar") { - let content = fs::read_to_string(path)?; - return Ok(cedar_policy::PolicySet::from_str(&content)?); - } - - if !path.is_dir() { - return Ok(cedar_policy::PolicySet::default()); - } - - let mut policies = cedar_policy::PolicySet::new(); - for entry in fs::read_dir(path)? { - policies.merge(&Self::load_from(&entry?.path())?, true)?; - } - Ok(policies) - } - - fn map_from( - &self, - http_request: envoy_types::pb::envoy::service::auth::v3::attribute_context::HttpRequest, - ) -> Result> { - let principal = self.principal_from(&http_request)?; - let permission = self.permission_from(&http_request)?; - let resource = self.resource_from(&http_request)?; - let context = self.context_from(http_request)?; - - Ok(cedar_policy::Request::new( - principal, permission, resource, context, None, - )?) - } - - fn principal_from( - &self, - http_request: &envoy_types::pb::envoy::service::auth::v3::attribute_context::HttpRequest, - ) -> Result> { - let subject = http_request - .headers - .get("x-jwt-claim-sub") - .map_or("", |v| v); - - Ok(cedar_policy::EntityUid::from_type_name_and_id( - cedar_policy::EntityTypeName::from_str("User")?, - cedar_policy::EntityId::from_str(subject)?, - )) - } - - fn permission_from( - &self, - http_request: &envoy_types::pb::envoy::service::auth::v3::attribute_context::HttpRequest, - ) -> Result> { - Ok(cedar_policy::EntityUid::from_type_name_and_id( - cedar_policy::EntityTypeName::from_str("Action")?, - cedar_policy::EntityId::from_str(&http_request.method)?, - )) - } - - fn resource_from( - &self, - http_request: &envoy_types::pb::envoy::service::auth::v3::attribute_context::HttpRequest, - ) -> Result> { - Ok(cedar_policy::EntityUid::from_type_name_and_id( - cedar_policy::EntityTypeName::from_str("Resource")?, - cedar_policy::EntityId::from_str(&http_request.path)?, - )) - } - - fn context_from( - &self, - http_request: envoy_types::pb::envoy::service::auth::v3::attribute_context::HttpRequest, - ) -> Result> { - let mut items = std::collections::HashMap::new(); - - items.insert("host".to_string(), self.safe_string(&http_request.host)); - items.insert("method".to_string(), self.safe_string(&http_request.method)); - items.insert("path".to_string(), self.safe_string(&http_request.path)); - - Ok(cedar_policy::Context::from_pairs( - items.into_iter().collect::>(), - )?) - } - - fn safe_string(&self, item: &str) -> cedar_policy::RestrictedExpression { - cedar_policy::RestrictedExpression::new_string(item.to_string()) - } -} - -impl Default for CedarAuthorizer { - fn default() -> Self { - Self::new_from( - std::path::Path::new("./etc/authzd"), - cedar_policy::Entities::empty(), - ) - } -} - -impl Authorizer for CedarAuthorizer { - fn authorize(&self, request: envoy_types::ext_authz::v3::pb::CheckRequest) -> bool { - let http_request = match request - .attributes - .as_ref() - .and_then(|attr| attr.request.as_ref()) - .and_then(|req| req.http.as_ref()) - { - Some(http) => http, - None => return false, - }; - - match self.map_from(http_request.clone()) { - Ok(cedar_request) => { - let response = - self.authorizer - .is_authorized(&cedar_request, &self.policies, &self.entities); - - let decision = response.decision(); - - tracing::info!( - decision = ?decision, - diagnostics = ?response.diagnostics(), - principal = %cedar_request.principal().unwrap(), - action = %cedar_request.action().unwrap(), - resource = %cedar_request.resource().unwrap(), - host = %http_request.host, - method = %http_request.method, - path = %http_request.path, - "http" - ); - - matches!(decision, cedar_policy::Decision::Allow) - } - Err(e) => { - tracing::error!( - error = %e, - path = %http_request.path, - "Failed to create Cedar request" - ); - false - } - } - } -} diff --git a/src/authorization/entities.rs b/src/authorization/entities.rs deleted file mode 100644 index 050f6f26..00000000 --- a/src/authorization/entities.rs +++ /dev/null @@ -1,145 +0,0 @@ -use crate::gitlab::Api; -use serde::Serialize; -use std::collections::HashSet; -use std::future::Future; -use std::pin::Pin; - -type BoxFuture<'a, T> = Pin + 'a>>; - -// Cedar entity structures -// Note: We define custom types instead of using cedar_policy::Entity directly because: -// 1. Cedar's Entity type is for runtime use, not JSON serialization -// 2. These types ensure our JSON output matches Cedar's expected format exactly -// 3. The #[serde(rename)] attributes handle Cedar's specific field naming requirements -#[derive(Debug, Serialize)] -pub struct CedarEntity { - pub uid: CedarUid, - pub attrs: serde_json::Value, - pub parents: Vec, -} - -#[derive(Debug, Serialize)] -pub struct CedarUid { - #[serde(rename = "type")] - pub entity_type: String, - pub id: String, -} - -#[derive(Debug, Serialize)] -pub struct CedarParent { - #[serde(rename = "type")] - pub parent_type: String, - pub id: String, -} - -pub struct EntitiesRepository { - api: Api, -} - -impl EntitiesRepository { - pub fn new(api: Api) -> EntitiesRepository { - EntitiesRepository { api } - } - - pub async fn all( - &self, - project_path: String, - ) -> Result, Box> { - let mut entities = Vec::new(); - let mut groups = HashSet::new(); - - let project = self.api.get_project(&project_path).await?; - - entities.push(CedarEntity { - uid: CedarUid { - entity_type: "Project".to_string(), - id: project.id.to_string(), - }, - attrs: serde_json::json!({ - "name": project.name, - "path": project.path, - "full_path": format!("{}/{}", project.namespace.full_path, project.path), - }), - parents: if project.namespace.kind == "group" { - vec![CedarParent { - parent_type: "Group".to_string(), - id: project.namespace.id.to_string(), - }] - } else { - vec![] - }, - }); - - for member in self.api.get_project_members(project.id).await? { - if member.state == "active" { - entities.push(CedarEntity { - uid: CedarUid { - entity_type: "User".to_string(), - id: member.id.to_string(), - }, - attrs: serde_json::json!({ - "username": member.username, - "access_level": member.access_level, - }), - parents: vec![], - }); - } - } - - if project.namespace.kind == "group" { - self.fetch_hierarchy(project.namespace.id, &mut entities, &mut groups) - .await?; - } - - Ok(entities) - } - - /// Validates that the entities can be parsed by Cedar - pub fn is_valid(entities: &[CedarEntity]) -> Result<(), Box> { - let json = serde_json::to_string(entities)?; - cedar_policy::Entities::from_json_str(&json, None)?; - Ok(()) - } - - fn fetch_hierarchy<'a>( - &'a self, - group_id: u64, - entities: &'a mut Vec, - groups: &'a mut HashSet, - ) -> BoxFuture<'a, Result<(), Box>> { - Box::pin(async move { - if groups.contains(&group_id) { - return Ok(()); - } - - groups.insert(group_id); - - let group = self.api.get_group(group_id).await?; - - let parents = if let Some(parent_id) = group.parent_id { - self.fetch_hierarchy(parent_id, entities, groups).await?; - vec![CedarParent { - parent_type: "Group".to_string(), - id: parent_id.to_string(), - }] - } else { - vec![] - }; - - entities.push(CedarEntity { - uid: CedarUid { - entity_type: "Group".to_string(), - id: group.id.to_string(), - }, - attrs: serde_json::json!({ - "name": group.name, - "path": group.path, - "full_path": group.full_path, - }), - parents, - }); - - Ok(()) - }) - } -} diff --git a/src/authorization/mod.rs b/src/authorization/mod.rs index d687d53f..69d59d29 100644 --- a/src/authorization/mod.rs +++ b/src/authorization/mod.rs @@ -1,11 +1,10 @@ pub mod authorizer; -pub mod cedar_authorizer; +pub mod cedar; pub mod check_service; -pub mod entities; pub mod server; pub use authorizer::Authorizer; -pub use cedar_authorizer::CedarAuthorizer; +pub use cedar::CedarAuthorizer; +pub use cedar::{CedarEntity, EntitiesRepository}; pub use check_service::CheckService; -pub use entities::{CedarEntity, EntitiesRepository}; pub use server::Server; diff --git a/src/authorization/server.rs b/src/authorization/server.rs index 31bf2af8..05e78c1a 100644 --- a/src/authorization/server.rs +++ b/src/authorization/server.rs @@ -1,4 +1,3 @@ -use super::cedar_authorizer::CedarAuthorizer; use super::check_service::CheckService; use envoy_types::ext_authz::v3::pb::AuthorizationServer; use std::sync::Arc; @@ -37,6 +36,6 @@ impl Server { impl Default for Server { fn default() -> Self { - Self::new(CedarAuthorizer::default()).unwrap() + Self::new(super::authorizer::DefaultAuthorizer::default()).unwrap() } } -- cgit v1.2.3