diff --git a/apps/hash-graph/src/subcommand/server.rs b/apps/hash-graph/src/subcommand/server.rs index 196f94d8a99..739b2aceb8c 100644 --- a/apps/hash-graph/src/subcommand/server.rs +++ b/apps/hash-graph/src/subcommand/server.rs @@ -32,7 +32,7 @@ use multiaddr::{Multiaddr, Protocol}; use regex::Regex; use reqwest::{Client, Url}; use tokio::{io, net::TcpListener, signal, time::timeout}; -use tokio_postgres::NoTls; +use tokio_postgres::{Client as PostgresClient, NoTls}; use tokio_util::{codec::FramedWrite, sync::CancellationToken}; use type_system::ontology::json_schema::DomainValidator; @@ -383,15 +383,15 @@ where /// Starts the main graph API server (REST + optional RPC). async fn start_server( pool: S, - postgres: PostgresStorePool, compiler: Arc, config: ServerConfig, query_logger: Option, + filter_protection: Arc>, lifecycle: &ServerLifecycle, ) -> Result<(), Report> where S: StorePool + Send + Sync + 'static, - for<'p> S::Store<'p>: RestApiStore + PrincipalStore + PolicyStore, + for<'p> S::Store<'p>: RestApiStore + PrincipalStore + PolicyStore + AsRef, { let store = Arc::new(pool); let temporal_client = create_temporal_client(&config.temporal) @@ -414,12 +414,12 @@ where let router = rest_api_router(RestRouterDependencies { store, - postgres, temporal_client, domain_regex: DomainValidator::new(config.allowed_url_domain), query_logger, api_config: config.api_config, compiler, + filter_protection, }); start_rest_server(router, config.http_address, lifecycle); @@ -456,9 +456,9 @@ pub async fn server(mut args: ServerArgs) -> Result<(), Report> { validate_links: !args.config.skip_link_validation, skip_embedding_creation: args.config.skip_embedding_creation, filter_protection: if args.config.skip_filter_protection { - PropertyProtectionFilterConfig::new() + Arc::new(PropertyProtectionFilterConfig::new()) } else { - PropertyProtectionFilterConfig::hash_default() + Arc::new(PropertyProtectionFilterConfig::hash_default()) }, }, ) @@ -478,8 +478,6 @@ pub async fn server(mut args: ServerArgs) -> Result<(), Report> { let lifecycle = ServerLifecycle::new(); - let postgres = pool.clone(); - if args.embed_admin { start_admin_server(pool.clone(), args.admin, &lifecycle); } @@ -488,6 +486,8 @@ pub async fn server(mut args: ServerArgs) -> Result<(), Report> { start_type_fetcher(args.type_fetcher.clone(), &lifecycle); } + let filter_protection = Arc::clone(&pool.settings.filter_protection); + let pool = FetchingPool::new( pool, ( @@ -523,10 +523,10 @@ pub async fn server(mut args: ServerArgs) -> Result<(), Report> { if let Err(error) = start_server( pool, - postgres, compiler, args.config, query_logger, + filter_protection, &lifecycle, ) .await diff --git a/libs/@local/graph/api/openapi/openapi.json b/libs/@local/graph/api/openapi/openapi.json index 85015cfa552..474a050b902 100644 --- a/libs/@local/graph/api/openapi/openapi.json +++ b/libs/@local/graph/api/openapi/openapi.json @@ -2471,6 +2471,15 @@ ], "operationId": "query_hashql", "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": "Interactive", "in": "header", diff --git a/libs/@local/graph/api/src/rest/hashql/compile.rs b/libs/@local/graph/api/src/rest/hashql/compile.rs index b56c9738864..f871e725fed 100644 --- a/libs/@local/graph/api/src/rest/hashql/compile.rs +++ b/libs/@local/graph/api/src/rest/hashql/compile.rs @@ -1,6 +1,7 @@ +use hash_graph_authorization::policies::action::ActionName; use hashql_ast::error::AstDiagnosticCategory; use hashql_core::{ - heap::{Heap, ResetAllocator as _, Scratch}, + heap::{self, Heap, ResetAllocator as _, Scratch}, module::ModuleRegistry, span::{SpanId, SpanTable}, symbol::sym, @@ -16,7 +17,10 @@ use hashql_mir::{ body::Body, def::{DefId, DefIdVec}, error::MirDiagnosticCategory, - pass::{LowerConfig, execution::ExecutionAnalysisResidual}, + pass::{ + LowerConfig, + execution::{ExecutionAnalysisResidual, VertexType}, + }, }; use hashql_syntax_jexpr::span::Span; @@ -29,6 +33,10 @@ pub(crate) struct CodeCompilationArtifact<'heap> { pub postgres: PreparedQueries<'heap, &'heap Heap>, } +pub(crate) struct CodeExecutionPermissions<'heap> { + pub actions: heap::Vec<'heap, ActionName>, +} + pub(crate) struct Compilation<'heap> { pub heap: &'heap Heap, @@ -39,6 +47,7 @@ pub(crate) struct Compilation<'heap> { pub entrypoint: DefId, pub artifact: CodeCompilationArtifact<'heap>, + pub permissions: CodeExecutionPermissions<'heap>, } impl<'heap> Compilation<'heap> { @@ -158,6 +167,16 @@ impl<'heap> Compilation<'heap> { let queries = postgres.compile(); scratch.reset(); + let mut actions = heap::Vec::new_in(heap); + for query in queries.iter() { + let action = match query.vertex_type { + VertexType::Entity => ActionName::ViewEntity, + }; + actions.push(action); + } + + let permissions = CodeExecutionPermissions { actions }; + context .diagnostics .into_status(()) @@ -176,6 +195,7 @@ impl<'heap> Compilation<'heap> { interpreter: bodies, postgres: queries, }, + permissions, }) } diff --git a/libs/@local/graph/api/src/rest/hashql/error.rs b/libs/@local/graph/api/src/rest/hashql/error.rs index 0fb9d7ed609..3f02f198cc5 100644 --- a/libs/@local/graph/api/src/rest/hashql/error.rs +++ b/libs/@local/graph/api/src/rest/hashql/error.rs @@ -2,10 +2,12 @@ use alloc::borrow::Cow; use core::ops::Range; use axum::response::{Html, IntoResponse as _}; +use error_stack::Report; +use hash_graph_authorization::policies::store::error::{ContextCreationError, DetermineActorError}; use hashql_ast::error::AstDiagnosticCategory; use hashql_core::span::{SpanId, SpanTable}; use hashql_diagnostics::{ - DiagnosticCategory, Failure, Sources, Status, Success, + Diagnostic, DiagnosticCategory, Failure, Label, Message, Sources, Status, Success, category::{TerminalDiagnosticCategory, canonical_category_id}, diagnostic::render::{Format, RenderOptions}, severity::Critical, @@ -67,6 +69,122 @@ impl DiagnosticCategory for HashQlDiagnosticCategory { } } +pub(crate) fn store_acquire_diagnostic( + report: &Report, + root_span: SpanId, +) -> Diagnostic { + let mut diagnostic = + Diagnostic::new(HashQlDiagnosticCategory::Infrastructure, Critical::BUG).primary( + Label::new(root_span, "failed to acquire database connection"), + ); + + diagnostic.add_message(Message::note( + "the query compiled successfully but the server could not open a database connection to \ + execute it", + )); + + log_report( + &mut diagnostic, + report, + "failed to acquire database connection", + ); + + diagnostic +} + +pub(crate) fn authorization_context_diagnostic( + report: &Report, + root_span: SpanId, +) -> Diagnostic { + match report.current_context() { + ContextCreationError::ActorNotFound { actor_id } => { + actor_not_found(report, root_span, actor_id) + } + ContextCreationError::DetermineActor { actor_id } => { + // DetermineActor wraps either ActorNotFound or StoreError. + // Only report "does not exist" when the actor was actually looked up + // and not found; a store error during lookup is infrastructure. + if report + .downcast_ref::() + .is_some_and(|inner| matches!(inner, DetermineActorError::StoreError)) + { + authorization_context_failed(report, root_span) + } else { + actor_not_found(report, root_span, actor_id) + } + } + ContextCreationError::BuildPrincipalContext { .. } + | ContextCreationError::BuildEntityTypeContext { .. } + | ContextCreationError::BuildPropertyTypeContext { .. } + | ContextCreationError::BuildDataTypeContext { .. } + | ContextCreationError::BuildEntityContext { .. } + | ContextCreationError::ResolveActorPolicies { .. } + | ContextCreationError::CreatePolicySet + | ContextCreationError::CreatePolicyContext + | ContextCreationError::StoreError => authorization_context_failed(report, root_span), + } +} + +fn actor_not_found( + report: &Report, + root_span: SpanId, + actor_id: &impl core::fmt::Display, +) -> Diagnostic { + let mut diagnostic = + Diagnostic::new(HashQlDiagnosticCategory::Infrastructure, Critical::ERROR).primary( + Label::new(root_span, format!("actor `{actor_id}` does not exist")), + ); + + diagnostic.add_message(Message::note( + "every request must be authenticated with a valid actor ID; the provided ID does not \ + correspond to any known user or machine", + )); + + log_report(&mut diagnostic, report, "actor not found"); + + diagnostic +} + +fn authorization_context_failed( + report: &Report, + root_span: SpanId, +) -> Diagnostic { + let mut diagnostic = + Diagnostic::new(HashQlDiagnosticCategory::Infrastructure, Critical::BUG).primary( + Label::new(root_span, "failed to build authorization context"), + ); + + diagnostic.add_message(Message::note(format!( + "the authorization system reported: {}", + report.current_context() + ))); + + diagnostic.add_message(Message::help( + "the query compiled successfully but the server could not resolve the policies needed to \ + authorize execution", + )); + + log_report( + &mut diagnostic, + report, + "failed to build authorization context", + ); + + diagnostic +} + +fn log_report( + diagnostic: &mut Diagnostic, + report: &Report, + log_message: &str, +) { + if cfg!(debug_assertions) { + diagnostic.add_message(Message::note(format!("{report:?}"))); + } else { + tracing::error!(?report, "{log_message}"); + } +} + #[derive(Debug, serde::Serialize)] struct PointerSpan { pub range: Range, diff --git a/libs/@local/graph/api/src/rest/hashql/mod.rs b/libs/@local/graph/api/src/rest/hashql/mod.rs index 62912a8e832..fcb5cf677dc 100644 --- a/libs/@local/graph/api/src/rest/hashql/mod.rs +++ b/libs/@local/graph/api/src/rest/hashql/mod.rs @@ -14,23 +14,29 @@ use core::num::NonZero; use std::thread::available_parallelism; use axum::{Extension, Router, response::IntoResponse as _, routing::post}; -use hash_graph_postgres_store::store::PostgresStorePool; -use hash_graph_store::pool::StorePool as _; +use hash_graph_authorization::policies::{ + MergePolicies, PolicyComponents, + store::{PolicyStore, PrincipalStore}, +}; +use hash_graph_postgres_store::store::postgres::PostgresClient; +use hash_graph_store::{filter::protection::PropertyProtectionFilterConfig, pool::StorePool}; use hash_temporal_client::TemporalClient; use hashql_core::{ - heap::{HeapPool, ScratchPool}, + heap::{HeapPool, ResetAllocator as _, ScratchPool}, span::{SpanId, SpanTable}, }; -use hashql_diagnostics::{ - Diagnostic, IntoStatus as _, Label, Message, Source, Sources, Status, StatusExt as _, Success, - severity::Critical, +use hashql_diagnostics::{IntoStatus as _, Source, Sources, Status, StatusExt as _, Success}; +use hashql_eval::{ + error::EvalDiagnosticCategory, + orchestrator::Orchestrator, + postgres::{AuthorizationPatch, PreparedQueryPatch}, }; -use hashql_eval::{error::EvalDiagnosticCategory, orchestrator::Orchestrator}; use hashql_mir::interpret::Inputs; use hashql_syntax_jexpr::span::Span; use http::StatusCode; use serde_json::value::RawValue; use tokio_util::task::LocalPoolHandle; +use type_system::principal::actor::ActorEntityUuid; use utoipa::OpenApi; use self::{ @@ -38,6 +44,7 @@ use self::{ error::{HashQlDiagnosticCategory, status_to_response}, value::OwnedValue, }; +use super::AuthenticatedUserHeader; use crate::rest::{InteractiveHeader, JsonCompatHeader, json::Json, status::BoxedResponse}; /// Shared resources for HashQL query compilation and execution, created once at server startup. @@ -70,9 +77,11 @@ impl CompilerContext { } /// Per-request database context. -struct ExecutionContext { - postgres: PostgresStorePool, +struct ExecutionContext { temporal: Option>, + actor_id: ActorEntityUuid, + store: Arc, + filter_protection: Arc>, } /// Controls the response format for a HashQL query. @@ -85,12 +94,15 @@ pub(crate) struct CompilationOutputOptions { /// Compiles and executes a HashQL query, returning the result as a [`Status`]. #[expect(clippy::future_not_send)] -async fn query_local_impl( +async fn query_local_impl( ctx: Arc, - exec: ExecutionContext, + exec: ExecutionContext, spans: &mut SpanTable, query: &[u8], -) -> Status { +) -> Status +where + S: for<'pool> StorePool: AsRef + PrincipalStore + PolicyStore>, +{ // Heap and scratch must be created inside this function because `spawn_pinned` requires // `'static`. Moving them across the spawn boundary isn't possible since they borrow from // the pool guards. @@ -100,37 +112,50 @@ async fn query_local_impl( let inputs = Inputs::new(); // TODO: https://linear.app/hash/issue/BE-41/hashql-expose-input-in-graph-api let Success { - value: compilation, + value: mut compilation, advisories, } = Compilation::compile(&heap, &mut scratch, spans, query)?; - let context = compilation.context(); - let Success { - value: client, + value: store, advisories, } = exec - .postgres - .acquire(exec.temporal) + .store + .acquire(exec.temporal.clone()) .await - .map_err(|report| { - let mut diagnostic = - Diagnostic::new(HashQlDiagnosticCategory::Infrastructure, Critical::BUG).primary( - Label::new(compilation.root_span, "failed to acquire postgres client"), - ); - - if cfg!(debug_assertions) { - diagnostic.add_message(Message::note(format!("{report:?}"))); - } else { - tracing::error!(?report, "failed to acquire postgres client"); - } - - diagnostic - }) + .map_err(|report| error::store_acquire_diagnostic(&report, compilation.root_span)) + .into_status() + .with_diagnostics(advisories)?; + + let mut policy_components = PolicyComponents::builder(&store).with_actor(exec.actor_id); + policy_components.add_actions( + compilation.permissions.actions.iter().copied(), + MergePolicies::Yes, + ); + let Success { + value: policy_components, + advisories, + } = policy_components + .await + .map_err(|report| error::authorization_context_diagnostic(&report, compilation.root_span)) .into_status() .with_diagnostics(advisories)?; + let property = &*exec.filter_protection; - let orchestrator = Orchestrator::new(client, &compilation.artifact.postgres, &context); + let patch = AuthorizationPatch::new(&policy_components, property); + + // TODO: in the future when we cache queries, this will have to clone them, but because this is + // oneshot, we can just ignore that for now. + for query in compilation.artifact.postgres.iter_mut() { + PreparedQueryPatch::new() + .layer(&patch) + .apply(query, &mut *scratch); + + scratch.reset(); + } + + let context = compilation.context(); + let orchestrator = Orchestrator::new(&store, &compilation.artifact.postgres, &context); orchestrator .run(&inputs, compilation.entrypoint, []) .await @@ -143,12 +168,15 @@ async fn query_local_impl( } #[expect(clippy::future_not_send)] -async fn query_local( +async fn query_local( ctx: Arc, - exec: ExecutionContext, + exec: ExecutionContext, query: Arc, options: CompilationOutputOptions, -) -> BoxedResponse { +) -> BoxedResponse +where + S: for<'pool> StorePool: AsRef + PrincipalStore + PolicyStore>, +{ let mut sources = Sources::new(); let source_id = sources.push(Source::new(query.get())); @@ -159,12 +187,18 @@ async fn query_local( } /// Spawns a query onto the local thread pool and awaits the response. -async fn run_query( +async fn run_query( ctx: Arc, - exec: ExecutionContext, + exec: ExecutionContext, query: Arc, options: CompilationOutputOptions, -) -> BoxedResponse { +) -> BoxedResponse +where + S: for<'pool> StorePool: AsRef + PrincipalStore + PolicyStore> + + Send + + Sync + + 'static, +{ // The compiler and interpreter hold references into bump-allocated heaps, making their // futures `!Send`. `spawn_pinned` runs them on a dedicated thread; the returned handle // is `Send` so the HTTP handler can await it normally. @@ -219,6 +253,7 @@ pub(crate) struct HashQlRequest { request_body = HashQlRequest, tag = "HashQL", params( + ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), ("Interactive" = Option, Header, description = "When true, error responses are rendered as HTML instead of JSON"), ("Json-Compat" = Option, Header, description = "When true, serializes the result as plain JSON values, stripping HashQL-specific type wrappers"), ), @@ -228,17 +263,28 @@ pub(crate) struct HashQlRequest { (status = 500, description = "Internal compiler or database error"), ) )] -pub(crate) async fn query_hashql( +#[expect(clippy::too_many_arguments, reason = "axum handler extractors")] +pub(crate) async fn query_hashql( + AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, + Extension(store_pool): Extension>, Extension(compiler): Extension>, - Extension(postgres): Extension>, Extension(temporal): Extension>>, + Extension(filter_protection): Extension>>, InteractiveHeader(interactive): InteractiveHeader, JsonCompatHeader(json_compat): JsonCompatHeader, Json(request): Json, -) -> BoxedResponse { +) -> BoxedResponse +where + S: for<'pool> StorePool: AsRef + PrincipalStore + PolicyStore> + + Send + + Sync + + 'static, +{ let exec = ExecutionContext { - postgres: (*postgres).clone(), temporal, + actor_id, + store: store_pool, + filter_protection, }; let options = CompilationOutputOptions { @@ -258,7 +304,13 @@ pub(crate) async fn query_hashql( pub(crate) struct HashQlResource; impl HashQlResource { - pub(crate) fn routes() -> Router { - Router::new().route("/hashql", post(query_hashql)) + pub(crate) fn routes() -> Router + where + S: for<'pool> StorePool: AsRef + PrincipalStore + PolicyStore> + + Send + + Sync + + 'static, + { + Router::new().route("/hashql", post(query_hashql::)) } } diff --git a/libs/@local/graph/api/src/rest/mod.rs b/libs/@local/graph/api/src/rest/mod.rs index 3ecfa5d27de..dcd728bf7ae 100644 --- a/libs/@local/graph/api/src/rest/mod.rs +++ b/libs/@local/graph/api/src/rest/mod.rs @@ -38,13 +38,15 @@ use error_stack::{Report, ResultExt as _}; use futures::{SinkExt as _, channel::mpsc::Sender}; use hash_codec::numeric::Real; use hash_graph_authorization::policies::store::{PolicyStore, PrincipalStore}; -use hash_graph_postgres_store::store::{PostgresStorePool, error::VersionedUrlAlreadyExists}; +use hash_graph_postgres_store::store::{ + error::VersionedUrlAlreadyExists, postgres::PostgresClient, +}; use hash_graph_store::{ account::AccountStore, data_type::DataTypeStore, entity::{DiffEntityParams, EntityStore}, entity_type::EntityTypeStore, - filter::{ParameterConversion, Selector}, + filter::{ParameterConversion, Selector, protection::PropertyProtectionFilterConfig}, pool::StorePool, property_type::PropertyTypeStore, subgraph::{ @@ -260,7 +262,7 @@ static STATIC_SCHEMAS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/rest/json fn api_resources() -> Vec where S: StorePool + Send + Sync + 'static, - for<'pool> S::Store<'pool>: RestApiStore + PrincipalStore + PolicyStore, + for<'pool> S::Store<'pool>: RestApiStore + PrincipalStore + PolicyStore + AsRef, { vec![ data_type::DataTypeResource::routes::(), @@ -269,6 +271,7 @@ where entity::EntityResource::routes::(), permissions::PermissionResource::routes::(), principal::PrincipalResource::routes::(), + hashql::HashQlResource::routes::(), ] } @@ -426,12 +429,12 @@ where S: StorePool + Send + Sync + 'static, { pub store: Arc, - pub postgres: PostgresStorePool, pub temporal_client: Option>, pub domain_regex: DomainValidator, pub query_logger: Option, pub api_config: ApiConfig, pub compiler: Arc, + pub filter_protection: Arc>, } /// A [`Router`] that only serves the `OpenAPI` specification (JSON, and necessary subschemas) for @@ -453,13 +456,12 @@ pub fn openapi_only_router() -> Router { pub fn rest_api_router(dependencies: RestRouterDependencies) -> Router where S: StorePool + Send + Sync + 'static, - for<'p> S::Store<'p>: RestApiStore + PrincipalStore + PolicyStore, + for<'p> S::Store<'p>: RestApiStore + PrincipalStore + PolicyStore + AsRef, { // All api resources are merged together into a super-router. let merged_routes = api_resources::() .into_iter() .fold(Router::new(), Router::merge) - .merge(hashql::HashQlResource::routes()) .fallback(|| { tracing::error!("404: Not found"); async { StatusCode::NOT_FOUND } @@ -477,11 +479,11 @@ where ) .layer(http_tracing_layer::HttpTracingLayer) .layer(Extension(dependencies.store)) - .layer(Extension(Arc::new(dependencies.postgres))) .layer(Extension(dependencies.temporal_client)) .layer(Extension(dependencies.domain_regex)) .layer(Extension(dependencies.api_config)) - .layer(Extension(dependencies.compiler)); + .layer(Extension(dependencies.compiler)) + .layer(Extension(dependencies.filter_protection)); if let Some(query_logger) = dependencies.query_logger { router = router.layer(Extension(query_logger)); diff --git a/libs/@local/graph/postgres-store/src/lib.rs b/libs/@local/graph/postgres-store/src/lib.rs index 4fc05125e87..e582f1fbbe6 100644 --- a/libs/@local/graph/postgres-store/src/lib.rs +++ b/libs/@local/graph/postgres-store/src/lib.rs @@ -10,6 +10,8 @@ // Library Features extend_one, iter_intersperse, + variant_count, + maybe_uninit_array_assume_init )] #![cfg_attr(not(miri), doc(test(attr(deny(warnings, clippy::all)))))] #![expect( 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 89d6f84cc77..aacab09cfd8 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs @@ -51,6 +51,7 @@ use hash_status::StatusCode; use hash_temporal_client::TemporalClient; use postgres_types::{Json, ToSql}; use time::OffsetDateTime; +pub use tokio_postgres::Client as PostgresClient; use tokio_postgres::{Client, GenericClient as _, error::SqlState}; use tracing::Instrument as _; use type_system::{ @@ -92,7 +93,7 @@ pub struct PostgresStoreSettings { /// /// When set, filters on protected properties will automatically exclude /// specified entity types to prevent enumeration attacks. - pub filter_protection: PropertyProtectionFilterConfig<'static>, + pub filter_protection: Arc>, } impl Default for PostgresStoreSettings { @@ -100,7 +101,7 @@ impl Default for PostgresStoreSettings { Self { validate_links: true, skip_embedding_creation: false, - filter_protection: PropertyProtectionFilterConfig::hash_default(), + filter_protection: Arc::new(PropertyProtectionFilterConfig::hash_default()), } } } diff --git a/libs/@local/graph/postgres-store/src/store/postgres/query/expression/conditional.rs b/libs/@local/graph/postgres-store/src/store/postgres/query/expression/conditional.rs index 1fd54724601..82f64a23872 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/query/expression/conditional.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/query/expression/conditional.rs @@ -54,6 +54,11 @@ pub enum Function { /// Transpiles to `(extract(epoch from ) * 1000)::int8` in PostgreSQL. ExtractEpochMs(Box), Unnest(Vec), + /// Returns all subscript positions where the array element equals the given value. + /// + /// Transpiles to `array_positions(, )` in PostgreSQL. + /// Returns an integer array (e.g. `{1,3}`) or an empty array if no match. + ArrayPositions(Box, Box), Now, } @@ -196,6 +201,13 @@ impl Transpile for Function { fmt.write_char(')') } + Self::ArrayPositions(array, value) => { + fmt.write_str("array_positions(")?; + array.transpile(fmt)?; + fmt.write_str(", ")?; + value.transpile(fmt)?; + fmt.write_char(')') + } Self::JsonPathQueryFirst(target, path) => { fmt.write_str("jsonb_path_query_first(")?; target.transpile(fmt)?; @@ -271,6 +283,7 @@ pub enum PostgresType { BigInt, Boolean, TimestampTzRange, + Uuid, } impl Transpile for PostgresType { @@ -289,6 +302,7 @@ impl Transpile for PostgresType { Self::Int => fmt.write_str("int"), Self::BigInt => fmt.write_str("bigint"), Self::Boolean => fmt.write_str("boolean"), + Self::Uuid => fmt.write_str("uuid"), Self::TimestampTzRange => fmt.write_str("tstzrange"), } } diff --git a/libs/@local/graph/postgres-store/src/store/postgres/query/expression/table_reference.rs b/libs/@local/graph/postgres-store/src/store/postgres/query/expression/table_reference.rs index d657db4ce7b..8379c94f8f4 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/query/expression/table_reference.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/query/expression/table_reference.rs @@ -79,6 +79,13 @@ impl Hash for TableNameImpl<'_> { #[derive(Clone, PartialEq, Eq, Hash)] pub struct TableName<'name>(TableNameImpl<'name>); +impl TableName<'_> { + #[must_use] + pub const fn from_table(name: Table) -> Self { + Self(TableNameImpl::Static(name)) + } +} + impl fmt::Debug for TableName<'_> { fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { self.transpile(fmt) diff --git a/libs/@local/graph/postgres-store/src/store/postgres/query/table.rs b/libs/@local/graph/postgres-store/src/store/postgres/query/table.rs index 98fa7a37781..bf1936a7e2b 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/query/table.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/query/table.rs @@ -2,6 +2,7 @@ use core::{ fmt::{self, Debug, Formatter}, hash::Hash, iter::{Chain, Once, once}, + mem::MaybeUninit, }; use hash_graph_store::{ @@ -1039,6 +1040,7 @@ impl DatabaseColumn for EntityEmbeddings { } #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +#[repr(u8)] pub enum EntityEditions { EditionId, Properties, @@ -1048,6 +1050,27 @@ pub enum EntityEditions { PropertyMetadata, } +impl EntityEditions { + #[expect(unsafe_code, clippy::cast_possible_truncation)] + pub const ALL: [Self; core::mem::variant_count::()] = { + let mut output = [MaybeUninit::uninit(); core::mem::variant_count::()]; + + let mut index = 0; + while index < core::mem::variant_count::() { + // SAFETY: `Self` is `repr(u8)` with contiguous discriminants starting at 0 (no explicit + // discriminant values). `index` ranges over `0..variant_count`, so every value is a + // valid discriminant. + let variant = unsafe { core::mem::transmute::(index as u8) }; + + output[index].write(variant); + index += 1; + } + + // SAFETY: We have initialized all variants in the loop above. + unsafe { MaybeUninit::array_assume_init(output) } + }; +} + impl DatabaseColumn for EntityEditions { fn parameter_type(self) -> ParameterType { match self { diff --git a/libs/@local/graph/store/src/filter/mod.rs b/libs/@local/graph/store/src/filter/mod.rs index 48123a2bf3b..81d098b655d 100644 --- a/libs/@local/graph/store/src/filter/mod.rs +++ b/libs/@local/graph/store/src/filter/mod.rs @@ -1419,7 +1419,7 @@ impl<'p> FilterExpression<'p, Entity> { actor_id: Option, ) -> Self { match expression { - PropertyFilterExpression::Path { path } => Self::Path { path }, + PropertyFilterExpression::Path { path } => Self::Path { path: path.into() }, PropertyFilterExpression::Parameter { parameter } => Self::Parameter { parameter, convert: None, diff --git a/libs/@local/graph/store/src/filter/parameter.rs b/libs/@local/graph/store/src/filter/parameter.rs index 51b10a5c015..81af215a64a 100644 --- a/libs/@local/graph/store/src/filter/parameter.rs +++ b/libs/@local/graph/store/src/filter/parameter.rs @@ -93,10 +93,12 @@ pub enum FilterExpressionList<'p, R: QueryRecord> { ParameterList { parameters: ParameterList<'p> }, } -impl<'p> From> for FilterExpressionList<'p, Entity> { - fn from(value: PropertyFilterExpressionList<'p>) -> Self { +impl From for FilterExpressionList<'_, Entity> { + fn from(value: PropertyFilterExpressionList) -> Self { match value { - PropertyFilterExpressionList::Path { path } => FilterExpressionList::Path { path }, + PropertyFilterExpressionList::Path { path } => { + FilterExpressionList::Path { path: path.into() } + } } } } diff --git a/libs/@local/graph/store/src/filter/protection.rs b/libs/@local/graph/store/src/filter/protection.rs index 9b18758e71a..22a56f1a519 100644 --- a/libs/@local/graph/store/src/filter/protection.rs +++ b/libs/@local/graph/store/src/filter/protection.rs @@ -560,28 +560,69 @@ use crate::{ subgraph::edges::SharedEdgeKind, }; +/// Subset of [`EntityQueryPath`] that property protection filters can reference. +/// +/// Adding a new variant requires updating every consumer that lowers these +/// to SQL expressions (including the HashQL authorization graft). +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum PropertyFilterEntityQueryPath { + /// The entity's UUID (`entity_temporal_metadata.entity_uuid`). + Uuid, + /// The base URLs of the entity's type(s) (`entity_edition_cache.base_urls`). + TypeBaseUrls, +} + +impl From for EntityQueryPath<'_> { + fn from(value: PropertyFilterEntityQueryPath) -> Self { + match value { + PropertyFilterEntityQueryPath::Uuid => EntityQueryPath::Uuid, + PropertyFilterEntityQueryPath::TypeBaseUrls => EntityQueryPath::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::BaseUrl, + inheritance_depth: None, + }, + } + } +} + +/// A single operand in a [`PropertyFilter`] comparison. #[derive(Debug, Clone, PartialEq)] pub enum PropertyFilterExpression<'p> { - Path { path: EntityQueryPath<'p> }, + /// A column reference resolved from an entity query path. + Path { path: PropertyFilterEntityQueryPath }, + /// A literal value bound as a query parameter. Parameter { parameter: Parameter<'p> }, + /// The UUID of the actor executing the query. + /// + /// Resolved to the public actor UUID when no actor is present. ActorId, } +/// An array-valued operand for [`PropertyFilter::In`]. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum PropertyFilterExpressionList<'p> { - Path { path: EntityQueryPath<'p> }, +pub enum PropertyFilterExpressionList { + /// An array column reference resolved from an entity query path. + Path { path: PropertyFilterEntityQueryPath }, } +/// Condition tree that controls when a property should be masked. +/// +/// Each protected property in [`PropertyProtectionFilterConfig`] is paired +/// with a `PropertyFilter` that evaluates to true when the property should +/// be stripped from the result. For example, the default configuration +/// masks email when the entity is a User and the actor is not the owner. #[derive(Debug, Clone, PartialEq)] pub enum PropertyFilter<'p> { + /// All conditions must hold. All(Vec), + /// At least one condition must hold. Any(Vec), + /// Scalar equality. Equal(PropertyFilterExpression<'p>, PropertyFilterExpression<'p>), + /// Scalar inequality. NotEqual(PropertyFilterExpression<'p>, PropertyFilterExpression<'p>), - In( - PropertyFilterExpression<'p>, - PropertyFilterExpressionList<'p>, - ), + /// Array containment (`lhs = ANY(rhs)`). + In(PropertyFilterExpression<'p>, PropertyFilterExpressionList), } #[derive(Debug, Default)] @@ -642,16 +683,12 @@ impl<'p> PropertyProtectionFilterConfig<'p> { )), }, PropertyFilterExpressionList::Path { - path: EntityQueryPath::EntityTypeEdge { - edge_kind: SharedEdgeKind::IsOfType, - path: EntityTypeQueryPath::BaseUrl, - inheritance_depth: None, - }, + path: PropertyFilterEntityQueryPath::TypeBaseUrls, }, ), PropertyFilter::NotEqual( PropertyFilterExpression::Path { - path: EntityQueryPath::Uuid, + path: PropertyFilterEntityQueryPath::Uuid, }, PropertyFilterExpression::ActorId, ), @@ -695,6 +732,11 @@ impl<'p> PropertyProtectionFilterConfig<'p> { } } + #[must_use] + pub const fn property_filters(&self) -> &HashMap> { + &self.property_filters + } + /// Returns the embedding exclusions map: entity type → properties to exclude. /// /// Use this to pre-filter entity properties before sending to embedding generation. diff --git a/libs/@local/graph/type-fetcher/src/store.rs b/libs/@local/graph/type-fetcher/src/store.rs index 799c5a4c1c4..6737711e54a 100644 --- a/libs/@local/graph/type-fetcher/src/store.rs +++ b/libs/@local/graph/type-fetcher/src/store.rs @@ -177,6 +177,15 @@ pub struct FetchingStore { connection_info: Option>, } +impl AsRef for FetchingStore +where + S: AsRef, +{ + fn as_ref(&self) -> &T { + self.store.as_ref() + } +} + impl PrincipalStore for FetchingStore where S: PrincipalStore + Send + Sync, diff --git a/libs/@local/hashql/eval/Cargo.toml b/libs/@local/hashql/eval/Cargo.toml index 4e7354e993e..e4eae274a4f 100644 --- a/libs/@local/hashql/eval/Cargo.toml +++ b/libs/@local/hashql/eval/Cargo.toml @@ -19,34 +19,35 @@ hashql-mir = { workspace = true, public = true } hashql-core = { workspace = true } # Private third-party dependencies -bytes = { workspace = true } -futures-lite = { workspace = true } -postgres-protocol = { workspace = true } -postgres-types = { workspace = true, features = ["uuid-1"] } -serde = { workspace = true } -serde_json = { workspace = true, features = ["raw_value"] } -simple-mermaid = { workspace = true } -tokio-postgres = { workspace = true } -url = { workspace = true } -uuid = { workspace = true } - -[dev-dependencies] -error-stack = { workspace = true } +bytes = { workspace = true } +futures-lite = { workspace = true } hash-graph-authorization = { workspace = true } hash-graph-store = { workspace = true } -hash-graph-test-data = { workspace = true } -hashql-compiletest = { workspace = true } -hashql-diagnostics = { workspace = true, features = ["render"] } -insta = { workspace = true } -libtest-mimic = { workspace = true } -regex = { workspace = true } -similar-asserts = { workspace = true } -sqruff-lib = { workspace = true } -sqruff-lib-core = { workspace = true } -testcontainers = { workspace = true, features = ["reusable-containers"] } -testcontainers-modules = { workspace = true, features = ["postgres"] } -tokio = { workspace = true } +postgres-protocol = { workspace = true } +postgres-types = { workspace = true, features = ["uuid-1"] } +serde = { workspace = true } +serde_json = { workspace = true, features = ["raw_value"] } +simple-mermaid = { workspace = true } +tokio-postgres = { workspace = true } type-system = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +error-stack = { workspace = true } +hash-graph-store = { workspace = true } +hash-graph-test-data = { workspace = true } +hashql-compiletest = { workspace = true } +hashql-diagnostics = { workspace = true, features = ["render"] } +insta = { workspace = true } +libtest-mimic = { workspace = true } +regex = { workspace = true } +similar-asserts = { workspace = true } +sqruff-lib = { workspace = true } +sqruff-lib-core = { workspace = true } +testcontainers = { workspace = true, features = ["reusable-containers"] } +testcontainers-modules = { workspace = true, features = ["postgres"] } +tokio = { workspace = true } [lints] workspace = true diff --git a/libs/@local/hashql/eval/package.json b/libs/@local/hashql/eval/package.json index 76ec76b7fbe..7a077119f5a 100644 --- a/libs/@local/hashql/eval/package.json +++ b/libs/@local/hashql/eval/package.json @@ -10,17 +10,17 @@ "test:unit": "mise run test:unit @rust/hashql-eval" }, "dependencies": { + "@blockprotocol/type-system-rs": "workspace:*", + "@rust/hash-graph-authorization": "workspace:*", "@rust/hash-graph-postgres-store": "workspace:*", + "@rust/hash-graph-store": "workspace:*", "@rust/hashql-core": "workspace:*", "@rust/hashql-diagnostics": "workspace:*", "@rust/hashql-hir": "workspace:*", "@rust/hashql-mir": "workspace:*" }, "devDependencies": { - "@blockprotocol/type-system-rs": "workspace:*", "@rust/error-stack": "workspace:*", - "@rust/hash-graph-authorization": "workspace:*", - "@rust/hash-graph-store": "workspace:*", "@rust/hash-graph-test-data": "workspace:*" } } diff --git a/libs/@local/hashql/eval/src/orchestrator/request/graph_read.rs b/libs/@local/hashql/eval/src/orchestrator/request/graph_read.rs index da996456fac..fbb7b64a900 100644 --- a/libs/@local/hashql/eval/src/orchestrator/request/graph_read.rs +++ b/libs/@local/hashql/eval/src/orchestrator/request/graph_read.rs @@ -32,6 +32,7 @@ use hashql_mir::{ }; use tokio_postgres::{Client, Row}; +use super::iter::ExactSizeAdapter; use crate::{ orchestrator::{ Indexed, Orchestrator, @@ -410,7 +411,13 @@ impl<'or, 'env, 'ctx, 'heap, C: AsRef, E: EventLog, A: Allocator> .inner .client .as_ref() - .query_raw(&statement, params.iter().map(|param| &**param)) + .query_raw( + &statement, + ExactSizeAdapter::from_chain( + params.iter().map(|param| &**param), + query.auxiliary_parameters().into_iter(), + ), + ) .await .map_err(|source| BridgeError::QueryExecution { sql: statement.clone(), diff --git a/libs/@local/hashql/eval/src/orchestrator/request/iter.rs b/libs/@local/hashql/eval/src/orchestrator/request/iter.rs new file mode 100644 index 00000000000..338a70cd5f4 --- /dev/null +++ b/libs/@local/hashql/eval/src/orchestrator/request/iter.rs @@ -0,0 +1,61 @@ +use core::iter; + +/// Adapter that adds [`ExactSizeIterator`] semantics to an iterator with known length. +pub(crate) struct ExactSizeAdapter { + iter: I, + len: usize, +} + +impl ExactSizeAdapter { + pub(crate) fn from_chain(first: I, second: J) -> ExactSizeAdapter> + where + I: ExactSizeIterator, + J: ExactSizeIterator, + { + let len = first.len() + second.len(); + + ExactSizeAdapter { + iter: first.chain(second), + len, + } + } +} + +impl Iterator for ExactSizeAdapter +where + I: Iterator, +{ + type Item = I::Item; + + fn next(&mut self) -> Option { + let item = self.iter.next()?; + self.len -= 1; + + Some(item) + } + + fn size_hint(&self) -> (usize, Option) { + (self.len, Some(self.len)) + } +} + +impl DoubleEndedIterator for ExactSizeAdapter +where + I: DoubleEndedIterator, +{ + fn next_back(&mut self) -> Option { + let item = self.iter.next_back()?; + self.len -= 1; + + Some(item) + } +} + +impl ExactSizeIterator for ExactSizeAdapter +where + I: Iterator, +{ + fn len(&self) -> usize { + self.len + } +} diff --git a/libs/@local/hashql/eval/src/orchestrator/request/mod.rs b/libs/@local/hashql/eval/src/orchestrator/request/mod.rs index a274e6d7a1e..db5ff9828ce 100644 --- a/libs/@local/hashql/eval/src/orchestrator/request/mod.rs +++ b/libs/@local/hashql/eval/src/orchestrator/request/mod.rs @@ -7,4 +7,5 @@ //! [`GraphRead`]: hashql_mir::body::terminator::GraphRead mod graph_read; +mod iter; pub(crate) use self::graph_read::GraphReadOrchestrator; diff --git a/libs/@local/hashql/eval/src/postgres/authorization/mod.rs b/libs/@local/hashql/eval/src/postgres/authorization/mod.rs new file mode 100644 index 00000000000..ddb181bbdb6 --- /dev/null +++ b/libs/@local/hashql/eval/src/postgres/authorization/mod.rs @@ -0,0 +1,233 @@ +//! Grafts actor-specific authorization onto compiled queries at runtime. +//! +//! The compilation pipeline produces actor-agnostic queries. This module patches +//! them with two kinds of runtime conditions: +//! +//! - **Policy** ([`policy`]): permit/forbid admission conditions added to WHERE +//! - **Protection** ([`protection`]): property masking applied inside the `entity_editions` LATERAL +//! subquery +use core::{alloc::Allocator, mem}; + +use hash_graph_authorization::policies::PolicyComponents; +use hash_graph_postgres_store::store::postgres::query::{ + Alias, Expression, FromItem, SelectExpression, TableReference, + table::{self, DatabaseColumn as _}, +}; +use hash_graph_store::filter::protection::PropertyProtectionFilterConfig; + +use self::{ + policy::{PolicyTranslation, PolicyTranslationUnit}, + protection::ProtectionTranslationUnit, +}; +use super::{ + PatchPreparedQueryLayer, + prepared::{PatchContext, PatchPreparedQuery}, +}; + +mod policy; +mod protection; +#[cfg(test)] +mod tests; + +fn find_from_by_alias<'from, 'id>( + from: &'from mut FromItem<'id>, + needle: Alias, +) -> Option<&'from mut FromItem<'id>> { + match from { + FromItem::Table { + only: _, + table: _, + alias: Some(TableReference { + alias: Some(alias), .. + }), + column_alias: _, + tablesample: _, + } if needle == *alias => Some(from), + FromItem::Subquery { + lateral: _, + statement: _, + alias: Some(TableReference { + alias: Some(alias), .. + }), + column_alias: _, + } if needle == *alias => Some(from), + FromItem::Function { + lateral: _, + function: _, + with_ordinality: _, + alias: Some(TableReference { + alias: Some(alias), .. + }), + column_alias: _, + } if needle == *alias => Some(from), + FromItem::JoinOn { + left, + join_type: _, + right, + condition: _, + } => { + // right biased, that way we're faster in finding our goal, as our tree is left-heavy, + // with leaves being on the right side. + find_from_by_alias(right, needle).or_else(|| find_from_by_alias(left, needle)) + } + FromItem::JoinUsing { + left: _, + join_type: _, + right: _, + columns: _, + alias: Some(TableReference { + alias: Some(alias), .. + }), + } if needle == *alias => Some(from), + FromItem::JoinUsing { + left, + join_type: _, + right, + columns: _, + alias: _, + } => find_from_by_alias(left, needle).or_else(|| find_from_by_alias(right, needle)), + FromItem::CrossJoin { left, right } => { + find_from_by_alias(left, needle).or_else(|| find_from_by_alias(right, needle)) + } + FromItem::NaturalJoin { + left, + join_type: _, + right, + } => find_from_by_alias(left, needle).or_else(|| find_from_by_alias(right, needle)), + FromItem::Table { .. } | FromItem::Subquery { .. } | FromItem::Function { .. } => None, + } +} + +/// Patch layer that grafts actor-specific authorization onto a compiled query. +/// +/// Adds two kinds of runtime conditions: +/// +/// - **Policy admission**: permit/forbid conditions appended to WHERE. +/// - **Property masking**: CASE WHEN expressions grafted into the `entity_editions` LATERAL +/// subquery, stripping protected property keys from `properties` and `property_metadata`. +/// +/// This layer must be the innermost in the [`PreparedQueryPatch`] pipeline. +/// It locates the `entity_editions` LATERAL by the alias assigned during +/// compilation. If an outer layer were to introduce its own `entity_editions`, +/// the graft would patch the wrong node and masking would be bypassed. +/// +/// [`PreparedQueryPatch`]: super::PreparedQueryPatch +pub struct AuthorizationPatch<'policy, 'path> { + policy: &'policy PolicyComponents, + properties: &'policy PropertyProtectionFilterConfig<'path>, +} + +impl<'policy, 'path> AuthorizationPatch<'policy, 'path> { + #[must_use] + pub const fn new( + policy: &'policy PolicyComponents, + properties: &'policy PropertyProtectionFilterConfig<'path>, + ) -> Self { + Self { policy, properties } + } +} + +impl PatchPreparedQueryLayer + for AuthorizationPatch<'_, '_> +{ + fn patch_query( + &self, + context: &mut PatchContext<'_, A>, + query: &mut super::PreparedQuery<'_, A>, + scratch: S, + next: &N, + ) where + N: PatchPreparedQuery, + { + let mut policy = PolicyTranslationUnit { + projections: &mut context.projections, + parameters: &mut query.auxiliary_parameters, + actor_id: self.policy.actor_id(), + }; + let PolicyTranslation { condition } = + policy.transpile(query.vertex_type, self.policy, &scratch); + query.statement.where_expression.add_condition(condition); + + // Lower protection BEFORE join materialization so its join demands + // (e.g. entity_edition_cache for TypeBaseUrls) are registered. + // The resulting mask expression is grafted AFTER joins are built. + let entity_edition_alias = context.projections.entity_edition_alias(); + + let keys_to_remove = entity_edition_alias.and_then(|_| { + let mut protection = ProtectionTranslationUnit { + projections: &mut context.projections, + parameters: &mut query.auxiliary_parameters, + actor_id: self.policy.actor_id(), + }; + + protection + .transpile(self.policy, self.properties) + .keys_to_remove + }); + + next.patch_query(context, query, scratch); + + let Some((entity_edition_alias, keys_to_remove)) = + Option::zip(entity_edition_alias, keys_to_remove) + else { + return; + }; + + let from = query + .statement + .from + .as_mut() + .unwrap_or_else(|| unreachable!("prepared queries always have a from value")); + + let Some(FromItem::Subquery { + lateral: _, + statement, + alias: _, + column_alias: _, + }) = find_from_by_alias(from, entity_edition_alias) + else { + unreachable!( + "entity_edition_alias not found in from clause, even though it has been requested" + ); + }; + + #[cfg(debug_assertions)] + let mut grafted_columns = 0_u32; + + for column in &mut statement.selects { + let SelectExpression::Expression { + expression, + alias: Some(alias), + } = column + else { + unreachable!( + "selects must be expressions, see: projections::build_entity_editions" + ); + }; + + if alias.as_ref() == table::EntityEditions::Properties.as_str() + || alias.as_ref() == table::EntityEditions::PropertyMetadata.as_str() + { + let base = mem::replace(expression, Expression::Parameter(0)); + + // Group the result of the subtraction so that subsequent operators bind to the + // result, and not to one of its parts. + *expression = Expression::subtract(base, keys_to_remove.clone()).grouped(); + + #[cfg(debug_assertions)] + { + grafted_columns += 1; + } + } + } + + #[cfg(debug_assertions)] + { + debug_assert_eq!( + grafted_columns, 2, + "entity_editions LATERAL must contain both `properties` and `property_metadata` \ + projections for masking; found {grafted_columns}", + ); + } + } +} diff --git a/libs/@local/hashql/eval/src/postgres/authorization/policy/mod.rs b/libs/@local/hashql/eval/src/postgres/authorization/policy/mod.rs new file mode 100644 index 00000000000..74297bf76ee --- /dev/null +++ b/libs/@local/hashql/eval/src/postgres/authorization/policy/mod.rs @@ -0,0 +1,403 @@ +use alloc::borrow::Cow; +use core::alloc::Allocator; + +use hash_graph_authorization::policies::{ + Effect, OptimizationData, PolicyComponents, + action::ActionName, + resource::{EntityResourceConstraint, EntityResourceFilter, ResourceConstraint}, +}; +use hash_graph_postgres_store::store::postgres::query::{ + BinaryExpression, BinaryOperator, Column, ColumnReference, Constant, Expression, PostgresType, + table, +}; +use hash_graph_store::filter::PathToken; +use hashql_mir::pass::execution::VertexType; +use type_system::{ + ontology::{BaseUrl, VersionedUrl}, + principal::actor::{ActorEntityUuid, ActorId}, +}; + +use crate::postgres::{parameters::AuxiliaryParameters, projections::AuxiliaryProjections}; + +#[cfg(test)] +mod tests; + +/// Checks whether the entity has a specific `(base_url, version)` type pair. +/// +/// ```sql +/// array_positions(eit.base_urls, $base::text) +/// && array_positions(eit.versions, $version::bigint) +/// ``` +/// +/// The overlap of position sets preserves the pairing invariant: a shared +/// position means `base_urls[i] = $base AND versions[i] = $version`. +fn convert_is_of_type( + unit: &mut PolicyTranslationUnit<'_, A>, + url: VersionedUrl, +) -> Expression { + let table = unit.projections.entity_edition_cache(); + + let base_url_index = unit.parameters.push(url.base_url); + let version_index = unit.parameters.push(url.version); + + // array_positions(eit.base_urls, $base) + let base_url_positions = Expression::Function( + hash_graph_postgres_store::store::postgres::query::Function::ArrayPositions( + Box::new(Expression::ColumnReference(ColumnReference { + correlation: Some(table.clone()), + name: Column::EntityEditionCache(table::EntityEditionCache::BaseUrls).into(), + })), + Box::new(Expression::Parameter(base_url_index)), + ), + ); + + // array_positions(eit.versions, $version) + let version_positions = Expression::Function( + hash_graph_postgres_store::store::postgres::query::Function::ArrayPositions( + Box::new(Expression::ColumnReference(ColumnReference { + correlation: Some(table), + name: Column::EntityEditionCache(table::EntityEditionCache::Versions).into(), + })), + Box::new(Expression::Parameter(version_index)), + ), + ); + + // array_positions(...) && array_positions(...) + Expression::Binary(BinaryExpression { + op: BinaryOperator::Overlap, + left: Box::new(base_url_positions), + right: Box::new(version_positions), + }) +} + +/// Checks whether the entity has any type with the given base URL (any version). +/// +/// ```sql +/// $base = ANY(eit.base_urls) +/// ``` +fn convert_is_of_base_type( + unit: &mut PolicyTranslationUnit<'_, A>, + base_url: BaseUrl, +) -> Expression { + let base_url_index = unit.parameters.push(base_url); + + // $base = ANY(eit.base_urls) + Expression::Binary(BinaryExpression { + op: BinaryOperator::In, + left: Box::new(Expression::Parameter(base_url_index)), + right: Box::new(Expression::ColumnReference(ColumnReference { + correlation: Some(unit.projections.entity_edition_cache()), + name: Column::EntityEditionCache(table::EntityEditionCache::BaseUrls).into(), + })), + }) +} + +/// Checks whether the entity was created by the current actor. +/// +/// Uses entity-level provenance from `entity_ids` (the original creator). +/// Anonymous requests compare against the public actor UUID. +fn convert_created_by_principal( + unit: &mut PolicyTranslationUnit<'_, A>, +) -> Expression { + let actor_uuid = unit + .actor_id + .map_or_else(ActorEntityUuid::public_actor, ActorEntityUuid::from); + let actor_index = unit.parameters.push(actor_uuid); + + // ids.provenance->>'createdById' + let provenance = Expression::ColumnReference(ColumnReference { + correlation: Some(unit.projections.entity_ids()), + name: Column::EntityIds(table::EntityIds::Provenance).into(), + }); + + let created_by = Expression::Function( + hash_graph_postgres_store::store::postgres::query::Function::JsonExtractAsText( + Box::new(provenance), + PathToken::Field(Cow::Borrowed("createdById")), + ), + ); + + // ->> returns text, so the UUID parameter needs a text cast + Expression::equal( + created_by, + Expression::Parameter(actor_index).cast(PostgresType::Text), + ) +} + +/// Converts an [`EntityResourceFilter`] tree into a SQL [`Expression`]. +fn convert_entity_resource_filter( + unit: &mut PolicyTranslationUnit<'_, A>, + filter: &EntityResourceFilter, +) -> Expression { + match filter { + EntityResourceFilter::All { filters } => Expression::all( + filters + .iter() + .map(|filter| convert_entity_resource_filter(unit, filter)) + .collect(), + ), + EntityResourceFilter::Any { filters } => Expression::any( + filters + .iter() + .map(|filter| convert_entity_resource_filter(unit, filter)) + .collect(), + ), + EntityResourceFilter::Not { filter } => convert_entity_resource_filter(unit, filter).not(), + EntityResourceFilter::IsOfType { entity_type } => { + convert_is_of_type(unit, entity_type.clone()) + } + EntityResourceFilter::IsOfBaseType { entity_type } => { + convert_is_of_base_type(unit, entity_type.clone()) + } + EntityResourceFilter::CreatedByPrincipal => convert_created_by_principal(unit), + } +} + +/// Converts a [`ResourceConstraint`] into a SQL [`Expression`]. +/// +/// Non-entity resource types (entity types, property types, data types, meta) +/// produce `FALSE` since they cannot match entity rows. +/// +/// All referenced columns (`entity_uuid`, `web_id`, `provenance`, +/// `base_urls`) are NOT NULL in the schema, so the generated +/// predicates are two-valued. If nullable columns were ever +/// referenced, forbid negation (`NOT expr`) would need +/// `expr IS NOT TRUE` to remain fail-closed under SQL +/// three-valued logic. +fn convert_resource_constraint( + unit: &mut PolicyTranslationUnit<'_, A>, + constraint: &ResourceConstraint, +) -> Expression { + match constraint { + &ResourceConstraint::Web { web_id } => { + let index = unit.parameters.push(web_id); + + // base.web_id = $N + Expression::equal( + Expression::ColumnReference(ColumnReference { + correlation: Some(unit.projections.temporal_metadata()), + name: Column::EntityTemporalMetadata(table::EntityTemporalMetadata::WebId) + .into(), + }), + Expression::Parameter(index), + ) + } + &ResourceConstraint::Entity(EntityResourceConstraint::Exact { id }) => { + let index = unit.parameters.push(id); + + // base.entity_uuid = $N + Expression::equal( + Expression::ColumnReference(ColumnReference { + correlation: Some(unit.projections.temporal_metadata()), + name: Column::EntityTemporalMetadata(table::EntityTemporalMetadata::EntityUuid) + .into(), + }), + Expression::Parameter(index), + ) + } + ResourceConstraint::Entity(EntityResourceConstraint::Web { web_id, filter }) => { + let lhs = + convert_resource_constraint(unit, &ResourceConstraint::Web { web_id: *web_id }); + let rhs = convert_entity_resource_filter(unit, filter); + + Expression::all(vec![lhs, rhs]) + } + ResourceConstraint::Entity(EntityResourceConstraint::Any { filter }) => { + convert_entity_resource_filter(unit, filter) + } + ResourceConstraint::EntityType(_) + | ResourceConstraint::PropertyType(_) + | ResourceConstraint::DataType(_) + | ResourceConstraint::Meta(_) => Expression::Constant(Constant::Boolean(false)), + } +} + +/// Adds batched permit expressions from pre-analyzed [`OptimizationData`]. +fn optimize( + unit: &mut PolicyTranslationUnit<'_, A>, + permits: &mut Option>, + OptimizationData { + permitted_entity_uuids, + permitted_entity_type_uuids: _, + permitted_property_type_uuids: _, + permitted_data_type_uuids: _, + permitted_web_ids, + }: &OptimizationData, +) { + match &**permitted_entity_uuids { + [] => {} + &[entity_uuid] => { + let index = unit.parameters.push(entity_uuid); + + // base.entity_uuid = $N + permits.get_or_insert_default().push(Expression::equal( + Expression::ColumnReference(ColumnReference { + correlation: Some(unit.projections.temporal_metadata()), + name: Column::EntityTemporalMetadata(table::EntityTemporalMetadata::EntityUuid) + .into(), + }), + Expression::Parameter(index), + )); + } + entity_uuids => { + let index = unit.parameters.push(entity_uuids.to_vec()); + + // base.entity_uuid = ANY($N::uuid[]) + permits.get_or_insert_default().push(Expression::r#in( + Expression::ColumnReference(ColumnReference { + correlation: Some(unit.projections.temporal_metadata()), + name: Column::EntityTemporalMetadata(table::EntityTemporalMetadata::EntityUuid) + .into(), + }), + Expression::Parameter(index) + .cast(PostgresType::Array(Box::new(PostgresType::Uuid))), + )); + } + } + + match &**permitted_web_ids { + [] => {} + &[web_id] => { + let index = unit.parameters.push(web_id); + + // base.web_id = $N + permits.get_or_insert_default().push(Expression::equal( + Expression::ColumnReference(ColumnReference { + correlation: Some(unit.projections.temporal_metadata()), + name: Column::EntityTemporalMetadata(table::EntityTemporalMetadata::WebId) + .into(), + }), + Expression::Parameter(index), + )); + } + web_ids => { + let index = unit.parameters.push(web_ids.to_vec()); + + // base.web_id = ANY($N::uuid[]) + permits.get_or_insert_default().push(Expression::r#in( + Expression::ColumnReference(ColumnReference { + correlation: Some(unit.projections.temporal_metadata()), + name: Column::EntityTemporalMetadata(table::EntityTemporalMetadata::WebId) + .into(), + }), + Expression::Parameter(index) + .cast(PostgresType::Array(Box::new(PostgresType::Uuid))), + )); + } + } +} + +/// The WHERE condition produced by lowering permit/forbid policies. +pub(super) struct PolicyTranslation { + pub condition: Expression, +} + +/// Lowers [`PolicyComponents`] into a SQL condition. +/// +/// Borrows shared projections and parameters so that multiple lowering +/// passes (policy, protection) accumulate into the same patch. +pub(crate) struct PolicyTranslationUnit<'parent, A: Allocator> { + pub projections: &'parent mut AuxiliaryProjections, + pub parameters: &'parent mut AuxiliaryParameters, + pub actor_id: Option, +} + +impl PolicyTranslationUnit<'_, A> { + pub(crate) fn transpile( + &mut self, + vertex_type: VertexType, + policy: &PolicyComponents, + scratch: S, + ) -> PolicyTranslation + where + S: Allocator, + A: Clone, + { + let action = match vertex_type { + hashql_mir::pass::execution::VertexType::Entity => ActionName::ViewEntity, + }; + let projections_snapshot = self.projections.snapshot(); + + let policies = policy.extract_filter_policies(action); + let optimization_data = policy.optimization_data(action); + + let mut permit_constraints = Vec::new_in(&scratch); + let mut forbid_constraints = Vec::new_in(&scratch); + let mut blank_permit = false; + + for (effect, constraint) in policies { + match (effect, constraint) { + (Effect::Permit, _) if blank_permit => {} + (Effect::Permit, None) => { + blank_permit = true; + permit_constraints.clear(); + } + (Effect::Forbid, None) => { + // reset the projections to be from what they have been before + *self.projections = projections_snapshot; + + // Blank forbid: deny everything, no further analysis needed. + return PolicyTranslation { + condition: Expression::Constant(Constant::Boolean(false)), + }; + } + (Effect::Permit, Some(constraint)) => { + permit_constraints.push(constraint); + } + (Effect::Forbid, Some(constraint)) => { + forbid_constraints.push(constraint); + } + } + } + + // Phase 2: lower only surviving constraints to SQL + let mut permits = Some(permit_constraints) + .filter(|constraints| !constraints.is_empty()) + .map(|constraints| { + constraints + .into_iter() + .map(|constraint| convert_resource_constraint(self, constraint)) + .collect() + }); + if !blank_permit { + optimize(self, &mut permits, optimization_data); + } + let permits = permits.map(Expression::any); + + // No permits means deny-all regardless of forbids. + // Return early to avoid lowering forbid constraints (which would + // push params and register joins that nothing references). + if !blank_permit && permits.is_none() { + return PolicyTranslation { + condition: Expression::Constant(Constant::Boolean(false)), + }; + } + + let forbids = Some(forbid_constraints) + .filter(|constraints| !constraints.is_empty()) + .map(|constraints| { + constraints + .into_iter() + .map(|constraint| convert_resource_constraint(self, constraint)) + .collect() + }) + .map(Expression::any); + + let expression = match (blank_permit, permits, forbids) { + // blank permit, no forbids: allow all + (true, _, None) => Expression::Constant(Constant::Boolean(true)), + // blank permit + forbids: allow everything except forbidden + (true, _, Some(forbids)) => forbids.not(), + // constrained permits only + (false, Some(permits), None) => permits, + // constrained permits + forbids + (false, Some(permits), Some(forbids)) => Expression::all(vec![permits, forbids.not()]), + // unreachable: handled by early return above + (false, None, _) => unreachable!("no-permit case returned early"), + }; + + PolicyTranslation { + condition: expression, + } + } +} diff --git a/libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs b/libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs new file mode 100644 index 00000000000..71d52fab5d1 --- /dev/null +++ b/libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs @@ -0,0 +1,596 @@ +use alloc::alloc::Global; +use std::path::PathBuf; + +use hash_graph_authorization::policies::{ + OptimizationData, + resource::{ + EntityResourceConstraint, EntityResourceFilter, EntityTypeResourceConstraint, + EntityTypeResourceFilter, ResourceConstraint, + }, +}; +use hash_graph_postgres_store::store::postgres::query::Transpile as _; +use hashql_mir::pass::execution::VertexType; +use insta::{Settings, assert_snapshot}; +use type_system::{ + knowledge::entity::id::EntityUuid, + ontology::BaseUrl, + principal::{ + actor::{ActorId, UserId}, + actor_group::WebId, + }, +}; + +use super::{ + convert_created_by_principal, convert_entity_resource_filter, convert_is_of_base_type, + convert_is_of_type, convert_resource_constraint, optimize, +}; +use crate::postgres::{ + authorization::tests::{ + ACTOR_UUID, ENTITY_UUID_1, ENTITY_UUID_2, Fixture, WEB_UUID_1, forbid, make_url, permit, + policy_components, + }, + parameters::AuxiliaryParameters, +}; + +fn snapshot_settings() -> Settings { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let mut settings = Settings::clone_current(); + settings.set_snapshot_path(manifest_dir.join("tests/ui/postgres/authorization/policy")); + settings.set_prepend_module_to_snapshot(false); + settings +} + +fn snapshot_with_params(sql: &str, parameters: &AuxiliaryParameters) -> String { + format!("{sql}\n\nparameters: {parameters:?}") +} + +#[test] +fn is_of_type_overlap() { + let mut fixture = Fixture::new(); + let url = make_url("https://hash.ai/@h/types/entity-type/machine/", 1); + let description = format!("{url:?}"); + let expr = convert_is_of_type(&mut fixture.policy(), url); + + let mut settings = snapshot_settings(); + settings.set_description(description); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "is_of_type_overlap", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn is_of_base_type_any() { + let mut fixture = Fixture::new(); + let base = BaseUrl::new("https://hash.ai/@h/types/entity-type/machine/".to_owned()) + .expect("valid base URL"); + let description = format!("{base:?}"); + let expr = convert_is_of_base_type(&mut fixture.policy(), base); + + let mut settings = snapshot_settings(); + settings.set_description(description); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "is_of_base_type_any", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn created_by_principal_with_actor() { + let mut fixture = Fixture::new(); + let expr = convert_created_by_principal(&mut fixture.policy()); + + let mut settings = snapshot_settings(); + settings.set_description(format!( + "CreatedByPrincipal, actor = {:?}", + fixture.policy().actor_id + )); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "created_by_principal_with_actor", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn created_by_principal_anonymous() { + let mut fixture = Fixture::new(); + let expr = convert_created_by_principal(&mut fixture.policy_anon()); + + let mut settings = snapshot_settings(); + settings.set_description(format!( + "CreatedByPrincipal, actor = {:?}", + fixture.policy_anon().actor_id + )); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "created_by_principal_anonymous", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn constraint_exact_entity() { + let mut fixture = Fixture::new(); + let constraint = ResourceConstraint::Entity(EntityResourceConstraint::Exact { + id: EntityUuid::new(ENTITY_UUID_1), + }); + let expr = convert_resource_constraint(&mut fixture.policy(), &constraint); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{constraint:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "constraint_exact_entity", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn constraint_web() { + let mut fixture = Fixture::new(); + let constraint = ResourceConstraint::Web { + web_id: WebId::new(WEB_UUID_1), + }; + let expr = convert_resource_constraint(&mut fixture.policy(), &constraint); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{constraint:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "constraint_web", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn constraint_any_with_type_filter() { + let mut fixture = Fixture::new(); + let constraint = ResourceConstraint::Entity(EntityResourceConstraint::Any { + filter: EntityResourceFilter::IsOfType { + entity_type: make_url("https://hash.ai/@h/types/entity-type/user/", 1), + }, + }); + let expr = convert_resource_constraint(&mut fixture.policy(), &constraint); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{constraint:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "constraint_any_with_type_filter", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn constraint_web_with_created_by() { + let mut fixture = Fixture::new(); + let constraint = ResourceConstraint::Entity(EntityResourceConstraint::Web { + web_id: WebId::new(WEB_UUID_1), + filter: EntityResourceFilter::CreatedByPrincipal, + }); + let expr = convert_resource_constraint(&mut fixture.policy(), &constraint); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{constraint:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "constraint_web_with_created_by", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn constraint_non_entity_is_false() { + let mut fixture = Fixture::new(); + let constraint = ResourceConstraint::EntityType(EntityTypeResourceConstraint::Any { + filter: EntityTypeResourceFilter::All { filters: vec![] }, + }); + let expr = convert_resource_constraint(&mut fixture.policy(), &constraint); + assert_eq!(expr.transpile_to_string(), "FALSE"); +} + +#[test] +fn filter_all_conjunction() { + let mut fixture = Fixture::new(); + let filter = EntityResourceFilter::All { + filters: vec![ + EntityResourceFilter::CreatedByPrincipal, + EntityResourceFilter::IsOfBaseType { + entity_type: BaseUrl::new("https://hash.ai/@h/types/entity-type/user/".to_owned()) + .expect("valid base URL"), + }, + ], + }; + let expr = convert_entity_resource_filter(&mut fixture.policy(), &filter); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{filter:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "filter_all_conjunction", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn filter_any_disjunction() { + let mut fixture = Fixture::new(); + let filter = EntityResourceFilter::Any { + filters: vec![ + EntityResourceFilter::IsOfType { + entity_type: make_url("https://hash.ai/@h/types/entity-type/user/", 1), + }, + EntityResourceFilter::IsOfType { + entity_type: make_url("https://hash.ai/@h/types/entity-type/machine/", 2), + }, + ], + }; + let expr = convert_entity_resource_filter(&mut fixture.policy(), &filter); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{filter:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "filter_any_disjunction", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn filter_not_negation() { + let mut fixture = Fixture::new(); + let filter = EntityResourceFilter::Not { + filter: Box::new(EntityResourceFilter::CreatedByPrincipal), + }; + let expr = convert_entity_resource_filter(&mut fixture.policy(), &filter); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{filter:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "filter_not_negation", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn optimize_single_entity_uuid() { + let mut fixture = Fixture::new(); + let mut permits = None; + let data = OptimizationData { + permitted_entity_uuids: vec![EntityUuid::new(ENTITY_UUID_1)], + permitted_entity_type_uuids: vec![], + permitted_property_type_uuids: vec![], + permitted_data_type_uuids: vec![], + permitted_web_ids: vec![], + }; + optimize(&mut fixture.policy(), &mut permits, &data); + let expr = permits.expect("should have a permit expression"); + assert_eq!(expr.len(), 1); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{data:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "optimize_single_entity", + snapshot_with_params(&expr[0].transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn optimize_multiple_entity_uuids() { + let mut fixture = Fixture::new(); + let mut permits = None; + let data = OptimizationData { + permitted_entity_uuids: vec![ + EntityUuid::new(ENTITY_UUID_1), + EntityUuid::new(ENTITY_UUID_2), + ], + permitted_entity_type_uuids: vec![], + permitted_property_type_uuids: vec![], + permitted_data_type_uuids: vec![], + permitted_web_ids: vec![], + }; + optimize(&mut fixture.policy(), &mut permits, &data); + let expr = permits.expect("should have a permit expression"); + assert_eq!(expr.len(), 1); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{data:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "optimize_multiple_entities", + snapshot_with_params(&expr[0].transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn optimize_single_web_id() { + let mut fixture = Fixture::new(); + let mut permits = None; + let data = OptimizationData { + permitted_entity_uuids: vec![], + permitted_entity_type_uuids: vec![], + permitted_property_type_uuids: vec![], + permitted_data_type_uuids: vec![], + permitted_web_ids: vec![WebId::new(WEB_UUID_1)], + }; + optimize(&mut fixture.policy(), &mut permits, &data); + let expr = permits.expect("should have a permit expression"); + assert_eq!(expr.len(), 1); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{data:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "optimize_single_web", + snapshot_with_params(&expr[0].transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn optimize_multiple_web_ids() { + let mut fixture = Fixture::new(); + let mut permits = None; + let data = OptimizationData { + permitted_entity_uuids: vec![], + permitted_entity_type_uuids: vec![], + permitted_property_type_uuids: vec![], + permitted_data_type_uuids: vec![], + permitted_web_ids: vec![WebId::new(WEB_UUID_1), WebId::new(ENTITY_UUID_2)], + }; + optimize(&mut fixture.policy(), &mut permits, &data); + let expr = permits.expect("should have a permit expression"); + assert_eq!(expr.len(), 1); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{data:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "optimize_multiple_webs", + snapshot_with_params(&expr[0].transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn optimize_empty_is_noop() { + let mut fixture = Fixture::new(); + let mut unit = fixture.policy(); + let mut permits = None; + let data = OptimizationData::default(); + optimize(&mut unit, &mut permits, &data); + assert!( + permits.is_none(), + "empty optimization should not add permits" + ); +} + +#[test] +fn filter_empty_all_is_true() { + let mut fixture = Fixture::new(); + let filter = EntityResourceFilter::All { filters: vec![] }; + let expr = convert_entity_resource_filter(&mut fixture.policy(), &filter); + assert_eq!(expr.transpile_to_string(), "TRUE"); +} + +#[test] +fn filter_empty_any_is_false() { + let mut fixture = Fixture::new(); + let filter = EntityResourceFilter::Any { filters: vec![] }; + let expr = convert_entity_resource_filter(&mut fixture.policy(), &filter); + assert_eq!(expr.transpile_to_string(), "FALSE"); +} + +#[test] +fn algebra_blank_forbid_denies_all() { + let mut fixture = Fixture::new(); + let actor = Some(ActorId::User(UserId::new(ACTOR_UUID))); + let policy = policy_components(actor, vec![forbid(|| None)]); + let result = fixture + .policy() + .transpile(VertexType::Entity, &policy, Global); + assert_eq!(result.condition.transpile_to_string(), "FALSE"); + assert_eq!( + fixture.parameters.len(), + 0, + "blank forbid should not allocate parameters", + ); + assert!( + fixture.projections.entity_ids.is_none(), + "blank forbid should not register entity_ids", + ); + assert!( + fixture.projections.entity_edition_cache.is_none(), + "blank forbid should not register entity_edition_cache", + ); +} + +#[test] +fn algebra_blank_permit_allows_all() { + let mut fixture = Fixture::new(); + let actor = Some(ActorId::User(UserId::new(ACTOR_UUID))); + let policy = policy_components(actor, vec![permit(|| None)]); + let result = fixture + .policy() + .transpile(VertexType::Entity, &policy, Global); + assert_eq!(result.condition.transpile_to_string(), "TRUE"); +} + +#[test] +fn algebra_blank_permit_with_forbids() { + let mut fixture = Fixture::new(); + let actor = Some(ActorId::User(UserId::new(ACTOR_UUID))); + let policies = vec![ + permit(|| None), + forbid(|| { + Some(ResourceConstraint::Web { + web_id: WebId::new(WEB_UUID_1), + }) + }), + ]; + let description = format!( + "{:?}", + policies.iter().map(|policy| policy()).collect::>() + ); + let policy = policy_components(actor, policies); + let result = fixture + .policy() + .transpile(VertexType::Entity, &policy, Global); + + let mut settings = snapshot_settings(); + settings.set_description(description); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "algebra_blank_permit_with_forbids", + snapshot_with_params(&result.condition.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn algebra_no_permits_denies_all() { + let mut fixture = Fixture::new(); + let actor = Some(ActorId::User(UserId::new(ACTOR_UUID))); + let policy = policy_components( + actor, + vec![forbid(|| { + Some(ResourceConstraint::Web { + web_id: WebId::new(WEB_UUID_1), + }) + })], + ); + let result = fixture + .policy() + .transpile(VertexType::Entity, &policy, Global); + assert_eq!( + result.condition.transpile_to_string(), + "FALSE", + "forbids without permits should deny all", + ); + assert_eq!( + fixture.parameters.len(), + 0, + "no-permit early return should not allocate forbid parameters", + ); + assert!( + fixture.projections.entity_ids.is_none(), + "no-permit early return should not register joins", + ); +} + +#[test] +fn algebra_constrained_permits_only() { + let mut fixture = Fixture::new(); + let actor = Some(ActorId::User(UserId::new(ACTOR_UUID))); + let policies = vec![ + permit(|| { + Some(ResourceConstraint::Entity( + EntityResourceConstraint::Exact { + id: EntityUuid::new(ENTITY_UUID_1), + }, + )) + }), + permit(|| { + Some(ResourceConstraint::Web { + web_id: WebId::new(WEB_UUID_1), + }) + }), + ]; + let description = format!( + "{:?}", + policies.iter().map(|policy| policy()).collect::>() + ); + let policy = policy_components(actor, policies); + let result = fixture + .policy() + .transpile(VertexType::Entity, &policy, Global); + + let mut settings = snapshot_settings(); + settings.set_description(description); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "algebra_constrained_permits_only", + snapshot_with_params(&result.condition.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn algebra_permits_and_forbids() { + let mut fixture = Fixture::new(); + let actor = Some(ActorId::User(UserId::new(ACTOR_UUID))); + let policies = vec![ + permit(|| { + Some(ResourceConstraint::Entity( + EntityResourceConstraint::Exact { + id: EntityUuid::new(ENTITY_UUID_1), + }, + )) + }), + forbid(|| { + Some(ResourceConstraint::Entity(EntityResourceConstraint::Any { + filter: EntityResourceFilter::IsOfType { + entity_type: make_url("https://hash.ai/@h/types/entity-type/user/", 1), + }, + })) + }), + ]; + let description = format!( + "{:?}", + policies.iter().map(|policy| policy()).collect::>() + ); + let policy = policy_components(actor, policies); + let result = fixture + .policy() + .transpile(VertexType::Entity, &policy, Global); + + let mut settings = snapshot_settings(); + settings.set_description(description); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "algebra_permits_and_forbids", + snapshot_with_params(&result.condition.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn algebra_blank_forbid_preserves_prior_projections() { + let mut fixture = Fixture::new(); + let actor = Some(ActorId::User(UserId::new(ACTOR_UUID))); + + // First transpile registers entity_ids via CreatedByPrincipal. + let first_policy = policy_components( + actor, + vec![permit(|| { + Some(ResourceConstraint::Entity(EntityResourceConstraint::Any { + filter: EntityResourceFilter::CreatedByPrincipal, + })) + })], + ); + let first_result = fixture + .policy() + .transpile(VertexType::Entity, &first_policy, Global); + assert_ne!( + first_result.condition.transpile_to_string(), + "FALSE", + "first transpile should produce a real condition", + ); + assert!( + fixture.projections.entity_ids.is_some(), + "CreatedByPrincipal should register entity_ids", + ); + let entity_ids_alias = fixture.projections.entity_ids; + + // Second transpile with blank forbid should deny all but preserve + // the entity_ids projection registered by the first call. + let second_policy = policy_components(actor, vec![forbid(|| None)]); + let second_result = fixture + .policy() + .transpile(VertexType::Entity, &second_policy, Global); + assert_eq!(second_result.condition.transpile_to_string(), "FALSE"); + assert_eq!( + fixture.projections.entity_ids, entity_ids_alias, + "blank forbid should preserve pre-existing projections", + ); +} diff --git a/libs/@local/hashql/eval/src/postgres/authorization/protection/mod.rs b/libs/@local/hashql/eval/src/postgres/authorization/protection/mod.rs new file mode 100644 index 00000000000..f93cf368f37 --- /dev/null +++ b/libs/@local/hashql/eval/src/postgres/authorization/protection/mod.rs @@ -0,0 +1,200 @@ +use core::alloc::Allocator; + +use hash_graph_authorization::policies::PolicyComponents; +use hash_graph_postgres_store::store::postgres::query::{ + Column, ColumnReference, Expression, Function, PostgresType, table, +}; +use hash_graph_store::filter::{ + Parameter, + protection::{ + PropertyFilter, PropertyFilterEntityQueryPath, PropertyFilterExpression, + PropertyFilterExpressionList, PropertyProtectionFilterConfig, + }, +}; +use type_system::{ + ontology::BaseUrl, + principal::actor::{ActorEntityUuid, ActorId}, +}; + +use crate::postgres::{parameters::AuxiliaryParameters, projections::AuxiliaryProjections}; + +#[cfg(test)] +mod tests; + +/// Resolves a query path to its backing column. +fn resolve_path( + unit: &mut ProtectionTranslationUnit<'_, A>, + path: PropertyFilterEntityQueryPath, +) -> Expression { + match path { + // base.entity_uuid + PropertyFilterEntityQueryPath::Uuid => Expression::ColumnReference(ColumnReference { + correlation: Some(unit.projections.temporal_metadata()), + name: Column::EntityTemporalMetadata(table::EntityTemporalMetadata::EntityUuid).into(), + }), + // eec.base_urls + PropertyFilterEntityQueryPath::TypeBaseUrls => { + Expression::ColumnReference(ColumnReference { + correlation: Some(unit.projections.entity_edition_cache()), + name: Column::EntityEditionCache(table::EntityEditionCache::BaseUrls).into(), + }) + } + } +} + +/// Resolves a filter operand to a SQL expression. +fn resolve_expression( + unit: &mut ProtectionTranslationUnit<'_, A>, + expression: &PropertyFilterExpression<'_>, +) -> Expression { + match expression { + &PropertyFilterExpression::Path { path } => resolve_path(unit, path), + PropertyFilterExpression::Parameter { parameter } => { + let index = match parameter { + &Parameter::Boolean(bool) => unit.parameters.push(bool), + Parameter::Decimal(decimal) => unit.parameters.push(decimal.clone()), + Parameter::Text(text) => unit.parameters.push(text.clone().into_owned()), + Parameter::Vector(embedding) => unit.parameters.push(embedding.to_owned()), + Parameter::Any(value) => unit.parameters.push(value.clone()), + &Parameter::Uuid(uuid) => unit.parameters.push(uuid), + Parameter::OntologyTypeVersion(version) => { + unit.parameters.push(version.clone().into_owned()) + } + &Parameter::Timestamp(timestamp) => unit.parameters.push(timestamp), + }; + + Expression::Parameter(index) + } + PropertyFilterExpression::ActorId => { + let actor_uuid = unit + .actor_id + .map_or_else(ActorEntityUuid::public_actor, ActorEntityUuid::from); + let index = unit.parameters.push(actor_uuid); + + Expression::Parameter(index) + } + } +} + +/// Lowers a [`PropertyFilter`] condition tree into a SQL boolean expression. +fn lower_filter( + unit: &mut ProtectionTranslationUnit<'_, A>, + filter: &PropertyFilter<'_>, +) -> Expression { + match filter { + PropertyFilter::All(filters) => Expression::all( + filters + .iter() + .map(|filter| lower_filter(unit, filter)) + .collect(), + ), + PropertyFilter::Any(filters) => Expression::any( + filters + .iter() + .map(|filter| lower_filter(unit, filter)) + .collect(), + ), + PropertyFilter::Equal(lhs, rhs) => { + let lhs = resolve_expression(unit, lhs); + let rhs = resolve_expression(unit, rhs); + + Expression::equal(lhs, rhs) + } + PropertyFilter::NotEqual(lhs, rhs) => { + let lhs = resolve_expression(unit, lhs); + let rhs = resolve_expression(unit, rhs); + + Expression::not_equal(lhs, rhs) + } + // $lhs = ANY($rhs) + PropertyFilter::In(lhs, rhs) => { + let lhs = resolve_expression(unit, lhs); + let rhs = match rhs { + &PropertyFilterExpressionList::Path { path } => resolve_path(unit, path), + }; + + Expression::r#in(lhs, rhs) + } + } +} + +/// Builds the mask expression for a single protected property. +/// +/// ```sql +/// CASE WHEN +/// THEN ARRAY[$url]::text[] +/// ELSE '{}'::text[] +/// END +/// ``` +/// +/// Evaluates to a single-element array containing the property's base URL +/// when the condition holds (property should be masked), or an empty array +/// otherwise. +fn lower_property_filter( + unit: &mut ProtectionTranslationUnit<'_, A>, + property_url: BaseUrl, + filter: &PropertyFilter<'_>, +) -> Expression { + let condition = lower_filter(unit, filter); + let url_index = unit.parameters.push(property_url); + + // CASE WHEN THEN ARRAY[$url]::text[] ELSE '{}'::text[] END + Expression::CaseWhen { + conditions: vec![( + condition, + Expression::Function(Function::ArrayLiteral { + elements: vec![Expression::Parameter(url_index)], + element_type: PostgresType::Text, + }), + )], + else_result: Some(Box::new(Expression::Function(Function::ArrayLiteral { + elements: vec![], + element_type: PostgresType::Text, + }))), + } +} + +/// The mask expression produced by lowering property protection rules. +/// +/// When present, this expression evaluates to a `text[]` of property base URLs +/// that should be stripped. It is subtracted from the `properties` and +/// `property_metadata` columns inside the `entity_editions` LATERAL subquery. +pub(super) struct ProtectionTranslation { + /// `NULL` when no properties are protected (no masking needed). + pub keys_to_remove: Option, +} + +/// Lowers [`PropertyProtectionFilterConfig`] into a mask expression. +/// +/// Borrows shared projections and parameters so that multiple lowering +/// passes (policy, protection) accumulate into the same patch. +pub(crate) struct ProtectionTranslationUnit<'parent, A: Allocator> { + pub projections: &'parent mut AuxiliaryProjections, + pub parameters: &'parent mut AuxiliaryParameters, + pub actor_id: Option, +} + +impl ProtectionTranslationUnit<'_, A> { + pub(crate) fn transpile( + &mut self, + policy: &PolicyComponents, + config: &PropertyProtectionFilterConfig<'_>, + ) -> ProtectionTranslation { + if config.is_empty() || policy.is_instance_admin() { + return ProtectionTranslation { + keys_to_remove: None, + }; + } + + let cases: Vec<_> = config + .property_filters() + .iter() + .map(|(property_url, filter)| lower_property_filter(self, property_url.clone(), filter)) + .collect(); + + // array_cat(CASE ..., CASE ..., ...) + ProtectionTranslation { + keys_to_remove: Some(Expression::concatenate(cases)), + } + } +} diff --git a/libs/@local/hashql/eval/src/postgres/authorization/protection/tests.rs b/libs/@local/hashql/eval/src/postgres/authorization/protection/tests.rs new file mode 100644 index 00000000000..04da91e4dee --- /dev/null +++ b/libs/@local/hashql/eval/src/postgres/authorization/protection/tests.rs @@ -0,0 +1,395 @@ +use alloc::{alloc::Global, borrow::Cow}; +use std::path::PathBuf; + +use hash_graph_postgres_store::store::postgres::query::Transpile as _; +use hash_graph_store::filter::{ + Parameter, + protection::{ + PropertyFilter, PropertyFilterEntityQueryPath, PropertyFilterExpression, + PropertyFilterExpressionList, PropertyProtectionFilterConfig, + }, +}; +use insta::{Settings, assert_snapshot}; +use type_system::ontology::BaseUrl; + +use super::{lower_filter, lower_property_filter, resolve_expression, resolve_path}; +use crate::postgres::{ + authorization::tests::{ACTOR_UUID, Fixture, policy_components, policy_components_admin}, + parameters::AuxiliaryParameters, +}; + +fn snapshot_with_params(sql: &str, parameters: &AuxiliaryParameters) -> String { + format!("{sql}\n\nparameters: {parameters:?}") +} + +fn base_url(url: &str) -> BaseUrl { + BaseUrl::new(url.to_owned()).expect("valid base URL") +} + +fn snapshot_settings() -> Settings { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let mut settings = Settings::clone_current(); + settings.set_snapshot_path(manifest_dir.join("tests/ui/postgres/authorization/protection")); + settings.set_prepend_module_to_snapshot(false); + settings +} + +#[test] +fn resolve_path_uuid() { + let mut fixture = Fixture::new(); + let expr = resolve_path( + &mut fixture.protection(), + PropertyFilterEntityQueryPath::Uuid, + ); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{:?}", PropertyFilterEntityQueryPath::Uuid)); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "resolve_path_uuid", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn resolve_path_type_base_urls() { + let mut fixture = Fixture::new(); + let expr = resolve_path( + &mut fixture.protection(), + PropertyFilterEntityQueryPath::TypeBaseUrls, + ); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{:?}", PropertyFilterEntityQueryPath::TypeBaseUrls)); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "resolve_path_type_base_urls", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn resolve_expression_text_parameter() { + let mut fixture = Fixture::new(); + let param = PropertyFilterExpression::Parameter { + parameter: Parameter::Text(Cow::Borrowed("hello")), + }; + let expr = resolve_expression(&mut fixture.protection(), ¶m); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{param:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "resolve_expression_text", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn resolve_expression_actor_id() { + let mut fixture = Fixture::new(); + let expr = resolve_expression( + &mut fixture.protection(), + &PropertyFilterExpression::ActorId, + ); + + let mut settings = snapshot_settings(); + settings.set_description(format!( + "ActorId, actor = {:?}", + fixture.protection().actor_id + )); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "resolve_expression_actor_id", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn lower_filter_equal() { + let mut fixture = Fixture::new(); + let filter = PropertyFilter::Equal( + PropertyFilterExpression::Path { + path: PropertyFilterEntityQueryPath::Uuid, + }, + PropertyFilterExpression::ActorId, + ); + let expr = lower_filter(&mut fixture.protection(), &filter); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{filter:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "lower_filter_equal", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn lower_filter_not_equal() { + let mut fixture = Fixture::new(); + let filter = PropertyFilter::NotEqual( + PropertyFilterExpression::Path { + path: PropertyFilterEntityQueryPath::Uuid, + }, + PropertyFilterExpression::ActorId, + ); + let expr = lower_filter(&mut fixture.protection(), &filter); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{filter:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "lower_filter_not_equal", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn lower_filter_in() { + let mut fixture = Fixture::new(); + let filter = PropertyFilter::In( + PropertyFilterExpression::Parameter { + parameter: Parameter::Text(Cow::Borrowed("https://hash.ai/@h/types/entity-type/user/")), + }, + PropertyFilterExpressionList::Path { + path: PropertyFilterEntityQueryPath::TypeBaseUrls, + }, + ); + let expr = lower_filter(&mut fixture.protection(), &filter); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{filter:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "lower_filter_in", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn lower_filter_nested_all() { + let mut fixture = Fixture::new(); + let filter = PropertyFilter::All(vec![ + PropertyFilter::In( + PropertyFilterExpression::Parameter { + parameter: Parameter::Text(Cow::Borrowed( + "https://hash.ai/@h/types/entity-type/user/", + )), + }, + PropertyFilterExpressionList::Path { + path: PropertyFilterEntityQueryPath::TypeBaseUrls, + }, + ), + PropertyFilter::NotEqual( + PropertyFilterExpression::Path { + path: PropertyFilterEntityQueryPath::Uuid, + }, + PropertyFilterExpression::ActorId, + ), + ]); + let expr = lower_filter(&mut fixture.protection(), &filter); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{filter:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "lower_filter_nested_all", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn lower_property_filter_case_when() { + let mut fixture = Fixture::new(); + let filter = PropertyFilter::Equal( + PropertyFilterExpression::Path { + path: PropertyFilterEntityQueryPath::Uuid, + }, + PropertyFilterExpression::ActorId, + ); + let url = base_url("https://hash.ai/@h/types/property-type/email/"); + let expr = lower_property_filter(&mut fixture.protection(), url, &filter); + + let mut settings = snapshot_settings(); + settings.set_description(format!("property: email/, filter: {filter:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "lower_property_filter_case_when", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn transpile_empty_config_returns_none() { + let mut fixture = Fixture::new(); + let policy = policy_components(None, vec![]); + let config = PropertyProtectionFilterConfig::new(); + let result = fixture.protection().transpile(&policy, &config); + assert!(result.keys_to_remove.is_none()); + assert_eq!( + fixture.parameters.len(), + 0, + "empty config should not allocate parameters", + ); +} + +#[test] +fn transpile_instance_admin_returns_none() { + let mut fixture = Fixture::new(); + let actor = Some(type_system::principal::actor::ActorId::User( + type_system::principal::actor::UserId::new(ACTOR_UUID), + )); + let admin_policy = policy_components_admin(actor, vec![]); + let normal_policy = policy_components(actor, vec![]); + + assert!( + admin_policy.is_instance_admin(), + "admin policy should be instance admin", + ); + assert!( + !normal_policy.is_instance_admin(), + "normal policy should not be instance admin", + ); + + let mut config = PropertyProtectionFilterConfig::new(); + config.protect_property( + base_url("https://hash.ai/@h/types/property-type/email/"), + PropertyFilter::Equal( + PropertyFilterExpression::Path { + path: PropertyFilterEntityQueryPath::Uuid, + }, + PropertyFilterExpression::ActorId, + ), + ); + + let admin_result = fixture.protection().transpile(&admin_policy, &config); + assert!( + admin_result.keys_to_remove.is_none(), + "instance admin should bypass protection", + ); + assert_eq!( + fixture.parameters.len(), + 0, + "instance admin bypass should not allocate parameters", + ); + + let normal_result = fixture.protection().transpile(&normal_policy, &config); + assert!( + normal_result.keys_to_remove.is_some(), + "non-admin should produce a mask", + ); +} + +#[test] +fn transpile_hash_default_config() { + let mut fixture = Fixture::new(); + let actor = Some(type_system::principal::actor::ActorId::User( + type_system::principal::actor::UserId::new(ACTOR_UUID), + )); + let policy = policy_components(actor, vec![]); + let config = PropertyProtectionFilterConfig::hash_default(); + let result = fixture.protection().transpile(&policy, &config); + let expr = result.keys_to_remove.expect("should produce a mask"); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{config:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "transpile_hash_default", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn lower_filter_any_disjunction() { + let mut fixture = Fixture::new(); + let filter = PropertyFilter::Any(vec![ + PropertyFilter::Equal( + PropertyFilterExpression::Path { + path: PropertyFilterEntityQueryPath::Uuid, + }, + PropertyFilterExpression::ActorId, + ), + PropertyFilter::In( + PropertyFilterExpression::Parameter { + parameter: Parameter::Text(Cow::Borrowed( + "https://hash.ai/@h/types/entity-type/user/", + )), + }, + PropertyFilterExpressionList::Path { + path: PropertyFilterEntityQueryPath::TypeBaseUrls, + }, + ), + ]); + let expr = lower_filter(&mut fixture.protection(), &filter); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{filter:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "lower_filter_any_disjunction", + snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters), + ); +} + +#[test] +fn transpile_multiple_protected_properties() { + let mut fixture = Fixture::new(); + let actor = Some(type_system::principal::actor::ActorId::User( + type_system::principal::actor::UserId::new(ACTOR_UUID), + )); + let policy = policy_components(actor, vec![]); + let mut config = PropertyProtectionFilterConfig::new(); + config.protect_property( + base_url("https://hash.ai/@h/types/property-type/email/"), + PropertyFilter::Equal( + PropertyFilterExpression::Path { + path: PropertyFilterEntityQueryPath::Uuid, + }, + PropertyFilterExpression::ActorId, + ), + ); + config.protect_property( + base_url("https://hash.ai/@h/types/property-type/phone/"), + PropertyFilter::NotEqual( + PropertyFilterExpression::Path { + path: PropertyFilterEntityQueryPath::Uuid, + }, + PropertyFilterExpression::ActorId, + ), + ); + let result = fixture.protection().transpile(&policy, &config); + let expr = result.keys_to_remove.expect("should produce a mask"); + + let sql = expr.transpile_to_string(); + let params = format!("{:?}", fixture.parameters); + + // Two properties produce concatenated CASE expressions. + assert!( + sql.contains("||"), + "multiple properties should be concatenated with ||: {sql}", + ); + // Each property contributes one actor UUID param and one property URL param. + assert_eq!( + fixture.parameters.len(), + 4, + "expected 4 params (2 per property): {params}", + ); + + // Both property URLs must appear in the params (order-independent). + assert!( + params.contains("email"), + "params should contain email property URL: {params}", + ); + assert!( + params.contains("phone"), + "params should contain phone property URL: {params}", + ); + + // Both operator shapes must appear (= for email, != for phone). + assert!( + sql.contains("= $") && sql.contains("!= $"), + "should contain both = and != operators: {sql}", + ); +} diff --git a/libs/@local/hashql/eval/src/postgres/authorization/tests.rs b/libs/@local/hashql/eval/src/postgres/authorization/tests.rs new file mode 100644 index 00000000000..74eabb5e110 --- /dev/null +++ b/libs/@local/hashql/eval/src/postgres/authorization/tests.rs @@ -0,0 +1,713 @@ +//! Mock store for authorization unit tests. +//! +//! Returns pre-configured policies without requiring a database connection. + +use alloc::alloc::Global; +use core::{fmt::Write as _, future}; +use std::{collections::HashSet, path::PathBuf}; + +use error_stack::Report; +use hash_graph_authorization::policies::{ + ContextBuilder, Effect, MergePolicies, Policy, PolicyComponents, PolicyId, ResolvedPolicy, + action::ActionName, + principal::actor::AuthenticatedActor, + resource::{ + DataTypeResource, EntityResource, EntityTypeResource, PropertyTypeResource, + ResourceConstraint, + }, + store::{ + CreateWebParameter, CreateWebResponse, PolicyCreationParams, PolicyFilter, PolicyStore, + PolicyUpdateOperation, PrincipalStore, ResolvePoliciesParams, RoleAssignmentStatus, + RoleUnassignmentStatus, + error::{ + BuildDataTypeContextError, BuildEntityContextError, BuildEntityTypeContextError, + BuildPrincipalContextError, BuildPropertyTypeContextError, CreatePolicyError, + DetermineActorError, EnsureSystemPoliciesError, GetPoliciesError, + GetSystemAccountError, RemovePolicyError, RoleAssignmentError, TeamRoleError, + UpdatePolicyError, WebCreationError, WebRoleError, + }, + }, +}; +use hash_graph_store::filter::{ + Parameter, + protection::{ + PropertyFilter, PropertyFilterEntityQueryPath, PropertyFilterExpression, + PropertyFilterExpressionList, PropertyProtectionFilterConfig, + }, +}; +use hashql_core::{ + heap::Heap, module::std_lib::graph::types::knowledge::entity as entity_types, symbol::sym, + r#type::environment::Environment, +}; +use hashql_mir::{ + body::{basic_block::BasicBlockId, local::Local, terminator::GraphReadBody}, + builder::body, + intern::Interner, +}; +use insta::{Settings, assert_snapshot}; +use type_system::{ + knowledge::entity::id::{EntityEditionId, EntityUuid}, + ontology::{BaseUrl, VersionedUrl, id::OntologyTypeVersion}, + principal::{ + actor::{ActorEntityUuid, ActorId, MachineId, UserId}, + actor_group::{ActorGroupEntityUuid, ActorGroupId, TeamId, WebId}, + role::{RoleName, TeamRole, TeamRoleId, WebRole, WebRoleId}, + }, +}; +use uuid::Uuid; + +use super::{policy::PolicyTranslationUnit, protection::ProtectionTranslationUnit}; +use crate::{ + context::CodeGenerationContext, + postgres::{ + AuthorizationPatch, Parameters, PostgresCompiler, PreparedQueryPatch, + parameters::AuxiliaryParameters, + projections::{AuxiliaryProjections, Projections}, + tests::{CompilationFixture, format_body, lint_sql}, + }, +}; + +pub(crate) struct Fixture { + pub projections: AuxiliaryProjections, + pub parameters: AuxiliaryParameters, +} + +impl Fixture { + pub(crate) fn new() -> Self { + let base = Projections::new(); + let params = Parameters::new_in(Global); + + Self { + projections: AuxiliaryProjections::new(&base), + parameters: AuxiliaryParameters::new(¶ms, Global), + } + } + + pub(crate) fn policy(&mut self) -> PolicyTranslationUnit<'_, Global> { + PolicyTranslationUnit { + projections: &mut self.projections, + parameters: &mut self.parameters, + actor_id: Some(ActorId::User(UserId::new(ACTOR_UUID))), + } + } + + pub(crate) fn policy_anon(&mut self) -> PolicyTranslationUnit<'_, Global> { + PolicyTranslationUnit { + projections: &mut self.projections, + parameters: &mut self.parameters, + actor_id: None, + } + } + + pub(crate) fn protection(&mut self) -> ProtectionTranslationUnit<'_, Global> { + ProtectionTranslationUnit { + projections: &mut self.projections, + parameters: &mut self.parameters, + actor_id: Some(ActorId::User(UserId::new(ACTOR_UUID))), + } + } +} + +/// Returns pre-configured policies for a given actor. +pub(crate) struct MockStore { + pub actor_id: Option, + pub is_instance_admin: bool, + pub policies: Vec, +} + +impl PolicyStore for MockStore +where + F: Fn() -> ResolvedPolicy + Send + Sync, +{ + async fn create_policy( + &mut self, + _: AuthenticatedActor, + _: PolicyCreationParams, + ) -> Result> { + unimplemented!("not needed for authorization expression tests") + } + + async fn get_policy_by_id( + &self, + _: AuthenticatedActor, + _: PolicyId, + ) -> Result, Report> { + unimplemented!("not needed for authorization expression tests") + } + + async fn query_policies( + &self, + _: AuthenticatedActor, + _: &PolicyFilter, + ) -> Result, Report> { + unimplemented!("not needed for authorization expression tests") + } + + fn resolve_policies_for_actor( + &self, + _: AuthenticatedActor, + params: ResolvePoliciesParams<'_>, + ) -> impl Future, Report>> { + assert!( + params.actions.contains(&ActionName::ViewEntity), + "MockStore expects ViewEntity action", + ); + + future::ready(Ok(self.policies.iter().map(|policy| (policy)()).collect())) + } + + async fn update_policy_by_id( + &mut self, + _: AuthenticatedActor, + _: PolicyId, + _: &[PolicyUpdateOperation], + ) -> Result> { + unimplemented!("not needed for authorization expression tests") + } + + async fn archive_policy_by_id( + &mut self, + _: AuthenticatedActor, + _: PolicyId, + ) -> Result<(), Report> { + unimplemented!("not needed for authorization expression tests") + } + + async fn delete_policy_by_id( + &mut self, + _: AuthenticatedActor, + _: PolicyId, + ) -> Result<(), Report> { + unimplemented!("not needed for authorization expression tests") + } + + async fn seed_system_policies(&mut self) -> Result<(), Report> { + unimplemented!("not needed for authorization expression tests") + } + + async fn build_entity_type_context( + &self, + _: &[&VersionedUrl], + ) -> Result>, Report<[BuildEntityTypeContextError]>> { + unimplemented!("not needed for authorization expression tests") + } + + async fn build_property_type_context( + &self, + _: &[&VersionedUrl], + ) -> Result>, Report<[BuildPropertyTypeContextError]>> { + unimplemented!("not needed for authorization expression tests") + } + + async fn build_data_type_context( + &self, + _: &[&VersionedUrl], + ) -> Result>, Report<[BuildDataTypeContextError]>> { + unimplemented!("not needed for authorization expression tests") + } + + async fn build_entity_context( + &self, + _: &[EntityEditionId], + ) -> Result>, Report<[BuildEntityContextError]>> { + unimplemented!("not needed for authorization expression tests") + } +} + +impl PrincipalStore for MockStore +where + F: Send + Sync, +{ + async fn get_or_create_system_machine( + &mut self, + _: &str, + ) -> Result> { + unimplemented!("not needed for authorization expression tests") + } + + async fn create_web( + &mut self, + _: ActorId, + _: CreateWebParameter, + ) -> Result> { + unimplemented!("not needed for authorization expression tests") + } + + async fn get_web_roles( + &mut self, + _: ActorEntityUuid, + _: WebId, + ) -> Result, Report> { + unimplemented!("not needed for authorization expression tests") + } + + async fn get_team_roles( + &mut self, + _: ActorEntityUuid, + _: TeamId, + ) -> Result, Report> { + unimplemented!("not needed for authorization expression tests") + } + + async fn assign_role( + &mut self, + _: ActorEntityUuid, + _: ActorEntityUuid, + _: ActorGroupEntityUuid, + _: RoleName, + ) -> Result> { + unimplemented!("not needed for authorization expression tests") + } + + async fn get_actor_group_role( + &mut self, + _: ActorEntityUuid, + _: ActorGroupEntityUuid, + ) -> Result, Report> { + unimplemented!("not needed for authorization expression tests") + } + + async fn get_role_assignments( + &mut self, + _: ActorGroupEntityUuid, + _: RoleName, + ) -> Result, Report> { + unimplemented!("not needed for authorization expression tests") + } + + async fn unassign_role( + &mut self, + _: ActorEntityUuid, + _: ActorEntityUuid, + _: ActorGroupEntityUuid, + _: RoleName, + ) -> Result> { + unimplemented!("not needed for authorization expression tests") + } + + fn determine_actor( + &self, + actor_entity_uuid: ActorEntityUuid, + ) -> impl Future, Report>> { + let expected = self + .actor_id + .map_or_else(ActorEntityUuid::public_actor, ActorEntityUuid::from); + assert_eq!( + actor_entity_uuid, expected, + "MockStore received unexpected actor UUID", + ); + + future::ready(Ok(self.actor_id)) + } + + fn build_principal_context( + &self, + actor_id: ActorId, + context_builder: &mut ContextBuilder, + ) -> impl Future>> { + assert_eq!( + Some(actor_id), + self.actor_id, + "MockStore received unexpected actor in build_principal_context", + ); + if self.is_instance_admin { + use type_system::principal::actor_group::{ActorGroup, Team}; + + context_builder.add_actor_group(&ActorGroup::Team(Team { + id: TeamId::new(Uuid::nil()), + name: "instance-admins".to_owned(), + parent_id: ActorGroupId::Web(WebId::new(Uuid::nil())), + roles: HashSet::new(), + })); + } + + future::ready(Ok(())) + } +} + +pub(crate) const ACTOR_UUID: Uuid = Uuid::from_u128(0xAAAA_AAAA_AAAA_AAAA_AAAA_AAAA_AAAA_AAAA); +pub(crate) const ENTITY_UUID_1: Uuid = Uuid::from_u128(0x1111_1111_1111_1111_1111_1111_1111_1111); +pub(crate) const ENTITY_UUID_2: Uuid = Uuid::from_u128(0x2222_2222_2222_2222_2222_2222_2222_2222); +pub(crate) const WEB_UUID_1: Uuid = Uuid::from_u128(0x3333_3333_3333_3333_3333_3333_3333_3333); + +pub(crate) fn policy_components( + actor_id: Option, + policies: Vec ResolvedPolicy + Send + Sync>>, +) -> PolicyComponents { + let store = MockStore { + actor_id, + is_instance_admin: false, + policies, + }; + + let actor = actor_id.map_or_else(ActorEntityUuid::public_actor, ActorEntityUuid::from); + + futures_lite::future::block_on( + PolicyComponents::builder(&store) + .with_actor(actor) + .with_action(ActionName::ViewEntity, MergePolicies::Yes) + .into_future(), + ) + .expect("mock store should not fail") +} + +const PERMIT_POLICY_UUID: Uuid = Uuid::from_u128(0xBBBB_BBBB_BBBB_BBBB_BBBB_BBBB_BBBB_BBBB); +const FORBID_POLICY_UUID: Uuid = Uuid::from_u128(0xCCCC_CCCC_CCCC_CCCC_CCCC_CCCC_CCCC_CCCC); + +pub(crate) fn permit<'resource>( + resource: impl Fn() -> Option + Send + Sync + 'resource, +) -> Box ResolvedPolicy + Send + Sync + 'resource> { + Box::new(move || ResolvedPolicy { + original_policy_id: PolicyId::new(PERMIT_POLICY_UUID), + effect: Effect::Permit, + actions: vec![ActionName::ViewEntity], + resource: (resource)(), + }) +} + +pub(crate) fn forbid<'resource>( + resource: impl Fn() -> Option + Send + Sync + 'resource, +) -> Box ResolvedPolicy + Send + Sync + 'resource> { + Box::new(move || ResolvedPolicy { + original_policy_id: PolicyId::new(FORBID_POLICY_UUID), + effect: Effect::Forbid, + actions: vec![ActionName::ViewEntity], + resource: (resource)(), + }) +} + +pub(crate) fn policy_components_admin( + actor_id: Option, + policies: Vec ResolvedPolicy + Send + Sync>>, +) -> PolicyComponents { + let store = MockStore { + actor_id, + is_instance_admin: true, + policies, + }; + + let actor = actor_id.map_or_else(ActorEntityUuid::public_actor, ActorEntityUuid::from); + + futures_lite::future::block_on( + PolicyComponents::builder(&store) + .with_actor(actor) + .with_action(ActionName::ViewEntity, MergePolicies::Yes) + .into_future(), + ) + .expect("mock store should not fail") +} + +pub(crate) fn make_url(base: &str, version: u32) -> VersionedUrl { + VersionedUrl { + base_url: BaseUrl::new(base.to_owned()).expect("valid base URL"), + version: OntologyTypeVersion { + major: version, + pre_release: None, + }, + } +} + +fn compile_and_patch<'heap>( + fixture: &CompilationFixture<'heap>, + heap: &'heap Heap, + policy: &hash_graph_authorization::policies::PolicyComponents, + properties: &PropertyProtectionFilterConfig<'_>, +) -> String { + let mut scratch = hashql_core::heap::Scratch::new(); + let def = fixture.def(); + + let mut context = CodeGenerationContext::new_in( + &fixture.env, + &fixture.interner, + &fixture.bodies, + &fixture.execution, + heap, + &mut scratch, + ); + + let mut filters = hashql_core::heap::Vec::new_in(heap); + filters.push(GraphReadBody::Filter(def, Local::ENV)); + + let read = hashql_mir::body::terminator::GraphRead { + head: hashql_mir::body::terminator::GraphReadHead::Entity { + axis: hashql_mir::body::operand::Operand::Place(hashql_mir::body::place::Place::local( + Local::ENV, + )), + }, + body: filters, + tail: hashql_mir::body::terminator::GraphReadTail::Collect, + target: BasicBlockId::START, + }; + + let mut prepared_query = { + let mut compiler = PostgresCompiler::new_in(&mut context, &mut scratch); + compiler.compile_graph_read(&read) + }; + + assert!( + context.diagnostics.is_empty(), + "unexpected diagnostics from compilation", + ); + + let patch = PreparedQueryPatch::new().layer(AuthorizationPatch::new(policy, properties)); + patch.apply(&mut prepared_query, Global); + + let body = format_body(fixture, heap); + let sql = lint_sql(&prepared_query.transpile().to_string()); + let compiled_params = format!("{}", prepared_query.parameters); + let auxiliary_params = format!("{:?}", prepared_query.auxiliary_parameters); + + let mut output = String::new(); + writeln!(output, "{:=^80}\n", " MIR ").expect("write to String"); + write!(output, "{body}").expect("write to String"); + writeln!(output, "\n{:=^80}\n", " SQL ").expect("write to String"); + write!(output, "{sql}").expect("write to String"); + if !compiled_params.is_empty() { + writeln!(output, "\n{:=^80}\n", " Compiled Parameters ").expect("write to String"); + write!(output, "{compiled_params}").expect("write to String"); + } + writeln!(output, "\n{:=^80}\n", " Auxiliary Parameters ").expect("write to String"); + write!(output, "{auxiliary_params}").expect("write to String"); + output +} + +fn snapshot_settings() -> Settings { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let mut settings = Settings::clone_current(); + settings.set_snapshot_path(manifest_dir.join("tests/ui/postgres/authorization/integration")); + settings.set_prepend_module_to_snapshot(false); + settings +} + +/// Compiles a property-accessing filter, then applies authorization with +/// constrained permits, forbids, and property protection masking. +#[test] +fn patch_with_policy_and_protection() { + let heap = Heap::new(); + let interner = Interner::new(&heap); + let env = Environment::new(&heap); + + let body = body!(interner, env; [graph::read::filter]@0/2 -> Bool { + decl env: (), vertex: (|r#type| entity_types::types::entity(r#type, r#type.unknown(), None)), + field_val: ?, input_val: ?, result: Bool; + @proj v_props = vertex.properties: ?, + v_name = v_props.name: ?; + + bb0() { + field_val = load v_name; + input_val = input.load! "expected"; + result = bin.== field_val input_val; + return result; + } + }); + + let compilation = CompilationFixture::new(&heap, env, body); + + let actor = Some(ActorId::User(UserId::new(ACTOR_UUID))); + let policy = policy_components( + actor, + vec![ + permit(|| { + Some( + hash_graph_authorization::policies::resource::ResourceConstraint::Entity( + hash_graph_authorization::policies::resource::EntityResourceConstraint::Exact { + id: EntityUuid::new(ENTITY_UUID_1), + }, + ), + ) + }), + permit(|| { + Some( + hash_graph_authorization::policies::resource::ResourceConstraint::Web { + web_id: WebId::new(WEB_UUID_1), + }, + ) + }), + forbid(|| { + Some( + hash_graph_authorization::policies::resource::ResourceConstraint::Entity( + hash_graph_authorization::policies::resource::EntityResourceConstraint::Any { + filter: hash_graph_authorization::policies::resource::EntityResourceFilter::IsOfType { + entity_type: make_url( + "https://hash.ai/@h/types/entity-type/restricted/", + 1, + ), + }, + }, + ), + ) + }), + ], + ); + + let mut properties = PropertyProtectionFilterConfig::new(); + properties.protect_property( + BaseUrl::new("https://hash.ai/@h/types/property-type/email/".to_owned()) + .expect("valid base URL"), + PropertyFilter::Equal( + PropertyFilterExpression::Path { + path: PropertyFilterEntityQueryPath::Uuid, + }, + PropertyFilterExpression::ActorId, + ), + ); + + let report = compile_and_patch(&compilation, &heap, &policy, &properties); + + let settings = snapshot_settings(); + let _guard = settings.bind_to_scope(); + assert_snapshot!("patch_with_policy_and_protection", report); +} + +/// Blank permit with no protection produces minimal changes: +/// WHERE gets TRUE, no property masking, no auxiliary joins. +#[test] +fn patch_blank_permit_no_protection() { + let heap = Heap::new(); + let interner = Interner::new(&heap); + let env = Environment::new(&heap); + + let body = body!(interner, env; [graph::read::filter]@0/2 -> Bool { + decl env: (), vertex: [Opaque sym::path::Entity; ?], + result: Bool; + + bb0() { + result = input.load! "flag"; + return result; + } + }); + + let compilation = CompilationFixture::new(&heap, env, body); + + let actor = Some(ActorId::User(UserId::new(ACTOR_UUID))); + let policy = policy_components(actor, vec![permit(|| None)]); + let properties = PropertyProtectionFilterConfig::new(); + + let report = compile_and_patch(&compilation, &heap, &policy, &properties); + + let settings = snapshot_settings(); + let _guard = settings.bind_to_scope(); + assert_snapshot!("patch_blank_permit_no_protection", report); +} + +/// Blank forbid produces FALSE in WHERE regardless of other policies. +/// Protection masking still applies as defense-in-depth. +#[test] +fn patch_blank_forbid_denies_all() { + let heap = Heap::new(); + let interner = Interner::new(&heap); + let env = Environment::new(&heap); + + let body = body!(interner, env; [graph::read::filter]@0/2 -> Bool { + decl env: (), vertex: (|r#type| entity_types::types::entity(r#type, r#type.unknown(), None)), + field_val: ?, input_val: ?, result: Bool; + @proj v_props = vertex.properties: ?, + v_name = v_props.name: ?; + + bb0() { + field_val = load v_name; + input_val = input.load! "expected"; + result = bin.== field_val input_val; + return result; + } + }); + + let compilation = CompilationFixture::new(&heap, env, body); + + let actor = Some(ActorId::User(UserId::new(ACTOR_UUID))); + let policy = policy_components(actor, vec![forbid(|| None)]); + let properties = PropertyProtectionFilterConfig::hash_default(); + + let report = compile_and_patch(&compilation, &heap, &policy, &properties); + + let settings = snapshot_settings(); + let _guard = settings.bind_to_scope(); + assert_snapshot!("patch_blank_forbid_denies_all", report); +} + +/// Instance admin bypasses property protection entirely, even with +/// a non-empty protection config. +#[test] +fn patch_instance_admin_bypasses_protection() { + let heap = Heap::new(); + let interner = Interner::new(&heap); + let env = Environment::new(&heap); + + let body = body!(interner, env; [graph::read::filter]@0/2 -> Bool { + decl env: (), vertex: (|r#type| entity_types::types::entity(r#type, r#type.unknown(), None)), + field_val: ?, input_val: ?, result: Bool; + @proj v_props = vertex.properties: ?, + v_name = v_props.name: ?; + + bb0() { + field_val = load v_name; + input_val = input.load! "expected"; + result = bin.== field_val input_val; + return result; + } + }); + + let compilation = CompilationFixture::new(&heap, env, body); + + let actor = Some(ActorId::User(UserId::new(ACTOR_UUID))); + let policy = policy_components_admin(actor, vec![permit(|| None)]); + let properties = PropertyProtectionFilterConfig::hash_default(); + + let report = compile_and_patch(&compilation, &heap, &policy, &properties); + + let settings = snapshot_settings(); + let _guard = settings.bind_to_scope(); + assert_snapshot!("patch_instance_admin_bypasses_protection", report); +} + +/// Protection filter that references `TypeBaseUrls`, requiring the +/// `entity_edition_cache` auxiliary join to be in scope inside the +/// `entity_editions` LATERAL mask expression. +#[test] +fn patch_protection_with_type_base_urls() { + let heap = Heap::new(); + let interner = Interner::new(&heap); + let env = Environment::new(&heap); + + let body = body!(interner, env; [graph::read::filter]@0/2 -> Bool { + decl env: (), vertex: (|r#type| entity_types::types::entity(r#type, r#type.unknown(), None)), + field_val: ?, input_val: ?, result: Bool; + @proj v_props = vertex.properties: ?, + v_name = v_props.name: ?; + + bb0() { + field_val = load v_name; + input_val = input.load! "expected"; + result = bin.== field_val input_val; + return result; + } + }); + + let compilation = CompilationFixture::new(&heap, env, body); + + let actor = Some(ActorId::User(UserId::new(ACTOR_UUID))); + let policy = policy_components(actor, vec![permit(|| None)]); + + // Protection uses TypeBaseUrls path, which demands entity_edition_cache join. + let mut properties = PropertyProtectionFilterConfig::new(); + properties.protect_property( + BaseUrl::new("https://hash.ai/@h/types/property-type/email/".to_owned()) + .expect("valid base URL"), + PropertyFilter::In( + PropertyFilterExpression::Parameter { + parameter: Parameter::Text(alloc::borrow::Cow::Borrowed( + "https://hash.ai/@h/types/entity-type/user/", + )), + }, + PropertyFilterExpressionList::Path { + path: PropertyFilterEntityQueryPath::TypeBaseUrls, + }, + ), + ); + + let report = compile_and_patch(&compilation, &heap, &policy, &properties); + + let settings = snapshot_settings(); + let _guard = settings.bind_to_scope(); + assert_snapshot!("patch_protection_with_type_base_urls", report); +} diff --git a/libs/@local/hashql/eval/src/postgres/filter/tests.rs b/libs/@local/hashql/eval/src/postgres/filter/tests.rs index 486ec2b8707..30a76976711 100644 --- a/libs/@local/hashql/eval/src/postgres/filter/tests.rs +++ b/libs/@local/hashql/eval/src/postgres/filter/tests.rs @@ -11,96 +11,33 @@ use alloc::alloc::Global; use std::path::PathBuf; -use hash_graph_postgres_store::store::postgres::query::{Expression, Transpile as _}; +use hash_graph_postgres_store::store::postgres::query::Transpile as _; use hashql_core::{ heap::{Heap, Scratch}, id::Id as _, module::std_lib::graph::types::knowledge::entity as entity_types, - pretty::Formatter, symbol::sym, - r#type::{TypeBuilder, TypeFormatter, TypeFormatterOptions, TypeId, environment::Environment}, + r#type::{TypeBuilder, TypeId, environment::Environment}, }; -use hashql_diagnostics::DiagnosticIssues; use hashql_hir::node::operation::InputOp; use hashql_mir::{ body::{Body, Source, basic_block::BasicBlockId, local::Local, terminator::GraphReadBody}, builder::{BodyBuilder, body}, - context::MirContext, - def::{DefId, DefIdVec}, + def::DefId, intern::Interner, - pass::{ - GlobalAnalysisPass as _, - analysis::SizeEstimationAnalysis, - execution::{ExecutionAnalysis, ExecutionAnalysisResidual, IslandKind, TargetId}, - }, - pretty::TextFormatOptions, + pass::execution::{IslandKind, TargetId}, }; use insta::{Settings, assert_snapshot}; -use sqruff_lib::core::{config::FluffConfig, linter::core::Linter}; -use sqruff_lib_core::dialects::init::DialectKind; use crate::{ context::CodeGenerationContext, - postgres::{DatabaseContext, PostgresCompiler, filter::GraphReadFilterCompiler}, + postgres::{ + DatabaseContext, PostgresCompiler, + filter::GraphReadFilterCompiler, + tests::{CompilationFixture, format_body, lint_sql}, + }, }; -/// Runs the full execution analysis pipeline on a single `body!`-constructed filter body -/// and returns everything needed for compilation. -struct Fixture<'heap> { - env: Environment<'heap>, - interner: crate::intern::Interner<'heap>, - bodies: DefIdVec, &'heap Heap>, - execution: DefIdVec>, &'heap Heap>, -} - -impl<'heap> Fixture<'heap> { - fn new(heap: &'heap Heap, env: Environment<'heap>, body: Body<'heap>) -> Self { - assert!( - matches!(body.source, Source::GraphReadFilter(_)), - "these tests require GraphReadFilter bodies", - ); - - let interner = Interner::new(heap); - let mut scratch = Scratch::new(); - - let mut bodies = DefIdVec::new_in(heap); - bodies.push(body); - - let mut mir_context = MirContext { - heap, - env: &env, - interner: &interner, - diagnostics: DiagnosticIssues::new(), - }; - - let mut size_analysis = SizeEstimationAnalysis::new_in(&scratch); - size_analysis.run(&mut mir_context, &bodies); - let footprints = size_analysis.finish(); - - let analysis = ExecutionAnalysis { - footprints: &footprints, - scratch: &mut scratch, - }; - let execution = analysis.run_all_in(&mut mir_context, &mut bodies, heap); - - assert!( - mir_context.diagnostics.is_empty(), - "execution analysis produced diagnostics: this likely means the body is malformed", - ); - - Self { - env, - interner: interner.into(), - bodies, - execution, - } - } - - fn def(&self) -> DefId { - self.bodies.iter().next().expect("fixture has one body").id - } -} - struct FilterIslandReport { entry_block: BasicBlockId, target: TargetId, @@ -130,27 +67,10 @@ impl core::fmt::Display for FilterReport { } } -fn format_body<'heap>(fixture: &Fixture<'heap>, heap: &'heap Heap) -> String { - let formatter = Formatter::new(heap); - let mut type_formatter = - TypeFormatter::new(&formatter, &fixture.env, TypeFormatterOptions::terse()); - - let mut text_format = TextFormatOptions { - writer: Vec::::new(), - indent: 4, - sources: (), - types: &mut type_formatter, - annotations: (), - } - .build(); - - let body = &fixture.bodies[fixture.def()]; - text_format.format_body(body).expect("formatting failed"); - - String::from_utf8(text_format.writer).expect("valid UTF-8") -} - -fn compile_filter_islands<'heap>(fixture: &Fixture<'heap>, heap: &'heap Heap) -> FilterReport { +fn compile_filter_islands<'heap>( + fixture: &CompilationFixture<'heap>, + heap: &'heap Heap, +) -> FilterReport { let mut scratch = Scratch::new(); let def = fixture.def(); @@ -182,12 +102,6 @@ fn compile_filter_islands<'heap>(fixture: &Fixture<'heap>, heap: &'heap Heap) -> let mut island_reports = Vec::new(); - let mut linter_config = FluffConfig::default(); - linter_config - .override_dialect(DialectKind::Postgres) - .expect("dialect should be loaded"); - let linter = Linter::new(linter_config, None, None, false).expect("linter should be created"); - #[expect(clippy::string_slice, reason = "Known to be valid codepoints")] for (island_id, entry_block) in postgres_islands { let island = &residual.islands[island_id]; @@ -205,11 +119,9 @@ fn compile_filter_islands<'heap>(fixture: &Fixture<'heap>, heap: &'heap Heap) -> let sql = expression.transpile_to_string(); - let linted = linter - .lint_string(&format!("SELECT {sql}"), None, true) - .expect("should be valid SQL"); - - let fixed = linted.fix_string(); + // Wrap in SELECT so sqruff can parse the expression, then strip the + // prefix and re-indent. + let fixed = lint_sql(&format!("SELECT {sql}")); let fixed: String = fixed[7..] .lines() .map(|line| &line[4..]) @@ -268,14 +180,9 @@ impl core::fmt::Display for QueryReport { } } -fn compile_full_query<'heap>(fixture: &Fixture<'heap>, heap: &'heap Heap) -> QueryReport { - compile_full_query_with_mask(fixture, heap, None) -} - -fn compile_full_query_with_mask<'heap>( - fixture: &Fixture<'heap>, +fn compile_full_query<'heap>( + fixture: &CompilationFixture<'heap>, heap: &'heap Heap, - property_mask: Option, ) -> QueryReport { let mut scratch = Scratch::new(); let def = fixture.def(); @@ -304,8 +211,7 @@ fn compile_full_query_with_mask<'heap>( }; let prepared_query = { - let mut compiler = - PostgresCompiler::new_in(&mut context, &mut scratch).with_property_mask(property_mask); + let mut compiler = PostgresCompiler::new_in(&mut context, &mut scratch); compiler.compile_graph_read(&read) }; @@ -314,22 +220,11 @@ fn compile_full_query_with_mask<'heap>( "unexpected diagnostics from full compilation", ); - let mut linter_config = FluffConfig::default(); - linter_config - .override_dialect(DialectKind::Postgres) - .expect("dialect should be loaded"); - let linter = Linter::new(linter_config, None, None, false).expect("linter should be created"); - - let sql = prepared_query.transpile().to_string(); - let linted = linter - .lint_string(&sql, None, true) - .expect("should be valid SQL"); - let parameters = format!("{}", prepared_query.parameters); QueryReport { body: format_body(fixture, heap), - sql: linted.fix_string(), + sql: lint_sql(&prepared_query.transpile().to_string()), parameters, } } @@ -369,7 +264,7 @@ fn diamond_cfg_merge() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -399,7 +294,7 @@ fn switch_int_many_branches() { bb5() { return true; } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -431,7 +326,7 @@ fn straight_line_goto_chain() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -476,7 +371,7 @@ fn island_exit_goto() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -518,7 +413,7 @@ fn island_exit_with_live_out() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -565,7 +460,7 @@ fn island_exit_switch_int() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -607,7 +502,7 @@ fn island_exit_empty_arrays() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -640,7 +535,7 @@ fn data_island_provides_without_lateral() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); // No Postgres exec islands should exist — only a Data island. let filter_report = compile_filter_islands(&fixture, &heap); @@ -693,7 +588,7 @@ fn provides_drives_select_and_joins() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_full_query(&fixture, &heap); let settings = snapshot_settings(); @@ -723,7 +618,7 @@ fn property_field_equality() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -754,7 +649,7 @@ fn nested_property_access() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -785,7 +680,7 @@ fn left_entity_filter() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -793,46 +688,6 @@ fn left_entity_filter() { assert_snapshot!("left_entity_filter", report.to_string()); } -/// Property mask wraps `properties` and `property_metadata` SELECT expressions with -/// `(col - mask)` but leaves other columns untouched. -#[test] -fn property_mask() { - let heap = Heap::new(); - let interner = Interner::new(&heap); - let env = Environment::new(&heap); - - let callee_id = DefId::new(99); - - // Properties access in bb0 (Postgres Data island) with an apply in bb1 (Interpreter) - // ensures Properties and `PropertyMetadata` appear in the provides set. - let body = body!(interner, env; [graph::read::filter]@0/2 -> ? { - decl env: (), vertex: (|t| entity_types::types::entity(t, t.unknown(), None)), - props: ?, prop_meta: ?, func: [fn() -> ?], result: ?; - @proj v_props = vertex.properties: ?, - v_meta = vertex.metadata: ?, - v_prop_meta = v_meta.property_metadata: ?; - - bb0() { - props = load v_props; - prop_meta = load v_prop_meta; - func = load callee_id; - result = apply func; - return result; - } - }); - - let fixture = Fixture::new(&heap, env, body); - - // Use a parameter placeholder as the mask expression. - let mask = Expression::Parameter(99); - - let report = compile_full_query_with_mask(&fixture, &heap, Some(mask)); - - let settings = snapshot_settings(); - let _guard = settings.bind_to_scope(); - assert_snapshot!("property_mask", report.to_string()); -} - /// Tuple aggregate followed by `.0` numeric field projection → /// `json_extract_path(base, (0)::text)`. #[test] @@ -853,7 +708,7 @@ fn field_index_projection() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -881,7 +736,7 @@ fn field_by_name_projection() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -933,7 +788,7 @@ fn dynamic_index_projection() { body.source = Source::GraphReadFilter(hashql_hir::node::HirId::PLACEHOLDER); body.id = DefId::new(0); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -959,7 +814,7 @@ fn unary_neg() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -985,7 +840,7 @@ fn unary_not() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -1012,7 +867,7 @@ fn binary_sub_numeric_cast() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -1038,7 +893,7 @@ fn unary_bitnot() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -1075,7 +930,7 @@ fn temporal_decision_time_interval() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_full_query(&fixture, &heap); let settings = snapshot_settings(); @@ -1102,7 +957,7 @@ fn binary_bitand_bigint_cast() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -1129,7 +984,7 @@ fn binary_bitand_boolean_and() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -1156,7 +1011,7 @@ fn binary_bitor_bigint_cast() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); @@ -1183,7 +1038,7 @@ fn binary_bitor_boolean_or() { } }); - let fixture = Fixture::new(&heap, env, body); + let fixture = CompilationFixture::new(&heap, env, body); let report = compile_filter_islands(&fixture, &heap); let settings = snapshot_settings(); diff --git a/libs/@local/hashql/eval/src/postgres/mod.rs b/libs/@local/hashql/eval/src/postgres/mod.rs index 0f18a5f1f00..95ab001275a 100644 --- a/libs/@local/hashql/eval/src/postgres/mod.rs +++ b/libs/@local/hashql/eval/src/postgres/mod.rs @@ -30,8 +30,8 @@ use core::{alloc::Allocator, fmt::Display}; use hash_graph_postgres_store::store::postgres::query::{ - self, Column, Expression, Identifier, SelectExpression, SelectStatement, Transpile as _, - WhereExpression, table::EntityTemporalMetadata, + self, Column, Expression, Identifier, SelectExpression, SelectStatement, WhereExpression, + table::EntityTemporalMetadata, }; use hashql_core::{ debug_panic, @@ -42,7 +42,6 @@ use hashql_core::{ use hashql_mir::{ body::{ Body, - basic_block::BasicBlockId, local::Local, terminator::{GraphRead, GraphReadBody, GraphReadHead, TerminatorKind}, }, @@ -56,21 +55,30 @@ use hashql_mir::{ }, }; -use self::{ - continuation::ContinuationColumn, filter::GraphReadFilterCompiler, projections::Projections, - types::traverse_struct, -}; pub use self::{ + authorization::AuthorizationPatch, continuation::ContinuationField, parameters::{Parameter, ParameterIndex, ParameterValue, Parameters, TemporalAxis}, + prepared::{ + PatchPreparedQuery, PatchPreparedQueryLayer, PreparedQueries, PreparedQuery, + PreparedQueryPatch, + }, +}; +use self::{ + continuation::ContinuationColumn, filter::GraphReadFilterCompiler, + parameters::AuxiliaryParameters, projections::Projections, types::traverse_struct, }; use crate::context::CodeGenerationContext; +mod authorization; mod continuation; pub(crate) mod error; mod filter; mod parameters; +mod prepared; mod projections; +#[cfg(test)] +pub(crate) mod tests; mod traverse; mod types; @@ -188,50 +196,6 @@ impl Display for ColumnDescriptor { } } -/// A fully-compiled SQL query ready for execution. -/// -/// Contains the typed query AST ([`SelectStatement`]), the parameter catalog ([`Parameters`]) -/// for binding runtime values, and a column manifest ([`ColumnDescriptor`]s) that tells the -/// bridge how to decode each result column. -pub struct PreparedQuery<'heap, A: Allocator> { - pub vertex_type: VertexType, - pub parameters: Parameters<'heap, A>, - pub statement: SelectStatement, - pub columns: Vec, -} - -impl PreparedQuery<'_, A> { - pub fn transpile(&self) -> impl Display { - core::fmt::from_fn(|fmt| self.statement.transpile(fmt)) - } -} - -/// Registry of compiled SQL queries, indexed by definition and basic block. -/// -/// The SQL lowering pass produces one [`PreparedQuery`] per [`GraphRead`] -/// terminator in the MIR. This struct stores them contiguously in `queries` -/// with `offsets` providing per-definition starting positions, so -/// [`find`](Self::find) can locate the correct query for a given `(DefId, -/// BasicBlockId)` pair. -/// -/// [`GraphRead`]: hashql_mir::body::terminator::GraphRead -pub struct PreparedQueries<'heap, A: Allocator> { - offsets: Box, A>, - queries: Vec<(BasicBlockId, PreparedQuery<'heap, A>), A>, -} - -impl<'heap, A: Allocator> PreparedQueries<'heap, A> { - pub fn find(&self, body: DefId, block: BasicBlockId) -> Option<&PreparedQuery<'heap, A>> { - let start = self.offsets[body]; - let end = self.offsets[body.plus(1)]; - - self.queries[start..end] - .iter() - .find(|(id, _)| *id == block) - .map(|(_, query)| query) - } -} - /// Compiles Postgres-targeted MIR islands into a single PostgreSQL `SELECT`. /// /// Created per evaluation and used to compile [`GraphRead`] terminators. Compilation emits @@ -244,14 +208,6 @@ pub struct PostgresCompiler<'eval, 'ctx, 'heap, A: Allocator, S: Allocator> { alloc: A, scratch: S, - - /// Pre-built expression to subtract protected property keys from JSONB columns. - /// - /// When present, `properties` and `property_metadata` `SELECT` expressions are - /// wrapped as `(column - mask)`. The caller builds this from the permission - /// system's protection rules; the compiler doesn't know about entity types - /// or actors. - property_mask: Option, } impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> @@ -267,21 +223,9 @@ impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> context, alloc, scratch, - property_mask: None, } } - /// Sets an optional JSONB key mask applied to selected property columns. - /// - /// When set, `properties` and `property_metadata` selections are wrapped as - /// `(column - mask)` to strip protected keys from the output. The compiler itself does not - /// understand permissions; the caller is responsible for building the mask. - #[must_use] - pub fn with_property_mask(mut self, property_mask: Option) -> Self { - self.property_mask = property_mask; - self - } - /// Joins the property types across all filter bodies into a single type. /// /// Each filter body may operate on a different `Entity`. This computes the @@ -400,7 +344,10 @@ impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> } } - fn compile_graph_read_entity(&mut self, read: &GraphRead<'heap>) -> PreparedQuery<'heap, A> + fn compile_graph_read_entity( + &mut self, + read: &GraphRead<'heap>, + ) -> prepared::PreparedQuery<'heap, A> where A: Clone, { @@ -431,14 +378,7 @@ impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> for traversal_path in provides[VertexType::Entity].iter() { let TraversalPath::Entity(path) = traversal_path; - let mut expression = traverse::eval_entity_path(&mut db, path); - - if matches!(path, EntityPath::Properties | EntityPath::PropertyMetadata) - && let Some(mask) = &self.property_mask - { - expression = Expression::grouped(Expression::subtract(expression, mask.clone())); - } - + let expression = traverse::eval_entity_path(&mut db, path); let alias = Identifier::from(traversal_path.as_symbol().unwrap()); let field_type = traversal_path @@ -498,10 +438,13 @@ impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> .where_expression(db.where_expression) .build(); - PreparedQuery { + let auxiliary_parameters = AuxiliaryParameters::new(&db.parameters, self.alloc.clone()); + prepared::PreparedQuery { vertex_type: VertexType::Entity, parameters: db.parameters, statement: query, + projections: db.projections, + auxiliary_parameters, columns, } } @@ -509,7 +452,10 @@ impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> /// Compiles a [`GraphRead`] into a [`PreparedQuery`]. /// /// [`GraphRead`]: hashql_mir::body::terminator::GraphRead - pub fn compile_graph_read(&mut self, read: &'ctx GraphRead<'heap>) -> PreparedQuery<'heap, A> + pub fn compile_graph_read( + &mut self, + read: &'ctx GraphRead<'heap>, + ) -> prepared::PreparedQuery<'heap, A> where A: Clone, { @@ -519,7 +465,7 @@ impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> } #[expect(unsafe_code)] - pub fn compile(&mut self) -> PreparedQueries<'heap, A> + pub fn compile(&mut self) -> prepared::PreparedQueries<'heap, A> where A: Clone, { @@ -550,6 +496,6 @@ impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> offsets[body_id.plus(1)] = queries.len(); } - PreparedQueries { offsets, queries } + prepared::PreparedQueries { offsets, queries } } } diff --git a/libs/@local/hashql/eval/src/postgres/parameters.rs b/libs/@local/hashql/eval/src/postgres/parameters.rs index e929f18fb9e..d52c7356c52 100644 --- a/libs/@local/hashql/eval/src/postgres/parameters.rs +++ b/libs/@local/hashql/eval/src/postgres/parameters.rs @@ -22,6 +22,7 @@ use hashql_mir::{ body::{local::Local, place::FieldIndex}, interpret::value::Int, }; +use postgres_types::ToSql; id::newtype!( /// Index of a SQL parameter in the compiled query, rendered as `$N` by the SQL formatter. @@ -249,6 +250,44 @@ impl fmt::Display for Parameters<'_, A> { } } +/// Runtime parameter values for authorization conditions, indexed after +/// the compiled parameters (`$K+1..`). +#[derive(Debug)] +pub(crate) struct AuxiliaryParameters { + initial_offset: usize, + parameters: Vec, A>, +} + +impl AuxiliaryParameters { + pub(crate) fn new(params: &Parameters<'_, A>, alloc: A) -> Self { + Self { + initial_offset: params.len(), + parameters: Vec::new_in(alloc), + } + } + + /// Pushes a value and returns its 1-based parameter index (`$N`). + pub(crate) fn push(&mut self, value: impl ToSql + Sync + 'static) -> usize + where + A: Clone, + { + let alloc = self.parameters.allocator().clone(); + self.parameters.push(Box::new_in(value, alloc)); + + self.parameters.len() + self.initial_offset + } + + /// Returns the number of auxiliary parameters allocated. + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.parameters.len() + } + + pub(crate) fn iter(&self) -> impl ExactSizeIterator { + self.parameters.iter().map(|param| &**param) + } +} + #[cfg(test)] mod tests { #![expect(clippy::min_ident_chars)] diff --git a/libs/@local/hashql/eval/src/postgres/prepared.rs b/libs/@local/hashql/eval/src/postgres/prepared.rs new file mode 100644 index 00000000000..d752604e78b --- /dev/null +++ b/libs/@local/hashql/eval/src/postgres/prepared.rs @@ -0,0 +1,276 @@ +#![expect( + clippy::field_scoped_visibility_modifiers, + reason = "internal module that is opaque to the outside" +)] +use core::{alloc::Allocator, fmt::Display, marker::PhantomData}; + +use hash_graph_postgres_store::store::postgres::query::{SelectStatement, Transpile as _}; +use hashql_core::id::Id as _; +use hashql_mir::{ + body::basic_block::BasicBlockId, + def::{DefId, DefIdSlice}, + pass::execution::VertexType, +}; +use postgres_types::ToSql; + +use super::{ + ColumnDescriptor, Parameters, + parameters::AuxiliaryParameters, + projections::{AuxiliaryProjections, Projections}, +}; + +/// A fully-compiled SQL query ready for execution. +/// +/// Contains the typed query AST ([`SelectStatement`]), the parameter catalog ([`Parameters`]) +/// for binding runtime values, and a column manifest ([`ColumnDescriptor`]s) that tells the +/// bridge how to decode each result column. +pub struct PreparedQuery<'heap, A: Allocator> { + pub vertex_type: VertexType, + pub parameters: Parameters<'heap, A>, + pub statement: SelectStatement, + pub columns: Vec, + + pub(super) projections: Projections, + pub(super) auxiliary_parameters: AuxiliaryParameters, +} + +impl PreparedQuery<'_, A> { + pub fn transpile(&self) -> impl Display { + core::fmt::from_fn(|fmt| self.statement.transpile(fmt)) + } + + pub fn auxiliary_parameters( + &self, + ) -> impl IntoIterator { + self.auxiliary_parameters.iter() + } +} + +/// Cons cell for the patch layer list. +/// +/// Layers are pushed via [`PreparedQueryPatch::layer`] and execute +/// outermost-first: the last layer added runs first and receives the +/// rest of the list as its `next` continuation. +pub struct HCons { + head: H, + tail: T, +} + +/// Terminal element of the patch layer list. +/// +/// When reached, materializes all auxiliary joins registered during +/// the preceding layers by calling +/// [`AuxiliaryProjections::build_joins`]. +pub struct HNil; + +/// Shared mutable context threaded through every patch layer. +/// +/// Layers register projection demands (auxiliary joins) here; `HNil` +/// materializes them into the query's FROM clause at the end of the +/// chain. +pub struct PatchContext<'ctx, A: Allocator> { + pub projections: AuxiliaryProjections, + pub alloc: A, + _marker: PhantomData<&'ctx ()>, +} + +/// A composed patch chain that can be applied to a [`PreparedQuery`]. +/// +/// The terminal element materializes all auxiliary joins; composite +/// elements delegate to their head layer, passing the remaining +/// chain as the `next` continuation. +pub trait PatchPreparedQuery { + fn patch_query( + &self, + context: &mut PatchContext<'_, A>, + query: &mut PreparedQuery<'_, A>, + scratch: S, + ); +} + +impl PatchPreparedQuery for &T +where + A: Allocator, + S: Allocator, + T: PatchPreparedQuery, +{ + fn patch_query( + &self, + context: &mut PatchContext<'_, A>, + query: &mut PreparedQuery<'_, A>, + scratch: S, + ) { + T::patch_query(self, context, query, scratch); + } +} + +/// A single patch layer in the continuation-passing pipeline. +/// +/// Each layer receives a `next` continuation and must call +/// `next.patch_query()` exactly once. Work done before that call +/// can register projection demands and modify the WHERE clause; +/// work done after can inspect and rewrite the materialized FROM +/// tree. +/// +/// Skipping the `next` call prevents join materialization and +/// blocks all inner layers. Calling it more than once duplicates +/// joins and conditions. +pub trait PatchPreparedQueryLayer { + fn patch_query( + &self, + context: &mut PatchContext<'_, A>, + query: &mut PreparedQuery<'_, A>, + scratch: S, + next: &N, + ) where + N: PatchPreparedQuery; +} + +impl PatchPreparedQueryLayer for &T +where + A: Allocator, + S: Allocator, + T: PatchPreparedQueryLayer, +{ + fn patch_query( + &self, + context: &mut PatchContext<'_, A>, + query: &mut PreparedQuery<'_, A>, + scratch: S, + next: &N, + ) where + N: PatchPreparedQuery, + { + T::patch_query(self, context, query, scratch, next); + } +} + +impl PatchPreparedQuery for HNil { + fn patch_query( + &self, + context: &mut PatchContext<'_, A>, + query: &mut PreparedQuery<'_, A>, + _: S, + ) { + let from = query + .statement + .from + .take() + .unwrap_or_else(|| unreachable!("every prepared query must have a FROM clause")); + + query.statement.from = Some(context.projections.build_joins(from)); + } +} + +impl PatchPreparedQuery for HCons +where + H: PatchPreparedQueryLayer, + T: PatchPreparedQuery, +{ + fn patch_query( + &self, + context: &mut PatchContext<'_, A>, + query: &mut PreparedQuery<'_, A>, + scratch: S, + ) { + let Self { head, tail } = self; + + head.patch_query(context, query, scratch, tail); + } +} + +/// Builder for a [`PatchPreparedQueryLayer`] pipeline. +/// +/// Layers are added with [`layer`](Self::layer) and execute outermost-first: +/// the last layer added runs first. The pipeline terminates by +/// materializing all auxiliary joins accumulated by the layers. +/// +/// ```text +/// PreparedQueryPatch::new() +/// .layer(authorization) // runs first, calls next for joins +/// .apply(&mut query, scratch); +/// ``` +pub struct PreparedQueryPatch { + patches: T, +} + +impl PreparedQueryPatch { + #[must_use] + pub const fn new() -> Self { + Self { patches: HNil } + } +} + +impl PreparedQueryPatch { + /// Wraps the current pipeline with an additional outer layer. + /// + /// The new layer executes before all previously added layers. + pub fn layer(self, other: T2) -> PreparedQueryPatch> { + PreparedQueryPatch { + patches: HCons { + head: other, + tail: self.patches, + }, + } + } + + /// Runs the full patch pipeline against `query`. + /// + /// May modify the query's statement and add auxiliary parameters + /// accessible through [`PreparedQuery::auxiliary_parameters`]. + pub fn apply(self, query: &mut PreparedQuery, scratch: S) + where + T: PatchPreparedQuery, + { + let alloc = query.columns.allocator().clone(); + + let projections = AuxiliaryProjections::new(&query.projections); + let mut context = PatchContext { + projections, + alloc, + _marker: PhantomData, + }; + + self.patches.patch_query(&mut context, query, scratch); + } +} + +impl Default for PreparedQueryPatch { + fn default() -> Self { + Self::new() + } +} + +/// Registry of compiled SQL queries, indexed by definition and basic block. +/// +/// The SQL lowering pass produces one [`PreparedQuery`] per [`GraphRead`] +/// terminator in the MIR. This struct stores them contiguously in `queries` +/// with `offsets` providing per-definition starting positions, so +/// [`find`](Self::find) can locate the correct query for a given `(DefId, +/// BasicBlockId)` pair. +/// +/// [`GraphRead`]: hashql_mir::body::terminator::GraphRead +pub struct PreparedQueries<'heap, A: Allocator> { + pub(crate) offsets: Box, A>, + pub(crate) queries: Vec<(BasicBlockId, PreparedQuery<'heap, A>), A>, +} + +impl<'heap, A: Allocator> PreparedQueries<'heap, A> { + pub fn find(&self, body: DefId, block: BasicBlockId) -> Option<&PreparedQuery<'heap, A>> { + let start = self.offsets[body]; + let end = self.offsets[body.plus(1)]; + + self.queries[start..end] + .iter() + .find(|(id, _)| *id == block) + .map(|(_, query)| query) + } + + pub fn iter(&self) -> impl Iterator> { + self.queries.iter().map(|(_, query)| query) + } + + pub fn iter_mut(&mut self) -> impl Iterator> { + self.queries.iter_mut().map(|(_, query)| query) + } +} diff --git a/libs/@local/hashql/eval/src/postgres/projections.rs b/libs/@local/hashql/eval/src/postgres/projections.rs index 66a55aa58f6..cb2d94b5a62 100644 --- a/libs/@local/hashql/eval/src/postgres/projections.rs +++ b/libs/@local/hashql/eval/src/postgres/projections.rs @@ -2,12 +2,12 @@ //! //! See [`Projections`] for the main entry point. -use core::alloc::Allocator; +use core::{alloc::Allocator, mem}; use hash_graph_postgres_store::store::postgres::query::{ self, Alias, Column, ColumnName, ColumnReference, ForeignKeyReference, FromItem, Identifier, JoinType, PostgresType, SelectExpression, SelectStatement, Table, TableName, TableReference, - table, + table::{self, DatabaseColumn as _}, }; use hashql_core::symbol::sym; @@ -32,17 +32,18 @@ impl From for ColumnName<'_> { /// /// Accessors like [`Self::entity_editions`] register that a table is needed and return a /// reference to it. The actual `FROM` tree is built once at the end via [`Self::build_from`]. +#[derive(Debug, Clone)] pub(crate) struct Projections { index: usize, /// Always present as the base table; everything joins through it. - base_alias: Alias, + pub base_alias: Alias, - entity_editions: Option, - entity_ids: Option, - entity_type_ids: Option, - left: Option, - right: Option, + pub entity_editions: Option, + pub entity_ids: Option, + pub entity_type_ids: Option, + pub left: Option, + pub right: Option, } impl Projections { @@ -61,6 +62,10 @@ impl Projections { } } + pub(crate) const fn snapshot(&self) -> Self { + Self { ..*self } + } + const fn next_alias(index: &mut usize) -> Alias { let alias = Alias { condition_index: 0, @@ -167,11 +172,6 @@ impl Projections { let mut from = base; - // entity_editions ON edition_id (INNER) - if let Some(alias) = self.entity_editions { - from = self.build_entity_editions(from, alias); - } - // entity_ids ON (web_id, entity_uuid) (INNER) if let Some(alias) = self.entity_ids { from = self.build_entity_ids(from, alias); @@ -205,6 +205,11 @@ impl Projections { from = self.build_entity_has_right_entity(from, alias); } + // CROSS JOIN LATERAL entity_editions ON edition_id (INNER) + if let Some(alias) = self.entity_editions { + from = self.build_entity_editions(from, alias); + } + // CROSS JOIN LATERALs for continuation subqueries (must come after // all regular joins since they may reference any of the joined tables) for lateral in laterals { @@ -214,22 +219,98 @@ impl Projections { from } - fn build_entity_editions<'item>(&self, from: FromItem<'item>, alias: Alias) -> FromItem<'item> { - let fk = ForeignKeyReference::Single { - on: Column::EntityTemporalMetadata(table::EntityTemporalMetadata::EditionId), - join: Column::EntityEditions(table::EntityEditions::EditionId), - join_type: JoinType::Inner, + /// Builds `entity_editions` as a LATERAL subquery with explicit column projections. + /// + /// ```sql + /// CROSS JOIN LATERAL ( + /// SELECT ee. AS , ... + /// FROM entity_editions AS ee + /// WHERE ee.edition_id = base.edition_id + /// ) AS + /// ``` + /// + /// The explicit projections let the authorization graft locate and replace + /// individual column expressions (e.g. applying a property mask to `properties`). + pub(crate) fn build_entity_editions<'item>( + &self, + from: FromItem<'item>, + alias: Alias, + ) -> FromItem<'item> { + let inner_ref = TableReference { + schema: None, + name: TableName::from("ee"), + alias: None, }; - from.join( - JoinType::Inner, - FromItem::table(Table::EntityEditions).alias(Table::EntityEditions.aliased(alias)), - ) - .on(fk.conditions(self.base_alias, alias)) - .build() + // entity_editions AS ee + let inner_from = FromItem::table(Table::EntityEditions) + .alias(inner_ref.clone()) + .build(); + + // ee.edition_id = base.edition_id + let correlation = query::Expression::equal( + query::Expression::ColumnReference(ColumnReference { + correlation: Some(inner_ref.clone()), + name: Column::EntityEditions(table::EntityEditions::EditionId).into(), + }), + query::Expression::ColumnReference(ColumnReference { + correlation: Some(self.temporal_metadata()), + name: Column::EntityTemporalMetadata(table::EntityTemporalMetadata::EditionId) + .into(), + }), + ); + + // Project every column, `ee.[property] AS [property]`, this mirrors `*`, but makes each + // column available by name. + let selects = table::EntityEditions::ALL + .into_iter() + .map(|column| SelectExpression::Expression { + expression: query::Expression::ColumnReference(ColumnReference { + correlation: Some(inner_ref.clone()), + name: Column::EntityEditions(column).into(), + }), + alias: Some(column.as_str().into()), + }) + .collect(); + + // WHERE ee.edition_id = base.edition_id + let r#where = query::WhereExpression { + conditions: vec![correlation], + cursor: Vec::new(), + }; + + // SELECT + // ee.[property] AS [property], + // ... + // FROM entity_editions as ee + // WHERE ee.edition_id = base.edition_id + let subquery = SelectStatement::builder() + .selects(selects) + .from(inner_from) + .where_expression(r#where) + .build(); + + // LATERAL (subquery) AS [alias] + let lateral = FromItem::Subquery { + lateral: true, + statement: Box::new(subquery), + alias: Some(TableReference { + schema: None, + name: TableName::from(Table::EntityEditions), + alias: Some(alias), + }), + column_alias: vec![], + }; + + // CROSS JOIN LATERAL (...) + from.cross_join(lateral) } - fn build_entity_ids<'item>(&self, from: FromItem<'item>, alias: Alias) -> FromItem<'item> { + pub(crate) fn build_entity_ids<'item>( + &self, + from: FromItem<'item>, + alias: Alias, + ) -> FromItem<'item> { let fk = ForeignKeyReference::Double { on: [ Column::EntityTemporalMetadata(table::EntityTemporalMetadata::WebId), @@ -421,3 +502,345 @@ impl Projections { .build() } } + +/// Tracks joins that authorization conditions need. +/// +/// Accessors reuse joins from the base [`Projections`] when available, falling +/// back to fresh joins compiled by [`build_joins`](Self::build_joins). +#[derive(Debug)] +pub struct AuxiliaryProjections { + index: usize, + base: Projections, + + pub entity_ids: Option, + pub entity_edition_cache: Option, +} + +impl AuxiliaryProjections { + pub(crate) const fn new(base: &Projections) -> Self { + Self { + index: base.index, + base: base.snapshot(), + entity_ids: None, + entity_edition_cache: None, + } + } + + const fn next_alias(&mut self) -> Alias { + let alias = Alias { + condition_index: 0, + chain_depth: 0, + number: self.index, + }; + self.index += 1; + alias + } + + pub(crate) const fn snapshot(&self) -> Self { + Self { + base: self.base.snapshot(), + ..*self + } + } + + pub(crate) fn temporal_metadata(&self) -> TableReference<'static> { + self.base.temporal_metadata() + } + + /// Entity-level provenance, joined on `(web_id, entity_uuid)`. + pub(crate) fn entity_ids(&mut self) -> TableReference<'static> { + let alias = if let Some(base_alias) = self.base.entity_ids { + base_alias + } else if let Some(alias) = self.entity_ids { + alias + } else { + let alias = self.next_alias(); + self.entity_ids = Some(alias); + alias + }; + + TableReference { + schema: None, + name: TableName::from(Table::EntityIds), + alias: Some(alias), + } + } + + pub(crate) const fn entity_edition_alias(&self) -> Option { + self.base.entity_editions + } + + /// Entity type assignments, joined on `entity_edition_id`. + /// + /// Always allocates a fresh join; the base projections' type aggregate + /// is a scoped LATERAL subquery and cannot be reused. + pub(crate) fn entity_edition_cache(&mut self) -> TableReference<'static> { + let alias = if let Some(alias) = self.entity_edition_cache { + alias + } else { + let alias = self.next_alias(); + self.entity_edition_cache = Some(alias); + alias + }; + + TableReference { + schema: None, + name: TableName::from(Table::EntityEditionCache), + alias: Some(alias), + } + } + + /// Appends authorization joins before the LATERAL subqueries. + /// + /// The compiled FROM tree ends with a chain of `CROSS JOIN LATERAL` nodes + /// (`entity_editions`, then continuations). Authorization joins must appear + /// before these so that the LATERAL subqueries can reference them. + /// + /// This traverses the right spine of `CrossJoin` nodes to find the + /// insertion point (the innermost non-LATERAL join tree), appends + /// authorization joins there, and reassembles the LATERAL chain on top. + pub(crate) fn build_joins(&self, mut from: FromItem<'static>) -> FromItem<'static> { + // This value has no semantic purpose, but is simply a value that we can construct in a + // constant environment and which is cheap. The value will never end up inside of the from + // clause, only when it panics, but all bets are off then anyway. + const SENTINEL: FromItem<'static> = FromItem::Table { + only: true, + table: TableReference { + schema: None, + name: TableName::from_table(table::Table::OntologyIds), + alias: None, + }, + alias: None, + column_alias: Vec::new(), + tablesample: None, + }; + + if self.entity_ids.is_none() && self.entity_edition_cache.is_none() { + return from; + } + + // Walk down the left spine of CrossJoin nodes to find the + // regular join tree underneath the LATERAL chain. + let mut inner = &mut from; + while let FromItem::CrossJoin { left, right: _ } = inner { + inner = left; + } + + // Temporarily swap the core out so we can append joins to it. + let mut core = mem::replace(inner, SENTINEL); + + if let Some(alias) = self.entity_ids { + core = self.base.build_entity_ids(core, alias); + } + + if let Some(alias) = self.entity_edition_cache { + let fk = ForeignKeyReference::Single { + on: Column::EntityTemporalMetadata(table::EntityTemporalMetadata::EditionId), + join: Column::EntityEditionCache(table::EntityEditionCache::EntityEditionId), + join_type: JoinType::Inner, + }; + + core = core + .join( + JoinType::Inner, + FromItem::table(Table::EntityEditionCache) + .alias(Table::EntityEditionCache.aliased(alias)), + ) + .on(fk.conditions(self.base.base_alias, alias)) + .build(); + } + + // Put the augmented core back; the LATERAL chain above is untouched. + *inner = core; + + from + } +} + +#[cfg(test)] +mod tests { + use alloc::alloc::Global; + use std::path::PathBuf; + + use hash_graph_postgres_store::store::postgres::query::{ + FromItem, SelectStatement, Table, TableName, TableReference, Transpile as _, + }; + use insta::{Settings, assert_snapshot}; + + use super::{AuxiliaryProjections, Projections}; + use crate::postgres::Parameters; + + fn snapshot_settings() -> Settings { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let mut settings = Settings::clone_current(); + settings + .set_snapshot_path(manifest_dir.join("tests/ui/postgres/authorization/projections")); + settings.set_prepend_module_to_snapshot(false); + settings + } + + fn make_lateral(name: &'static str) -> FromItem<'static> { + FromItem::Subquery { + lateral: true, + statement: Box::new( + SelectStatement::builder() + .selects(vec![]) + .from( + FromItem::table(Table::EntityTemporalMetadata) + .alias(TableReference { + schema: None, + name: TableName::from(name), + alias: None, + }) + .build(), + ) + .build(), + ), + alias: Some(TableReference { + schema: None, + name: TableName::from(name), + alias: None, + }), + column_alias: vec![], + } + } + + #[test] + fn build_from_base_only() { + let projections = Projections::new(); + let mut parameters = Parameters::new_in(Global); + let from = projections.build_from(&mut parameters, Vec::new()); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{projections:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!("build_from_base_only", from.transpile_to_string()); + } + + #[test] + fn build_from_with_entity_editions() { + let mut projections = Projections::new(); + projections.entity_editions(); + let mut parameters = Parameters::new_in(Global); + let from = projections.build_from(&mut parameters, Vec::new()); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{projections:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "build_from_with_entity_editions", + from.transpile_to_string() + ); + } + + #[test] + fn build_from_with_entity_ids_and_editions() { + let mut projections = Projections::new(); + projections.entity_ids(); + projections.entity_editions(); + let mut parameters = Parameters::new_in(Global); + let from = projections.build_from(&mut parameters, Vec::new()); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{projections:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "build_from_with_entity_ids_and_editions", + from.transpile_to_string(), + ); + } + + #[test] + fn build_from_editions_before_continuations() { + let mut projections = Projections::new(); + projections.entity_editions(); + let mut parameters = Parameters::new_in(Global); + + let lateral = make_lateral("continuation_1"); + let from = projections.build_from(&mut parameters, vec![lateral]); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{projections:?}, 1 continuation lateral")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "build_from_editions_before_continuations", + from.transpile_to_string(), + ); + } + + #[test] + fn build_joins_no_laterals_no_auth() { + let base = Projections::new(); + let aux = AuxiliaryProjections::new(&base); + + let from = FromItem::table(Table::EntityTemporalMetadata) + .alias(TableReference { + schema: None, + name: TableName::from(Table::EntityTemporalMetadata), + alias: Some(base.base_alias), + }) + .build(); + + let result = aux.build_joins(from.clone()); + assert_eq!( + result.transpile_to_string(), + from.transpile_to_string(), + "no auth joins requested means FROM is unchanged", + ); + } + + #[test] + fn build_joins_inserts_before_laterals() { + let base = Projections::new(); + let mut aux = AuxiliaryProjections::new(&base); + aux.entity_edition_cache(); + + let core = FromItem::table(Table::EntityTemporalMetadata) + .alias(TableReference { + schema: None, + name: TableName::from(Table::EntityTemporalMetadata), + alias: Some(base.base_alias), + }) + .build(); + + let lateral_1 = make_lateral("continuation_1"); + let lateral_2 = make_lateral("continuation_2"); + let from = core.cross_join(lateral_1).cross_join(lateral_2); + + let result = aux.build_joins(from); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{aux:?}, 2 continuation laterals")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "build_joins_inserts_before_laterals", + result.transpile_to_string(), + ); + } + + #[test] + fn build_joins_no_laterals_with_auth() { + let base = Projections::new(); + let mut aux = AuxiliaryProjections::new(&base); + aux.entity_ids(); + aux.entity_edition_cache(); + + let from = FromItem::table(Table::EntityTemporalMetadata) + .alias(TableReference { + schema: None, + name: TableName::from(Table::EntityTemporalMetadata), + alias: Some(base.base_alias), + }) + .build(); + + let result = aux.build_joins(from); + + let mut settings = snapshot_settings(); + settings.set_description(format!("{aux:?}")); + let _guard = settings.bind_to_scope(); + assert_snapshot!( + "build_joins_no_laterals_with_auth", + result.transpile_to_string(), + ); + } +} diff --git a/libs/@local/hashql/eval/src/postgres/tests.rs b/libs/@local/hashql/eval/src/postgres/tests.rs new file mode 100644 index 00000000000..63547197cfb --- /dev/null +++ b/libs/@local/hashql/eval/src/postgres/tests.rs @@ -0,0 +1,117 @@ +//! Shared test infrastructure for Postgres compiler tests. + +use hashql_core::{ + heap::{Heap, Scratch}, + pretty::Formatter, + r#type::{TypeFormatter, TypeFormatterOptions, environment::Environment}, +}; +use hashql_diagnostics::DiagnosticIssues; +use hashql_mir::{ + body::{Body, Source}, + context::MirContext, + def::{DefId, DefIdVec}, + intern::Interner, + pass::{ + GlobalAnalysisPass as _, + analysis::SizeEstimationAnalysis, + execution::{ExecutionAnalysis, ExecutionAnalysisResidual}, + }, + pretty::TextFormatOptions, +}; +use sqruff_lib::core::{config::FluffConfig, linter::core::Linter}; +use sqruff_lib_core::dialects::init::DialectKind; + +/// Compiles a single MIR filter body through execution analysis. +/// +/// Provides the analyzed body, environment, and interner needed by +/// [`PostgresCompiler`](super::PostgresCompiler) and downstream test helpers. +pub(super) struct CompilationFixture<'heap> { + pub env: Environment<'heap>, + pub interner: crate::intern::Interner<'heap>, + pub bodies: DefIdVec, &'heap Heap>, + pub execution: DefIdVec>, &'heap Heap>, +} + +impl<'heap> CompilationFixture<'heap> { + pub(super) fn new(heap: &'heap Heap, env: Environment<'heap>, body: Body<'heap>) -> Self { + assert!( + matches!(body.source, Source::GraphReadFilter(_)), + "these tests require GraphReadFilter bodies", + ); + + let interner = Interner::new(heap); + let mut scratch = Scratch::new(); + + let mut bodies = DefIdVec::new_in(heap); + bodies.push(body); + + let mut mir_context = MirContext { + heap, + env: &env, + interner: &interner, + diagnostics: DiagnosticIssues::new(), + }; + + let mut size_analysis = SizeEstimationAnalysis::new_in(&scratch); + size_analysis.run(&mut mir_context, &bodies); + let footprints = size_analysis.finish(); + + let analysis = ExecutionAnalysis { + footprints: &footprints, + scratch: &mut scratch, + }; + let execution = analysis.run_all_in(&mut mir_context, &mut bodies, heap); + + assert!( + mir_context.diagnostics.is_empty(), + "execution analysis produced diagnostics: this likely means the body is malformed", + ); + + Self { + env, + interner: interner.into(), + bodies, + execution, + } + } + + pub(super) fn def(&self) -> DefId { + self.bodies.iter().next().expect("fixture has one body").id + } +} + +/// Pretty-prints the MIR body from a compilation fixture. +pub(super) fn format_body<'heap>(fixture: &CompilationFixture<'heap>, heap: &'heap Heap) -> String { + let formatter = Formatter::new(heap); + let mut type_formatter = + TypeFormatter::new(&formatter, &fixture.env, TypeFormatterOptions::terse()); + + let mut text_format = TextFormatOptions { + writer: Vec::::new(), + indent: 4, + sources: (), + types: &mut type_formatter, + annotations: (), + } + .build(); + + let body = &fixture.bodies[fixture.def()]; + text_format.format_body(body).expect("formatting failed"); + + String::from_utf8(text_format.writer).expect("valid UTF-8") +} + +/// Lints a SQL string through sqruff with Postgres dialect. +pub(super) fn lint_sql(sql: &str) -> String { + let mut linter_config = FluffConfig::default(); + linter_config + .override_dialect(DialectKind::Postgres) + .expect("dialect should be loaded"); + let linter = Linter::new(linter_config, None, None, false).expect("linter should be created"); + + let linted = linter + .lint_string(sql, None, true) + .expect("should be valid SQL"); + + linted.fix_string() +} diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/.spec.toml b/libs/@local/hashql/eval/tests/ui/postgres/authorization/.spec.toml new file mode 100644 index 00000000000..56a01cfa866 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/.spec.toml @@ -0,0 +1,2 @@ +skip = true +suite = "eval/postgres-authorization" diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/integration/patch_blank_forbid_denies_all.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/integration/patch_blank_forbid_denies_all.snap new file mode 100644 index 00000000000..86559089d2e --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/integration/patch_blank_forbid_denies_all.snap @@ -0,0 +1,101 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/tests.rs +expression: report +--- +===================================== MIR ====================================== + +fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Boolean { + let %2: ? + let %3: ? + let %4: Boolean + + bb0(): { + %2 = %1.properties.name + %3 = input LOAD expected + %4 = %2 == %3 + + return %4 + } +} +===================================== SQL ====================================== + +SELECT + ("continuation_0_0"."row")."block" AS "continuation_0_0_block", + ("continuation_0_0"."row")."locals" AS "continuation_0_0_locals", + ("continuation_0_0"."row")."values" AS "continuation_0_0_values" +FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" +INNER JOIN "entity_edition_cache" AS "entity_edition_cache_0_0_2" + ON + "entity_edition_cache_0_0_2"."entity_edition_id" + = "entity_temporal_metadata_0_0_0"."entity_edition_id" +CROSS JOIN LATERAL ( + SELECT + "ee"."entity_edition_id" AS "entity_edition_id", + ( + "ee"."properties" + - ( + CASE + WHEN + ($5 = ANY("entity_edition_cache_0_0_2"."base_urls")) + AND ( + "entity_temporal_metadata_0_0_0"."entity_uuid" != $6 + ) + THEN ARRAY[$7]::text [] + ELSE ARRAY[]::text [] + END + ) + ) AS "properties", + "ee"."archived" AS "archived", + "ee"."confidence" AS "confidence", + "ee"."provenance" AS "provenance", + ( + "ee"."property_metadata" + - ( + CASE + WHEN + ($5 = ANY("entity_edition_cache_0_0_2"."base_urls")) + AND ( + "entity_temporal_metadata_0_0_0"."entity_uuid" != $6 + ) + THEN ARRAY[$7]::text [] + ELSE ARRAY[]::text [] + END + ) + ) AS "property_metadata" + FROM "entity_editions" AS "ee" + WHERE + "ee"."entity_edition_id" + = "entity_temporal_metadata_0_0_0"."entity_edition_id" +) AS "entity_editions_0_0_1" +CROSS JOIN LATERAL + ( + SELECT + ( + ROW( + COALESCE( + ( + (TO_JSONB(JSONB_EXTRACT_PATH("entity_editions_0_0_1"."properties", ((($3::text))::text))) = TO_JSONB(($4::jsonb)))::boolean + ), + FALSE + ), + NULL, + NULL, + NULL + )::continuation + ) AS "row" + ) AS "continuation_0_0" +WHERE + "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) + AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) + AND ("continuation_0_0"."row")."filter" IS NOT FALSE + AND FALSE + +============================= Compiled Parameters ============================== + +$1: TemporalAxis(Transaction) +$2: TemporalAxis(Decision) +$3: Symbol(name) +$4: Input(expected) +============================= Auxiliary Parameters ============================= + +AuxiliaryParameters { initial_offset: 4, parameters: ["https://hash.ai/@h/types/entity-type/user/", ActorEntityUuid(EntityUuid(aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)), "https://hash.ai/@h/types/property-type/email/"] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/integration/patch_blank_permit_no_protection.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/integration/patch_blank_permit_no_protection.snap new file mode 100644 index 00000000000..f6c559203a7 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/integration/patch_blank_permit_no_protection.snap @@ -0,0 +1,45 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/tests.rs +expression: report +--- +===================================== MIR ====================================== + +fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Boolean { + let %2: Boolean + + bb0(): { + %2 = input LOAD flag + + return %2 + } +} +===================================== SQL ====================================== + +SELECT + ("continuation_0_0"."row")."block" AS "continuation_0_0_block", + ("continuation_0_0"."row")."locals" AS "continuation_0_0_locals", + ("continuation_0_0"."row")."values" AS "continuation_0_0_values" +FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" +CROSS JOIN LATERAL + ( + SELECT + ( + ROW( + COALESCE(((($3::jsonb))::boolean), FALSE), NULL, NULL, NULL + )::continuation + ) AS "row" + ) AS "continuation_0_0" +WHERE + "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) + AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) + AND ("continuation_0_0"."row")."filter" IS NOT FALSE + AND TRUE + +============================= Compiled Parameters ============================== + +$1: TemporalAxis(Transaction) +$2: TemporalAxis(Decision) +$3: Input(flag) +============================= Auxiliary Parameters ============================= + +AuxiliaryParameters { initial_offset: 3, parameters: [] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/integration/patch_instance_admin_bypasses_protection.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/integration/patch_instance_admin_bypasses_protection.snap new file mode 100644 index 00000000000..6c237b26310 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/integration/patch_instance_admin_bypasses_protection.snap @@ -0,0 +1,71 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/tests.rs +expression: report +--- +===================================== MIR ====================================== + +fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Boolean { + let %2: ? + let %3: ? + let %4: Boolean + + bb0(): { + %2 = %1.properties.name + %3 = input LOAD expected + %4 = %2 == %3 + + return %4 + } +} +===================================== SQL ====================================== + +SELECT + ("continuation_0_0"."row")."block" AS "continuation_0_0_block", + ("continuation_0_0"."row")."locals" AS "continuation_0_0_locals", + ("continuation_0_0"."row")."values" AS "continuation_0_0_values" +FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" +CROSS JOIN LATERAL ( + SELECT + "ee"."entity_edition_id" AS "entity_edition_id", + "ee"."properties" AS "properties", + "ee"."archived" AS "archived", + "ee"."confidence" AS "confidence", + "ee"."provenance" AS "provenance", + "ee"."property_metadata" AS "property_metadata" + FROM "entity_editions" AS "ee" + WHERE + "ee"."entity_edition_id" + = "entity_temporal_metadata_0_0_0"."entity_edition_id" +) AS "entity_editions_0_0_1" +CROSS JOIN LATERAL + ( + SELECT + ( + ROW( + COALESCE( + ( + (TO_JSONB(JSONB_EXTRACT_PATH("entity_editions_0_0_1"."properties", ((($3::text))::text))) = TO_JSONB(($4::jsonb)))::boolean + ), + FALSE + ), + NULL, + NULL, + NULL + )::continuation + ) AS "row" + ) AS "continuation_0_0" +WHERE + "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) + AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) + AND ("continuation_0_0"."row")."filter" IS NOT FALSE + AND TRUE + +============================= Compiled Parameters ============================== + +$1: TemporalAxis(Transaction) +$2: TemporalAxis(Decision) +$3: Symbol(name) +$4: Input(expected) +============================= Auxiliary Parameters ============================= + +AuxiliaryParameters { initial_offset: 4, parameters: [] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/integration/patch_protection_with_type_base_urls.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/integration/patch_protection_with_type_base_urls.snap new file mode 100644 index 00000000000..789fc585325 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/integration/patch_protection_with_type_base_urls.snap @@ -0,0 +1,95 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/tests.rs +expression: report +--- +===================================== MIR ====================================== + +fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Boolean { + let %2: ? + let %3: ? + let %4: Boolean + + bb0(): { + %2 = %1.properties.name + %3 = input LOAD expected + %4 = %2 == %3 + + return %4 + } +} +===================================== SQL ====================================== + +SELECT + ("continuation_0_0"."row")."block" AS "continuation_0_0_block", + ("continuation_0_0"."row")."locals" AS "continuation_0_0_locals", + ("continuation_0_0"."row")."values" AS "continuation_0_0_values" +FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" +INNER JOIN "entity_edition_cache" AS "entity_edition_cache_0_0_2" + ON + "entity_edition_cache_0_0_2"."entity_edition_id" + = "entity_temporal_metadata_0_0_0"."entity_edition_id" +CROSS JOIN LATERAL ( + SELECT + "ee"."entity_edition_id" AS "entity_edition_id", + ( + "ee"."properties" + - ( + CASE + WHEN + $5 = ANY("entity_edition_cache_0_0_2"."base_urls") + THEN ARRAY[$6]::text [] + ELSE ARRAY[]::text [] + END + ) + ) AS "properties", + "ee"."archived" AS "archived", + "ee"."confidence" AS "confidence", + "ee"."provenance" AS "provenance", + ( + "ee"."property_metadata" + - ( + CASE + WHEN + $5 = ANY("entity_edition_cache_0_0_2"."base_urls") + THEN ARRAY[$6]::text [] + ELSE ARRAY[]::text [] + END + ) + ) AS "property_metadata" + FROM "entity_editions" AS "ee" + WHERE + "ee"."entity_edition_id" + = "entity_temporal_metadata_0_0_0"."entity_edition_id" +) AS "entity_editions_0_0_1" +CROSS JOIN LATERAL + ( + SELECT + ( + ROW( + COALESCE( + ( + (TO_JSONB(JSONB_EXTRACT_PATH("entity_editions_0_0_1"."properties", ((($3::text))::text))) = TO_JSONB(($4::jsonb)))::boolean + ), + FALSE + ), + NULL, + NULL, + NULL + )::continuation + ) AS "row" + ) AS "continuation_0_0" +WHERE + "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) + AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) + AND ("continuation_0_0"."row")."filter" IS NOT FALSE + AND TRUE + +============================= Compiled Parameters ============================== + +$1: TemporalAxis(Transaction) +$2: TemporalAxis(Decision) +$3: Symbol(name) +$4: Input(expected) +============================= Auxiliary Parameters ============================= + +AuxiliaryParameters { initial_offset: 4, parameters: ["https://hash.ai/@h/types/entity-type/user/", "https://hash.ai/@h/types/property-type/email/"] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/integration/patch_with_policy_and_protection.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/integration/patch_with_policy_and_protection.snap new file mode 100644 index 00000000000..a67704e7712 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/integration/patch_with_policy_and_protection.snap @@ -0,0 +1,108 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/tests.rs +expression: report +--- +===================================== MIR ====================================== + +fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Boolean { + let %2: ? + let %3: ? + let %4: Boolean + + bb0(): { + %2 = %1.properties.name + %3 = input LOAD expected + %4 = %2 == %3 + + return %4 + } +} +===================================== SQL ====================================== + +SELECT + ("continuation_0_0"."row")."block" AS "continuation_0_0_block", + ("continuation_0_0"."row")."locals" AS "continuation_0_0_locals", + ("continuation_0_0"."row")."values" AS "continuation_0_0_values" +FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" +INNER JOIN "entity_edition_cache" AS "entity_edition_cache_0_0_2" + ON + "entity_edition_cache_0_0_2"."entity_edition_id" + = "entity_temporal_metadata_0_0_0"."entity_edition_id" +CROSS JOIN LATERAL ( + SELECT + "ee"."entity_edition_id" AS "entity_edition_id", + ( + "ee"."properties" + - ( + CASE + WHEN + "entity_temporal_metadata_0_0_0"."entity_uuid" = $9 + THEN ARRAY[$10]::text [] + ELSE ARRAY[]::text [] + END + ) + ) AS "properties", + "ee"."archived" AS "archived", + "ee"."confidence" AS "confidence", + "ee"."provenance" AS "provenance", + ( + "ee"."property_metadata" + - ( + CASE + WHEN + "entity_temporal_metadata_0_0_0"."entity_uuid" = $9 + THEN ARRAY[$10]::text [] + ELSE ARRAY[]::text [] + END + ) + ) AS "property_metadata" + FROM "entity_editions" AS "ee" + WHERE + "ee"."entity_edition_id" + = "entity_temporal_metadata_0_0_0"."entity_edition_id" +) AS "entity_editions_0_0_1" +CROSS JOIN LATERAL + ( + SELECT + ( + ROW( + COALESCE( + ( + (TO_JSONB(JSONB_EXTRACT_PATH("entity_editions_0_0_1"."properties", ((($3::text))::text))) = TO_JSONB(($4::jsonb)))::boolean + ), + FALSE + ), + NULL, + NULL, + NULL + )::continuation + ) AS "row" + ) AS "continuation_0_0" +WHERE + "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) + AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) + AND ("continuation_0_0"."row")."filter" IS NOT FALSE + AND ( + ( + ("entity_temporal_metadata_0_0_0"."entity_uuid" = $5) + OR ("entity_temporal_metadata_0_0_0"."web_id" = $6) + ) + ) + AND ( + NOT ( + ( + ARRAY_POSITIONS("entity_edition_cache_0_0_2"."base_urls", $7) + && ARRAY_POSITIONS("entity_edition_cache_0_0_2"."versions", $8) + ) + ) + ) + +============================= Compiled Parameters ============================== + +$1: TemporalAxis(Transaction) +$2: TemporalAxis(Decision) +$3: Symbol(name) +$4: Input(expected) +============================= Auxiliary Parameters ============================= + +AuxiliaryParameters { initial_offset: 4, parameters: [EntityUuid(11111111-1111-1111-1111-111111111111), WebId(ActorGroupEntityUuid(EntityUuid(33333333-3333-3333-3333-333333333333))), "https://hash.ai/@h/types/entity-type/restricted/", OntologyTypeVersion { major: 1, pre_release: None }, ActorEntityUuid(EntityUuid(aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)), "https://hash.ai/@h/types/property-type/email/"] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/algebra_blank_permit_with_forbids.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/algebra_blank_permit_with_forbids.snap new file mode 100644 index 00000000000..668e6126858 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/algebra_blank_permit_with_forbids.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "[ResolvedPolicy { original_policy_id: PolicyId(bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb), effect: Permit, actions: [ViewEntity], resource: None }, ResolvedPolicy { original_policy_id: PolicyId(cccccccc-cccc-cccc-cccc-cccccccccccc), effect: Forbid, actions: [ViewEntity], resource: Some(Web { web_id: WebId(ActorGroupEntityUuid(EntityUuid(33333333-3333-3333-3333-333333333333))) }) }]" +expression: "snapshot_with_params(&result.condition.transpile_to_string(),\n&fixture.parameters)" +--- +NOT(("entity_temporal_metadata_0_0_0"."web_id" = $1)) + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [WebId(ActorGroupEntityUuid(EntityUuid(33333333-3333-3333-3333-333333333333)))] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/algebra_constrained_permits_only.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/algebra_constrained_permits_only.snap new file mode 100644 index 00000000000..f016bf06555 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/algebra_constrained_permits_only.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "[ResolvedPolicy { original_policy_id: PolicyId(bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb), effect: Permit, actions: [ViewEntity], resource: Some(Entity(Exact { id: EntityUuid(11111111-1111-1111-1111-111111111111) })) }, ResolvedPolicy { original_policy_id: PolicyId(bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb), effect: Permit, actions: [ViewEntity], resource: Some(Web { web_id: WebId(ActorGroupEntityUuid(EntityUuid(33333333-3333-3333-3333-333333333333))) }) }]" +expression: "snapshot_with_params(&result.condition.transpile_to_string(),\n&fixture.parameters)" +--- +(("entity_temporal_metadata_0_0_0"."entity_uuid" = $1) OR ("entity_temporal_metadata_0_0_0"."web_id" = $2)) + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [EntityUuid(11111111-1111-1111-1111-111111111111), WebId(ActorGroupEntityUuid(EntityUuid(33333333-3333-3333-3333-333333333333)))] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/algebra_permits_and_forbids.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/algebra_permits_and_forbids.snap new file mode 100644 index 00000000000..b24727f97b3 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/algebra_permits_and_forbids.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "[ResolvedPolicy { original_policy_id: PolicyId(bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb), effect: Permit, actions: [ViewEntity], resource: Some(Entity(Exact { id: EntityUuid(11111111-1111-1111-1111-111111111111) })) }, ResolvedPolicy { original_policy_id: PolicyId(cccccccc-cccc-cccc-cccc-cccccccccccc), effect: Forbid, actions: [ViewEntity], resource: Some(Entity(Any { filter: IsOfType { entity_type: VersionedUrl { base_url: \"https://hash.ai/@h/types/entity-type/user/\", version: OntologyTypeVersion { major: 1, pre_release: None } } } })) }]" +expression: "snapshot_with_params(&result.condition.transpile_to_string(),\n&fixture.parameters)" +--- +(("entity_temporal_metadata_0_0_0"."entity_uuid" = $1)) AND (NOT((array_positions("entity_edition_cache_0_0_1"."base_urls", $2) && array_positions("entity_edition_cache_0_0_1"."versions", $3)))) + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [EntityUuid(11111111-1111-1111-1111-111111111111), "https://hash.ai/@h/types/entity-type/user/", OntologyTypeVersion { major: 1, pre_release: None }] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/constraint_any_with_type_filter.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/constraint_any_with_type_filter.snap new file mode 100644 index 00000000000..8f3a417c77a --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/constraint_any_with_type_filter.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "Entity(Any { filter: IsOfType { entity_type: VersionedUrl { base_url: \"https://hash.ai/@h/types/entity-type/user/\", version: OntologyTypeVersion { major: 1, pre_release: None } } } })" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +array_positions("entity_edition_cache_0_0_1"."base_urls", $1) && array_positions("entity_edition_cache_0_0_1"."versions", $2) + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: ["https://hash.ai/@h/types/entity-type/user/", OntologyTypeVersion { major: 1, pre_release: None }] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/constraint_exact_entity.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/constraint_exact_entity.snap new file mode 100644 index 00000000000..c832b6ae1c2 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/constraint_exact_entity.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "Entity(Exact { id: EntityUuid(11111111-1111-1111-1111-111111111111) })" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +"entity_temporal_metadata_0_0_0"."entity_uuid" = $1 + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [EntityUuid(11111111-1111-1111-1111-111111111111)] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/constraint_web.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/constraint_web.snap new file mode 100644 index 00000000000..797105acb8b --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/constraint_web.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "Web { web_id: WebId(ActorGroupEntityUuid(EntityUuid(33333333-3333-3333-3333-333333333333))) }" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +"entity_temporal_metadata_0_0_0"."web_id" = $1 + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [WebId(ActorGroupEntityUuid(EntityUuid(33333333-3333-3333-3333-333333333333)))] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/constraint_web_with_created_by.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/constraint_web_with_created_by.snap new file mode 100644 index 00000000000..41544a4e983 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/constraint_web_with_created_by.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "Entity(Web { web_id: WebId(ActorGroupEntityUuid(EntityUuid(33333333-3333-3333-3333-333333333333))), filter: CreatedByPrincipal })" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +("entity_temporal_metadata_0_0_0"."web_id" = $1) AND ("entity_ids_0_0_1"."provenance"->>'createdById' = ($2::text)) + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [WebId(ActorGroupEntityUuid(EntityUuid(33333333-3333-3333-3333-333333333333))), ActorEntityUuid(EntityUuid(aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa))] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/created_by_principal_anonymous.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/created_by_principal_anonymous.snap new file mode 100644 index 00000000000..b6fb3fcfbc6 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/created_by_principal_anonymous.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "CreatedByPrincipal, actor = None" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +"entity_ids_0_0_1"."provenance"->>'createdById' = ($1::text) + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [ActorEntityUuid(EntityUuid(00000000-0000-0000-0000-000000000000))] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/created_by_principal_with_actor.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/created_by_principal_with_actor.snap new file mode 100644 index 00000000000..350cce15c0a --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/created_by_principal_with_actor.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "CreatedByPrincipal, actor = Some(User(UserId(ActorEntityUuid(EntityUuid(aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)))))" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +"entity_ids_0_0_1"."provenance"->>'createdById' = ($1::text) + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [ActorEntityUuid(EntityUuid(aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa))] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/filter_all_conjunction.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/filter_all_conjunction.snap new file mode 100644 index 00000000000..a00a83a6bd8 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/filter_all_conjunction.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "All { filters: [CreatedByPrincipal, IsOfBaseType { entity_type: \"https://hash.ai/@h/types/entity-type/user/\" }] }" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +("entity_ids_0_0_1"."provenance"->>'createdById' = ($1::text)) AND ($2 = ANY("entity_edition_cache_0_0_2"."base_urls")) + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [ActorEntityUuid(EntityUuid(aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)), "https://hash.ai/@h/types/entity-type/user/"] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/filter_any_disjunction.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/filter_any_disjunction.snap new file mode 100644 index 00000000000..eebdfa883b8 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/filter_any_disjunction.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "Any { filters: [IsOfType { entity_type: VersionedUrl { base_url: \"https://hash.ai/@h/types/entity-type/user/\", version: OntologyTypeVersion { major: 1, pre_release: None } } }, IsOfType { entity_type: VersionedUrl { base_url: \"https://hash.ai/@h/types/entity-type/machine/\", version: OntologyTypeVersion { major: 2, pre_release: None } } }] }" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +((array_positions("entity_edition_cache_0_0_1"."base_urls", $1) && array_positions("entity_edition_cache_0_0_1"."versions", $2)) OR (array_positions("entity_edition_cache_0_0_1"."base_urls", $3) && array_positions("entity_edition_cache_0_0_1"."versions", $4))) + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: ["https://hash.ai/@h/types/entity-type/user/", OntologyTypeVersion { major: 1, pre_release: None }, "https://hash.ai/@h/types/entity-type/machine/", OntologyTypeVersion { major: 2, pre_release: None }] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/filter_not_negation.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/filter_not_negation.snap new file mode 100644 index 00000000000..137a47725c8 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/filter_not_negation.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "Not { filter: CreatedByPrincipal }" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +NOT("entity_ids_0_0_1"."provenance"->>'createdById' = ($1::text)) + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [ActorEntityUuid(EntityUuid(aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa))] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/is_of_base_type_any.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/is_of_base_type_any.snap new file mode 100644 index 00000000000..a06819e9a0d --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/is_of_base_type_any.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "\"https://hash.ai/@h/types/entity-type/machine/\"" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +$1 = ANY("entity_edition_cache_0_0_1"."base_urls") + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: ["https://hash.ai/@h/types/entity-type/machine/"] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/is_of_type_overlap.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/is_of_type_overlap.snap new file mode 100644 index 00000000000..be7b4936932 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/is_of_type_overlap.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "VersionedUrl { base_url: \"https://hash.ai/@h/types/entity-type/machine/\", version: OntologyTypeVersion { major: 1, pre_release: None } }" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +array_positions("entity_edition_cache_0_0_1"."base_urls", $1) && array_positions("entity_edition_cache_0_0_1"."versions", $2) + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: ["https://hash.ai/@h/types/entity-type/machine/", OntologyTypeVersion { major: 1, pre_release: None }] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/optimize_multiple_entities.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/optimize_multiple_entities.snap new file mode 100644 index 00000000000..d92d941c87f --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/optimize_multiple_entities.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "OptimizationData { permitted_entity_uuids: [EntityUuid(11111111-1111-1111-1111-111111111111), EntityUuid(22222222-2222-2222-2222-222222222222)], permitted_entity_type_uuids: [], permitted_property_type_uuids: [], permitted_data_type_uuids: [], permitted_web_ids: [] }" +expression: "snapshot_with_params(&expr[0].transpile_to_string(), &fixture.parameters)" +--- +"entity_temporal_metadata_0_0_0"."entity_uuid" = ANY(($1::uuid[])) + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [[EntityUuid(11111111-1111-1111-1111-111111111111), EntityUuid(22222222-2222-2222-2222-222222222222)]] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/optimize_multiple_webs.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/optimize_multiple_webs.snap new file mode 100644 index 00000000000..85d10ebd442 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/optimize_multiple_webs.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "OptimizationData { permitted_entity_uuids: [], permitted_entity_type_uuids: [], permitted_property_type_uuids: [], permitted_data_type_uuids: [], permitted_web_ids: [WebId(ActorGroupEntityUuid(EntityUuid(33333333-3333-3333-3333-333333333333))), WebId(ActorGroupEntityUuid(EntityUuid(22222222-2222-2222-2222-222222222222)))] }" +expression: "snapshot_with_params(&expr[0].transpile_to_string(), &fixture.parameters)" +--- +"entity_temporal_metadata_0_0_0"."web_id" = ANY(($1::uuid[])) + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [[WebId(ActorGroupEntityUuid(EntityUuid(33333333-3333-3333-3333-333333333333))), WebId(ActorGroupEntityUuid(EntityUuid(22222222-2222-2222-2222-222222222222)))]] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/optimize_single_entity.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/optimize_single_entity.snap new file mode 100644 index 00000000000..51f5789b8f5 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/optimize_single_entity.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "OptimizationData { permitted_entity_uuids: [EntityUuid(11111111-1111-1111-1111-111111111111)], permitted_entity_type_uuids: [], permitted_property_type_uuids: [], permitted_data_type_uuids: [], permitted_web_ids: [] }" +expression: "snapshot_with_params(&expr[0].transpile_to_string(), &fixture.parameters)" +--- +"entity_temporal_metadata_0_0_0"."entity_uuid" = $1 + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [EntityUuid(11111111-1111-1111-1111-111111111111)] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/optimize_single_web.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/optimize_single_web.snap new file mode 100644 index 00000000000..a9004160a74 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/policy/optimize_single_web.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/policy/tests.rs +description: "OptimizationData { permitted_entity_uuids: [], permitted_entity_type_uuids: [], permitted_property_type_uuids: [], permitted_data_type_uuids: [], permitted_web_ids: [WebId(ActorGroupEntityUuid(EntityUuid(33333333-3333-3333-3333-333333333333)))] }" +expression: "snapshot_with_params(&expr[0].transpile_to_string(), &fixture.parameters)" +--- +"entity_temporal_metadata_0_0_0"."web_id" = $1 + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [WebId(ActorGroupEntityUuid(EntityUuid(33333333-3333-3333-3333-333333333333)))] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_from_base_only.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_from_base_only.snap new file mode 100644 index 00000000000..f146840e1d3 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_from_base_only.snap @@ -0,0 +1,6 @@ +--- +source: libs/@local/hashql/eval/src/postgres/projections.rs +description: "Projections { index: 1, base_alias: Alias { condition_index: 0, chain_depth: 0, number: 0 }, entity_editions: None, entity_ids: None, entity_type_ids: None, left: None, right: None }" +expression: from.transpile_to_string() +--- +"entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_from_editions_before_continuations.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_from_editions_before_continuations.snap new file mode 100644 index 00000000000..db43804e947 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_from_editions_before_continuations.snap @@ -0,0 +1,11 @@ +--- +source: libs/@local/hashql/eval/src/postgres/projections.rs +description: "Projections { index: 2, base_alias: Alias { condition_index: 0, chain_depth: 0, number: 0 }, entity_editions: Some(Alias { condition_index: 0, chain_depth: 0, number: 1 }), entity_ids: None, entity_type_ids: None, left: None, right: None }, 1 continuation lateral" +expression: from.transpile_to_string() +--- +"entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" +CROSS JOIN LATERAL (SELECT "ee"."entity_edition_id" AS "entity_edition_id", "ee"."properties" AS "properties", "ee"."archived" AS "archived", "ee"."confidence" AS "confidence", "ee"."provenance" AS "provenance", "ee"."property_metadata" AS "property_metadata" +FROM "entity_editions" AS "ee" +WHERE "ee"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id") AS "entity_editions_0_0_1" +CROSS JOIN LATERAL (SELECT +FROM "entity_temporal_metadata" AS "continuation_1") AS "continuation_1" diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_from_with_entity_editions.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_from_with_entity_editions.snap new file mode 100644 index 00000000000..a639fd48197 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_from_with_entity_editions.snap @@ -0,0 +1,9 @@ +--- +source: libs/@local/hashql/eval/src/postgres/projections.rs +description: "Projections { index: 2, base_alias: Alias { condition_index: 0, chain_depth: 0, number: 0 }, entity_editions: Some(Alias { condition_index: 0, chain_depth: 0, number: 1 }), entity_ids: None, entity_type_ids: None, left: None, right: None }" +expression: from.transpile_to_string() +--- +"entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" +CROSS JOIN LATERAL (SELECT "ee"."entity_edition_id" AS "entity_edition_id", "ee"."properties" AS "properties", "ee"."archived" AS "archived", "ee"."confidence" AS "confidence", "ee"."provenance" AS "provenance", "ee"."property_metadata" AS "property_metadata" +FROM "entity_editions" AS "ee" +WHERE "ee"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id") AS "entity_editions_0_0_1" diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_from_with_entity_ids_and_editions.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_from_with_entity_ids_and_editions.snap new file mode 100644 index 00000000000..99d7138658f --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_from_with_entity_ids_and_editions.snap @@ -0,0 +1,12 @@ +--- +source: libs/@local/hashql/eval/src/postgres/projections.rs +description: "Projections { index: 3, base_alias: Alias { condition_index: 0, chain_depth: 0, number: 0 }, entity_editions: Some(Alias { condition_index: 0, chain_depth: 0, number: 2 }), entity_ids: Some(Alias { condition_index: 0, chain_depth: 0, number: 1 }), entity_type_ids: None, left: None, right: None }" +expression: from.transpile_to_string() +--- +"entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" +INNER JOIN "entity_ids" AS "entity_ids_0_0_1" + ON "entity_ids_0_0_1"."web_id" = "entity_temporal_metadata_0_0_0"."web_id" + AND "entity_ids_0_0_1"."entity_uuid" = "entity_temporal_metadata_0_0_0"."entity_uuid" +CROSS JOIN LATERAL (SELECT "ee"."entity_edition_id" AS "entity_edition_id", "ee"."properties" AS "properties", "ee"."archived" AS "archived", "ee"."confidence" AS "confidence", "ee"."provenance" AS "provenance", "ee"."property_metadata" AS "property_metadata" +FROM "entity_editions" AS "ee" +WHERE "ee"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id") AS "entity_editions_0_0_2" diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_joins_inserts_before_laterals.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_joins_inserts_before_laterals.snap new file mode 100644 index 00000000000..2e4773da3f3 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_joins_inserts_before_laterals.snap @@ -0,0 +1,12 @@ +--- +source: libs/@local/hashql/eval/src/postgres/projections.rs +description: "AuxiliaryProjections { index: 2, base: Projections { index: 1, base_alias: Alias { condition_index: 0, chain_depth: 0, number: 0 }, entity_editions: None, entity_ids: None, entity_type_ids: None, left: None, right: None }, entity_ids: None, entity_edition_cache: Some(Alias { condition_index: 0, chain_depth: 0, number: 1 }) }, 2 continuation laterals" +expression: result.transpile_to_string() +--- +"entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" +INNER JOIN "entity_edition_cache" AS "entity_edition_cache_0_0_1" + ON "entity_edition_cache_0_0_1"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" +CROSS JOIN LATERAL (SELECT +FROM "entity_temporal_metadata" AS "continuation_1") AS "continuation_1" +CROSS JOIN LATERAL (SELECT +FROM "entity_temporal_metadata" AS "continuation_2") AS "continuation_2" diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_joins_no_laterals_with_auth.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_joins_no_laterals_with_auth.snap new file mode 100644 index 00000000000..3e10be55d53 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/projections/build_joins_no_laterals_with_auth.snap @@ -0,0 +1,11 @@ +--- +source: libs/@local/hashql/eval/src/postgres/projections.rs +description: "AuxiliaryProjections { index: 3, base: Projections { index: 1, base_alias: Alias { condition_index: 0, chain_depth: 0, number: 0 }, entity_editions: None, entity_ids: None, entity_type_ids: None, left: None, right: None }, entity_ids: Some(Alias { condition_index: 0, chain_depth: 0, number: 1 }), entity_edition_cache: Some(Alias { condition_index: 0, chain_depth: 0, number: 2 }) }" +expression: result.transpile_to_string() +--- +"entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" +INNER JOIN "entity_ids" AS "entity_ids_0_0_1" + ON "entity_ids_0_0_1"."web_id" = "entity_temporal_metadata_0_0_0"."web_id" + AND "entity_ids_0_0_1"."entity_uuid" = "entity_temporal_metadata_0_0_0"."entity_uuid" +INNER JOIN "entity_edition_cache" AS "entity_edition_cache_0_0_2" + ON "entity_edition_cache_0_0_2"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_filter_any_disjunction.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_filter_any_disjunction.snap new file mode 100644 index 00000000000..bb94a356c73 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_filter_any_disjunction.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/protection/tests.rs +description: "Any([Equal(Path { path: Uuid }, ActorId), In(Parameter { parameter: Text(\"https://hash.ai/@h/types/entity-type/user/\") }, Path { path: TypeBaseUrls })])" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +(("entity_temporal_metadata_0_0_0"."entity_uuid" = $1) OR ($2 = ANY("entity_edition_cache_0_0_1"."base_urls"))) + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [ActorEntityUuid(EntityUuid(aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)), "https://hash.ai/@h/types/entity-type/user/"] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_filter_equal.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_filter_equal.snap new file mode 100644 index 00000000000..9e488176338 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_filter_equal.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/protection/tests.rs +description: "Equal(Path { path: Uuid }, ActorId)" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +"entity_temporal_metadata_0_0_0"."entity_uuid" = $1 + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [ActorEntityUuid(EntityUuid(aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa))] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_filter_in.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_filter_in.snap new file mode 100644 index 00000000000..9914b7df3a8 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_filter_in.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/protection/tests.rs +description: "In(Parameter { parameter: Text(\"https://hash.ai/@h/types/entity-type/user/\") }, Path { path: TypeBaseUrls })" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +$1 = ANY("entity_edition_cache_0_0_1"."base_urls") + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: ["https://hash.ai/@h/types/entity-type/user/"] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_filter_nested_all.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_filter_nested_all.snap new file mode 100644 index 00000000000..49715021218 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_filter_nested_all.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/protection/tests.rs +description: "All([In(Parameter { parameter: Text(\"https://hash.ai/@h/types/entity-type/user/\") }, Path { path: TypeBaseUrls }), NotEqual(Path { path: Uuid }, ActorId)])" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +($1 = ANY("entity_edition_cache_0_0_1"."base_urls")) AND ("entity_temporal_metadata_0_0_0"."entity_uuid" != $2) + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: ["https://hash.ai/@h/types/entity-type/user/", ActorEntityUuid(EntityUuid(aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa))] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_filter_not_equal.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_filter_not_equal.snap new file mode 100644 index 00000000000..33afb9e3d1d --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_filter_not_equal.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/protection/tests.rs +description: "NotEqual(Path { path: Uuid }, ActorId)" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +"entity_temporal_metadata_0_0_0"."entity_uuid" != $1 + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [ActorEntityUuid(EntityUuid(aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa))] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_property_filter_case_when.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_property_filter_case_when.snap new file mode 100644 index 00000000000..1d19d19bf9f --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/lower_property_filter_case_when.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/protection/tests.rs +description: "property: email/, filter: Equal(Path { path: Uuid }, ActorId)" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +CASE WHEN "entity_temporal_metadata_0_0_0"."entity_uuid" = $1 THEN ARRAY[$2]::text[] ELSE ARRAY[]::text[] END + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [ActorEntityUuid(EntityUuid(aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)), "https://hash.ai/@h/types/property-type/email/"] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/resolve_expression_actor_id.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/resolve_expression_actor_id.snap new file mode 100644 index 00000000000..1efe1bae591 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/resolve_expression_actor_id.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/protection/tests.rs +description: "ActorId, actor = Some(User(UserId(ActorEntityUuid(EntityUuid(aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)))))" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +$1 + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [ActorEntityUuid(EntityUuid(aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa))] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/resolve_expression_text.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/resolve_expression_text.snap new file mode 100644 index 00000000000..1aba2af6940 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/resolve_expression_text.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/protection/tests.rs +description: "Parameter { parameter: Text(\"hello\") }" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +$1 + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: ["hello"] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/resolve_path_type_base_urls.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/resolve_path_type_base_urls.snap new file mode 100644 index 00000000000..0af762fd121 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/resolve_path_type_base_urls.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/protection/tests.rs +description: TypeBaseUrls +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +"entity_edition_cache_0_0_1"."base_urls" + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/resolve_path_uuid.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/resolve_path_uuid.snap new file mode 100644 index 00000000000..545e38dda58 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/resolve_path_uuid.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/protection/tests.rs +description: Uuid +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +"entity_temporal_metadata_0_0_0"."entity_uuid" + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: [] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/transpile_hash_default.snap b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/transpile_hash_default.snap new file mode 100644 index 00000000000..781e2410db0 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/authorization/protection/transpile_hash_default.snap @@ -0,0 +1,8 @@ +--- +source: libs/@local/hashql/eval/src/postgres/authorization/protection/tests.rs +description: "PropertyProtectionFilterConfig { property_filters: {\"https://hash.ai/@h/types/property-type/email/\": All([In(Parameter { parameter: Text(\"https://hash.ai/@h/types/entity-type/user/\") }, Path { path: TypeBaseUrls }), NotEqual(Path { path: Uuid }, ActorId)])}, embedding_exclusions: {\"https://hash.ai/@h/types/entity-type/user/\": [\"https://hash.ai/@h/types/property-type/email/\"]} }" +expression: "snapshot_with_params(&expr.transpile_to_string(), &fixture.parameters)" +--- +(CASE WHEN ($1 = ANY("entity_edition_cache_0_0_1"."base_urls")) AND ("entity_temporal_metadata_0_0_0"."entity_uuid" != $2) THEN ARRAY[$3]::text[] ELSE ARRAY[]::text[] END) + +parameters: AuxiliaryParameters { initial_offset: 0, parameters: ["https://hash.ai/@h/types/entity-type/user/", ActorEntityUuid(EntityUuid(aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)), "https://hash.ai/@h/types/property-type/email/"] } diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/data_island_provides_without_lateral.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/data_island_provides_without_lateral.snap index 5a1e06110e6..ac9c3672871 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/data_island_provides_without_lateral.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/data_island_provides_without_lateral.snap @@ -97,10 +97,6 @@ SELECT "entity_has_left_entity_0_0_4"."provenance" AS "left_entity_provenance", "entity_has_right_entity_0_0_5"."provenance" AS "right_entity_provenance" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -INNER JOIN "entity_editions" AS "entity_editions_0_0_1" - ON - "entity_editions_0_0_1"."entity_edition_id" - = "entity_temporal_metadata_0_0_0"."entity_edition_id" INNER JOIN "entity_ids" AS "entity_ids_0_0_3" ON "entity_ids_0_0_3"."web_id" = "entity_temporal_metadata_0_0_0"."web_id" @@ -134,6 +130,19 @@ LEFT OUTER JOIN "entity_has_right_entity" AS "entity_has_right_entity_0_0_5" = "entity_temporal_metadata_0_0_0"."web_id" AND "entity_has_right_entity_0_0_5"."entity_uuid" = "entity_temporal_metadata_0_0_0"."entity_uuid" +CROSS JOIN LATERAL ( + SELECT + "ee"."entity_edition_id" AS "entity_edition_id", + "ee"."properties" AS "properties", + "ee"."archived" AS "archived", + "ee"."confidence" AS "confidence", + "ee"."provenance" AS "provenance", + "ee"."property_metadata" AS "property_metadata" + FROM "entity_editions" AS "ee" + WHERE + "ee"."entity_edition_id" + = "entity_temporal_metadata_0_0_0"."entity_edition_id" +) AS "entity_editions_0_0_1" WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/provides_drives_select_and_joins.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/provides_drives_select_and_joins.snap index a918721f4c9..47ebe5203b7 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/provides_drives_select_and_joins.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/provides_drives_select_and_joins.snap @@ -99,10 +99,6 @@ SELECT "entity_has_left_entity_0_0_4"."provenance" AS "left_entity_provenance", "entity_has_right_entity_0_0_5"."provenance" AS "right_entity_provenance" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -INNER JOIN "entity_editions" AS "entity_editions_0_0_1" - ON - "entity_editions_0_0_1"."entity_edition_id" - = "entity_temporal_metadata_0_0_0"."entity_edition_id" INNER JOIN "entity_ids" AS "entity_ids_0_0_3" ON "entity_ids_0_0_3"."web_id" = "entity_temporal_metadata_0_0_0"."web_id" @@ -136,6 +132,19 @@ LEFT OUTER JOIN "entity_has_right_entity" AS "entity_has_right_entity_0_0_5" = "entity_temporal_metadata_0_0_0"."web_id" AND "entity_has_right_entity_0_0_5"."entity_uuid" = "entity_temporal_metadata_0_0_0"."entity_uuid" +CROSS JOIN LATERAL ( + SELECT + "ee"."entity_edition_id" AS "entity_edition_id", + "ee"."properties" AS "properties", + "ee"."archived" AS "archived", + "ee"."confidence" AS "confidence", + "ee"."provenance" AS "provenance", + "ee"."property_metadata" AS "property_metadata" + FROM "entity_editions" AS "ee" + WHERE + "ee"."entity_edition_id" + = "entity_temporal_metadata_0_0_0"."entity_edition_id" +) AS "entity_editions_0_0_1" WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) diff --git a/tests/graph/benches/graph/scenario/runner.rs b/tests/graph/benches/graph/scenario/runner.rs index b084b6524d0..70ddc59a63b 100644 --- a/tests/graph/benches/graph/scenario/runner.rs +++ b/tests/graph/benches/graph/scenario/runner.rs @@ -1,4 +1,5 @@ extern crate alloc; +use alloc::sync::Arc; use core::sync::atomic::{self, AtomicUsize}; use std::{collections::HashMap, fs::File, io::BufReader, path::Path}; @@ -216,7 +217,7 @@ impl Runner { PostgresStoreSettings { validate_links: true, skip_embedding_creation: true, - filter_protection: PropertyProtectionFilterConfig::new(), // Disabled for benchmarks + filter_protection: Arc::new(PropertyProtectionFilterConfig::new()), // Disabled for benchmarks }, ) .await diff --git a/tests/graph/http/tests/hashql.http b/tests/graph/http/tests/hashql.http index 5dfe5041548..d403802c77e 100644 --- a/tests/graph/http/tests/hashql.http +++ b/tests/graph/http/tests/hashql.http @@ -38,9 +38,74 @@ X-Authenticated-User-Actor-Id: {{system_machine_id}} client.global.set("user_id", response.body.userId); %} +### HashQL: missing auth header returns 400 +POST http://127.0.0.1:4000/hashql +Content-Type: application/json + +{ + "query": ["#literal", 1], + "inputs": [] +} + +> {% + client.test("status", function() { + client.assert(response.status === 400, "Expected 400 for missing auth header"); + }); + client.test("mentions header", function() { + client.assert(response.body.includes("X-Authenticated-User-Actor-Id"), + "Expected response to mention missing header name"); + }); +%} + +### HashQL: invalid actor returns 400 with diagnostic +POST http://127.0.0.1:4000/hashql +Content-Type: application/json +X-Authenticated-User-Actor-Id: ffffffff-ffff-ffff-ffff-ffffffffffff + +{ + "query": ["let", "axes", + ["::graph::temporal::PinnedTransactionTimeTemporalAxes", + {"#struct": { + "pinned": ["::graph::temporal::TransactionTime", + ["::graph::temporal::Timestamp", {"#literal": 4102444800000}] + ], + "variable": ["::graph::temporal::DecisionTime", + ["::graph::temporal::Interval", {"#struct": { + "start": ["::graph::temporal::UnboundedTemporalBound"], + "end": ["::graph::temporal::ExclusiveTemporalBound", + ["::graph::temporal::Timestamp", {"#literal": 4102444800000}] + ] + }}] + ] + }} + ], + ["::graph::tail::collect", + ["::graph::body::filter", + ["::graph::head::entities", "axes"], + ["fn", {"#tuple": []}, {"#struct": {"vertex": "_"}}, "_", + {"#literal": false} + ] + ] + ] + ], + "inputs": [] +} + +> {% + client.test("status", function() { + client.assert(response.status === 400, "Expected 400 for invalid actor"); + }); + client.test("has diagnostic", function() { + client.assert(response.body.primary !== undefined, "Expected primary diagnostic"); + client.assert(response.body.primary.labels.labels[0].message.includes("does not exist"), + "Expected diagnostic to mention actor does not exist"); + }); +%} + ### HashQL: filter-false returns empty list POST http://127.0.0.1:4000/hashql Content-Type: application/json +X-Authenticated-User-Actor-Id: {{user_id}} { "query": ["let", "axes", @@ -86,6 +151,7 @@ Content-Type: application/json ### HashQL: parse error returns 400 with diagnostics POST http://127.0.0.1:4000/hashql Content-Type: application/json +X-Authenticated-User-Actor-Id: {{user_id}} { "query": {"not": "a valid query"}, @@ -105,6 +171,7 @@ Content-Type: application/json ### HashQL: missing inputs field returns 400 POST http://127.0.0.1:4000/hashql Content-Type: application/json +X-Authenticated-User-Actor-Id: {{user_id}} { "query": {"#literal": 1} @@ -124,6 +191,7 @@ Content-Type: application/json ### HashQL: json-compat wraps result in envelope with advisories POST http://127.0.0.1:4000/hashql Content-Type: application/json +X-Authenticated-User-Actor-Id: {{user_id}} Json-Compat: true { @@ -170,6 +238,7 @@ Json-Compat: true ### HashQL: interactive header returns HTML on error POST http://127.0.0.1:4000/hashql Content-Type: application/json +X-Authenticated-User-Actor-Id: {{user_id}} Interactive: true { diff --git a/tests/graph/integration/postgres/email_filter_protection.rs b/tests/graph/integration/postgres/email_filter_protection.rs index 6e17b025d40..b455a22795a 100644 --- a/tests/graph/integration/postgres/email_filter_protection.rs +++ b/tests/graph/integration/postgres/email_filter_protection.rs @@ -17,7 +17,7 @@ //! //! The tests below verify each case from the truth tables in the protection module. -use alloc::borrow::Cow; +use alloc::{borrow::Cow, sync::Arc}; use std::collections::HashSet; use hash_graph_postgres_store::store::PostgresStoreSettings; @@ -26,20 +26,16 @@ use hash_graph_store::{ CountEntitiesParams, CreateEntityParams, EntityQueryPath, EntityQuerySorting, EntityQuerySortingRecord, EntityStore as _, QueryEntitiesParams, QueryEntitySubgraphParams, }, - entity_type::EntityTypeQueryPath, filter::{ Filter, FilterExpression, JsonPath, Parameter, PathToken, protection::{ - PropertyFilter, PropertyFilterExpression, PropertyFilterExpressionList, - PropertyProtectionFilterConfig, + PropertyFilter, PropertyFilterEntityQueryPath, PropertyFilterExpression, + PropertyFilterExpressionList, PropertyProtectionFilterConfig, }, }, query::{NullOrdering, Ordering}, subgraph::{ - edges::{ - EdgeDirection, EntityTraversalEdge, EntityTraversalPath, GraphResolveDepths, - SharedEdgeKind, - }, + edges::{EdgeDirection, EntityTraversalEdge, EntityTraversalPath, GraphResolveDepths}, temporal_axes::{ PinnedTemporalAxisUnresolved, QueryTemporalAxesUnresolved, VariableTemporalAxisUnresolved, @@ -1416,7 +1412,7 @@ fn phone_filter(phone: &str) -> Filter<'static, type_system::knowledge::entity:: /// Creates a `FilterProtectionConfig` that protects both email AND phone for User. /// /// Excludes User entities when filtering by email or phone, UNLESS the actor is that User. -fn multi_property_config() -> PropertyProtectionFilterConfig<'static> { +fn multi_property_config() -> Arc> { let email_url = BaseUrl::new(EMAIL_PROPERTY_BASE_URL.to_owned()).expect("valid email base URL"); let phone_url = BaseUrl::new(PHONE_PROPERTY_BASE_URL.to_owned()).expect("valid phone base URL"); @@ -1428,16 +1424,12 @@ fn multi_property_config() -> PropertyProtectionFilterConfig<'static> { parameter: Parameter::Text(Cow::Borrowed(USER_ENTITY_TYPE_BASE_URL)), }, PropertyFilterExpressionList::Path { - path: EntityQueryPath::EntityTypeEdge { - edge_kind: SharedEdgeKind::IsOfType, - path: EntityTypeQueryPath::BaseUrl, - inheritance_depth: None, - }, + path: PropertyFilterEntityQueryPath::TypeBaseUrls, }, ), PropertyFilter::NotEqual( PropertyFilterExpression::Path { - path: EntityQueryPath::Uuid, + path: PropertyFilterEntityQueryPath::Uuid, }, PropertyFilterExpression::ActorId, ), @@ -1447,7 +1439,7 @@ fn multi_property_config() -> PropertyProtectionFilterConfig<'static> { let mut config = PropertyProtectionFilterConfig::new(); config.protect_property(email_url, user_protection()); config.protect_property(phone_url, user_protection()); - config + Arc::new(config) } /// Seeds the database with multi-property protection config. @@ -2040,7 +2032,7 @@ fn secret_code_filter( /// Creates a `FilterProtectionConfig` for multi-type testing: /// - email protected for `User` (unless actor is that `User`) /// - `secret_code` protected for `SecretEntity` (unless actor is that `SecretEntity`) -fn multi_type_config() -> PropertyProtectionFilterConfig<'static> { +fn multi_type_config() -> Arc> { let email_url = BaseUrl::new(EMAIL_PROPERTY_BASE_URL.to_owned()).expect("valid email base URL"); let secret_code_url = BaseUrl::new(SECRET_CODE_PROPERTY_BASE_URL.to_owned()).expect("valid secret_code base URL"); @@ -2054,16 +2046,12 @@ fn multi_type_config() -> PropertyProtectionFilterConfig<'static> { parameter: Parameter::Text(Cow::Borrowed(USER_ENTITY_TYPE_BASE_URL)), }, PropertyFilterExpressionList::Path { - path: EntityQueryPath::EntityTypeEdge { - edge_kind: SharedEdgeKind::IsOfType, - path: EntityTypeQueryPath::BaseUrl, - inheritance_depth: None, - }, + path: PropertyFilterEntityQueryPath::TypeBaseUrls, }, ), PropertyFilter::NotEqual( PropertyFilterExpression::Path { - path: EntityQueryPath::Uuid, + path: PropertyFilterEntityQueryPath::Uuid, }, PropertyFilterExpression::ActorId, ), @@ -2077,22 +2065,19 @@ fn multi_type_config() -> PropertyProtectionFilterConfig<'static> { parameter: Parameter::Text(Cow::Borrowed(SECRET_ENTITY_TYPE_BASE_URL)), }, PropertyFilterExpressionList::Path { - path: EntityQueryPath::EntityTypeEdge { - edge_kind: SharedEdgeKind::IsOfType, - path: EntityTypeQueryPath::BaseUrl, - inheritance_depth: None, - }, + path: PropertyFilterEntityQueryPath::TypeBaseUrls, }, ), PropertyFilter::NotEqual( PropertyFilterExpression::Path { - path: EntityQueryPath::Uuid, + path: PropertyFilterEntityQueryPath::Uuid, }, PropertyFilterExpression::ActorId, ), ]), ); - config + + Arc::new(config) } /// Seeds the database with multi-type protection config.