Repository: cedar-policy/cedar (rust)

Issue:
Schema Validation for Storing Actions in Entities
### Category

Cedar validation features

### Describe the feature you'd like to request

Our application has a User entity and a Role entity. The Role entity contains a list of actions that users with that role are allowed to take. For instance, here is what the role entity looks like:
```
{
    "uid": {
        "type": "Role",
        "id": "admin"
    },
    "attrs": {
        "actions": [
             Action::"listSessions",
             Action::"deleteSession"
        ]
    }
}
```

and the Users have a role attribute like so:
```
{
    "uid": {
        "type": "User",
        "id": "user1"
    },
    "parents": [],
    "attrs": {
         "role": {
              "type": "Role",
              "id": "admin"
          }
     }
}
```

Our policies check if the action is allowed in the role like so:
```
permit (
    principal,
    action,
    resource
) when {
    action in principal.role.actions
};
```

The problem is that it isn't possible to validate the presence of the action list inside the role. Writing a Schema like
```
Role": {
    "shape": {
     "type": "Record",
      "attributes": {
        "actions": {
          "type": "Set",
          "element": {
            "type": "Entity",
            "name": "Action"
          }
        }
      }
    }
  },
```
produces an error `Undeclared entity types: {"Action"}`

It would be great if we could get an extension for the validator to allow us to list actions inside entities.

Thank you!

### Describe alternatives you've considered

We originally had the permissions for each user listed as Cedar policies, but having the permissions inside the Role entity allows us to quickly return what permissions a role has, as well as have a configuration file that defines every permission for each role.

### Additional context

_No response_

### Is this something that you'd be interested in working on?

- [ ] 👋 I may be able to implement this feature request
- [ ] ⚠️ This feature might incur a breaking change

Pull request:
allow entity attributes and tags to contain Action-typed entities
## Description of changes

Allow entity attributes and tags to contain Action-typed entities.  This was always supported in the evaluator, but is now also supported in policy validation, entity validation, and request validation.  Any action UID literals that appear in attribute values or tag values must refer to actions that actually exist in the schema.

## Issue #, if available

Fixes #304 

## Checklist for requesting a review

The change in this PR is (choose one, and delete the other options):

- [x] A bug fix or other functionality change requiring a patch to `cedar-policy`.

I confirm that this PR (choose one, and delete the other options):

- [x] Updates the "Unreleased" section of the CHANGELOG with a description of my change (required for major/minor version bumps).

I confirm that [`cedar-spec`](https://github.com/cedar-policy/cedar-spec) (choose one, and delete the other options):

- [x] Does not require updates because my change does not impact the Cedar formal model or DRT infrastructure.

I confirm that [`docs.cedarpolicy.com`](https://docs.cedarpolicy.com/) (choose one, and delete the other options):

- [x] Does not require updates because my change does not impact the Cedar language specification.


Patch:
diff --git a/cedar-policy-core/src/entities/conformance.rs b/cedar-policy-core/src/entities/conformance.rs
index 2db7dfc6..e0a4a5b9 100644
--- a/cedar-policy-core/src/entities/conformance.rs
+++ b/cedar-policy-core/src/entities/conformance.rs
@@ -14,21 +14,22 @@
  * limitations under the License.
  */
 
-use std::collections::{BTreeMap, HashMap};
+pub mod err;
 
 use super::{json::err::TypeMismatchError, EntityTypeDescription, Schema, SchemaType};
-use super::{Eid, EntityUID, Literal};
+use super::{Eid, EntityUID, ExprKind, Literal};
 use crate::ast::{
     BorrowedRestrictedExpr, Entity, PartialValue, PartialValueToRestrictedExprError, RestrictedExpr,
 };
-use crate::entities::ExprKind;
 use crate::extensions::{ExtensionFunctionLookupError, Extensions};
+use err::{
+    EntitySchemaConformanceError, InvalidEnumEntity, InvalidEnumEntityError, UndeclaredAction,
+    UnexpectedEntityTypeError,
+};
 use miette::Diagnostic;
 use smol_str::SmolStr;
+use std::collections::{BTreeMap, HashMap};
 use thiserror::Error;
-pub mod err;
-
-use err::{EntitySchemaConformanceError, InvalidEnumEntityError, UnexpectedEntityTypeError};
 
 /// Struct used to check whether entities conform to a schema
 #[derive(Debug, Clone)]
@@ -73,8 +74,7 @@ impl<S: Schema> EntitySchemaConformanceChecker<'_, S> {
         // For each ancestor that actually appears in `entity`, ensure the
         // ancestor type is allowed by the schema
         for ancestor_euid in ancestors {
-            validate_euid(self.schema, ancestor_euid)
-                .map_err(|e| EntitySchemaConformanceError::InvalidEnumEntity(e.into()))?;
+            validate_euid(self.schema, ancestor_euid)?;
             let ancestor_type = ancestor_euid.entity_type();
             if schema_etype.allowed_parent_types().contains(ancestor_type) {
                 // note that `allowed_parent_types()` was transitively
@@ -144,11 +144,10 @@ impl<S: Schema> EntitySchemaConformanceChecker<'_, S> {
                                 err,
                             ));
                         }
-                    }
+                    };
                 }
             }
