From f534bc9d479394fbd53c00b04aa3641fd04c129a Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 1 Oct 2026 03:30:13 +0000 Subject: [PATCH] fix: reauthorize authoritative generated relationship targets --- CHANGELOG.md | 19 + Cargo.lock | 4 +- Cargo.toml | 2 +- MIGRATION.md | 26 + crates/graphql-orm-macros/Cargo.toml | 2 +- crates/graphql-orm-macros/README.md | 6 +- .../fixtures/cross-crate-relations/Cargo.lock | 4 +- .../examples/authorized_links.rs | 99 +++ .../source-models/src/lib.rs | 759 +++++++++++++++++- .../target-models/src/lib.rs | 1 + crates/graphql-orm-macros/src/operations.rs | 93 +-- crates/graphql-orm-macros/src/relations.rs | 114 ++- crates/graphql-orm/Cargo.toml | 4 +- crates/graphql-orm/README.md | 2 +- crates/graphql-orm/src/graphql/loaders.rs | 239 ++++++ .../src/graphql/orm/authorized_pagination.rs | 7 + .../tests/cross_crate_relations_fixture.rs | 9 +- .../fixtures/backend-coexistence/Cargo.lock | 4 +- .../fixtures/federation-end-to-end/Cargo.lock | 4 +- .../migration-api-compatibility/Cargo.lock | 4 +- .../repository-aggregate-consumer/Cargo.lock | 4 +- .../tests/support/owned_postgres.rs | 2 + .../graphql-orm/entities-and-relations.md | 57 +- docs/reference/workspace-packages.md | 4 +- 24 files changed, 1265 insertions(+), 204 deletions(-) create mode 100644 crates/graphql-orm-macros/fixtures/cross-crate-relations/source-models/examples/authorized_links.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index dabe235e..13ec58a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,25 @@ This file is the authoritative user-facing release chronology. The former [release-notes ledger](docs/archive/2026/graphql-orm-release-notes.md) is retained for historical context. +## 0.33.5 - 2026-10-01 + +Companion macros crate: `graphql-orm-macros` **0.33.5**. + +- Fix generated relationship resolvers returning preloaded objects before current + target authorization and complete ownership-key resolution. Resolve the + authoritative database target even when a snapshot/cache was populated. +- Check current entity, row and selected-field policies. Nullable target denial + preserves the parent and reports a sanitized error on the relationship path. + Missing or ownership-mismatched targets ordinarily return null without errors. +- Apply SQL visibility before relation pages/counts, partition batches by current + predicate and database identity, and retain the existing uncached loader type. + Callback-only to-many policies require an explicit host scan budget and fail + closed on exhaustion; retain only the bounded visible page in memory. +- Add executable cross-crate SQLite/PostgreSQL regressions and a SQL-free + authoritative relationship example. Existing SDL/cursor formats are unchanged. + +No stored-data migration or release publication is included. + ## 0.33.4 - 2026-10-01 Companion macros crate: `graphql-orm-macros` **0.33.4**. diff --git a/Cargo.lock b/Cargo.lock index a36454a1..fabba8ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3146,7 +3146,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.33.4" +version = "0.33.5" dependencies = [ "agql-auth", "async-graphql", @@ -3245,7 +3245,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.33.4" +version = "0.33.5" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 74a093ab..03f19125 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ cynic-parser = { version = "=0.11.2", features = ["pretty"] } futures = "0.3" getrandom = "0.3" graphql-composition = "=0.12.2" -graphql-orm = { path = "crates/graphql-orm", version = "0.33.4", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.33.5", default-features = false } graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.15.0" } graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.7.2", default-features = false } graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.4.0" } diff --git a/MIGRATION.md b/MIGRATION.md index 9e24882b..fda8ff0b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -13,6 +13,32 @@ supersedes: [] `graphql-orm` is distributed from GitHub only. Use a reviewed full 40-character commit in `rev`; neither the runtime nor macros crate is published to crates.io. +## 0.33.4 to 0.33.5: authoritative generated relationship access + +Adopt runtime and macros 0.33.5 together. Generated relationship resolvers no +longer trust preloaded objects as authoritative targets. Install `Database` in +request/schema data; target tables must remain available for current resolution. +Declare complete tenant/identity bindings. Existing public declarations, SDL and +cursor formats are unchanged; snapshots and cached objects grant no authority. + +Entity, current row and selected field policies are enforced at traversal. +A denied nullable target is null with a safe child-path error; missing or moved +ownership targets ordinarily return null without errors. Field policy callbacks +may run during traversal preflight as well as ordinary field resolution; both +receive the actual child field context, including its arguments and alias/path. + +For bounded batched pages/counts prefer a `ReadVisibility::Complete` SQL predicate. +Callback-only/prefilter policies on to-many links now require the existing +`AuthorizedScanConfig` on `Database`. The resolver scans bounded candidate batches, +counts authorized rows across the complete result and retains only the requested +visible window. Missing configuration or insufficient budget returns a safe error +instead of a partial count/exhaustion claim. This corrective behavior replaces +previously unchecked relation row policies. Concurrent external DML is not a +snapshot-isolation guarantee across separate read statements. + +See [the authoritative relationship example](crates/graphql-orm-macros/fixtures/cross-crate-relations/source-models/examples/authorized_links.rs) +and [the relation contract](docs/reference/graphql-orm/entities-and-relations.md). + ## 0.33.3 to 0.33.4: generated cross-crate relationships Adopt runtime and macros 0.33.4 together from the same reviewed published tag. diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index 75a3ab45..90451168 100644 --- a/crates/graphql-orm-macros/Cargo.toml +++ b/crates/graphql-orm-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-macros" -version = "0.33.4" +version = "0.33.5" edition = "2024" authors = ["Toby Martin"] description = "Procedural macros for async-graphql and ORM-backed entities, relations, and CRUD operations." diff --git a/crates/graphql-orm-macros/README.md b/crates/graphql-orm-macros/README.md index 1aaecaab..bc19ad21 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -16,13 +16,13 @@ macro/runtime versions aligned: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.33.4", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.33.5", default-features = false, features = ["sqlite"] } ``` Direct use is supported for tooling that needs the macro package: ```toml -graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.33.4", default-features = false, features = ["sqlite"] } +graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.33.5", default-features = false, features = ["sqlite"] } ``` The direct dependency still requires a compatible `graphql-orm` runtime in the @@ -166,6 +166,6 @@ aggregate enums keep their GraphQL derive and naming attributes. See the [external runtime consumer](../graphql-orm/tests/fixtures/repository-aggregate-consumer/src/lib.rs) for compiled plain Rust aggregate calls without a direct async-graphql dependency. -Version 0.33.4 gives the repository aggregate compatibility fix a distinct package +Version 0.33.3 gives the repository aggregate compatibility fix a distinct package identity after the independent 0.33.2 fixture maintenance. Generated behavior is unchanged from the reviewed aggregate fix. diff --git a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock index a95d3fde..580c89b6 100644 --- a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock +++ b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock @@ -1028,7 +1028,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.33.4" +version = "0.33.5" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.33.4" +version = "0.33.5" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm-macros/fixtures/cross-crate-relations/source-models/examples/authorized_links.rs b/crates/graphql-orm-macros/fixtures/cross-crate-relations/source-models/examples/authorized_links.rs new file mode 100644 index 00000000..09709210 --- /dev/null +++ b/crates/graphql-orm-macros/fixtures/cross-crate-relations/source-models/examples/authorized_links.rs @@ -0,0 +1,99 @@ +//! Disposable, SQL-free host example: cached snapshots grant no target access. +#[cfg(feature = "sqlite")] +#[tokio::main] +async fn main() -> Result<(), Box> { + use async_graphql::{ + Context, EmptyMutation, EmptySubscription, Object, Schema, dataloader::DataLoader, + }; + use cross_crate_source_models::Session; + use cross_crate_target_models::{CreateEndpointInput, Endpoint}; + use graphql_orm::{db::Database, graphql::loaders::RelationLoader, prelude::*}; + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + struct Policy(Arc); + impl EntityPolicy for Policy { + fn can_access_entity<'a>( + &'a self, + _: Option<&'a Context<'_>>, + _: &'a Database, + entity: &'static str, + _: Option<&'static str>, + kind: EntityAccessKind, + _: EntityAccessSurface, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { + Ok(entity != "Endpoint" + || kind != EntityAccessKind::Read + || self.0.load(Ordering::SeqCst)) + }) + } + } + struct Query; + #[Object] + impl Query { + async fn session(&self) -> Session { + Session { + id: "session-1".into(), + endpoint_id: "endpoint-1".into(), + tenant_id: "tenant-1".into(), + endpoint: Some(Endpoint { + id: "forged".into(), + tenant_id: "tenant-2".into(), + name: "private snapshot".into(), + }), + } + } + } + let allowed = Arc::new(AtomicBool::new(true)); + let pool = graphql_orm::sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await?; + let db = Database::::with_entity_policy(pool, Policy(allowed.clone())); + let plan = db + .schema() + .plan_migration_to_entities("example", "owned fixture", &[Endpoint::metadata()]) + .await?; + db.schema() + .apply_migration(&plan, ApplyOptions::default()) + .await?; + Endpoint::insert( + &db, + CreateEndpointInput { + id: "endpoint-1".into(), + tenant_id: "tenant-1".into(), + name: "Current authorized target".into(), + }, + ) + .await?; + let schema = Schema::build(Query, EmptyMutation, EmptySubscription) + .data(db.clone()) + .data("example-user".to_owned()) + .data(DataLoader::new( + RelationLoader::::new(db), + tokio::spawn, + )) + .finish(); + let query = + "{ session { id link: endpoint { ...Target } } } fragment Target on Endpoint { id name }"; + let current = schema.execute(query).await; + assert!(current.errors.is_empty()); + assert_eq!( + current.data.into_json()?["session"]["link"]["name"], + "Current authorized target" + ); + allowed.store(false, Ordering::SeqCst); + let denied = schema.execute(query).await; + let data = denied.data.into_json()?; + assert_eq!(data["session"]["id"], "session-1"); + assert!(data["session"]["link"].is_null()); + assert_eq!(denied.errors[0].message, "forbidden"); + println!("current target resolved; denied nullable target is null; parent preserved"); + Ok(()) +} +#[cfg(not(feature = "sqlite"))] +fn main() { + println!("Run with the sqlite feature for disposable execution."); +} diff --git a/crates/graphql-orm-macros/fixtures/cross-crate-relations/source-models/src/lib.rs b/crates/graphql-orm-macros/fixtures/cross-crate-relations/source-models/src/lib.rs index e3f0b166..fd8b2f19 100644 --- a/crates/graphql-orm-macros/fixtures/cross-crate-relations/source-models/src/lib.rs +++ b/crates/graphql-orm-macros/fixtures/cross-crate-relations/source-models/src/lib.rs @@ -1,9 +1,12 @@ use async_graphql::SimpleObject; -use cross_crate_target_models::Endpoint; +use cross_crate_target_models::{ + Endpoint, EndpointConnection, EndpointEdge, EndpointOrderByInput, EndpointWhereInput, +}; use graphql_orm::prelude::*; #[derive( GraphQLEntity, + GraphQLOperations, GraphQLRelations, SimpleObject, serde::Serialize, @@ -105,7 +108,45 @@ pub struct Activity { pub endpoint: Option, } -#[cfg(all(test, feature = "sqlite"))] +#[derive( + GraphQLEntity, + GraphQLRelations, + SimpleObject, + serde::Serialize, + serde::Deserialize, + Clone, + Debug, +)] +#[graphql(complex)] +#[cfg_attr( + feature = "sqlite", + graphql_entity(backend = "sqlite", table = "cross_groups", plural = "Groups") +)] +#[cfg_attr( + feature = "postgres", + graphql_entity(backend = "postgres", table = "cross_groups", plural = "Groups") +)] +#[cfg_attr( + feature = "mssql", + graphql_entity(backend = "mssql", table = "cross_groups", plural = "Groups") +)] +pub struct Group { + #[primary_key] + #[filterable(type = "string")] + #[sortable] + pub id: String, + #[graphql(skip)] + pub tenant_id: String, + #[graphql(skip)] + #[relation(target = "Endpoint", from = ["tenant_id"], to = ["tenant_id"], multiple, emit_fk = false)] + pub endpoints: Vec, +} + +#[cfg(all(test, feature = "postgres"))] +#[path = "../../../../../graphql-orm/tests/support/owned_postgres.rs"] +mod owned_postgres; + +#[cfg(all(test, any(feature = "sqlite", feature = "postgres")))] mod tests { use super::*; use async_graphql::dataloader::DataLoader; @@ -117,7 +158,9 @@ mod tests { atomic::{AtomicBool, Ordering}, }; + #[cfg(feature = "sqlite")] static TARGET_SELECTS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + #[cfg(feature = "sqlite")] unsafe extern "C" fn trace( kind: u32, _context: *mut std::ffi::c_void, @@ -140,28 +183,63 @@ mod tests { struct Query { sessions: Vec, activities: Vec, + groups: Vec, } #[Object] impl Query { - async fn sessions(&self, _ctx: &Context<'_>) -> Vec { - self.sessions.clone() + async fn sessions(&self, ctx: &Context<'_>) -> async_graphql::Result> { + let db = ctx.data_unchecked::>(); + db.ensure_entity_access( + Some(ctx), + "Session", + None, + EntityAccessKind::Read, + EntityAccessSurface::GraphqlQuery, + ) + .await?; + Ok(self.sessions.clone()) } async fn activities(&self, _ctx: &Context<'_>) -> Vec { self.activities.clone() } + async fn groups(&self, ctx: &Context<'_>) -> async_graphql::Result> { + let db = ctx.data_unchecked::>(); + db.ensure_entity_access( + Some(ctx), + "Group", + None, + EntityAccessKind::Read, + EntityAccessSurface::GraphqlQuery, + ) + .await?; + Ok(self.groups.clone()) + } } struct TargetPolicy(Arc); - impl EntityPolicy for TargetPolicy { + impl EntityPolicy for TargetPolicy { fn can_access_entity<'a>( &'a self, - _ctx: Option<&'a Context<'_>>, - _db: &'a Database, + ctx: Option<&'a Context<'_>>, + _db: &'a Database, entity: &'static str, _key: Option<&'static str>, kind: EntityAccessKind, _surface: EntityAccessSurface, ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { Box::pin(async move { + let identity = ctx.and_then(|ctx| ctx.data_opt::()); + if entity == "Endpoint" + && identity.is_some_and(|identity| identity.owner == "entity-error") + { + return Err(async_graphql::Error::new("private entity policy details")); + } + if identity.is_some_and(|identity| !identity.current) { + return Ok(false); + } + if entity != "Endpoint" && identity.is_some_and(|identity| !identity.source_allowed) + { + return Ok(false); + } Ok(entity != "Endpoint" || kind != EntityAccessKind::Read || self.0.load(Ordering::SeqCst)) @@ -186,15 +264,587 @@ mod tests { } } + #[derive(Clone)] + struct Identity { + owner: String, + current: bool, + source_allowed: bool, + name_allowed: bool, + } + fn identity(owner: &str) -> Identity { + Identity { + owner: owner.into(), + current: true, + source_allowed: true, + name_allowed: true, + } + } + struct CurrentRowPolicy { + allowed: Arc, + complete: bool, + } + impl RowPolicy for CurrentRowPolicy { + fn read_visibility<'a>( + &'a self, + ctx: Option<&'a Context<'_>>, + _: &'a Database, + entity: &'static str, + _: Option<&'static str>, + _: EntityAccessSurface, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> + { + Box::pin(async move { + if entity != "Endpoint" { + return Ok(ReadVisibility::Unrestricted); + } + if !self.complete { + return Ok(ReadVisibility::CallbackOnly); + } + let owner = &ctx.unwrap().data_unchecked::().owner; + let filter = cross_crate_target_models::EndpointWhereInput { + name: Some(StringFilter { + starts_with: Some(format!("{owner}:")), + ..Default::default() + }), + ..Default::default() + }; + Ok(ReadVisibility::Complete(ReadPredicate::from_filter::< + DefaultBackend, + _, + >(&filter)?)) + }) + } + fn can_read_row<'a>( + &'a self, + ctx: Option<&'a Context<'_>>, + _: &'a Database, + entity: &'static str, + _: Option<&'static str>, + surface: EntityAccessSurface, + row: &'a (dyn std::any::Any + Send + Sync), + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { + assert_eq!(surface, EntityAccessSurface::GraphqlRelation); + if entity != "Endpoint" { + return Ok(true); + } + let row = row.downcast_ref::().unwrap(); + if ctx.unwrap().data_unchecked::().owner == "row-error" { + return Err(async_graphql::Error::new("private row policy details")); + } + let owner = &ctx.unwrap().data_unchecked::().owner; + Ok(self.allowed.load(Ordering::SeqCst) + && row.name.starts_with(&format!("{owner}:"))) + }) + } + fn can_write_row<'a>( + &'a self, + _: Option<&'a Context<'_>>, + _: &'a Database, + _: &'static str, + _: Option<&'static str>, + _: EntityAccessSurface, + _: &'a (dyn std::any::Any + Send + Sync), + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(true) }) + } + } + struct CurrentFieldPolicy; + impl FieldPolicy for CurrentFieldPolicy { + fn can_read_field<'a>( + &'a self, + ctx: &'a Context<'_>, + _: &'a Database, + _: &'static str, + field: &'static str, + _: Option<&'static str>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { + assert_eq!(ctx.field().name(), field); + Ok(ctx.data_unchecked::().name_allowed) + }) + } + fn can_write_field<'a>( + &'a self, + _: &'a Context<'_>, + _: &'a Database, + _: &'static str, + _: &'static str, + _: Option<&'static str>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(true) }) + } + } + + #[cfg(feature = "sqlite")] + type FixtureOwner = (); + #[cfg(feature = "postgres")] + type FixtureOwner = crate::owned_postgres::OwnedPostgres; + async fn fixture_pool() + -> Result<(::Pool, FixtureOwner), Box> + { + #[cfg(feature = "sqlite")] + { + Ok(( + graphql_orm::sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await?, + (), + )) + } + #[cfg(feature = "postgres")] + { + let owner = FixtureOwner::start("relationship-authorization")?; + let pool = graphql_orm::sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&owner.url) + .await?; + Ok((pool, owner)) + } + } + fn cleanup_owner(owner: &mut FixtureOwner) -> Result<(), Box> { + #[cfg(feature = "postgres")] + owner.cleanup()?; + #[cfg(feature = "sqlite")] + let _ = owner; + Ok(()) + } + async fn authorized_db( + complete: bool, + budget: u32, + ) -> Result< + ( + Database, + Arc, + Arc, + FixtureOwner, + ), + Box, + > { + let (pool, owner) = fixture_pool().await?; + let entity_allowed = Arc::new(AtomicBool::new(true)); + let row_allowed = Arc::new(AtomicBool::new(true)); + let mut db = Database::::with_entity_policy( + pool, + TargetPolicy(entity_allowed.clone()), + ); + let plan = db + .schema() + .plan_migration_to_entities("fixture", "owned target", &[Endpoint::metadata()]) + .await?; + db.schema() + .apply_migration(&plan, ApplyOptions::default()) + .await?; + for (id, tenant, name) in [ + ("a", "one", "one:current A"), + ("b", "one", "two:denied B"), + ("c", "one", "one:current C"), + ("d", "two", "two:current D"), + ] { + Endpoint::insert( + &db, + cross_crate_target_models::CreateEndpointInput { + id: id.into(), + tenant_id: tenant.into(), + name: name.into(), + }, + ) + .await?; + } + db.set_row_policy(CurrentRowPolicy { + allowed: row_allowed.clone(), + complete, + }); + db.set_field_policy(CurrentFieldPolicy); + let db = db.with_authorized_scan_config(AuthorizedScanConfig::new([3; 32], 2, budget)); + Ok((db, entity_allowed, row_allowed, owner)) + } + fn cached_schema( + db: Database, + sessions: Vec, + groups: Vec, + loader: bool, + ) -> Schema { + let builder = Schema::build( + Query { + sessions, + activities: vec![], + groups, + }, + EmptyMutation, + EmptySubscription, + ) + .data(db.clone()) + .data("fixture-user".to_owned()); + if loader { + builder + .data(DataLoader::new( + RelationLoader::::new(db), + tokio::spawn, + )) + .finish() + } else { + builder.finish() + } + } + fn preloaded(id: &str, tenant: &str, name: &str) -> Endpoint { + Endpoint { + id: id.into(), + tenant_id: tenant.into(), + name: name.into(), + } + } + const CHILD: &str = + "{ sessions { id link: endpoint { ...Target } } } fragment Target on Endpoint { id name }"; + #[tokio::test] - async fn cross_crate_execution_ownership_batching_and_nullable_entity_denial() + async fn application_loader_cache_cannot_supply_target_authority() + -> Result<(), Box> { + use async_graphql::dataloader::HashMapCache; + use graphql_orm::graphql::loaders::{ + CompositeRelationQueryKey, RelationKey, RelationKeyPartKind, RelationLoadResult, + }; + let (db, allowed, _, mut owner) = authorized_db(false, 20).await?; + let loader = DataLoader::with_cache( + RelationLoader::::new(db.clone()), + tokio::spawn, + HashMapCache::default(), + ); + let values = vec![SqlValue::String("a".into()), SqlValue::String("one".into())]; + loader + .feed_one( + CompositeRelationQueryKey { + relation: "Session::endpoint", + parent_key: RelationKey::from_sql_values(&values), + parent_values: values, + fk_columns: vec!["\"id\"", "\"tenant_id\""], + key_part_kinds: vec![RelationKeyPartKind::String; 2], + where_signature: None, + order_signature: None, + page_signature: None, + filter: None, + sorts: vec![], + pagination: None, + auth_context: None, + }, + RelationLoadResult { + entities: vec![preloaded("a", "two", "private cached target")], + total_count: 1, + has_next_page: false, + has_previous_page: false, + offset: 0, + }, + ) + .await; + let schema = Schema::build( + Query { + sessions: vec![session("parent", "a", "one")], + activities: vec![], + groups: vec![], + }, + EmptyMutation, + EmptySubscription, + ) + .data(db.clone()) + .data("fixture-user".to_owned()) + .data(loader) + .finish(); + let current = schema + .execute(async_graphql::Request::new(CHILD).data(identity("one"))) + .await; + assert!(current.errors.is_empty(), "{:?}", current.errors); + assert_eq!( + current.data.into_json()?["sessions"][0]["link"]["name"], + "one:current A" + ); + allowed.store(false, Ordering::SeqCst); + let denied = schema + .execute(async_graphql::Request::new(CHILD).data(identity("one"))) + .await; + assert!(denied.data.into_json()?["sessions"][0]["link"].is_null()); + assert_eq!(denied.errors[0].message, "forbidden"); + db.pool().close().await; + cleanup_owner(&mut owner)?; + Ok(()) + } + + #[tokio::test] + async fn generated_parent_does_not_eagerly_query_a_denied_nullable_target() -> Result<(), Box> { - let pool = graphql_orm::sqlx::sqlite::SqlitePoolOptions::new() - .max_connections(1) - .connect("sqlite::memory:") + use std::sync::atomic::AtomicUsize; + struct Observe(Arc); + impl ReadQueryObserver for Observe { + fn on_read(&self, sql: &str, _: usize) { + if sql.contains("cross_endpoints") { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + } + let (pool, mut owner) = fixture_pool().await?; + let reads = Arc::new(AtomicUsize::new(0)); + let db = Database::::with_entity_policy( + pool, + TargetPolicy(Arc::new(AtomicBool::new(false))), + ) + .with_read_query_observer(Observe(reads.clone())); + // Intentionally create only the authorized source table. A pool-only + // eager target preload used to query the missing table and fail parent. + let plan = db + .schema() + .plan_migration_to_entities("source", "owned source", &[Session::metadata()]) .await?; + db.schema() + .apply_migration(&plan, ApplyOptions::default()) + .await?; + Session::insert( + &db, + CreateSessionInput { + id: "parent".into(), + endpoint_id: "a".into(), + tenant_id: "one".into(), + }, + ) + .await?; + let schema = Schema::build(SessionQueries, EmptyMutation, EmptySubscription) + .data(db.clone()) + .data("fixture-user".to_owned()) + .finish(); + let response = schema + .execute("{ session(id: \"parent\") { id link: endpoint { id name } } }") + .await; + let data = response.data.into_json()?; + assert_eq!(data["session"]["id"], "parent", "{:?}", response.errors); + assert!(data["session"]["link"].is_null()); + assert_eq!(response.errors.len(), 1); + assert_eq!(response.errors[0].message, "forbidden"); + assert_eq!( + response.errors[0].path.last(), + Some(&async_graphql::PathSegment::Field("link".into())) + ); + assert_eq!(reads.load(Ordering::SeqCst), 0); + db.pool().close().await; + cleanup_owner(&mut owner)?; + Ok(()) + } + + #[tokio::test] + async fn preloaded_targets_are_authoritative_owned_and_rechecked_on_every_request() + -> Result<(), Box> { + for use_loader in [true, false] { + let (db, entity_allowed, row_allowed, mut owner) = authorized_db(false, 20).await?; + let mut parent = session("parent", "a", "one"); + parent.endpoint = Some(preloaded("forged", "two", "private forged name")); + let mut mismatch = session("mismatch", "a", "two"); + mismatch.endpoint = Some(preloaded("a", "one", "cached cross-tenant value")); + let mut missing = session("missing", "absent", "one"); + missing.endpoint = Some(preloaded("absent", "one", "fabricated missing value")); + let schema = cached_schema( + db.clone(), + vec![parent, mismatch, missing], + vec![], + use_loader, + ); + let response = schema + .execute(async_graphql::Request::new(CHILD).data(identity("one"))) + .await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json()?; + assert_eq!(data["sessions"][0]["link"]["name"], "one:current A"); + assert!(data["sessions"][1]["link"].is_null()); + assert!(data["sessions"][2]["link"].is_null()); + + for policy in [&entity_allowed, &row_allowed] { + policy.store(false, Ordering::SeqCst); + let denied = schema + .execute(async_graphql::Request::new(CHILD).data(identity("one"))) + .await; + assert_eq!(denied.data.into_json()?["sessions"][0]["id"], "parent"); + assert!(!denied.errors.is_empty()); + assert!( + denied + .errors + .iter() + .all(|error| error.message == "forbidden" + && error.path.last() + == Some(&async_graphql::PathSegment::Field("link".into()))) + ); + policy.store(true, Ordering::SeqCst); + } + let denied_identity = schema + .execute(async_graphql::Request::new(CHILD).data(identity("two"))) + .await; + assert!(denied_identity.data.into_json()?["sessions"][0]["link"].is_null()); + assert_eq!(denied_identity.errors.len(), 1); + + let mut field_denied = identity("one"); + field_denied.name_allowed = false; + let denied = schema + .execute(async_graphql::Request::new(CHILD).data(field_denied)) + .await; + let data = denied.data.into_json()?; + assert_eq!(data["sessions"][0]["id"], "parent"); + assert!( + data["sessions"][0]["link"].is_null(), + "field denial response: {data}; errors={:?}", + denied.errors + ); + assert_eq!(denied.errors[0].message, "forbidden"); + assert_eq!( + denied.errors[0].path.last(), + Some(&async_graphql::PathSegment::Field("link".into())) + ); + + for selection in [ + "{ sessions { link: endpoint { id hidden: name @skip(if: true) } } }", + "query($skip: Boolean!) { sessions { link: endpoint { id ...Secret @skip(if: $skip) } } } fragment Secret on Endpoint { name }", + "query($include: Boolean!) { sessions { link: endpoint { id ... @include(if: $include) { name } } } }", + ] { + let mut identity = identity("one"); + identity.name_allowed = false; + let skipped = schema + .execute( + async_graphql::Request::new(selection) + .variables(async_graphql::Variables::from_json( + serde_json::json!({"skip": true, "include": false}), + )) + .data(identity), + ) + .await; + assert!( + skipped.errors.is_empty(), + "skipped field must not be denied: {selection}; {:?}", + skipped.errors + ); + assert_eq!(skipped.data.into_json()?["sessions"][0]["link"]["id"], "a"); + } + + for owner in ["entity-error", "row-error"] { + let denied = schema + .execute(async_graphql::Request::new(CHILD).data(identity(owner))) + .await; + assert_eq!(denied.data.into_json()?["sessions"][0]["id"], "parent"); + assert!( + denied + .errors + .iter() + .all(|error| error.message == "forbidden"), + "policy provider diagnostics must stay private" + ); + } + + // Reuse the schema/loader and all preloaded snapshots after ownership changes. + Endpoint::update_by_id( + &db, + &"a".to_owned(), + cross_crate_target_models::UpdateEndpointInput { + tenant_id: Some("two".into()), + name: Some("two:reassigned".into()), + }, + ) + .await?; + let moved = schema + .execute(async_graphql::Request::new(CHILD).data(identity("one"))) + .await; + assert!(moved.data.into_json()?["sessions"][0]["link"].is_null()); + let current = schema + .execute(async_graphql::Request::new(CHILD).data(identity("two"))) + .await; + assert_eq!( + current.data.into_json()?["sessions"][1]["link"]["name"], + "two:reassigned" + ); + + let mut expired = identity("one"); + expired.current = false; + assert!( + !schema + .execute(async_graphql::Request::new(CHILD).data(expired)) + .await + .errors + .is_empty() + ); + let mut denied_source = identity("one"); + denied_source.source_allowed = false; + assert!( + !schema + .execute(async_graphql::Request::new(CHILD).data(denied_source)) + .await + .errors + .is_empty() + ); + db.pool().close().await; + cleanup_owner(&mut owner)?; + } + Ok(()) + } + + #[tokio::test] + async fn preloaded_many_relations_authorize_before_pages_and_counts() + -> Result<(), Box> { + for complete in [false, true] { + let (db, _, _, mut owner) = authorized_db(complete, 20).await?; + let groups = vec![Group { + id: "group".into(), + tenant_id: "one".into(), + endpoints: vec![preloaded("d", "two", "cached other tenant")], + }]; + let schema = cached_schema(db.clone(), vec![], groups, true); + let response = schema.execute(async_graphql::Request::new("{ groups { id links: endpoints(page: {limit: 1, offset: 1}) { edges { node { id name } } pageInfo { totalCount hasNextPage hasPreviousPage } } } }").data(identity("one"))).await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json()?; + assert_eq!(data["groups"][0]["links"]["edges"][0]["node"]["id"], "c"); + assert_eq!(data["groups"][0]["links"]["pageInfo"]["totalCount"], 2); + assert_eq!(data["groups"][0]["links"]["pageInfo"]["hasNextPage"], false); + let all = schema.execute(async_graphql::Request::new("{ groups { endpoints { edges { node { id } } pageInfo { totalCount } } } }").data(identity("one"))).await; + assert!(all.errors.is_empty(), "{:?}", all.errors); + assert_eq!( + all.data.into_json()?["groups"][0]["endpoints"]["edges"] + .as_array() + .unwrap() + .len(), + 2 + ); + db.pool().close().await; + cleanup_owner(&mut owner)?; + } + let (db, _, _, mut owner) = authorized_db(false, 2).await?; + let schema = cached_schema( + db.clone(), + vec![], + vec![Group { + id: "group".into(), + tenant_id: "one".into(), + endpoints: vec![], + }], + true, + ); + let budget = schema + .execute( + async_graphql::Request::new("{ groups { endpoints { pageInfo { totalCount } } } }") + .data(identity("one")), + ) + .await; + assert!( + !budget.errors.is_empty(), + "a partial scan must not publish count/exhaustion" + ); + assert_eq!(budget.errors[0].message, "authorization is misconfigured"); + db.pool().close().await; + cleanup_owner(&mut owner)?; + Ok(()) + } + + #[tokio::test] + async fn cross_crate_execution_ownership_batching_and_nullable_entity_denial() + -> Result<(), Box> { + let (pool, mut owner) = fixture_pool().await?; let allowed = Arc::new(AtomicBool::new(true)); - let db = Database::::with_entity_policy(pool, TargetPolicy(allowed.clone())); + let db = + Database::::with_entity_policy(pool, TargetPolicy(allowed.clone())); let plan = db .schema() .plan_migration_to_entities("fixture", "synthetic target", &[Endpoint::metadata()]) @@ -226,6 +876,7 @@ mod tests { session("s3", "moved", "one"), session("s4", "missing", "one"), ], + groups: vec![], activities: vec![ activity("h1", Some("a"), Some("one")), activity("h2", None, Some("one")), @@ -238,27 +889,30 @@ mod tests { .data(db.clone()) .data("fixture-user".to_owned()) .data(DataLoader::new( - RelationLoader::::new(db), + RelationLoader::::new(db), tokio::spawn, )) .finish(); - let mut connection = db_pool.acquire().await?; + #[cfg(feature = "sqlite")] { - let mut handle = connection.lock_handle().await?; - // The driver is paused while installing a native execution trace. - // No application query path calls SQLite FFI or handwritten SQL. - let result = unsafe { - libsqlite3_sys::sqlite3_trace_v2( - handle.as_raw_handle().as_ptr(), - libsqlite3_sys::SQLITE_TRACE_STMT as u32, - Some(trace), - std::ptr::null_mut(), - ) - }; - assert_eq!(result, libsqlite3_sys::SQLITE_OK); + let mut connection = db_pool.acquire().await?; + { + let mut handle = connection.lock_handle().await?; + // The driver is paused while installing a native execution trace. + // No application query path calls SQLite FFI or handwritten SQL. + let result = unsafe { + libsqlite3_sys::sqlite3_trace_v2( + handle.as_raw_handle().as_ptr(), + libsqlite3_sys::SQLITE_TRACE_STMT as u32, + Some(trace), + std::ptr::null_mut(), + ) + }; + assert_eq!(result, libsqlite3_sys::SQLITE_OK); + } + drop(connection); + TARGET_SELECTS.store(0, Ordering::SeqCst); } - drop(connection); - TARGET_SELECTS.store(0, Ordering::SeqCst); let response = schema.execute("{ sessions { id endpoint { id name } } activities { id endpointName endpoint { id name } } }").await; assert!(response.errors.is_empty(), "{:?}", response.errors); let data = response.data.into_json()?; @@ -272,6 +926,7 @@ mod tests { assert_eq!(data["activities"][0]["endpointName"], "historical name"); assert!(data["activities"][1]["endpoint"].is_null()); assert!(data["activities"][2]["endpoint"].is_null()); + #[cfg(feature = "sqlite")] assert_eq!( TARGET_SELECTS.load(Ordering::SeqCst), 2, @@ -299,6 +954,54 @@ mod tests { .all(|error| !error.message.contains("endpoint.read")) ); db_pool.close().await; + cleanup_owner(&mut owner)?; + Ok(()) + } + #[tokio::test] + async fn preloaded_nullable_target_cannot_bypass_current_entity_policy() + -> Result<(), Box> { + let (pool, mut owner) = fixture_pool().await?; + let db = Database::::with_entity_policy( + pool, + TargetPolicy(Arc::new(AtomicBool::new(false))), + ); + let mut parent = session("parent", "authorized-key", "one"); + parent.endpoint = Some(Endpoint { + id: "forged-key".into(), + tenant_id: "two".into(), + name: "private preloaded target".into(), + }); + let db_pool = db.pool().clone(); + let schema = Schema::build( + Query { + sessions: vec![parent], + activities: vec![], + groups: vec![], + }, + EmptyMutation, + EmptySubscription, + ) + .data(db.clone()) + .data("fixture-user".to_owned()) + .data(DataLoader::new( + RelationLoader::::new(db), + tokio::spawn, + )) + .finish(); + let response = schema.execute("{ sessions { id link: endpoint { ...Target } } } fragment Target on Endpoint { id name }").await; + let data = response.data.into_json()?; + assert_eq!(data["sessions"][0]["id"], "parent"); + assert!( + data["sessions"][0]["link"].is_null(), + "preloaded object bypassed current target policy: {data}" + ); + assert_eq!(response.errors.len(), 1); + assert_eq!( + response.errors[0].path.last(), + Some(&async_graphql::PathSegment::Field("link".into())) + ); + db_pool.close().await; + cleanup_owner(&mut owner)?; Ok(()) } } diff --git a/crates/graphql-orm-macros/fixtures/cross-crate-relations/target-models/src/lib.rs b/crates/graphql-orm-macros/fixtures/cross-crate-relations/target-models/src/lib.rs index f43774ab..cb27a1a6 100644 --- a/crates/graphql-orm-macros/fixtures/cross-crate-relations/target-models/src/lib.rs +++ b/crates/graphql-orm-macros/fixtures/cross-crate-relations/target-models/src/lib.rs @@ -39,6 +39,7 @@ pub struct Endpoint { pub tenant_id: String, #[filterable(type = "string")] #[sortable] + #[graphql_orm(read_policy = "endpoint.name.read")] pub name: String, } diff --git a/crates/graphql-orm-macros/src/operations.rs b/crates/graphql-orm-macros/src/operations.rs index ba9d98d2..f2622a7d 100644 --- a/crates/graphql-orm-macros/src/operations.rs +++ b/crates/graphql-orm-macros/src/operations.rs @@ -3265,10 +3265,6 @@ pub(crate) fn generate_graphql_operations( let update_input_str = format!("Update{}Input", struct_name); let result_type_str = format!("{}Result", struct_name); let changed_event_str = format!("{}ChangedEvent", struct_name); - let has_relations = fields - .iter() - .filter_map(|f| parse_field_metadata(f).ok()) - .any(|m| m.is_relation); let has_search = fields .iter() .filter_map(|f| parse_field_metadata(f).ok()) @@ -3325,89 +3321,12 @@ pub(crate) fn generate_graphql_operations( serde_rename_all, auto_generated_pk, )?; - let relation_preload_list = if has_relations { - quote! { - let selection = ctx.field().selection_set().collect::>(); - if !generic_conn.edges.is_empty() { - let cursors = generic_conn - .edges - .iter() - .map(|edge| edge.cursor.clone()) - .collect::>(); - let mut entities = std::mem::take(&mut generic_conn.edges) - .into_iter() - .map(|edge| edge.node) - .collect::>(); - <#struct_name as ::graphql_orm::graphql::orm::RelationLoader<#backend_marker>>::bulk_load_relations_with_auth( - &mut entities, - pool, - &selection, - auth_context.as_ref(), - ) - .await - .map_err(|e| ::graphql_orm::async_graphql::Error::new(e.to_string()))?; - generic_conn.edges = cursors - .into_iter() - .zip(entities.into_iter()) - .map(|(cursor, node)| ::graphql_orm::graphql::pagination::Edge { cursor, node }) - .collect(); - } - } - } else { - quote! {} - }; - let relation_preload_search_list = if has_relations { - quote! { - let selection = ctx.field().selection_set().collect::>(); - if !generic_conn.edges.is_empty() { - let edge_meta = generic_conn - .edges - .iter() - .map(|edge| (edge.cursor.clone(), edge.score)) - .collect::>(); - let mut entities = std::mem::take(&mut generic_conn.edges) - .into_iter() - .map(|edge| edge.node) - .collect::>(); - <#struct_name as ::graphql_orm::graphql::orm::RelationLoader<#backend_marker>>::bulk_load_relations_with_auth( - &mut entities, - pool, - &selection, - auth_context.as_ref(), - ) - .await - .map_err(|e| ::graphql_orm::async_graphql::Error::new(e.to_string()))?; - generic_conn.edges = edge_meta - .into_iter() - .zip(entities.into_iter()) - .map(|((cursor, score), node)| ::graphql_orm::graphql::orm::SearchConnectionEdge { - cursor, - score, - node, - }) - .collect(); - } - } - } else { - quote! {} - }; - let relation_preload_single = if has_relations { - quote! { - if let Some(entity) = entity.as_mut() { - let selection = ctx.field().selection_set().collect::>(); - <#struct_name as ::graphql_orm::graphql::orm::RelationLoader<#backend_marker>>::load_relations_with_auth( - entity, - pool, - &selection, - auth_context.as_ref(), - ) - .await - .map_err(|e| ::graphql_orm::async_graphql::Error::new(e.to_string()))?; - } - } - } else { - quote! {} - }; + // Target authority belongs to the relationship resolver. Pool-only eager + // preloads cannot evaluate current request policies and may fail a parent + // before its nullable child can report a sanitized traversal error. + let relation_preload_list = quote! {}; + let relation_preload_search_list = quote! {}; + let relation_preload_single = quote! {}; let find_by_id_method = if has_composite_primary_key { quote! {} diff --git a/crates/graphql-orm-macros/src/relations.rs b/crates/graphql-orm-macros/src/relations.rs index 79b48b25..aab92223 100644 --- a/crates/graphql-orm-macros/src/relations.rs +++ b/crates/graphql-orm-macros/src/relations.rs @@ -497,7 +497,6 @@ pub(crate) fn generate_graphql_relations( .map(|column| backend_quote_identifier_path(backend, column)) .collect::>(); let source_supports_dataloader = r.source_supports_dataloader; - let storage_kind = r.storage_kind; let source_fields = &r.source_field_idents; let source_kinds = &r.source_kinds; let source_optional = &r.source_optional; @@ -757,22 +756,6 @@ pub(crate) fn generate_graphql_relations( }; if r.is_multiple { - let preloaded_entities = match storage_kind { - RelationStorageKind::BoxedMany => { - quote! { - let entities: Vec<#target_type> = self.#field_name - .iter() - .cloned() - .map(|entity| *entity) - .collect(); - } - } - _ => { - quote! { - let entities = self.#field_name.clone(); - } - } - }; // One-to-many relation with smart batching Ok(quote! { #[doc = #description] @@ -798,7 +781,10 @@ pub(crate) fn generate_graphql_relations( <#target_type as ::graphql_orm::graphql::orm::Entity>::metadata().read_policy, ::graphql_orm::graphql::orm::EntityAccessKind::Read, ::graphql_orm::graphql::orm::EntityAccessSurface::GraphqlRelation, - ).await?; + ).await.map_err(::graphql_orm::graphql::loaders::relation_authorization_error)?; + let visibility = db.read_visibility::<#target_type>( + Some(ctx), ::graphql_orm::graphql::orm::EntityAccessSurface::GraphqlRelation, + ).await.map_err(::graphql_orm::graphql::loaders::relation_authorization_error)?; if let Some(filter) = &where_input { filter @@ -806,30 +792,14 @@ pub(crate) fn generate_graphql_relations( .map_err(::graphql_orm::graphql::errors::graphql_error_from_sqlx)?; } - if where_input.is_none() && order_by.is_none() && page.is_none() && !self.#field_name.is_empty() { - #preloaded_entities - let edges: Vec<#edge_type> = entities - .into_iter() - .enumerate() - .map(|(i, entity)| #edge_type { - cursor: ::graphql_orm::graphql::pagination::encode_cursor(i as i64), - node: entity, - }) - .collect(); - let page_info = ::graphql_orm::graphql::pagination::PageInfo { - has_next_page: false, - has_previous_page: false, - start_cursor: edges.first().map(|e| e.cursor.clone()), - end_cursor: edges.last().map(|e| e.cursor.clone()), - total_count: Some(edges.len() as i64), - }; - return Ok(#connection_type { edges, page_info }); - } - // Use DataLoader whenever the relation key can be batched. #source_binding_multiple let use_dataloader = #source_supports_dataloader + && !visibility.requires_residual_checks() + && ctx.data_opt::<::graphql_orm::async_graphql::dataloader::DataLoader< + ::graphql_orm::graphql::loaders::RelationLoader<#target_type, #backend_marker> + >>().is_some() && order_by .as_ref() .is_none_or(|order| !order.requires_context()); @@ -840,9 +810,11 @@ pub(crate) fn generate_graphql_relations( let loader = ctx.data_unchecked::>>(); loader - .load_one(#relation_query_key) + .load_one(::graphql_orm::graphql::loaders::with_relation_visibility::<#target_type, #backend_marker>( + #relation_query_key, &visibility, + )?) .await - .map_err(|e| ::graphql_orm::async_graphql::Error::new(e.to_string()))? + .map_err(|e| ::graphql_orm::graphql::errors::OrmPublicError::internal(e.to_string()).into_graphql_error())? .unwrap_or(::graphql_orm::graphql::loaders::RelationLoadResult { entities: Vec::new(), total_count: 0, @@ -880,19 +852,25 @@ pub(crate) fn generate_graphql_relations( }); } + if visibility.requires_residual_checks() { + ::graphql_orm::graphql::loaders::scan_authorized_relation( + db, ctx, query, auth_context.as_ref(), &visibility, resolved_page, + ).await? + } else { + query = query.with_read_visibility(&visibility)?; // Count must be computed before/independent of pagination window. // EntityQuery::count ignores limit/offset and uses only WHERE clauses. let total = query .count_with_auth(db, auth_context.as_ref()) .await - .map_err(|e| ::graphql_orm::async_graphql::Error::new(e.to_string()))?; + .map_err(|e| ::graphql_orm::graphql::errors::OrmPublicError::internal(e.to_string()).into_graphql_error())?; let offset = resolved_page.offset.max(0) as usize; let entities = query .fetch_all_with_auth(db, auth_context.as_ref()) .await - .map_err(|e| ::graphql_orm::async_graphql::Error::new(e.to_string()))?; + .map_err(|e| ::graphql_orm::graphql::errors::OrmPublicError::internal(e.to_string()).into_graphql_error())?; ::graphql_orm::graphql::loaders::RelationLoadResult { has_next_page: (offset as i64 + entities.len() as i64) < total, @@ -901,8 +879,10 @@ pub(crate) fn generate_graphql_relations( total_count: total, offset: offset as i64, } + } }; + ::graphql_orm::graphql::loaders::authorize_relation_fields(db, ctx, &loaded.entities, true).await?; let edges: Vec<#edge_type> = loaded.entities .into_iter() .enumerate() @@ -924,18 +904,6 @@ pub(crate) fn generate_graphql_relations( } }) } else { - let preloaded_single = match storage_kind { - RelationStorageKind::BoxedSingle => { - quote! { - return Ok(self.#field_name.clone().map(|entity| *entity)); - } - } - _ => { - quote! { - return Ok(self.#field_name.clone()); - } - } - }; // Single relation (many-to-one) - uses DataLoader when the source key supports it Ok(quote! { #[doc = #description] @@ -948,10 +916,6 @@ pub(crate) fn generate_graphql_relations( let _auth_subject = ::graphql_orm::graphql::auth::enforce_resolver_auth(ctx, #resolver_auth_mode)?; #source_condition_single_guard - if self.#field_name.is_some() { - #preloaded_single - } - let db = ctx.data_unchecked::<::graphql_orm::db::Database<#backend_marker>>(); let auth_context = ctx .data_opt::<::graphql_orm::graphql::orm::DbAuthContext>() @@ -962,18 +926,27 @@ pub(crate) fn generate_graphql_relations( <#target_type as ::graphql_orm::graphql::orm::Entity>::metadata().read_policy, ::graphql_orm::graphql::orm::EntityAccessKind::Read, ::graphql_orm::graphql::orm::EntityAccessSurface::GraphqlRelation, - ).await?; + ).await.map_err(::graphql_orm::graphql::loaders::relation_authorization_error)?; + let visibility = db.read_visibility::<#target_type>( + Some(ctx), ::graphql_orm::graphql::orm::EntityAccessSurface::GraphqlRelation, + ).await.map_err(::graphql_orm::graphql::loaders::relation_authorization_error)?; #source_binding_single - let result = if #source_supports_dataloader { + let result = if #source_supports_dataloader && ctx.data_opt::< + ::graphql_orm::async_graphql::dataloader::DataLoader< + ::graphql_orm::graphql::loaders::RelationLoader<#target_type, #backend_marker> + > + >().is_some() { use ::graphql_orm::graphql::loaders::RelationLoader; use ::graphql_orm::async_graphql::dataloader::DataLoader; let loader = ctx.data_unchecked::>>(); loader - .load_one(#single_relation_query_key) + .load_one(::graphql_orm::graphql::loaders::with_relation_visibility::<#target_type, #backend_marker>( + #single_relation_query_key, &visibility, + )?) .await - .map_err(|e| ::graphql_orm::async_graphql::Error::new(e.to_string()))? + .map_err(|e| ::graphql_orm::graphql::errors::OrmPublicError::internal(e.to_string()).into_graphql_error())? .and_then(|mut result| result.entities.drain(..).next()) } else { let mut relation_sql_values = relation_sql_values.clone(); @@ -982,11 +955,26 @@ pub(crate) fn generate_graphql_relations( } EntityQuery::<#target_type, #backend_marker>::new() .where_values(&#fallback_relation_clause, relation_sql_values) + .with_read_visibility(&visibility)? .fetch_one_with_auth(db, auth_context.as_ref()) .await - .map_err(|e| ::graphql_orm::async_graphql::Error::new(e.to_string()))? + .map_err(|e| ::graphql_orm::graphql::errors::OrmPublicError::internal(e.to_string()).into_graphql_error())? }; + if visibility.requires_residual_checks() { + if let Some(row) = result.as_ref() { + if !db.can_read_row( + Some(ctx), <#target_type as ::graphql_orm::graphql::orm::Entity>::entity_name(), + <#target_type as ::graphql_orm::graphql::orm::Entity>::metadata().read_policy, + ::graphql_orm::graphql::orm::EntityAccessSurface::GraphqlRelation, row, + ).await.map_err(::graphql_orm::graphql::loaders::relation_authorization_error)? { + return Err(::graphql_orm::graphql::errors::OrmPublicError::forbidden().into_graphql_error()); + } + } + } + if let Some(row) = result.as_ref() { + ::graphql_orm::graphql::loaders::authorize_relation_fields(db, ctx, ::std::slice::from_ref(row), false).await?; + } Ok(result) } }) diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index 707d06a1..b86ba1b3 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.33.4" +version = "0.33.5" edition = "2024" description = "Runtime support crate for graphql-orm-macros" license = "MIT" @@ -68,7 +68,7 @@ futures = "0.3" geo = { version = "0.33", optional = true, default-features = false } geo-types = { version = "0.7", optional = true } geojson = { version = "1", optional = true, default-features = true } -graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.33.4", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.33.5", default-features = false } graphql-orm-operation-catalog = { workspace = true } rust_decimal = { workspace = true } serde = { version = "1", features = ["derive"] } diff --git a/crates/graphql-orm/README.md b/crates/graphql-orm/README.md index 0bc5be3b..7fce0175 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -29,7 +29,7 @@ backend: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.33.4", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.33.5", default-features = false, features = ["sqlite"] } ``` This unpublished package has no docs.rs release. Use this Git README and the diff --git a/crates/graphql-orm/src/graphql/loaders.rs b/crates/graphql-orm/src/graphql/loaders.rs index ed916906..9dd003eb 100644 --- a/crates/graphql-orm/src/graphql/loaders.rs +++ b/crates/graphql-orm/src/graphql/loaders.rs @@ -164,6 +164,245 @@ pub struct RelationLoadResult { pub offset: i64, } +/// Generated resolver support. Sanitize host policy failures at a relationship +/// boundary while retaining the public authorization-misconfiguration code. +#[doc(hidden)] +pub fn relation_authorization_error(error: async_graphql::Error) -> async_graphql::Error { + use crate::graphql::errors::{OrmErrorCode, OrmPublicError}; + let code = if error + .extensions + .as_ref() + .and_then(|extensions| extensions.get("code")) + == Some(&async_graphql::Value::String( + "AUTHORIZATION_MISCONFIGURED".into(), + )) { + OrmErrorCode::AuthorizationMisconfigured + } else { + OrmErrorCode::Forbidden + }; + OrmPublicError::new(code) + .with_internal(format!("{error:?}")) + .into_graphql_error() +} + +/// Generated resolver support for current SQL visibility and batch partitioning. +#[doc(hidden)] +pub fn with_relation_visibility( + mut key: CompositeRelationQueryKey, + visibility: &crate::graphql::orm::ReadVisibility, +) -> async_graphql::Result +where + T: crate::graphql::orm::Entity + 'static, + B: OrmBackend, +{ + visibility.validate::()?; + if let Some(predicate) = visibility.predicate_expression() { + key.filter = Some(match key.filter { + Some(filter) => { + crate::graphql::orm::FilterExpression::And(vec![filter, predicate.clone()]) + } + None => predicate.clone(), + }); + key.where_signature = Some(format!("{:?}", key.filter)); + } + Ok(key) +} + +/// Generated resolver support. Check selected target fields on authoritative +/// rows before returning a relationship object. This also keeps a field denial +/// on a nullable target from exposing a partially resolved target object. +#[doc(hidden)] +pub async fn authorize_relation_fields( + db: &crate::db::Database, + ctx: &async_graphql::Context<'_>, + rows: &[T], + connection: bool, +) -> async_graphql::Result<()> +where + T: crate::graphql::orm::Entity + Send + Sync + 'static, + B: OrmBackend, +{ + // Use execution-pruned selections while retaining the real child context: + // field arguments, aliases, and path-dependent host policies must agree + // with ordinary target field resolution. + let fields = relation_selected_fields(ctx, &ctx.item.node.selection_set.node); + if connection { + for edge in fields + .iter() + .filter(|field| field.node.name.node.as_str().eq_ignore_ascii_case("edges")) + { + let edge_ctx = ctx.with_field(edge); + for node in relation_selected_fields(ctx, &edge.node.selection_set.node) + .into_iter() + .filter(|field| field.node.name.node.as_str().eq_ignore_ascii_case("node")) + { + let selected = relation_selected_fields(ctx, &node.node.selection_set.node); + for (index, row) in rows.iter().enumerate() { + let indexed_ctx = edge_ctx.with_index(index); + let node_ctx = indexed_ctx.with_field(node); + check_relation_fields(db, &node_ctx, &selected, ::std::slice::from_ref(row)) + .await?; + } + } + } + } else { + check_relation_fields(db, ctx, &fields, rows).await?; + } + Ok(()) +} + +fn relation_selected_fields<'a>( + ctx: &'a async_graphql::Context<'_>, + selection: &'a async_graphql::parser::types::SelectionSet, +) -> Vec<&'a async_graphql::Positioned> { + use async_graphql::parser::types::Selection; + let mut fields = Vec::new(); + for item in &selection.items { + match &item.node { + Selection::Field(field) => fields.push(field), + Selection::FragmentSpread(spread) => { + if let Some(fragment) = ctx.query_env.fragments.get(&spread.node.fragment_name.node) + { + fields.extend(relation_selected_fields( + ctx, + &fragment.node.selection_set.node, + )); + } + } + Selection::InlineFragment(fragment) => fields.extend(relation_selected_fields( + ctx, + &fragment.node.selection_set.node, + )), + } + } + fields +} + +async fn check_relation_fields( + db: &crate::db::Database, + ctx: &async_graphql::Context<'_>, + selected: &[&async_graphql::Positioned], + rows: &[T], +) -> async_graphql::Result<()> +where + T: crate::graphql::orm::Entity + Send + Sync + 'static, + B: OrmBackend, +{ + for row in rows { + for selection in selected { + let Some(field) = T::repository_field_policies().iter().find(|field| { + field.api_name == selection.node.name.node.as_str() && field.read_policy.is_some() + }) else { + continue; + }; + let field_ctx = ctx.with_field(selection); + db.ensure_readable_field( + &field_ctx, + T::entity_name(), + field.api_name, + field.read_policy, + Some(row), + ) + .await + .map_err(relation_authorization_error)?; + } + } + Ok(()) +} + +/// Generated resolver support for callback-only/prefiltered target policies. +/// Count authorized rows before paging, retaining only the requested window. +/// Exhausting the host's scan budget fails closed rather than publishing a +/// partial count or treating denied rows as end-of-data. +#[doc(hidden)] +pub async fn scan_authorized_relation( + db: &crate::db::Database, + ctx: &async_graphql::Context<'_>, + mut query: crate::graphql::orm::EntityQuery, + auth: Option<&DbAuthContext>, + visibility: &crate::graphql::orm::ReadVisibility, + page: crate::graphql::orm::PaginationRequest, +) -> async_graphql::Result> +where + B: OrmBackend, + T: BatchLoadEntity + crate::graphql::orm::Entity, +{ + use crate::graphql::errors::{OrmErrorCode, OrmPublicError}; + use crate::graphql::orm::{EntityAccessSurface, PageInput, PaginationConfig, SqlDialect}; + let invalid = + || OrmPublicError::new(OrmErrorCode::AuthorizationMisconfigured).into_graphql_error(); + let config = db.authorized_scan_config().ok_or_else(invalid)?; + if config.batch_size == 0 + || config.max_scanned_rows == 0 + || query.requires_in_memory_filtering() + { + return Err(invalid()); + } + query = query.with_read_visibility(visibility)?; + // Stable source batches must not depend on non-unique caller ordering alone. + for column in T::PRIMARY_KEYS { + if !query + .order_clauses + .iter() + .any(|clause| crate::graphql::orm::sort_clause_mentions_column(clause, column)) + { + query + .order_clauses + .push(format!("{} ASC", B::DIALECT.quote_identifier(column))); + } + } + let offset = page.offset.max(0); + let limit = page.limit.ok_or_else(invalid)?.max(0); + let mut entities = Vec::new(); + let mut total_count = 0_i64; + let mut scanned = 0_u32; + loop { + let size = config.batch_size.min(config.max_scanned_rows - scanned); + if size == 0 { + return Err(invalid()); + } + let rows = query + .clone() + .paginate(&PageInput { + limit: Some(i64::from(size)), + offset: Some(i64::from(scanned)), + }) + .fetch_all_with_auth_and_pagination(db, auth, PaginationConfig::unbounded()) + .await + .map_err(|error| OrmPublicError::from(error).into_graphql_error())?; + let short = rows.len() < size as usize; + scanned += rows.len() as u32; + for row in rows { + if db + .can_read_row( + Some(ctx), + T::entity_name(), + T::metadata().read_policy, + EntityAccessSurface::GraphqlRelation, + &row, + ) + .await + .map_err(relation_authorization_error)? + { + if total_count >= offset && entities.len() < limit as usize { + entities.push(row); + } + total_count += 1; + } + } + if short { + break; + } + } + Ok(RelationLoadResult { + has_next_page: offset.saturating_add(entities.len() as i64) < total_count, + has_previous_page: offset > 0, + entities, + total_count, + offset, + }) +} + #[derive(Clone, Debug, Hash, PartialEq, Eq)] struct RelationGroupKey { relation: &'static str, diff --git a/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs b/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs index 4eeb0b11..e9dbd36d 100644 --- a/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs +++ b/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs @@ -137,6 +137,13 @@ pub enum ReadVisibility { Unrestricted, } impl ReadVisibility { + pub(crate) fn predicate_expression(&self) -> Option<&FilterExpression> { + match self { + Self::Prefilter(predicate) | Self::Complete(predicate) => Some(&predicate.expression), + _ => None, + } + } + pub fn requires_residual_checks(&self) -> bool { matches!(self, Self::CallbackOnly | Self::Prefilter(_)) } diff --git a/crates/graphql-orm/tests/cross_crate_relations_fixture.rs b/crates/graphql-orm/tests/cross_crate_relations_fixture.rs index 386a99c3..1b923cd1 100644 --- a/crates/graphql-orm/tests/cross_crate_relations_fixture.rs +++ b/crates/graphql-orm/tests/cross_crate_relations_fixture.rs @@ -1,4 +1,4 @@ -#![cfg(feature = "sqlite")] +#![cfg(any(feature = "sqlite", feature = "postgres"))] #[test] fn generated_relationships_execute_across_real_crate_boundaries() { @@ -7,6 +7,11 @@ fn generated_relationships_execute_across_real_crate_boundaries() { "/../graphql-orm-macros/fixtures/cross-crate-relations/Cargo.toml" ); let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_owned()); + let backend = if cfg!(feature = "postgres") { + "postgres" + } else { + "sqlite" + }; let status = std::process::Command::new(cargo) .env("CARGO_BUILD_JOBS", "2") .env("CARGO_INCREMENTAL", "0") @@ -19,7 +24,7 @@ fn generated_relationships_execute_across_real_crate_boundaries() { "cross-crate-source-models", "--no-default-features", "--features", - "sqlite", + backend, "--lib", ]) .status() diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index 202f847d..3d38e493 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock @@ -1350,7 +1350,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.33.4" +version = "0.33.5" dependencies = [ "agql-auth", "async-graphql", @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.33.4" +version = "0.33.5" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock b/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock index 91fc4a16..c39614d4 100644 --- a/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock @@ -2508,7 +2508,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.33.4" +version = "0.33.5" dependencies = [ "async-graphql", "chacha20poly1305", @@ -2531,7 +2531,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.33.4" +version = "0.33.5" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock b/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock index 95db44e2..72c01869 100644 --- a/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock @@ -1004,7 +1004,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.33.4" +version = "0.33.5" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.33.4" +version = "0.33.5" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock index 17b60921..886c1bc0 100644 --- a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock @@ -1004,7 +1004,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.33.4" +version = "0.33.5" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.33.4" +version = "0.33.5" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/support/owned_postgres.rs b/crates/graphql-orm/tests/support/owned_postgres.rs index 19676ae8..cb36379e 100644 --- a/crates/graphql-orm/tests/support/owned_postgres.rs +++ b/crates/graphql-orm/tests/support/owned_postgres.rs @@ -58,6 +58,8 @@ impl OwnedPostgres { "exec", &owned.container_id, "pg_isready", + "-h", + "127.0.0.1", "-U", "graphql_orm_owner", "-d", diff --git a/docs/reference/graphql-orm/entities-and-relations.md b/docs/reference/graphql-orm/entities-and-relations.md index 08984203..3fa84182 100644 --- a/docs/reference/graphql-orm/entities-and-relations.md +++ b/docs/reference/graphql-orm/entities-and-relations.md @@ -567,8 +567,8 @@ nullable child selections and SQLite native statement traces. Multiple parents execute two target SELECTs, one dispatch for each source relationship. Current target entity denial leaves the authorized parent present, returns a null child and attaches a sanitized error to that child path. Missing targets cause no error. -This fixture does not establish the complete row/field/cache/preload authorization -contract; those checks remain a separate workstream. +The original compilation-only fix did not establish the complete row/field/cache/preload authorization +contract; the independent authority fix and its executable checks are described below. ```sh cargo run --manifest-path crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.toml \ @@ -585,3 +585,56 @@ PostgreSQL and MSSQL fixture lanes compile the same generated declarations with fix has SQLite execution evidence; these other lanes are compile-only, with no live SQL Server execution claimed. Private repository-backed generated view adapters and computed queries are independent capabilities, not added by this fix. + + +## Current authority for generated relationship targets + +Preloaded relationship fields are snapshots. Generated resolvers resolve the +current target from the database using every declared `from`/`to` member; +manually populated or cached objects cannot supply target authority or bypass +ownership reassignment. No additional public CRUD roots are required. Keep +immutable historical fields separate from current links. + +Every traversal checks target entity access and current request row visibility. +Callback row denial and selected-field denial return a safe error on the +relationship path. For nullable links the parent remains present and the child +is null. A missing target or mismatched ownership tuple ordinarily returns null +without error. SQL-complete visibility hides nonmatching targets as absent; +resolvers never probe around the predicate to distinguish hidden from missing. +Selected target fields are preflighted before returning the target; ordinary +field resolvers retain their checks. Preflight supplies the actual target field context, including arguments and +alias/path; providers may be evaluated again during ordinary field resolution. Aliases, fragments, +and execution-pruned skip/include selections retain their field identities. + +The generated batch hook uses `DataLoader>` with its default +`NoCache` storage. It batches concurrent loads without reusing results across +requests; other cache types are not accepted as authoritative targets and the +resolver falls back to a fresh typed query. Current visibility predicates and +canonical `DbAuthContext` participate in SQL batch grouping. A cursor, snapshot, +source record or cached row never grants target access. + +To-many SQL-complete visibility is applied before page windows and counts. +Callback-only/prefilter visibility uses the existing host-owned +`AuthorizedScanConfig`; bounded batches are examined to exhaustion, only the +requested visible page is retained, and counts include every authorized row. +Absent configuration or budget exhaustion fails closed with +`AUTHORIZATION_MISCONFIGURED`, without a partial count or false end-of-data. +These residual scans run per parent; SQL-complete batches remain shared across +parents. Generated parent operations no longer issue pool-only eager target preloads: a +nullable target denial is handled by its resolver rather than failing its parent +before traversal. Existing explicit bulk preload APIs remain source compatible but their snapshots +are not reused for GraphQL authority, so callers may avoid redundant preloads. +No cross-statement snapshot isolation under concurrent external DML is promised. + +Run the SQL-free standalone example: + +```sh +cargo run --manifest-path crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.toml --locked -p cross-crate-source-models --no-default-features --features sqlite --example authorized_links +``` + +The [two-crate executable fixture](../../../crates/graphql-orm-macros/fixtures/cross-crate-relations/source-models/src/lib.rs) +checks current entity/row/field policies, preloaded forgeries, moved ownership, +reused schema/loader requests, two identities, expired/source-denied identities, +nullable error paths, alias/fragment selection and bounded page/count semantics +on SQLite and test-owned disposable PostgreSQL. SQLite tracing also measures +real multi-parent target dispatches. MSSQL coverage is compilation only. diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 7ef1248a..42606651 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -18,11 +18,11 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | -| `graphql-orm` | `0.33.4` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.33.5` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-ai` | `0.106.0` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.15.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.7.2` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | -| `graphql-orm-macros` | `0.33.4` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.33.5` | `crates/graphql-orm-macros` | `sqlite` | none | | `graphql-orm-operation-catalog` | `0.4.0` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | | `graphql-orm-router` | `0.5.3` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none |