diff --git a/libs/@local/graph/authorization/schemas/policies.cedarschema b/libs/@local/graph/authorization/schemas/policies.cedarschema index cd585427046..326f0b5f47f 100644 --- a/libs/@local/graph/authorization/schemas/policies.cedarschema +++ b/libs/@local/graph/authorization/schemas/policies.cedarschema @@ -15,16 +15,16 @@ namespace HASH { entity EntityType in [Web] { }; - entity User in [HASH::Web::Role, HASH::Team::Role, HASH::Web::Team::Role] { + entity User, Machine in [HASH::Web::Role, HASH::Team::Role, HASH::Web::Team::Role] { }; action create, view, update appliesTo { - principal: User, + principal: [User, Machine], resource: [Entity, EntityType], }; action instantiate appliesTo { - principal: User, + principal: [User, Machine], resource: [EntityType], }; } diff --git a/libs/@local/graph/authorization/src/policies/context.rs b/libs/@local/graph/authorization/src/policies/context.rs new file mode 100644 index 00000000000..fddd836f8a0 --- /dev/null +++ b/libs/@local/graph/authorization/src/policies/context.rs @@ -0,0 +1,64 @@ +use cedar_policy_core::{ + ast, + entities::{Entities, TCComputation}, + extensions::Extensions, +}; +use error_stack::{Report, ResultExt as _}; + +use super::{Validator, principal::Actor, resource::Resource}; + +#[derive(Debug, derive_more::Display, derive_more::Error)] +pub enum ContextError { + #[display("transitive closure computation failed")] + TransitiveClosureError, +} + +#[derive(Debug, Default)] +pub struct Context { + entities: Entities, +} + +impl Context { + #[must_use] + pub(crate) const fn entities(&self) -> &Entities { + &self.entities + } +} + +#[derive(Debug, Default)] +pub struct ContextBuilder { + entities: Vec, +} + +impl ContextBuilder { + #[must_use] + pub fn with_actor(mut self, actor: &Actor) -> Self { + self.entities.push(actor.to_cedar_entity()); + self + } + + #[must_use] + pub fn with_resource(mut self, resource: &Resource) -> Self { + self.entities.push(resource.to_cedar_entity()); + self + } + + /// Builds the context. + /// + /// It will compute the transitive closure of the entities in the context. + /// + /// # Errors + /// + /// - [`ContextError::TransitiveClosureError`] if the transitive closure computation fails. + pub fn build(self) -> Result> { + Ok(Context { + entities: Entities::from_entities( + self.entities, + Some(&Validator::core_schema()), + TCComputation::ComputeNow, + Extensions::none(), + ) + .change_context(ContextError::TransitiveClosureError)?, + }) + } +} diff --git a/libs/@local/graph/authorization/src/policies/mod.rs b/libs/@local/graph/authorization/src/policies/mod.rs index 2a585825202..54b36d87396 100644 --- a/libs/@local/graph/authorization/src/policies/mod.rs +++ b/libs/@local/graph/authorization/src/policies/mod.rs @@ -4,28 +4,27 @@ pub mod principal; pub mod resource; mod cedar; +mod context; +mod set; +mod validation; use alloc::{collections::BTreeMap, sync::Arc}; -use core::{error::Error, fmt, str::FromStr as _}; -use std::{collections::HashMap, sync::LazyLock}; +use core::{fmt, str::FromStr as _}; use cedar::CedarEntityId as _; -use cedar_policy_core::{ - ast, - entities::{Entities, TCComputation}, - evaluator::Evaluator, - extensions::Extensions, - parser::parse_policy_or_template_to_est_and_ast, -}; -use cedar_policy_validator::{CoreSchema, ValidatorSchema}; +use cedar_policy_core::{ast, extensions::Extensions, parser::parse_policy}; use error_stack::{Report, ResultExt as _}; use uuid::Uuid; pub(crate) use self::cedar::cedar_resource_type; use self::{ action::{ActionConstraint, ActionId}, - principal::{PrincipalConstraint, user::User}, - resource::{Resource, ResourceConstraint}, + principal::{ActorId, PrincipalConstraint}, + resource::{ResourceConstraint, ResourceId}, +}; +pub use self::{ + context::{Context, ContextBuilder, ContextError}, + validation::{PolicyValidationError, Validator}, }; #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] @@ -83,6 +82,7 @@ pub struct Policy { } #[non_exhaustive] +#[derive(Debug)] pub struct RequestContext; impl RequestContext { @@ -95,23 +95,25 @@ impl RequestContext { } } +#[derive(Debug)] pub struct Request<'a> { - user: &'a User, + actor: ActorId, action: ActionId, - resource: &'a Resource<'a>, + resource: &'a ResourceId<'a>, context: RequestContext, } impl Request<'_> { - pub(crate) fn to_cedar(&self) -> Result> { - Ok(ast::Request::new( - (self.user.id.to_euid(), None), + pub(crate) fn to_cedar(&self) -> ast::Request { + ast::Request::new( + (self.actor.to_euid(), None), (self.action.to_euid(), None), (self.resource.to_euid(), None), self.context.to_cedar(), - Some(&*POLICY_SCHEMA), + Some(Validator::schema()), Extensions::none(), - )?) + ) + .expect("Request should be a valid Cedar request") } } @@ -129,28 +131,9 @@ pub enum InvalidPolicy { InvalidSyntax, } -static POLICY_SCHEMA: LazyLock = LazyLock::new(|| { - let (schema, warnings) = ValidatorSchema::from_cedarschema_str( - include_str!("../../schemas/policies.cedarschema"), - Extensions::none(), - ) - .unwrap_or_else(|error| { - panic!("Policy schema is invalid: {error}"); - }); - - for warning in warnings { - tracing::warn!("policy schema warning: {warning}"); - #[cfg(test)] - { - eprintln!("policy schema warning: {warning}"); - } - } - schema -}); - impl Policy { - pub(crate) fn try_from_cedar_template( - policy: &ast::Template, + pub(crate) fn try_from_cedar( + policy: &ast::StaticPolicy, ) -> Result> { Ok(Self { id: PolicyId::new( @@ -170,7 +153,7 @@ impl Policy { }) } - pub(crate) fn to_cedar(&self) -> ast::Template { + pub(crate) fn to_cedar_template(&self) -> ast::Template { let (resource_constraint, resource_expr) = self.resource.to_cedar(); ast::Template::new( ast::PolicyID::from_string(self.id.to_string()), @@ -187,6 +170,12 @@ impl Policy { ) } + pub(crate) fn to_cedar_static_policy( + &self, + ) -> Result> { + Ok(self.to_cedar_template().try_into()?) + } + /// Parses a policy from a string. /// /// If `policy_id` is not provided, a new [`PolicyId`] will be generated. @@ -199,41 +188,17 @@ impl Policy { text: &str, policy_id: Option, ) -> Result> { - let (_, template) = parse_policy_or_template_to_est_and_ast( - Some(ast::PolicyID::from_string( - policy_id - .unwrap_or_else(|| PolicyId::new(Uuid::new_v4())) - .to_string(), - )), - text, + Self::try_from_cedar( + &parse_policy( + Some(ast::PolicyID::from_string( + policy_id + .unwrap_or_else(|| PolicyId::new(Uuid::new_v4())) + .to_string(), + )), + text, + ) + .change_context(InvalidPolicy::InvalidSyntax)?, ) - .change_context(InvalidPolicy::InvalidSyntax)?; - Self::try_from_cedar_template(&template) - } - - /// Evaluates the policy for the given request. - /// - /// # Errors - /// - /// - [`Error`] if the policy is invalid. - // TODO: Use `Report` instead of `Box` - pub fn evaluate(&self, request: &Request) -> Result> { - let cedar_policy = Arc::new(self.to_cedar()); - let policy_id = cedar_policy.id().clone(); - let policy = ast::Template::link(cedar_policy, policy_id, HashMap::new())?; - - let entities = Entities::new().add_entities( - [ - Arc::new(request.user.to_entity()?), - Arc::new(request.resource.to_entity()?), - ], - Some(&CoreSchema::new(&POLICY_SCHEMA)), - TCComputation::ComputeNow, - Extensions::none(), - )?; - - let evaluator = Evaluator::new(request.to_cedar()?, &entities, Extensions::none()); - Ok(evaluator.evaluate(&policy)?) } } @@ -242,15 +207,16 @@ impl Policy { mod tests { use core::error::Error; - use cedar_policy_core::ast; - use cedar_policy_validator::{ValidationMode, Validator}; use indoc::formatdoc; use pretty_assertions::assert_eq; use serde_json::{Value as JsonValue, json}; use uuid::Uuid; use super::Policy; - use crate::{policies::POLICY_SCHEMA, test_utils::check_serialization}; + use crate::{ + policies::{Validator, set::PolicySet}, + test_utils::check_serialization, + }; #[track_caller] pub(crate) fn check_policy( @@ -260,33 +226,20 @@ mod tests { ) -> Result<(), Box> { check_serialization(policy, value); - let cedar_policy = policy.to_cedar(); + let cedar_policy = policy.to_cedar_template(); assert_eq!(cedar_policy.to_string(), cedar_string.as_ref()); - if !policy.principal.has_slot() && !policy.resource.has_slot() { - Policy::try_from_cedar_template(&cedar_policy)?; - } - let mut policy_set = ast::PolicySet::new(); - policy_set - .add_template(cedar_policy) - .expect("Should be able to add a policy to an empty policy set"); - let result = - Validator::new((*POLICY_SCHEMA).clone()).validate(&policy_set, ValidationMode::Strict); - if !result.validation_passed() { - let messages = result - .validation_errors() - .map(|error| format!(" - error: {error}")) - .chain( - result - .validation_warnings() - .map(|warning| format!(" - warning: {warning}")), - ) - .collect::>() - .join("\n"); - panic!("Policy is invalid:\n{messages}"); + let mut policy_set = PolicySet::default(); + if policy.principal.has_slot() || policy.resource.has_slot() { + policy_set.add_template(policy)?; + } else { + let static_policy = policy.to_cedar_static_policy()?; + policy_set.add_policy(&Policy::try_from_cedar(&static_policy)?)?; } + Validator.validate_policy_set(&policy_set)?; + Ok(()) } @@ -299,9 +252,12 @@ mod tests { use super::*; use crate::policies::{ - ActionConstraint, ActionId, Effect, PolicyId, PrincipalConstraint, Request, - RequestContext, ResourceConstraint, - principal::user::{User, UserId, UserPrincipalConstraint}, + ActionConstraint, ActionId, ContextBuilder, Effect, PolicyId, PrincipalConstraint, + Request, RequestContext, ResourceConstraint, + principal::{ + Actor, + user::{User, UserId, UserPrincipalConstraint}, + }, resource::{EntityResource, EntityResourceConstraint, Resource}, }; @@ -356,10 +312,11 @@ mod tests { ), )?; - let actor = User { + let actor = Actor::User(User { id: user_id, roles: Vec::new(), - }; + }); + let actor_id = actor.id(); let entity = Resource::Entity(EntityResource { web_id: OwnedById::new(Uuid::new_v4()), @@ -369,20 +326,35 @@ mod tests { .expect("Invalid entity type URL"), ]), }); - - assert!(policy.evaluate(&Request { - user: &actor, - action: ActionId::View, - resource: &entity, - context: RequestContext, - })?); - - assert!(!policy.evaluate(&Request { - user: &actor, - action: ActionId::Update, - resource: &entity, - context: RequestContext, - })?); + let resource_id = entity.id(); + + let context = ContextBuilder::default() + .with_actor(&actor) + .with_resource(&entity) + .build()?; + + let mut policy_set = PolicySet::default(); + policy_set.add_policy(&policy)?; + + assert!(policy_set.evaluate( + &Request { + actor: actor_id, + action: ActionId::View, + resource: &resource_id, + context: RequestContext, + }, + &context + )?); + + assert!(!policy_set.evaluate( + &Request { + actor: actor_id, + action: ActionId::Update, + resource: &resource_id, + context: RequestContext, + }, + &context + )?); Ok(()) } diff --git a/libs/@local/graph/authorization/src/policies/principal/actor.rs b/libs/@local/graph/authorization/src/policies/principal/actor.rs new file mode 100644 index 00000000000..74526884101 --- /dev/null +++ b/libs/@local/graph/authorization/src/policies/principal/actor.rs @@ -0,0 +1,46 @@ +use cedar_policy_core::ast; + +use super::{ + machine::{Machine, MachineId}, + user::{User, UserId}, +}; +use crate::policies::cedar::CedarEntityId as _; + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum ActorId { + User(UserId), + Machine(MachineId), +} + +impl ActorId { + pub(crate) fn to_euid(self) -> ast::EntityUID { + match self { + Self::User(id) => id.to_euid(), + Self::Machine(id) => id.to_euid(), + } + } +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)] +pub enum Actor { + User(User), + Machine(Machine), +} + +impl Actor { + #[must_use] + pub const fn id(&self) -> ActorId { + match self { + Self::User(user) => ActorId::User(user.id), + Self::Machine(machine) => ActorId::Machine(machine.id), + } + } + + pub(crate) fn to_cedar_entity(&self) -> ast::Entity { + match self { + Self::User(user) => user.to_entity(), + Self::Machine(machine) => machine.to_entity(), + } + } +} diff --git a/libs/@local/graph/authorization/src/policies/principal/machine.rs b/libs/@local/graph/authorization/src/policies/principal/machine.rs new file mode 100644 index 00000000000..3ffcb23adb7 --- /dev/null +++ b/libs/@local/graph/authorization/src/policies/principal/machine.rs @@ -0,0 +1,291 @@ +#![expect( + clippy::empty_enum, + reason = "serde::Deseiriealize does not use the never-type" +)] + +use alloc::sync::Arc; +use core::{error::Error, fmt, iter, str::FromStr as _}; +use std::sync::LazyLock; + +use cedar_policy_core::{ast, extensions::Extensions}; +use error_stack::Report; +use uuid::Uuid; + +use super::{InPrincipalConstraint, TeamPrincipalConstraint, role::RoleId}; +use crate::policies::{cedar::CedarEntityId, principal::web::WebPrincipalConstraint}; + +#[derive( + Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] +#[cfg_attr( + feature = "postgres", + derive(postgres_types::FromSql, postgres_types::ToSql), + postgres(transparent) +)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[repr(transparent)] +pub struct MachineId(Uuid); + +impl MachineId { + #[must_use] + pub const fn new(uuid: Uuid) -> Self { + Self(uuid) + } + + #[must_use] + pub const fn into_uuid(self) -> Uuid { + self.0 + } + + #[must_use] + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl fmt::Display for MachineId { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, fmt) + } +} + +impl CedarEntityId for MachineId { + fn entity_type() -> &'static Arc { + static ENTITY_TYPE: LazyLock> = + LazyLock::new(|| crate::policies::cedar_resource_type(["Machine"])); + &ENTITY_TYPE + } + + fn to_eid(&self) -> ast::Eid { + ast::Eid::new(self.0.to_string()) + } + + fn from_eid(eid: &ast::Eid) -> Result> { + Ok(Self::new(Uuid::from_str(eid.as_ref())?)) + } +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Machine { + pub id: MachineId, + pub roles: Vec, +} + +impl Machine { + pub(crate) fn to_entity(&self) -> ast::Entity { + ast::Entity::new( + self.id.to_euid(), + iter::empty(), + self.roles.iter().map(RoleId::to_euid).collect(), + iter::empty(), + Extensions::none(), + ) + .expect("Machine should be a valid Cedar entity") + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(untagged, rename_all_fields = "camelCase", deny_unknown_fields)] +pub enum MachinePrincipalConstraint { + #[expect( + clippy::empty_enum_variants_with_brackets, + reason = "Serialization is different" + )] + Any {}, + Exact { + #[serde(deserialize_with = "Option::deserialize")] + machine_id: Option, + }, + Web(WebPrincipalConstraint), + Team(TeamPrincipalConstraint), +} + +impl MachinePrincipalConstraint { + #[must_use] + pub const fn has_slot(&self) -> bool { + match self { + Self::Any {} + | Self::Exact { + machine_id: Some(_), + } => false, + Self::Exact { machine_id: None } => true, + Self::Web(web) => web.has_slot(), + Self::Team(team) => team.has_slot(), + } + } + + #[must_use] + pub(crate) fn to_cedar(&self) -> ast::PrincipalConstraint { + match self { + Self::Any {} => { + ast::PrincipalConstraint::is_entity_type(Arc::clone(MachineId::entity_type())) + } + Self::Exact { machine_id } => machine_id + .map_or_else(ast::PrincipalConstraint::is_eq_slot, |machine_id| { + ast::PrincipalConstraint::is_eq(Arc::new(machine_id.to_euid())) + }), + Self::Web(web) => web.to_cedar_in_type::(), + Self::Team(team) => team.to_cedar_in_type::(), + } + } +} + +impl From for MachinePrincipalConstraint { + fn from(value: InPrincipalConstraint) -> Self { + match value { + InPrincipalConstraint::Web(web) => Self::Web(web), + InPrincipalConstraint::Team(team) => Self::Team(team), + } + } +} + +#[cfg(test)] +mod tests { + use core::error::Error; + + use hash_graph_types::owned_by_id::OwnedById; + use serde_json::json; + use uuid::Uuid; + + use super::{MachineId, WebPrincipalConstraint}; + use crate::{ + policies::{ + PrincipalConstraint, + principal::{MachinePrincipalConstraint, tests::check_principal, web::WebRoleId}, + }, + test_utils::check_deserialization_error, + }; + + #[test] + fn any() -> Result<(), Box> { + check_principal( + PrincipalConstraint::Machine(MachinePrincipalConstraint::Any {}), + json!({ + "type": "machine", + }), + "principal is HASH::Machine", + )?; + + check_deserialization_error::( + json!({ + "type": "machine", + "additional": "unexpected", + }), + "data did not match any variant of untagged enum MachinePrincipalConstraint", + )?; + + Ok(()) + } + + #[test] + fn exact() -> Result<(), Box> { + let machine_id = MachineId::new(Uuid::new_v4()); + check_principal( + PrincipalConstraint::Machine(MachinePrincipalConstraint::Exact { + machine_id: Some(machine_id), + }), + json!({ + "type": "machine", + "machineId": machine_id, + }), + format!(r#"principal == HASH::Machine::"{machine_id}""#), + )?; + + check_principal( + PrincipalConstraint::Machine(MachinePrincipalConstraint::Exact { machine_id: None }), + json!({ + "type": "machine", + "machineId": null, + }), + "principal == ?principal", + )?; + + check_deserialization_error::( + json!({ + "type": "machine", + "machineId": machine_id, + "additional": "unexpected", + }), + "data did not match any variant of untagged enum MachinePrincipalConstraint", + )?; + + Ok(()) + } + + #[test] + fn organization() -> Result<(), Box> { + let web_id = OwnedById::new(Uuid::new_v4()); + check_principal( + PrincipalConstraint::Machine(MachinePrincipalConstraint::Web( + WebPrincipalConstraint::InWeb { id: Some(web_id) }, + )), + json!({ + "type": "machine", + "id": web_id, + }), + format!(r#"principal is HASH::Machine in HASH::Web::"{web_id}""#), + )?; + + check_principal( + PrincipalConstraint::Machine(MachinePrincipalConstraint::Web( + WebPrincipalConstraint::InWeb { id: None }, + )), + json!({ + "type": "machine", + "id": null, + }), + "principal is HASH::Machine in ?principal", + )?; + + check_deserialization_error::( + json!({ + "type": "machine", + "id": web_id, + "additional": "unexpected", + }), + "data did not match any variant of untagged enum MachinePrincipalConstraint", + )?; + + Ok(()) + } + + #[test] + fn organization_role() -> Result<(), Box> { + let web_role_id = WebRoleId::new(Uuid::new_v4()); + check_principal( + PrincipalConstraint::Machine(MachinePrincipalConstraint::Web( + WebPrincipalConstraint::InRole { + role_id: Some(web_role_id), + }, + )), + json!({ + "type": "machine", + "roleId": web_role_id, + }), + format!(r#"principal is HASH::Machine in HASH::Web::Role::"{web_role_id}""#), + )?; + + check_principal( + PrincipalConstraint::Machine(MachinePrincipalConstraint::Web( + WebPrincipalConstraint::InRole { role_id: None }, + )), + json!({ + "type": "machine", + "roleId": null, + }), + "principal is HASH::Machine in ?principal", + )?; + + check_deserialization_error::( + json!({ + "type": "machine", + "webRoleId": web_role_id, + "additional": "unexpected", + }), + "data did not match any variant of untagged enum MachinePrincipalConstraint", + )?; + + Ok(()) + } +} diff --git a/libs/@local/graph/authorization/src/policies/principal/mod.rs b/libs/@local/graph/authorization/src/policies/principal/mod.rs index 79d1283e704..8f636790c54 100644 --- a/libs/@local/graph/authorization/src/policies/principal/mod.rs +++ b/libs/@local/graph/authorization/src/policies/principal/mod.rs @@ -7,13 +7,18 @@ use cedar_policy_core::ast; use error_stack::{Report, ResultExt as _, bail}; use hash_graph_types::owned_by_id::OwnedById; +pub use self::actor::{Actor, ActorId}; use self::{ + machine::{MachineId, MachinePrincipalConstraint}, team::{TeamId, TeamPrincipalConstraint, TeamRoleId}, user::{UserId, UserPrincipalConstraint}, web::{WebPrincipalConstraint, WebRoleId, WebTeamId, WebTeamRoleId}, }; use super::cedar::CedarEntityId as _; +mod actor; +pub mod machine; +pub mod role; pub mod team; pub mod user; pub mod web; @@ -32,6 +37,7 @@ pub enum PrincipalConstraint { )] Public {}, User(UserPrincipalConstraint), + Machine(MachinePrincipalConstraint), Web(WebPrincipalConstraint), Team(TeamPrincipalConstraint), } @@ -121,6 +127,7 @@ impl PrincipalConstraint { match self { Self::Public {} => false, Self::User(user) => user.has_slot(), + Self::Machine(machine) => machine.has_slot(), Self::Web(web) => web.has_slot(), Self::Team(team) => team.has_slot(), } @@ -162,6 +169,13 @@ impl PrincipalConstraint { .change_context(InvalidPrincipalConstraint::InvalidPrincipalId)?, ), })) + } else if *principal.entity_type() == **MachineId::entity_type() { + Ok(Self::Machine(MachinePrincipalConstraint::Exact { + machine_id: Some( + MachineId::from_eid(principal.eid()) + .change_context(InvalidPrincipalConstraint::InvalidPrincipalId)?, + ), + })) } else { bail!(InvalidPrincipalConstraint::UnexpectedEntityType( ast::EntityType::clone(principal.entity_type()) @@ -180,6 +194,13 @@ impl PrincipalConstraint { Ok(Self::User(UserPrincipalConstraint::from( InPrincipalConstraint::try_from_cedar_in(in_principal)?, ))) + } else if *principal_type == **MachineId::entity_type() { + let Some(in_principal) = in_principal else { + return Ok(Self::Machine(MachinePrincipalConstraint::Any {})); + }; + Ok(Self::Machine(MachinePrincipalConstraint::from( + InPrincipalConstraint::try_from_cedar_in(in_principal)?, + ))) } else { bail!(InvalidPrincipalConstraint::UnexpectedEntityType( ast::EntityType::clone(principal_type) @@ -192,6 +213,7 @@ impl PrincipalConstraint { match self { Self::Public {} => ast::PrincipalConstraint::any(), Self::User(user) => user.to_cedar(), + Self::Machine(machine) => machine.to_cedar(), Self::Web(organization) => organization.to_cedar(), Self::Team(team) => team.to_cedar(), } diff --git a/libs/@local/graph/authorization/src/policies/principal/role.rs b/libs/@local/graph/authorization/src/policies/principal/role.rs new file mode 100644 index 00000000000..ee82242403f --- /dev/null +++ b/libs/@local/graph/authorization/src/policies/principal/role.rs @@ -0,0 +1,25 @@ +use cedar_policy_core::ast; + +use super::{ + team::TeamRoleId, + web::{WebRoleId, WebTeamRoleId}, +}; +use crate::policies::cedar::CedarEntityId as _; + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)] +pub enum RoleId { + Web(WebRoleId), + WebTeam(WebTeamRoleId), + Team(TeamRoleId), +} + +impl RoleId { + pub(crate) fn to_euid(&self) -> ast::EntityUID { + match self { + Self::Web(web_role_id) => web_role_id.to_euid(), + Self::WebTeam(web_team_role_id) => web_team_role_id.to_euid(), + Self::Team(team_role_id) => team_role_id.to_euid(), + } + } +} diff --git a/libs/@local/graph/authorization/src/policies/principal/user.rs b/libs/@local/graph/authorization/src/policies/principal/user.rs index 3964e9f6015..9246e7581bd 100644 --- a/libs/@local/graph/authorization/src/policies/principal/user.rs +++ b/libs/@local/graph/authorization/src/policies/principal/user.rs @@ -11,7 +11,7 @@ use cedar_policy_core::{ast, extensions::Extensions}; use error_stack::Report; use uuid::Uuid; -use super::{InPrincipalConstraint, TeamPrincipalConstraint, TeamRoleId, WebRoleId, WebTeamRoleId}; +use super::{InPrincipalConstraint, TeamPrincipalConstraint, role::RoleId}; use crate::policies::{cedar::CedarEntityId, principal::web::WebPrincipalConstraint}; #[derive( @@ -65,24 +65,6 @@ impl CedarEntityId for UserId { } } -#[derive(Debug, serde::Serialize, serde::Deserialize)] -#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)] -pub enum RoleId { - Web(WebRoleId), - WebTeam(WebTeamRoleId), - Team(TeamRoleId), -} - -impl RoleId { - fn to_euid(&self) -> ast::EntityUID { - match self { - Self::Web(web_role_id) => web_role_id.to_euid(), - Self::WebTeam(web_team_role_id) => web_team_role_id.to_euid(), - Self::Team(team_role_id) => team_role_id.to_euid(), - } - } -} - #[derive(Debug, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct User { @@ -91,14 +73,15 @@ pub struct User { } impl User { - pub(crate) fn to_entity(&self) -> Result> { - Ok(ast::Entity::new( + pub(crate) fn to_entity(&self) -> ast::Entity { + ast::Entity::new( self.id.to_euid(), iter::empty(), self.roles.iter().map(RoleId::to_euid).collect(), iter::empty(), Extensions::none(), - )?) + ) + .expect("User should be a valid Cedar entity") } } diff --git a/libs/@local/graph/authorization/src/policies/resource/entity.rs b/libs/@local/graph/authorization/src/policies/resource/entity.rs index 2e6de5fdf5c..50fefd702e9 100644 --- a/libs/@local/graph/authorization/src/policies/resource/entity.rs +++ b/libs/@local/graph/authorization/src/policies/resource/entity.rs @@ -12,6 +12,7 @@ use uuid::Uuid; use super::entity_type::EntityTypeId; use crate::policies::cedar::CedarEntityId; +#[derive(Debug)] pub struct EntityResource<'a> { pub web_id: OwnedById, pub id: EntityUuid, @@ -53,8 +54,8 @@ impl EntityResourceFilter { } impl EntityResource<'_> { - pub(crate) fn to_cedar_entity(&self) -> Result> { - Ok(ast::Entity::new( + pub(crate) fn to_cedar_entity(&self) -> ast::Entity { + ast::Entity::new( self.id.to_euid(), [( SmolStr::new_static("entity_types"), @@ -67,7 +68,8 @@ impl EntityResource<'_> { iter::once(self.web_id.to_euid()).collect(), iter::empty(), Extensions::none(), - )?) + ) + .expect("Entity should be a valid Cedar entity") } } diff --git a/libs/@local/graph/authorization/src/policies/resource/entity_type.rs b/libs/@local/graph/authorization/src/policies/resource/entity_type.rs index 8fa4617227a..aba25afb309 100644 --- a/libs/@local/graph/authorization/src/policies/resource/entity_type.rs +++ b/libs/@local/graph/authorization/src/policies/resource/entity_type.rs @@ -40,14 +40,15 @@ impl fmt::Display for EntityTypeId { } } +#[derive(Debug)] pub struct EntityTypeResource<'a> { pub web_id: OwnedById, pub id: Cow<'a, EntityTypeId>, } impl EntityTypeResource<'_> { - pub(crate) fn to_cedar_entity(&self) -> Result> { - Ok(ast::Entity::new( + pub(crate) fn to_cedar_entity(&self) -> ast::Entity { + ast::Entity::new( self.id.to_euid(), [ ( @@ -62,7 +63,8 @@ impl EntityTypeResource<'_> { iter::once(self.web_id.to_euid()).collect(), iter::empty(), Extensions::none(), - )?) + ) + .expect("Entity type should be a valid Cedar entity") } } diff --git a/libs/@local/graph/authorization/src/policies/resource/mod.rs b/libs/@local/graph/authorization/src/policies/resource/mod.rs index 8b3f2a97791..03ab78e621f 100644 --- a/libs/@local/graph/authorization/src/policies/resource/mod.rs +++ b/libs/@local/graph/authorization/src/policies/resource/mod.rs @@ -6,8 +6,8 @@ mod entity; mod entity_type; -use alloc::sync::Arc; -use core::{error::Error, str::FromStr as _}; +use alloc::{borrow::Cow, sync::Arc}; +use core::str::FromStr as _; use cedar_policy_core::ast; use error_stack::{Report, ResultExt as _, bail}; @@ -22,20 +22,40 @@ pub use self::{ }; use crate::policies::cedar::CedarEntityId as _; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResourceId<'a> { + Entity(EntityUuid), + EntityType(Cow<'a, EntityTypeId>), +} + +impl ResourceId<'_> { + pub(crate) fn to_euid(&self) -> ast::EntityUID { + match self { + Self::Entity(id) => id.to_euid(), + Self::EntityType(id) => id.to_euid(), + } + } +} + +#[derive(Debug)] pub enum Resource<'a> { Entity(EntityResource<'a>), EntityType(EntityTypeResource<'a>), } impl Resource<'_> { - pub(crate) fn to_euid(&self) -> ast::EntityUID { + #[must_use] + pub const fn id(&self) -> ResourceId<'_> { match self { - Self::Entity(entity) => entity.id.to_euid(), - Self::EntityType(entity_type) => entity_type.id.to_euid(), + Self::Entity(entity) => ResourceId::Entity(entity.id), + Self::EntityType(entity_type) => ResourceId::EntityType(match &entity_type.id { + Cow::Borrowed(id) => Cow::Borrowed(*id), + Cow::Owned(id) => Cow::Borrowed(id), + }), } } - pub(crate) fn to_entity(&self) -> Result> { + pub(crate) fn to_cedar_entity(&self) -> ast::Entity { match self { Self::Entity(entity) => entity.to_cedar_entity(), Self::EntityType(entity_type) => entity_type.to_cedar_entity(), diff --git a/libs/@local/graph/authorization/src/policies/set/mod.rs b/libs/@local/graph/authorization/src/policies/set/mod.rs new file mode 100644 index 00000000000..3180f634a41 --- /dev/null +++ b/libs/@local/graph/authorization/src/policies/set/mod.rs @@ -0,0 +1,70 @@ +use cedar_policy_core::{ + ast, + authorizer::{Authorizer, Decision}, +}; +use error_stack::{Report, ResultExt as _, TryReportIteratorExt as _}; + +use super::{Context, Policy, Request}; + +#[derive(Debug, derive_more::Display, derive_more::Error)] +#[display("policy set insertion failed")] +pub struct PolicySetInsertionError; + +#[derive(Debug, derive_more::Display, derive_more::Error)] +#[display("policy set evaluation failed")] +pub struct PolicyEvaluationError; + +#[derive(Debug, Default)] +pub struct PolicySet { + policies: ast::PolicySet, +} + +impl PolicySet { + pub fn add_policy(&mut self, policy: &Policy) -> Result<(), Report> { + self.policies + .add_static( + policy + .to_cedar_static_policy() + .change_context(PolicySetInsertionError)?, + ) + .change_context(PolicySetInsertionError)?; + Ok(()) + } + + pub fn add_template(&mut self, policy: &Policy) -> Result<(), Report> { + self.policies + .add_template(policy.to_cedar_template()) + .change_context(PolicySetInsertionError)?; + Ok(()) + } + + pub(crate) const fn policies(&self) -> &ast::PolicySet { + &self.policies + } + + /// Evaluates the policy set for the given request. + /// + /// # Errors + /// + /// Returns an error if the evaluation fails. + pub fn evaluate( + &self, + request: &Request, + context: &Context, + ) -> Result> { + let authorizer = Authorizer::new(); + + let response = + authorizer.is_authorized(request.to_cedar(), self.policies(), context.entities()); + + response + .diagnostics + .errors + .into_iter() + .map(|error| Err(Report::new(error))) + .try_collect_reports::<()>() + .change_context(PolicyEvaluationError)?; + + Ok(response.decision == Decision::Allow) + } +} diff --git a/libs/@local/graph/authorization/src/policies/validation.rs b/libs/@local/graph/authorization/src/policies/validation.rs new file mode 100644 index 00000000000..2704530345b --- /dev/null +++ b/libs/@local/graph/authorization/src/policies/validation.rs @@ -0,0 +1,81 @@ +use std::sync::LazyLock; + +use cedar_policy_core::extensions::Extensions; +use cedar_policy_validator::{CoreSchema, ValidationMode, ValidatorSchema}; +use error_stack::{Report, ResultExt as _, TryReportIteratorExt as _}; + +use super::set::PolicySet; + +static SCHEMA: LazyLock = LazyLock::new(|| { + let (schema, warnings) = ValidatorSchema::from_cedarschema_str( + include_str!("../../schemas/policies.cedarschema"), + Extensions::none(), + ) + .unwrap_or_else(|error| { + panic!("Policy schema is invalid: {error}"); + }); + + for warning in warnings { + tracing::warn!("policy schema warning: {warning}"); + #[cfg(test)] + { + eprintln!("policy schema warning: {warning}"); + } + } + schema +}); + +static VALIDATOR: LazyLock = + LazyLock::new(|| cedar_policy_validator::Validator::new((*SCHEMA).clone())); + +#[derive(Debug, derive_more::Display, derive_more::Error)] +#[display("pocliy validation failed")] +pub struct PolicyValidationError; + +#[derive(Debug)] +pub struct Validator; + +impl Validator { + pub(crate) fn schema() -> &'static ValidatorSchema { + &SCHEMA + } + + pub(crate) fn core_schema() -> CoreSchema<'static> { + CoreSchema::new(Self::schema()) + } + + /// Validate a policy set. + /// + /// # Errors + /// + /// Returns a [`Report`] if the policy set is invalid. The report contains + /// a list of validation errors. + #[expect( + clippy::unused_self, + reason = "More fields will be added in the future" + )] + #[track_caller] + pub fn validate_policy_set( + &self, + policy_set: &PolicySet, + ) -> Result<(), Report> { + let result = VALIDATOR.validate(policy_set.policies(), ValidationMode::Strict); + #[cfg(test)] + { + for warning in result.validation_warnings() { + eprintln!("validation warning: {warning}"); + } + } + + for warning in result.validation_warnings() { + tracing::warn!(message=%warning, "validation warning"); + } + + result + .into_errors_and_warnings() + .0 + .map(|error| Err(Report::new(error))) + .try_collect_reports() + .change_context(PolicyValidationError) + } +}