-            validate_euids_in_partial_value(self.schema, val)
-                .map_err(|e| EntitySchemaConformanceError::InvalidEnumEntity(e.into()))?;
+            validate_euids_in_partial_value(self.schema, val)?;
         }
         Ok(())
     }
@@ -195,8 +194,7 @@ impl<S: Schema> EntitySchemaConformanceChecker<'_, S> {
             }
         }
         for val in tags.values() {
-            validate_euids_in_partial_value(self.schema, val)
-                .map_err(|e| EntitySchemaConformanceError::InvalidEnumEntity(e.into()))?;
+            validate_euids_in_partial_value(self.schema, val)?;
         }
         Ok(())
     }
@@ -209,8 +207,6 @@ impl<S: Schema> EntitySchemaConformanceChecker<'_, S> {
         if etype.is_action() {
             self.validate_action(entity)?;
         } else {
-            validate_euid(self.schema, uid)
-                .map_err(|e| EntitySchemaConformanceError::InvalidEnumEntity(e.into()))?;
             let schema_etype = self.schema.entity_type(etype).ok_or_else(|| {
                 let suggested_types = self
                     .schema
@@ -222,6 +218,7 @@ impl<S: Schema> EntitySchemaConformanceChecker<'_, S> {
                 }
             })?;
 
+            validate_euid(self.schema, uid)?;
             self.validate_entity_attributes(uid, entity.attrs(), &schema_etype)?;
             self.validate_entity_ancestors(uid, entity.ancestors(), &schema_etype)?;
             self.validate_tags(uid, entity.tags(), &schema_etype)?;
@@ -245,31 +242,65 @@ pub fn is_valid_enumerated_entity(
         .map(|_| ())
 }
 
