Repository: hashintel/hash
Pull request #6880: H-4345: Prepare evaluation of policies when calling from API
Author: login: TimDiekmann
Labels: name: area/deps
description: Relates to third-party dependencies (area)
name: area/apps > hash*
description: Affects HASH (a `hash-*` app)
name: area/libs
description: Relates to first-party libraries/crates/packages (area)
name: type/eng > backend
description: Owned by the @backend team
name: area/tests
description: New or updated tests
name: area/apps
name: area/apps > hash-graph
Created: 2025-04-07T17:09:17Z | Merged: 2025-04-09T07:34:10Z

## 🌟 What is the purpose of this PR?

To allow the usage of the new policies we need to bubble up the interface to the Graph API layer. Also, it has to be possible to create a principal context which can be used in Cedar.

## 🔍 What does this change?

- Implement a `PrincipalStore`
- Expose `PrincipalStore` up to the `rest` module (but with commented out functionality to not interfere with the current permission system)
- Implement creation of principal context creation
- Greatly simplify handling of actions by creating a list of parents in Rust.

## Pre-Merge Checklist 🚀

### 🚢 Has this modified a publishable library?

<!-- Confirm you have taken the necessary action to record a changeset or publish a change, as appropriate -->
<!-- Tick AT LEAST ONE box and delete the rest. Do not delete this section! see libs/README.md for info on publishing -->

This PR:

- [x] does not modify any publishable blocks or libraries, or modifications do not need publishing

### 📜 Does this require a change to the docs?

<!-- If this adds a user facing feature or modifies how an existing feature is used, it likely needs a docs change. -->
<!-- Tick ONE box and delete the rest. Do not delete this section! -->

The changes in this PR:

- [x] are internal and do not require a docs change

### 🕸️ Does this require a change to the Turbo Graph?

<!-- If this adds or moves an existing package, modifies `scripts` in a `package.json`, it likely needs a turbo graph change. -->
<!-- Tick ONE box and delete the rest. Do not delete this section! -->

The changes in this PR:

- [x] do not affect the execution graph

## 🛡 What tests cover this?

- Previous tests were adjusted to use the new `create_web` function signature
- A seeding logic was added to tests
- A new test was added to verify consistency of the Cedar schema and the Rust implementation


Diff:
diff --git a/Cargo.lock b/Cargo.lock
index 7b8d74c73f1..41d12bd4fad 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2317,6 +2317,26 @@ dependencies = [
  "syn 2.0.100",
 ]
 
+[[package]]
+name = "enum-iterator"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c280b9e6b3ae19e152d8e31cf47f18389781e119d4013a2a2bb0180e5facc635"
+dependencies = [
+ "enum-iterator-derive",
+]
+
+[[package]]
+name = "enum-iterator-derive"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1ab991c1362ac86c61ab6f556cff143daa22e5a15e4e189df818b2fd19fe65b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.100",
+]
+
 [[package]]
 name = "enum-ordinalize"
 version = "4.3.0"
