Repository: hashintel/hash
Pull request #7121: H-4424, H-4425: Implement and expose policy querying
Author: login: TimDiekmann
Labels: 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/tests > integration
description: New or updated integration tests
Created: 2025-05-09T12:46:45Z | Merged: 2025-05-12T15:45:04Z

## 🌟 What is the purpose of this PR?

Extend the query capability in the Graph and expose three endpoints to the Node API:

- `getPolicyById`
- `queryPolicies`: search for policies matching a specific (optional) filter
- `resolvePoliciesForActor`: searches all policies which applies to a specific user. In comparison to `queryPolicies`, this does not only query for a principal filter, but also recursively from the actor onwards. While `queryPolicies` look at all policies and filter out policies which don't match the filter, this function starts from an actor and traverses principal constraints and returns all associated policies.

## 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


Diff:
diff --git a/libs/@local/graph/api/openapi/openapi.json b/libs/@local/graph/api/openapi/openapi.json
index 9cdc0f9b0c9..cb254e45a12 100644
--- a/libs/@local/graph/api/openapi/openapi.json
+++ b/libs/@local/graph/api/openapi/openapi.json
@@ -2915,6 +2915,139 @@
         }
       }
     },
+    "/policies/query": {
+      "post": {
+        "tags": [
+          "Graph",
+          "Policy"
+        ],
+        "operationId": "query_policies",
+        "parameters": [
+          {
+            "name": "X-Authenticated-User-Actor-Id",
+            "in": "header",
+            "description": "The ID of the actor which is used to authorize the request",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/ActorEntityUuid"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {}
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "List of policies matching the filter",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {}
+                }
+              }
+            }
+          },
+          "500": {
+            "description": "Store error occurred"
+          }
+        }
+      }
+    },
+    "/policies/resolve/actor": {
+      "post": {
+        "tags": [
+          "Graph",
+          "Policy"
+        ],
+        "operationId": "resolve_policies_for_actor",
+        "parameters": [
+          {
+            "name": "X-Authenticated-User-Actor-Id",
+            "in": "header",
+            "description": "The ID of the actor which is used to authorize the request",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/ActorEntityUuid"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {}
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "List of policies found for the actor",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {}
+                }
+              }
+            }
+          },
+          "500": {
+            "description": "Store error occurred"
+          }
+        }
+      }
+    },
+    "/policies/{policy_id}": {
+      "get": {
+        "tags": [
+          "Graph",
+          "Policy"
+        ],
+        "operationId": "get_policy_by_id",
+        "parameters": [
+          {
+            "name": "X-Authenticated-User-Actor-Id",
+            "in": "header",
+            "description": "The ID of the actor which is used to authorize the request",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/ActorEntityUuid"
+            }
+          },
+          {
+            "name": "policy_id",
+            "in": "path",
+            "description": "The ID of the policy to find",
+            "required": true,
+            "schema": {
+              "type": "string",
+              "format": "uuid"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "The policy with the specified id or `null`",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "nullable": true
+                }
+              }
+            }
+          },
+          "500": {
+            "description": "Store error occurred"
+          }
+        }
+      }
+    },
     "/property-types": {
       "post": {
         "tags": [
diff --git a/libs/@local/graph/api/src/rest/actor.rs b/libs/@local/graph/api/src/rest/actor.rs
index edf85fa680c..f54707b78a1 100644
--- a/libs/@local/graph/api/src/rest/actor.rs
+++ b/libs/@local/graph/api/src/rest/actor.rs
@@ -11,7 +11,10 @@ use axum::{
 use error_stack::ResultExt as _;
 use hash_graph_authorization::{
     AuthorizationApi as _, AuthorizationApiPool,
-    policies::store::PrincipalStore,
+    policies::{
+        Policy, PolicyId,
+        store::{PolicyFilter, PolicyStore, PrincipalStore},
+    },
     schema::{AccountGroupPermission, AccountGroupRelationAndSubject},
     zanzibar::Consistency,
 };
@@ -44,6 +47,10 @@ use crate::rest::{
 
         check_account_group_permission,
         get_actor_group_relations,
+
+        get_policy_by_id,
+        query_policies,
+        resolve_policies_for_actor,
     ),
     components(
         schemas(
@@ -70,7 +77,7 @@ impl ActorResource {
     where
         S: StorePool + Send + Sync + 'static,
         A: AuthorizationApiPool + Send + Sync + 'static,
-        for<'p, 'a> S::Store<'p, A::Api<'a>>: PrincipalStore,
+        for<'p, 'a> S::Store<'p, A::Api<'a>>: PrincipalStore + PolicyStore,
     {
         // TODO: The URL format here is preliminary and will have to change.
         Router::new()
@@ -101,6 +108,13 @@ impl ActorResource {
                         .route("/relations", get(get_actor_group_relations::<A>)),
                 ),
             )
+            .nest(
+                "/policies",
+                Router::new()
+                    .route("/:policy_id", get(get_policy_by_id::<S, A>))
+                    .route("/query", post(query_policies::<S, A>))
+                    .route("/resolve/actor", post(resolve_policies_for_actor::<S, A>)),
+            )
     }
 }
 
@@ -389,3 +403,141 @@ where
 
     result
 }
+
+#[utoipa::path(
+    get,
+    path = "/policies/{policy_id}",
+    tag = "Policy",
+    params(
+        ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"),
+        ("policy_id" = Uuid, Path, description = "The ID of the policy to find"),
+    ),
+    responses(
+        (status = 200, content_type = "application/json", description = "The policy with the specified id or `null`", body = Option<Value>),
+
+        (status = 500, description = "Store error occurred"),
+    )
+)]
+#[tracing::instrument(
+    level = "info",
+    skip(store_pool, authorization_api_pool, temporal_client)
+)]
+async fn get_policy_by_id<S, A>(
+    AuthenticatedUserHeader(authenticated_actor_id): AuthenticatedUserHeader,
+    Path(policy_id): Path<PolicyId>,
+    store_pool: Extension<Arc<S>>,
+    authorization_api_pool: Extension<Arc<A>>,
+    temporal_client: Extension<Option<Arc<TemporalClient>>>,
+) -> Result<Json<Option<Policy>>, Response>
+where
+    S: StorePool + Send + Sync,
+    A: AuthorizationApiPool + Send + Sync,
+    for<'p, 'a> S::Store<'p, A::Api<'a>>: PolicyStore,
+{
+    store_pool
+        .acquire(
+            authorization_api_pool
+                .acquire()
+                .await
+                .map_err(report_to_response)?,
+            temporal_client.0,
+        )
+        .await
+        .map_err(report_to_response)?
+        .get_policy_by_id(authenticated_actor_id, policy_id)
+        .await
+        .map_err(report_to_response)
+        .map(Json)
+}
+
+#[utoipa::path(
+    post,
+    path = "/policies/query",
+    request_body = Value,
+    tag = "Policy",
+    params(
+        ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"),
+    ),
+    responses(
+        (status = 200, content_type = "application/json", description = "List of policies matching the filter", body = Vec<Value>),
+
+        (status = 500, description = "Store error occurred"),
+    )
+)]
+#[tracing::instrument(
+    level = "info",
+    skip(store_pool, authorization_api_pool, temporal_client)
+)]
+async fn query_policies<S, A>(
+    AuthenticatedUserHeader(authenticated_actor_id): AuthenticatedUserHeader,
+    store_pool: Extension<Arc<S>>,
+    authorization_api_pool: Extension<Arc<A>>,
+    temporal_client: Extension<Option<Arc<TemporalClient>>>,
+    Json(filter): Json<PolicyFilter>,
+) -> Result<Json<Vec<Policy>>, Response>
+where
+    S: StorePool + Send + Sync,
+    A: AuthorizationApiPool + Send + Sync,
+    for<'p, 'a> S::Store<'p, A::Api<'a>>: PolicyStore,
+{
+    store_pool
+        .acquire(
+            authorization_api_pool
+                .acquire()
+                .await
+                .map_err(report_to_response)?,
+            temporal_client.0,
+        )
+        .await
+        .map_err(report_to_response)?
+        .query_policies(authenticated_actor_id, &filter)
+        .await
+        .map_err(report_to_response)
+        .map(Json)
+}
+
+#[utoipa::path(
+    post,
+    path = "/policies/resolve/actor",
+    request_body = Value,
+    tag = "Policy",
+    params(
+        ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"),
+    ),
+    responses(
+        (status = 200, content_type = "application/json", description = "List of policies found for the actor", body = Vec<Value>),
+
+        (status = 500, description = "Store error occurred"),
+    )
+)]
+#[tracing::instrument(
+    level = "info",
+    skip(store_pool, authorization_api_pool, temporal_client)
+)]
+async fn resolve_policies_for_actor<S, A>(
+    AuthenticatedUserHeader(authenticated_actor_id): AuthenticatedUserHeader,
+    store_pool: Extension<Arc<S>>,
+    authorization_api_pool: Extension<Arc<A>>,
+    temporal_client: Extension<Option<Arc<TemporalClient>>>,
+    Json(actor_id): Json<ActorId>,
+) -> Result<Json<Vec<Policy>>, Response>
+where
+    S: StorePool + Send + Sync,
+    A: AuthorizationApiPool + Send + Sync,
+    for<'p, 'a> S::Store<'p, A::Api<'a>>: PolicyStore,
+{
+    store_pool
+        .acquire(
+            authorization_api_pool
+                .acquire()
+                .await
+                .map_err(report_to_response)?,
+            temporal_client.0,
+        )
+        .await
+        .map_err(report_to_response)?
+        .resolve_policies_for_actor(authenticated_actor_id, actor_id)
+        .await
+        .map_err(report_to_response)
+        .map(Json)
+}
diff --git a/libs/@local/graph/api/src/rest/mod.rs b/libs/@local/graph/api/src/rest/mod.rs
index 5c70c4fe192..b329b2f4aa8 100644
--- a/libs/@local/graph/api/src/rest/mod.rs
+++ b/libs/@local/graph/api/src/rest/mod.rs
@@ -30,7 +30,7 @@ use futures::{SinkExt as _, channel::mpsc::Sender};
 use hash_codec::numeric::Real;
 use hash_graph_authorization::{
     AuthorizationApiPool,
-    policies::store::PrincipalStore,
+    policies::store::{PolicyStore, PrincipalStore},
     schema::{
         AccountGroupPermission, DataTypePermission, EntityPermission, EntityTypePermission,
         PropertyTypePermission,
@@ -197,7 +197,7 @@ fn api_resources<S, A>() -> Vec<Router>
 where
     S: StorePool + Send + Sync + 'static,
     A: AuthorizationApiPool + Send + Sync + 'static,
-    for<'pool, 'api> S::Store<'pool, A::Api<'api>>: RestApiStore + PrincipalStore,
+    for<'pool, 'api> S::Store<'pool, A::Api<'api>>: RestApiStore + PrincipalStore + PolicyStore,
 {
     vec![
         actor::ActorResource::routes::<S, A>(),
@@ -366,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<'p, 'a> S::Store<'p, A::Api<'a>>: RestApiStore + PrincipalStore,
+    for<'p, 'a> S::Store<'p, A::Api<'a>>: RestApiStore + PrincipalStore + PolicyStore,
 {
     // All api resources are merged together into a super-router.
     let merged_routes = api_resources::<S, A>()
diff --git a/libs/@local/graph/authorization/src/policies/store/error.rs b/libs/@local/graph/authorization/src/policies/store/error.rs
index 09550063d0e..152e22634d0 100644
--- a/libs/@local/graph/authorization/src/policies/store/error.rs
+++ b/libs/@local/graph/authorization/src/policies/store/error.rs
@@ -155,6 +155,8 @@ impl Error for PolicyStoreError {}
 pub enum GetPoliciesError {
     #[display("Actor with ID `{actor_id}` does not exist")]
     ActorNotFound { actor_id: ActorId },
+    #[display("Invalid principal constraint")]
+    InvalidPrincipalConstraint,
     #[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 80224218535..ed3a2b5aa27 100644
--- a/libs/@local/graph/authorization/src/policies/store/mod.rs
+++ b/libs/@local/graph/authorization/src/policies/store/mod.rs
@@ -89,6 +89,86 @@ pub enum RoleUnassignmentStatus {
     NotAssigned,
 }
 
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
+#[cfg_attr(feature = "codegen", derive(specta::Type))]
+#[serde(rename_all = "kebab-case", tag = "filter", deny_unknown_fields)]
+pub enum PrincipalFilter {
+    Unconstrained,
+    Constrained(PrincipalConstraint),
+}
+
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
+#[cfg_attr(feature = "codegen", derive(specta::Type))]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct PolicyFilter {
+    pub principal: Option<PrincipalFilter>,
+}
+
+#[trait_variant::make(Send)]
+pub trait PolicyStore {
+    /// Retrieves a policy by its ID.
+    ///
+    /// # Errors
+    ///
+    /// - [`StoreError`] if the underlying store returns an error
+    ///
+    /// [`StoreError`]: GetPoliciesError::StoreError
+    async fn get_policy_by_id(
+        &self,
+        authenticated_actor: ActorEntityUuid,
+        policy_id: PolicyId,
+    ) -> Result<Option<Policy>, Report<GetPoliciesError>>;
+
+    /// Queries for policies in the local store that match the provided filter.
+    ///
+    /// This method queries the underlying policy store using the given [`PolicyFilter`] and returns
+    /// a list of matching [`Policy`] objects. The filter can be used to specify criteria such as
+    /// policy type, subject, resource, or action.
+    ///
+    /// Note that this does not resolve indirect policies (e.g., policies applying to roles held by
+    /// a specific actor). For resolving all policies applicable to an actor, including indirect
+    /// ones, use [`resolve_policies_for_actor`].
+    ///
+    /// [`resolve_policies_for_actor`]: Self::resolve_policies_for_actor
+    ///
+    /// # Errors
+    ///
+    /// - [`StoreError`] if the underlying store returns an error
+    ///
+    /// [`StoreError`]: GetPoliciesError::StoreError
+    async fn query_policies(
+        &self,
+        authenticated_actor: ActorEntityUuid,
+        filter: &PolicyFilter,
+    ) -> Result<Vec<Policy>, Report<GetPoliciesError>>;
+
+    /// Resolves all policies that apply to the given actor, including both direct and indirect
+    /// associations.
+    ///
+    /// This method queries the underlying policy store to find policies that are relevant to the
+    /// specified actor. The policies returned may include those that apply to the actor directly,
+    /// as well as policies that apply to any roles the actor has.
+    ///
+    /// This provides a complete set of policies that apply to an actor, including all policies that
+    ///   - apply to the actor itself,
+    ///   - apply to the actor's roles,
+    ///   - apply to the actor's groups, and
+    ///   - apply to the actor's parent groups (for teams).
+    ///
+    /// # Errors
+    ///
+    /// - [`ActorNotFound`] if the target actor does not exist in the store.
+    /// - [`StoreError`] if the underlying store encounters an error during resolution.
+    ///
+    /// [`ActorNotFound`]: GetPoliciesError::ActorNotFound
+    /// [`StoreError`]: GetPoliciesError::StoreError
+    async fn resolve_policies_for_actor(
+        &self,
+        authenticated_actor: ActorEntityUuid,
+        actor_id: ActorId,
+    ) -> Result<Vec<Policy>, Report<GetPoliciesError>>;
+}
+
 #[trait_variant::make(PrincipalStore: Send)]
 pub trait LocalPrincipalStore {
     /// Searches for the system account and returns its ID.
@@ -216,7 +296,7 @@ pub trait LocalPrincipalStore {
     ) -> Result<RoleUnassignmentStatus, Report<RoleAssignmentError>>;
 }
 
-pub trait PolicyStore {
+pub trait OldPolicyStore {
     /// Creates a new user within the given web and returns its ID.
     ///
     /// # Errors
@@ -415,7 +495,7 @@ pub struct MemoryPolicyStore {
     policies: HashMap<PrincipalIndex, HashMap<PolicyId, Policy>>,
 }
 
-impl PolicyStore for MemoryPolicyStore {
+impl OldPolicyStore for MemoryPolicyStore {
     fn create_user(&mut self, web_id: WebId) -> Result<UserId, Report<ActorCreationError>> {
         ensure!(
             self.teams.contains_key(&ActorGroupId::Web(web_id)),
diff --git a/libs/@local/graph/authorization/tests/policies/main.rs b/libs/@local/graph/authorization/tests/policies/main.rs
index 6e4d9401bf5..54ab6568612 100644
--- a/libs/@local/graph/authorization/tests/policies/main.rs
+++ b/libs/@local/graph/authorization/tests/policies/main.rs
@@ -16,7 +16,7 @@ use hash_graph_authorization::policies::{
     Authorized, ContextBuilder, PartialResourceId, PolicySet, Request, RequestContext,
     action::ActionName,
     resource::{EntityResource, EntityTypeId, EntityTypeResource},
-    store::{MemoryPolicyStore, PolicyStore},
+    store::{MemoryPolicyStore, OldPolicyStore},
 };
 use type_system::{
     knowledge::entity::id::EntityUuid,
@@ -44,7 +44,7 @@ struct TestWeb {
 
 impl TestWeb {
     fn generate(
-        policy_store: &mut impl PolicyStore,
+        policy_store: &mut impl OldPolicyStore,
         context: &mut ContextBuilder,
         shortname: impl Into<String>,
     ) -> Result<Self, Box<dyn Error>> {
@@ -87,7 +87,7 @@ struct TestUser {
 
 impl TestUser {
     fn generate(
-        policy_store: &mut impl PolicyStore,
+        policy_store: &mut impl OldPolicyStore,
         context: &mut ContextBuilder,
         shortname: impl Into<String>,
     ) -> Result<Self, Box<dyn Error>> {
@@ -130,7 +130,7 @@ struct TestMachine {
 impl TestMachine {
     fn generate(
         web_id: WebId,
-        policy_store: &mut impl PolicyStore,
+        policy_store: &mut impl OldPolicyStore,
         context: &mut ContextBuilder,
         name: impl Into<String>,
     ) -> Result<Self, Box<dyn Error>> {
@@ -169,7 +169,7 @@ struct TestSystem {
 
 impl TestSystem {
     fn generate(
-        policy_store: &mut impl PolicyStore,
+        policy_store: &mut impl OldPolicyStore,
         context: &mut ContextBuilder,
     ) -> Result<Self, Box<dyn Error>> {
         let web = TestWeb::generate(policy_store, context, "h")?;
diff --git a/libs/@local/graph/authorization/types/index.snap.d.ts b/libs/@local/graph/authorization/types/index.snap.d.ts
index f1b806c5fae..e793f240382 100644
--- a/libs/@local/graph/authorization/types/index.snap.d.ts
+++ b/libs/@local/graph/authorization/types/index.snap.d.ts
@@ -79,3 +79,11 @@ export type EntityTypeResourceFilter = {
 	type: "isVersion"
 	version: OntologyTypeVersion
 };
+export interface PolicyFilter {
+	principal?: PrincipalFilter;
+}
+export type PrincipalFilter = {
+	filter: "unconstrained"
+} | {
+	filter: "constrained"
+} & PrincipalConstraint;
diff --git a/libs/@local/graph/postgres-store/src/permissions/mod.rs b/libs/@local/graph/postgres-store/src/permissions/mod.rs
index e8438df6da5..41cc12f91ba 100644
--- a/libs/@local/graph/postgres-store/src/permissions/mod.rs
+++ b/libs/@local/graph/postgres-store/src/permissions/mod.rs
@@ -9,17 +9,14 @@ use hash_graph_authorization::{
         Context, ContextBuilder, Policy, PolicyId,
         action::ActionName,
         principal::PrincipalConstraint,
-        resource::ResourceConstraint,
         store::{RoleAssignmentStatus, RoleUnassignmentStatus},
     },
 };
-use postgres_types::{Json, ToSql};
+use postgres_types::Json;
 use tokio_postgres::{GenericClient as _, error::SqlState};
 use type_system::principal::{
     PrincipalId, PrincipalType,
-    actor::{
-        Actor, ActorEntityUuid, ActorId, ActorType, Ai, AiId, Machine, MachineId, User, UserId,
-    },
+    actor::{Actor, ActorEntityUuid, ActorId, Ai, AiId, Machine, MachineId, User, UserId},
     actor_group::{ActorGroup, ActorGroupEntityUuid, ActorGroupId, Team, TeamId, Web, WebId},
     role::{Role, RoleId, RoleName, TeamRole, TeamRoleId, WebRole, WebRoleId},
 };
@@ -29,14 +26,6 @@ use crate::store::{AsClient, PostgresStore};
 
 mod error;
 pub use self::error::{ActionError, PolicyError, PrincipalError};
-const fn principal_type_into_actor_type(principal_type: PrincipalType) -> Option<ActorType> {
-    match principal_type {
-        PrincipalType::User => Some(ActorType::User),
-        PrincipalType::Machine => Some(ActorType::Machine),
-        PrincipalType::Ai => Some(ActorType::Ai),
-        _ => None,
-    }
-}
 
 impl<C, A> PostgresStore<C, A>
 where
@@ -1480,180 +1469,4 @@ where
             .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
-    ///   - apply to the actor itself,
-    ///   - apply to the actor's roles,
-    ///   - apply to the actor's groups, and
-    ///   - apply to the actor's parent groups (for teams).
-    ///
-    /// # Errors
-    ///
-    /// - [`PrincipalNotFound`] if the actor with the given ID doesn't exist
-    /// - [`StoreError`] if a database error occurs
-    ///
-    /// [`PrincipalNotFound`]: PrincipalError::PrincipalNotFound
-    /// [`StoreError`]: PrincipalError::StoreError
-    // TODO: We should probably allow filtering based on the action as well here
-    #[expect(clippy::too_many_lines)]
-    pub async fn get_policies_for_actor(
-        &self,
-        actor_id: ActorId,
-    ) -> Result<HashMap<PolicyId, Policy>, Report<PolicyError>> {
-        // The below query does several things. It:
-        //   1. gets all principals that the actor can act as
-        //   2. gets all policies that apply to those principals
-        //   3. TODO: filters the policies based on the action -- currently not implemented
-        //
-        // Principals are retrieved by:
-        //   - the actor itself, filtered by the actor ID and actor type
-        //   - all roles assigned to the actor, filtered by the actor ID
-        //   - all actor groups associated with those roles, determined by the role's actor group ID
-        //   - all parent actor groups of those actor groups (for teams), determined by the actor
-        //     group hierarchy
-        //
-        // The actions are associated in the `policy_action` table. We join that table and aggregate
-        // the actions for each policy. All actions are included, but the action hierarchy is used
-        // to determine which actions are relevant to the actor.
-        self.as_client()
-            .query_raw(
-                "
-                WITH principals AS (
-                    -- The actor itself
-                    SELECT id, principal_type
-                    FROM actor
-                    WHERE id = $1 AND principal_type = $2
-
-                    UNION ALL
-
-                    -- All roles directly assigned to the actor
-                    SELECT role.id, role.principal_type
-                    FROM actor_role
-                    JOIN role ON actor_role.role_id = role.id
-                    WHERE actor_role.actor_id = $1
-
-                    UNION ALL
-
-                    -- Direct actor group of each role - always included
-                    SELECT actor_group.id, actor_group.principal_type
-                    FROM actor_role
-                    JOIN role ON actor_role.role_id = role.id
-                    JOIN actor_group ON actor_group.id = role.actor_group_id
-                    WHERE actor_role.actor_id = $1
-
-                    UNION ALL
-
-                    -- All parent actor groups of actor groups (recursively through hierarchy)
-                    SELECT parent.id, parent.principal_type
-                    FROM actor_role
-                    JOIN role ON actor_role.role_id = role.id
-                    JOIN team_hierarchy
-                      ON team_hierarchy.child_id = role.actor_group_id
-                    JOIN actor_group parent ON parent.id = team_hierarchy.parent_id
-                    WHERE actor_role.actor_id = $1
-                ),
-                -- We filter out policies that don't apply to the actor's type or principal ID
-                policy AS (
-                    -- global and actor-type based policies
-                    SELECT policy.*
-                    FROM policy
-                    WHERE principal_id IS NULL
-                      AND (actor_type IS NULL OR actor_type = $2)
-
-                    UNION ALL
-
-                    -- actor type policies
-                    SELECT policy.*
-                    FROM policy
-                    JOIN principals
-                      ON policy.principal_id = principals.id
-                     AND policy.principal_type = principals.principal_type
-                    WHERE policy.actor_type IS NULL OR policy.actor_type = $2
-                )
-                SELECT
-                    policy.id,
-                    policy.effect,
-                    policy.principal_id,
-                    policy.principal_type,
-                    policy.actor_type,
-                    policy.resource_constraint,
-                    array_agg(policy_action.action_name)
-                FROM policy
-                JOIN policy_action ON policy.id = policy_action.policy_id
-                GROUP BY
-                    policy.id, policy.effect, policy.principal_id, policy.principal_type,
-                    policy.actor_type, policy.resource_constraint
-                ",
-                [
-                    &actor_id as &(dyn ToSql + Sync),
-                    &PrincipalType::from(actor_id.actor_type()),
-                ],
-            )
-            .await
-            .change_context(PolicyError::StoreError)?
-            .map_err(|error| Report::new(error).change_context(PolicyError::StoreError))
-            .and_then(async |row| -> Result<_, Report<PolicyError>> {
-                let policy_id = PolicyId::new(row.get(0));
-                let effect = row.get(1);
-                let principal_uuid: Option<Uuid> = row.get(2);
-                let principal_type: Option<PrincipalType> = row.get(3);
-                let actor_type: Option<PrincipalType> = row.get(4);
-                let resource_constraint = row
-                    .get::<_, Option<Json<ResourceConstraint>>>(5)
-                    .map(|json| json.0);
-                let actions = row.get(6);
-
-                let principal_id = match (principal_uuid, principal_type) {
-                    (Some(uuid), Some(principal_type)) => {
-                        Some(PrincipalId::new(uuid, principal_type))
-                    }
-                    (None, None) => None,
-                    (Some(_), None) | (None, Some(_)) => {
-                        return Err(Report::new(PolicyError::InvalidPrincipalConstraint));
-                    }
-                };
-
-                let actor_type = actor_type
-                    .map(|principal_type| {
-                        principal_type_into_actor_type(principal_type)
-                            .ok_or(PolicyError::InvalidPrincipalConstraint)
-                    })
-                    .transpose()?;
-
-                let principal_constraint = match (principal_id, actor_type) {
-                    (None, None) => None,
-                    (None, Some(actor_type)) => Some(PrincipalConstraint::ActorType { actor_type }),
-                    (Some(PrincipalId::Actor(actor)), None) => {
-                        Some(PrincipalConstraint::Actor { actor })
-                    }
-                    (Some(PrincipalId::ActorGroup(actor_group)), actor_type) => {
-                        Some(PrincipalConstraint::ActorGroup {
-                            actor_group,
-                            actor_type,
-                        })
-                    }
-                    (Some(PrincipalId::Role(role)), actor_type) => {
-                        Some(PrincipalConstraint::Role { role, actor_type })
-                    }
-                    _ => return Err(Report::new(PolicyError::InvalidPrincipalConstraint)),
-                };
-
-                Ok((
-                    policy_id,
-                    Policy {
-                        id: policy_id,
-                        effect,
-                        principal: principal_constraint,
-                        actions,
-                        resource: resource_constraint,
-                        constraints: None,
-                    },
-                ))
-            })
-            .try_collect::<HashMap<_, _>>()
-            .await
-            .change_context(PolicyError::StoreError)
-    }
 }
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 fdc207cf7b8..b47a11660b3 100644
--- a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs
+++ b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs
@@ -20,12 +20,13 @@ use hash_graph_authorization::{
         RequestContext,
         action::ActionName,
         principal::PrincipalConstraint,
+        resource::ResourceConstraint,
         store::{
-            CreateWebParameter, CreateWebResponse, PrincipalStore, RoleAssignmentStatus,
-            RoleUnassignmentStatus,
+            CreateWebParameter, CreateWebResponse, PolicyFilter, PolicyStore, PrincipalFilter,
+            PrincipalStore, RoleAssignmentStatus, RoleUnassignmentStatus,
             error::{
-                EnsureSystemPoliciesError, GetSystemAccountError, RoleAssignmentError,
-                WebCreationError,
+                EnsureSystemPoliciesError, GetPoliciesError, GetSystemAccountError,
+                RoleAssignmentError, WebCreationError,
             },
         },
     },
@@ -71,8 +72,8 @@ use type_system::{
         provenance::{OntologyEditionProvenance, OntologyOwnership, OntologyProvenance},
     },
     principal::{
-        PrincipalType,
-        actor::{ActorEntityUuid, ActorId, AiId, MachineId},
+        PrincipalId, PrincipalType,
+        actor::{ActorEntityUuid, ActorId, ActorType, AiId, MachineId},
         actor_group::{ActorGroupEntityUuid, ActorGroupId, TeamId, WebId},
         role::RoleName,
     },
@@ -119,8 +120,8 @@ where
         system_machine_actor: ActorId,
     ) -> Result<(), Report<EnsureSystemPoliciesError>> {
         tracing::info!("Seeding system policies");
-        for (policy_id, policy) in self
-            .get_policies_for_actor(system_machine_actor)
+        for policy in self
+            .resolve_policies_for_actor(system_machine_actor.into(), system_machine_actor)
             .await
             .change_context(EnsureSystemPoliciesError::ReadPoliciesFailed)?
         {
@@ -128,7 +129,7 @@ where
                 Some(PrincipalConstraint::Actor { actor }) if actor == system_machine_actor => {
                     // TODO: Implement logic for handling removal of old policies to avoid
                     //       that we re-create policies that are already present.
-                    self.remove_policy(policy_id)
+                    self.remove_policy(policy.id)
                         .await
                         .change_context(EnsureSystemPoliciesError::RemoveOldPolicyFailed)?;
                 }
@@ -278,14 +279,14 @@ where
             .await
             .change_context(WebCreationError::StoreError)?;
         let policies = self
-            .get_policies_for_actor(actor)
+            .resolve_policies_for_actor(actor.into(), actor)
             .await
             .change_context(WebCreationError::StoreError)?;
 
         let web_id = WebId::new(parameter.id.unwrap_or_else(Uuid::new_v4));
 
         let policy_set = PolicySet::default()
-            .with_policies(policies.values())
+            .with_policies(&policies)
             .change_context(WebCreationError::StoreError)?;
         match policy_set
             .evaluate(
@@ -756,6 +757,330 @@ where
     }
 }
 
+struct PolicyParts {
+    id: PolicyId,
+    effect: Effect,
+    principal_uuid: Option<Uuid>,
+    principal_type: Option<PrincipalType>,
+    actor_type: Option<PrincipalType>,
+    resource_constraint: Option<ResourceConstraint>,
+    actions: Vec<ActionName>,
+}
+impl PolicyParts {
+    fn into_policy(self) -> Result<Policy, Report<GetPoliciesError>> {
+        let principal_id = match (self.principal_uuid, self.principal_type) {
+            (Some(uuid), Some(principal_type)) => Some(PrincipalId::new(uuid, principal_type)),
+            (None, None) => None,
+            (Some(_), None) | (None, Some(_)) => {
+                return Err(Report::new(GetPoliciesError::InvalidPrincipalConstraint));
+            }
+        };
+
+        let actor_type = self
+            .actor_type
+            .map(|principal_type| match principal_type {
+                PrincipalType::User => Ok(ActorType::User),
+                PrincipalType::Machine => Ok(ActorType::Machine),
+                PrincipalType::Ai => Ok(ActorType::Ai),
+                _ => Err(GetPoliciesError::InvalidPrincipalConstraint),
+            })
+            .transpose()?;
+
+        let principal_constraint = match (principal_id, actor_type) {
+            (None, None) => None,
+            (None, Some(actor_type)) => Some(PrincipalConstraint::ActorType { actor_type }),
+            (Some(PrincipalId::Actor(actor)), None) => Some(PrincipalConstraint::Actor { actor }),
+            (Some(PrincipalId::ActorGroup(actor_group)), actor_type) => {
+                Some(PrincipalConstraint::ActorGroup {
+                    actor_group,
+                    actor_type,
+                })
+            }
+            (Some(PrincipalId::Role(role)), actor_type) => {
+                Some(PrincipalConstraint::Role { role, actor_type })
+            }
+            _ => return Err(Report::new(GetPoliciesError::InvalidPrincipalConstraint)),
+        };
+
+        Ok(Policy {
+            id: self.id,
+            effect: self.effect,
+            principal: principal_constraint,
+            actions: self.actions,
+            resource: self.resource_constraint,
+            constraints: None,
+        })
+    }
+}
+
+impl<C, A> PolicyStore for PostgresStore<C, A>
+where
+    C: AsClient,
+    A: AuthorizationApi + Send + Sync,
+{
+    async fn get_policy_by_id(
+        &self,
+        _authenticated_actor: ActorEntityUuid,
+        policy_id: PolicyId,
+    ) -> Result<Option<Policy>, Report<GetPoliciesError>> {
+        self.as_client()
+            .query_opt(
+                "
+                SELECT
+                    policy.id,
+                    policy.effect,
+                    policy.principal_id,
+                    policy.principal_type,
+                    policy.actor_type,
+                    policy.resource_constraint,
+                    array_agg(policy_action.action_name)
+                FROM policy
+                JOIN policy_action ON policy.id = policy_action.policy_id
+                WHERE policy.id = $1
+                GROUP BY
+                    policy.id, policy.effect, policy.principal_id, policy.principal_type,
+                    policy.actor_type, policy.resource_constraint
+                ",
+                &[&policy_id],
+            )
+            .await
+            .change_context(GetPoliciesError::StoreError)?
+            .map(|row| {
+                PolicyParts {
+                    id: row.get(0),
+                    effect: row.get(1),
+                    principal_uuid: row.get(2),
+                    principal_type: row.get(3),
+                    actor_type: row.get(4),
+                    resource_constraint: row
+                        .get::<_, Option<Json<ResourceConstraint>>>(5)
+                        .map(|json| json.0),
+                    actions: row.get(6),
+                }
+                .into_policy()
+            })
+            .transpose()
+    }
+
+    async fn query_policies(
+        &self,
+        _authenticated_actor: ActorEntityUuid,
+        filter: &PolicyFilter,
+    ) -> Result<Vec<Policy>, Report<GetPoliciesError>> {
+        let mut filters = Vec::new();
+        let mut parameters = Vec::<Box<dyn ToSql + Send + Sync>>::new();
+
+        if let Some(principal) = &filter.principal {
+            let (principal_id, actor_type) = match principal {
+                PrincipalFilter::Constrained(PrincipalConstraint::ActorType { actor_type }) => {
+                    (None, Some(*actor_type))
+                }
+                PrincipalFilter::Constrained(PrincipalConstraint::Actor { actor }) => {
+                    (Some(PrincipalId::Actor(*actor)), None)
+                }
+                PrincipalFilter::Constrained(PrincipalConstraint::ActorGroup {
+                    actor_group,
+                    actor_type,
+                }) => (Some(PrincipalId::ActorGroup(*actor_group)), *actor_type),
+                PrincipalFilter::Constrained(PrincipalConstraint::Role { role, actor_type }) => {
+                    (Some(PrincipalId::Role(*role)), *actor_type)
+                }
+                PrincipalFilter::Unconstrained => (None, None),
+            };
+            if let Some(principal_id) = principal_id {
+                parameters.push(Box::new(principal_id));
+                parameters.push(Box::new(principal_id.principal_type()));
+                filters.push(format!(
+                    "policy.principal_id = ${} AND policy.principal_type = ${}",
+                    parameters.len() - 1,
+                    parameters.len()
+                ));
+            } else {
+                filters.push(
+                    "policy.principal_id IS NULL AND policy.principal_type IS NULL".to_owned(),
+                );
+            }
+
+            if let Some(actor_type) = actor_type {
+                parameters.push(Box::new(PrincipalType::from(actor_type)));
+                filters.push(format!("policy.actor_type = ${}", parameters.len()));
+            } else {
+                filters.push("policy.actor_type IS NULL".to_owned());
+            }
+        }
+
+        let base_query = "
+            SELECT
+                policy.id,
+                policy.effect,
+                policy.principal_id,
+                policy.principal_type,
+                policy.actor_type,
+                policy.resource_constraint,
+                array_agg(policy_action.action_name)
+            FROM policy
+            JOIN policy_action ON policy.id = policy_action.policy_id
+            "
+        .to_owned();
+        let group_by_query = "
+            GROUP BY
+                policy.id,
+                policy.effect,
+                policy.principal_id,
+                policy.principal_type,
+                policy.actor_type,
+                policy.resource_constraint
+            ";
+
+        let query = if filters.is_empty() {
+            format!("{base_query} {group_by_query}")
+        } else {
+            format!(
+                "{base_query} WHERE {} {group_by_query}",
+                filters.join(" AND ")
+            )
+        };
+
+        self.as_client()
+            .query_raw(&query, parameters)
+            .await
+            .change_context(GetPoliciesError::StoreError)?
+            .map_err(|error| Report::new(error).change_context(GetPoliciesError::StoreError))
+            .and_then(async |row| -> Result<_, Report<GetPoliciesError>> {
+                PolicyParts {
+                    id: PolicyId::new(row.get(0)),
+                    effect: row.get(1),
+                    principal_uuid: row.get(2),
+                    principal_type: row.get(3),
+                    actor_type: row.get(4),
+                    resource_constraint: row
+                        .get::<_, Option<Json<ResourceConstraint>>>(5)
+                        .map(|json| json.0),
+                    actions: row.get(6),
+                }
+                .into_policy()
+            })
+            .try_collect()
+            .await
+    }
+
+    async fn resolve_policies_for_actor(
+        &self,
+        _authenticated_actor: ActorEntityUuid,
+        actor_id: ActorId,
+    ) -> Result<Vec<Policy>, Report<GetPoliciesError>> {
+        // The below query does several things. It:
+        //   1. gets all principals that the actor can act as
+        //   2. gets all policies that apply to those principals
+        //   3. TODO: filters the policies based on the action -- currently not implemented
+        //   4. TODO: filters the policies based on the resource -- currently not implemented
+        //
+        // Principals are retrieved by:
+        //   - the actor itself, filtered by the actor ID and actor type
+        //   - all roles assigned to the actor, filtered by the actor ID
+        //   - all actor groups associated with those roles, determined by the role's actor group ID
+        //   - all parent actor groups of those actor groups (for teams), determined by the actor
+        //     group hierarchy
+        //
+        // The actions are associated in the `policy_action` table. We join that table and aggregate
+        // the actions for each policy. All actions are included, but the action hierarchy is used
+        // to determine which actions are relevant to the actor.
+        self.as_client()
+            .query_raw(
+                "
+                WITH principals AS (
+                    -- The actor itself
+                    SELECT id, principal_type
+                    FROM actor
+                    WHERE id = $1 AND principal_type = $2
+
+                    UNION ALL
+
+                    -- All roles directly assigned to the actor
+                    SELECT role.id, role.principal_type
+                    FROM actor_role
+                    JOIN role ON actor_role.role_id = role.id
+                    WHERE actor_role.actor_id = $1
+
+                    UNION ALL
+
+                    -- Direct actor group of each role - always included
+                    SELECT actor_group.id, actor_group.principal_type
+                    FROM actor_role
+                    JOIN role ON actor_role.role_id = role.id
+                    JOIN actor_group ON actor_group.id = role.actor_group_id
+                    WHERE actor_role.actor_id = $1
+
+                    UNION ALL
+
+                    -- All parent actor groups of actor groups (recursively through hierarchy)
+                    SELECT parent.id, parent.principal_type
+                    FROM actor_role
+                    JOIN role ON actor_role.role_id = role.id
+                    JOIN team_hierarchy
+                      ON team_hierarchy.child_id = role.actor_group_id
+                    JOIN actor_group parent ON parent.id = team_hierarchy.parent_id
+                    WHERE actor_role.actor_id = $1
+                ),
+                -- We filter out policies that don't apply to the actor's type or principal ID
+                policy AS (
+                    -- global and actor-type based policies
+                    SELECT policy.*
+                    FROM policy
+                    WHERE principal_id IS NULL
+                      AND (actor_type IS NULL OR actor_type = $2)
+
+                    UNION ALL
+
+                    -- actor type policies
+                    SELECT policy.*
+                    FROM policy
+                    JOIN principals
+                      ON policy.principal_id = principals.id
+                     AND policy.principal_type = principals.principal_type
+                    WHERE policy.actor_type IS NULL OR policy.actor_type = $2
+                )
+                SELECT
+                    policy.id,
+                    policy.effect,
+                    policy.principal_id,
+                    policy.principal_type,
+                    policy.actor_type,
+                    policy.resource_constraint,
+                    array_agg(policy_action.action_name)
+                FROM policy
+                JOIN policy_action ON policy.id = policy_action.policy_id
+                GROUP BY
+                    policy.id, policy.effect, policy.principal_id, policy.principal_type,
+                    policy.actor_type, policy.resource_constraint
+                ",
+                [
+                    &actor_id as &(dyn ToSql + Sync),
+                    &PrincipalType::from(actor_id.actor_type()),
+                ],
+            )
+            .await
+            .change_context(GetPoliciesError::StoreError)?
+            .map_err(|error| Report::new(error).change_context(GetPoliciesError::StoreError))
+            .and_then(async |row| -> Result<_, Report<GetPoliciesError>> {
+                PolicyParts {
+                    id: row.get(0),
+                    effect: row.get(1),
+                    principal_uuid: row.get(2),
+                    principal_type: row.get(3),
+                    actor_type: row.get(4),
+                    resource_constraint: row
+                        .get::<_, Option<Json<ResourceConstraint>>>(5)
+                        .map(|json| json.0),
+                    actions: row.get(6),
+                }
+                .into_policy()
+            })
+            .try_collect::<Vec<_>>()
+            .await
+    }
+}
+
 #[derive(Debug, Copy, Clone, PartialEq, Eq)]
 enum OntologyLocation {
     Owned,
diff --git a/libs/@local/graph/postgres-store/tests/principals/policies.rs b/libs/@local/graph/postgres-store/tests/principals/policies.rs
index b4c9447c087..1078643353f 100644
--- a/libs/@local/graph/postgres-store/tests/principals/policies.rs
+++ b/libs/@local/graph/postgres-store/tests/principals/policies.rs
@@ -1,4 +1,5 @@
 use core::error::Error;
+use std::collections::{HashMap, HashSet};
 
 use hash_graph_authorization::{
     AuthorizationApi,
@@ -7,7 +8,7 @@ use hash_graph_authorization::{
         action::ActionName,
         principal::PrincipalConstraint,
         resource::{EntityResourceConstraint, EntityResourceFilter, ResourceConstraint},
-        store::{CreateWebParameter, PrincipalStore as _},
+        store::{CreateWebParameter, PolicyStore as _, PrincipalStore as _},
     },
 };
 use hash_graph_postgres_store::store::{AsClient, PostgresStore};
@@ -357,40 +358,55 @@ async fn global_policies() -> Result<(), Box<dyn Error>> {
 
     // Every actor should get global policies
     let user1_policies = client
-        .get_policies_for_actor(ActorId::User(env.user1))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(env.user1))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
     let user2_policies = client
-        .get_policies_for_actor(ActorId::User(env.user2))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(env.user2))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
     let machine_policies = client
-        .get_policies_for_actor(ActorId::Machine(env.machine_id))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::Machine(env.machine_id))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
     let ai_policies = client
-        .get_policies_for_actor(ActorId::Ai(env.ai_id))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::Ai(env.ai_id))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
     let nonexisting_policies = client
-        .get_policies_for_actor(ActorId::User(UserId::new(Uuid::new_v4())))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(UserId::new(Uuid::new_v4())))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
 
     // All actors should have the global policy
     assert!(
-        user1_policies.contains_key(&env.policies.global),
+        user1_policies.contains(&env.policies.global),
         "User1 should have global policy"
     );
     assert!(
-        user2_policies.contains_key(&env.policies.global),
+        user2_policies.contains(&env.policies.global),
         "User2 should have global policy"
     );
     assert!(
-        machine_policies.contains_key(&env.policies.global),
+        machine_policies.contains(&env.policies.global),
         "Machine should have global policy"
     );
     assert!(
-        ai_policies.contains_key(&env.policies.global),
+        ai_policies.contains(&env.policies.global),
         "AI should have global policy"
     );
     assert!(
-        nonexisting_policies.contains_key(&env.policies.global),
+        nonexisting_policies.contains(&env.policies.global),
         "Non-existent actor should have global policy"
     );
 
@@ -406,51 +422,66 @@ async fn actor_type_policies() -> Result<(), Box<dyn Error>> {
 
     // Test user type policies
     let user1_policies = client
-        .get_policies_for_actor(ActorId::User(env.user1))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(env.user1))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
     let user2_policies = client
-        .get_policies_for_actor(ActorId::User(env.user2))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(env.user2))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
     let machine_policies = client
-        .get_policies_for_actor(ActorId::Machine(env.machine_id))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::Machine(env.machine_id))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
     let nonexisting_machine_policies = client
-        .get_policies_for_actor(ActorId::Machine(MachineId::new(Uuid::new_v4())))
-        .await?;
+        .resolve_policies_for_actor(
+            actor_id.into(),
+            ActorId::Machine(MachineId::new(Uuid::new_v4())),
+        )
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
 
     // Users should have user type policies, machines should not
     assert!(
-        user1_policies.contains_key(&env.policies.user_type),
+        user1_policies.contains(&env.policies.user_type),
         "User1 should have user type policy"
     );
     assert!(
-        user2_policies.contains_key(&env.policies.user_type),
+        user2_policies.contains(&env.policies.user_type),
         "User2 should have user type policy"
     );
     assert!(
-        !machine_policies.contains_key(&env.policies.user_type),
+        !machine_policies.contains(&env.policies.user_type),
         "Machine should not have user type policy"
     );
     assert!(
-        !nonexisting_machine_policies.contains_key(&env.policies.user_type),
+        !nonexisting_machine_policies.contains(&env.policies.user_type),
         "Non-existent machine should not have access"
     );
 
     // Machines should have machine type policies, users should not
     assert!(
-        !user1_policies.contains_key(&env.policies.machine_type),
+        !user1_policies.contains(&env.policies.machine_type),
         "User1 should not have machine type policy"
     );
     assert!(
-        !user2_policies.contains_key(&env.policies.machine_type),
+        !user2_policies.contains(&env.policies.machine_type),
         "User2 should not have machine type policy"
     );
     assert!(
-        machine_policies.contains_key(&env.policies.machine_type),
+        machine_policies.contains(&env.policies.machine_type),
         "Machine should have machine type policy"
     );
     assert!(
-        nonexisting_machine_policies.contains_key(&env.policies.machine_type),
+        nonexisting_machine_policies.contains(&env.policies.machine_type),
         "Non-existent machine should have machine type policy"
     );
 
@@ -466,26 +497,35 @@ async fn specific_actor_policies() -> Result<(), Box<dyn Error>> {
 
     // user1 has a specific policy assigned
     let user1_policies = client
-        .get_policies_for_actor(ActorId::User(env.user1))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(env.user1))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
     let user2_policies = client
-        .get_policies_for_actor(ActorId::User(env.user2))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(env.user2))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
     let nonexisting_user_policies = client
-        .get_policies_for_actor(ActorId::User(UserId::new(Uuid::new_v4())))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(UserId::new(Uuid::new_v4())))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
 
     // User1 should have its specific policy, user2 should not
     assert!(
-        user1_policies.contains_key(&env.policies.user1),
+        user1_policies.contains(&env.policies.user1),
         "User1 should have its specific policy"
     );
     assert!(
-        !user2_policies.contains_key(&env.policies.user1),
+        !user2_policies.contains(&env.policies.user1),
         "User2 should not have User1's policy"
     );
     assert!(
-        !nonexisting_user_policies.contains_key(&env.policies.user1),
+        !nonexisting_user_policies.contains(&env.policies.user1),
         "Non-existent user should not have User1's policy"
     );
 
@@ -501,21 +541,27 @@ async fn role_based_policies() -> Result<(), Box<dyn Error>> {
 
     // Test role-based policies
     let user1_policies = client
-        .get_policies_for_actor(ActorId::User(env.user1))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(env.user1))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
     let user2_policies = client
-        .get_policies_for_actor(ActorId::User(env.user2))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(env.user2))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
 
     // User1 has web1_role, should have its policies
     assert!(
-        user1_policies.contains_key(&env.policies.web1_role),
+        user1_policies.contains(&env.policies.web1_role),
         "User1 should have web1_role policy"
     );
 
     // User2 has web2_role and should have type-constrained policy for that role
     assert!(
-        user2_policies.contains_key(&env.policies.web2_role_user),
+        user2_policies.contains(&env.policies.web2_role_user),
         "User2 should have web2_role_user policy"
     );
 
@@ -529,17 +575,20 @@ async fn role_based_policies() -> Result<(), Box<dyn Error>> {
         .await?;
 
     let machine_policies = client
-        .get_policies_for_actor(ActorId::Machine(special_machine_id))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::Machine(special_machine_id))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
 
     // Machine has web1_role but should still get role-based policies without actor type constraints
     assert!(
-        machine_policies.contains_key(&env.policies.web1_role),
+        machine_policies.contains(&env.policies.web1_role),
         "Machine with web1_role should get its policies"
     );
     // Machine has web2_role but should not get role-based policies with actor type constraints
     assert!(
-        !machine_policies.contains_key(&env.policies.web2_role_user),
+        !machine_policies.contains(&env.policies.web2_role_user),
         "Machine with web2_role should not get its policies"
     );
 
@@ -556,21 +605,27 @@ async fn team_hierarchy_policies() -> Result<(), Box<dyn Error>> {
     // Test team hierarchies
     // User2 has team1_role, AI has nested_team_role which is under team1
     let user2_policies = client
-        .get_policies_for_actor(ActorId::User(env.user2))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(env.user2))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
     let ai_policies = client
-        .get_policies_for_actor(ActorId::Ai(env.ai_id))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::Ai(env.ai_id))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
 
     // User2 should have team1's policies
     assert!(
-        user2_policies.contains_key(&env.policies.team1),
+        user2_policies.contains(&env.policies.team1),
         "User2 should have team1 policy"
     );
 
     // AI with nested_team_role should inherit policies from parent team1
     assert!(
-        ai_policies.contains_key(&env.policies.team1),
+        ai_policies.contains(&env.policies.team1),
         "AI should inherit team1 policy through team hierarchy"
     );
 
@@ -587,19 +642,22 @@ async fn policy_count_and_content() -> Result<(), Box<dyn Error>> {
     let nonexistent_id = UserId::new(Uuid::new_v4());
 
     let user1_policies = client
-        .get_policies_for_actor(ActorId::User(env.user1))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(env.user1))
+        .await?
+        .into_iter()
+        .map(|policy| (policy.id, policy))
+        .collect::<HashMap<_, _>>();
     let user2_policies = client
-        .get_policies_for_actor(ActorId::User(env.user2))
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(env.user2))
         .await?;
     let machine_policies = client
-        .get_policies_for_actor(ActorId::Machine(env.machine_id))
+        .resolve_policies_for_actor(actor_id.into(), ActorId::Machine(env.machine_id))
         .await?;
     let ai_policies = client
-        .get_policies_for_actor(ActorId::Ai(env.ai_id))
+        .resolve_policies_for_actor(actor_id.into(), ActorId::Ai(env.ai_id))
         .await?;
     let nonexistent_policies = client
-        .get_policies_for_actor(ActorId::User(nonexistent_id))
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(nonexistent_id))
         .await?;
 
     // Verify that we have at least one policy for each actor
@@ -641,10 +699,13 @@ async fn role_assignment_changes() -> Result<(), Box<dyn Error>> {
 
     // Initial policy count
     let user2_policies = client
-        .get_policies_for_actor(ActorId::User(env.user2))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(env.user2))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
     let initial_count = user2_policies.len();
-    let has_web2_policy = user2_policies.contains_key(&env.policies.web2_role_user);
+    let has_web2_policy = user2_policies.contains(&env.policies.web2_role_user);
     assert!(
         has_web2_policy,
         "User2 should initially have web2_role_user policy"
@@ -657,10 +718,13 @@ async fn role_assignment_changes() -> Result<(), Box<dyn Error>> {
 
     // Should have fewer policies now
     let updated_policies = client
-        .get_policies_for_actor(ActorId::User(env.user2))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(env.user2))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
     assert!(
-        !updated_policies.contains_key(&env.policies.web2_role_user),
+        !updated_policies.contains(&env.policies.web2_role_user),
         "User2 should no longer have web2_role_user policy after role removal"
     );
     assert!(
@@ -675,8 +739,11 @@ async fn role_assignment_changes() -> Result<(), Box<dyn Error>> {
 
     // Should have different policies now after adding a new role
     let final_policies = client
-        .get_policies_for_actor(ActorId::User(env.user2))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(env.user2))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
 
     // We should have more policies after adding a role
     assert!(
@@ -697,7 +764,7 @@ 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, _actor_id) = db.seed([]).await?;
+    let (mut client, actor_id) = db.seed([]).await?;
 
     let user_id = client.create_user(None).await?;
     client.register_action(ActionName::All).await?;
@@ -721,8 +788,11 @@ async fn resource_constraints_are_preserved() -> Result<(), Box<dyn Error>> {
         .await?;
 
     let policies = client
-        .get_policies_for_actor(ActorId::User(user_id))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(user_id))
+        .await?
+        .into_iter()
+        .map(|policy| (policy.id, policy))
+        .collect::<HashMap<_, _>>();
 
     // Ensure resource constraint is preserved
     assert!(
@@ -836,19 +906,22 @@ async fn deep_team_hierarchy() -> Result<(), Box<dyn Error>> {
 
     // User should get all policies through the hierarchy
     let policies = client
-        .get_policies_for_actor(ActorId::User(user_id))
-        .await?;
+        .resolve_policies_for_actor(actor_id.into(), ActorId::User(user_id))
+        .await?
+        .into_iter()
+        .map(|policy| policy.id)
+        .collect::<HashSet<_>>();
 
     assert!(
-        policies.contains_key(&web_policy_id),
+        policies.contains(&web_policy_id),
         "User should inherit web team policy"
     );
     assert!(
-        policies.contains_key(&team1_policy_id),
+        policies.contains(&team1_policy_id),
         "User should inherit team1 policy"
     );
     assert!(
-        policies.contains_key(&team5_policy_id),
+        policies.contains(&team5_policy_id),
         "User should have direct team5 policy"
     );
 
diff --git a/libs/@local/graph/sdk/typescript/src/policy.ts b/libs/@local/graph/sdk/typescript/src/policy.ts
new file mode 100644
index 00000000000..821034e8e71
--- /dev/null
+++ b/libs/@local/graph/sdk/typescript/src/policy.ts
@@ -0,0 +1,65 @@
+import type { ActorId } from "@blockprotocol/type-system";
+import type { GraphApi } from "@local/hash-graph-client";
+import type {
+  Policy,
+  PolicyFilter,
+  PolicyId,
+} from "@rust/hash-graph-authorization/types";
+
+import type { AuthenticationContext } from "./authentication-context.js";
+
+/**
+ * Retrieves a policy by its ID.
+ *
+ * Returns the policy if it exists, or `null` if not found.
+ */
+export const getPolicyById = (
+  graphAPI: GraphApi,
+  authentication: AuthenticationContext,
+  policyId: PolicyId,
+): Promise<Policy | null> =>
+  graphAPI
+    .getPolicyById(authentication.actorId, policyId)
+    .then(({ data: policy }) => policy as Policy | null);
+
+/**
+ * Queries for policies in the local store that match the provided filter.
+ *
+ * This method queries the underlying policy store using the given [`PolicyFilter`] and returns
+ * a list of matching [`Policy`] objects. The filter can be used to specify criteria such as
+ * policy type, subject, resource, or action.
+ *
+ * Note that this does not resolve indirect policies (e.g., policies applying to roles held by
+ * a specific actor). For resolving all policies applicable to an actor, including indirect
+ * ones, use `resolvePoliciesForActor`.
+ */
+export const queryPolicies = (
+  graphAPI: GraphApi,
+  authentication: AuthenticationContext,
+  filter: PolicyFilter,
+): Promise<Policy[]> =>
+  graphAPI
+    .queryPolicies(authentication.actorId, filter)
+    .then(({ data: policies }) => policies as Policy[]);
+
+/**
+ * Searches for policies that apply to the given actor.
+ *
+ * This method queries the underlying policy store to find policies that are relevant to the
+ * specified actor. The policies returned may include those that apply to the actor directly,
+ * as well as policies that apply to any roles the actor has.
+ *
+ * This provides a complete set of policies that apply to an actor, including all policies that
+ *   - apply to the actor itself,
+ *   - apply to the actor's roles,
+ *   - apply to the actor's groups, and
+ *   - apply to the actor's parent groups (for teams).
+ */
+export const resolvePoliciesForActor = (
+  graphAPI: GraphApi,
+  authentication: AuthenticationContext,
+  actorId: ActorId,
+): Promise<Policy[]> =>
+  graphAPI
+    .resolvePoliciesForActor(authentication.actorId, actorId)
+    .then(({ data: policies }) => policies as Policy[]);
diff --git a/libs/@local/graph/type-fetcher/src/store.rs b/libs/@local/graph/type-fetcher/src/store.rs
index 46196c1d79b..a391d792dcf 100644
--- a/libs/@local/graph/type-fetcher/src/store.rs
+++ b/libs/@local/graph/type-fetcher/src/store.rs
@@ -5,11 +5,15 @@ use std::collections::{HashMap, HashSet};
 use error_stack::{Report, ResultExt as _};
 use hash_graph_authorization::{
     AuthorizationApi,
-    policies::store::{
-        CreateWebParameter, CreateWebResponse, PrincipalStore, RoleAssignmentStatus,
-        RoleUnassignmentStatus,
-        error::{
-            EnsureSystemPoliciesError, GetSystemAccountError, RoleAssignmentError, WebCreationError,
+    policies::{
+        Policy, PolicyId,
+        store::{
+            CreateWebParameter, CreateWebResponse, PolicyFilter, PolicyStore, PrincipalStore,
+            RoleAssignmentStatus, RoleUnassignmentStatus,
+            error::{
+                EnsureSystemPoliciesError, GetPoliciesError, GetSystemAccountError,
+                RoleAssignmentError, WebCreationError,
+            },
         },
     },
     schema::{
@@ -243,6 +247,40 @@ where
     }
 }
 
+impl<S, A> PolicyStore for FetchingStore<S, A>
+where
+    S: PolicyStore + Sync,
+    A: Send + Sync,
+{
+    async fn get_policy_by_id(
+        &self,
+        authenticated_actor: ActorEntityUuid,
+        policy_id: PolicyId,
+    ) -> Result<Option<Policy>, Report<GetPoliciesError>> {
+        self.store
+            .get_policy_by_id(authenticated_actor, policy_id)
+            .await
+    }
+
+    async fn query_policies(
+        &self,
+        authenticated_actor: ActorEntityUuid,
+        filter: &PolicyFilter,
+    ) -> Result<Vec<Policy>, Report<GetPoliciesError>> {
+        self.store.query_policies(authenticated_actor, filter).await
+    }
+
+    async fn resolve_policies_for_actor(
+        &self,
+        authenticated_actor: ActorEntityUuid,
+        actor_id: ActorId,
+    ) -> Result<Vec<Policy>, Report<GetPoliciesError>> {
+        self.store
+            .resolve_policies_for_actor(authenticated_actor, actor_id)
+            .await
+    }
+}
+
 const DATA_TYPE_RELATIONSHIPS: [DataTypeRelationAndSubject; 1] =
     [DataTypeRelationAndSubject::Viewer {
         subject: DataTypeViewerSubject::Public,
diff --git a/tests/hash-backend-integration/src/tests/graph/authorization/policy.test.ts b/tests/hash-backend-integration/src/tests/graph/authorization/policy.test.ts
new file mode 100644
index 00000000000..95c825c8c64
--- /dev/null
+++ b/tests/hash-backend-integration/src/tests/graph/authorization/policy.test.ts
@@ -0,0 +1,101 @@
+import { deleteKratosIdentity } from "@apps/hash-api/src/auth/ory-kratos";
+import { ensureSystemGraphIsInitialized } from "@apps/hash-api/src/graph/ensure-system-graph-is-initialized";
+import type { User } from "@apps/hash-api/src/graph/knowledge/system-types/user";
+import { systemAccountId } from "@apps/hash-api/src/graph/system-account";
+import { Logger } from "@local/hash-backend-utils/logger";
+import {
+  getPolicyById,
+  queryPolicies,
+  resolvePoliciesForActor,
+} from "@local/hash-graph-sdk/policy";
+import type { Policy, PolicyId } from "@rust/hash-graph-authorization/types";
+import { beforeAll, describe, expect, it } from "vitest";
+
+import { resetGraph } from "../../test-server";
+import { createTestImpureGraphContext, createTestUser } from "../../util";
+
+const logger = new Logger({
+  environment: "test",
+  level: "debug",
+  serviceName: "integration-tests",
+});
+
+const graphContext = createTestImpureGraphContext();
+const { graphApi } = graphContext;
+
+describe("Policy CRUD", () => {
+  let testUser: User;
+  let testPolicy: Policy;
+
+  beforeAll(async () => {
+    await ensureSystemGraphIsInitialized({ logger, context: graphContext });
+
+    testUser = await createTestUser(graphContext, "entitytest", logger);
+
+    return async () => {
+      await deleteKratosIdentity({
+        kratosIdentityId: testUser.kratosIdentityId,
+      });
+
+      await resetGraph();
+    };
+  });
+
+  it("can query all policies", async () => {
+    const authentication = { actorId: testUser.accountId };
+
+    const policies = await queryPolicies(graphApi, authentication, {});
+
+    expect(policies.length).toBeGreaterThan(0);
+  });
+
+  it("can query system-actor policies", async () => {
+    const authentication = { actorId: testUser.accountId };
+
+    const policies = await queryPolicies(graphApi, authentication, {
+      principal: {
+        filter: "constrained",
+        type: "actor",
+        actorType: "machine",
+        id: systemAccountId,
+      },
+    });
+
+    expect(policies.length).toBeGreaterThan(0);
+    testPolicy = policies[0]!;
+  });
+
+  it("can get specific policies", async () => {
+    const authentication = { actorId: testUser.accountId };
+
+    expect(
+      await getPolicyById(graphApi, authentication, testPolicy.id),
+    ).toStrictEqual(testPolicy);
+
+    expect(
+      await getPolicyById(
+        graphApi,
+        authentication,
+        "00000000-0000-0000-0000-000000000000" as PolicyId,
+      ),
+    ).toBeNull();
+  });
+
+  it("can find policies for an actor", async () => {
+    const authentication = { actorId: testUser.accountId };
+
+    expect(
+      await resolvePoliciesForActor(graphApi, authentication, {
+        actorType: "machine",
+        id: systemAccountId,
+      }),
+    ).toContainEqual(testPolicy);
+
+    expect(
+      await resolvePoliciesForActor(graphApi, authentication, {
+        actorType: "user",
+        id: testUser.accountId,
+      }),
+    ).toStrictEqual([]);
+  });
+});


Reviews:
user: login: hashdotai
body: The PR implements and exposes policy querying functionality through three new endpoints: `getPolicyById`, `queryPolicies`, and `resolvePoliciesForActor`. The implementation is well-structured and introduces the necessary traits and database operations to support these operations.

Overall, the code is of good quality, but there are several areas that could be improved:

1. The OpenAPI schema definitions are incomplete, with missing request and response schemas for the new endpoints. This is important for API documentation and client type generation.

2. Some error handling and validation could be enhanced, particularly for the policy filter validation before it's passed to the database queries.

3. The SQL queries are complex but would benefit from more detailed inline documentation, especially around the team hierarchy traversal logic.

4. The TypeScript SDK could use more type-safety in some places.

These issues are relatively minor and can be addressed with straightforward changes. The implementation otherwise correctly handles the complex policy resolution logic, including traversing team hierarchies and handling different actor types appropriately.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T12:50:02Z
user: login: TimDiekmann
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T12:53:08Z
user: login: TimDiekmann
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T12:53:12Z
user: login: TimDiekmann
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T12:53:20Z
user: login: TimDiekmann
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T12:57:03Z
user: login: TimDiekmann
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T12:57:35Z
user: login: TimDiekmann
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T12:57:44Z
user: login: TimDiekmann
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T12:58:12Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR extends policy querying functionality in the Graph and Node API by implementing three new endpoints (getPolicyById, queryPolicies, and resolvePoliciesForActor) and updating the underlying store and tests accordingly.
- Introduces new endpoints in the REST API for policy retrieval and resolution.
- Refactors backend store methods and test assertions to use the new policy querying interface.
- Updates OpenAPI documentation to reflect the new endpoints and their request/response schemas.

### Reviewed Changes

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

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| tests/hash-backend-integration/src/tests/graph/authorization/policy.test.ts | Added tests to verify functionality of new policy query endpoints. |
| libs/@local/graph/type-fetcher/src/store.rs | Updated store proxy methods to delegate policy queries to the inner store. |
| libs/@local/graph/sdk/typescript/src/policy.ts | Introduced TypeScript SDK functions for policy querying. |
| libs/@local/graph/postgres-store/tests/principals/policies.rs | Modified tests to use new resolve_policies_for_actor and adjust HashSet assertions. |
| libs/@local/graph/postgres-store/src/store/postgres/mod.rs | Refactored system policy seeding and policy query implementations. |
| libs/@local/graph/postgres-store/src/permissions/mod.rs | Removed deprecated helper function as part of the refactor. |
| libs/@local/graph/authorization/tests/policies/main.rs | Updated trait bounds from PolicyStore to OldPolicyStore in tests. |
| libs/@local/graph/authorization/src/policies/store/mod.rs | Added new types and traits (PolicyFilter, PrincipalFilter) and deprecated the old PolicyStore trait. |
| libs/@local/graph/authorization/src/policies/store/error.rs | Introduced a new error variant for invalid principal constraints. |
| libs/@local/graph/api/src/rest/mod.rs | Updated REST API trait bounds to include PolicyStore for policy endpoints. |
| libs/@local/graph/api/src/rest/actor.rs | Added new REST endpoints to expose policy querying functionalities. |
| libs/@local/graph/api/openapi/openapi.json | Updated OpenAPI documentation to include the new policy endpoints. |
</details>





state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T13:12:26Z
user: login: CiaranMn
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-12T15:17:02Z

Review comments:
diff_hunk: @@ -2910,6 +2910,139 @@
         }
       }
     },
+    "/policies/query": {
+      "post": {
+        "tags": [
+          "Graph",
+          "Policy"
+        ],
+        "operationId": "query_policies",
+        "parameters": [
+          {
+            "name": "X-Authenticated-User-Actor-Id",
+            "in": "header",
+            "description": "The ID of the actor which is used to authorize the request",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/ActorEntityUuid"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {}
path: libs/@local/graph/api/openapi/openapi.json
user: login: hashdotai
body: The schema for this endpoint is incomplete. You should define a proper schema for the request body and response items. This helps with API documentation and client type generation.
created_at: 2025-05-09T12:50:01Z
author_association: MEMBER
diff_hunk: @@ -2910,6 +2910,139 @@
         }
       }
     },
+    "/policies/query": {
+      "post": {
+        "tags": [
+          "Graph",
+          "Policy"
+        ],
+        "operationId": "query_policies",
+        "parameters": [
+          {
+            "name": "X-Authenticated-User-Actor-Id",
+            "in": "header",
+            "description": "The ID of the actor which is used to authorize the request",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/ActorEntityUuid"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {}
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "List of policies matching the filter",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {}
path: libs/@local/graph/api/openapi/openapi.json
user: login: hashdotai
body: The request body and response schema are undefined here as well. Consider defining the proper schema types.
created_at: 2025-05-09T12:50:01Z
author_association: MEMBER
diff_hunk: @@ -2910,6 +2910,139 @@
         }
       }
     },
+    "/policies/query": {
+      "post": {
+        "tags": [
+          "Graph",
+          "Policy"
+        ],
+        "operationId": "query_policies",
+        "parameters": [
+          {
+            "name": "X-Authenticated-User-Actor-Id",
+            "in": "header",
+            "description": "The ID of the actor which is used to authorize the request",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/ActorEntityUuid"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {}
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "List of policies matching the filter",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {}
+                }
+              }
+            }
+          },
+          "500": {
+            "description": "Store error occurred"
+          }
+        }
+      }
+    },
+    "/policies/resolve/actor": {
+      "post": {
+        "tags": [
+          "Graph",
+          "Policy"
+        ],
+        "operationId": "resolve_policies_for_actor",
+        "parameters": [
+          {
+            "name": "X-Authenticated-User-Actor-Id",
+            "in": "header",
+            "description": "The ID of the actor which is used to authorize the request",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/ActorEntityUuid"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {}
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "List of policies found for the actor",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {}
path: libs/@local/graph/api/openapi/openapi.json
user: login: hashdotai
body: Same issue with missing schema definition for request and response bodies. This should be properly defined to ensure API consumers have correct type information.
created_at: 2025-05-09T12:50:01Z
author_association: MEMBER
diff_hunk: @@ -2910,6 +2910,139 @@
         }
       }
     },
+    "/policies/query": {
+      "post": {
+        "tags": [
+          "Graph",
+          "Policy"
+        ],
+        "operationId": "query_policies",
+        "parameters": [
+          {
+            "name": "X-Authenticated-User-Actor-Id",
+            "in": "header",
+            "description": "The ID of the actor which is used to authorize the request",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/ActorEntityUuid"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {}
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "List of policies matching the filter",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {}
+                }
+              }
+            }
+          },
+          "500": {
+            "description": "Store error occurred"
+          }
+        }
+      }
+    },
+    "/policies/resolve/actor": {
+      "post": {
+        "tags": [
+          "Graph",
+          "Policy"
+        ],
+        "operationId": "resolve_policies_for_actor",
+        "parameters": [
+          {
+            "name": "X-Authenticated-User-Actor-Id",
+            "in": "header",
+            "description": "The ID of the actor which is used to authorize the request",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/ActorEntityUuid"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {}
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "List of policies found for the actor",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {}
+                }
+              }
+            }
+          },
+          "500": {
+            "description": "Store error occurred"
+          }
+        }
+      }
+    },
+    "/policies/{policy_id}": {
+      "get": {
+        "tags": [
+          "Graph",
+          "Policy"
+        ],
+        "operationId": "get_policy_by_id",
+        "parameters": [
+          {
+            "name": "X-Authenticated-User-Actor-Id",
+            "in": "header",
+            "description": "The ID of the actor which is used to authorize the request",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/ActorEntityUuid"
+            }
+          },
+          {
+            "name": "policy_id",
+            "in": "path",
+            "description": "The ID of the policy to find",
+            "required": true,
+            "schema": {
+              "type": "string",
+              "format": "uuid"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "",
path: libs/@local/graph/api/openapi/openapi.json
user: login: hashdotai
body: The empty description doesn't provide any context about what this endpoint returns. Consider adding a descriptive message explaining the purpose and return value.
created_at: 2025-05-09T12:50:01Z
author_association: MEMBER
diff_hunk: @@ -389,3 +403,141 @@ where
 
     result
 }
+
+#[utoipa::path(
+    get,
+    path = "/policies/{policy_id}",
+    tag = "Policy",
+    params(
+        ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"),
+        ("policy_id" = Uuid, Path, description = "The ID of the policy to find"),
+    ),
+    responses(
+        (status = 200, content_type = "application/json", body = Option<Value>),
+
+        (status = 500, description = "Store error occurred"),
+    )
+)]
+#[tracing::instrument(
+    level = "info",
+    skip(store_pool, authorization_api_pool, temporal_client)
+)]
+async fn get_policy_by_id<S, A>(
+    AuthenticatedUserHeader(authenticated_actor_id): AuthenticatedUserHeader,
+    Path(policy_id): Path<PolicyId>,
+    store_pool: Extension<Arc<S>>,
+    authorization_api_pool: Extension<Arc<A>>,
+    temporal_client: Extension<Option<Arc<TemporalClient>>>,
+) -> Result<Json<Option<Policy>>, Response>
+where
+    S: StorePool + Send + Sync,
+    A: AuthorizationApiPool + Send + Sync,
+    for<'p, 'a> S::Store<'p, A::Api<'a>>: PolicyStore,
+{
+    store_pool
+        .acquire(
+            authorization_api_pool
+                .acquire()
+                .await
+                .map_err(report_to_response)?,
+            temporal_client.0,
+        )
+        .await
+        .map_err(report_to_response)?
+        .get_policy_by_id(authenticated_actor_id, policy_id)
+        .await
+        .map_err(report_to_response)
+        .map(Json)
+}
+
+#[utoipa::path(
+    post,
+    path = "/policies/query",
+    request_body = Value,
+    tag = "Policy",
+    params(
+        ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"),
+    ),
+    responses(
+        (status = 200, content_type = "application/json", description = "List of policies matching the filter", body = Vec<Value>),
+
+        (status = 500, description = "Store error occurred"),
+    )
+)]
+#[tracing::instrument(
+    level = "info",
+    skip(store_pool, authorization_api_pool, temporal_client)
+)]
+async fn query_policies<S, A>(
+    AuthenticatedUserHeader(authenticated_actor_id): AuthenticatedUserHeader,
+    store_pool: Extension<Arc<S>>,
+    authorization_api_pool: Extension<Arc<A>>,
+    temporal_client: Extension<Option<Arc<TemporalClient>>>,
+    Json(filter): Json<PolicyFilter>,
path: libs/@local/graph/api/src/rest/actor.rs
user: login: hashdotai
body: It would be helpful to add error handling documentation here for cases like when the policy filter is invalid. Consider documenting possible error responses with appropriate status codes.
created_at: 2025-05-09T12:50:01Z
author_association: MEMBER
diff_hunk: @@ -756,6 +757,330 @@ where
     }
 }
 
+struct PolicyParts {
+    id: PolicyId,
+    effect: Effect,
+    principal_uuid: Option<Uuid>,
+    principal_type: Option<PrincipalType>,
+    actor_type: Option<PrincipalType>,
+    resource_constraint: Option<ResourceConstraint>,
+    actions: Vec<ActionName>,
+}
+impl PolicyParts {
+    fn into_policy(self) -> Result<Policy, Report<GetPoliciesError>> {
+        let principal_id = match (self.principal_uuid, self.principal_type) {
+            (Some(uuid), Some(principal_type)) => Some(PrincipalId::new(uuid, principal_type)),
+            (None, None) => None,
+            (Some(_), None) | (None, Some(_)) => {
+                return Err(Report::new(GetPoliciesError::InvalidPrincipalConstraint));
+            }
+        };
+
+        let actor_type = self
+            .actor_type
+            .map(|principal_type| match principal_type {
+                PrincipalType::User => Ok(ActorType::User),
+                PrincipalType::Machine => Ok(ActorType::Machine),
+                PrincipalType::Ai => Ok(ActorType::Ai),
+                _ => Err(GetPoliciesError::InvalidPrincipalConstraint),
+            })
+            .transpose()?;
+
+        let principal_constraint = match (principal_id, actor_type) {
+            (None, None) => None,
+            (None, Some(actor_type)) => Some(PrincipalConstraint::ActorType { actor_type }),
+            (Some(PrincipalId::Actor(actor)), None) => Some(PrincipalConstraint::Actor { actor }),
+            (Some(PrincipalId::ActorGroup(actor_group)), actor_type) => {
+                Some(PrincipalConstraint::ActorGroup {
+                    actor_group,
+                    actor_type,
+                })
+            }
+            (Some(PrincipalId::Role(role)), actor_type) => {
+                Some(PrincipalConstraint::Role { role, actor_type })
+            }
+            _ => return Err(Report::new(GetPoliciesError::InvalidPrincipalConstraint)),
+        };
+
+        Ok(Policy {
+            id: self.id,
+            effect: self.effect,
+            principal: principal_constraint,
+            actions: self.actions,
+            resource: self.resource_constraint,
+            constraints: None,
+        })
+    }
+}
+
+impl<C, A> PolicyStore for PostgresStore<C, A>
+where
+    C: AsClient,
+    A: AuthorizationApi + Send + Sync,
+{
+    async fn get_policy_by_id(
+        &self,
+        _authenticated_actor: ActorEntityUuid,
+        policy_id: PolicyId,
+    ) -> Result<Option<Policy>, Report<GetPoliciesError>> {
+        self.as_client()
+            .query_opt(
+                "
+                SELECT
+                    policy.id,
+                    policy.effect,
+                    policy.principal_id,
+                    policy.principal_type,
+                    policy.actor_type,
+                    policy.resource_constraint,
+                    array_agg(policy_action.action_name)
+                FROM policy
+                JOIN policy_action ON policy.id = policy_action.policy_id
+                WHERE policy.id = $1
+                GROUP BY
+                    policy.id, policy.effect, policy.principal_id, policy.principal_type,
+                    policy.actor_type, policy.resource_constraint
+                ",
+                &[&policy_id],
+            )
+            .await
+            .change_context(GetPoliciesError::StoreError)?
+            .map(|row| {
+                PolicyParts {
+                    id: row.get(0),
+                    effect: row.get(1),
+                    principal_uuid: row.get(2),
+                    principal_type: row.get(3),
+                    actor_type: row.get(4),
+                    resource_constraint: row
+                        .get::<_, Option<Json<ResourceConstraint>>>(5)
+                        .map(|json| json.0),
+                    actions: row.get(6),
+                }
+                .into_policy()
+            })
+            .transpose()
+    }
+
+    async fn query_policies(
+        &self,
+        _authenticated_actor: ActorEntityUuid,
+        filter: &PolicyFilter,
+    ) -> Result<Vec<Policy>, Report<GetPoliciesError>> {
path: libs/@local/graph/postgres-store/src/store/postgres/mod.rs
user: login: hashdotai
body: This function should validate the filter before passing it to the store. Consider adding validation logic to ensure the filter is well-formed before proceeding.
created_at: 2025-05-09T12:50:02Z
author_association: MEMBER
diff_hunk: @@ -756,6 +757,330 @@
     }
 }
 
+struct PolicyParts {
+    id: PolicyId,
+    effect: Effect,
+    principal_uuid: Option<Uuid>,
+    principal_type: Option<PrincipalType>,
+    actor_type: Option<PrincipalType>,
+    resource_constraint: Option<ResourceConstraint>,
+    actions: Vec<ActionName>,
+}
+impl PolicyParts {
+    fn into_policy(self) -> Result<Policy, Report<GetPoliciesError>> {
+        let principal_id = match (self.principal_uuid, self.principal_type) {
+            (Some(uuid), Some(principal_type)) => Some(PrincipalId::new(uuid, principal_type)),
+            (None, None) => None,
+            (Some(_), None) | (None, Some(_)) => {
+                return Err(Report::new(GetPoliciesError::InvalidPrincipalConstraint));
+            }
+        };
+
+        let actor_type = self
+            .actor_type
+            .map(|principal_type| match principal_type {
+                PrincipalType::User => Ok(ActorType::User),
+                PrincipalType::Machine => Ok(ActorType::Machine),
+                PrincipalType::Ai => Ok(ActorType::Ai),
+                _ => Err(GetPoliciesError::InvalidPrincipalConstraint),
+            })
+            .transpose()?;
+
+        let principal_constraint = match (principal_id, actor_type) {
+            (None, None) => None,
+            (None, Some(actor_type)) => Some(PrincipalConstraint::ActorType { actor_type }),
+            (Some(PrincipalId::Actor(actor)), None) => Some(PrincipalConstraint::Actor { actor }),
+            (Some(PrincipalId::ActorGroup(actor_group)), actor_type) => {
+                Some(PrincipalConstraint::ActorGroup {
+                    actor_group,
+                    actor_type,
+                })
+            }
+            (Some(PrincipalId::Role(role)), actor_type) => {
+                Some(PrincipalConstraint::Role { role, actor_type })
+            }
+            _ => return Err(Report::new(GetPoliciesError::InvalidPrincipalConstraint)),
+        };
+
+        Ok(Policy {
+            id: self.id,
+            effect: self.effect,
+            principal: principal_constraint,
+            actions: self.actions,
+            resource: self.resource_constraint,
+            constraints: None,
+        })
+    }
+}
+
+impl<C, A> PolicyStore for PostgresStore<C, A>
+where
+    C: AsClient,
+    A: AuthorizationApi + Send + Sync,
+{
+    async fn get_policy_by_id(
+        &self,
+        _authenticated_actor: ActorEntityUuid,
+        policy_id: PolicyId,
+    ) -> Result<Option<Policy>, Report<GetPoliciesError>> {
+        self.as_client()
+            .query_opt(
+                "
+                SELECT
+                    policy.id,
+                    policy.effect,
+                    policy.principal_id,
+                    policy.principal_type,
+                    policy.actor_type,
+                    policy.resource_constraint,
+                    array_agg(policy_action.action_name)
+                FROM policy
+                JOIN policy_action ON policy.id = policy_action.policy_id
+                WHERE policy.id = $1
+                GROUP BY
+                    policy.id, policy.effect, policy.principal_id, policy.principal_type,
+                    policy.actor_type, policy.resource_constraint
+                ",
+                &[&policy_id],
+            )
+            .await
+            .change_context(GetPoliciesError::StoreError)?
+            .map(|row| {
+                PolicyParts {
+                    id: row.get(0),
+                    effect: row.get(1),
+                    principal_uuid: row.get(2),
+                    principal_type: row.get(3),
+                    actor_type: row.get(4),
+                    resource_constraint: row
+                        .get::<_, Option<Json<ResourceConstraint>>>(5)
+                        .map(|json| json.0),
+                    actions: row.get(6),
+                }
+                .into_policy()
+            })
+            .transpose()
+    }
+
+    async fn query_policies(
+        &self,
+        _authenticated_actor: ActorEntityUuid,
+        filter: &PolicyFilter,
+    ) -> Result<Vec<Policy>, Report<GetPoliciesError>> {
+        let mut filters = Vec::new();
+        let mut parameters = Vec::<Box<dyn ToSql + Send + Sync>>::new();
+
+        if let Some(principal) = &filter.principal {
+            let (principal_id, actor_type) = match principal {
+                PrincipalFilter::Constrained(PrincipalConstraint::ActorType { actor_type }) => {
+                    (None, Some(*actor_type))
+                }
+                PrincipalFilter::Constrained(PrincipalConstraint::Actor { actor }) => {
+                    (Some(PrincipalId::Actor(*actor)), None)
+                }
+                PrincipalFilter::Constrained(PrincipalConstraint::ActorGroup {
+                    actor_group,
+                    actor_type,
+                }) => (Some(PrincipalId::ActorGroup(*actor_group)), *actor_type),
+                PrincipalFilter::Constrained(PrincipalConstraint::Role { role, actor_type }) => {
+                    (Some(PrincipalId::Role(*role)), *actor_type)
+                }
+                PrincipalFilter::Unconstrained => (None, None),
+            };
+            if let Some(principal_id) = principal_id {
+                parameters.push(Box::new(principal_id));
+                parameters.push(Box::new(principal_id.principal_type()));
+                filters.push(format!(
+                    "policy.principal_id = ${} AND policy.principal_type = ${}",
+                    parameters.len() - 1,
+                    parameters.len()
+                ));
+            } else {
+                filters.push(
+                    "policy.principal_id IS NULL AND policy.principal_type IS NULL".to_owned(),
+                );
+            }
+
+            if let Some(actor_type) = actor_type {
+                parameters.push(Box::new(PrincipalType::from(actor_type)));
+                filters.push(format!("policy.actor_type = ${}", parameters.len()));
+            } else {
+                filters.push("policy.actor_type IS NULL".to_owned());
+            }
+        }
+
+        let base_query = "
+            SELECT
+                policy.id,
+                policy.effect,
+                policy.principal_id,
+                policy.principal_type,
+                policy.actor_type,
+                policy.resource_constraint,
+                array_agg(policy_action.action_name)
+            FROM policy
+            JOIN policy_action ON policy.id = policy_action.policy_id
+            "
+        .to_owned();
+        let group_by_query = "
+            GROUP BY
+                policy.id,
+                policy.effect,
+                policy.principal_id,
+                policy.principal_type,
+                policy.actor_type,
+                policy.resource_constraint
+            ";
+
+        let query = if filters.is_empty() {
+            format!("{base_query} {group_by_query}")
+        } else {
+            format!(
+                "{base_query} WHERE {} {group_by_query}",
+                filters.join(" AND ")
+            )
+        };
+
+        self.as_client()
+            .query_raw(&query, parameters)
+            .await
+            .change_context(GetPoliciesError::StoreError)?
+            .map_err(|error| Report::new(error).change_context(GetPoliciesError::StoreError))
+            .and_then(async |row| -> Result<_, Report<GetPoliciesError>> {
+                PolicyParts {
+                    id: PolicyId::new(row.get(0)),
+                    effect: row.get(1),
+                    principal_uuid: row.get(2),
+                    principal_type: row.get(3),
+                    actor_type: row.get(4),
+                    resource_constraint: row
+                        .get::<_, Option<Json<ResourceConstraint>>>(5)
+                        .map(|json| json.0),
+                    actions: row.get(6),
+                }
+                .into_policy()
+            })
+            .try_collect()
+            .await
+    }
+
+    async fn resolve_policies_for_actor(
+        &self,
+        _authenticated_actor: ActorEntityUuid,
+        actor_id: ActorId,
+    ) -> Result<Vec<Policy>, Report<GetPoliciesError>> {
+        // The below query does several things. It:
+        //   1. gets all principals that the actor can act as
+        //   2. gets all policies that apply to those principals
+        //   3. TODO: filters the policies based on the action -- currently not implemented
+        //   4. TODO: filters the policies based on the resource -- currently not implemented
+        //
+        // Principals are retrieved by:
+        //   - the actor itself, filtered by the actor ID and actor type
+        //   - all roles assigned to the actor, filtered by the actor ID
+        //   - all actor groups associated with those roles, determined by the role's actor group ID
+        //   - all parent actor groups of those actor groups (for teams), determined by the actor
+        //     group hierarchy
+        //
+        // The actions are associated in the `policy_action` table. We join that table and aggregate
+        // the actions for each policy. All actions are included, but the action hierarchy is used
+        // to determine which actions are relevant to the actor.
+        self.as_client()
+            .query_raw(
+                "
path: libs/@local/graph/postgres-store/src/store/postgres/mod.rs
user: login: hashdotai
body: This SQL query is quite complex and might benefit from some additional comments explaining specific parts, especially around the team hierarchy traversal logic. Consider adding more detailed inline documentation.
created_at: 2025-05-09T12:50:02Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,63 @@
+import type { ActorId } from "@blockprotocol/type-system";
+import type { GraphApi } from "@local/hash-graph-client";
+import type {
+  Policy,
+  PolicyFilter,
+  PolicyId,
+} from "@rust/hash-graph-authorization/types";
+
+import type { AuthenticationContext } from "./authentication-context.js";
+
+/**
+ * Retrieves a policy by its ID.
+ */
+export const getPolicyById = (
+  graphAPI: GraphApi,
+  authentication: AuthenticationContext,
+  policyId: PolicyId,
+): Promise<Policy | undefined> =>
+  graphAPI
+    .getPolicyById(authentication.actorId, policyId)
+    .then(({ data: policy }) => (policy as Policy | null) ?? undefined);
+
+/**
+ * Queries for policies in the local store that match the provided filter.
+ *
+ * This method queries the underlying policy store using the given [`PolicyFilter`] and returns
+ * a list of matching [`Policy`] objects. The filter can be used to specify criteria such as
+ * policy type, subject, resource, or action.
+ *
+ * Note that this does not resolve indirect policies (e.g., policies applying to roles held by
+ * a specific actor). For resolving all policies applicable to an actor, including indirect
+ * ones, use `resolvePoliciesForActor`.
+ */
+export const queryPolicies = (
+  graphAPI: GraphApi,
+  authentication: AuthenticationContext,
+  filter: PolicyFilter,
+): Promise<Policy[]> =>
+  graphAPI
+    .queryPolicies(authentication.actorId, filter)
path: libs/@local/graph/sdk/typescript/src/policy.ts
user: login: hashdotai
body: It would be better to use proper TypeScript parameter and return types here instead of `any`. Consider using the generated Policy type.
created_at: 2025-05-09T12:50:02Z
author_association: MEMBER
diff_hunk: @@ -2910,6 +2910,139 @@
         }
       }
     },
+    "/policies/query": {
+      "post": {
+        "tags": [
+          "Graph",
+          "Policy"
+        ],
+        "operationId": "query_policies",
+        "parameters": [
+          {
+            "name": "X-Authenticated-User-Actor-Id",
+            "in": "header",
+            "description": "The ID of the actor which is used to authorize the request",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/ActorEntityUuid"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {}
path: libs/@local/graph/api/openapi/openapi.json
user: login: TimDiekmann
body: We intentionally return `any` here to cast it to the properly generated type in the SDK
created_at: 2025-05-09T12:53:07Z
author_association: MEMBER
diff_hunk: @@ -2910,6 +2910,139 @@
         }
       }
     },
+    "/policies/query": {
+      "post": {
+        "tags": [
+          "Graph",
+          "Policy"
+        ],
+        "operationId": "query_policies",
+        "parameters": [
+          {
+            "name": "X-Authenticated-User-Actor-Id",
+            "in": "header",
+            "description": "The ID of the actor which is used to authorize the request",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/ActorEntityUuid"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {}
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "List of policies matching the filter",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {}
path: libs/@local/graph/api/openapi/openapi.json
user: login: TimDiekmann
body: We intentionally return `any` here to cast it to the properly generated type in the SDK
created_at: 2025-05-09T12:53:11Z
author_association: MEMBER
diff_hunk: @@ -2910,6 +2910,139 @@
         }
       }
     },
+    "/policies/query": {
+      "post": {
+        "tags": [
+          "Graph",
+          "Policy"
+        ],
+        "operationId": "query_policies",
+        "parameters": [
+          {
+            "name": "X-Authenticated-User-Actor-Id",
+            "in": "header",
+            "description": "The ID of the actor which is used to authorize the request",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/ActorEntityUuid"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {}
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "List of policies matching the filter",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {}
+                }
+              }
+            }
+          },
+          "500": {
+            "description": "Store error occurred"
+          }
+        }
+      }
+    },
+    "/policies/resolve/actor": {
+      "post": {
+        "tags": [
+          "Graph",
+          "Policy"
+        ],
+        "operationId": "resolve_policies_for_actor",
+        "parameters": [
+          {
+            "name": "X-Authenticated-User-Actor-Id",
+            "in": "header",
+            "description": "The ID of the actor which is used to authorize the request",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/ActorEntityUuid"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {}
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "List of policies found for the actor",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {}
path: libs/@local/graph/api/openapi/openapi.json
user: login: TimDiekmann
body: We intentionally return `any` here to cast it to the properly generated type in the SDK
created_at: 2025-05-09T12:53:19Z
author_association: MEMBER
diff_hunk: @@ -756,6 +757,330 @@
     }
 }
 
+struct PolicyParts {
+    id: PolicyId,
+    effect: Effect,
+    principal_uuid: Option<Uuid>,
+    principal_type: Option<PrincipalType>,
+    actor_type: Option<PrincipalType>,
+    resource_constraint: Option<ResourceConstraint>,
+    actions: Vec<ActionName>,
+}
+impl PolicyParts {
+    fn into_policy(self) -> Result<Policy, Report<GetPoliciesError>> {
+        let principal_id = match (self.principal_uuid, self.principal_type) {
+            (Some(uuid), Some(principal_type)) => Some(PrincipalId::new(uuid, principal_type)),
+            (None, None) => None,
+            (Some(_), None) | (None, Some(_)) => {
+                return Err(Report::new(GetPoliciesError::InvalidPrincipalConstraint));
+            }
+        };
+
+        let actor_type = self
+            .actor_type
+            .map(|principal_type| match principal_type {
+                PrincipalType::User => Ok(ActorType::User),
+                PrincipalType::Machine => Ok(ActorType::Machine),
+                PrincipalType::Ai => Ok(ActorType::Ai),
+                _ => Err(GetPoliciesError::InvalidPrincipalConstraint),
+            })
+            .transpose()?;
+
+        let principal_constraint = match (principal_id, actor_type) {
+            (None, None) => None,
+            (None, Some(actor_type)) => Some(PrincipalConstraint::ActorType { actor_type }),
+            (Some(PrincipalId::Actor(actor)), None) => Some(PrincipalConstraint::Actor { actor }),
+            (Some(PrincipalId::ActorGroup(actor_group)), actor_type) => {
+                Some(PrincipalConstraint::ActorGroup {
+                    actor_group,
+                    actor_type,
+                })
+            }
+            (Some(PrincipalId::Role(role)), actor_type) => {
+                Some(PrincipalConstraint::Role { role, actor_type })
+            }
+            _ => return Err(Report::new(GetPoliciesError::InvalidPrincipalConstraint)),
+        };
+
+        Ok(Policy {
+            id: self.id,
+            effect: self.effect,
+            principal: principal_constraint,
+            actions: self.actions,
+            resource: self.resource_constraint,
+            constraints: None,
+        })
+    }
+}
+
+impl<C, A> PolicyStore for PostgresStore<C, A>
+where
+    C: AsClient,
+    A: AuthorizationApi + Send + Sync,
+{
+    async fn get_policy_by_id(
+        &self,
+        _authenticated_actor: ActorEntityUuid,
+        policy_id: PolicyId,
+    ) -> Result<Option<Policy>, Report<GetPoliciesError>> {
+        self.as_client()
+            .query_opt(
+                "
+                SELECT
+                    policy.id,
+                    policy.effect,
+                    policy.principal_id,
+                    policy.principal_type,
+                    policy.actor_type,
+                    policy.resource_constraint,
+                    array_agg(policy_action.action_name)
+                FROM policy
+                JOIN policy_action ON policy.id = policy_action.policy_id
+                WHERE policy.id = $1
+                GROUP BY
+                    policy.id, policy.effect, policy.principal_id, policy.principal_type,
+                    policy.actor_type, policy.resource_constraint
+                ",
+                &[&policy_id],
+            )
+            .await
+            .change_context(GetPoliciesError::StoreError)?
+            .map(|row| {
+                PolicyParts {
+                    id: row.get(0),
+                    effect: row.get(1),
+                    principal_uuid: row.get(2),
+                    principal_type: row.get(3),
+                    actor_type: row.get(4),
+                    resource_constraint: row
+                        .get::<_, Option<Json<ResourceConstraint>>>(5)
+                        .map(|json| json.0),
+                    actions: row.get(6),
+                }
+                .into_policy()
+            })
+            .transpose()
+    }
+
+    async fn query_policies(
+        &self,
+        _authenticated_actor: ActorEntityUuid,
+        filter: &PolicyFilter,
+    ) -> Result<Vec<Policy>, Report<GetPoliciesError>> {
+        let mut filters = Vec::new();
+        let mut parameters = Vec::<Box<dyn ToSql + Send + Sync>>::new();
+
+        if let Some(principal) = &filter.principal {
+            let (principal_id, actor_type) = match principal {
+                PrincipalFilter::Constrained(PrincipalConstraint::ActorType { actor_type }) => {
+                    (None, Some(*actor_type))
+                }
+                PrincipalFilter::Constrained(PrincipalConstraint::Actor { actor }) => {
+                    (Some(PrincipalId::Actor(*actor)), None)
+                }
+                PrincipalFilter::Constrained(PrincipalConstraint::ActorGroup {
+                    actor_group,
+                    actor_type,
+                }) => (Some(PrincipalId::ActorGroup(*actor_group)), *actor_type),
+                PrincipalFilter::Constrained(PrincipalConstraint::Role { role, actor_type }) => {
+                    (Some(PrincipalId::Role(*role)), *actor_type)
+                }
+                PrincipalFilter::Unconstrained => (None, None),
+            };
+            if let Some(principal_id) = principal_id {
+                parameters.push(Box::new(principal_id));
+                parameters.push(Box::new(principal_id.principal_type()));
+                filters.push(format!(
+                    "policy.principal_id = ${} AND policy.principal_type = ${}",
+                    parameters.len() - 1,
+                    parameters.len()
+                ));
+            } else {
+                filters.push(
+                    "policy.principal_id IS NULL AND policy.principal_type IS NULL".to_owned(),
+                );
+            }
+
+            if let Some(actor_type) = actor_type {
+                parameters.push(Box::new(PrincipalType::from(actor_type)));
+                filters.push(format!("policy.actor_type = ${}", parameters.len()));
+            } else {
+                filters.push("policy.actor_type IS NULL".to_owned());
+            }
+        }
+
+        let base_query = "
+            SELECT
+                policy.id,
+                policy.effect,
+                policy.principal_id,
+                policy.principal_type,
+                policy.actor_type,
+                policy.resource_constraint,
+                array_agg(policy_action.action_name)
+            FROM policy
+            JOIN policy_action ON policy.id = policy_action.policy_id
+            "
+        .to_owned();
+        let group_by_query = "
+            GROUP BY
+                policy.id,
+                policy.effect,
+                policy.principal_id,
+                policy.principal_type,
+                policy.actor_type,
+                policy.resource_constraint
+            ";
+
+        let query = if filters.is_empty() {
+            format!("{base_query} {group_by_query}")
+        } else {
+            format!(
+                "{base_query} WHERE {} {group_by_query}",
+                filters.join(" AND ")
+            )
+        };
+
+        self.as_client()
+            .query_raw(&query, parameters)
+            .await
+            .change_context(GetPoliciesError::StoreError)?
+            .map_err(|error| Report::new(error).change_context(GetPoliciesError::StoreError))
+            .and_then(async |row| -> Result<_, Report<GetPoliciesError>> {
+                PolicyParts {
+                    id: PolicyId::new(row.get(0)),
+                    effect: row.get(1),
+                    principal_uuid: row.get(2),
+                    principal_type: row.get(3),
+                    actor_type: row.get(4),
+                    resource_constraint: row
+                        .get::<_, Option<Json<ResourceConstraint>>>(5)
+                        .map(|json| json.0),
+                    actions: row.get(6),
+                }
+                .into_policy()
+            })
+            .try_collect()
+            .await
+    }
+
+    async fn resolve_policies_for_actor(
+        &self,
+        _authenticated_actor: ActorEntityUuid,
+        actor_id: ActorId,
+    ) -> Result<Vec<Policy>, Report<GetPoliciesError>> {
+        // The below query does several things. It:
+        //   1. gets all principals that the actor can act as
+        //   2. gets all policies that apply to those principals
+        //   3. TODO: filters the policies based on the action -- currently not implemented
+        //   4. TODO: filters the policies based on the resource -- currently not implemented
+        //
+        // Principals are retrieved by:
+        //   - the actor itself, filtered by the actor ID and actor type
+        //   - all roles assigned to the actor, filtered by the actor ID
+        //   - all actor groups associated with those roles, determined by the role's actor group ID
+        //   - all parent actor groups of those actor groups (for teams), determined by the actor
+        //     group hierarchy
+        //
+        // The actions are associated in the `policy_action` table. We join that table and aggregate
+        // the actions for each policy. All actions are included, but the action hierarchy is used
+        // to determine which actions are relevant to the actor.
+        self.as_client()
+            .query_raw(
+                "
path: libs/@local/graph/postgres-store/src/store/postgres/mod.rs
user: login: TimDiekmann
body: It's documented
created_at: 2025-05-09T12:57:03Z
author_association: MEMBER
diff_hunk: @@ -756,6 +757,330 @@ where
     }
 }
 
+struct PolicyParts {
+    id: PolicyId,
+    effect: Effect,
+    principal_uuid: Option<Uuid>,
+    principal_type: Option<PrincipalType>,
+    actor_type: Option<PrincipalType>,
+    resource_constraint: Option<ResourceConstraint>,
+    actions: Vec<ActionName>,
+}
+impl PolicyParts {
+    fn into_policy(self) -> Result<Policy, Report<GetPoliciesError>> {
+        let principal_id = match (self.principal_uuid, self.principal_type) {
+            (Some(uuid), Some(principal_type)) => Some(PrincipalId::new(uuid, principal_type)),
+            (None, None) => None,
+            (Some(_), None) | (None, Some(_)) => {
+                return Err(Report::new(GetPoliciesError::InvalidPrincipalConstraint));
+            }
+        };
+
+        let actor_type = self
+            .actor_type
+            .map(|principal_type| match principal_type {
+                PrincipalType::User => Ok(ActorType::User),
+                PrincipalType::Machine => Ok(ActorType::Machine),
+                PrincipalType::Ai => Ok(ActorType::Ai),
+                _ => Err(GetPoliciesError::InvalidPrincipalConstraint),
+            })
+            .transpose()?;
+
+        let principal_constraint = match (principal_id, actor_type) {
+            (None, None) => None,
+            (None, Some(actor_type)) => Some(PrincipalConstraint::ActorType { actor_type }),
+            (Some(PrincipalId::Actor(actor)), None) => Some(PrincipalConstraint::Actor { actor }),
+            (Some(PrincipalId::ActorGroup(actor_group)), actor_type) => {
+                Some(PrincipalConstraint::ActorGroup {
+                    actor_group,
+                    actor_type,
+                })
+            }
+            (Some(PrincipalId::Role(role)), actor_type) => {
+                Some(PrincipalConstraint::Role { role, actor_type })
+            }
+            _ => return Err(Report::new(GetPoliciesError::InvalidPrincipalConstraint)),
+        };
+
+        Ok(Policy {
+            id: self.id,
+            effect: self.effect,
+            principal: principal_constraint,
+            actions: self.actions,
+            resource: self.resource_constraint,
+            constraints: None,
+        })
+    }
+}
+
+impl<C, A> PolicyStore for PostgresStore<C, A>
+where
+    C: AsClient,
+    A: AuthorizationApi + Send + Sync,
+{
+    async fn get_policy_by_id(
+        &self,
+        _authenticated_actor: ActorEntityUuid,
+        policy_id: PolicyId,
+    ) -> Result<Option<Policy>, Report<GetPoliciesError>> {
+        self.as_client()
+            .query_opt(
+                "
+                SELECT
+                    policy.id,
+                    policy.effect,
+                    policy.principal_id,
+                    policy.principal_type,
+                    policy.actor_type,
+                    policy.resource_constraint,
+                    array_agg(policy_action.action_name)
+                FROM policy
+                JOIN policy_action ON policy.id = policy_action.policy_id
+                WHERE policy.id = $1
+                GROUP BY
+                    policy.id, policy.effect, policy.principal_id, policy.principal_type,
+                    policy.actor_type, policy.resource_constraint
+                ",
+                &[&policy_id],
+            )
+            .await
+            .change_context(GetPoliciesError::StoreError)?
+            .map(|row| {
+                PolicyParts {
+                    id: row.get(0),
+                    effect: row.get(1),
+                    principal_uuid: row.get(2),
+                    principal_type: row.get(3),
+                    actor_type: row.get(4),
+                    resource_constraint: row
+                        .get::<_, Option<Json<ResourceConstraint>>>(5)
+                        .map(|json| json.0),
+                    actions: row.get(6),
+                }
+                .into_policy()
+            })
+            .transpose()
+    }
+
+    async fn query_policies(
+        &self,
+        _authenticated_actor: ActorEntityUuid,
+        filter: &PolicyFilter,
+    ) -> Result<Vec<Policy>, Report<GetPoliciesError>> {
path: libs/@local/graph/postgres-store/src/store/postgres/mod.rs
user: login: TimDiekmann
body: The filter is validated.
created_at: 2025-05-09T12:57:35Z
author_association: MEMBER
diff_hunk: @@ -2910,6 +2910,139 @@
         }
       }
     },
+    "/policies/query": {
+      "post": {
+        "tags": [
+          "Graph",
+          "Policy"
+        ],
+        "operationId": "query_policies",
+        "parameters": [
+          {
+            "name": "X-Authenticated-User-Actor-Id",
+            "in": "header",
+            "description": "The ID of the actor which is used to authorize the request",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/ActorEntityUuid"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {}
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "List of policies matching the filter",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {}
+                }
+              }
+            }
+          },
+          "500": {
+            "description": "Store error occurred"
+          }
+        }
+      }
+    },
+    "/policies/resolve/actor": {
+      "post": {
+        "tags": [
+          "Graph",
+          "Policy"
+        ],
+        "operationId": "resolve_policies_for_actor",
+        "parameters": [
+          {
+            "name": "X-Authenticated-User-Actor-Id",
+            "in": "header",
+            "description": "The ID of the actor which is used to authorize the request",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/ActorEntityUuid"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {}
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "description": "List of policies found for the actor",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {}
+                }
+              }
+            }
+          },
+          "500": {
+            "description": "Store error occurred"
+          }
+        }
+      }
+    },
+    "/policies/{policy_id}": {
+      "get": {
+        "tags": [
+          "Graph",
+          "Policy"
+        ],
+        "operationId": "get_policy_by_id",
+        "parameters": [
+          {
+            "name": "X-Authenticated-User-Actor-Id",
+            "in": "header",
+            "description": "The ID of the actor which is used to authorize the request",
+            "required": true,
+            "schema": {
+              "$ref": "#/components/schemas/ActorEntityUuid"
+            }
+          },
+          {
+            "name": "policy_id",
+            "in": "path",
+            "description": "The ID of the policy to find",
+            "required": true,
+            "schema": {
+              "type": "string",
+              "format": "uuid"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "",
path: libs/@local/graph/api/openapi/openapi.json
user: login: TimDiekmann
body: [`6d5bec4` (#7121)](https://github.com/hashintel/hash/pull/7121/commits/6d5bec4a5de9bf1b9f5cecf432a765e2dc276c00)
created_at: 2025-05-09T12:57:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,63 @@
+import type { ActorId } from "@blockprotocol/type-system";
+import type { GraphApi } from "@local/hash-graph-client";
+import type {
+  Policy,
+  PolicyFilter,
+  PolicyId,
+} from "@rust/hash-graph-authorization/types";
+
+import type { AuthenticationContext } from "./authentication-context.js";
+
+/**
+ * Retrieves a policy by its ID.
+ */
+export const getPolicyById = (
+  graphAPI: GraphApi,
+  authentication: AuthenticationContext,
+  policyId: PolicyId,
+): Promise<Policy | undefined> =>
+  graphAPI
+    .getPolicyById(authentication.actorId, policyId)
+    .then(({ data: policy }) => (policy as Policy | null) ?? undefined);
+
+/**
+ * Queries for policies in the local store that match the provided filter.
+ *
+ * This method queries the underlying policy store using the given [`PolicyFilter`] and returns
+ * a list of matching [`Policy`] objects. The filter can be used to specify criteria such as
+ * policy type, subject, resource, or action.
+ *
+ * Note that this does not resolve indirect policies (e.g., policies applying to roles held by
+ * a specific actor). For resolving all policies applicable to an actor, including indirect
+ * ones, use `resolvePoliciesForActor`.
+ */
+export const queryPolicies = (
+  graphAPI: GraphApi,
+  authentication: AuthenticationContext,
+  filter: PolicyFilter,
+): Promise<Policy[]> =>
+  graphAPI
+    .queryPolicies(authentication.actorId, filter)
path: libs/@local/graph/sdk/typescript/src/policy.ts
user: login: TimDiekmann
body: The next line returns a proper TypeScript type
created_at: 2025-05-09T12:58:12Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-05-09T12:51:23Z
body: ## [Codecov](https://app.codecov.io/gh/hashintel/hash/pull/7121?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hashintel) Report
Attention: Patch coverage is `0%` with `9 lines` in your changes missing coverage. Please review.
> Project coverage is 30.71%. Comparing base [(`f848c8b`)](https://app.codecov.io/gh/hashintel/hash/commit/f848c8b325a0e13a51175349329ff5b5316e56ce?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hashintel) to head [(`a26ebe3`)](https://app.codecov.io/gh/hashintel/hash/commit/a26ebe3952140bb7f21a6270740fb85da967c568?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hashintel).
> Report is 11 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/hashintel/hash/pull/7121?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hashintel) | Patch % | Lines |
|---|---|---|
| [libs/@local/graph/sdk/typescript/src/policy.ts](https://app.codecov.io/gh/hashintel/hash/pull/7121?src=pr&el=tree&filepath=libs%2F%40local%2Fgraph%2Fsdk%2Ftypescript%2Fsrc%2Fpolicy.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hashintel#diff-bGlicy9AbG9jYWwvZ3JhcGgvc2RrL3R5cGVzY3JpcHQvc3JjL3BvbGljeS50cw==) | 0.00% | [9 Missing :warning: ](https://app.codecov.io/gh/hashintel/hash/pull/7121?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hashintel) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #7121      +/-   ##
==========================================
- Coverage   30.71%   30.71%   -0.01%     
==========================================
  Files         610      611       +1     
  Lines       23311    23297      -14     
  Branches     2974     2970       -4     
==========================================
- Hits         7160     7155       -5     
+ Misses      16094    16085       -9     
  Partials       57       57              
```

| [Flag](https://app.codecov.io/gh/hashintel/hash/pull/7121/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hashintel) | Coverage Δ | |
|---|---|---|
| [apps.hash-ai-worker-ts](https://app.codecov.io/gh/hashintel/hash/pull/7121/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hashintel) | `1.32% <ø> (ø)` | |
| [apps.hash-api](https://app.codecov.io/gh/hashintel/hash/pull/7121/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hashintel) | `0.00% <ø> (ø)` | |
| [local.hash-backend-utils](https://app.codecov.io/gh/hashintel/hash/pull/7121/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hashintel) | `3.95% <ø> (ø)` | |
| [local.hash-graph-sdk](https://app.codecov.io/gh/hashintel/hash/pull/7121/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hashintel) | `0.00% <0.00%> (ø)` | |
| [local.hash-isomorphic-utils](https://app.codecov.io/gh/hashintel/hash/pull/7121/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hashintel) | `0.00% <ø> (ø)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hashintel#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/hashintel/hash/pull/7121?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hashintel).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hashintel).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-10T15:15:38Z
body: <!-- add-pr-comment:benchmark-results -->

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

## 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 10.9 \mathrm{μs}\left({\color{gray}0.091 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/representative_read_entity_type/get_entity_type_by_id/Account+ID_+`d4e16033-c281-4cde-aa35-9085bf2e7579`/flamegraph.svg) |


## scaling_read_entity_complete_one_depth

| Function | Value | Mean | Flame graphs | 
|----------|-------|------|--------------|
| entity_by_id | 25 entities | $$179 \mathrm{ms} \pm 881 \mathrm{μs}\left({\color{gray}0.831 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/scaling_read_entity_complete_one_depth/entity_by_id/25+entities/flamegraph.svg) |
| entity_by_id | 10 entities | $$51.2 \mathrm{ms} \pm 3.09 \mathrm{ms}\left({\color{red}56.9 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/scaling_read_entity_complete_one_depth/entity_by_id/10+entities/flamegraph.svg) |
| entity_by_id | 5 entities | $$27.2 \mathrm{ms} \pm 99.7 \mathrm{μs}\left({\color{gray}0.685 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/scaling_read_entity_complete_one_depth/entity_by_id/5+entities/flamegraph.svg) |
| entity_by_id | 50 entities | $$270 \mathrm{ms} \pm 2.52 \mathrm{ms}\left({\color{lightgreen}-94.989 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/scaling_read_entity_complete_one_depth/entity_by_id/50+entities/flamegraph.svg) |
| entity_by_id | 1 entities | $$21.1 \mathrm{ms} \pm 70.1 \mathrm{μs}\left({\color{gray}0.727 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/scaling_read_entity_complete_one_depth/entity_by_id/1+entities/flamegraph.svg) |


## representative_read_multiple_entities

| Function | Value | Mean | Flame graphs | 
|----------|-------|------|--------------|
| link_by_source_by_property | depths: DT=0, PT=0, ET=0, E=0 | $$37.4 \mathrm{ms} \pm 378 \mathrm{μs}\left({\color{gray}0.402 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/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 | $$73.5 \mathrm{ms} \pm 577 \mathrm{μs}\left({\color{gray}1.60 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/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 | $$91.1 \mathrm{ms} \pm 824 \mathrm{μs}\left({\color{gray}1.64 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/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=0, PT=2, ET=2, E=2 | $$85.8 \mathrm{ms} \pm 548 \mathrm{μs}\left({\color{gray}-0.319 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/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=2, E=2 | $$81.7 \mathrm{ms} \pm 534 \mathrm{μs}\left({\color{gray}0.659 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/representative_read_multiple_entities/link_by_source_by_property/depths_+DT=0,+PT=0,+ET=2,+E=2/flamegraph.svg) |
| link_by_source_by_property | depths: DT=255, PT=255, ET=255, E=255 | $$98.5 \mathrm{ms} \pm 480 \mathrm{μs}\left({\color{gray}-2.663 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/representative_read_multiple_entities/link_by_source_by_property/depths_+DT=255,+PT=255,+ET=255,+E=255/flamegraph.svg) |
| entity_by_property | depths: DT=0, PT=0, ET=0, E=0 | $$40.8 \mathrm{ms} \pm 279 \mathrm{μs}\left({\color{red}5.85 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/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 | $$42.4 \mathrm{ms} \pm 472 \mathrm{μs}\left({\color{gray}1.73 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/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 | $$55.6 \mathrm{ms} \pm 365 \mathrm{μs}\left({\color{gray}-0.759 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/representative_read_multiple_entities/entity_by_property/depths_+DT=2,+PT=2,+ET=2,+E=2/flamegraph.svg) |
| entity_by_property | depths: DT=0, PT=2, ET=2, E=2 | $$51.7 \mathrm{ms} \pm 411 \mathrm{μs}\left({\color{gray}-1.233 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/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=2, E=2 | $$47.5 \mathrm{ms} \pm 416 \mathrm{μs}\left({\color{gray}-0.720 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/representative_read_multiple_entities/entity_by_property/depths_+DT=0,+PT=0,+ET=2,+E=2/flamegraph.svg) |
| entity_by_property | depths: DT=255, PT=255, ET=255, E=255 | $$65.3 \mathrm{ms} \pm 410 \mathrm{μs}\left({\color{gray}0.111 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/representative_read_multiple_entities/entity_by_property/depths_+DT=255,+PT=255,+ET=255,+E=255/flamegraph.svg) |


## scaling_read_entity_complete_zero_depth

| Function | Value | Mean | Flame graphs | 
|----------|-------|------|--------------|
| entity_by_id | 25 entities | $$3.37 \mathrm{ms} \pm 25.2 \mathrm{μs}\left({\color{red}22.1 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/scaling_read_entity_complete_zero_depth/entity_by_id/25+entities/flamegraph.svg) |
| entity_by_id | 10 entities | $$2.28 \mathrm{ms} \pm 11.6 \mathrm{μs}\left({\color{gray}0.308 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/scaling_read_entity_complete_zero_depth/entity_by_id/10+entities/flamegraph.svg) |
| entity_by_id | 5 entities | $$2.07 \mathrm{ms} \pm 9.45 \mathrm{μs}\left({\color{gray}0.872 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/scaling_read_entity_complete_zero_depth/entity_by_id/5+entities/flamegraph.svg) |
| entity_by_id | 50 entities | $$4.06 \mathrm{ms} \pm 24.1 \mathrm{μs}\left({\color{gray}-1.200 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/scaling_read_entity_complete_zero_depth/entity_by_id/50+entities/flamegraph.svg) |
| entity_by_id | 1 entities | $$2.04 \mathrm{ms} \pm 11.0 \mathrm{μs}\left({\color{gray}0.021 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/scaling_read_entity_complete_zero_depth/entity_by_id/1+entities/flamegraph.svg) |


## scaling_read_entity_linkless

| Function | Value | Mean | Flame graphs | 
|----------|-------|------|--------------|
| entity_by_id | 10 entities | $$2.08 \mathrm{ms} \pm 11.2 \mathrm{μs}\left({\color{gray}0.868 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/scaling_read_entity_linkless/entity_by_id/10+entities/flamegraph.svg) |
| entity_by_id | 10000 entities | $$13.8 \mathrm{ms} \pm 75.3 \mathrm{μs}\left({\color{red}48.8 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/scaling_read_entity_linkless/entity_by_id/10000+entities/flamegraph.svg) |
| entity_by_id | 100 entities | $$2.27 \mathrm{ms} \pm 14.6 \mathrm{μs}\left({\color{gray}0.401 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/scaling_read_entity_linkless/entity_by_id/100+entities/flamegraph.svg) |
| entity_by_id | 1000 entities | $$3.04 \mathrm{ms} \pm 19.8 \mathrm{μs}\left({\color{gray}-1.411 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/scaling_read_entity_linkless/entity_by_id/1000+entities/flamegraph.svg) |
| entity_by_id | 1 entities | $$2.06 \mathrm{ms} \pm 9.50 \mathrm{μs}\left({\color{gray}1.99 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/scaling_read_entity_linkless/entity_by_id/1+entities/flamegraph.svg) |


## representative_read_entity

| Function | Value | Mean | Flame graphs | 
|----------|-------|------|--------------|
| entity_by_id | entity type ID: `https://blockprotocol.org/@alice/types/entity-type/building/v/1` | $$16.1 \mathrm{ms} \pm 205 \mathrm{μs}\left({\color{lightgreen}-10.255 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/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/person/v/1` | $$17.4 \mathrm{ms} \pm 242 \mathrm{μs}\left({\color{red}7.29 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/representative_read_entity/entity_by_id/entity+type+ID_+`https___blockprotocol.org_@alice_types_entity-t_4/flamegraph.svg) |
| entity_by_id | entity type ID: `https://blockprotocol.org/@alice/types/entity-type/block/v/1` | $$17.5 \mathrm{ms} \pm 235 \mathrm{μs}\left({\color{red}6.02 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/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/book/v/1` | $$16.6 \mathrm{ms} \pm 183 \mathrm{μs}\left({\color{gray}-1.668 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/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/playlist/v/1` | $$17.3 \mathrm{ms} \pm 253 \mathrm{μs}\left({\color{gray}4.17 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/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/uk-address/v/1` | $$16.4 \mathrm{ms} \pm 190 \mathrm{μs}\left({\color{gray}-0.507 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/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/page/v/2` | $$16.4 \mathrm{ms} \pm 228 \mathrm{μs}\left({\color{lightgreen}-28.835 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/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/song/v/1` | $$16.0 \mathrm{ms} \pm 187 \mathrm{μs}\left({\color{lightgreen}-5.966 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/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` | $$17.1 \mathrm{ms} \pm 201 \mathrm{μs}\left({\color{lightgreen}-26.123 \mathrm{\\%}}\right) $$ | [Flame Graph](https://benchmarks.hash.dev/a26ebe3952140bb7f21a6270740fb85da967c568/representative_read_entity/entity_by_id/entity+type+ID_+`https___blockprotocol.org_@alice_types_entity-t_2/flamegraph.svg) |

</details>

author_association: CONTRIBUTOR