-/// Validate if `euid` is valid, provided that it's of an enumerated type
-pub fn validate_euid(schema: &impl Schema, euid: &EntityUID) -> Result<(), InvalidEnumEntityError> {
-    if let Some(desc) = schema.entity_type(euid.entity_type()) {
+/// Errors returned from `validate_euid()` and friends
+///
+/// This is NOT a publicly exported error type.
+#[derive(Debug, Error, Diagnostic)]
+pub enum ValidateEuidError {
+    /// EUID's type is an enum type, but its value is not one of the declared enum values
+    #[error(transparent)]
+    #[diagnostic(transparent)]
+    InvalidEnumEntity(#[from] InvalidEnumEntityError),
+    /// EUID's type is an action type, but it is not one of the declared actions
+    #[error(transparent)]
+    #[diagnostic(transparent)]
+    UndeclaredAction(#[from] UndeclaredAction),
+}
+
+impl From<ValidateEuidError> for EntitySchemaConformanceError {
+    fn from(e: ValidateEuidError) -> Self {
+        match e {
+            ValidateEuidError::InvalidEnumEntity(e) => InvalidEnumEntity::from(e).into(),
+            ValidateEuidError::UndeclaredAction(e) => e.into(),
+        }
+    }
+}
+
+/// Validate if `euid` is valid
+///
+/// As of this writing, the only ways for an `euid` to be invalid are if it is
+/// of enumerated entity type or action type, in which case it needs to have one
+/// of the specific entity IDs declared in the schema.
+pub fn validate_euid(schema: &impl Schema, euid: &EntityUID) -> Result<(), ValidateEuidError> {
+    let entity_type = euid.entity_type();
+    if let Some(desc) = schema.entity_type(entity_type) {
         if let Some(choices) = desc.enum_entity_eids() {
             is_valid_enumerated_entity(&Vec::from(choices), euid)?;
         }
     }
+    if entity_type.is_action() && schema.action(euid).is_none() {
+        return Err(ValidateEuidError::UndeclaredAction(UndeclaredAction {
+            uid: euid.clone(),
+        }));
+    }
     Ok(())
 }
 
 fn validate_euids_in_subexpressions<'a>(
     exprs: impl IntoIterator<Item = &'a crate::ast::Expr>,
     schema: &impl Schema,
-) -> std::result::Result<(), InvalidEnumEntityError> {
+) -> std::result::Result<(), ValidateEuidError> {
     exprs.into_iter().try_for_each(|e| match e.expr_kind() {
         ExprKind::Lit(Literal::EntityUID(euid)) => validate_euid(schema, euid.as_ref()),
         _ => Ok(()),
     })
 }
 
-/// Validate if enumerated entities in `val` are valid
+/// Validate if enumerated entities and action UIDs in `val` are valid
 pub fn validate_euids_in_partial_value(
     schema: &impl Schema,
     val: &PartialValue,
-) -> Result<(), InvalidEnumEntityError> {
+) -> Result<(), ValidateEuidError> {
     match val {
         PartialValue::Value(val) => validate_euids_in_subexpressions(
             RestrictedExpr::from(val.clone()).subexpressions(),
diff --git a/cedar-policy-core/src/entities/conformance/err.rs b/cedar-policy-core/src/entities/conformance/err.rs
index 5982e50a..536561f5 100644
--- a/cedar-policy-core/src/entities/conformance/err.rs
+++ b/cedar-policy-core/src/entities/conformance/err.rs
@@ -60,7 +60,7 @@ pub enum EntitySchemaConformanceError {
     /// Encountered an action which was not declared in the schema
     #[error(transparent)]
     #[diagnostic(transparent)]
-    UndeclaredAction(UndeclaredAction),
+    UndeclaredAction(#[from] UndeclaredAction),
     /// Encountered an action whose definition doesn't precisely match the
     /// schema's declaration of that action
     #[error(transparent)]
@@ -190,7 +190,7 @@ pub struct ActionDeclarationMismatch {
 #[error("found action entity `{uid}`, but it was not declared as an action in the schema")]
 pub struct UndeclaredAction {
     /// Action which was not declared in the schema
-    uid: EntityUID,
+    pub(crate) uid: EntityUID,
 }
 
 /// Found an ancestor of a type that's not allowed for that entity
diff --git a/cedar-policy-core/src/validator/coreschema.rs b/cedar-policy-core/src/validator/coreschema.rs
index 9db1ea8a..0159d0d1 100644
--- a/cedar-policy-core/src/validator/coreschema.rs
+++ b/cedar-policy-core/src/validator/coreschema.rs
@@ -14,8 +14,10 @@
  * limitations under the License.
  */
 use crate::ast::{Eid, EntityType, EntityUID};
-use crate::entities::conformance::err::InvalidEnumEntityError;
-use crate::entities::conformance::{is_valid_enumerated_entity, validate_euids_in_partial_value};
+use crate::entities::conformance::{
+    err::InvalidEnumEntityError, is_valid_enumerated_entity, validate_euids_in_partial_value,
+    ValidateEuidError,
+};
 use crate::extensions::{ExtensionFunctionLookupError, Extensions};
 use crate::validator::{
     ValidatorActionId, ValidatorEntityType, ValidatorEntityTypeKind, ValidatorSchema,
@@ -262,8 +264,19 @@ impl ast::RequestSchema for ValidatorSchema {
         })?;
 
         // Validate entity UIDs in the context
-        validate_euids_in_partial_value(&CoreSchema::new(&self), &context.clone().into())
-            .map_err(RequestValidationError::InvalidEnumEntity)?;
+        validate_euids_in_partial_value(&CoreSchema::new(&self), &context.clone().into()).map_err(
+            |e| match e {
+                ValidateEuidError::InvalidEnumEntity(e) => {
+                    RequestValidationError::InvalidEnumEntity(e)
+                }
+                ValidateEuidError::UndeclaredAction(e) => {
+                    request_validation_errors::UndeclaredActionError {
+                        action: Arc::new(e.uid),
+                    }
+                    .into()
+                }
+            },
+        )?;
 
         // Typecheck the context against the expected context type
         let expected_context_ty = validator_action_id.context_type();
diff --git a/cedar-policy-core/src/validator/schema.rs b/cedar-policy-core/src/validator/schema.rs
index 034fb4e5..4f7fd083 100644
--- a/cedar-policy-core/src/validator/schema.rs
+++ b/cedar-policy-core/src/validator/schema.rs
@@ -607,6 +607,18 @@ impl ValidatorSchema {
             }
         }
 
+        // Add entity-type declarations for the `Action` type in each namespace
+        // that contains actions.  This allows schemas to, for instance, have
+        // attributes of type `Action` or `Set<Action>` etc.
+        let action_types: HashSet<InternalName> = all_defs
+            .action_defs
+            .iter()
+            .map(|action_def| action_def.entity_type().as_ref().as_ref().clone())
+            .collect();
+        for action_type in action_types {
+            all_defs.mark_as_defined_as_entity_type(action_type);
+        }
+
         // Now use `all_defs` to resolve all [`ConditionalName`] type references
         // into fully-qualified [`InternalName`] references.
         // ("Resolve" here just means convert to fully-qualified
@@ -795,6 +807,7 @@ impl ValidatorSchema {
         // Pass `true` here so that we also check that the action hierarchy does
         // not contain cycles.
         compute_tc(&mut action_ids, true)?;
+
         #[cfg(feature = "extended-schema")]
         let common_type_validators = common_types
             .clone()
@@ -806,31 +819,29 @@ impl ValidatorSchema {
             })
             .map(|ct| ValidatorCommonType::new(ct.0, ct.1))
             .collect();
+
         // Return with an error if there is an undeclared entity or action
         // referenced in any fragment. `{entity,action}_children` are provided
-        // for the `undeclared_parent_{entities,actions}` arguments because
+        // for the `undeclared_parent_{entities,actions}` arguments because we
         // removed keys from these maps as we encountered declarations for the
         // entity types or actions. Any keys left in the map are therefore
         // undeclared.
         Self::check_for_undeclared(
+            &all_defs,
             &entity_types,
             entity_children.into_keys(),
             &action_ids,
             action_children.into_keys(),
             common_types.into_values(),
         )?;
-        #[cfg(not(feature = "extended-schema"))]
-        let validator_schema = Ok(ValidatorSchema::new_from_maps(entity_types, action_ids));
-        #[cfg(feature = "extended-schema")]
-        let validator_schema = Ok(ValidatorSchema::new_from_maps(
+        Ok(ValidatorSchema::new_from_maps(
             entity_types,
             action_ids,
             #[cfg(feature = "extended-schema")]
             common_type_validators,
             #[cfg(feature = "extended-schema")]
             validator_namespaces,
-        ));
-        validator_schema
+        ))
     }
 
     /// Check that all entity types and actions referenced in the schema are in
@@ -838,6 +849,7 @@ impl ValidatorSchema {
     /// This function assumes that all entity types are fully qualified, which
     /// is indicated by the use of the [`EntityType`] and [`EntityUID`] types.
     fn check_for_undeclared(
+        all_defs: &AllDefs,
         entity_types: &HashMap<EntityType, ValidatorEntityType>,
         undeclared_parent_entities: impl IntoIterator<Item = EntityType>,
         action_ids: &HashMap<EntityUID, ValidatorActionId>,
@@ -858,17 +870,13 @@ impl ValidatorSchema {
         // types to their parent entity types.
         for entity_type in entity_types.values() {
             for (_, attr_typ) in entity_type.attributes().iter() {
-                Self::check_undeclared_in_type(
-                    &attr_typ.attr_type,
-                    entity_types,
-                    &mut undeclared_e,
-                );
+                Self::check_undeclared_in_type(&attr_typ.attr_type, all_defs, &mut undeclared_e);
             }
         }
 
         // Check for undeclared entity types within common types.
         for common_type in common_types {
-            Self::check_undeclared_in_type(&common_type.ty, entity_types, &mut undeclared_e);
+            Self::check_undeclared_in_type(&common_type.ty, all_defs, &mut undeclared_e);
         }
 
         // Undeclared actions in a `memberOf` list.
@@ -877,7 +885,7 @@ impl ValidatorSchema {
         // types and `appliesTo` lists. See the `entity_types` loop for why the
         // `descendants` list is not checked.
         for action in action_ids.values() {
-            Self::check_undeclared_in_type(&action.context, entity_types, &mut undeclared_e);
+            Self::check_undeclared_in_type(&action.context, all_defs, &mut undeclared_e);
 
             for p_entity in action.applies_to_principals() {
                 if !entity_types.contains_key(p_entity) {
@@ -918,13 +926,13 @@ impl ValidatorSchema {
     /// `undeclared_types` set.
     fn check_undeclared_in_type(
         ty: &Type,
-        entity_types: &HashMap<EntityType, ValidatorEntityType>,
+        all_defs: &AllDefs,
         undeclared_types: &mut BTreeSet<EntityType>,
     ) {
         match ty {
             Type::EntityOrRecord(EntityRecordKind::Entity(lub)) => {
                 for name in lub.iter() {
-                    if !entity_types.contains_key(name) {
+                    if !all_defs.is_defined_as_entity(name.as_ref().as_ref()) {
                         undeclared_types.insert(name.clone());
                     }
                 }
@@ -932,17 +940,13 @@ impl ValidatorSchema {
 
             Type::EntityOrRecord(EntityRecordKind::Record { attrs, .. }) => {
                 for (_, attr_ty) in attrs.iter() {
-                    Self::check_undeclared_in_type(
-                        &attr_ty.attr_type,
-                        entity_types,
-                        undeclared_types,
-                    );
+                    Self::check_undeclared_in_type(&attr_ty.attr_type, all_defs, undeclared_types);
                 }
             }
 
             Type::Set {
                 element_type: Some(element_type),
-            } => Self::check_undeclared_in_type(element_type, entity_types, undeclared_types),
+            } => Self::check_undeclared_in_type(element_type, all_defs, undeclared_types),
 
             _ => (),
         }
@@ -1276,6 +1280,11 @@ impl AllDefs {
         self.action_defs.contains(euid)
     }
 
+    /// Mark the given [`InternalName`] as defined as an entity type
+    pub fn mark_as_defined_as_entity_type(&mut self, name: InternalName) {
+        self.entity_defs.insert(name);
+    }
+
     /// Mark the given [`InternalName`] as defined as a common type
     pub fn mark_as_defined_as_common_type(&mut self, name: InternalName) {
         self.common_defs.insert(name);
diff --git a/cedar-policy/CHANGELOG.md b/cedar-policy/CHANGELOG.md
index d808ce44..0fc3898b 100644
--- a/cedar-policy/CHANGELOG.md
+++ b/cedar-policy/CHANGELOG.md
@@ -33,6 +33,8 @@ Cedar Language Version: TBD
 
 ### Changed
 
+- Allow entity attributes and tags to contain Action-typed entities, and pass
+  policy/entity/request validation (#1652)
 - Changed experimental `entity-manifest` function `compute_entity_manifest` to
   accept an `&Validator` instead of `&Schema`. Callers can construct a `Validator`
   from a schema with `Validator::new` afterwhich a reference to the original


Test patch:
diff --git a/cedar-policy-core/src/validator/typecheck/test/namespace.rs b/cedar-policy-core/src/validator/typecheck/test/namespace.rs
index dfc045c0..40731a4a 100644
--- a/cedar-policy-core/src/validator/typecheck/test/namespace.rs
+++ b/cedar-policy-core/src/validator/typecheck/test/namespace.rs
@@ -30,9 +30,10 @@ use crate::{
 };
 
 use super::test_utils::{
-    assert_exactly_one_diagnostic, assert_policy_typecheck_fails, assert_policy_typecheck_warns,
-    assert_policy_typechecks, assert_sets_equal, assert_typecheck_fails, assert_typechecks,
-    expr_id_placeholder, get_loc,
+    assert_entities_do_not_validate, assert_entities_validate, assert_exactly_one_diagnostic,
+    assert_policy_typecheck_fails, assert_policy_typecheck_warns, assert_policy_typechecks,
+    assert_request_does_not_validate, assert_request_validates, assert_sets_equal,
+    assert_typecheck_fails, assert_typechecks, expr_id_placeholder, get_loc,
 };
 use crate::validator::{
     diagnostics::ValidationError,
@@ -716,3 +717,480 @@ fn multi_namespace_action_group_cycle() {
         Err(SchemaError::CycleInActionHierarchy(_))
     )
 }
+
+#[test]
+fn actions_in_attributes() {
+    // inspired by the example in #304
+    let (schema, _) = json_schema::Fragment::from_cedarschema_str(
+        r#"
+        entity Role {
+            actions: Set<Action>
+        };
+        entity User {
+            role: Role
+        };
+        entity Session;
+        action getSession, deleteSession appliesTo {
+            principal: [User],
+            resource: [Session],
+        };
+    "#,
+        Extensions::all_available(),
+    )
+    .unwrap();
+    assert_policy_typechecks(
+        schema.clone(),
+        parse_policy(
+            None,
+            r#"
+            permit(principal, action, resource) when {
+                action in principal.role.actions
+            };
+            "#,
+        )
+        .unwrap(),
+    );
+    assert_entities_validate(
+        json!([
+            {
+                "uid": {
+                    "type": "User",
+                    "id": "user1",
+                },
+                "parents": [],
+                "attrs": {
+                    "role": { "type": "Role", "id": "admin" }
+                }
+            },
+            {
+                "uid": {
+                    "type": "Role",
+                    "id": "admin"
+                },
+                "parents": [],
+                "attrs": {
+                    "actions": [
+                        { "type": "Action", "id": "getSession" },
+                        { "type": "Action", "id": "deleteSession" },
+                    ]
+                }
+            },
+        ]),
+        schema.clone(),
+    );
+    assert_request_validates(
+        "User::\"user1\"",
+        "Action::\"getSession\"",
+        "Session::\"1\"",
+        json!({}),
+        schema,
+    );
+
+    // now using namespaced actions
+    let (schema, _) = json_schema::Fragment::from_cedarschema_str(
+        r#"
+        namespace A {
+            entity Role {
+                actions: Set<Action>  // implicitly Set<A::Action>
+            };
+            entity User {
+                role: Role  // implicitly A::Role
+            };
+            entity Session;
+            action getSession, deleteSession appliesTo {
+                principal: [User],  // implicitly A::User
+                resource: [Session],  // implicitly A::Session
+            };
+        }
+        namespace B {
+            entity Role {
+                actions: Set<Action>,  // implicily Set<B::Action>
+                a_actions: Set<A::Action>,
+                b_actions: Set<B::Action>,
+            };
+            entity User {
+                role: Role,  // implicitly B::Role
+                a_role: A::Role,
+                b_role: B::Role,
+            };
+            entity Session;
+            action getSession, deleteSession appliesTo {
+                principal: [User],  // implicitly B::User
+                resource: [Session],  // implicitly B::Session
+            };
+        }
+        action outer appliesTo {
+            principal: [A::User],
+            resource: [B::Session],
+        };
+        entity Tagged tags Action;
+    "#,
+        Extensions::all_available(),
+    )
+    .unwrap();
+    // this policy should typecheck for all possible actions
+    assert_policy_typechecks(
+        schema.clone(),
+        parse_policy(
+            None,
+            r#"
+            permit(principal, action, resource) when {
+                action in principal.role.actions
+            };
+            "#,
+        )
+        .unwrap(),
+    );
+    assert_entities_validate(
+        json!([
+            {
+                "uid": {
+                    "type": "A::User",
+                    "id": "user1",
+                },
+                "parents": [],
+                "attrs": {
+                    "role": { "type": "A::Role", "id": "admin" }
+                }
+            },
+            {
+                "uid": {
+                    "type": "B::User",
+                    "id": "user1",
+                },
+                "parents": [],
+                "attrs": {
+                    "role": { "type": "B::Role", "id": "admin" },
+                    "a_role": { "type": "A::Role", "id": "admin" },
+                    "b_role": { "type": "B::Role", "id": "admin" },
+                }
+            }
+        ]),
+        schema.clone(),
+    );
+    assert_entities_validate(
+        json!([
+            {
+                "uid": {
+                    "type": "A::Role",
+                    "id": "admin",
+                },
+                "parents": [],
+                "attrs": {
+                    "actions": [
+                        { "type": "A::Action", "id": "getSession" },
+                        { "type": "A::Action", "id": "deleteSession" },
+                    ]
+                }
+            },
+            {
+                "uid": {
+                    "type": "B::Role",
+                    "id": "admin",
+                },
+                "parents": [],
+                "attrs": {
+                    "actions": [
+                        { "type": "B::Action", "id": "getSession" },
+                        { "type": "B::Action", "id": "deleteSession" },
+                    ],
+                    "a_actions": [
+                        { "type": "A::Action", "id": "getSession" },
+                        { "type": "A::Action", "id": "deleteSession" },
+                    ],
+                    "b_actions": [
+                        { "type": "B::Action", "id": "getSession" },
+                        { "type": "B::Action", "id": "deleteSession" },
+                    ],
+                }
+            },
+        ]),
+        schema.clone(),
+    );
+    assert_entities_do_not_validate(
+        json!([
+            {
+                "uid": {
+                    "type": "B::User",
+                    "id": "user1",
+                },
+                "parents": [],
+                "attrs": {
+                    // needs to be a B::Role, not an A::Role
+                    "role": { "type": "A::Role", "id": "admin" },
+                    "a_role": { "type": "A::Role", "id": "admin" },
+                    "b_role": { "type": "B::Role", "id": "admin" },
+                }
+            }
+        ]),
+        schema.clone(),
+    );
+    assert_entities_do_not_validate(
+        json!([
+            {
+                "uid": {
+                    "type": "B::Role",
+                    "id": "get",
+                },
+                "parents": [],
+                "attrs": {
+                    "actions": [
+                        { "type": "B::Action", "id": "getSession" },
+                    ],
+                    "a_actions": [
+                        // cannot contain a B::Action
+                        { "type": "B::Action", "id": "getSession" },
+                    ],
+                    "b_actions": [
+                        { "type": "B::Action", "id": "getSession" },
+                    ],
+                }
+            }
+        ]),
+        schema.clone(),
+    );
+    assert_entities_do_not_validate(
+        json!([
+            {
+                "uid": {
+                    "type": "B::Role",
+                    "id": "get",
+                },
+                "parents": [],
+                "attrs": {
+                    "actions": [
+                        { "type": "B::Action", "id": "getSession" },
+                    ],
+                    "a_actions": [
+                        // cannot contain Action::"outer"
+                        { "type": "Action", "id": "outer" },
+                    ],
+                    "b_actions": [
+                        { "type": "B::Action", "id": "getSession" },
+                    ],
+                }
+            }
+        ]),
+        schema.clone(),
+    );
+    assert_entities_do_not_validate(
+        json!([
+            {
+                "uid": {
+                    "type": "A::Role",
+                    "id": "get",
+                },
+                "parents": [],
+                "attrs": {
+                    "actions": [
+                        // cannot contain a B::Action
+                        { "type": "B::Action", "id": "getSession" },
+                    ],
+                }
+            }
+        ]),
+        schema.clone(),
+    );
+    assert_entities_do_not_validate(
+        json!([
+            {
+                "uid": {
+                    "type": "A::Role",
+                    "id": "get",
+                },
+                "parents": [],
+                "attrs": {
+                    "actions": [
+                        // cannot contain Action::"outer"
+                        { "type": "Action", "id": "outer" },
+                    ],
+                }
+            }
+        ]),
+        schema.clone(),
+    );
+    assert_entities_do_not_validate(
+        json!([
+            {
+                "uid": {
+                    "type": "A::Role",
+                    "id": "get",
+                },
+                "parents": [],
+                "attrs": {
+                    "actions": [
+                        // cannot contain A::Action::"doesnotexist"
+                        { "type": "A::Action", "id": "doesnotexist" },
+                    ],
+                }
+            }
+        ]),
+        schema.clone(),
+    );
+    assert_entities_validate(
+        json!([
+            {
+                "uid": {
+                    "type": "Tagged",
+                    "id": "tagged",
+                },
+                "parents": [],
+                "attrs": {},
+                "tags": {
+                    "tag1": { "type": "Action", "id": "outer" },
+                }
+            }
+        ]),
+        schema.clone(),
+    );
+    assert_entities_do_not_validate(
+        json!([
+            {
+                "uid": {
+                    "type": "Tagged",
+                    "id": "tagged",
+                },
+                "parents": [],
+                "attrs": {},
+                "tags": {
+                    // cannot contain an A::Action
+                    "tag1": { "type": "A::Action", "id": "getSession" },
+                }
+            }
+        ]),
+        schema.clone(),
+    );
+    assert_entities_do_not_validate(
+        json!([
+            {
+                "uid": {
+                    "type": "Tagged",
+                    "id": "tagged",
+                },
+                "parents": [],
+                "attrs": {},
+                "tags": {
+                    // cannot contain Action::"doesnotexist"
+                    "tag1": { "type": "Action", "id": "doesnotexist" },
+                }
+            }
+        ]),
+        schema.clone(),
+    );
+    assert_request_validates(
+        "A::User::\"user1\"",
+        "A::Action::\"getSession\"",
+        "A::Session::\"1\"",
+        json!({}),
+        schema.clone(),
+    );
+    assert_request_validates(
+        "B::User::\"user1\"",
+        "B::Action::\"getSession\"",
+        "B::Session::\"1\"",
+        json!({}),
+        schema.clone(),
+    );
+    assert_request_does_not_validate(
+        "B::User::\"user1\"",
+        "A::Action::\"getSession\"",
+        "A::Session::\"1\"",
+        json!({}),
+        schema.clone(),
+    );
+    assert_request_does_not_validate(
+        "A::User::\"user1\"",
+        "A::Action::\"getSession\"",
+        "B::Session::\"1\"",
+        json!({}),
+        schema.clone(),
+    );
+    assert_request_does_not_validate(
+        "A::User::\"user1\"",
+        "B::Action::\"getSession\"",
+        "B::Session::\"1\"",
+        json!({}),
+        schema.clone(),
+    );
+    assert_request_does_not_validate(
+        "B::User::\"user1\"",
+        "B::Action::\"getSession\"",
+        "A::Session::\"1\"",
+        json!({}),
+        schema.clone(),
+    );
+    assert_request_validates(
+        "A::User::\"user1\"",
+        "Action::\"outer\"",
+        "B::Session::\"1\"",
+        json!({}),
+        schema.clone(),
+    );
+    assert_request_does_not_validate(
+        "B::User::\"user1\"",
+        "Action::\"outer\"",
+        "B::Session::\"1\"",
+        json!({}),
+        schema,
+    );
+
+    // In the above example, `Action` always referred to the `Action` type in
+    // the current namespace.
+    // We also want to support referring to the un-namespaced `Action` type, at
+    // least if there are no `action` declarations in the current namespace.
+    let (schema, _) = json_schema::Fragment::from_cedarschema_str(
+        r#"
+        namespace A {
+            entity Role {
+                actions: Set<Action>  // needs to be interpreted as un-namespaced `Action`, not `A::Action`
+            };
+            entity User {
+                role: Role  // implicitly A::Role
+            };
+            entity Session;
+        }
+        action getSession, deleteSession appliesTo {
+            principal: [A::User],
+            resource: [A::Session],
+        };
+        "#,
+        Extensions::all_available(),
+    ).unwrap();
+    assert_policy_typechecks(
+        schema.clone(),
+        parse_policy(
+            None,
+            r#"
+            permit(principal, action, resource) when {
+                action in principal.role.actions
+            };
+            "#,
+        )
+        .unwrap(),
+    );
+    assert_entities_validate(
+        json!([
+            {
+                "uid": {
+                    "type": "A::Role",
+                    "id": "admin",
+                },
+                "parents": [],
+                "attrs": {
+                    "actions": [
+                        { "type": "Action", "id": "getSession" },
+                        { "type": "Action", "id": "deleteSession" },
+                    ]
+                }
+            }
+        ]),
+        schema.clone(),
+    );
+    assert_request_validates(
+        "A::User::\"user1\"",
+        "Action::\"getSession\"",
+        "A::Session::\"1\"",
+        json!({}),
+        schema,
+    );
+}
diff --git a/cedar-policy-core/src/validator/typecheck/test/test_utils.rs b/cedar-policy-core/src/validator/typecheck/test/test_utils.rs
index 53fca45d..c332f21f 100644
--- a/cedar-policy-core/src/validator/typecheck/test/test_utils.rs
+++ b/cedar-policy-core/src/validator/typecheck/test/test_utils.rs
@@ -21,7 +21,8 @@ use cool_asserts::assert_matches;
 use itertools::Itertools;
 use std::{collections::HashSet, hash::Hash, sync::Arc};
 
-use crate::ast::{EntityUID, Expr, PolicyID, Template, ACTION_ENTITY_TYPE};
+use crate::ast::{Context, EntityUID, Expr, PolicyID, Request, Template, ACTION_ENTITY_TYPE};
+use crate::entities::{err::EntitiesError, Entities, EntityJsonParser, TCComputation};
 use crate::extensions::Extensions;
 use crate::parser::{IntoMaybeLoc, Loc, MaybeLoc};
 
@@ -30,8 +31,8 @@ use crate::validator::{
     typecheck::{SingleEnvTypechecker, TypecheckAnswer, Typechecker},
     types::{CapabilitySet, OpenTag, RequestEnv, Type},
     validation_errors::UnexpectedTypeHelp,
-    NamespaceDefinitionWithActionAttributes, RawName, ValidationError, ValidationMode,
-    ValidationWarning, ValidatorSchema,
+    CoreSchema, NamespaceDefinitionWithActionAttributes, RawName, RequestValidationError,
+    ValidationError, ValidationMode, ValidationWarning, ValidatorSchema,
 };
 
 use similar_asserts::assert_eq;
@@ -157,6 +158,7 @@ impl SchemaProvider for ValidatorSchema {
 }
 
 impl SchemaProvider for json_schema::Fragment<RawName> {
+    #[track_caller]
     fn schema(self) -> ValidatorSchema {
         self.try_into()
             .unwrap_or_else(|e| panic!("failed to construct schema: {:?}", miette::Report::new(e)))
@@ -164,6 +166,7 @@ impl SchemaProvider for json_schema::Fragment<RawName> {
 }
 
 impl SchemaProvider for json_schema::NamespaceDefinition<RawName> {
+    #[track_caller]
     fn schema(self) -> ValidatorSchema {
         self.try_into()
             .unwrap_or_else(|e| panic!("failed to construct schema: {:?}", miette::Report::new(e)))
@@ -171,6 +174,7 @@ impl SchemaProvider for json_schema::NamespaceDefinition<RawName> {
 }
 
 impl SchemaProvider for NamespaceDefinitionWithActionAttributes<RawName> {
+    #[track_caller]
     fn schema(self) -> ValidatorSchema {
         self.try_into()
             .unwrap_or_else(|e| panic!("failed to construct schema: {:?}", miette::Report::new(e)))
@@ -178,6 +182,7 @@ impl SchemaProvider for NamespaceDefinitionWithActionAttributes<RawName> {
 }
 
 impl SchemaProvider for &str {
+    #[track_caller]
     fn schema(self) -> ValidatorSchema {
         ValidatorSchema::from_cedarschema_str(self, Extensions::all_available())
             .unwrap_or_else(|e| panic!("failed to construct schema: {:?}", miette::Report::new(e)))
@@ -413,6 +418,83 @@ pub(crate) fn assert_typecheck_fails_empty_schema_without_type(
     assert_typecheck_fails(empty_schema_file(), expr, None)
 }
 
+/// Assert that the given entities (as a JSON list of entity objects) validate
+/// with the given schema.
+#[track_caller]
+pub(crate) fn assert_entities_validate(
+    entities: serde_json::Value,
+    schema: impl SchemaProvider,
+) -> Entities {
+    let schema = schema.schema();
+    let coreschema = CoreSchema::new(&schema);
+    let ejsonparser = EntityJsonParser::new(
+        Some(&coreschema),
+        Extensions::all_available(),
+        TCComputation::ComputeNow,
+    );
+    ejsonparser.from_json_value(entities).unwrap()
+}
+
+/// Assert that the given entities (as a JSON list of entity objects) do not
+/// validate with the given schema.
+#[track_caller]
+pub(crate) fn assert_entities_do_not_validate(
+    entities: serde_json::Value,
+    schema: impl SchemaProvider,
+) -> EntitiesError {
+    let schema = schema.schema();
+    let coreschema = CoreSchema::new(&schema);
+    let ejsonparser = EntityJsonParser::new(
+        Some(&coreschema),
+        Extensions::all_available(),
+        TCComputation::ComputeNow,
+    );
+    ejsonparser.from_json_value(entities).unwrap_err()
+}
+
+/// Assert that the given request (PARC) validates with the given schema.
+#[track_caller]
+pub(crate) fn assert_request_validates(
+    principal_uid: &str,
+    action_uid: &str,
+    resource_uid: &str,
+    context: serde_json::Value,
+    schema: impl SchemaProvider,
+) -> Request {
+    let schema = schema.schema();
+    Request::new(
+        (principal_uid.parse().unwrap(), None),
+        (action_uid.parse().unwrap(), None),
+        (resource_uid.parse().unwrap(), None),
+        Context::from_json_value(context).unwrap(),
+        Some(&schema),
+        Extensions::all_available(),
+    )
+    .unwrap()
+}
+
+/// Assert that the given request (PARC) does not validate with the given
+/// schema.
+#[track_caller]
+pub(crate) fn assert_request_does_not_validate(
+    principal_uid: &str,
+    action_uid: &str,
+    resource_uid: &str,
+    context: serde_json::Value,
+    schema: impl SchemaProvider,
+) -> RequestValidationError {
+    let schema = schema.schema();
+    Request::new(
+        (principal_uid.parse().unwrap(), None),
+        (action_uid.parse().unwrap(), None),
+        (resource_uid.parse().unwrap(), None),
+        Context::from_json_value(context).unwrap(),
+        Some(&schema),
+        Extensions::all_available(),
+    )
+    .unwrap_err()
+}
+
 /// Assert that the given `HashSet` has exactly one `Diagnostic`. Return it.
 /// If there are more than one, panic and display all the `Diagnostic`s in pretty format.
 #[track_caller]