use crate::{ValidatorEntityType, ValidatorSchema};
use cedar_policy_core::extensions::{ExtensionFunctionLookupError, Extensions};
use cedar_policy_core::{ast, entities};
use miette::Diagnostic;
use smol_str::SmolStr;
use std::collections::hash_map::Values;
use std::collections::HashSet;
use std::iter::Cloned;
use std::sync::Arc;
use thiserror::Error;
#[derive(Debug)]
pub struct CoreSchema<'a> {
schema: &'a ValidatorSchema,
}
impl<'a> CoreSchema<'a> {
pub fn new(schema: &'a ValidatorSchema) -> Self {
Self { schema }
}
}
impl<'a> entities::Schema for CoreSchema<'a> {
type EntityTypeDescription = EntityTypeDescription;
type ActionEntityIterator = Cloned<Values<'a, ast::EntityUID, Arc<ast::Entity>>>;
fn entity_type(&self, entity_type: &ast::EntityType) -> Option<EntityTypeDescription> {
EntityTypeDescription::new(self.schema, entity_type)
}
fn action(&self, action: &ast::EntityUID) -> Option<Arc<ast::Entity>> {
self.schema.actions.get(action).cloned()
}
fn entity_types_with_basename<'b>(
&'b self,
basename: &'b ast::UnreservedId,
) -> Box<dyn Iterator<Item = ast::EntityType> + 'b> {
Box::new(
self.schema
.entity_types()
.filter_map(move |(entity_type, _)| {
if &entity_type.name().basename() == basename {
Some(entity_type.clone())
} else {
None
}
}),
)
}
fn action_entities(&self) -> Self::ActionEntityIterator {
self.schema.actions.values().cloned()
}
}
#[derive(Debug)]
pub struct EntityTypeDescription {
core_type: ast::EntityType,
validator_type: ValidatorEntityType,
allowed_parent_types: Arc<HashSet<ast::EntityType>>,
}
impl EntityTypeDescription {
pub fn new(schema: &ValidatorSchema, type_name: &ast::EntityType) -> Option<Self> {
Some(Self {
core_type: type_name.clone(),
validator_type: schema.get_entity_type(type_name).cloned()?,
allowed_parent_types: {
let mut set = HashSet::new();
for (possible_parent_typename, possible_parent_et) in schema.entity_types() {
if possible_parent_et.descendants.contains(type_name) {
set.insert(possible_parent_typename.clone());
}
}
Arc::new(set)
},
})
}
}
impl entities::EntityTypeDescription for EntityTypeDescription {
fn entity_type(&self) -> ast::EntityType {
self.core_type.clone()
}
fn attr_type(&self, attr: &str) -> Option<entities::SchemaType> {
let attr_type: &crate::types::Type = &self.validator_type.attr(attr)?.attr_type;
#[allow(clippy::expect_used)]
let core_schema_type: entities::SchemaType = attr_type
.clone()
.try_into()
.expect("failed to convert validator type into Core SchemaType");
debug_assert!(attr_type.is_consistent_with(&core_schema_type));
Some(core_schema_type)
}
fn tag_type(&self) -> Option<entities::SchemaType> {
let tag_type: &crate::types::Type = self.validator_type.tag_type()?;
#[allow(clippy::expect_used)]
let core_schema_type: entities::SchemaType = tag_type
.clone()
.try_into()
.expect("failed to convert validator type into Core SchemaType");
debug_assert!(tag_type.is_consistent_with(&core_schema_type));
Some(core_schema_type)
}
fn required_attrs<'s>(&'s self) -> Box<dyn Iterator<Item = SmolStr> + 's> {
Box::new(
self.validator_type
.attributes
.iter()
.filter(|(_, ty)| ty.is_required)
.map(|(attr, _)| attr.clone()),
)
}
fn allowed_parent_types(&self) -> Arc<HashSet<ast::EntityType>> {
Arc::clone(&self.allowed_parent_types)
}
fn open_attributes(&self) -> bool {
self.validator_type.open_attributes.is_open()
}
}
impl ast::RequestSchema for ValidatorSchema {
type Error = RequestValidationError;
fn validate_request(
&self,
request: &ast::Request,
extensions: &Extensions<'_>,
) -> std::result::Result<(), Self::Error> {
use ast::EntityUIDEntry;
if let EntityUIDEntry::Known {
euid: principal, ..
} = request.principal()
{
if self.get_entity_type(principal.entity_type()).is_none() {
return Err(request_validation_errors::UndeclaredPrincipalTypeError {
principal_ty: principal.entity_type().clone(),
}
.into());
}
}
if let EntityUIDEntry::Known { euid: resource, .. } = request.resource() {
if self.get_entity_type(resource.entity_type()).is_none() {
return Err(request_validation_errors::UndeclaredResourceTypeError {
resource_ty: resource.entity_type().clone(),
}
.into());
}
}
match request.action() {
EntityUIDEntry::Known { euid: action, .. } => {
let validator_action_id = self.get_action_id(action).ok_or_else(|| {
request_validation_errors::UndeclaredActionError {
action: Arc::clone(action),
}
})?;
if let EntityUIDEntry::Known {
euid: principal, ..
} = request.principal()
{
if !validator_action_id.is_applicable_principal_type(principal.entity_type()) {
return Err(request_validation_errors::InvalidPrincipalTypeError {
principal_ty: principal.entity_type().clone(),
action: Arc::clone(action),
}
.into());
}
}
if let EntityUIDEntry::Known { euid: resource, .. } = request.resource() {
if !validator_action_id.is_applicable_resource_type(resource.entity_type()) {
return Err(request_validation_errors::InvalidResourceTypeError {
resource_ty: resource.entity_type().clone(),
action: Arc::clone(action),
}
.into());
}
}
if let Some(context) = request.context() {
let expected_context_ty = validator_action_id.context_type();
if !expected_context_ty
.typecheck_partial_value(&context.clone().into(), extensions)
.map_err(RequestValidationError::TypeOfContext)?
{
return Err(request_validation_errors::InvalidContextError {
context: context.clone(),
action: Arc::clone(action),
}
.into());
}
}
}
EntityUIDEntry::Unknown { .. } => {
}
}
Ok(())
}
}
impl<'a> ast::RequestSchema for CoreSchema<'a> {
type Error = RequestValidationError;
fn validate_request(
&self,
request: &ast::Request,
extensions: &Extensions<'_>,
) -> Result<(), Self::Error> {
self.schema.validate_request(request, extensions)
}
}
#[derive(Debug, Diagnostic, Error)]
pub enum RequestValidationError {
#[error(transparent)]
#[diagnostic(transparent)]
UndeclaredAction(#[from] request_validation_errors::UndeclaredActionError),
#[error(transparent)]
#[diagnostic(transparent)]
UndeclaredPrincipalType(#[from] request_validation_errors::UndeclaredPrincipalTypeError),
#[error(transparent)]
#[diagnostic(transparent)]
UndeclaredResourceType(#[from] request_validation_errors::UndeclaredResourceTypeError),
#[error(transparent)]
#[diagnostic(transparent)]
InvalidPrincipalType(#[from] request_validation_errors::InvalidPrincipalTypeError),
#[error(transparent)]
#[diagnostic(transparent)]
InvalidResourceType(#[from] request_validation_errors::InvalidResourceTypeError),
#[error(transparent)]
#[diagnostic(transparent)]
InvalidContext(#[from] request_validation_errors::InvalidContextError),
#[error("context is not valid: {0}")]
#[diagnostic(transparent)]
TypeOfContext(ExtensionFunctionLookupError),
}
pub mod request_validation_errors {
use cedar_policy_core::ast;
use miette::Diagnostic;
use std::sync::Arc;
use thiserror::Error;
#[derive(Debug, Error, Diagnostic)]
#[error("request's action `{action}` is not declared in the schema")]
pub struct UndeclaredActionError {
pub(super) action: Arc<ast::EntityUID>,
}
impl UndeclaredActionError {
pub fn action(&self) -> &ast::EntityUID {
&self.action
}
}
#[derive(Debug, Error, Diagnostic)]
#[error("principal type `{principal_ty}` is not declared in the schema")]
pub struct UndeclaredPrincipalTypeError {
pub(super) principal_ty: ast::EntityType,
}
impl UndeclaredPrincipalTypeError {
pub fn principal_ty(&self) -> &ast::EntityType {
&self.principal_ty
}
}
#[derive(Debug, Error, Diagnostic)]
#[error("resource type `{resource_ty}` is not declared in the schema")]
pub struct UndeclaredResourceTypeError {
pub(super) resource_ty: ast::EntityType,
}
impl UndeclaredResourceTypeError {
pub fn resource_ty(&self) -> &ast::EntityType {
&self.resource_ty
}
}
#[derive(Debug, Error, Diagnostic)]
#[error("principal type `{principal_ty}` is not valid for `{action}`")]
pub struct InvalidPrincipalTypeError {
pub(super) principal_ty: ast::EntityType,
pub(super) action: Arc<ast::EntityUID>,
}
impl InvalidPrincipalTypeError {
pub fn principal_ty(&self) -> &ast::EntityType {
&self.principal_ty
}
pub fn action(&self) -> &ast::EntityUID {
&self.action
}
}
#[derive(Debug, Error, Diagnostic)]
#[error("resource type `{resource_ty}` is not valid for `{action}`")]
pub struct InvalidResourceTypeError {
pub(super) resource_ty: ast::EntityType,
pub(super) action: Arc<ast::EntityUID>,
}
impl InvalidResourceTypeError {
pub fn resource_ty(&self) -> &ast::EntityType {
&self.resource_ty
}
pub fn action(&self) -> &ast::EntityUID {
&self.action
}
}
#[derive(Debug, Error, Diagnostic)]
#[error("context `{context}` is not valid for `{action}`")]
pub struct InvalidContextError {
pub(super) context: ast::Context,
pub(super) action: Arc<ast::EntityUID>,
}
impl InvalidContextError {
pub fn context(&self) -> &ast::Context {
&self.context
}
pub fn action(&self) -> &ast::EntityUID {
&self.action
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ContextSchema(
crate::types::Type,
);
impl entities::ContextSchema for ContextSchema {
fn context_type(&self) -> entities::SchemaType {
#[allow(clippy::expect_used)]
self.0
.clone()
.try_into()
.expect("failed to convert validator type into Core SchemaType")
}
}
pub fn context_schema_for_action(
schema: &ValidatorSchema,
action: &ast::EntityUID,
) -> Option<ContextSchema> {
schema.context_type(action).cloned().map(ContextSchema)
}
#[cfg(test)]
mod test {
use super::*;
use cedar_policy_core::test_utils::{expect_err, ExpectedErrorMessageBuilder};
use cool_asserts::assert_matches;
use serde_json::json;
fn schema() -> ValidatorSchema {
let src = json!(
{ "": {
"entityTypes": {
"User": {
"memberOfTypes": [ "Group" ]
},
"Group": {
"memberOfTypes": []
},
"Photo": {
"memberOfTypes": [ "Album" ]
},
"Album": {
"memberOfTypes": []
}
},
"actions": {
"view_photo": {
"appliesTo": {
"principalTypes": ["User", "Group"],
"resourceTypes": ["Photo"]
}
},
"edit_photo": {
"appliesTo": {
"principalTypes": ["User", "Group"],
"resourceTypes": ["Photo"],
"context": {
"type": "Record",
"attributes": {
"admin_approval": {
"type": "Boolean",
"required": true,
}
}
}
}
}
}
}});
ValidatorSchema::from_json_value(src, Extensions::all_available())
.expect("failed to create ValidatorSchema")
}
#[test]
fn success_concrete_request_no_context() {
assert_matches!(
ast::Request::new(
(
ast::EntityUID::with_eid_and_type("User", "abc123").unwrap(),
None
),
(
ast::EntityUID::with_eid_and_type("Action", "view_photo").unwrap(),
None
),
(
ast::EntityUID::with_eid_and_type("Photo", "vacationphoto94.jpg").unwrap(),
None
),
ast::Context::empty(),
Some(&schema()),
Extensions::all_available(),
),
Ok(_)
);
}
#[test]
fn success_concrete_request_with_context() {
assert_matches!(
ast::Request::new(
(
ast::EntityUID::with_eid_and_type("User", "abc123").unwrap(),
None
),
(
ast::EntityUID::with_eid_and_type("Action", "edit_photo").unwrap(),
None
),
(
ast::EntityUID::with_eid_and_type("Photo", "vacationphoto94.jpg").unwrap(),
None
),
ast::Context::from_pairs(
[("admin_approval".into(), ast::RestrictedExpr::val(true))],
Extensions::all_available()
)
.unwrap(),
Some(&schema()),
Extensions::all_available(),
),
Ok(_)
);
}
#[test]
fn success_principal_unknown() {
assert_matches!(
ast::Request::new_with_unknowns(
ast::EntityUIDEntry::Unknown { loc: None },
ast::EntityUIDEntry::known(
ast::EntityUID::with_eid_and_type("Action", "view_photo").unwrap(),
None,
),
ast::EntityUIDEntry::known(
ast::EntityUID::with_eid_and_type("Photo", "vacationphoto94.jpg").unwrap(),
None,
),
Some(ast::Context::empty()),
Some(&schema()),
Extensions::all_available(),
),
Ok(_)
);
}
#[test]
fn success_action_unknown() {
assert_matches!(
ast::Request::new_with_unknowns(
ast::EntityUIDEntry::known(
ast::EntityUID::with_eid_and_type("User", "abc123").unwrap(),
None,
),
ast::EntityUIDEntry::Unknown { loc: None },
ast::EntityUIDEntry::known(
ast::EntityUID::with_eid_and_type("Photo", "vacationphoto94.jpg").unwrap(),
None,
),
Some(ast::Context::empty()),
Some(&schema()),
Extensions::all_available(),
),
Ok(_)
);
}
#[test]
fn success_resource_unknown() {
assert_matches!(
ast::Request::new_with_unknowns(
ast::EntityUIDEntry::known(
ast::EntityUID::with_eid_and_type("User", "abc123").unwrap(),
None,
),
ast::EntityUIDEntry::known(
ast::EntityUID::with_eid_and_type("Action", "view_photo").unwrap(),
None,
),
ast::EntityUIDEntry::Unknown { loc: None },
Some(ast::Context::empty()),
Some(&schema()),
Extensions::all_available(),
),
Ok(_)
);
}
#[test]
fn success_context_unknown() {
assert_matches!(
ast::Request::new_with_unknowns(
ast::EntityUIDEntry::known(
ast::EntityUID::with_eid_and_type("User", "abc123").unwrap(),
None,
),
ast::EntityUIDEntry::known(
ast::EntityUID::with_eid_and_type("Action", "view_photo").unwrap(),
None,
),
ast::EntityUIDEntry::known(
ast::EntityUID::with_eid_and_type("Photo", "vacationphoto94.jpg").unwrap(),
None,
),
None,
Some(&schema()),
Extensions::all_available(),
),
Ok(_)
)
}
#[test]
fn success_everything_unspecified() {
assert_matches!(
ast::Request::new_with_unknowns(
ast::EntityUIDEntry::Unknown { loc: None },
ast::EntityUIDEntry::Unknown { loc: None },
ast::EntityUIDEntry::Unknown { loc: None },
None,
Some(&schema()),
Extensions::all_available(),
),
Ok(_)
);
}
#[test]
fn success_unknown_action_but_invalid_types() {
assert_matches!(
ast::Request::new_with_unknowns(
ast::EntityUIDEntry::known(
ast::EntityUID::with_eid_and_type("Album", "abc123").unwrap(),
None,
),
ast::EntityUIDEntry::Unknown { loc: None },
ast::EntityUIDEntry::known(
ast::EntityUID::with_eid_and_type("User", "alice").unwrap(),
None,
),
None,
Some(&schema()),
Extensions::all_available(),
),
Ok(_)
);
}
#[test]
fn action_not_declared() {
assert_matches!(
ast::Request::new(
(ast::EntityUID::with_eid_and_type("User", "abc123").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Action", "destroy").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Photo", "vacationphoto94.jpg").unwrap(), None),
ast::Context::empty(),
Some(&schema()),
Extensions::all_available(),
),
Err(e) => {
expect_err("", &miette::Report::new(e), &ExpectedErrorMessageBuilder::error(r#"request's action `Action::"destroy"` is not declared in the schema"#).build());
}
);
}
#[test]
fn principal_type_not_declared() {
assert_matches!(
ast::Request::new(
(ast::EntityUID::with_eid_and_type("Foo", "abc123").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Action", "view_photo").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Photo", "vacationphoto94.jpg").unwrap(), None),
ast::Context::empty(),
Some(&schema()),
Extensions::all_available(),
),
Err(e) => {
expect_err("", &miette::Report::new(e), &ExpectedErrorMessageBuilder::error("principal type `Foo` is not declared in the schema").build());
}
);
}
#[test]
fn resource_type_not_declared() {
assert_matches!(
ast::Request::new(
(ast::EntityUID::with_eid_and_type("User", "abc123").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Action", "view_photo").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Foo", "vacationphoto94.jpg").unwrap(), None),
ast::Context::empty(),
Some(&schema()),
Extensions::all_available(),
),
Err(e) => {
expect_err("", &miette::Report::new(e), &ExpectedErrorMessageBuilder::error("resource type `Foo` is not declared in the schema").build());
}
);
}
#[test]
fn principal_type_invalid() {
assert_matches!(
ast::Request::new(
(ast::EntityUID::with_eid_and_type("Album", "abc123").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Action", "view_photo").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Photo", "vacationphoto94.jpg").unwrap(), None),
ast::Context::empty(),
Some(&schema()),
Extensions::all_available(),
),
Err(e) => {
expect_err("", &miette::Report::new(e), &ExpectedErrorMessageBuilder::error(r#"principal type `Album` is not valid for `Action::"view_photo"`"#).build());
}
);
}
#[test]
fn resource_type_invalid() {
assert_matches!(
ast::Request::new(
(ast::EntityUID::with_eid_and_type("User", "abc123").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Action", "view_photo").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Group", "coders").unwrap(), None),
ast::Context::empty(),
Some(&schema()),
Extensions::all_available(),
),
Err(e) => {
expect_err("", &miette::Report::new(e), &ExpectedErrorMessageBuilder::error(r#"resource type `Group` is not valid for `Action::"view_photo"`"#).build());
}
);
}
#[test]
fn context_missing_attribute() {
assert_matches!(
ast::Request::new(
(ast::EntityUID::with_eid_and_type("User", "abc123").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Action", "edit_photo").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Photo", "vacationphoto94.jpg").unwrap(), None),
ast::Context::empty(),
Some(&schema()),
Extensions::all_available(),
),
Err(e) => {
expect_err("", &miette::Report::new(e), &ExpectedErrorMessageBuilder::error(r#"context `<first-class record with 0 fields>` is not valid for `Action::"edit_photo"`"#).build());
}
);
}
#[test]
fn context_extra_attribute() {
let context_with_extra_attr = ast::Context::from_pairs(
[
("admin_approval".into(), ast::RestrictedExpr::val(true)),
("extra".into(), ast::RestrictedExpr::val(42)),
],
Extensions::all_available(),
)
.unwrap();
assert_matches!(
ast::Request::new(
(ast::EntityUID::with_eid_and_type("User", "abc123").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Action", "edit_photo").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Photo", "vacationphoto94.jpg").unwrap(), None),
context_with_extra_attr.clone(),
Some(&schema()),
Extensions::all_available(),
),
Err(e) => {
expect_err("", &miette::Report::new(e), &ExpectedErrorMessageBuilder::error(r#"context `<first-class record with 2 fields>` is not valid for `Action::"edit_photo"`"#).build());
}
);
}
#[test]
fn context_attribute_wrong_type() {
let context_with_wrong_type_attr = ast::Context::from_pairs(
[(
"admin_approval".into(),
ast::RestrictedExpr::set([ast::RestrictedExpr::val(true)]),
)],
Extensions::all_available(),
)
.unwrap();
assert_matches!(
ast::Request::new(
(ast::EntityUID::with_eid_and_type("User", "abc123").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Action", "edit_photo").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Photo", "vacationphoto94.jpg").unwrap(), None),
context_with_wrong_type_attr.clone(),
Some(&schema()),
Extensions::all_available(),
),
Err(e) => {
expect_err("", &miette::Report::new(e), &ExpectedErrorMessageBuilder::error(r#"context `<first-class record with 1 fields>` is not valid for `Action::"edit_photo"`"#).build());
}
);
}
#[test]
fn context_attribute_heterogeneous_set() {
let context_with_heterogeneous_set = ast::Context::from_pairs(
[(
"admin_approval".into(),
ast::RestrictedExpr::set([
ast::RestrictedExpr::val(true),
ast::RestrictedExpr::val(-1001),
]),
)],
Extensions::all_available(),
)
.unwrap();
assert_matches!(
ast::Request::new(
(ast::EntityUID::with_eid_and_type("User", "abc123").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Action", "edit_photo").unwrap(), None),
(ast::EntityUID::with_eid_and_type("Photo", "vacationphoto94.jpg").unwrap(), None),
context_with_heterogeneous_set.clone(),
Some(&schema()),
Extensions::all_available(),
),
Err(e) => {
expect_err("", &miette::Report::new(e), &ExpectedErrorMessageBuilder::error(r#"context `<first-class record with 1 fields>` is not valid for `Action::"edit_photo"`"#).build());
}
);
}
}