diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c8519b4..72628fe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,19 @@ 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.35.2 - 2026-10-01 + +Companion macros crate: `graphql-orm-macros` **0.35.2** (alignment only). + +- Allow transaction-bound repository `count` and `exists` with an installed + row policy when its current visibility is explicitly unrestricted or complete + SQL authorization. Intersect complete predicates with caller filters before + the database count, on the same pinned transaction as surrounding writes. +- Retain entity authorization and reject callback-only/prefilter policies, + wrong-entity predicates and residual filters under an installed row policy. + Counts do not decode rows or invoke per-row callbacks. Existing no-policy + behavior, public APIs, GraphQL SDL and stored data are unchanged. + ## 0.35.1 - 2026-10-01 Companion macros crate: `graphql-orm-macros` **0.35.1**. diff --git a/Cargo.lock b/Cargo.lock index 41c1e4c0..ba03168c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3146,7 +3146,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.35.1" +version = "0.35.2" dependencies = [ "agql-auth", "async-graphql", @@ -3245,7 +3245,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.35.1" +version = "0.35.2" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 9ed80644..a5d5df9d 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.35.1", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.35.2", 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 9310587f..2f2420a7 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -13,6 +13,20 @@ 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. +## Policy-aware transaction counts (0.35.2) + +Adopt aligned runtime/macros 0.35.2. No schema, stored-data, public API or +GraphQL SDL migration is required. Transaction-bound repository `count` and +`exists` now accept current explicit `Unrestricted` visibility or a complete +SQL predicate from the installed row policy. Complete predicates are combined +with caller filters before counting on the existing pinned transaction. + +Entity authorization remains required. Callback-only/prefilter visibility and +residual query filters under an installed policy remain rejected; no full-row +scan replaces SQL authorization. Re-evaluate verified identity and ownership +for every call and never declare a partial predicate complete. Existing +no-policy behavior is retained. See [repository helpers](docs/reference/graphql-orm/runtime-and-writes.md#repository-helpers). + ## Policy-aware typed projections (0.35.1) Adopt aligned ORM/macros 0.35.1. No schema or GraphQL SDL migration is required. diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index a35e69ca..f7cf6ac6 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.35.1" +version = "0.35.2" 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 06ea3263..418a352d 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.35.1", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.35.2", 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.35.1", default-features = false, features = ["sqlite"] } +graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.35.2", default-features = false, features = ["sqlite"] } ``` The direct dependency still requires a compatible `graphql-orm` runtime in the diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index 4e375e80..d394800e 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.35.1" +version = "0.35.2" 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.35.1", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.35.2", 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 08a38f64..46a2b450 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.35.1", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.35.2", 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/orm/core.rs b/crates/graphql-orm/src/graphql/orm/core.rs index cb29ef05..0c0130ec 100644 --- a/crates/graphql-orm/src/graphql/orm/core.rs +++ b/crates/graphql-orm/src/graphql/orm/core.rs @@ -983,13 +983,22 @@ where ) .await .map_err(|error| sqlx::Error::Protocol(format!("{error:?}")))?; - if self.hook_ctx.database().row_policy().is_some() { + let database = self.hook_ctx.database(); + let visibility = database + .read_visibility::(None, EntityAccessSurface::Repository) + .await + .map_err(crate::graphql::errors::sqlx_error_from_graphql)?; + if visibility.requires_residual_checks() + || (database.row_policy().is_some() && self.query.requires_in_memory_filtering()) + { return Err(sqlx::Error::Protocol( "repository count reads require database-visible row-policy predicates".to_string(), )); } let query = self.query; - query.count_in_transaction(self.hook_ctx).await + query + .count_with_visibility_in_transaction(self.hook_ctx, visibility.predicate_expression()) + .await } pub async fn exists(self) -> crate::Result { diff --git a/crates/graphql-orm/src/graphql/orm/query.rs b/crates/graphql-orm/src/graphql/orm/query.rs index 27948140..e375b40c 100644 --- a/crates/graphql-orm/src/graphql/orm/query.rs +++ b/crates/graphql-orm/src/graphql/orm/query.rs @@ -4075,15 +4075,39 @@ where &self, context: &mut MutationContext<'_, B>, ) -> crate::Result + where + B: WriteBackend, + { + self.count_with_visibility_in_transaction(context, None) + .await + } + + #[cfg(any(feature = "sqlite", feature = "postgres", feature = "mssql"))] + pub(crate) async fn count_with_visibility_in_transaction( + &self, + context: &mut MutationContext<'_, B>, + visibility: Option<&FilterExpression>, + ) -> crate::Result where B: WriteBackend, { if self.requires_in_memory_filtering() { + if visibility.is_some() { + return Err(sqlx::Error::Protocol( + "SQL-authorized counts require database-visible query filters".to_string(), + )); + } let mut query = self.clone(); query.page = None; return Ok(query.fetch_all_in_transaction(context).await?.len() as i64); } let mut query = self.build_select_query()?; + if let Some(predicate) = visibility { + query.filter = Some(match query.filter { + Some(filter) => FilterExpression::And(vec![filter, predicate.clone()]), + None => predicate.clone(), + }); + } query.count_only = true; query.pagination = None; query.sorts.clear(); diff --git a/crates/graphql-orm/tests/repository_counts.rs b/crates/graphql-orm/tests/repository_counts.rs new file mode 100644 index 00000000..10f7052e --- /dev/null +++ b/crates/graphql-orm/tests/repository_counts.rs @@ -0,0 +1,273 @@ +#![cfg(feature = "sqlite")] + +use graphql_orm::prelude::*; + +#[derive(RepositoryEntity, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[repository_entity(backend = "sqlite", table = "count_rows", plural = "CountRows")] +struct CountRow { + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "string")] + id: String, + #[filterable(type = "string")] + tenant: String, +} + +#[derive(Clone, Copy)] +enum Visibility { + Unrestricted, + Complete, + CallbackOnly, + Prefilter, + WrongEntity, +} + +#[derive(RepositoryEntity, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[repository_entity(backend = "sqlite", table = "other_rows", plural = "OtherRows")] +struct OtherRow { + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "string")] + id: String, +} + +impl RowPolicy for Visibility { + fn read_visibility<'a>( + &'a self, + ctx: Option<&'a async_graphql::Context<'_>>, + _db: &'a Database, + _entity: &'static str, + _key: Option<&'static str>, + surface: EntityAccessSurface, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + assert!(ctx.is_none()); + assert_eq!(surface, EntityAccessSurface::Repository); + Box::pin(async move { + Ok(match self { + Self::Unrestricted => ReadVisibility::Unrestricted, + Self::CallbackOnly => ReadVisibility::CallbackOnly, + Self::WrongEntity => { + ReadVisibility::Complete(ReadPredicate::from_filter::( + &OtherRowWhereInput { + id: Some(StringFilter { + eq: Some("1".into()), + ..Default::default() + }), + ..Default::default() + }, + )?) + } + Self::Complete | Self::Prefilter => { + let predicate = + ReadPredicate::from_filter::(&CountRowWhereInput { + tenant: Some(StringFilter { + eq: Some("alpha".into()), + ..Default::default() + }), + ..Default::default() + })?; + if matches!(self, Self::Complete) { + ReadVisibility::Complete(predicate) + } else { + ReadVisibility::Prefilter(predicate) + } + } + }) + }) + } + + fn can_read_row<'a>( + &'a self, + _ctx: Option<&'a async_graphql::Context<'_>>, + _db: &'a Database, + _entity: &'static str, + _key: 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 { panic!("SQL counts must not decode rows for callbacks") }) + } + + fn can_write_row<'a>( + &'a self, + _ctx: Option<&'a async_graphql::Context<'_>>, + _db: &'a Database, + _entity: &'static str, + _key: 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 { Ok(true) }) + } +} + +struct DenyEntity; + +impl EntityPolicy for DenyEntity { + fn can_access_entity<'a>( + &'a self, + _ctx: Option<&'a async_graphql::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 { Ok(false) }) + } +} + +async fn fixture(visibility: Option) -> Database { + let mut db = Database::::connect_sqlite("sqlite::memory:") + .await + .unwrap(); + sqlx::query("CREATE TABLE count_rows(id TEXT PRIMARY KEY, tenant TEXT NOT NULL)") + .execute(db.pool()) + .await + .unwrap(); + sqlx::query("INSERT INTO count_rows VALUES('1','alpha'),('2','beta'),('3','alpha')") + .execute(db.pool()) + .await + .unwrap(); + if let Some(policy) = visibility { + db.set_row_policy(policy); + } + db +} + +#[tokio::test] +async fn unrestricted_policy_counts_inside_repository_transaction() { + let db = fixture(Some(Visibility::Unrestricted)).await; + let total = db + .transaction(TransactionMode::StateMachine, |tx| { + Box::pin(async move { Ok(tx.query::().count().await?) }) + }) + .await + .unwrap(); + assert_eq!(total, 3); +} + +#[tokio::test] +async fn complete_visibility_intersects_caller_filter_and_exists() { + let db = fixture(Some(Visibility::Complete)).await; + let result = db + .transaction(TransactionMode::StateMachine, |tx| { + Box::pin(async move { + let visible = tx.query::().count().await?; + let hidden = tx + .query::() + .filter(CountRowWhereInput { + tenant: Some(StringFilter { + eq: Some("beta".into()), + ..Default::default() + }), + ..Default::default() + }) + .exists() + .await?; + Ok((visible, hidden)) + }) + }) + .await + .unwrap(); + assert_eq!(result, (2, false)); +} + +#[tokio::test] +async fn callback_and_prefilter_counts_remain_rejected() { + for policy in [Visibility::CallbackOnly, Visibility::Prefilter] { + let db = fixture(Some(policy)).await; + assert!( + db.transaction(TransactionMode::StateMachine, |tx| { + Box::pin(async move { Ok(tx.query::().count().await?) }) + }) + .await + .is_err() + ); + } +} + +#[tokio::test] +async fn no_policy_count_keeps_existing_behavior() { + let db = fixture(None).await; + let count = db + .transaction(TransactionMode::StateMachine, |tx| { + Box::pin(async move { Ok(tx.query::().count().await?) }) + }) + .await + .unwrap(); + assert_eq!(count, 3); +} + +#[tokio::test] +async fn wrong_entity_predicate_is_rejected_before_sql() { + let db = fixture(Some(Visibility::WrongEntity)).await; + assert!( + db.transaction(TransactionMode::StateMachine, |tx| { + Box::pin(async move { Ok(tx.query::().count().await?) }) + }) + .await + .is_err() + ); +} + +#[tokio::test] +async fn unrestricted_visibility_does_not_override_entity_denial() { + let mut db = fixture(Some(Visibility::Unrestricted)).await; + db.set_entity_policy(DenyEntity); + assert!( + db.transaction(TransactionMode::StateMachine, |tx| { + Box::pin(async move { Ok(tx.query::().count().await?) }) + }) + .await + .is_err() + ); +} + +#[tokio::test] +async fn counts_use_the_current_policy_instead_of_reusing_visibility() { + let mut db = fixture(Some(Visibility::Unrestricted)).await; + let before = db + .transaction(TransactionMode::StateMachine, |tx| { + Box::pin(async move { Ok(tx.query::().count().await?) }) + }) + .await + .unwrap(); + db.set_row_policy(Visibility::Complete); + let after = db + .transaction(TransactionMode::StateMachine, |tx| { + Box::pin(async move { Ok(tx.query::().count().await?) }) + }) + .await + .unwrap(); + assert_eq!((before, after), (3, 2)); +} + +#[tokio::test] +async fn count_sees_writes_in_the_same_transaction_and_rollback_keeps_them_private() { + let db = fixture(Some(Visibility::Complete)).await; + let result: Result<(), _> = db + .transaction(TransactionMode::StateMachine, |tx| { + Box::pin(async move { + tx.insert::(CreateCountRowInput { + id: "4".into(), + tenant: "alpha".into(), + }) + .await?; + assert_eq!(tx.query::().count().await?, 3); + Err(OrmPublicError::with_message( + OrmErrorCode::Conflict, + "deliberate rollback", + )) + }) + }) + .await; + assert!(result.is_err()); + let after = db + .transaction(TransactionMode::StateMachine, |tx| { + Box::pin(async move { Ok(tx.query::().count().await?) }) + }) + .await + .unwrap(); + assert_eq!(after, 2); +} diff --git a/docs/reference/graphql-orm/runtime-and-writes.md b/docs/reference/graphql-orm/runtime-and-writes.md index 15717adf..9fe2e4d3 100644 --- a/docs/reference/graphql-orm/runtime-and-writes.md +++ b/docs/reference/graphql-orm/runtime-and-writes.md @@ -99,6 +99,15 @@ let users = User::find_many( let total = User::count_all(&database).await?; ``` +Inside `Database::transaction`, `tx.query::().count()` and `.exists()` retain +repository entity authorization. An installed row policy must declare current +`ReadVisibility::Unrestricted` or `Complete` SQL visibility; complete predicates +are intersected with caller filters before the count on the same pinned +transaction. Callback-only/prefilter policies and residual query filters under +an installed row policy are rejected. The SQL count does not materialize entity +rows or invoke per-row callbacks. With no row policy, existing residual-filter +behavior remains available. + Write-capable entities generate: - `insert(&database, input)` and `insert_many(&database, inputs)` diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index d6e51d84..f58f23fd 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.35.1` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.35.2` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-ai` | `0.106.1` | `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.35.1` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.35.2` | `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 |