diff --git a/CHANGELOG.md b/CHANGELOG.md index 0810262a..5e130ae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,12 +16,21 @@ for historical context. ## Unreleased -Companion runtime/macros version: **0.58.0**. +Companion runtime/macros version: **0.59.0**. - Extend explicit `timestamp = "host"` to public GraphQLEntity fields and ordinary String timestamps (including optional forms). Caller-supplied source timestamps stay unchanged instead of receiving the ORM mutation clock. Existing automatic timestamp behavior remains the default. Policies, hooks and field restrictions still apply. +- Revalidate configured cursor ordering and all composite key fields before SQL + and before release, independently of the selected payload. Reject hidden raw + cursor values on GraphQL keyset and scan surfaces. + +- Generate static keyset reads and authorized scans for entities with composite primary keys. Configured order must end with the complete primary-key tuple in declaration order, with no duplicate order fields. Every component is retained in cursor ordering; existing authorization, limits and read-only schema posture remain in force. + ## 0.57.0 (2026-10-22) +- Reject private configured cursor fields despite explicit read/order surface + overrides, before GraphQL keyset or scan reads; preserve repository policy. + Companion macros crate: `graphql-orm-macros` **0.57.0**. - Add the SQL Server-only `#[graphql_orm(mssql_money)]` field domain for exact diff --git a/Cargo.lock b/Cargo.lock index 230ad5cb..bf0e13a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3113,7 +3113,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.58.0" +version = "0.59.0" dependencies = [ "agql-auth", "async-graphql", @@ -3217,7 +3217,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.58.0" +version = "0.59.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index f7c33caa..0cc96316 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,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.58.0", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.59.0", default-features = false } graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.16.1" } graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.10.0", default-features = false } graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.4.1" } diff --git a/MIGRATION.md b/MIGRATION.md index 7213fd1f..7ace34b1 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -19,6 +19,21 @@ published to crates.io. Upgrade runtime/macros together. Public GraphQLEntity and RepositoryEntity timestamp fields may explicitly use `#[graphql_orm(timestamp = "host")]` on ordinary `i64`, `String`, or optional forms whose Rust or physical name is `created_at` or `updated_at`. For an external source timestamp mapped to physical `updated_at`, opt in on the mapped field to prevent automatic epoch assignment. Values are bound unchanged; String content is caller-owned and is not parsed or normalized as a date. Existing automatic behavior remains unchanged without the annotation. The annotation does not change stored type/default metadata; conventional Rust names still need `default = false` to suppress their implicit default when appropriate. Schema-only, generated/version/key/relation fields and date/JSON/boolean adapters remain rejected. No data or schema migration is required merely to opt in; preserve field/row authorization and distinguish source observation time from collection and mutation clocks. +## 0.58.0 to 0.59.0: composite-primary-key keysets + +Explicit `read = true` or `order = true` never overrides a private configured +GraphQL cursor field. Keyset and scan queries reject it before the first read; +trusted repository pagination retains its independent field policy. + +Configured cursors carry raw ordering values, so their fields now require current +read authority even when clients select only `cursor`. GraphQL rejects private, +skipped and transformed cursor fields. Repository policies remain independent. +Manual `AuthorizedKeysetEntity` implementations must supply `KEYSET_CURSOR_FIELDS`; +missing field metadata fails closed. Generated derives populate it automatically. +Ordering, fingerprints and cursor bytes remain unchanged for authorized reads. + +Upgrade runtime/macros together. Composite-key entities may now declare `keyset = "rank desc, partition_key asc, item_no asc"` when `(partition_key, item_no)` is the primary key in declaration order. Every primary-key member must appear as the final unique tiebreaker tuple; order fields may not repeat. The generated keyset and scan read surfaces use the existing bounded, authorized cursor implementation. Existing valid single-key orders/cursors are unchanged. No generated GraphQL writes or subscriptions are enabled for composite-key entities by this change. No data or schema migration is required, and an externally managed source stays externally managed. + ## 0.56.0 to 0.57.0: exact SQL Server MONEY reads Upgrade `graphql-orm` and `graphql-orm-macros` together to 0.57.0 using one diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index 02b043d4..d284b97e 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.58.0" +version = "0.59.0" 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 03de127d..ce6965f4 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -14,6 +14,19 @@ Version 0.58.0 extends explicit host-owned timestamps to public entities and Str values without replacing source timestamps with the mutation clock. See the [migration guide](../../MIGRATION.md#0570-to-0580-public-host-owned-timestamps). +GraphQL-private configured ordering fields remain unavailable even when +`read = true` or `order = true` is explicitly set. Repository field policy and +permitted repository pagination retain their independent behavior. + +Configured keyset cursors require current read authority for every order field +and complete primary-key suffix, even for cursor-only selections. Checks run +before queries and again against returned rows. Private, skipped or transformed +GraphQL order fields fail closed; repository field policy remains independent. + +Version 0.59.0 supports static keyset reads with a complete composite-primary-key +tiebreaker tuple. Schema ownership and write posture are unchanged. See the +[migration guide](../../MIGRATION.md#0580-to-0590-composite-primary-key-keysets). + Version 0.57.0 adds the SQL Server-only `#[graphql_orm(mssql_money)]` field annotation and identifier-safe exact read projections for native `MONEY`. It pairs with runtime 0.57.0; see the 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 fd0a9df1..e29956ae 100644 --- a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock +++ b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock @@ -1049,7 +1049,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.58.0" +version = "0.59.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1076,7 +1076,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.58.0" +version = "0.59.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock b/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock index c72ad0a2..5713ab16 100644 --- a/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock +++ b/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock @@ -1110,7 +1110,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.58.0" +version = "0.59.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1152,7 +1152,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.58.0" +version = "0.59.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm-macros/src/operations.rs b/crates/graphql-orm-macros/src/operations.rs index 46c80711..cbd29793 100644 --- a/crates/graphql-orm-macros/src/operations.rs +++ b/crates/graphql-orm-macros/src/operations.rs @@ -1534,12 +1534,6 @@ pub(crate) fn generate_graphql_operations( } } let keyset_parts = if let Some(config) = entity_meta.keyset.as_deref() { - if has_composite_primary_key { - return Err(syn::Error::new_spanned( - struct_name, - "keyset pagination currently requires a single-column primary key", - )); - } let mut parts = Vec::new(); for raw in config.split(',') { let tokens = raw.split_whitespace().collect::>(); @@ -1597,16 +1591,32 @@ pub(crate) fn generate_graphql_operations( nulls_first, )); } - if parts.is_empty() - || parts - .last() - .is_none_or(|(field, _, _, _, _)| field != &pk_field) - { + let complete_key_suffix = parts.len() >= primary_key_fields.len() + && parts[parts.len().saturating_sub(primary_key_fields.len())..] + .iter() + .zip(&primary_key_fields) + .all(|((field, _, _, _, _), key)| field == &key.field_name); + if !complete_key_suffix { return Err(syn::Error::new_spanned( struct_name, - "keyset order must end with the primary key as a unique tiebreaker", + if has_composite_primary_key { + "keyset order must end with every primary-key field in declaration order as a unique tiebreaker" + } else { + "keyset order must end with the primary key as a unique tiebreaker" + }, )); } + if has_composite_primary_key { + let mut ordered_fields = std::collections::BTreeSet::new(); + for (field, _, _, _, _) in &parts { + if !ordered_fields.insert(field.to_string()) { + return Err(syn::Error::new_spanned( + field, + "composite keyset order must not repeat a field", + )); + } + } + } Some(parts) } else { None @@ -4263,9 +4273,34 @@ pub(crate) fn generate_graphql_operations( let keyset_generated = if let Some(parts) = &keyset_parts { let mut order_tokens = Vec::new(); let mut cursor_tokens = Vec::new(); + let mut cursor_field_metadata = Vec::new(); let mut fingerprint_parts = Vec::new(); for (field, ty, column, ascending, nulls_first) in parts { let quoted_column = backend_quote_identifier_path(backend, column); + let source = fields + .iter() + .find(|candidate| candidate.ident.as_ref() == Some(field)) + .expect("keyset field was resolved during parsing"); + let meta = parse_field_metadata(source)?; + let api_name = graphql_field_name( + &meta, + &field.to_string(), + graphql_rename_fields, + serde_rename_all, + ); + let graphql_readable = meta.read + && !meta.is_private + && !meta.input_only + && !meta.graphql_skip + && meta.transform_read.is_none() + && meta.transform_write.is_none(); + let policy = match meta.read_policy.as_deref() { + Some(policy) => quote! { Some(#policy) }, + None => quote! { None }, + }; + cursor_field_metadata.push(quote! { ::graphql_orm::graphql::orm::KeysetCursorField { + column: #quoted_column, api_name: #api_name, read_policy: #policy, graphql_readable: #graphql_readable, + } }); let direction = if *ascending { quote! { ::graphql_orm::graphql::orm::KeysetOrderColumn::asc(#quoted_column) } } else { @@ -4333,6 +4368,7 @@ pub(crate) fn generate_graphql_operations( } impl ::graphql_orm::graphql::orm::AuthorizedKeysetEntity for #struct_name { + const KEYSET_CURSOR_FIELDS: &'static [::graphql_orm::graphql::orm::KeysetCursorField] = &[#(#cursor_field_metadata),*]; const KEYSET_ORDER: &'static [::graphql_orm::graphql::orm::KeysetOrderColumn] = &[#(#order_tokens),*]; const KEYSET_FINGERPRINT: &'static str = #fingerprint; fn keyset_values(&self) -> Vec<::graphql_orm::graphql::pagination::KeysetValue> { @@ -9321,3 +9357,55 @@ pub(crate) fn generate_graphql_operations( } }) } + +#[cfg(test)] +mod composite_keyset_tests { + use super::generate_graphql_operations; + + fn entity(order: &str) -> syn::DeriveInput { + let order = syn::LitStr::new(order, proc_macro2::Span::call_site()); + syn::parse_quote! { + #[graphql_entity(table = "composite_keyset_fixture", keyset = #order)] + struct CompositeKeysetFixture { + #[primary_key] + #[graphql_orm(auto_generated = false)] + partition_key: String, + #[primary_key] + #[graphql_orm(auto_generated = false)] + item_no: i64, + rank: i64, + } + } + } + + #[test] + fn complete_tuple_is_required_as_the_unique_order_suffix() { + assert!( + generate_graphql_operations(&entity("rank desc, partition_key asc, item_no asc")) + .is_ok() + ); + for order in [ + "rank desc, partition_key asc", + "rank desc, item_no asc", + "rank desc, item_no asc, partition_key asc", + ] { + let error = generate_graphql_operations(&entity(order)).unwrap_err(); + assert!( + error.to_string().contains("every primary-key field"), + "{error}" + ); + } + } + + #[test] + fn duplicate_composite_order_fields_are_rejected() { + let error = generate_graphql_operations(&entity( + "partition_key asc, rank desc, partition_key asc, item_no asc", + )) + .unwrap_err(); + assert!( + error.to_string().contains("must not repeat a field"), + "{error}" + ); + } +} diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index 12339f34..e586774d 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.58.0" +version = "0.59.0" edition = "2024" description = "Runtime support crate for graphql-orm-macros" license = "MIT" @@ -73,7 +73,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.58.0", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.59.0", 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 c8ad373a..14d1ebe8 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -14,6 +14,19 @@ Version 0.58.0 extends explicit host-owned timestamps to public entities and Str values without replacing source timestamps with the mutation clock. See the [migration guide](../../MIGRATION.md#0570-to-0580-public-host-owned-timestamps). +GraphQL-private configured ordering fields remain unavailable even when +`read = true` or `order = true` is explicitly set. Repository field policy and +permitted repository pagination retain their independent behavior. + +Configured keyset cursors require current read authority for every order field +and complete primary-key suffix, even for cursor-only selections. Checks run +before queries and again against returned rows. Private, skipped or transformed +GraphQL order fields fail closed; repository field policy remains independent. + +Version 0.59.0 supports static keyset reads with a complete composite-primary-key +tiebreaker tuple. Schema ownership and write posture are unchanged. See the +[migration guide](../../MIGRATION.md#0580-to-0590-composite-primary-key-keysets). + Version 0.57.0 adds exact SQL Server `MONEY` typed reads through `#[graphql_orm(mssql_money)]`. The generated read projection casts the native column to `DECIMAL(19,4)` under its original alias before decoding; ordinary diff --git a/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs b/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs index 79bf7e7a..02bc3125 100644 --- a/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs +++ b/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs @@ -168,9 +168,22 @@ impl ReadVisibility { pub trait AuthorizedKeysetEntity: Entity { const KEYSET_ORDER: &'static [KeysetOrderColumn]; const KEYSET_FINGERPRINT: &'static str; + /// Authority for every configured ordering value carried by a cursor. + /// Missing metadata fails closed; generated derives supply it. + const KEYSET_CURSOR_FIELDS: &'static [KeysetCursorField] = &[]; fn keyset_values(&self) -> Vec; } +/// Configured ordering metadata, independent of GraphQL payload selection. +#[derive(Clone, Copy, Debug)] +pub struct KeysetCursorField { + pub column: &'static str, + pub api_name: &'static str, + pub read_policy: Option<&'static str>, + /// Raw cursor values may be public only for readable, untransformed scalars. + pub graphql_readable: bool, +} + /// Diagnostics for actual fetched database rows, before entity decoding. /// SQL contains placeholders; parameter values are deliberately omitted. pub trait ReadQueryObserver: Send + Sync { @@ -367,6 +380,41 @@ where B: OrmBackend, T: AuthorizedKeysetEntity + FromSqlRow + Clone + Send + Sync + 'static, { + async fn authorize_keyset_order_fields( + db: &Database, + ctx: Option<&async_graphql::Context<'_>>, + order: &[KeysetOrderColumn], + row: Option<&T>, + ) -> async_graphql::Result<()> { + for part in order { + let field = T::KEYSET_CURSOR_FIELDS + .iter() + .find(|field| field.column == part.column) + .ok_or_else(invalid)?; + let (api_name, policy, graphql_readable) = + (field.api_name, field.read_policy, field.graphql_readable); + let record = row.map(|row| row as &(dyn std::any::Any + Send + Sync)); + if let Some(ctx) = ctx { + if !graphql_readable { + return Err(OrmPublicError::forbidden().into_graphql_error()); + } + db.ensure_readable_field(ctx, T::entity_name(), api_name, policy, record) + .await?; + } else { + db.ensure_repository_readable_field( + None, + T::entity_name(), + api_name, + policy, + record, + ) + .await + .map_err(|error| error.into_graphql_error())?; + } + } + Ok(()) + } + fn keyset_bound( mut self, values: &[KeysetValue], @@ -475,6 +523,7 @@ where .map_err(|e| e.into_graphql_error())?; let backward = page.direction == KeysetWindowDirection::Backward; let visibility = self.authorize_pagination(db, ctx).await?; + Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, None).await?; if visibility.requires_residual_checks() && (ctx.is_some() || page.include_total_count) { return Err(invalid()); } @@ -547,6 +596,10 @@ where (has_more, opposite) }; Self::authorize_page_fields(db, ctx, &nodes).await?; + for node in &nodes { + Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, Some(node)).await?; + } + Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, None).await?; let edges = nodes .into_iter() .map(|node| Edge { @@ -577,6 +630,7 @@ where page: AuthorizedScanInput, ) -> async_graphql::Result> { let visibility = self.authorize_pagination(db, ctx).await?; + Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, None).await?; let config = db.authorized_scan_config().ok_or_else(invalid)?; if config.batch_size == 0 || config.max_scanned_rows == 0 { return Err(invalid()); @@ -659,6 +713,10 @@ where } }; Self::authorize_page_fields(db, ctx, &nodes).await?; + for node in &nodes { + Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, Some(node)).await?; + } + Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, None).await?; let continuation = if status == ScanStatus::Exhausted { None } else { diff --git a/crates/graphql-orm/tests/composite_keyset_pagination.rs b/crates/graphql-orm/tests/composite_keyset_pagination.rs new file mode 100644 index 00000000..f28dc7cc --- /dev/null +++ b/crates/graphql-orm/tests/composite_keyset_pagination.rs @@ -0,0 +1,224 @@ +#![cfg(feature = "sqlite")] + +use graphql_orm::prelude::*; +use std::collections::BTreeSet; + +#[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[graphql_entity( + table = "composite_keyset_records", + plural = "CompositeKeysetRecords", + keyset = "rank desc nulls last, partition_key asc, item_no asc" +)] +struct CompositeRecord { + #[primary_key] + #[sortable] + #[graphql_orm(auto_generated = false, db_column = "partition")] + #[filterable(type = "string")] + partition_key: String, + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "number")] + item_no: i64, + #[filterable(type = "number")] + rank: Option, +} + +schema_roots! { + query_custom_ops: [], + generated_mutations: "none", + entities: [CompositeRecord], +} + +async fn setup() -> graphql_orm::Result> { + let database = Database::::connect_sqlite("sqlite::memory:").await?; + // This database and schema are disposable fixture-owned state, never an external source. + sqlx::query("CREATE TABLE composite_keyset_records (\"partition\" TEXT NOT NULL, item_no INTEGER NOT NULL, rank INTEGER, PRIMARY KEY (\"partition\", item_no))") + .execute(database.pool()).await?; + for (partition, item, rank) in [ + ("a", 1, Some(3_i64)), + ("a", 2, Some(3)), + ("b", 1, Some(3)), + ("b", 2, Some(2)), + ("a", 3, None), + ("b", 3, None), + ] { + sqlx::query( + "INSERT INTO composite_keyset_records (\"partition\", item_no, rank) VALUES (?, ?, ?)", + ) + .bind(partition) + .bind(item) + .bind(rank) + .execute(database.pool()) + .await?; + } + Ok(database) +} + +#[tokio::test] +async fn forward_pages_keep_complete_composite_identity_and_nullable_order() +-> graphql_orm::Result<()> { + let database = setup().await?; + let mut after = None; + let mut keys = Vec::new(); + for page_index in 0..4 { + let page = CompositeRecord::keyset_page( + &database, + CompositeRecordWhereInput::default(), + KeysetPageInput { + after, + limit: Some(2), + include_total_count: false, + }, + ) + .await + .expect("composite keyset page"); + keys.extend( + page.edges + .into_iter() + .map(|edge| (edge.node.partition_key, edge.node.item_no)), + ); + if page_index == 0 { + sqlx::query("INSERT INTO composite_keyset_records (\"partition\", item_no, rank) VALUES ('new-before-cursor', 1, 99)") + .execute(database.pool()).await?; + } + if !page.page_info.has_next_page { + break; + } + after = page.page_info.end_cursor; + } + assert_eq!( + keys, + vec![ + ("a".into(), 1), + ("a".into(), 2), + ("b".into(), 1), + ("b".into(), 2), + ("a".into(), 3), + ("b".into(), 3) + ] + ); + assert_eq!(keys.iter().collect::>().len(), 6); + Ok(()) +} + +#[tokio::test] +async fn backward_pages_keep_the_complete_key_tiebreaker() -> graphql_orm::Result<()> { + let database = setup().await?; + let tail = CompositeRecord::keyset_connection_page( + &database, + CompositeRecordWhereInput::default(), + graphql_orm::graphql::pagination::KeysetConnectionInput { + last: Some(2), + ..Default::default() + }, + ) + .await + .expect("composite tail"); + assert_eq!( + tail.edges + .iter() + .map(|edge| (edge.node.partition_key.as_str(), edge.node.item_no)) + .collect::>(), + [("a", 3), ("b", 3)] + ); + let older = CompositeRecord::keyset_connection_page( + &database, + CompositeRecordWhereInput::default(), + graphql_orm::graphql::pagination::KeysetConnectionInput { + last: Some(2), + before: tail.page_info.start_cursor, + ..Default::default() + }, + ) + .await + .expect("composite older window"); + assert_eq!( + older + .edges + .iter() + .map(|edge| (edge.node.partition_key.as_str(), edge.node.item_no)) + .collect::>(), + [("b", 1), ("b", 2)] + ); + Ok(()) +} + +#[tokio::test] +async fn generated_composite_keyset_is_admitted_without_graphql_writes() -> graphql_orm::Result<()> +{ + let schema = schema_builder(setup().await?) + .data("test-user".to_string()) + .finish(); + let sdl = schema.sdl(); + assert!(sdl.contains("compositeKeysetRecordsKeyset(")); + assert!(sdl.contains("compositeKeysetRecordsScan(")); + assert!(!sdl.contains("createCompositeRecord(")); + let response = schema.execute("{ compositeKeysetRecordsKeyset(page: {limit: 2}) { edges { node { partitionKey itemNo } } } }").await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data.into_json().unwrap()["compositeKeysetRecordsKeyset"]["edges"] + .as_array() + .unwrap() + .len(), + 2 + ); + Ok(()) +} + +struct CompositeCursorAuthority(&'static str); +impl FieldPolicy for CompositeCursorAuthority { + fn can_read_field<'a>( + &'a self, + _: &'a async_graphql::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 { Ok(field != self.0) }) + } + fn can_write_field<'a>( + &'a self, + _: &'a async_graphql::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) }) + } +} +struct CompositeReadCounter(std::sync::Arc); +impl ReadQueryObserver for CompositeReadCounter { + fn on_read(&self, _: &str, _: usize) { + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } +} +#[tokio::test] +async fn cursor_only_composite_reads_require_rank_and_both_key_components_before_select() { + for denied in ["rank", "partitionKey", "itemNo"] { + let mut database = setup().await.unwrap(); + database.set_field_policy(CompositeCursorAuthority(denied)); + let reads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let database = database.with_read_query_observer(CompositeReadCounter(reads.clone())); + let schema = schema_builder(database) + .data(AuthSubject::new("fixture-principal")) + .finish(); + let response = schema + .execute("{ compositeKeysetRecordsKeyset(page: {limit: 2}) { edges { cursor } } }") + .await; + assert!( + !response.errors.is_empty(), + "unselected cursor component {denied} was not checked" + ); + assert_eq!( + reads.load(std::sync::atomic::Ordering::SeqCst), + 0, + "denied cursor field reached SELECT" + ); + assert!(response.data.into_json().unwrap()["compositeKeysetRecordsKeyset"].is_null()); + } +} diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index f62f7e78..ba2cf541 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock @@ -1368,7 +1368,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.58.0" +version = "0.59.0" dependencies = [ "agql-auth", "async-graphql", @@ -1442,7 +1442,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.58.0" +version = "0.59.0" 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 3595f604..03dcba9e 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.58.0" +version = "0.59.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -2532,7 +2532,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.58.0" +version = "0.59.0" 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 b01570a0..dd6f87c3 100644 --- a/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.58.0" +version = "0.59.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1052,7 +1052,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.58.0" +version = "0.59.0" 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 0e435f5e..6d4572ed 100644 --- a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.58.0" +version = "0.59.0" dependencies = [ "async-graphql", "async-trait", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.58.0" +version = "0.59.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/keyset_cursor_field_authority.rs b/crates/graphql-orm/tests/keyset_cursor_field_authority.rs new file mode 100644 index 00000000..bfb0931c --- /dev/null +++ b/crates/graphql-orm/tests/keyset_cursor_field_authority.rs @@ -0,0 +1,383 @@ +#![cfg(feature = "sqlite")] +use graphql_orm::async_graphql::Request; +use graphql_orm::prelude::*; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; + +#[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[graphql_entity( + table = "cursor_policy_records", + plural = "CursorPolicyRecords", + keyset = "protected_rank asc, id asc" +)] +struct CursorPolicyRecord { + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[sortable] + id: String, + #[graphql_orm(read_policy = "rank.read")] + #[sortable] + protected_rank: i64, +} +schema_roots! { query_custom_ops: [], generated_mutations: "none", entities: [CursorPolicyRecord], } + +#[derive(Clone)] +struct RankAuthority { + allowed: Arc, + deny_rows: Arc, + deny_keys: Arc, +} +impl RankAuthority { + fn new() -> Self { + Self { + allowed: Arc::new(AtomicBool::new(true)), + deny_rows: Arc::new(AtomicBool::new(false)), + deny_keys: Arc::new(AtomicBool::new(false)), + } + } +} +impl FieldPolicy for RankAuthority { + fn can_read_field<'a>( + &'a self, + _: &'a async_graphql::Context<'_>, + _: &'a Database, + _: &'static str, + field: &'static str, + _: Option<&'static str>, + record: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { + Ok(match field { + "protectedRank" => { + self.allowed.load(Ordering::SeqCst) + && (record.is_none() || !self.deny_rows.load(Ordering::SeqCst)) + } + "id" => !self.deny_keys.load(Ordering::SeqCst), + _ => true, + }) + }) + } + fn can_read_repository_field<'a>( + &'a self, + _: Option>, + _: &'a Database, + _: &'static str, + field: &'static str, + _: Option<&'static str>, + record: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { + Ok(match field { + "protectedRank" => { + self.allowed.load(Ordering::SeqCst) + && (record.is_none() || !self.deny_rows.load(Ordering::SeqCst)) + } + "id" => !self.deny_keys.load(Ordering::SeqCst), + _ => true, + }) + }) + } + fn can_write_field<'a>( + &'a self, + _: &'a async_graphql::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) }) + } +} + +#[tokio::test] +async fn configured_keyset_cursor_only_selection_requires_unselected_order_field_authority() { + let mut db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); + sqlx::query( + "CREATE TABLE cursor_policy_records (id TEXT PRIMARY KEY, protected_rank INTEGER NOT NULL)", + ) + .execute(db.pool()) + .await + .unwrap(); + sqlx::query("INSERT INTO cursor_policy_records VALUES ('one',17),('two',29)") + .execute(db.pool()) + .await + .unwrap(); + let authority = RankAuthority::new(); + db.set_field_policy(authority.clone()); + let schema = schema_builder(db).finish(); + let request = || { + Request::new("{ cursorPolicyRecordsKeyset(page: {limit: 1}) { edges { cursor } } }") + .data(AuthSubject::new("fixture-principal")) + }; + let before = schema.execute(request()).await; + assert!(before.errors.is_empty(), "{:?}", before.errors); + let value = before.data.into_json().unwrap(); + let cursor = value["cursorPolicyRecordsKeyset"]["edges"][0]["cursor"] + .as_str() + .unwrap(); + assert!(cursor.starts_with("gomk1.")); + authority.allowed.store(false, Ordering::SeqCst); + let denied = schema.execute(request()).await; + assert!( + !denied.errors.is_empty(), + "a cursor-only selection must not bypass the configured ordering field's current read policy" + ); + assert!(denied.data.into_json().unwrap()["cursorPolicyRecordsKeyset"].is_null()); +} + +struct Observe { + reads: Arc, + revoke: Option>, +} +impl ReadQueryObserver for Observe { + fn on_read(&self, _: &str, _: usize) { + self.reads.fetch_add(1, Ordering::SeqCst); + if let Some(allowed) = &self.revoke { + allowed.store(false, Ordering::SeqCst); + } + } +} +async fn setup() -> (Database, RankAuthority, Arc) { + let mut db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); + sqlx::query( + "CREATE TABLE cursor_policy_records (id TEXT PRIMARY KEY, protected_rank INTEGER NOT NULL)", + ) + .execute(db.pool()) + .await + .unwrap(); + sqlx::query("INSERT INTO cursor_policy_records VALUES ('one',17),('two',29)") + .execute(db.pool()) + .await + .unwrap(); + let authority = RankAuthority::new(); + db.set_field_policy(authority.clone()); + let reads = Arc::new(AtomicUsize::new(0)); + let db = db.with_read_query_observer(Observe { + reads: reads.clone(), + revoke: None, + }); + (db, authority, reads) +} +#[tokio::test] +async fn configured_orders_deny_before_select_even_for_cursor_only_payload() { + for denied in ["rank", "key"] { + let (db, authority, reads) = setup().await; + if denied == "rank" { + authority.allowed.store(false, Ordering::SeqCst); + } else { + authority.deny_keys.store(true, Ordering::SeqCst); + } + let schema = schema_builder(db).finish(); + let response = schema + .execute( + Request::new( + "{ cursorPolicyRecordsKeyset(page: {limit: 1}) { edges { cursor } } }", + ) + .data(AuthSubject::new("fixture-principal")), + ) + .await; + assert!(!response.errors.is_empty()); + assert_eq!(reads.load(Ordering::SeqCst), 0); + } +} +#[tokio::test] +async fn row_field_denial_and_permission_change_during_read_never_release_cursor() { + for during_read in [false, true] { + let (db, authority, reads) = setup().await; + let db = if during_read { + db.with_read_query_observer(Observe { + reads: reads.clone(), + revoke: Some(authority.allowed.clone()), + }) + } else { + authority.deny_rows.store(true, Ordering::SeqCst); + db + }; + let schema = schema_builder(db).finish(); + let response = schema + .execute( + Request::new( + "{ cursorPolicyRecordsKeyset(page: {limit: 1}) { edges { cursor } } }", + ) + .data(AuthSubject::new("fixture-principal")), + ) + .await; + assert!(!response.errors.is_empty()); + assert!(reads.load(Ordering::SeqCst) > 0); + assert!(response.data.into_json().unwrap()["cursorPolicyRecordsKeyset"].is_null()); + } +} +#[tokio::test] +async fn repository_backward_and_scan_paths_retain_current_field_checks() { + let (db, authority, reads) = setup().await; + let configured = CursorPolicyRecord::keyset_page(&db, Default::default(), Default::default()) + .await + .unwrap(); + assert!( + configured + .page_info + .start_cursor + .unwrap() + .starts_with("gomk1.") + ); + authority.allowed.store(false, Ordering::SeqCst); + reads.store(0, Ordering::SeqCst); + assert!( + CursorPolicyRecord::keyset_connection_page( + &db, + Default::default(), + KeysetConnectionInput { + last: Some(1), + ..Default::default() + } + ) + .await + .is_err() + ); + let db = db.with_authorized_scan_config(AuthorizedScanConfig::new([7; 32], 2, 4)); + assert!( + CursorPolicyRecord::authorized_scan(&db, Default::default(), Default::default()) + .await + .is_err() + ); + assert_eq!(reads.load(Ordering::SeqCst), 0); + authority.allowed.store(true, Ordering::SeqCst); + let scan = CursorPolicyRecord::authorized_scan( + &db, + Default::default(), + AuthorizedScanInput { + limit: Some(1), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(scan.nodes.len(), 1); + assert!(scan.page_info.continuation.unwrap().starts_with("goms1.")); +} + +mod skipped_order { + use super::*; + #[derive( + GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize, + )] + #[graphql_entity( + table = "skipped_cursor_records", + plural = "SkippedCursorRecords", + keyset = "hidden_rank asc, id asc" + )] + struct HiddenOrderRecord { + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[sortable] + id: String, + // Keep the generated update input valid while testing hidden cursor fields. + label: String, + #[graphql(skip)] + hidden_rank: i64, + } + schema_roots! { query_custom_ops: [], generated_mutations: "none", entities: [HiddenOrderRecord], } + #[tokio::test] + async fn configured_hidden_field_cannot_escape_via_cursor_or_scan() { + let db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); + sqlx::query("CREATE TABLE skipped_cursor_records (id TEXT PRIMARY KEY, label TEXT NOT NULL, hidden_rank INTEGER NOT NULL)").execute(db.pool()).await.unwrap(); + sqlx::query( + "INSERT INTO skipped_cursor_records VALUES ('one','First',17),('two','Second',29)", + ) + .execute(db.pool()) + .await + .unwrap(); + let reads = Arc::new(AtomicUsize::new(0)); + let db = db + .with_read_query_observer(Observe { + reads: reads.clone(), + revoke: None, + }) + .with_authorized_scan_config(AuthorizedScanConfig::new([7; 32], 2, 4)); + let schema = schema_builder(db.clone()).finish(); + for operation in [ + "{ skippedCursorRecordsKeyset(page: {limit: 1}) { edges { cursor } } }", + "{ skippedCursorRecordsScan(page: {limit: 1}) { pageInfo { continuation status } } }", + ] { + let response = schema + .execute(Request::new(operation).data(AuthSubject::new("fixture-principal"))) + .await; + assert!( + !response.errors.is_empty(), + "a hidden raw ordering value must be denied independently of selection" + ); + } + assert_eq!(reads.load(Ordering::SeqCst), 0); + // Trusted repository reads retain their ordinary independent field policy. + let page = HiddenOrderRecord::keyset_page(&db, Default::default(), Default::default()) + .await + .unwrap(); + assert_eq!(page.edges.len(), 2); + } +} + +mod private_order { + use super::*; + #[derive( + GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize, + )] + #[graphql_entity( + table = "private_cursor_records", + plural = "PrivateCursorRecords", + keyset = "hidden_rank asc, id asc" + )] + struct HiddenOrderRecord { + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[sortable] + id: String, + // Keep the generated update input valid while testing hidden cursor fields. + label: String, + // Explicit repository surface overrides cannot publish a private cursor value. + #[graphql_orm(private, read = true, order = true)] + #[sortable] + hidden_rank: i64, + } + schema_roots! { query_custom_ops: [], generated_mutations: "none", entities: [HiddenOrderRecord], } + #[tokio::test] + async fn configured_hidden_field_cannot_escape_via_cursor_or_scan() { + let db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); + sqlx::query("CREATE TABLE private_cursor_records (id TEXT PRIMARY KEY, label TEXT NOT NULL, hidden_rank INTEGER NOT NULL)").execute(db.pool()).await.unwrap(); + sqlx::query( + "INSERT INTO private_cursor_records VALUES ('one','First',17),('two','Second',29)", + ) + .execute(db.pool()) + .await + .unwrap(); + let reads = Arc::new(AtomicUsize::new(0)); + let db = db + .with_read_query_observer(Observe { + reads: reads.clone(), + revoke: None, + }) + .with_authorized_scan_config(AuthorizedScanConfig::new([7; 32], 2, 4)); + let schema = schema_builder(db.clone()).finish(); + for operation in [ + "{ privateCursorRecordsKeyset(page: {limit: 1}) { edges { cursor } } }", + "{ privateCursorRecordsScan(page: {limit: 1}) { pageInfo { continuation status } } }", + ] { + let response = schema + .execute(Request::new(operation).data(AuthSubject::new("fixture-principal"))) + .await; + assert!( + !response.errors.is_empty(), + "a hidden raw ordering value must be denied independently of selection" + ); + } + assert_eq!(reads.load(Ordering::SeqCst), 0); + // Trusted repository reads retain their ordinary independent field policy. + let page = HiddenOrderRecord::keyset_page(&db, Default::default(), Default::default()) + .await + .unwrap(); + assert_eq!(page.edges.len(), 2); + } +} diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 5299741f..e88bd6eb 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.58.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.59.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-ai` | `0.109.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm` (dev-only), `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.16.1` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.10.0` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | -| `graphql-orm-macros` | `0.58.0` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.59.0` | `crates/graphql-orm-macros` | `sqlite` | none | | `graphql-orm-operation-catalog` | `0.4.1` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | | `graphql-orm-router` | `0.6.4` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none |