@@ -3231,6 +3251,7 @@ dependencies = [
  "cedar-policy-validator",
  "derive-where",
  "derive_more 2.0.1",
+ "enum-iterator",
  "error-stack",
  "futures",
  "futures-core",
@@ -3247,6 +3268,7 @@ dependencies = [
  "tokio",
  "tokio-util",
  "tracing",
+ "trait-variant",
  "type-system",
  "utoipa",
  "uuid",
diff --git a/Cargo.toml b/Cargo.toml
index 487fcccb7c2..0b61701fa88 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -130,6 +130,7 @@ dotenv-flow              = { version = "=0.16.2", default-features = false }
 ecow                     = { version = "=0.2.4", default-features = false }
 either                   = { version = "=1.15.0", default-features = false }
 email_address            = { version = "=0.2.9", default-features = false }
+enum-iterator            = { version = "=2.1.0", default-features = false }
 enumflags2               = { version = "=0.7.11", default-features = false }
 erased-serde             = { version = "=0.4.6", default-features = false }
 expect-test              = { version = "=1.5.1", default-features = false }
diff --git a/apps/hash-graph/src/subcommand/server.rs b/apps/hash-graph/src/subcommand/server.rs
index 6f06ce7ec9b..91f03a0dbb2 100644
--- a/apps/hash-graph/src/subcommand/server.rs
+++ b/apps/hash-graph/src/subcommand/server.rs
@@ -20,6 +20,7 @@ use hash_graph_api::{
 use hash_graph_authorization::{
     AuthorizationApi as _, AuthorizationApiPool, NoAuthorization,
     backend::{SpiceDbOpenApi, ZanzibarBackend as _},
+    policies::store::PrincipalStore,
     zanzibar::ZanzibarClient,
 };
 use hash_graph_postgres_store::store::{
@@ -190,6 +191,7 @@ fn server_rpc<S, A>(
 where
     S: StorePool + Send + Sync + 'static,
     A: AuthorizationApiPool + Send + Sync + 'static,
+    for<'p, 'a> S::Store<'p, A::Api<'a>>: PrincipalStore,
 {
     let server = Server::new(harpc_server::ServerConfig::default()).change_context(GraphError)?;
 
diff --git a/libs/@local/graph/api/src/rest/mod.rs b/libs/@local/graph/api/src/rest/mod.rs
index 060647eaad9..4f82c59c1fe 100644
--- a/libs/@local/graph/api/src/rest/mod.rs
+++ b/libs/@local/graph/api/src/rest/mod.rs
@@ -31,6 +31,7 @@ use futures::{SinkExt as _, channel::mpsc::Sender};
 use hash_codec::numeric::Real;
 use hash_graph_authorization::{
     AuthorizationApiPool,
+    policies::store::PrincipalStore,
     schema::{
         AccountGroupPermission, DataTypePermission, EntityPermission, EntityTypePermission,
         PropertyTypePermission,
@@ -196,7 +197,7 @@ fn api_resources<S, A>() -> Vec<Router>
 where
     S: StorePool + Send + Sync + 'static,
     A: AuthorizationApiPool + Send + Sync + 'static,
-    for<'pool> S::Store<'pool, A::Api<'pool>>: RestApiStore,
+    for<'pool, 'api> S::Store<'pool, A::Api<'api>>: RestApiStore + PrincipalStore,
 {
     vec![
         account::AccountResource::routes::<S, A>(),
@@ -215,7 +216,7 @@ fn api_documentation() -> Vec<openapi::OpenApi> {
         property_type::PropertyTypeResource::documentation(),
         entity_type::EntityTypeResource::documentation(),
         entity::EntityResource::documentation(),
-        web::WebResource::documentation(),
+        web::WebResource::openapi(),
     ]
 }
 
@@ -365,7 +366,7 @@ pub fn rest_api_router<S, A>(dependencies: RestRouterDependencies<S, A>) -> Rout
 where
     S: StorePool + Send + Sync + 'static,
     A: AuthorizationApiPool + Send + Sync + 'static,
-    for<'pool> S::Store<'pool, A::Api<'pool>>: RestApiStore,
+    for<'p, 'a> S::Store<'p, A::Api<'a>>: RestApiStore + PrincipalStore,
 {
     // All api resources are merged together into a super-router.
     let merged_routes = api_resources::<S, A>()
diff --git a/libs/@local/graph/api/src/rest/web.rs b/libs/@local/graph/api/src/rest/web.rs
index 4b9a8ac9949..706e5344d0a 100644
--- a/libs/@local/graph/api/src/rest/web.rs
+++ b/libs/@local/graph/api/src/rest/web.rs
@@ -13,6 +13,7 @@ use error_stack::Report;
 use hash_graph_authorization::{
     AuthorizationApi as _, AuthorizationApiPool,
     backend::{ModifyRelationshipOperation, PermissionAssertion},
+    policies::store::PrincipalStore,
     schema::{
         WebDataTypeViewerSubject, WebEntityCreatorSubject, WebEntityEditorSubject,
         WebEntityTypeViewerSubject, WebEntityViewerSubject, WebOwnerSubject, WebPermission,
@@ -29,7 +30,6 @@ use serde::Deserialize;
 use type_system::web::OwnedById;
 use utoipa::{OpenApi, ToSchema};
 
-use super::api_resource::RoutedResource;
 use crate::rest::{AuthenticatedUserHeader, PermissionResponse, status::report_to_response};
 
 #[derive(OpenApi)]
@@ -62,12 +62,13 @@ use crate::rest::{AuthenticatedUserHeader, PermissionResponse, status::report_to
 )]
 pub(crate) struct WebResource;
 
-impl RoutedResource for WebResource {
+impl WebResource {
     /// Create routes for interacting with accounts.
-    fn routes<S, A>() -> Router
+    pub(crate) fn routes<S, A>() -> Router
     where
         S: StorePool + Send + Sync + 'static,
         A: AuthorizationApiPool + Send + Sync + 'static,
+        for<'p, 'a> S::Store<'p, A::Api<'a>>: PrincipalStore,
     {
         Router::new().nest(
             "/webs",
@@ -118,6 +119,7 @@ async fn create_web<S, A>(
 where
     S: StorePool + Send + Sync,
     A: AuthorizationApiPool + Send + Sync,
+    for<'p, 'a> S::Store<'p, A::Api<'a>>: PrincipalStore,
 {
     let authorization_api = authorization_api_pool.acquire().await.map_err(|error| {
         tracing::error!(?error, "Could not acquire access to the authorization API");
@@ -132,6 +134,21 @@ where
             StatusCode::INTERNAL_SERVER_ERROR
         })?;
 
+    // TODO: Uncomment this once we use the new principals
+    // store
+    //     .create_web(
+    //         ActorId::User(UserId::new(actor_id)),
+    //         CreateWebParameter {
+    //             id: Some(params.owned_by_id.into_uuid()),
+    //         },
+    //     )
+    //     .await
+    //     .map_err(|report| {
+    //         tracing::error!(error=?report, "Could not create web id");
+
+    //         StatusCode::INTERNAL_SERVER_ERROR
+    //     })?;
+
     store
         .insert_web_id(actor_id, params)
         .await
diff --git a/libs/@local/graph/authorization/Cargo.toml b/libs/@local/graph/authorization/Cargo.toml
index 5d1cc91ee12..7a4ca6cd580 100644
--- a/libs/@local/graph/authorization/Cargo.toml
+++ b/libs/@local/graph/authorization/Cargo.toml
@@ -25,6 +25,7 @@ cedar-policy-core      = { workspace = true }
 cedar-policy-validator = { workspace = true }
 derive-where           = { workspace = true }
 derive_more            = { workspace = true, features = ["display", "error", "from"] }
+enum-iterator          = { workspace = true }
 futures                = { workspace = true }
 serde                  = { workspace = true, features = ["derive", "unstable"] }
 serde_json             = { workspace = true }
@@ -34,6 +35,7 @@ smol_str               = { workspace = true }
 tokio                  = { workspace = true }
 tokio-util             = { workspace = true, features = ["io"] }
 tracing                = { workspace = true, features = ["attributes"] }
+trait-variant          = { workspace = true }
 utoipa                 = { workspace = true, optional = true }
 uuid                   = { workspace = true, features = ["v4"] }
 
diff --git a/libs/@local/graph/authorization/schemas/policies.cedarschema b/libs/@local/graph/authorization/schemas/policies.cedarschema
index f51957aed3e..b2713e0432e 100644
--- a/libs/@local/graph/authorization/schemas/policies.cedarschema
+++ b/libs/@local/graph/authorization/schemas/policies.cedarschema
@@ -20,12 +20,29 @@ namespace HASH {
   entity User, Machine, Ai in [HASH::Web::Role, HASH::Subteam::Role] {
   };
 
-  action create, view, update appliesTo {
+  action all;
+
+  action createWeb in [all] appliesTo {
+    principal: [User, Machine, Ai],
+    resource: [Web],
+  };
+
+  action create, view, update in [all] appliesTo {
     principal: [User, Machine, Ai],
     resource: [Entity, EntityType],
   };
 
-  action instantiate appliesTo {
+  action viewEntity in [view] appliesTo {
+    principal: [User, Machine, Ai],
+    resource: [Entity],
+  };
+
+  action viewEntityType in [view] appliesTo {
+    principal: [User, Machine, Ai],
+    resource: [EntityType],
+  };
+
+  action instantiate in [all] appliesTo {
     principal: [User, Machine, Ai],
     resource: [EntityType],
   };
diff --git a/libs/@local/graph/authorization/src/policies/action/mod.rs b/libs/@local/graph/authorization/src/policies/action/mod.rs
index 612ea6f9a9c..b2e8b99a232 100644
--- a/libs/@local/graph/authorization/src/policies/action/mod.rs
+++ b/libs/@local/graph/authorization/src/policies/action/mod.rs
@@ -1,7 +1,7 @@
 use alloc::sync::Arc;
 #[cfg(feature = "postgres")]
 use core::error::Error;
-use core::{fmt, str::FromStr};
+use core::{fmt, iter, str::FromStr};
 use std::sync::LazyLock;
 
 #[cfg(feature = "postgres")]
@@ -13,22 +13,61 @@ use serde::Serialize as _;
 use crate::policies::cedar::CedarEntityId;
 
 #[derive(
-    Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
+    Debug,
+    Copy,
+    Clone,
+    PartialEq,
+    Eq,
+    PartialOrd,
+    Ord,
+    Hash,
+    serde::Serialize,
+    serde::Deserialize,
+    enum_iterator::Sequence,
 )]
 #[serde(rename_all = "camelCase")]
 pub enum ActionName {
     All,
 
     Create,
+    CreateWeb,
 
     View,
     ViewEntity,
+    ViewEntityType,
 
     Update,
 
     Instantiate,
 }
 
+impl ActionName {
+    #[must_use]
+    pub const fn parent(self) -> Option<Self> {
+        match self {
+            Self::All => None,
+            Self::Create | Self::CreateWeb | Self::View | Self::Update | Self::Instantiate => {
+                Some(Self::All)
+            }
+            Self::ViewEntity | Self::ViewEntityType => Some(Self::View),
+        }
+    }
+
+    pub fn parents(self) -> impl Iterator<Item = Self> {
+        iter::successors(self.parent(), |&action| action.parent())
+    }
+
+    #[must_use]
+    pub fn is_parent_of(self, other: Self) -> bool {
+        other.parents().any(|parent| parent == self)
+    }
+
+    #[must_use]
+    pub fn is_child_of(self, other: Self) -> bool {
+        self.parents().any(|parent| parent == other)
+    }
+}
+
 #[cfg(feature = "postgres")]
 impl<'a> postgres_types::FromSql<'a> for ActionName {
     postgres_types::accepts!(TEXT);
@@ -103,6 +142,7 @@ pub enum InvalidActionConstraint {
 #[expect(clippy::panic_in_result_fn, reason = "Assertions in test are expected")]
 mod tests {
     use core::error::Error;
+    use std::collections::HashMap;
 
     use indoc::formatdoc;
     use pretty_assertions::assert_eq;
@@ -110,7 +150,10 @@ mod tests {
     use uuid::Uuid;
 
     use crate::{
-        policies::{ActionName, Effect, Policy, PolicyId, tests::check_policy},
+        policies::{
+            ActionName, Effect, Policy, PolicyId, cedar::CedarEntityId as _, tests::check_policy,
+            validation,
+        },
         test_utils::check_serialization,
     };
 
@@ -195,4 +238,132 @@ mod tests {
 
         Ok(())
     }
+
+    /// Validates consistency between the Rust `ActionName` enum hierarchy and the Cedar schema.
+    /// This test ensures that:
+    /// - Each action defined in the Cedar schema can be mapped to a Rust `ActionName`
+    /// - The parent-child relationships defined by `action.parents()` match the hierarchical
+    ///   relationships in the Cedar schema (descendants)
+    #[test]
+    fn action_ids() -> Result<(), Box<dyn Error>> {
+        for action_id in validation::PolicyValidator::schema().action_ids() {
+            let action_name = ActionName::from_euid(action_id.name())?;
+            for descendant_id in action_id.descendants() {
+                let descendant = ActionName::from_euid(descendant_id)?;
+                println!("{action_name} is parent of {descendant}");
+                assert!(
+                    action_name.is_parent_of(descendant),
+                    "{action_name} is not a parent of {descendant}"
+                );
+                assert!(
+                    descendant.is_child_of(action_name),
+                    "{descendant} is not a child of {action_name}"
+                );
+            }
+        }
+
+        Ok(())
+    }
+
+    /// Complements the `action_ids` test by validating consistency in the reverse direction.
+    /// This test ensures that:
+    /// - Every `ActionName` enum variant is present in the Cedar schema
+    /// - For each action, its `parents()` method returns actions that are consistent with the
+    ///   descendants relationship in the Cedar schema
+    /// - Every descendant of an action in the Cedar schema is correctly identified as a child of
+    ///   that action in the Rust code
+    #[test]
+    fn action_names() -> Result<(), Box<dyn Error>> {
+        let action_ids = validation::PolicyValidator::schema()
+            .action_ids()
+            .map(|action_id| ActionName::from_euid(action_id.name()).map(|name| (name, action_id)))
+            .collect::<Result<HashMap<_, _>, _>>()?;
+        for action in enum_iterator::all::<ActionName>() {
+            let action_id = action.to_euid();
+            for parent in action.parents() {
+                assert!(
+                    action_ids[&parent]
+                        .descendants()
+                        .any(|descendant| *descendant == action_id),
+                    "{parent} is not a parent of {action}"
+                );
+            }
+            let action_id = action_ids
+                .get(&action)
+                .unwrap_or_else(|| panic!("Action {action:?} is not present in the schema"));
+            for descendant_id in action_id.descendants() {
+                let descendant = ActionName::from_euid(descendant_id)?;
+                assert!(
+                    descendant.is_child_of(action),
+                    "{descendant} is not a child of {action}"
+                );
+            }
+        }
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_parents_method() {
+        // All has no parents
+        assert_eq!(ActionName::All.parents().collect::<Vec<_>>(), vec![]);
+
+        // First level actions have All as their only parent
+        assert_eq!(
+            ActionName::Create.parents().collect::<Vec<_>>(),
+            vec![ActionName::All]
+        );
+        assert_eq!(
+            ActionName::CreateWeb.parents().collect::<Vec<_>>(),
+            vec![ActionName::All]
+        );
+        assert_eq!(
+            ActionName::View.parents().collect::<Vec<_>>(),
+            vec![ActionName::All]
+        );
+        assert_eq!(
+            ActionName::Update.parents().collect::<Vec<_>>(),
+            vec![ActionName::All]
+        );
+        assert_eq!(
+            ActionName::Instantiate.parents().collect::<Vec<_>>(),
+            vec![ActionName::All]
+        );
+
+        // Second level actions have their direct parent and All as ancestors
+        assert_eq!(
+            ActionName::ViewEntity.parents().collect::<Vec<_>>(),
+            vec![ActionName::View, ActionName::All]
+        );
+        assert_eq!(
+            ActionName::ViewEntityType.parents().collect::<Vec<_>>(),
+            vec![ActionName::View, ActionName::All]
+        );
+    }
+
+    #[test]
+    fn test_is_parent_of_method() {
+        // View is parent of ViewEntity and ViewEntityType
+        assert!(ActionName::View.is_parent_of(ActionName::ViewEntity));
+        assert!(ActionName::View.is_parent_of(ActionName::ViewEntityType));
+
+        // Negative cases
+        assert!(!ActionName::Create.is_parent_of(ActionName::View));
+        assert!(!ActionName::View.is_parent_of(ActionName::Create));
+        assert!(!ActionName::All.is_parent_of(ActionName::All));
+        assert!(!ActionName::ViewEntity.is_parent_of(ActionName::View));
+    }
+
+    #[test]
+    fn test_is_child_of_method() {
+        // ViewEntity and ViewEntityType are children of View
+        assert!(ActionName::ViewEntity.is_child_of(ActionName::View));
+        assert!(ActionName::ViewEntityType.is_child_of(ActionName::View));
+
+        // Negative cases
+        assert!(!ActionName::View.is_child_of(ActionName::Create));
+        assert!(!ActionName::Create.is_child_of(ActionName::View));
+        assert!(!ActionName::All.is_child_of(ActionName::All));
+        assert!(!ActionName::View.is_child_of(ActionName::ViewEntity));
+    }
 }
diff --git a/libs/@local/graph/authorization/src/policies/context.rs b/libs/@local/graph/authorization/src/policies/context.rs
index 449dcdb39c9..592d8fa3b07 100644
--- a/libs/@local/graph/authorization/src/policies/context.rs
+++ b/libs/@local/graph/authorization/src/policies/context.rs
@@ -9,7 +9,7 @@ use error_stack::{Report, ResultExt as _};
 
 use super::{
     PolicyValidator,
-    principal::{Actor, actor::Machine, role::Role},
+    principal::{Actor, role::Role, team::Team},
     resource::{EntityResource, EntityTypeResource},
 };
 
@@ -19,7 +19,7 @@ pub enum ContextError {
     TransitiveClosureError,
 }
 
-#[derive(Default)]
+#[derive(Default, derive_more::Display)]
 pub struct Context {
     entities: Entities,
 }
@@ -43,18 +43,30 @@ pub struct ContextBuilder {
 }
 
 impl ContextBuilder {
-    pub fn add_machine(&mut self, machine: &Machine) {
-        self.entities.push(machine.to_cedar_entity());
-    }
-
+    /// Adds an actor to the context for policy evaluation.
+    ///
+    /// This allows the actor to be identified as a principal during authorization,
+    /// making it available for matching against principal constraints in policies.
     pub fn add_actor(&mut self, actor: &Actor) {
         self.entities.push(actor.to_cedar_entity());
     }
 
+    /// Adds a role to the context for policy evaluation.
+    ///
+    /// This allows policies associated with the role to be considered during authorization,
+    /// enabling role-based access control as part of the Cedar evaluation context.
     pub fn add_role(&mut self, role: &Role) {
         self.entities.push(role.to_cedar_entity());
     }
 
+    /// Adds a team to the context for policy evaluation.
+    ///
+    /// This allows policies associated with the team to be considered during authorization,
+    /// making the team available as a potential principal in the Cedar evaluation context.
+    pub fn add_team(&mut self, team: &Team) {
+        self.entities.push(team.to_cedar_entity());
+    }
+
     pub fn add_entity(&mut self, entity: &EntityResource) {
         self.entities.push(entity.to_cedar_entity());
     }
diff --git a/libs/@local/graph/authorization/src/policies/mod.rs b/libs/@local/graph/authorization/src/policies/mod.rs
index 5f29e3c6951..1faa29d3e1d 100644
--- a/libs/@local/graph/authorization/src/policies/mod.rs
+++ b/libs/@local/graph/authorization/src/policies/mod.rs
@@ -16,7 +16,7 @@ use core::fmt;
 use cedar::CedarEntityId as _;
 use cedar_policy_core::{ast, extensions::Extensions, parser::parse_policy};
 use error_stack::{Report, ResultExt as _, TryReportIteratorExt as _};
-use type_system::{knowledge::entity::id::EntityUuid, provenance::ActorId};
+use type_system::{knowledge::entity::id::EntityUuid, provenance::ActorId, web::OwnedById};
 use uuid::Uuid;
 
 pub(crate) use self::cedar::cedar_resource_type;
@@ -120,6 +120,7 @@ impl RequestContext {
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub enum PartialResourceId<'a> {
+    Web(Option<OwnedById>),
     Entity(Option<EntityUuid>),
     EntityType(Option<Cow<'a, EntityTypeId>>),
 }
@@ -135,6 +136,16 @@ pub struct Request<'a> {
 impl PartialResourceId<'_> {
     fn to_euid(&self) -> ast::EntityUIDEntry {
         match self {
+            Self::Web(web_id) => web_id.as_ref().map_or_else(
+                || ast::EntityUIDEntry::Unknown {
+                    ty: Some((**OwnedById::entity_type()).clone()),
+                    loc: None,
+                },
+                |web_id| ast::EntityUIDEntry::Known {
+                    euid: Arc::new(web_id.to_euid()),
+                    loc: None,
+                },
+            ),
             Self::Entity(entity_uuid) => entity_uuid.as_ref().map_or_else(
                 || ast::EntityUIDEntry::Unknown {
                     ty: Some((**EntityUuid::entity_type()).clone()),
diff --git a/libs/@local/graph/authorization/src/policies/principal/mod.rs b/libs/@local/graph/authorization/src/policies/principal/mod.rs
index e6375690e3b..70e07579279 100644
--- a/libs/@local/graph/authorization/src/policies/principal/mod.rs
+++ b/libs/@local/graph/authorization/src/policies/principal/mod.rs
@@ -10,8 +10,8 @@ use uuid::Uuid;
 
 pub use self::actor::Actor;
 use self::{
-    role::{RoleId, SubteamRoleId, WebRoleId},
-    team::{SubteamId, TeamId},
+    role::{Role, RoleId, SubteamRoleId, WebRoleId},
+    team::{SubteamId, Team, TeamId},
 };
 use super::cedar::CedarEntityId as _;
 
@@ -46,6 +46,13 @@ impl PrincipalId {
     }
 }
 
+#[derive(Debug)]
+pub enum Principal {
+    Actor(Actor),
+    Team(Team),
+    Role(Role),
+}
+
 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
 #[serde(
     tag = "type",
diff --git a/libs/@local/graph/authorization/src/policies/principal/role/mod.rs b/libs/@local/graph/authorization/src/policies/principal/role/mod.rs
index 3af5d1d4b01..7052f990bae 100644
--- a/libs/@local/graph/authorization/src/policies/principal/role/mod.rs
+++ b/libs/@local/graph/authorization/src/policies/principal/role/mod.rs
@@ -8,6 +8,7 @@ pub use self::{
     subteam::{SubteamRole, SubteamRoleId},
     web::{WebRole, WebRoleId},
 };
+use super::team::TeamId;
 use crate::policies::cedar::CedarEntityId as _;
 
 #[derive(
@@ -70,6 +71,14 @@ impl Role {
             Self::Subteam(subteam_role) => subteam_role.to_cedar_entity(),
         }
     }
+
+    #[must_use]
+    pub const fn team_id(&self) -> TeamId {
+        match self {
+            Self::Web(web_role) => TeamId::Web(web_role.web_id),
+            Self::Subteam(subteam_role) => TeamId::Subteam(subteam_role.subteam_id),
+        }
+    }
 }
 
 #[cfg(test)]
diff --git a/libs/@local/graph/authorization/src/policies/principal/team/mod.rs b/libs/@local/graph/authorization/src/policies/principal/team/mod.rs
index a92c34e8fa9..6ba38d08940 100644
--- a/libs/@local/graph/authorization/src/policies/principal/team/mod.rs
+++ b/libs/@local/graph/authorization/src/policies/principal/team/mod.rs
@@ -59,6 +59,14 @@ pub enum Team {
     Subteam(Subteam),
 }
 
+impl Team {
+    pub(crate) fn to_cedar_entity(&self) -> ast::Entity {
+        match self {
+            Self::Web(web) => web.to_cedar_entity(),
+            Self::Subteam(subteam) => subteam.to_cedar_entity(),
+        }
+    }
+}
 #[cfg(test)]
 mod tests {
     use core::error::Error;
diff --git a/libs/@local/graph/authorization/src/policies/principal/team/subteam.rs b/libs/@local/graph/authorization/src/policies/principal/team/subteam.rs
index 838cabc52d5..90037745b08 100644
--- a/libs/@local/graph/authorization/src/policies/principal/team/subteam.rs
+++ b/libs/@local/graph/authorization/src/policies/principal/team/subteam.rs
@@ -1,8 +1,8 @@
 use alloc::sync::Arc;
-use core::str::FromStr as _;
+use core::{iter, str::FromStr as _};
 use std::{collections::HashSet, sync::LazyLock};
 
-use cedar_policy_core::ast;
+use cedar_policy_core::{ast, extensions::Extensions};
 use error_stack::Report;
 use uuid::Uuid;
 
@@ -66,6 +66,19 @@ pub struct Subteam {
     pub roles: HashSet<SubteamRoleId>,
 }
 
+impl Subteam {
+    pub(crate) fn to_cedar_entity(&self) -> ast::Entity {
+        ast::Entity::new(
+            self.id.to_euid(),
+            iter::empty(),
+            self.parents.iter().copied().map(TeamId::to_euid).collect(),
+            iter::empty(),
+            Extensions::none(),
+        )
+        .expect("subteam should be a valid Cedar entity")
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use core::error::Error;
diff --git a/libs/@local/graph/authorization/src/policies/principal/team/web.rs b/libs/@local/graph/authorization/src/policies/principal/team/web.rs
index 2cb2081bc25..a82deb54ac0 100644
--- a/libs/@local/graph/authorization/src/policies/principal/team/web.rs
+++ b/libs/@local/graph/authorization/src/policies/principal/team/web.rs
@@ -1,8 +1,8 @@
 use alloc::sync::Arc;
-use core::str::FromStr as _;
+use core::{iter, str::FromStr as _};
 use std::{collections::HashSet, sync::LazyLock};
 
-use cedar_policy_core::ast;
+use cedar_policy_core::{ast, extensions::Extensions};
 use error_stack::Report;
 use type_system::web::OwnedById;
 use uuid::Uuid;
@@ -33,6 +33,19 @@ pub struct Web {
     pub roles: HashSet<WebRoleId>,
 }
 
+impl Web {
+    pub(crate) fn to_cedar_entity(&self) -> ast::Entity {
+        ast::Entity::new(
+            self.id.to_euid(),
+            iter::empty(),
+            HashSet::new(),
+            iter::empty(),
+            Extensions::none(),
+        )
+        .expect("web should be a valid Cedar entity")
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use core::error::Error;
diff --git a/libs/@local/graph/authorization/src/policies/store/error.rs b/libs/@local/graph/authorization/src/policies/store/error.rs
index d95cb700fa4..7cb9f82ed95 100644
--- a/libs/@local/graph/authorization/src/policies/store/error.rs
+++ b/libs/@local/graph/authorization/src/policies/store/error.rs
@@ -20,6 +20,10 @@ impl Error for ActorCreationError {}
 #[derive(Debug, derive_more::Display)]
 #[display("Could not create web: {_variant}")]
 pub enum WebCreationError {
+    #[display("Web with ID `{web_id}` already exists")]
+    AlreadyExists { web_id: OwnedById },
+    #[display("Permission to create web was denied")]
+    NotAuthorized,
     #[display("Store operation failed")]
     StoreError,
 }
diff --git a/libs/@local/graph/authorization/src/policies/store/mod.rs b/libs/@local/graph/authorization/src/policies/store/mod.rs
index bb42cb8886b..a8645ea8d34 100644
--- a/libs/@local/graph/authorization/src/policies/store/mod.rs
+++ b/libs/@local/graph/authorization/src/policies/store/mod.rs
@@ -51,6 +51,29 @@ pub enum RoleCreationParameter {
     Subteam { web_id: OwnedById, parent: TeamId },
 }
 
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
+pub struct CreateWebParameter {
+    pub id: Option<Uuid>,
+}
+
+#[trait_variant::make(PrincipalStore: Send)]
+pub trait LocalPrincipalStore {
+    /// Creates a new web and returns its ID.
+    ///
+    /// # Errors
+    ///
+    /// - [`AlreadyExists`] if the web already exists
+    /// - [`StoreError`] if the underlying store returns an error
+    ///
+    /// [`AlreadyExists`]: WebCreationError::AlreadyExists
+    /// [`StoreError`]: WebCreationError::StoreError
+    async fn create_web(
+        &mut self,
+        actor: ActorId,
+        parameter: CreateWebParameter,
+    ) -> Result<OwnedById, Report<WebCreationError>>;
+}
+
 pub trait PolicyStore {
     /// Creates a new user within the given web and returns its ID.
     ///
diff --git a/libs/@local/graph/postgres-store/src/permissions/error.rs b/libs/@local/graph/postgres-store/src/permissions/error.rs
index db3ee204dd8..0f1f5aced55 100644
--- a/libs/@local/graph/postgres-store/src/permissions/error.rs
+++ b/libs/@local/graph/postgres-store/src/permissions/error.rs
@@ -10,6 +10,9 @@ pub enum PrincipalError {
     #[display("Principal with ID {id} doesn't exist")]
     PrincipalNotFound { id: PrincipalId },
 
+    #[display("Context builder error")]
+    ContextBuilderError,
+
     #[display("Database error")]
     StoreError,
 }
@@ -25,8 +28,6 @@ pub enum ActionError {
     NotFound { id: ActionName },
     #[display("Action `{id}` has children which must be unregistered first")]
     HasChildren { id: ActionName },
-    #[display("Action `{id}` has no parent")]
-    HasNoParent { id: ActionName },
     #[display("Action `{id}` has a self-cycle")]
     HasSelfCycle { id: ActionName },
 
diff --git a/libs/@local/graph/postgres-store/src/permissions/mod.rs b/libs/@local/graph/postgres-store/src/permissions/mod.rs
index d1ca3ad26bb..3cc8e1b946a 100644
--- a/libs/@local/graph/postgres-store/src/permissions/mod.rs
+++ b/libs/@local/graph/postgres-store/src/permissions/mod.rs
@@ -2,19 +2,16 @@ use std::collections::{HashMap, HashSet};
 
 use error_stack::{Report, ResultExt as _, ensure};
 use futures::TryStreamExt as _;
-use hash_graph_authorization::{
-    AuthorizationApi,
-    policies::{
-        Policy, PolicyId,
-        action::ActionName,
-        principal::{
-            PrincipalConstraint, PrincipalId,
-            actor::{Ai, Machine, User},
-            role::{Role, RoleId, SubteamRole, SubteamRoleId, WebRole, WebRoleId},
-            team::{Subteam, SubteamId, TeamId, Web},
-        },
-        resource::ResourceConstraint,
+use hash_graph_authorization::policies::{
+    Context, ContextBuilder, Policy, PolicyId,
+    action::ActionName,
+    principal::{
+        Actor, PrincipalConstraint, PrincipalId,
+        actor::{Ai, Machine, User},
+        role::{Role, RoleId, SubteamRole, SubteamRoleId, WebRole, WebRoleId},
+        team::{Subteam, SubteamId, Team, TeamId, Web},
     },
+    resource::ResourceConstraint,
 };
 use postgres_types::{Json, ToSql};
 use tokio_postgres::{GenericClient as _, error::SqlState};
@@ -111,7 +108,7 @@ pub enum RoleUnassignmentStatus {
     NotAssigned,
 }
 
-impl<C: AsClient, A: AuthorizationApi> PostgresStore<C, A> {
+impl<C: AsClient, A: Send + Sync> PostgresStore<C, A> {
     /// Checks if a principal with the given ID exists.
     ///
     /// # Errors
@@ -193,37 +190,6 @@ impl<C: AsClient, A: AuthorizationApi> PostgresStore<C, A> {
         Ok(())
     }
 
-    /// Creates a new web with the given ID, or generates a new UUID if none is provided.
-    ///
-    /// # Errors
-    ///
-    /// - [`PrincipalAlreadyExists`] if a web with the given ID already exists
-    /// - [`StoreError`] if a database error occurs
-    ///
-    /// [`PrincipalAlreadyExists`]: PrincipalError::PrincipalAlreadyExists
-    /// [`StoreError`]: PrincipalError::StoreError
-    pub async fn create_web(
-        &mut self,
-        id: Option<Uuid>,
-    ) -> Result<OwnedById, Report<PrincipalError>> {
-        let web_id = OwnedById::new(id.unwrap_or_else(Uuid::new_v4));
-        if let Err(error) = self
-            .as_mut_client()
-            .execute("INSERT INTO web (id) VALUES ($1)", &[web_id.as_uuid()])
-            .await
-        {
-            return if error.code() == Some(&SqlState::UNIQUE_VIOLATION) {
-                Err(error).change_context(PrincipalError::PrincipalAlreadyExists {
-                    id: PrincipalId::Team(TeamId::Web(web_id)),
-                })
-            } else {
-                Err(error).change_context(PrincipalError::StoreError)
-            };
-        }
-
-        Ok(web_id)
-    }
-
     /// Checks if a web with the given ID exists.
     ///
     /// # Errors
@@ -442,6 +408,21 @@ impl<C: AsClient, A: AuthorizationApi> PostgresStore<C, A> {
         Ok(user_id)
     }
 
+    /// Gets an actor by its ID.
+    ///
+    /// # Errors
+    ///
+    /// - [`StoreError`] if a database error occurs
+    ///
+    /// [`StoreError`]: PrincipalError::StoreError
+    pub async fn get_actor(&self, id: ActorId) -> Result<Option<Actor>, Report<PrincipalError>> {
+        Ok(match id {
+            ActorId::User(id) => self.get_user(id).await?.map(Actor::User),
+            ActorId::Machine(id) => self.get_machine(id).await?.map(Actor::Machine),
+            ActorId::Ai(id) => self.get_ai(id).await?.map(Actor::Ai),
+        })
+    }
+
     /// Checks if a user with the given ID exists.
     ///
     /// # Errors
@@ -464,12 +445,37 @@ impl<C: AsClient, A: AuthorizationApi> PostgresStore<C, A> {
     pub async fn get_user(&self, id: UserId) -> Result<Option<User>, Report<PrincipalError>> {
         Ok(self
             .as_client()
-            .query_opt("SELECT id FROM \"user\" WHERE id = $1", &[id.as_uuid()])
+            .query_opt(
+                "
+                SELECT \"user\".id,
+                       array_agg(role.id) FILTER (WHERE role.id IS NOT NULL),
+                       array_agg(role.principal_type) FILTER (WHERE role.id IS NOT NULL)
+                FROM \"user\"
+                LEFT OUTER JOIN actor_role ON \"user\".id = actor_role.actor_id
+                LEFT OUTER JOIN role ON actor_role.role_id = role.id
+                WHERE \"user\".id = $1
+                GROUP BY \"user\".id",
+                &[id.as_uuid()],
+            )
             .await
             .change_context(PrincipalError::StoreError)?
-            .map(|row| User {
-                id: UserId::new(ActorEntityUuid::new(row.get(0))),
-                roles: HashSet::new(),
+            .map(|row| {
+                let role_ids = row.get::<_, Option<Vec<Uuid>>>(1).unwrap_or_default();
+                let principal_types = row
+                    .get::<_, Option<Vec<PrincipalType>>>(2)
+                    .unwrap_or_default();
+                User {
+                    id: UserId::new(row.get(0)),
+                    roles: role_ids
+                        .into_iter()
+                        .zip(principal_types)
+                        .map(|(id, principal_type)| match principal_type {
+                            PrincipalType::WebRole => RoleId::Web(WebRoleId::new(id)),
+                            PrincipalType::SubteamRole => RoleId::Subteam(SubteamRoleId::new(id)),
+                            _ => unreachable!("Unexpected role type: {principal_type:?}"),
+                        })
+                        .collect(),
+                }
             }))
     }
 
@@ -545,12 +551,37 @@ impl<C: AsClient, A: AuthorizationApi> PostgresStore<C, A> {
     ) -> Result<Option<Machine>, Report<PrincipalError>> {
         Ok(self
             .as_client()
-            .query_opt("SELECT id FROM machine WHERE id = $1", &[id.as_uuid()])
+            .query_opt(
+                "
+                SELECT machine.id,
+                       array_agg(role.id) FILTER (WHERE role.id IS NOT NULL),
+                       array_agg(role.principal_type) FILTER (WHERE role.id IS NOT NULL)
+                FROM machine
+                LEFT OUTER JOIN actor_role ON machine.id = actor_role.actor_id
+                LEFT OUTER JOIN role ON actor_role.role_id = role.id
+                WHERE machine.id = $1
+                GROUP BY machine.id",
+                &[id.as_uuid()],
+            )
             .await
             .change_context(PrincipalError::StoreError)?
-            .map(|row| Machine {
-                id: MachineId::new(ActorEntityUuid::new(row.get(0))),
-                roles: HashSet::new(),
+            .map(|row| {
+                let role_ids = row.get::<_, Option<Vec<Uuid>>>(1).unwrap_or_default();
+                let principal_types = row
+                    .get::<_, Option<Vec<PrincipalType>>>(2)
+                    .unwrap_or_default();
+                Machine {
+                    id: MachineId::new(row.get(0)),
+                    roles: role_ids
+                        .into_iter()
+                        .zip(principal_types)
+                        .map(|(id, principal_type)| match principal_type {
+                            PrincipalType::WebRole => RoleId::Web(WebRoleId::new(id)),
+                            PrincipalType::SubteamRole => RoleId::Subteam(SubteamRoleId::new(id)),
+                            _ => unreachable!("Unexpected role type: {principal_type:?}"),
+                        })
+                        .collect(),
+                }
             }))
     }
 
@@ -619,12 +650,37 @@ impl<C: AsClient, A: AuthorizationApi> PostgresStore<C, A> {
     pub async fn get_ai(&self, id: AiId) -> Result<Option<Ai>, Report<PrincipalError>> {
         Ok(self
             .as_client()
-            .query_opt("SELECT id FROM ai WHERE id = $1", &[id.as_uuid()])
+            .query_opt(
+                "
+                SELECT ai.id,
+                       array_agg(role.id) FILTER (WHERE role.id IS NOT NULL),
+                       array_agg(role.principal_type) FILTER (WHERE role.id IS NOT NULL)
+                FROM ai
+                LEFT OUTER JOIN actor_role ON ai.id = actor_role.actor_id
+                LEFT OUTER JOIN role ON actor_role.role_id = role.id
+                WHERE ai.id = $1
+                GROUP BY ai.id",
+                &[id.as_uuid()],
+            )
             .await
             .change_context(PrincipalError::StoreError)?
-            .map(|row| Ai {
-                id: AiId::new(ActorEntityUuid::new(row.get(0))),
-                roles: HashSet::new(),
+            .map(|row| {
+                let role_ids = row.get::<_, Option<Vec<Uuid>>>(1).unwrap_or_default();
+                let principal_types = row
+                    .get::<_, Option<Vec<PrincipalType>>>(2)
+                    .unwrap_or_default();
+                Ai {
+                    id: AiId::new(row.get(0)),
+                    roles: role_ids
+                        .into_iter()
+                        .zip(principal_types)
+                        .map(|(id, principal_type)| match principal_type {
+                            PrincipalType::WebRole => RoleId::Web(WebRoleId::new(id)),
+                            PrincipalType::SubteamRole => RoleId::Subteam(SubteamRoleId::new(id)),
+                            _ => unreachable!("Unexpected role type: {principal_type:?}"),
+                        })
+                        .collect(),
+                }
             }))
     }
 
@@ -838,7 +894,7 @@ impl<C: AsClient, A: AuthorizationApi> PostgresStore<C, A> {
     pub async fn get_actor_roles(
         &self,
         actor_id: ActorId,
-    ) -> Result<HashSet<RoleId>, Report<PrincipalError>> {
+    ) -> Result<HashMap<RoleId, Role>, Report<PrincipalError>> {
         // Check if the actor exists
         if !self.is_principal(PrincipalId::Actor(actor_id)).await? {
             return Err(Report::new(PrincipalError::PrincipalNotFound {
@@ -846,33 +902,42 @@ impl<C: AsClient, A: AuthorizationApi> PostgresStore<C, A> {
             }));
         }
 
-        let rows = self
-            .as_client()
-            .query(
-                "SELECT r.id, r.principal_type
-                 FROM actor_role ar
-                 JOIN role r ON ar.role_id = r.id
-                 WHERE ar.actor_id = $1",
+        self.as_client()
+            .query_raw(
+                "SELECT role.id, role.principal_type, role.team_id
+                 FROM actor_role
+                 JOIN role ON actor_role.role_id = role.id
+                 WHERE actor_role.actor_id = $1",
                 &[actor_id.as_uuid()],
             )
             .await
-            .change_context(PrincipalError::StoreError)?;
-
-        let mut roles = HashSet::new();
-        for row in rows {
-            let id: Uuid = row.get(0);
-            let principal_type: PrincipalType = row.get(1);
-
-            let role_id = match principal_type {
-                PrincipalType::WebRole => RoleId::Web(WebRoleId::new(id)),
-                PrincipalType::SubteamRole => RoleId::Subteam(SubteamRoleId::new(id)),
-                _ => continue, // Skip non-role principal types
-            };
-
-            roles.insert(role_id);
-        }
+            .change_context(PrincipalError::StoreError)?
+            .map_ok(|row| {
+                let role_id: Uuid = row.get(0);
+                let principal_type: PrincipalType = row.get(1);
+                let team_id: Uuid = row.get(2);
 
-        Ok(roles)
+                match principal_type {
+                    PrincipalType::WebRole => (
+                        RoleId::Web(WebRoleId::new(role_id)),
+                        Role::Web(WebRole {
+                            id: WebRoleId::new(role_id),
+                            web_id: OwnedById::new(team_id),
+                        }),
+                    ),
+                    PrincipalType::SubteamRole => (
+                        RoleId::Subteam(SubteamRoleId::new(role_id)),
+                        Role::Subteam(SubteamRole {
+                            id: SubteamRoleId::new(role_id),
+                            subteam_id: SubteamId::new(team_id),
+                        }),
+                    ),
+                    _ => unreachable!("Unexpected role type: {principal_type:?}"),
+                }
+            })
+            .try_collect()
+            .await
+            .change_context(PrincipalError::StoreError)
     }
 
     /// Gets all actors assigned to a specific role.
@@ -931,18 +996,12 @@ impl<C: AsClient, A: AuthorizationApi> PostgresStore<C, A> {
     ///
     /// - [`AlreadyExists`] if an action with the same ID already exists
     /// - [`NotFound`] if the parent action doesn't exist
-    /// - [`HasSelfCycle`] if the action has a self-cycle
     /// - [`StoreError`] if a database error occurs
     ///
     /// [`AlreadyExists`]: ActionError::AlreadyExists
     /// [`NotFound`]: ActionError::NotFound
-    /// [`HasSelfCycle`]: ActionError::HasSelfCycle
     /// [`StoreError`]: ActionError::StoreError
-    pub async fn register_action(
-        &mut self,
-        action: ActionName,
-        parent: Option<ActionName>,
-    ) -> Result<(), Report<ActionError>> {
+    pub async fn register_action(&mut self, action: ActionName) -> Result<(), Report<ActionError>> {
         let transaction = self
             .transaction()
             .await
@@ -952,11 +1011,11 @@ impl<C: AsClient, A: AuthorizationApi> PostgresStore<C, A> {
             .as_client()
             .execute(
                 "INSERT INTO action (name, parent) VALUES ($1, $2)",
-                &[&action, &parent],
+                &[&action, &action.parent()],
             )
             .await
             .map_err(|error| {
-                let policy_error = match (error.code(), parent) {
+                let policy_error = match (error.code(), action.parent()) {
                     (Some(&SqlState::CHECK_VIOLATION), _) => {
                         ActionError::HasSelfCycle { id: action }
                     }
@@ -971,40 +1030,18 @@ impl<C: AsClient, A: AuthorizationApi> PostgresStore<C, A> {
                 Report::new(error).change_context(policy_error)
             })?;
 
-        if let Some(parent) = parent {
-            // Set up all parent-child relationships in a single query
-            // - We insert the self-cycle first with depth 0. This allows easier lookup of the
-            //   hierarchy as every action is present in the hierarchy table
-            // - Next we insert the direct parent-child relationship with depth 1
-            // - Finally, we insert the transitive relationships for all other depths
-            transaction
-                .as_client()
-                .execute(
-                    "INSERT INTO action_hierarchy (child_name, parent_name, depth)
+        transaction
+            .as_client()
+            .execute(
+                "INSERT INTO action_hierarchy (child_name, parent_name, depth)
                     SELECT $1, $1, 0
                     UNION ALL
-                    SELECT $1, $2, 1
-                    UNION ALL
-                    SELECT $1, parent_name, depth + 1
-                    FROM action_hierarchy
-                    WHERE child_name = $2 AND depth > 0",
-                    &[&action, &parent],
-                )
-                .await
-                .change_context(ActionError::StoreError)?;
-        } else if action == ActionName::All {
-            transaction
-                .as_client()
-                .execute(
-                    "INSERT INTO action_hierarchy (child_name, parent_name, depth)
-                    VALUES ($1, $1, 0)",
-                    &[&action],
-                )
-                .await
-                .change_context(ActionError::StoreError)?;
-        } else {
-            return Err(Report::new(ActionError::HasNoParent { id: action }));
-        }
+                    SELECT $1, parent_name, ordinality
+                    FROM unnest($2::text[]) WITH ORDINALITY as t(parent_name, ordinality)",
+                &[&action, &action.parents().collect::<Vec<_>>()],
+            )
+            .await
+            .change_context(ActionError::StoreError)?;
 
         transaction
             .commit()
@@ -1199,6 +1236,108 @@ impl<C: AsClient, A: AuthorizationApi> PostgresStore<C, A> {
         Ok(policy_id)
     }
 
+    /// Builds a context used to evaluate policies for an actor.
+    ///
+    /// # Errors
+    ///
+    /// - [`PrincipalNotFound`] if the actor with the given ID doesn't exist
+    /// - [`StoreError`] if a database error occurs
+    ///
+    /// [`PrincipalNotFound`]: PrincipalError::PrincipalNotFound
+    /// [`StoreError`]: PrincipalError::StoreError
+    ///
+    /// # Performance considerations
+    ///
+    /// This function performs multiple database queries to collect all entities needed for policy
+    /// evaluation, which could become a performance bottleneck for frequently accessed actors.
+    /// Future optimizations may include:
+    ///   - Combining some queries into a single more complex query
+    ///   - Implementing caching strategies for frequently accessed contexts
+    ///   - Prefetching contexts for related actors in batch operations
+    pub async fn build_principal_context(
+        &self,
+        actor_id: ActorId,
+    ) -> Result<Context, Report<PrincipalError>> {
+        let mut context_builder = ContextBuilder::default();
+
+        let actor = self
+            .get_actor(actor_id)
+            .await?
+            .ok_or(PrincipalError::PrincipalNotFound {
+                id: PrincipalId::Actor(actor_id),
+            })?;
+        context_builder.add_actor(&actor);
+
+        let team_ids = self
+            .get_actor_roles(actor_id)
+            .await?
+            .into_values()
+            .map(|role| {
+                context_builder.add_role(&role);
+                role.team_id().into_uuid()
+            })
+            .collect::<Vec<_>>();
+
+        self.as_client()
+            .query_raw(
+                "
+                WITH teams AS (
+                    SELECT parent_id AS id FROM team_hierarchy WHERE child_id = ANY($1)
+                    UNION ALL
+                    SELECT id FROM team WHERE id = ANY($1)
+                )
+                SELECT
+                    'subteam'::PRINCIPAL_TYPE,
+                    subteam.id,
+                    parent.principal_type,
+                    parent.id
+                 FROM subteam
+                 JOIN teams ON subteam.id = teams.id
+                 JOIN team parent ON subteam.parent_id = parent.id
+
+                 UNION ALL
+
+                 SELECT
+                    'web'::PRINCIPAL_TYPE,
+                    web.id,
+                    NULL,
+                    NULL
+                 FROM web
+                 JOIN teams ON web.id = teams.id
+                 ",
+                &[&team_ids],
+            )
+            .await
+            .change_context(PrincipalError::StoreError)?
+            .map_ok(|row| match row.get(0) {
+                PrincipalType::Web => Team::Web(Web {
+                    id: OwnedById::new(row.get(1)),
+                    roles: HashSet::new(),
+                }),
+                PrincipalType::Subteam => Team::Subteam(Subteam {
+                    id: SubteamId::new(row.get(1)),
+                    parents: vec![match row.get(2) {
+                        PrincipalType::Web => TeamId::Web(OwnedById::new(row.get(3))),
+                        PrincipalType::Subteam => TeamId::Subteam(SubteamId::new(row.get(3))),
+                        team_type => unreachable!("Unexpected team type: {team_type:?}"),
+                    }],
+                    roles: HashSet::new(),
+                }),
+                team_type => unreachable!("Unexpected team type: {team_type:?}"),
+            })
+            .try_collect::<Vec<_>>()
+            .await
+            .change_context(PrincipalError::StoreError)?
+            .into_iter()
+            .for_each(|team| {
+                context_builder.add_team(&team);
+            });
+
+        context_builder
+            .build()
+            .change_context(PrincipalError::ContextBuilderError)
+    }
+
     /// Gets all policies associated with an actor.
     ///
     /// This provides a complete set of policies that apply to an actor, including all policies that
diff --git a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs
index 47ca8c3b3d0..e7bc4c28241 100644
--- a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs
+++ b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs
@@ -14,6 +14,11 @@ use error_stack::{Report, ResultExt as _};
 use hash_graph_authorization::{
     AuthorizationApi,
     backend::ModifyRelationshipOperation,
+    policies::{
+        Authorized, PartialResourceId, PolicySet, Request, RequestContext,
+        action::ActionName,
+        store::{CreateWebParameter, PrincipalStore, error::WebCreationError},
+    },
     schema::{
         AccountGroupAdministratorSubject, AccountGroupRelationAndSubject, WebDataTypeViewerSubject,
         WebEntityCreatorSubject, WebEntityEditorSubject, WebEntityTypeViewerSubject,
@@ -30,6 +35,7 @@ use hash_graph_store::{
     query::ConflictBehavior,
 };
 use hash_graph_temporal_versioning::{LeftClosedTemporalInterval, TransactionTime};
+use hash_status::StatusCode;
 use hash_temporal_client::TemporalClient;
 use postgres_types::Json;
 use time::OffsetDateTime;
@@ -51,9 +57,10 @@ use type_system::{
         property_type::{PropertyType, schema::PropertyTypeReference},
         provenance::{OntologyEditionProvenance, OntologyOwnership, OntologyProvenance},
     },
-    provenance::ActorEntityUuid,
+    provenance::{ActorEntityUuid, ActorId},
     web::{ActorGroupId, OwnedById},
 };
+use uuid::Uuid;
 
 pub use self::{
     pool::{AsClient, PostgresStorePool},
@@ -85,6 +92,68 @@ pub struct PostgresStore<C, A> {
     pub settings: PostgresStoreSettings,
 }
 
+impl<C, A> PrincipalStore for PostgresStore<C, A>
+where
+    C: AsClient,
+    A: Send + Sync,
+{
+    async fn create_web(
+        &mut self,
+        actor: ActorId,
+        parameter: CreateWebParameter,
+    ) -> Result<OwnedById, Report<WebCreationError>> {
+        let context = self
+            .build_principal_context(actor)
+            .await
+            .change_context(WebCreationError::StoreError)?;
+        let policies = self
+            .get_policies_for_actor(actor)
+            .await
+            .change_context(WebCreationError::StoreError)?;
+
+        let web_id = OwnedById::new(parameter.id.unwrap_or_else(Uuid::new_v4));
+
+        let policy_set = PolicySet::default()
+            .with_policies(policies.values())
+            .change_context(WebCreationError::StoreError)?;
+        match policy_set
+            .evaluate(
+                &Request {
+                    actor,
+                    action: ActionName::CreateWeb,
+                    resource: Some(&PartialResourceId::Web(Some(web_id))),
+                    context: RequestContext::default(),
+                },
+                &context,
+            )
+            .change_context(WebCreationError::StoreError)?
+        {
+            Authorized::Always => {}
+            Authorized::Never => {
+                return Err(Report::new(WebCreationError::NotAuthorized))
+                    .attach_printable(StatusCode::PermissionDenied);
+            }
+            Authorized::Partial(_) => {
+                unimplemented!("Web creation is not supported for partial authorization");
+            }
+        }
+
+        if let Err(error) = self
+            .as_mut_client()
+            .execute("INSERT INTO web (id) VALUES ($1)", &[web_id.as_uuid()])
+            .await
+        {
+            return if error.code() == Some(&SqlState::UNIQUE_VIOLATION) {
+                Err(error).change_context(WebCreationError::AlreadyExists { web_id })
+            } else {
+                Err(error).change_context(WebCreationError::StoreError)
+            };
+        }
+
+        Ok(web_id)
+    }
+}
+
 #[derive(Debug, Copy, Clone, PartialEq, Eq)]
 enum OntologyLocation {
     Owned,
diff --git a/libs/@local/graph/postgres-store/tests/principals/actions.rs b/libs/@local/graph/postgres-store/tests/principals/actions.rs
index 62649b89f50..9edd6e27ed7 100644
--- a/libs/@local/graph/postgres-store/tests/principals/actions.rs
+++ b/libs/@local/graph/postgres-store/tests/principals/actions.rs
@@ -8,10 +8,10 @@ use crate::DatabaseTestWrapper;
 #[tokio::test]
 async fn register_actions() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     assert!(!client.has_action(ActionName::All).await?);
-    client.register_action(ActionName::All, None).await?;
+    client.register_action(ActionName::All).await?;
     assert!(client.has_action(ActionName::All).await?);
     client.unregister_action(ActionName::All).await?;
     assert!(!client.has_action(ActionName::All).await?);
@@ -22,16 +22,14 @@ async fn register_actions() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn register_with_parent() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     assert!(!client.has_action(ActionName::All).await?);
-    client.register_action(ActionName::All, None).await?;
+    client.register_action(ActionName::All).await?;
     assert!(client.has_action(ActionName::All).await?);
 
     assert!(!client.has_action(ActionName::Create).await?);
-    client
-        .register_action(ActionName::Create, Some(ActionName::All))
-        .await?;
+    client.register_action(ActionName::Create).await?;
     assert!(client.has_action(ActionName::Create).await?);
 
     client.unregister_action(ActionName::Create).await?;
@@ -46,10 +44,10 @@ async fn register_with_parent() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn register_action_twice() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
-    client.register_action(ActionName::All, None).await?;
-    let result = client.register_action(ActionName::All, None).await;
+    client.register_action(ActionName::All).await?;
+    let result = client.register_action(ActionName::All).await;
 
     assert_matches!(
         result
@@ -67,12 +65,10 @@ async fn register_action_twice() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn register_with_non_existent_parent() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     assert!(!client.has_action(ActionName::View).await?);
-    let result = client
-        .register_action(ActionName::View, Some(ActionName::All))
-        .await;
+    let result = client.register_action(ActionName::View).await;
 
     assert_matches!(
         result
@@ -87,31 +83,10 @@ async fn register_with_non_existent_parent() -> Result<(), Box<dyn Error>> {
     Ok(())
 }
 
-#[tokio::test]
-async fn register_without_parent() -> Result<(), Box<dyn Error>> {
-    let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
-
-    assert!(!client.has_action(ActionName::View).await?);
-    let result = client.register_action(ActionName::View, None).await;
-
-    assert_matches!(
-        result
-            .expect_err("Unregistering a non-existent action should fail")
-            .current_context(),
-        ActionError::HasNoParent {
-            id: ActionName::View
-        },
-        "Error should indicate that action does not have a parent"
-    );
-
-    Ok(())
-}
-
 #[tokio::test]
 async fn unregister_non_existent_action() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     assert!(!client.has_action(ActionName::All).await?);
     let result = client.unregister_action(ActionName::All).await;
@@ -132,48 +107,44 @@ async fn unregister_non_existent_action() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn action_hierarchy() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
-    // Create a hierarchy: all -> create -> instantiate
-    client.register_action(ActionName::All, None).await?;
-    client
-        .register_action(ActionName::Create, Some(ActionName::All))
-        .await?;
-    client
-        .register_action(ActionName::Instantiate, Some(ActionName::Create))
-        .await?;
+    // Create a hierarchy: all -> view -> view entity
+    client.register_action(ActionName::All).await?;
+    client.register_action(ActionName::View).await?;
+    client.register_action(ActionName::ViewEntity).await?;
 
     // Verify hierarchy is correctly established
-    let parent_actions = client.get_parent_actions(ActionName::Instantiate).await?;
+    let parent_actions = client.get_parent_actions(ActionName::ViewEntity).await?;
     assert_eq!(
         parent_actions,
-        [ActionName::Create, ActionName::All],
+        [ActionName::View, ActionName::All],
         "Parent actions should be in order of depth"
     );
 
     // Delete hierarchy in correct order - bottom-up
     // Delete instantiate action first
-    client.unregister_action(ActionName::Instantiate).await?;
+    client.unregister_action(ActionName::ViewEntity).await?;
 
     // Verify instantiate action no longer exists
     assert!(
-        !client.has_action(ActionName::Instantiate).await?,
-        "Instantiate action should be deleted"
+        !client.has_action(ActionName::ViewEntity).await?,
+        "ViewEntity action should be deleted"
     );
 
     // Verify create action still exists
     assert!(
-        client.has_action(ActionName::Create).await?,
-        "Create action should still exist"
+        client.has_action(ActionName::View).await?,
+        "View action should still exist"
     );
 
     // Now we can delete create action since it no longer has children
-    client.unregister_action(ActionName::Create).await?;
+    client.unregister_action(ActionName::View).await?;
 
     // Verify create action no longer exists
     assert!(
-        !client.has_action(ActionName::Create).await?,
-        "Create action should be deleted"
+        !client.has_action(ActionName::View).await?,
+        "View action should be deleted"
     );
 
     // Finally delete the root action
@@ -189,13 +160,11 @@ async fn action_hierarchy() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn cannot_delete_action_with_children() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     // Create a simple parent-child hierarchy
-    client.register_action(ActionName::All, None).await?;
-    client
-        .register_action(ActionName::Create, Some(ActionName::All))
-        .await?;
+    client.register_action(ActionName::All).await?;
+    client.register_action(ActionName::Create).await?;
 
     // Delete the parent action
     let result = client.unregister_action(ActionName::All).await;
@@ -216,9 +185,9 @@ async fn cannot_delete_action_with_children() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn get_empty_parent_actions() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
-    client.register_action(ActionName::All, None).await?;
+    client.register_action(ActionName::All).await?;
     let parent_actions = client.get_parent_actions(ActionName::All).await?;
 
     // Should return an empty vector for non-existent actions
@@ -233,7 +202,7 @@ async fn get_empty_parent_actions() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn get_parent_actions_non_existent() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let client = db.client().await?;
+    let (client, _actor_id) = db.seed([]).await?;
 
     // Try to get parent actions for an action that doesn't exist
     let result = client.get_parent_actions(ActionName::View).await;
@@ -253,46 +222,40 @@ async fn get_parent_actions_non_existent() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn complex_action_hierarchy() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     // Create a more complex hierarchy:
     //                    All
     //                   /   \
-    //               Create   View
-    //              /      \
-    //     Instantiate    Update
+    //               View    Create
+    //              /    \
+    //     ViewEntity    ViewEntityType
 
-    client.register_action(ActionName::All, None).await?;
+    client.register_action(ActionName::All).await?;
 
     // First branch
-    client
-        .register_action(ActionName::Create, Some(ActionName::All))
-        .await?;
-    client
-        .register_action(ActionName::Instantiate, Some(ActionName::Create))
-        .await?;
-    client
-        .register_action(ActionName::Update, Some(ActionName::Create))
-        .await?;
+    client.register_action(ActionName::View).await?;
+    client.register_action(ActionName::ViewEntity).await?;
+    client.register_action(ActionName::ViewEntityType).await?;
 
     // Second branch
-    client
-        .register_action(ActionName::View, Some(ActionName::All))
-        .await?;
+    client.register_action(ActionName::Create).await?;
 
     // Verify hierarchies
-    let instantiate_parents = client.get_parent_actions(ActionName::Instantiate).await?;
+    let instantiate_parents = client.get_parent_actions(ActionName::ViewEntity).await?;
     assert_eq!(
         instantiate_parents,
-        [ActionName::Create, ActionName::All],
-        "Instantiate should have Create and All as parents"
+        [ActionName::View, ActionName::All],
+        "ViewEntity should have View and All as parents"
     );
 
-    let update_parents = client.get_parent_actions(ActionName::Update).await?;
+    let update_parents = client
+        .get_parent_actions(ActionName::ViewEntityType)
+        .await?;
     assert_eq!(
         update_parents,
-        [ActionName::Create, ActionName::All],
-        "Update should have Create and All as parents"
+        [ActionName::View, ActionName::All],
+        "Update should have View and All as parents"
     );
 
     let view_parents = client.get_parent_actions(ActionName::View).await?;
@@ -303,76 +266,24 @@ async fn complex_action_hierarchy() -> Result<(), Box<dyn Error>> {
     );
 
     // Verify proper deletion order works
-    client.unregister_action(ActionName::Instantiate).await?;
-    client.unregister_action(ActionName::Update).await?;
+    client.unregister_action(ActionName::ViewEntityType).await?;
+    client.unregister_action(ActionName::ViewEntity).await?;
 
-    // Now Create can be deleted
-    client.unregister_action(ActionName::Create).await?;
+    // Now View can be deleted
+    client.unregister_action(ActionName::View).await?;
 
     // Delete the other branch
-    client.unregister_action(ActionName::View).await?;
+    client.unregister_action(ActionName::Create).await?;
 
     // Finally delete the root
     client.unregister_action(ActionName::All).await?;
 
     // Verify all actions are gone
     assert!(!client.has_action(ActionName::All).await?);
-    assert!(!client.has_action(ActionName::Create).await?);
-    assert!(!client.has_action(ActionName::Instantiate).await?);
-    assert!(!client.has_action(ActionName::Update).await?);
     assert!(!client.has_action(ActionName::View).await?);
-
-    Ok(())
-}
-
-#[tokio::test]
-async fn prevent_action_cycles() -> Result<(), Box<dyn Error>> {
-    let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
-
-    // Create a simple hierarchy
-    client.register_action(ActionName::All, None).await?;
-    client
-        .register_action(ActionName::Create, Some(ActionName::All))
-        .await?;
-
-    // Try to create a cycle by making All a child of Create
-    // This should fail because it would create a cycle
-    let result = client
-        .register_action(ActionName::All, Some(ActionName::Create))
-        .await;
-
-    // We expect an error because ActionId::All already exists
-    assert_matches!(
-        result
-            .expect_err("Creating a cycle should fail")
-            .current_context(),
-        ActionError::AlreadyExists {
-            id: ActionName::All
-        },
-        "Error should indicate that action already exists"
-    );
-
-    Ok(())
-}
-
-#[tokio::test]
-async fn prevent_self_cycles() -> Result<(), Box<dyn Error>> {
-    let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
-
-    let result = client
-        .register_action(ActionName::All, Some(ActionName::All))
-        .await;
-    assert_matches!(
-        result
-            .expect_err("Creating a self-cycle should fail")
-            .current_context(),
-        ActionError::HasSelfCycle {
-            id: ActionName::All
-        },
-        "Error should indicate that action has a self-cycle"
-    );
+    assert!(!client.has_action(ActionName::ViewEntity).await?);
+    assert!(!client.has_action(ActionName::ViewEntityType).await?);
+    assert!(!client.has_action(ActionName::Create).await?);
 
     Ok(())
 }
diff --git a/libs/@local/graph/postgres-store/tests/principals/ai.rs b/libs/@local/graph/postgres-store/tests/principals/ai.rs
index f7f4cf7d9bc..0f9c241a128 100644
--- a/libs/@local/graph/postgres-store/tests/principals/ai.rs
+++ b/libs/@local/graph/postgres-store/tests/principals/ai.rs
@@ -1,6 +1,10 @@
 use core::{assert_matches::assert_matches, error::Error};
 
-use hash_graph_authorization::policies::principal::{PrincipalId, team::TeamId};
+use hash_graph_authorization::policies::{
+    action::ActionName,
+    principal::{PrincipalId, team::TeamId},
+    store::{CreateWebParameter, PrincipalStore as _},
+};
 use hash_graph_postgres_store::permissions::PrincipalError;
 use pretty_assertions::assert_eq;
 use type_system::{
@@ -14,7 +18,7 @@ use crate::DatabaseTestWrapper;
 #[tokio::test]
 async fn create_ai() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let ai_id = client.create_ai(None).await?;
     assert!(client.is_ai(ai_id).await?);
@@ -25,7 +29,7 @@ async fn create_ai() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn create_ai_with_id() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let id = Uuid::new_v4();
     let ai_id = client.create_ai(Some(id)).await?;
@@ -39,7 +43,7 @@ async fn create_ai_with_id() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn delete_ai() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let ai_id = client.create_ai(None).await?;
     assert!(client.is_ai(ai_id).await?);
@@ -53,14 +57,14 @@ async fn delete_ai() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn create_ai_with_duplicate_id() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let ai_id = client.create_ai(Some(Uuid::new_v4())).await?;
-    let result = client.create_ai(Some(*ai_id.as_uuid())).await;
+    let result = client.create_ai(Some(ai_id.into_uuid())).await;
     drop(client);
 
     let expected_actor_id = ActorId::Ai(AiId::new(ActorEntityUuid::new(EntityUuid::new(
-        *ai_id.as_uuid(),
+        ai_id.into_uuid(),
     ))));
 
     assert_matches!(
@@ -74,7 +78,7 @@ async fn create_ai_with_duplicate_id() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn get_non_existent_ai() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let client = db.client().await?;
+    let (client, _actor_id) = db.seed([]).await?;
 
     let non_existent_id = AiId::new(ActorEntityUuid::new(EntityUuid::new(Uuid::new_v4())));
     let result = client.get_ai(non_existent_id).await?;
@@ -90,13 +94,13 @@ async fn get_non_existent_ai() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn delete_non_existent_ai() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let non_existent_id = AiId::new(ActorEntityUuid::new(EntityUuid::new(Uuid::new_v4())));
     let result = client.delete_ai(non_existent_id).await;
 
     let expected_actor_id = ActorId::Ai(AiId::new(ActorEntityUuid::new(EntityUuid::new(
-        *non_existent_id.as_uuid(),
+        non_existent_id.into_uuid(),
     ))));
 
     assert_matches!(
@@ -110,9 +114,11 @@ async fn delete_non_existent_ai() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn create_web_ai_relation() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
     let ai_id = client.create_ai(None).await?;
 
     assert!(client.is_web(web_id).await?);
@@ -124,14 +130,16 @@ async fn create_web_ai_relation() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn ai_role_assignment() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     let ai_id = client.create_ai(None).await?;
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
     let role_id = client.create_role(None, TeamId::Web(web_id)).await?;
 
     let actor_id = ActorId::Ai(AiId::new(ActorEntityUuid::new(EntityUuid::new(
-        *ai_id.as_uuid(),
+        ai_id.into_uuid(),
     ))));
 
     // Assign the role to the AI
@@ -139,7 +147,7 @@ async fn ai_role_assignment() -> Result<(), Box<dyn Error>> {
 
     // Check that the AI has the role assigned
     let ai_roles = client.get_actor_roles(actor_id).await?;
-    assert!(ai_roles.contains(&role_id));
+    assert!(ai_roles.contains_key(&role_id));
 
     // Check that the role has the AI assigned
     let role_actors = client.get_role_actors(role_id).await?;
@@ -150,7 +158,7 @@ async fn ai_role_assignment() -> Result<(), Box<dyn Error>> {
 
     // Check that the AI no longer has the role assigned
     let ai_roles_after = client.get_actor_roles(actor_id).await?;
-    assert!(!ai_roles_after.contains(&role_id));
+    assert!(!ai_roles_after.contains_key(&role_id));
 
     Ok(())
 }
diff --git a/libs/@local/graph/postgres-store/tests/principals/machine.rs b/libs/@local/graph/postgres-store/tests/principals/machine.rs
index 7f9240f2509..37aee542912 100644
--- a/libs/@local/graph/postgres-store/tests/principals/machine.rs
+++ b/libs/@local/graph/postgres-store/tests/principals/machine.rs
@@ -1,6 +1,10 @@
 use core::{assert_matches::assert_matches, error::Error};
 
-use hash_graph_authorization::policies::principal::PrincipalId;
+use hash_graph_authorization::policies::{
+    action::ActionName,
+    principal::PrincipalId,
+    store::{CreateWebParameter, PrincipalStore as _},
+};
 use hash_graph_postgres_store::permissions::PrincipalError;
 use pretty_assertions::assert_eq;
 use type_system::{
@@ -14,7 +18,7 @@ use crate::DatabaseTestWrapper;
 #[tokio::test]
 async fn create_machine() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let machine_id = client.create_machine(None).await?;
     assert!(client.is_machine(machine_id).await?);
@@ -25,11 +29,11 @@ async fn create_machine() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn create_machine_with_id() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let id = Uuid::new_v4();
     let machine_id = client.create_machine(Some(id)).await?;
-    assert_eq!(*machine_id.as_uuid(), id);
+    assert_eq!(machine_id.into_uuid(), id);
     assert!(client.is_machine(machine_id).await?);
 
     Ok(())
@@ -38,7 +42,7 @@ async fn create_machine_with_id() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn delete_machine() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let machine_id = client.create_machine(None).await?;
     assert!(client.is_machine(machine_id).await?);
@@ -52,10 +56,10 @@ async fn delete_machine() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn create_machine_with_duplicate_id() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let machine_id = client.create_machine(Some(Uuid::new_v4())).await?;
-    let result = client.create_machine(Some(*machine_id.as_uuid())).await;
+    let result = client.create_machine(Some(machine_id.into_uuid())).await;
     drop(client);
 
     assert_matches!(
@@ -69,7 +73,7 @@ async fn create_machine_with_duplicate_id() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn delete_non_existent_machine() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let non_existent_id = MachineId::new(ActorEntityUuid::new(EntityUuid::new(Uuid::new_v4())));
     let result = client.delete_machine(non_existent_id).await;
@@ -86,9 +90,11 @@ async fn delete_non_existent_machine() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn create_web_machine_relation() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
     let machine_id = client.create_machine(None).await?;
 
     assert!(client.is_web(web_id).await?);
@@ -100,7 +106,7 @@ async fn create_web_machine_relation() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn get_non_existent_machine() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let client = db.client().await?;
+    let (client, _actor_id) = db.seed([]).await?;
 
     let non_existent_id = MachineId::new(ActorEntityUuid::new(EntityUuid::new(Uuid::new_v4())));
     let result = client.get_machine(non_existent_id).await?;
@@ -116,7 +122,7 @@ async fn get_non_existent_machine() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn get_machine() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let machine_id = client.create_machine(None).await?;
     let retrieved = client
diff --git a/libs/@local/graph/postgres-store/tests/principals/main.rs b/libs/@local/graph/postgres-store/tests/principals/main.rs
index 60c781014f7..dfb1a16c5f7 100644
--- a/libs/@local/graph/postgres-store/tests/principals/main.rs
+++ b/libs/@local/graph/postgres-store/tests/principals/main.rs
@@ -10,8 +10,13 @@ mod team;
 mod user;
 mod web;
 
-use error_stack::Report;
-use hash_graph_authorization::{AuthorizationApi, NoAuthorization};
+use std::collections::HashSet;
+
+use error_stack::{Report, ResultExt as _};
+use hash_graph_authorization::{
+    AuthorizationApi, NoAuthorization,
+    policies::{Effect, Policy, PolicyId, action::ActionName, principal::PrincipalConstraint},
+};
 use hash_graph_postgres_store::{
     Environment, load_env,
     store::{
@@ -22,6 +27,8 @@ use hash_graph_postgres_store::{
 use hash_graph_store::pool::StorePool;
 use hash_tracing::logging::env_filter;
 use tokio_postgres::NoTls;
+use type_system::provenance::ActorId;
+use uuid::Uuid;
 
 pub fn init_logging() {
     // It's likely that the initialization failed due to a previous initialization attempt. In this
@@ -84,9 +91,42 @@ impl DatabaseTestWrapper<NoAuthorization> {
         }
     }
 
-    pub(crate) async fn client(
+    pub(crate) async fn seed<const N: usize>(
         &mut self,
-    ) -> Result<PostgresStore<impl AsClient, impl AuthorizationApi>, Report<StoreError>> {
-        self.connection.transaction().await
+        actions: [ActionName; N],
+    ) -> Result<(PostgresStore<impl AsClient, impl AuthorizationApi>, ActorId), Report<StoreError>>
+    {
+        let mut transaction = self.connection.transaction().await?;
+        let actor = ActorId::Machine(
+            transaction
+                .create_machine(None)
+                .await
+                .change_context(StoreError)?,
+        );
+
+        let mut registered_actions = HashSet::new();
+        for action in actions {
+            transaction
+                .register_action(action)
+                .await
+                .change_context(StoreError)?;
+            registered_actions.insert(action);
+        }
+
+        if registered_actions.contains(&ActionName::CreateWeb) {
+            transaction
+                .create_policy(Policy {
+                    id: PolicyId::new(Uuid::new_v4()),
+                    effect: Effect::Permit,
+                    principal: Some(PrincipalConstraint::Actor { actor }),
+                    actions: vec![ActionName::CreateWeb],
+                    resource: None,
+                    constraints: None,
+                })
+                .await
+                .change_context(StoreError)?;
+        }
+
+        Ok((transaction, actor))
     }
 }
diff --git a/libs/@local/graph/postgres-store/tests/principals/policies.rs b/libs/@local/graph/postgres-store/tests/principals/policies.rs
index 72da81c93e4..2a989e7a112 100644
--- a/libs/@local/graph/postgres-store/tests/principals/policies.rs
+++ b/libs/@local/graph/postgres-store/tests/principals/policies.rs
@@ -11,6 +11,7 @@ use hash_graph_authorization::{
             team::{SubteamId, TeamId},
         },
         resource::{EntityResourceConstraint, EntityResourceFilter, ResourceConstraint},
+        store::{CreateWebParameter, PrincipalStore as _},
     },
 };
 use hash_graph_postgres_store::store::{AsClient, PostgresStore};
@@ -108,28 +109,22 @@ struct TestPolicyIds {
 #[expect(clippy::too_many_lines)]
 async fn setup_policy_test_environment(
     client: &mut PostgresStore<impl AsClient, impl AuthorizationApi>,
+    actor_id: ActorId,
 ) -> Result<TestPolicyEnvironment, Box<dyn Error>> {
     // Register actions
-    client.register_action(ActionName::All, None).await?;
-    client
-        .register_action(ActionName::Create, Some(ActionName::All))
-        .await?;
-    client
-        .register_action(ActionName::View, Some(ActionName::All))
-        .await?;
-    client
-        .register_action(ActionName::ViewEntity, Some(ActionName::View))
-        .await?;
-    client
-        .register_action(ActionName::Update, Some(ActionName::All))
-        .await?;
-    client
-        .register_action(ActionName::Instantiate, Some(ActionName::All))
-        .await?;
+    client.register_action(ActionName::Create).await?;
+    client.register_action(ActionName::View).await?;
+    client.register_action(ActionName::ViewEntity).await?;
+    client.register_action(ActionName::Update).await?;
+    client.register_action(ActionName::Instantiate).await?;
 
     // Create web teams (top level)
-    let web1_id = client.create_web(None).await?;
-    let web2_id = client.create_web(None).await?;
+    let web1_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
+    let web2_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
 
     // Create subteams with different hierarchies
     let subteam_1_id = client.create_subteam(None, TeamId::Web(web1_id)).await?;
@@ -326,9 +321,9 @@ async fn setup_policy_test_environment(
 #[tokio::test]
 async fn global_policies() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
-    let env = setup_policy_test_environment(&mut client).await?;
+    let env = setup_policy_test_environment(&mut client, actor_id).await?;
 
     // Every actor should get global policies
     let user1_policies = client
@@ -377,9 +372,9 @@ async fn global_policies() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn actor_type_policies() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
-    let env = setup_policy_test_environment(&mut client).await?;
+    let env = setup_policy_test_environment(&mut client, actor_id).await?;
 
     // Test user type policies
     let user1_policies = client
@@ -439,9 +434,9 @@ async fn actor_type_policies() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn specific_actor_policies() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
-    let env = setup_policy_test_environment(&mut client).await?;
+    let env = setup_policy_test_environment(&mut client, actor_id).await?;
 
     // user1 has a specific policy assigned
     let user1_policies = client
@@ -476,9 +471,9 @@ async fn specific_actor_policies() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn role_based_policies() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
-    let env = setup_policy_test_environment(&mut client).await?;
+    let env = setup_policy_test_environment(&mut client, actor_id).await?;
 
     // Test role-based policies
     let user1_policies = client
@@ -530,9 +525,9 @@ async fn role_based_policies() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn team_hierarchy_policies() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
-    let env = setup_policy_test_environment(&mut client).await?;
+    let env = setup_policy_test_environment(&mut client, actor_id).await?;
 
     // Test team hierarchies
     // User2 has subteam1_role, AI has nested_subteam_role which is under subteam1
@@ -561,9 +556,9 @@ async fn team_hierarchy_policies() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn policy_count_and_content() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
-    let env = setup_policy_test_environment(&mut client).await?;
+    let env = setup_policy_test_environment(&mut client, actor_id).await?;
 
     let nonexistent_id = UserId::new(ActorEntityUuid::new(EntityUuid::new(Uuid::new_v4())));
 
@@ -616,9 +611,9 @@ async fn policy_count_and_content() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn role_assignment_changes() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
-    let env = setup_policy_test_environment(&mut client).await?;
+    let env = setup_policy_test_environment(&mut client, actor_id).await?;
 
     // Initial policy count
     let user2_policies = client
@@ -678,10 +673,10 @@ async fn role_assignment_changes() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn resource_constraints_are_preserved() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let user_id = client.create_user(None).await?;
-    client.register_action(ActionName::All, None).await?;
+    client.register_action(ActionName::All).await?;
 
     // Create a policy with resource constraints
     let resource_policy_id = client
@@ -729,10 +724,12 @@ async fn resource_constraints_are_preserved() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn multiple_actor_roles() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     // Create teams and roles
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
     let role1_id = client.create_role(None, TeamId::Web(web_id)).await?;
     let role2_id = client.create_role(None, TeamId::Web(web_id)).await?;
     let role3_id = client.create_role(None, TeamId::Web(web_id)).await?;
@@ -749,8 +746,6 @@ async fn multiple_actor_roles() -> Result<(), Box<dyn Error>> {
         .assign_role_to_actor(ActorId::User(user_id), role3_id)
         .await?;
 
-    client.register_action(ActionName::All, None).await?;
-
     // Create policies for each role
     let policy1_id = client
         .create_policy(Policy {
@@ -822,10 +817,12 @@ async fn multiple_actor_roles() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn deep_team_hierarchy() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     // Create a deep team hierarchy
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
     let subteam1_id = client.create_subteam(None, TeamId::Web(web_id)).await?;
     let subteam2_id = client
         .create_subteam(None, TeamId::Subteam(subteam1_id))
@@ -851,8 +848,6 @@ async fn deep_team_hierarchy() -> Result<(), Box<dyn Error>> {
         .assign_role_to_actor(ActorId::User(user_id), subteam5_role_id)
         .await?;
 
-    client.register_action(ActionName::All, None).await?;
-
     // Create policies
     let web_policy_id = client
         .create_policy(Policy {
diff --git a/libs/@local/graph/postgres-store/tests/principals/role.rs b/libs/@local/graph/postgres-store/tests/principals/role.rs
index bb25bf629f9..d45659f4c5e 100644
--- a/libs/@local/graph/postgres-store/tests/principals/role.rs
+++ b/libs/@local/graph/postgres-store/tests/principals/role.rs
@@ -1,9 +1,13 @@
 use core::{assert_matches::assert_matches, error::Error};
 
-use hash_graph_authorization::policies::principal::{
-    PrincipalId,
-    role::{Role, RoleId, WebRoleId},
-    team::TeamId,
+use hash_graph_authorization::policies::{
+    action::ActionName,
+    principal::{
+        PrincipalId,
+        role::{Role, RoleId, WebRoleId},
+        team::TeamId,
+    },
+    store::{CreateWebParameter, PrincipalStore as _},
 };
 use hash_graph_postgres_store::permissions::{
     PrincipalError, RoleAssignmentStatus, RoleUnassignmentStatus,
@@ -21,10 +25,12 @@ use crate::DatabaseTestWrapper;
 #[tokio::test]
 async fn create_role() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     // First create a team to associate the role with
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
 
     // Then create a role associated with the team
     let role_id = client.create_role(None, TeamId::Web(web_id)).await?;
@@ -36,10 +42,12 @@ async fn create_role() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn create_role_with_id() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     // First create a team to associate the role with
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
 
     // Then create a role with a specific ID
     let id = Uuid::new_v4();
@@ -53,7 +61,7 @@ async fn create_role_with_id() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn create_role_with_nonexistent_team() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     // Try to create a role with a non-existent team
     let non_existent_team_id = TeamId::Web(OwnedById::new(Uuid::new_v4()));
@@ -70,10 +78,12 @@ async fn create_role_with_nonexistent_team() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn get_role() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     // First create a team to associate the role with
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
 
     // Then create a role associated with the team
     let role_id = client.create_role(None, TeamId::Web(web_id)).await?;
@@ -94,10 +104,12 @@ async fn get_role() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn delete_role() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     // First create a team to associate the role with
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
 
     // Then create a role associated with the team
     let role_id = client.create_role(None, TeamId::Web(web_id)).await?;
@@ -113,7 +125,7 @@ async fn delete_role() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn delete_nonexistent_role() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     // Try to delete a non-existent role
     let non_existent_id = RoleId::Web(WebRoleId::new(Uuid::new_v4()));
@@ -130,10 +142,12 @@ async fn delete_nonexistent_role() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn assign_role_to_actor() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     // Create a team, role, and user
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
     let role_id = client.create_role(None, TeamId::Web(web_id)).await?;
     let user_id = client.create_user(None).await?;
 
@@ -147,7 +161,7 @@ async fn assign_role_to_actor() -> Result<(), Box<dyn Error>> {
 
     // Get the user's roles
     let roles = client.get_actor_roles(ActorId::User(user_id)).await?;
-    assert!(roles.contains(&role_id));
+    assert!(roles.contains_key(&role_id));
 
     // Get the role's actors
     let actors = client.get_role_actors(role_id).await?;
@@ -167,10 +181,12 @@ async fn assign_role_to_actor() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn assign_role_to_nonexistent_actor() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     // Create a team and role
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
     let role_id = client.create_role(None, TeamId::Web(web_id)).await?;
 
     // Try to assign the role to a non-existent user
@@ -190,7 +206,7 @@ async fn assign_role_to_nonexistent_actor() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn assign_nonexistent_role_to_actor() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     // Create a user
     let user_id = client.create_user(None).await?;
@@ -212,10 +228,12 @@ async fn assign_nonexistent_role_to_actor() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn unassign_role_from_actor() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     // Create a team, role, and user
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
     let role_id = client.create_role(None, TeamId::Web(web_id)).await?;
     let user_id = client.create_user(None).await?;
 
@@ -226,7 +244,7 @@ async fn unassign_role_from_actor() -> Result<(), Box<dyn Error>> {
 
     // Verify the role was assigned
     let roles = client.get_actor_roles(ActorId::User(user_id)).await?;
-    assert!(roles.contains(&role_id));
+    assert!(roles.contains_key(&role_id));
 
     // Unassign the role from the user
     assert_matches!(
@@ -238,7 +256,7 @@ async fn unassign_role_from_actor() -> Result<(), Box<dyn Error>> {
 
     // Verify the role was unassigned
     let roles = client.get_actor_roles(ActorId::User(user_id)).await?;
-    assert!(!roles.contains(&role_id));
+    assert!(!roles.contains_key(&role_id));
 
     // Verify the actor was removed from the role
     let actors = client.get_role_actors(role_id).await?;
@@ -258,7 +276,7 @@ async fn unassign_role_from_actor() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn get_actor_roles_empty() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     // Create a user
     let user_id = client.create_user(None).await?;
@@ -273,10 +291,12 @@ async fn get_actor_roles_empty() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn get_role_actors_empty() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     // Create a team and role
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
     let role_id = client.create_role(None, TeamId::Web(web_id)).await?;
 
     // Get the role's actors (should be empty)
@@ -289,10 +309,12 @@ async fn get_role_actors_empty() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn create_web_team_role() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     // Create a web team
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
 
     // Create a role for the web team
     let role_id = client.create_role(None, TeamId::Web(web_id)).await?;
@@ -313,10 +335,12 @@ async fn create_web_team_role() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn create_subteam_role() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     // First create a parent team
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
 
     // Create a subteam
     let subteam_id = client.create_subteam(None, TeamId::Web(web_id)).await?;
@@ -342,10 +366,12 @@ async fn create_subteam_role() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn assign_role_to_machine() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     // Create a web team, role, and machine
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
     let role_id = client.create_role(None, TeamId::Web(web_id)).await?;
     let machine_id = client.create_machine(None).await?;
 
@@ -356,7 +382,7 @@ async fn assign_role_to_machine() -> Result<(), Box<dyn Error>> {
 
     // Get the machine's roles
     let roles = client.get_actor_roles(ActorId::Machine(machine_id)).await?;
-    assert!(roles.contains(&role_id));
+    assert!(roles.contains_key(&role_id));
 
     // Get the role's actors
     let actors = client.get_role_actors(role_id).await?;
@@ -369,7 +395,7 @@ async fn assign_role_to_machine() -> Result<(), Box<dyn Error>> {
 
     // Verify the role was unassigned
     let roles = client.get_actor_roles(ActorId::Machine(machine_id)).await?;
-    assert!(!roles.contains(&role_id));
+    assert!(!roles.contains_key(&role_id));
 
     Ok(())
 }
diff --git a/libs/@local/graph/postgres-store/tests/principals/team.rs b/libs/@local/graph/postgres-store/tests/principals/team.rs
index 951c19b2003..4d229f7fb3c 100644
--- a/libs/@local/graph/postgres-store/tests/principals/team.rs
+++ b/libs/@local/graph/postgres-store/tests/principals/team.rs
@@ -1,8 +1,12 @@
 use core::{assert_matches::assert_matches, error::Error};
 
-use hash_graph_authorization::policies::principal::{
-    PrincipalId,
-    team::{SubteamId, TeamId},
+use hash_graph_authorization::policies::{
+    action::ActionName,
+    principal::{
+        PrincipalId,
+        team::{SubteamId, TeamId},
+    },
+    store::{CreateWebParameter, PrincipalStore as _},
 };
 use hash_graph_postgres_store::permissions::PrincipalError;
 use pretty_assertions::assert_eq;
@@ -13,14 +17,16 @@ use crate::DatabaseTestWrapper;
 #[tokio::test]
 async fn create_subteam() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
     let subteam_id = client.create_subteam(None, TeamId::Web(web_id)).await?;
     assert!(client.is_subteam(subteam_id).await?);
 
     let subteam = client
-        .get_subteam(SubteamId::new(*subteam_id.as_uuid()))
+        .get_subteam(SubteamId::new(subteam_id.into_uuid()))
         .await?
         .expect("Subteam should exist");
 
@@ -32,13 +38,15 @@ async fn create_subteam() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn create_subteam_with_id() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
     let id = Uuid::new_v4();
     let subteam_id = client.create_subteam(Some(id), TeamId::Web(web_id)).await?;
 
-    assert_eq!(*subteam_id.as_uuid(), id);
+    assert_eq!(subteam_id.into_uuid(), id);
     assert!(client.is_subteam(subteam_id).await?);
 
     Ok(())
@@ -47,10 +55,12 @@ async fn create_subteam_with_id() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn delete_subteam_with_hierarchy() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     // Create a hierarchy: web -> mid_team -> bottom_team
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
     let mid_subteam_id = client.create_subteam(None, TeamId::Web(web_id)).await?;
     let bottom_subteam_id = client
         .create_subteam(None, TeamId::Subteam(mid_subteam_id))
@@ -58,7 +68,7 @@ async fn delete_subteam_with_hierarchy() -> Result<(), Box<dyn Error>> {
 
     // Verify hierarchy is correctly established
     let subteam = client
-        .get_subteam(SubteamId::new(*bottom_subteam_id.as_uuid()))
+        .get_subteam(SubteamId::new(bottom_subteam_id.into_uuid()))
         .await?
         .expect("Subteam should exist");
 
@@ -98,7 +108,7 @@ async fn delete_subteam_with_hierarchy() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn delete_non_existent_subteam() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     // Try to delete a non-existent subteam
     let non_existent_id = SubteamId::new(Uuid::new_v4());
@@ -117,10 +127,12 @@ async fn delete_non_existent_subteam() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn can_delete_subteam_with_children() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     // Create a simple parent-child hierarchy
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
     let parent_subteam_id = client.create_subteam(None, TeamId::Web(web_id)).await?;
     let child_subteam_id = client
         .create_subteam(None, TeamId::Subteam(parent_subteam_id))
@@ -139,10 +151,12 @@ async fn can_delete_subteam_with_children() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn create_subteam_with_duplicate_id() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     // Create a parent team
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
 
     // Create a subteam with a specific ID
     let id = Uuid::new_v4();
@@ -170,10 +184,12 @@ async fn create_subteam_with_duplicate_id() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn get_subteam() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     // Create a parent team and subteam
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
     let subteam_id = client.create_subteam(None, TeamId::Web(web_id)).await?;
 
     // Get the subteam and verify it matches
@@ -189,7 +205,7 @@ async fn get_subteam() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn get_non_existent_subteam() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let client = db.client().await?;
+    let (client, _actor_id) = db.seed([]).await?;
 
     // Try to get a non-existent subteam
     let non_existent_id = SubteamId::new(Uuid::new_v4());
diff --git a/libs/@local/graph/postgres-store/tests/principals/user.rs b/libs/@local/graph/postgres-store/tests/principals/user.rs
index b86bf7b339b..0bf6d170343 100644
--- a/libs/@local/graph/postgres-store/tests/principals/user.rs
+++ b/libs/@local/graph/postgres-store/tests/principals/user.rs
@@ -14,7 +14,7 @@ use crate::DatabaseTestWrapper;
 #[tokio::test]
 async fn create_user() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let user_id = client.create_user(None).await?;
     assert!(client.is_user(user_id).await?);
@@ -25,7 +25,7 @@ async fn create_user() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn create_user_with_id() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let id = Uuid::new_v4();
     let user_id = client.create_user(Some(id)).await?;
@@ -42,7 +42,7 @@ async fn create_user_with_id() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn delete_user() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let user_id = client.create_user(None).await?;
     assert!(client.is_user(user_id).await?);
@@ -56,10 +56,10 @@ async fn delete_user() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn create_user_with_duplicate_id() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let user_id = client.create_user(Some(Uuid::new_v4())).await?;
-    let result = client.create_user(Some(*user_id.as_uuid())).await;
+    let result = client.create_user(Some(user_id.into_uuid())).await;
     drop(client);
 
     assert_matches!(
@@ -73,7 +73,7 @@ async fn create_user_with_duplicate_id() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn get_non_existent_user() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let client = db.client().await?;
+    let (client, _actor_id) = db.seed([]).await?;
 
     let non_existent_id = UserId::new(ActorEntityUuid::new(EntityUuid::new(Uuid::new_v4())));
     let result = client.get_user(non_existent_id).await?;
@@ -89,7 +89,7 @@ async fn get_non_existent_user() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn delete_non_existent_user() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let non_existent_id = UserId::new(ActorEntityUuid::new(EntityUuid::new(Uuid::new_v4())));
     let result = client.delete_user(non_existent_id).await;
diff --git a/libs/@local/graph/postgres-store/tests/principals/web.rs b/libs/@local/graph/postgres-store/tests/principals/web.rs
index 39f8133d6ca..42994761f22 100644
--- a/libs/@local/graph/postgres-store/tests/principals/web.rs
+++ b/libs/@local/graph/postgres-store/tests/principals/web.rs
@@ -1,6 +1,10 @@
 use core::{assert_matches::assert_matches, error::Error};
 
-use hash_graph_authorization::policies::principal::{PrincipalId, team::TeamId};
+use hash_graph_authorization::policies::{
+    action::ActionName,
+    principal::{PrincipalId, team::TeamId},
+    store::{CreateWebParameter, PrincipalStore as _, error::WebCreationError},
+};
 use hash_graph_postgres_store::permissions::PrincipalError;
 use pretty_assertions::assert_eq;
 use type_system::web::OwnedById;
@@ -11,9 +15,11 @@ use crate::DatabaseTestWrapper;
 #[tokio::test]
 async fn create_web() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
     assert!(client.is_web(web_id).await?);
 
     let retrieved = client.get_web(web_id).await?.expect("Web should exist");
@@ -25,10 +31,12 @@ async fn create_web() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn create_web_with_id() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
     let id = Uuid::new_v4();
-    let web_id = client.create_web(Some(id)).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: Some(id) })
+        .await?;
     assert_eq!(web_id, OwnedById::new(id));
     assert!(client.is_web(web_id).await?);
 
@@ -38,9 +46,11 @@ async fn create_web_with_id() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn delete_web() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
 
-    let web_id = client.create_web(None).await?;
+    let web_id = client
+        .create_web(actor_id, CreateWebParameter { id: None })
+        .await?;
     assert!(client.is_web(web_id).await?);
 
     client.delete_web(web_id).await?;
@@ -52,15 +62,29 @@ async fn delete_web() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn create_web_with_duplicate_id() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
-
-    let web_id = client.create_web(Some(Uuid::new_v4())).await?;
-    let result = client.create_web(Some(*web_id.as_uuid())).await;
+    let (mut client, actor_id) = db.seed([ActionName::All, ActionName::CreateWeb]).await?;
+
+    let web_id = client
+        .create_web(
+            actor_id,
+            CreateWebParameter {
+                id: Some(Uuid::new_v4()),
+            },
+        )
+        .await?;
+    let result = client
+        .create_web(
+            actor_id,
+            CreateWebParameter {
+                id: Some(web_id.into_uuid()),
+            },
+        )
+        .await;
     drop(client);
 
     assert_matches!(
         result.expect_err("Creating a web with duplicate ID should fail").current_context(),
-        PrincipalError::PrincipalAlreadyExists { id } if *id == PrincipalId::Team(TeamId::Web(web_id))
+        WebCreationError::AlreadyExists { web_id: id } if *id == web_id
     );
 
     Ok(())
@@ -69,7 +93,7 @@ async fn create_web_with_duplicate_id() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn get_non_existent_web() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let client = db.client().await?;
+    let (client, _actor_id) = db.seed([]).await?;
 
     let non_existent_id = OwnedById::new(Uuid::new_v4());
     let result = client.get_web(non_existent_id).await?;
@@ -85,7 +109,7 @@ async fn get_non_existent_web() -> Result<(), Box<dyn Error>> {
 #[tokio::test]
 async fn delete_non_existent_web() -> Result<(), Box<dyn Error>> {
     let mut db = DatabaseTestWrapper::new().await;
-    let mut client = db.client().await?;
+    let (mut client, _actor_id) = db.seed([]).await?;
 
     let non_existent_id = OwnedById::new(Uuid::new_v4());
     let result = client.delete_web(non_existent_id).await;
diff --git a/libs/@local/graph/type-fetcher/src/store.rs b/libs/@local/graph/type-fetcher/src/store.rs
index f22d63427bb..37ec115c04a 100644
--- a/libs/@local/graph/type-fetcher/src/store.rs
+++ b/libs/@local/graph/type-fetcher/src/store.rs
@@ -5,6 +5,7 @@ use std::collections::{HashMap, HashSet};
 use error_stack::{Report, ResultExt as _};
 use hash_graph_authorization::{
     AuthorizationApi,
+    policies::store::{CreateWebParameter, PrincipalStore, error::WebCreationError},
     schema::{
         DataTypeRelationAndSubject, DataTypeViewerSubject, EntityRelationAndSubject,
         EntityTypeInstantiatorSubject, EntityTypeRelationAndSubject, EntityTypeViewerSubject,
@@ -71,7 +72,7 @@ use type_system::{
         property_type::{PropertyType, PropertyTypeMetadata},
         provenance::{OntologyOwnership, ProvidedOntologyEditionProvenance},
     },
-    provenance::{ActorEntityUuid, ActorType, OriginProvenance, OriginType},
+    provenance::{ActorEntityUuid, ActorId, ActorType, OriginProvenance, OriginType},
     web::OwnedById,
 };
 
@@ -163,6 +164,20 @@ pub struct FetchingStore<S, A> {
     connection_info: Option<TypeFetcherConnectionInfo<A>>,
 }
 
+impl<S, A> PrincipalStore for FetchingStore<S, A>
+where
+    S: PrincipalStore + Send,
+    A: Send,
+{
+    async fn create_web(
+        &mut self,
+        actor: ActorId,
+        parameter: CreateWebParameter,
+    ) -> Result<OwnedById, Report<WebCreationError>> {
+        self.store.create_web(actor, parameter).await
+    }
+}
+
 const DATA_TYPE_RELATIONSHIPS: [DataTypeRelationAndSubject; 1] =
     [DataTypeRelationAndSubject::Viewer {
         subject: DataTypeViewerSubject::Public,


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 28 out of 29 changed files in this pull request and generated no comments.

<details>
<summary>Files not reviewed (1)</summary>

* **libs/@local/graph/authorization/schemas/policies.cedarschema**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**libs/@local/graph/postgres-store/src/permissions/mod.rs:1004**
* The new 'register_action' implementation removes the explicit parent parameter and relies on 'action.parent()'. Please verify that actions (other than the root 'All') which do not explicitly provide a parent are correctly handled and that an error is raised when an action lacks a defined parent.
```
pub async fn register_action(&mut self, action: ActionName) -> Result<(), Report<ActionError>> {
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T17:09:49Z
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T17:12:26Z
user: login: hashdotai
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T17:13:39Z
user: login: hashdotai
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T17:13:39Z
user: login: hashdotai
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T17:13:39Z
user: login: hashdotai
body: This PR successfully implements the groundwork for using Cedar policies for permission evaluation. The implementation creates a solid foundation by making several key changes:

1. Action hierarchy modeling with proper parent-child relationships
2. Improved context building for policy evaluation that includes teams, roles, and actors
3. Refactoring of database queries to include more complete information about actors and their roles
4. Introduction of a PrincipalStore trait that allows for the creation of webs with appropriate authorization checks
5. Consistent schema between Cedar and the Rust implementation

The code is generally well-structured and follows good practices. I particularly appreciate:
- The use of iterators for parent-child relationships in ActionName
- The comprehensive database queries that collect all necessary information for policy evaluation
- The thoughtful trait designs that maintain backward compatibility while adding new functionality
- The Cedar schema validation test ensuring consistency between different parts of the system

I do see some areas for potential future optimization around the database access patterns in the context builder, but as mentioned in the PR description, this is intended to be a straightforward initial implementation that can be optimized later.

Overall, this is a solid implementation that achieves the goal of preparing the evaluation of policies when calling from the API layer.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T17:14:33Z
user: login: TimDiekmann
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T19:25:38Z
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T19:35:17Z
user: login: hashdotai
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T19:39:28Z
user: login: hashdotai
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T19:39:28Z
user: login: hashdotai
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T19:39:28Z
user: login: hashdotai
body: This PR introduces a solid foundation for the new policy evaluation system by exposing the Cedar policy interface to the Graph API layer and implementing principal context creation. The changes are well-structured and the implementation is thorough.

The addition of parent-child relationships for actions using iterators is particularly elegant, and the thorough testing of this functionality (including validation against the Cedar schema) gives confidence in the implementation. This will make policy evaluation more flexible and maintainable.

The implementation of the context building functionality is comprehensive, ensuring all necessary entities are available for Cedar policy evaluation. While there are potentially some performance considerations when fetching all context data, this provides a solid starting point that can be optimized in the future.

I've suggested some minor improvements to the documentation and a couple of small cleanups for unused imports. These are relatively minor issues that don't impact the overall quality of the implementation.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T19:39:50Z
user: login: TimDiekmann
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T19:46:33Z
user: login: TimDiekmann
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T19:46:48Z
user: login: TimDiekmann
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T19:47:00Z
user: login: CiaranMn
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-08T07:24:05Z
user: login: CiaranMn
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-08T12:48:57Z

Review comments:
diff_hunk: @@ -13,6 +13,7 @@
 use hash_graph_authorization::{
     AuthorizationApi as _, AuthorizationApiPool,
     backend::{ModifyRelationshipOperation, PermissionAssertion},
+    policies::store::{CreateWebParameter, PrincipalStore},
path: libs/@local/graph/api/src/rest/web.rs
user: login: github-advanced-security[bot]
body: unused import: `CreateWebParameter`

[Show more details](https://github.com/hashintel/hash/security/code-scanning/16456)
created_at: 2025-04-07T17:12:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,10 +27,12 @@
 };
 use hash_temporal_client::TemporalClient;
 use serde::Deserialize;
-use type_system::web::OwnedById;
+use type_system::{
+    provenance::{ActorId, UserId},
path: libs/@local/graph/api/src/rest/web.rs
user: login: github-advanced-security[bot]
body: unused imports: `ActorId` and `UserId`

[Show more details](https://github.com/hashintel/hash/security/code-scanning/16455)
created_at: 2025-04-07T17:12:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,10 +27,12 @@
 };
 use hash_temporal_client::TemporalClient;
 use serde::Deserialize;
-use type_system::web::OwnedById;
+use type_system::{
+    provenance::{ActorId, UserId},
path: libs/@local/graph/api/src/rest/web.rs
user: login: github-advanced-security[bot]
body: unused imports: `ActorId` and `UserId`

[Show more details](https://github.com/hashintel/hash/security/code-scanning/16454)
created_at: 2025-04-07T17:12:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,10 +27,12 @@
 };
 use hash_temporal_client::TemporalClient;
 use serde::Deserialize;
-use type_system::web::OwnedById;
+use type_system::{
+    provenance::{ActorId, UserId},
path: libs/@local/graph/api/src/rest/web.rs
user: login: hashdotai
body: @github-advanced-security This alert appears to be a duplicate of the previous one (threadId 2031669949). The imports for `ActorId` and `UserId` are intentionally kept to support the commented-out implementation that will be used when we switch to the new principals system.
created_at: 2025-04-07T17:13:39Z
author_association: MEMBER
diff_hunk: @@ -26,10 +27,12 @@
 };
 use hash_temporal_client::TemporalClient;
 use serde::Deserialize;
-use type_system::web::OwnedById;
+use type_system::{
+    provenance::{ActorId, UserId},
path: libs/@local/graph/api/src/rest/web.rs
user: login: hashdotai
body: @github-advanced-security Both `ActorId` and `UserId` are intentionally unused at the moment. They're imported to support the commented-out code block in lines 140-153, which will be enabled when we transition to using the new principals system. Keeping these imports with the code maintains the connection between them, making it easier to uncomment and implement the new system when ready.
created_at: 2025-04-07T17:13:39Z
author_association: MEMBER
diff_hunk: @@ -13,6 +13,7 @@
 use hash_graph_authorization::{
     AuthorizationApi as _, AuthorizationApiPool,
     backend::{ModifyRelationshipOperation, PermissionAssertion},
+    policies::store::{CreateWebParameter, PrincipalStore},
path: libs/@local/graph/api/src/rest/web.rs
user: login: hashdotai
body: @github-advanced-security The `CreateWebParameter` import is intentionally unused at the moment. As noted in the commented section below (lines 140-153), we're planning to use this import once the new principals system is fully implemented. I've kept it in place to maintain a clear connection between the commented code and its imports, making it easier to uncomment and use when we're ready to transition from the current permission system.
created_at: 2025-04-07T17:13:39Z
author_association: MEMBER
diff_hunk: @@ -20,15 +20,44 @@ pub enum ActionName {
     All,
 
     Create,
+    CreateWeb,
 
     View,
     ViewEntity,
+    ViewEntityType,
 
     Update,
 
     Instantiate,
 }
 
+impl ActionName {
+    #[must_use]
+    pub const fn parent(self) -> Option<Self> {
+        match self {
+            Self::All => None,
+            Self::Create | Self::CreateWeb | Self::View | Self::Update | Self::Instantiate => {
+                Some(Self::All)
+            }
+            Self::ViewEntity | Self::ViewEntityType => Some(Self::View),
+        }
+    }
+
+    pub fn parents(self) -> impl Iterator<Item = Self> {
+        iter::successors(self.parent(), |&action| action.parent())
path: libs/@local/graph/authorization/src/policies/action/mod.rs
user: login: hashdotai
body: Nice implementation of the parent/child relationships for actions. Using iterators for the `parents()` method is a clean approach. Consider adding a test case that explicitly tests the `is_parent_of` and `is_child_of` methods directly, as they're only indirectly tested through the `action_ids()` test.
created_at: 2025-04-07T17:14:33Z
author_association: MEMBER
diff_hunk: @@ -55,6 +55,10 @@ impl ContextBuilder {
         self.entities.push(role.to_cedar_entity());
     }
 
+    pub fn add_team(&mut self, team: &Team) {
path: libs/@local/graph/authorization/src/policies/context.rs
user: login: hashdotai
body: The Context implementation nicely centralizes the building of Cedar entities. Consider adding some documentation for the `add_team` method explaining its purpose, similar to the other methods in this class.
created_at: 2025-04-07T17:14:33Z
author_association: MEMBER
diff_hunk: @@ -464,12 +445,37 @@ impl<C: AsClient, A: AuthorizationApi> PostgresStore<C, A> {
     pub async fn get_user(&self, id: UserId) -> Result<Option<User>, Report<PrincipalError>> {
         Ok(self
             .as_client()
-            .query_opt("SELECT id FROM \"user\" WHERE id = $1", &[id.as_uuid()])
+            .query_opt(
+                "
+                SELECT \"user\".id,
+                       array_agg(role.id) FILTER (WHERE role.id IS NOT NULL),
+                       array_agg(role.principal_type) FILTER (WHERE role.id IS NOT NULL)
+                FROM \"user\"
+                LEFT OUTER JOIN actor_role ON \"user\".id = actor_role.actor_id
+                LEFT OUTER JOIN role ON actor_role.role_id = role.id
+                WHERE \"user\".id = $1
+                GROUP BY \"user\".id",
+                &[id.as_uuid()],
+            )
             .await
             .change_context(PrincipalError::StoreError)?
-            .map(|row| User {
-                id: UserId::new(ActorEntityUuid::new(row.get(0))),
-                roles: HashSet::new(),
+            .map(|row| {
+                let role_ids = row.get::<_, Option<Vec<Uuid>>>(1).unwrap_or_default();
path: libs/@local/graph/postgres-store/src/permissions/mod.rs
user: login: hashdotai
body: It's good that you've updated all the query implementations to properly include the roles when fetching actors. One optimization to consider for the future would be to extract this common query pattern (which appears in get_user, get_machine, and get_ai with nearly identical logic) into a shared utility function to reduce code duplication.
created_at: 2025-04-07T17:14:33Z
author_association: MEMBER
diff_hunk: @@ -1199,6 +1236,99 @@
         Ok(policy_id)
     }
 
+    /// Builds a context used to evaluate policies for an actor.
+    ///
+    /// # Errors
+    ///
+    /// - [`PrincipalNotFound`] if the actor with the given ID doesn't exist
+    /// - [`StoreError`] if a database error occurs
+    ///
+    /// [`PrincipalNotFound`]: PrincipalError::PrincipalNotFound
+    /// [`StoreError`]: PrincipalError::StoreError
+    pub async fn build_principal_context(
path: libs/@local/graph/postgres-store/src/permissions/mod.rs
user: login: hashdotai
body: The implementation of `build_principal_context` is quite thorough in collecting all the necessary entities for a proper Cedar evaluation. Be careful with the potentially large number of database queries here - this might become a performance bottleneck. Consider adding a comment about future optimization possibilities, such as combining some of these queries or implementing caching strategies.
created_at: 2025-04-07T17:14:33Z
author_association: MEMBER
diff_hunk: @@ -195,4 +227,24 @@
 
         Ok(())
     }
+
+    #[test]
path: libs/@local/graph/authorization/src/policies/action/mod.rs
user: login: hashdotai
body: Your implementation of the Cedar schema validation test is valuable to ensure consistency between the Rust code and Cedar schema. However, consider adding a test comment explaining what the test is verifying specifically, as it might not be immediately obvious to other developers.
created_at: 2025-04-07T17:14:33Z
author_association: MEMBER
diff_hunk: @@ -464,12 +445,37 @@ impl<C: AsClient, A: AuthorizationApi> PostgresStore<C, A> {
     pub async fn get_user(&self, id: UserId) -> Result<Option<User>, Report<PrincipalError>> {
         Ok(self
             .as_client()
-            .query_opt("SELECT id FROM \"user\" WHERE id = $1", &[id.as_uuid()])
+            .query_opt(
+                "
+                SELECT \"user\".id,
+                       array_agg(role.id) FILTER (WHERE role.id IS NOT NULL),
+                       array_agg(role.principal_type) FILTER (WHERE role.id IS NOT NULL)
+                FROM \"user\"
+                LEFT OUTER JOIN actor_role ON \"user\".id = actor_role.actor_id
+                LEFT OUTER JOIN role ON actor_role.role_id = role.id
+                WHERE \"user\".id = $1
+                GROUP BY \"user\".id",
+                &[id.as_uuid()],
+            )
             .await
             .change_context(PrincipalError::StoreError)?
-            .map(|row| User {
-                id: UserId::new(ActorEntityUuid::new(row.get(0))),
-                roles: HashSet::new(),
+            .map(|row| {
+                let role_ids = row.get::<_, Option<Vec<Uuid>>>(1).unwrap_or_default();
path: libs/@local/graph/postgres-store/src/permissions/mod.rs
user: login: TimDiekmann
body: It's expected that the implementation will diverge and unifying the implementation would just add more work later.
created_at: 2025-04-07T19:25:38Z
author_association: MEMBER
diff_hunk: @@ -103,14 +142,18 @@
 #[expect(clippy::panic_in_result_fn, reason = "Assertions in test are expected")]
 mod tests {
     use core::error::Error;
+    use std::collections::{HashMap, HashSet};
path: libs/@local/graph/authorization/src/policies/action/mod.rs
user: login: github-advanced-security[bot]
body: unused import: `HashSet`

[Show more details](https://github.com/hashintel/hash/security/code-scanning/16457)
created_at: 2025-04-07T19:35:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -9,7 +9,7 @@
 
 use super::{
     PolicyValidator,
-    principal::{Actor, actor::Machine, role::Role},
+    principal::{Actor, actor::Machine, role::Role, team::Team},
path: libs/@local/graph/authorization/src/policies/context.rs
user: login: github-advanced-security[bot]
body: unused import: `actor::Machine`

[Show more details](https://github.com/hashintel/hash/security/code-scanning/16458)
created_at: 2025-04-07T19:35:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -103,14 +142,18 @@
 #[expect(clippy::panic_in_result_fn, reason = "Assertions in test are expected")]
 mod tests {
     use core::error::Error;
+    use std::collections::{HashMap, HashSet};
path: libs/@local/graph/authorization/src/policies/action/mod.rs
user: login: hashdotai
body: @github-advanced-security Good catch. The unused `HashSet` import should be removed. Since this is in the test code, it doesn't impact production, but keeping imports clean is still good practice.
created_at: 2025-04-07T19:39:28Z
author_association: MEMBER
diff_hunk: @@ -464,12 +445,37 @@ impl<C: AsClient, A: AuthorizationApi> PostgresStore<C, A> {
     pub async fn get_user(&self, id: UserId) -> Result<Option<User>, Report<PrincipalError>> {
         Ok(self
             .as_client()
-            .query_opt("SELECT id FROM \"user\" WHERE id = $1", &[id.as_uuid()])
+            .query_opt(
+                "
+                SELECT \"user\".id,
+                       array_agg(role.id) FILTER (WHERE role.id IS NOT NULL),
+                       array_agg(role.principal_type) FILTER (WHERE role.id IS NOT NULL)
+                FROM \"user\"
+                LEFT OUTER JOIN actor_role ON \"user\".id = actor_role.actor_id
+                LEFT OUTER JOIN role ON actor_role.role_id = role.id
+                WHERE \"user\".id = $1
+                GROUP BY \"user\".id",
+                &[id.as_uuid()],
+            )
             .await
             .change_context(PrincipalError::StoreError)?
-            .map(|row| User {
-                id: UserId::new(ActorEntityUuid::new(row.get(0))),
-                roles: HashSet::new(),
+            .map(|row| {
+                let role_ids = row.get::<_, Option<Vec<Uuid>>>(1).unwrap_or_default();
path: libs/@local/graph/postgres-store/src/permissions/mod.rs
user: login: hashdotai
body: @TimDiekmann I understand your reasoning. If the implementations are expected to diverge, then keeping them separate makes sense to avoid refactoring work later. This approach gives you more flexibility to customize each actor type's query as needed in the future.
created_at: 2025-04-07T19:39:28Z
author_association: MEMBER
diff_hunk: @@ -9,7 +9,7 @@
 
 use super::{
     PolicyValidator,
-    principal::{Actor, actor::Machine, role::Role},
+    principal::{Actor, actor::Machine, role::Role, team::Team},
path: libs/@local/graph/authorization/src/policies/context.rs
user: login: hashdotai
body: @github-advanced-security Thanks for catching this. The `actor::Machine` import is indeed unused after the PR changes removed the `add_machine` method in favor of the more generic `add_actor` method. This import can be safely removed.
created_at: 2025-04-07T19:39:28Z
author_association: MEMBER
diff_hunk: @@ -43,18 +43,30 @@
 }
 
 impl ContextBuilder {
-    pub fn add_machine(&mut self, machine: &Machine) {
-        self.entities.push(machine.to_cedar_entity());
-    }
-
+    /// Adds an actor to the context for policy evaluation.
+    ///
+    /// This allows the actor to be identified as a principal during authorization,
+    /// making it available for matching against principal constraints in policies.
     pub fn add_actor(&mut self, actor: &Actor) {
         self.entities.push(actor.to_cedar_entity());
     }
 
+    /// Adds a role to the context for policy evaluation.
+    ///
+    /// This allows policies associated with the role to be considered during authorization,
+    /// enabling role-based access control as part of the Cedar evaluation context.
     pub fn add_role(&mut self, role: &Role) {
         self.entities.push(role.to_cedar_entity());
     }
 
+    /// Adds a team to the context for policy evaluation.
+    ///
+    /// This allows policies associated with the team to be considered during authorization,
+    /// making the team available as a potential principal in the Cedar evaluation context.
+    pub fn add_team(&mut self, team: &Team) {
path: libs/@local/graph/authorization/src/policies/context.rs
user: login: hashdotai
body: Please add documentation for the `add_team` method, similar to the style used for the other methods in this class. This would help maintain consistency in the documentation and make it clear what this method is intended for.
created_at: 2025-04-07T19:39:50Z
author_association: MEMBER
diff_hunk: @@ -103,14 +142,18 @@
 #[expect(clippy::panic_in_result_fn, reason = "Assertions in test are expected")]
 mod tests {
     use core::error::Error;
+    use std::collections::{HashMap, HashSet};
path: libs/@local/graph/authorization/src/policies/action/mod.rs
user: login: hashdotai
body: This import of `HashSet` is unused and can be removed.
created_at: 2025-04-07T19:39:50Z
author_association: MEMBER
diff_hunk: @@ -9,7 +9,7 @@
 
 use super::{
     PolicyValidator,
-    principal::{Actor, actor::Machine, role::Role},
+    principal::{Actor, actor::Machine, role::Role, team::Team},
path: libs/@local/graph/authorization/src/policies/context.rs
user: login: hashdotai
body: The `actor::Machine` import is unused after removing the `add_machine` method and can be safely removed.
created_at: 2025-04-07T19:39:50Z
author_association: MEMBER
diff_hunk: @@ -195,4 +238,132 @@
 
         Ok(())
     }
+
+    /// Validates consistency between the Rust `ActionName` enum hierarchy and the Cedar schema.
+    /// This test ensures that:
+    /// - Each action defined in the Cedar schema can be mapped to a Rust `ActionName`
+    /// - The parent-child relationships defined by `action.parents()` match the hierarchical
+    ///   relationships in the Cedar schema (descendants)
+    #[test]
path: libs/@local/graph/authorization/src/policies/action/mod.rs
user: login: hashdotai
body: Please add a more detailed doc comment that explains what this test is specifically verifying. It would be helpful to clarify that it's validating consistency between the Rust `ActionName` enum hierarchy and the Cedar schema structure.
created_at: 2025-04-07T19:39:50Z
author_association: MEMBER
diff_hunk: @@ -1199,6 +1236,108 @@ impl<C: AsClient, A: AuthorizationApi> PostgresStore<C, A> {
         Ok(policy_id)
     }
 
+    /// Builds a context used to evaluate policies for an actor.
+    ///
+    /// # Errors
+    ///
+    /// - [`PrincipalNotFound`] if the actor with the given ID doesn't exist
+    /// - [`StoreError`] if a database error occurs
+    ///
+    /// [`PrincipalNotFound`]: PrincipalError::PrincipalNotFound
+    /// [`StoreError`]: PrincipalError::StoreError
+    ///
+    /// # Performance considerations
+    ///
+    /// This function performs multiple database queries to collect all entities needed for policy
+    /// evaluation, which could become a performance bottleneck for frequently accessed actors.
+    /// Future optimizations may include:
+    ///   - Combining some queries into a single more complex query
+    ///   - Implementing caching strategies for frequently accessed contexts
+    ///   - Prefetching contexts for related actors in batch operations
+    pub async fn build_principal_context(
path: libs/@local/graph/postgres-store/src/permissions/mod.rs
user: login: hashdotai
body: This method performs multiple database queries to build the context, which could become a performance bottleneck for frequent requests. Consider adding a comment about potential future optimizations such as:

- Combining some queries into a single more complex query
- Implementing caching strategies for frequently accessed contexts
- Prefetching contexts for related actors in batch operations
created_at: 2025-04-07T19:39:50Z
author_association: MEMBER
diff_hunk: @@ -43,18 +43,30 @@
 }
 
 impl ContextBuilder {
-    pub fn add_machine(&mut self, machine: &Machine) {
-        self.entities.push(machine.to_cedar_entity());
-    }
-
+    /// Adds an actor to the context for policy evaluation.
+    ///
+    /// This allows the actor to be identified as a principal during authorization,
+    /// making it available for matching against principal constraints in policies.
     pub fn add_actor(&mut self, actor: &Actor) {
         self.entities.push(actor.to_cedar_entity());
     }
 
+    /// Adds a role to the context for policy evaluation.
+    ///
+    /// This allows policies associated with the role to be considered during authorization,
+    /// enabling role-based access control as part of the Cedar evaluation context.
     pub fn add_role(&mut self, role: &Role) {
         self.entities.push(role.to_cedar_entity());
     }
 
+    /// Adds a team to the context for policy evaluation.
+    ///
+    /// This allows policies associated with the team to be considered during authorization,
+    /// making the team available as a potential principal in the Cedar evaluation context.
+    pub fn add_team(&mut self, team: &Team) {
path: libs/@local/graph/authorization/src/policies/context.rs
user: login: TimDiekmann
body: This **_literally_** was added this commit!
created_at: 2025-04-07T19:46:33Z
author_association: MEMBER
diff_hunk: @@ -195,4 +238,132 @@
 
         Ok(())
     }
+
+    /// Validates consistency between the Rust `ActionName` enum hierarchy and the Cedar schema.
+    /// This test ensures that:
+    /// - Each action defined in the Cedar schema can be mapped to a Rust `ActionName`
+    /// - The parent-child relationships defined by `action.parents()` match the hierarchical
+    ///   relationships in the Cedar schema (descendants)
+    #[test]
path: libs/@local/graph/authorization/src/policies/action/mod.rs
user: login: TimDiekmann
body: This **_literally_** was added this commit!
created_at: 2025-04-07T19:46:48Z
author_association: MEMBER
diff_hunk: @@ -1199,6 +1236,108 @@ impl<C: AsClient, A: AuthorizationApi> PostgresStore<C, A> {
         Ok(policy_id)
     }
 
+    /// Builds a context used to evaluate policies for an actor.
+    ///
+    /// # Errors
+    ///
+    /// - [`PrincipalNotFound`] if the actor with the given ID doesn't exist
+    /// - [`StoreError`] if a database error occurs
+    ///
+    /// [`PrincipalNotFound`]: PrincipalError::PrincipalNotFound
+    /// [`StoreError`]: PrincipalError::StoreError
+    ///
+    /// # Performance considerations
+    ///
+    /// This function performs multiple database queries to collect all entities needed for policy
+    /// evaluation, which could become a performance bottleneck for frequently accessed actors.
+    /// Future optimizations may include:
+    ///   - Combining some queries into a single more complex query
+    ///   - Implementing caching strategies for frequently accessed contexts
+    ///   - Prefetching contexts for related actors in batch operations
+    pub async fn build_principal_context(
path: libs/@local/graph/postgres-store/src/permissions/mod.rs
user: login: TimDiekmann
body: This **_literally_** was added this commit!
created_at: 2025-04-07T19:47:00Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-08T09:49:22Z
body: <!-- add-pr-comment:benchmark-results -->

## Benchmark results
<details><summary><h2>@rust/hash-graph-benches – Integrations</h2></summary>

## representative_read_entity

| Function | Value | Mean | Flame graphs | 
|----------|-------|------|--------------|
| entity_by_id | entity type ID: `https://blockprotocol.org/@alice/types/entity-type/page/v/2` | $$17.0 \mathrm{ms} \pm 195 \mathrm{μs}\left({\color{gray}3.01 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_entity/entity_by_id/entity+type+ID_+`https___blockprotocol.org_@alice_types_entity-t_5/flamegraph.svg) |
| entity_by_id | entity type ID: `https://blockprotocol.org/@alice/types/entity-type/uk-address/v/1` | $$16.8 \mathrm{ms} \pm 181 \mathrm{μs}\left({\color{gray}0.168 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_entity/entity_by_id/entity+type+ID_+`https___blockprotocol.org_@alice_types_entity-t_8/flamegraph.svg) |
| entity_by_id | entity type ID: `https://blockprotocol.org/@alice/types/entity-type/building/v/1` | $$16.7 \mathrm{ms} \pm 173 \mathrm{μs}\left({\color{lightgreen}-29.653 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_entity/entity_by_id/entity+type+ID_+`https___blockprotocol.org_@alice_types_entity-t/flamegraph.svg) |
| entity_by_id | entity type ID: `https://blockprotocol.org/@alice/types/entity-type/block/v/1` | $$16.5 \mathrm{ms} \pm 200 \mathrm{μs}\left({\color{gray}-2.273 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_entity/entity_by_id/entity+type+ID_+`https___blockprotocol.org_@alice_types_entity-t_6/flamegraph.svg) |
| entity_by_id | entity type ID: `https://blockprotocol.org/@alice/types/entity-type/playlist/v/1` | $$16.8 \mathrm{ms} \pm 175 \mathrm{μs}\left({\color{lightgreen}-28.929 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_entity/entity_by_id/entity+type+ID_+`https___blockprotocol.org_@alice_types_entity-t_3/flamegraph.svg) |
| entity_by_id | entity type ID: `https://blockprotocol.org/@alice/types/entity-type/organization/v/1` | $$16.8 \mathrm{ms} \pm 243 \mathrm{μs}\left({\color{lightgreen}-29.533 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_entity/entity_by_id/entity+type+ID_+`https___blockprotocol.org_@alice_types_entity-t_2/flamegraph.svg) |
| entity_by_id | entity type ID: `https://blockprotocol.org/@alice/types/entity-type/song/v/1` | $$16.2 \mathrm{ms} \pm 217 \mathrm{μs}\left({\color{gray}-1.782 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_entity/entity_by_id/entity+type+ID_+`https___blockprotocol.org_@alice_types_entity-t_9/flamegraph.svg) |
| entity_by_id | entity type ID: `https://blockprotocol.org/@alice/types/entity-type/book/v/1` | $$16.8 \mathrm{ms} \pm 224 \mathrm{μs}\left({\color{gray}-0.300 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_entity/entity_by_id/entity+type+ID_+`https___blockprotocol.org_@alice_types_entity-t_7/flamegraph.svg) |
| entity_by_id | entity type ID: `https://blockprotocol.org/@alice/types/entity-type/person/v/1` | $$16.7 \mathrm{ms} \pm 230 \mathrm{μs}\left({\color{gray}-2.236 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_entity/entity_by_id/entity+type+ID_+`https___blockprotocol.org_@alice_types_entity-t_4/flamegraph.svg) |


## scaling_read_entity_complete_zero_depth

| Function | Value | Mean | Flame graphs | 
|----------|-------|------|--------------|
| entity_by_id | 10 entities | $$2.24 \mathrm{ms} \pm 15.2 \mathrm{μs}\left({\color{gray}-0.934 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/scaling_read_entity_complete_zero_depth/entity_by_id/10+entities/flamegraph.svg) |
| entity_by_id | 5 entities | $$2.03 \mathrm{ms} \pm 9.15 \mathrm{μs}\left({\color{gray}-3.032 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/scaling_read_entity_complete_zero_depth/entity_by_id/5+entities/flamegraph.svg) |
| entity_by_id | 50 entities | $$5.33 \mathrm{ms} \pm 24.4 \mathrm{μs}\left({\color{red}27.2 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/scaling_read_entity_complete_zero_depth/entity_by_id/50+entities/flamegraph.svg) |
| entity_by_id | 25 entities | $$2.79 \mathrm{ms} \pm 13.1 \mathrm{μs}\left({\color{gray}-1.656 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/scaling_read_entity_complete_zero_depth/entity_by_id/25+entities/flamegraph.svg) |
| entity_by_id | 1 entities | $$2.03 \mathrm{ms} \pm 13.3 \mathrm{μs}\left({\color{gray}-0.217 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/scaling_read_entity_complete_zero_depth/entity_by_id/1+entities/flamegraph.svg) |


## representative_read_entity_type

| Function | Value | Mean | Flame graphs | 
|----------|-------|------|--------------|
| get_entity_type_by_id | Account ID: `d4e16033-c281-4cde-aa35-9085bf2e7579` | $$2.15 \mathrm{ms} \pm 6.74 \mathrm{μs}\left({\color{gray}-1.243 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_entity_type/get_entity_type_by_id/Account+ID_+`d4e16033-c281-4cde-aa35-9085bf2e7579`/flamegraph.svg) |


## scaling_read_entity_linkless

| Function | Value | Mean | Flame graphs | 
|----------|-------|------|--------------|
| entity_by_id | 10000 entities | $$9.30 \mathrm{ms} \pm 82.0 \mathrm{μs}\left({\color{gray}-1.003 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/scaling_read_entity_linkless/entity_by_id/10000+entities/flamegraph.svg) |
| entity_by_id | 100 entities | $$2.13 \mathrm{ms} \pm 6.95 \mathrm{μs}\left({\color{lightgreen}-5.735 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/scaling_read_entity_linkless/entity_by_id/100+entities/flamegraph.svg) |
| entity_by_id | 10 entities | $$2.03 \mathrm{ms} \pm 8.23 \mathrm{μs}\left({\color{gray}-1.458 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/scaling_read_entity_linkless/entity_by_id/10+entities/flamegraph.svg) |
| entity_by_id | 1000 entities | $$2.97 \mathrm{ms} \pm 14.9 \mathrm{μs}\left({\color{lightgreen}-7.934 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/scaling_read_entity_linkless/entity_by_id/1000+entities/flamegraph.svg) |
| entity_by_id | 1 entities | $$2.02 \mathrm{ms} \pm 9.07 \mathrm{μs}\left({\color{gray}-1.697 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/scaling_read_entity_linkless/entity_by_id/1+entities/flamegraph.svg) |


## representative_read_multiple_entities

| Function | Value | Mean | Flame graphs | 
|----------|-------|------|--------------|
| entity_by_property | depths: DT=0, PT=2, ET=2, E=2 | $$54.0 \mathrm{ms} \pm 293 \mathrm{μs}\left({\color{gray}-1.170 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_multiple_entities/entity_by_property/depths_+DT=0,+PT=2,+ET=2,+E=2/flamegraph.svg) |
| entity_by_property | depths: DT=0, PT=0, ET=0, E=0 | $$39.3 \mathrm{ms} \pm 338 \mathrm{μs}\left({\color{gray}-0.192 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_multiple_entities/entity_by_property/depths_+DT=0,+PT=0,+ET=0,+E=0/flamegraph.svg) |
| entity_by_property | depths: DT=0, PT=0, ET=0, E=2 | $$43.5 \mathrm{ms} \pm 197 \mathrm{μs}\left({\color{gray}-0.538 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_multiple_entities/entity_by_property/depths_+DT=0,+PT=0,+ET=0,+E=2/flamegraph.svg) |
| entity_by_property | depths: DT=2, PT=2, ET=2, E=2 | $$58.1 \mathrm{ms} \pm 365 \mathrm{μs}\left({\color{gray}0.433 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_multiple_entities/entity_by_property/depths_+DT=2,+PT=2,+ET=2,+E=2/flamegraph.svg) |
| entity_by_property | depths: DT=255, PT=255, ET=255, E=255 | $$66.8 \mathrm{ms} \pm 310 \mathrm{μs}\left({\color{gray}-1.786 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_multiple_entities/entity_by_property/depths_+DT=255,+PT=255,+ET=255,+E=255/flamegraph.svg) |
| entity_by_property | depths: DT=0, PT=0, ET=2, E=2 | $$49.8 \mathrm{ms} \pm 607 \mathrm{μs}\left({\color{gray}0.799 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_multiple_entities/entity_by_property/depths_+DT=0,+PT=0,+ET=2,+E=2/flamegraph.svg) |
| link_by_source_by_property | depths: DT=0, PT=2, ET=2, E=2 | $$88.3 \mathrm{ms} \pm 553 \mathrm{μs}\left({\color{gray}-0.477 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_multiple_entities/link_by_source_by_property/depths_+DT=0,+PT=2,+ET=2,+E=2/flamegraph.svg) |
| link_by_source_by_property | depths: DT=0, PT=0, ET=0, E=0 | $$39.3 \mathrm{ms} \pm 210 \mathrm{μs}\left({\color{gray}-0.025 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_multiple_entities/link_by_source_by_property/depths_+DT=0,+PT=0,+ET=0,+E=0/flamegraph.svg) |
| link_by_source_by_property | depths: DT=0, PT=0, ET=0, E=2 | $$74.4 \mathrm{ms} \pm 386 \mathrm{μs}\left({\color{gray}-1.513 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_multiple_entities/link_by_source_by_property/depths_+DT=0,+PT=0,+ET=0,+E=2/flamegraph.svg) |
| link_by_source_by_property | depths: DT=2, PT=2, ET=2, E=2 | $$92.8 \mathrm{ms} \pm 542 \mathrm{μs}\left({\color{gray}0.371 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_multiple_entities/link_by_source_by_property/depths_+DT=2,+PT=2,+ET=2,+E=2/flamegraph.svg) |
| link_by_source_by_property | depths: DT=255, PT=255, ET=255, E=255 | $$101 \mathrm{ms} \pm 427 \mathrm{μs}\left({\color{gray}-1.102 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_multiple_entities/link_by_source_by_property/depths_+DT=255,+PT=255,+ET=255,+E=255/flamegraph.svg) |
| link_by_source_by_property | depths: DT=0, PT=0, ET=2, E=2 | $$83.7 \mathrm{ms} \pm 417 \mathrm{μs}\left({\color{gray}-1.258 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/representative_read_multiple_entities/link_by_source_by_property/depths_+DT=0,+PT=0,+ET=2,+E=2/flamegraph.svg) |


## scaling_read_entity_complete_one_depth

| Function | Value | Mean | Flame graphs | 
|----------|-------|------|--------------|
| entity_by_id | 10 entities | $$57.9 \mathrm{ms} \pm 143 \mathrm{μs}\left({\color{gray}-1.678 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/scaling_read_entity_complete_one_depth/entity_by_id/10+entities/flamegraph.svg) |
| entity_by_id | 5 entities | $$27.1 \mathrm{ms} \pm 116 \mathrm{μs}\left({\color{gray}-1.252 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/scaling_read_entity_complete_one_depth/entity_by_id/5+entities/flamegraph.svg) |
| entity_by_id | 50 entities | $$5.42 \mathrm{s} \pm 545 \mathrm{ms}\left({\color{gray}-0.479 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/scaling_read_entity_complete_one_depth/entity_by_id/50+entities/flamegraph.svg) |
| entity_by_id | 25 entities | $$86.0 \mathrm{ms} \pm 569 \mathrm{μs}\left({\color{lightgreen}-53.489 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/scaling_read_entity_complete_one_depth/entity_by_id/25+entities/flamegraph.svg) |
| entity_by_id | 1 entities | $$20.9 \mathrm{ms} \pm 104 \mathrm{μs}\left({\color{gray}-2.380 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/e0b4dc208b2bda60fba8db8e7b21ee541f44a53a/scaling_read_entity_complete_one_depth/entity_by_id/1+entities/flamegraph.svg) |

</details>

author_association: CONTRIBUTOR