diff --git a/CHANGELOG.md b/CHANGELOG.md index 103e9d2e..cf60f391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,20 @@ 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.42.0 (unreleased) + +- Fix runtime GraphQL alias swaps and scalar/relation output collisions; retain requested filter capabilities before empty-list constant lowering so authorization cannot be bypassed. + +- Runtime GraphQL composition explicitly rejects unsupported custom-directive factories before publication; built-in directives, bounded host extensions and checked subscription roots remain supported. + +- Add opt-in runtime GraphQL reads, checked dynamic composition, whole-operation + host authority preflight and bounded runtime relation batches. +- Add lossless JSON-text/full-i64 scalars and host-managed authenticated encrypted + cursor framing with trusted schema/order/authorization/parent bindings. +- Align macros at 0.42.0; static derives/SDL and default feature behavior are unchanged. +- C remains an unpublished dependent review checkpoint; custom-directive forwarding + requires the documented contract decision. D is not included. + ## 0.41.0 - unreleased Companion macros crate: `graphql-orm-macros` **0.41.0** (alignment only). diff --git a/Cargo.lock b/Cargo.lock index 9828a2f9..fc345171 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3146,11 +3146,13 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.41.0" +version = "0.42.0" dependencies = [ "agql-auth", "async-graphql", + "async-trait", "axum", + "base64 0.22.1", "chacha20poly1305", "chrono", "futures", @@ -3245,7 +3247,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.41.0" +version = "0.42.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 37685b00..3a090bda 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.41.0", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.42.0", 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 ec280f7e..ca646652 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -13,6 +13,15 @@ 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.41.0 to 0.42.0: opt-in runtime GraphQL reads + +Enable `runtime-graphql` alongside an explicit SQLite or PostgreSQL feature. No +stored-data migration or static SDL change is required. Hosts supply request +read authority and AEAD cursor protection before installing a protected module. +The dynamic scalar wire forms are new and do not replace static scalar mappings. +See [runtime GraphQL](docs/reference/graphql-orm/runtime-graphql.md) for composition, +limits, cursor rotation, SDK mappings and pending release/contract gates. + ## 0.40.0 to 0.41.0: transactional runtime mutations Adopt aligned ORM/macros 0.41.0. This is an additive, unpublished successor to A; diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index 55482615..02875bd4 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.41.0" +version = "0.42.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 feacd913..b2916bc1 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.41.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.42.0", 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.41.0", default-features = false, features = ["sqlite"] } +graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.42.0", default-features = false, features = ["sqlite"] } ``` The direct dependency still requires a compatible `graphql-orm` runtime in the @@ -183,6 +183,6 @@ See [host timestamp inputs, migration compatibility and cancellation limits](../ Complete SQLite/PostgreSQL text-group pages and SQL-visible ordinary aggregates: see [typed aggregates](../../docs/reference/graphql-orm/typed-aggregates.md). -Version 0.41.0 aligns with the schema-bound transactional runtime mutation addition. Released host-managed +Version 0.42.0 aligns with the schema-bound transactional runtime mutation addition. Released host-managed timestamps, framework-neutral repository aggregate helpers and static GraphQL SDL remain unchanged. 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 11e35273..eb09b35e 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.41.0" +version = "0.42.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.41.0" +version = "0.42.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index c4d4f496..2efa9b65 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.41.0" +version = "0.42.0" edition = "2024" description = "Runtime support crate for graphql-orm-macros" license = "MIT" @@ -39,6 +39,7 @@ mssql = [ "tokio/net", "tokio/rt", ] +runtime-graphql = ["async-graphql/dynamic-schema", "dep:base64", "dep:async-trait"] change-journal = [] auth-agql = ["dep:agql-auth"] router-protocol = ["graphql-orm-operation-catalog/router-protocol"] @@ -59,6 +60,8 @@ field-case-lower = ["graphql-orm-macros/field-case-lower"] field-case-upper = ["graphql-orm-macros/field-case-upper"] [dependencies] +base64 = { version = "0.22", optional = true } +async-trait = { version = "0.1", optional = true } chacha20poly1305 = "0.10" quick-xml = { version = "0.41", optional = true } agql-auth = { workspace = true, optional = true } @@ -68,7 +71,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.41.0", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.42.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 2ecf626c..6053d369 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.41.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.42.0", default-features = false, features = ["sqlite"] } ``` This unpublished package has no docs.rs release. Use this Git README and the @@ -208,3 +208,5 @@ Static migration APIs remain unchanged. Schema-bound runtime create/patch/delete with pinned authority, structural CAS, atomic host journals and cancellation poisoning are documented in [runtime mutations](../../docs/reference/graphql-orm/runtime-mutations.md). + +The opt-in `runtime-graphql` feature provides [authorized dynamic reads and checked composition](../../docs/reference/graphql-orm/runtime-graphql.md), with host-owned authority and confidential cursors. It does not change static SDL or add mutations. diff --git a/crates/graphql-orm/examples/runtime_graphql_reads.rs b/crates/graphql-orm/examples/runtime_graphql_reads.rs new file mode 100644 index 00000000..c9a76a4e --- /dev/null +++ b/crates/graphql-orm/examples/runtime_graphql_reads.rs @@ -0,0 +1,196 @@ +//! Disposable host composition example. Keys exist only for this process. +//! Run with exactly one backend and `runtime-graphql`; PostgreSQL uses owned Docker. +#[cfg(all( + feature = "runtime-graphql", + any(feature = "sqlite", feature = "postgres") +))] +mod example { + use chacha20poly1305::{ + ChaCha20Poly1305, KeyInit, + aead::{Aead, AeadCore, OsRng, Payload}, + }; + use graphql_orm::{ + async_graphql::{ + Request, + dynamic::{FieldFuture, FieldValue, TypeRef}, + }, + futures::future::BoxFuture, + }; + use graphql_orm::{ + graphql::{orm::*, runtime::*}, + prelude::*, + }; + use std::sync::Arc; + #[cfg(feature = "postgres")] + type Backend = PostgresBackend; + #[cfg(not(feature = "postgres"))] + type Backend = SqliteBackend; + #[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] + #[repository_entity(table = "public_notes", plural = "PublicNotes")] + struct PublicNote { + #[primary_key] + #[sortable] + #[graphql_orm(auto_generated = false)] + id: String, + exact: i64, + label: String, + } + struct PublicRead; + impl RuntimeReadAuthority for PublicRead { + fn authorize<'a>( + &'a self, + _: RuntimeReadCheck<'a>, + ) -> BoxFuture<'a, Result> { + // This disposable data set is intentionally public. A production host + // checks projection/filter/order/relation/count capabilities here. + Box::pin(async { Ok(RuntimeReadGrant::new(None)) }) + } + } + struct HostCipher(ChaCha20Poly1305); + impl RuntimeCursorProtector for HostCipher { + fn seal<'a>( + &'a self, + context: &'a RuntimeCursorContext, + plaintext: &'a str, + limits: RuntimeCursorProtectionLimits, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); + let mut bytes = nonce.to_vec(); + bytes.extend( + self.0 + .encrypt( + &nonce, + Payload { + msg: plaintext.as_bytes(), + aad: context.associated_data(), + }, + ) + .map_err(|_| RuntimeCursorProtectionError::CursorUnavailable)?, + ); + RuntimeSealedCursor::new("example-ephemeral", bytes, limits) + }) + } + fn open<'a>( + &'a self, + context: &'a RuntimeCursorContext, + key: &'a str, + bytes: &'a [u8], + limits: RuntimeCursorProtectionLimits, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + if key != "example-ephemeral" { + return Err(RuntimeCursorProtectionError::CursorUnavailable); + } + if bytes.len() < 28 + || bytes.len() + > limits + .max_plaintext_bytes + .saturating_add(limits.max_overhead_bytes) + { + return Err(RuntimeCursorProtectionError::InvalidCursor); + } + let value = self + .0 + .decrypt( + (&bytes[..12]).into(), + Payload { + msg: &bytes[12..], + aad: context.associated_data(), + }, + ) + .map_err(|_| RuntimeCursorProtectionError::InvalidCursor)?; + String::from_utf8(value).map_err(|_| RuntimeCursorProtectionError::InvalidCursor) + }) + } + } + pub async fn run(database: Database) -> Result<(), Box> { + // Typed repository setup is independent of GraphQL registration. + let schema = + Arc::new(RuntimeSchema::from_static_entities(&[PublicNote::metadata()])?.validate()?); + let target = schema.physical_schema::(Default::default())?; + let ownership = ManagedTableSet::new(["public_notes".into()])?; + let plan = database + .schema() + .plan_owned_migration( + "example", + "disposable notes", + &target, + &ownership, + PlanOptions::strict(), + ) + .await?; + database + .schema() + .apply_owned_migration(&plan, ApplyOptions::default()) + .await?; + PublicNote::insert( + &database, + CreatePublicNoteInput { + id: "note-one".into(), + exact: 9_007_199_254_740_993, + label: "Exact integer".into(), + }, + ) + .await?; + let module = RuntimeGraphqlModule::compile(schema.clone(), Default::default())?; + let cipher = HostCipher(ChaCha20Poly1305::new(&ChaCha20Poly1305::generate_key( + &mut OsRng, + ))); + let api = RuntimeGraphqlComposer::new(database, "Query", Default::default())? + .cursor_protection( + Arc::new(cipher), + RuntimeCursorAudience::new("upstream-example")?, + Default::default(), + )? + .query_field(RuntimeHostField::new( + "health", + TypeRef::named_nn("Boolean"), + |_| FieldFuture::new(async { Ok(Some(FieldValue::value(true))) }), + ))? + .install(module)? + .finish()?; + let response = api.execute(Request::new("{ health publicNotes(first:1) { edges { cursor node { id exact label } } totalCount } }") + .data(RuntimeGraphqlRequest::::new(schema.fingerprint(), Arc::new(PublicRead), None, Default::default()).with_cursor_scope("public-example"))).await; + if !response.errors.is_empty() { + return Err("runtime GraphQL example failed".into()); + } + let data = response.data.into_json()?; + assert_eq!( + data["publicNotes"]["edges"][0]["node"]["exact"], + "9007199254740993" + ); + assert_eq!(data["publicNotes"]["totalCount"], "1"); + println!("authorized runtime GraphQL read/count and protected cursor passed"); + Ok(()) + } +} +#[cfg(all(feature = "runtime-graphql", feature = "postgres"))] +#[path = "../tests/support/owned_postgres.rs"] +mod owned_postgres; +#[cfg(all( + feature = "runtime-graphql", + any(feature = "sqlite", feature = "postgres") +))] +#[tokio::main] +pub async fn main() -> Result<(), Box> { + #[cfg(feature = "postgres")] + { + let mut owned = owned_postgres::OwnedPostgres::start("runtime-gql-example")?; + let result = + example::run(graphql_orm::db::Database::connect_postgres(&owned.url).await?).await; + owned.cleanup()?; + result + } + #[cfg(not(feature = "postgres"))] + { + example::run(graphql_orm::db::Database::connect_sqlite("sqlite::memory:").await?).await + } +} +#[cfg(not(all( + feature = "runtime-graphql", + any(feature = "sqlite", feature = "postgres") +)))] +pub fn main() { + eprintln!("enable runtime-graphql and exactly one SQLite/PostgreSQL backend"); +} diff --git a/crates/graphql-orm/src/graphql/mod.rs b/crates/graphql-orm/src/graphql/mod.rs index 4bc23624..d9777188 100644 --- a/crates/graphql-orm/src/graphql/mod.rs +++ b/crates/graphql-orm/src/graphql/mod.rs @@ -9,3 +9,6 @@ pub mod loaders; pub mod orm; pub mod pagination; pub mod structural_auth; + +#[cfg(feature = "runtime-graphql")] +pub mod runtime; diff --git a/crates/graphql-orm/src/graphql/orm/runtime_query.rs b/crates/graphql-orm/src/graphql/orm/runtime_query.rs index 38f26f5a..ef1d20f8 100644 --- a/crates/graphql-orm/src/graphql/orm/runtime_query.rs +++ b/crates/graphql-orm/src/graphql/orm/runtime_query.rs @@ -420,6 +420,16 @@ pub struct RuntimeReadRequest { limits: RuntimeQueryLimits, } +#[cfg(feature = "runtime-graphql")] +impl RuntimeReadRequest { + pub(crate) fn validate_graphql_backend( + &self, + backend: DatabaseBackend, + ) -> Result<(), RuntimeQueryError> { + render_request(self, backend).map(|_| ()) + } +} + impl fmt::Debug for RuntimeReadRequest { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("RuntimeReadRequest") diff --git a/crates/graphql-orm/src/graphql/orm/runtime_relation.rs b/crates/graphql-orm/src/graphql/orm/runtime_relation.rs index 6383ba6f..8b31e1cc 100644 --- a/crates/graphql-orm/src/graphql/orm/runtime_relation.rs +++ b/crates/graphql-orm/src/graphql/orm/runtime_relation.rs @@ -185,6 +185,16 @@ pub struct RuntimeAnchoredReadRequest { anchors: Vec, } +#[cfg(feature = "runtime-graphql")] +impl RuntimeAnchoredReadRequest { + pub(crate) fn validate_graphql_backend( + &self, + backend: super::DatabaseBackend, + ) -> Result<(), super::RuntimeQueryError> { + self.inner.validate_graphql_backend(backend) + } +} + impl fmt::Debug for RuntimeAnchoredReadRequest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter @@ -211,6 +221,18 @@ pub struct RuntimeParentAnchor { } impl RuntimeParentAnchor { + #[cfg(feature = "runtime-graphql")] + pub(crate) fn graphql_cursor_scope(&self) -> Result, RuntimeRelationError> { + serde_json::to_vec(&( + &self.schema, + &self.relation, + &self.source, + &self.target, + &self.parent_identity, + )) + .map_err(|_| RuntimeRelationError::new(RuntimeRelationErrorCode::InvalidParent)) + } + /// Position of the parent in the anchored read result. pub const fn parent_index(&self) -> usize { self.parent_index diff --git a/crates/graphql-orm/src/graphql/runtime/cursor.rs b/crates/graphql-orm/src/graphql/runtime/cursor.rs new file mode 100644 index 00000000..e228d8da --- /dev/null +++ b/crates/graphql-orm/src/graphql/runtime/cursor.rs @@ -0,0 +1,557 @@ +use super::RuntimeGraphqlError; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use futures::future::BoxFuture; +use std::fmt; + +/// Explicit cursor privacy profile. Protection is never auto-detected. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RuntimeCursorProfile { + /// The host must supply authenticated encryption of the complete envelope. + #[default] + AuthenticatedEncryption, + /// Explicitly disclose the existing framework-neutral cursor envelope. + Unprotected, +} + +/// Bounds checked before decoding and after every host provider call. +#[derive(Clone, Copy, Debug)] +pub struct RuntimeCursorProtectionLimits { + pub max_plaintext_bytes: usize, + pub max_key_id_bytes: usize, + pub max_associated_data_bytes: usize, + pub max_overhead_bytes: usize, + pub max_token_bytes: usize, +} +impl Default for RuntimeCursorProtectionLimits { + fn default() -> Self { + Self { + max_plaintext_bytes: 16 * 1024, + max_key_id_bytes: 64, + max_associated_data_bytes: 16 * 1024, + max_overhead_bytes: 512, + max_token_bytes: 32 * 1024, + } + } +} + +/// Trusted host namespace; never taken from GraphQL arguments. +#[derive(Clone)] +pub struct RuntimeCursorAudience(String); +impl RuntimeCursorAudience { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() || value.len() > 1024 { + return Err(RuntimeGraphqlError::new("invalid_composition")); + } + Ok(Self(value)) + } +} +impl fmt::Debug for RuntimeCursorAudience { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("RuntimeCursorAudience([redacted])") + } +} + +/// Canonical associated data constructed only from trusted execution state. +/// +/// Providers must authenticate these exact bytes. Debug never displays them. +#[derive(Clone)] +pub struct RuntimeCursorContext(Vec); +impl RuntimeCursorContext { + /// Stable versioned associated data for the host's AEAD operation. + pub fn associated_data(&self) -> &[u8] { + &self.0 + } + pub(super) fn new( + audience: &RuntimeCursorAudience, + scope: &str, + parts: &[&[u8]], + limits: RuntimeCursorProtectionLimits, + ) -> Result { + let mut bytes = Vec::new(); + // Length-delimited components prevent concatenation ambiguity. + for part in [ + b"gormgqlc1".as_slice(), + audience.0.as_bytes(), + scope.as_bytes(), + ] + .into_iter() + .chain(parts.iter().copied()) + { + let length = u32::try_from(part.len()).map_err(|_| invalid())?; + let required = bytes + .len() + .checked_add(4) + .and_then(|n| n.checked_add(part.len())) + .ok_or_else(invalid)?; + if required > limits.max_associated_data_bytes { + return Err(invalid()); + } + bytes.extend_from_slice(&length.to_be_bytes()); + bytes.extend_from_slice(part); + } + Ok(Self(bytes)) + } +} +impl fmt::Debug for RuntimeCursorContext { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("RuntimeCursorContext([redacted])") + } +} + +/// Public key label plus confidential provider nonce/tag/ciphertext framing. +#[derive(Clone)] +pub struct RuntimeSealedCursor { + key_id: String, + ciphertext: Vec, +} +impl RuntimeSealedCursor { + pub fn new( + key_id: impl Into, + ciphertext: Vec, + limits: RuntimeCursorProtectionLimits, + ) -> Result { + let result = Self { + key_id: key_id.into(), + ciphertext, + }; + result + .validate(limits) + .map_err(|_| RuntimeCursorProtectionError::InvalidCursor)?; + Ok(result) + } + fn validate(&self, limits: RuntimeCursorProtectionLimits) -> Result<(), RuntimeGraphqlError> { + validate_key(&self.key_id, limits)?; + let max = limits + .max_plaintext_bytes + .checked_add(limits.max_overhead_bytes) + .ok_or_else(invalid)?; + if self.ciphertext.is_empty() || self.ciphertext.len() > max { + return Err(invalid()); + } + Ok(()) + } +} +impl fmt::Debug for RuntimeSealedCursor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("RuntimeSealedCursor([redacted])") + } +} + +/// Provider errors deliberately cannot carry crypto or plaintext diagnostics. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RuntimeCursorProtectionError { + InvalidCursor, + /// Unknown/retired key, expired token, or unavailable provider. + CursorUnavailable, +} +impl fmt::Display for RuntimeCursorProtectionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::InvalidCursor => "invalid_cursor", + Self::CursorUnavailable => "cursor_unavailable", + }) + } +} +impl std::error::Error for RuntimeCursorProtectionError {} +impl From for RuntimeGraphqlError { + fn from(value: RuntimeCursorProtectionError) -> Self { + Self::new(match value { + RuntimeCursorProtectionError::InvalidCursor => "invalid_cursor", + RuntimeCursorProtectionError::CursorUnavailable => "cursor_unavailable", + }) + } +} + +/// Host-owned authenticated encryption, keys, nonces, rotation and expiry. +/// +/// `seal` must encrypt the **entire** plaintext and authenticate the exact +/// context bytes. Signing or encoding readable data violates this contract. +/// `open` uses a bounded allowed key ring and authenticates before returning. +/// The host must honor all supplied bounds; no callback error permits fallback. +pub trait RuntimeCursorProtector: Send + Sync + 'static { + fn seal<'a>( + &'a self, + context: &'a RuntimeCursorContext, + plaintext: &'a str, + limits: RuntimeCursorProtectionLimits, + ) -> BoxFuture<'a, Result>; + fn open<'a>( + &'a self, + context: &'a RuntimeCursorContext, + key_id: &'a str, + ciphertext: &'a [u8], + limits: RuntimeCursorProtectionLimits, + ) -> BoxFuture<'a, Result>; +} +fn invalid() -> RuntimeGraphqlError { + RuntimeGraphqlError::new("invalid_cursor") +} +fn validate_key( + key: &str, + limits: RuntimeCursorProtectionLimits, +) -> Result<(), RuntimeGraphqlError> { + if key.is_empty() + || key.len() > limits.max_key_id_bytes + || !key + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + return Err(invalid()); + } + Ok(()) +} +pub(super) fn validate_token( + token: &str, + limits: RuntimeCursorProtectionLimits, +) -> Result<(&str, &str), RuntimeGraphqlError> { + if token.len() > limits.max_token_bytes { + return Err(invalid()); + } + let mut pieces = token.split('.'); + if pieces.next() != Some("gormgqlc1") { + return Err(invalid()); + } + let key = pieces.next().ok_or_else(invalid)?; + let encoded = pieces.next().ok_or_else(invalid)?; + if pieces.next().is_some() || encoded.is_empty() { + return Err(invalid()); + } + validate_key(key, limits)?; + let max = limits + .max_plaintext_bytes + .checked_add(limits.max_overhead_bytes) + .and_then(|n| n.checked_add(2)) + .and_then(|n| n.checked_div(3)) + .and_then(|n| n.checked_mul(4)) + .ok_or_else(invalid)?; + if encoded.len() > max + || !encoded + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + return Err(invalid()); + } + Ok((key, encoded)) +} +pub(super) async fn open( + provider: &dyn RuntimeCursorProtector, + context: &RuntimeCursorContext, + token: &str, + limits: RuntimeCursorProtectionLimits, +) -> Result { + let (key, encoded) = validate_token(token, limits)?; + let ciphertext = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| invalid())?; + let max = limits + .max_plaintext_bytes + .checked_add(limits.max_overhead_bytes) + .ok_or_else(invalid)?; + if ciphertext.len() > max { + return Err(invalid()); + } + let plaintext = provider.open(context, key, &ciphertext, limits).await?; + if plaintext.len() > limits.max_plaintext_bytes { + return Err(invalid()); + } + Ok(plaintext) +} +pub(super) async fn seal( + provider: &dyn RuntimeCursorProtector, + context: &RuntimeCursorContext, + plaintext: &str, + limits: RuntimeCursorProtectionLimits, +) -> Result { + if plaintext.len() > limits.max_plaintext_bytes { + return Err(invalid()); + } + let sealed = provider.seal(context, plaintext, limits).await?; + sealed.validate(limits)?; + if sealed.ciphertext.len() + > plaintext + .len() + .checked_add(limits.max_overhead_bytes) + .ok_or_else(invalid)? + { + return Err(invalid()); + } + let encoded_size = sealed + .ciphertext + .len() + .checked_add(2) + .and_then(|n| n.checked_div(3)) + .and_then(|n| n.checked_mul(4)) + .ok_or_else(invalid)?; + let size = 11usize + .checked_add(sealed.key_id.len()) + .and_then(|n| n.checked_add(encoded_size)) + .ok_or_else(invalid)?; + if size > limits.max_token_bytes { + return Err(invalid()); + } + Ok(format!( + "gormgqlc1.{}.{}", + sealed.key_id, + URL_SAFE_NO_PAD.encode(sealed.ciphertext) + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use chacha20poly1305::{ + ChaCha20Poly1305, KeyInit, + aead::{Aead, Payload}, + }; + struct AeadProvider; + impl RuntimeCursorProtector for AeadProvider { + fn seal<'a>( + &'a self, + context: &'a RuntimeCursorContext, + plaintext: &'a str, + limits: RuntimeCursorProtectionLimits, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let cipher = ChaCha20Poly1305::new(&[7; 32].into()); + let nonce_bytes = uuid::Uuid::new_v4(); + let nonce = &nonce_bytes.as_bytes()[..12]; + let mut bytes = nonce.to_vec(); + bytes.extend( + cipher + .encrypt( + nonce.into(), + Payload { + msg: plaintext.as_bytes(), + aad: context.associated_data(), + }, + ) + .map_err(|_| RuntimeCursorProtectionError::InvalidCursor)?, + ); + RuntimeSealedCursor::new("active", bytes, limits) + }) + } + fn open<'a>( + &'a self, + context: &'a RuntimeCursorContext, + key_id: &'a str, + ciphertext: &'a [u8], + _: RuntimeCursorProtectionLimits, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + if key_id != "active" { + return Err(RuntimeCursorProtectionError::CursorUnavailable); + } + if ciphertext.len() < 28 { + return Err(RuntimeCursorProtectionError::InvalidCursor); + } + let cipher = ChaCha20Poly1305::new(&[7; 32].into()); + let bytes = cipher + .decrypt( + (&ciphertext[..12]).into(), + Payload { + msg: &ciphertext[12..], + aad: context.associated_data(), + }, + ) + .map_err(|_| RuntimeCursorProtectionError::InvalidCursor)?; + String::from_utf8(bytes).map_err(|_| RuntimeCursorProtectionError::InvalidCursor) + }) + } + } + #[tokio::test] + async fn complete_envelope_is_encrypted_authenticated_bounded_and_scope_bound() { + let limits = RuntimeCursorProtectionLimits::default(); + let audience = RuntimeCursorAudience::new("test").unwrap(); + let context = + RuntimeCursorContext::new(&audience, "tenant-a", &[b"parent-secret"], limits).unwrap(); + let text = "gormrr1.hidden-order-value.hidden-parent-value"; + let token = seal(&AeadProvider, &context, text, limits).await.unwrap(); + assert!(!token.contains("hidden")); + assert_eq!( + open(&AeadProvider, &context, &token, limits).await.unwrap(), + text + ); + let wrong = + RuntimeCursorContext::new(&audience, "tenant-b", &[b"parent-secret"], limits).unwrap(); + assert_eq!( + open(&AeadProvider, &wrong, &token, limits) + .await + .unwrap_err() + .code(), + "invalid_cursor" + ); + assert!(open(&AeadProvider, &context, text, limits).await.is_err()); + let (_, encoded) = validate_token(&token, limits).unwrap(); + let mut bytes = URL_SAFE_NO_PAD.decode(encoded).unwrap(); + bytes[20] ^= 1; + let tampered = format!("gormgqlc1.active.{}", URL_SAFE_NO_PAD.encode(bytes)); + assert_eq!( + open(&AeadProvider, &context, &tampered, limits) + .await + .unwrap_err() + .code(), + "invalid_cursor" + ); + assert_eq!( + open( + &AeadProvider, + &context, + &token.replace("active", "retired"), + limits + ) + .await + .unwrap_err() + .code(), + "cursor_unavailable" + ); + assert!( + validate_token( + &token, + RuntimeCursorProtectionLimits { + max_token_bytes: 10, + ..limits + } + ) + .is_err() + ); + } + struct Rotating { + active: std::sync::atomic::AtomicU8, + retired: std::sync::atomic::AtomicBool, + } + impl RuntimeCursorProtector for Rotating { + fn seal<'a>( + &'a self, + context: &'a RuntimeCursorContext, + plaintext: &'a str, + limits: RuntimeCursorProtectionLimits, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let active = self.active.load(std::sync::atomic::Ordering::SeqCst); + let cipher = ChaCha20Poly1305::new(&[active; 32].into()); + let nonce = uuid::Uuid::new_v4(); + let nonce = &nonce.as_bytes()[..12]; + let mut bytes = nonce.to_vec(); + bytes.extend( + cipher + .encrypt( + nonce.into(), + Payload { + msg: plaintext.as_bytes(), + aad: context.associated_data(), + }, + ) + .map_err(|_| RuntimeCursorProtectionError::InvalidCursor)?, + ); + RuntimeSealedCursor::new(format!("key{active}"), bytes, limits) + }) + } + fn open<'a>( + &'a self, + context: &'a RuntimeCursorContext, + key_id: &'a str, + bytes: &'a [u8], + _: RuntimeCursorProtectionLimits, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let key = match key_id { + "key1" if !self.retired.load(std::sync::atomic::Ordering::SeqCst) => 1, + "key2" => 2, + _ => return Err(RuntimeCursorProtectionError::CursorUnavailable), + }; + if bytes.len() < 28 { + return Err(RuntimeCursorProtectionError::InvalidCursor); + } + let cipher = ChaCha20Poly1305::new(&[key; 32].into()); + let value = cipher + .decrypt( + (&bytes[..12]).into(), + Payload { + msg: &bytes[12..], + aad: context.associated_data(), + }, + ) + .map_err(|_| RuntimeCursorProtectionError::InvalidCursor)?; + String::from_utf8(value).map_err(|_| RuntimeCursorProtectionError::InvalidCursor) + }) + } + } + #[tokio::test] + async fn rotation_resumes_allowed_old_keys_and_retirement_fails_closed() { + use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; + let ring = Rotating { + active: AtomicU8::new(1), + retired: AtomicBool::new(false), + }; + let limits = RuntimeCursorProtectionLimits::default(); + let context = RuntimeCursorContext::new( + &RuntimeCursorAudience::new("rotation").unwrap(), + "partition", + &[b"schema/order/parent"], + limits, + ) + .unwrap(); + let old = seal(&ring, &context, "entire-internal-envelope", limits) + .await + .unwrap(); + ring.active.store(2, Ordering::SeqCst); + let new = seal(&ring, &context, "entire-internal-envelope", limits) + .await + .unwrap(); + assert!(old.starts_with("gormgqlc1.key1.")); + assert!(new.starts_with("gormgqlc1.key2.")); + assert_eq!( + open(&ring, &context, &old, limits).await.unwrap(), + "entire-internal-envelope" + ); + ring.retired.store(true, Ordering::SeqCst); + assert_eq!( + open(&ring, &context, &old, limits) + .await + .unwrap_err() + .code(), + "cursor_unavailable" + ); + assert_eq!( + open(&ring, &context, &new, limits).await.unwrap(), + "entire-internal-envelope" + ); + assert!( + seal( + &ring, + &context, + "too-long", + RuntimeCursorProtectionLimits { + max_plaintext_bytes: 2, + ..limits + } + ) + .await + .is_err() + ); + assert!( + seal( + &ring, + &context, + "ok", + RuntimeCursorProtectionLimits { + max_overhead_bytes: 1, + ..limits + } + ) + .await + .is_err() + ); + assert!( + RuntimeCursorContext::new( + &RuntimeCursorAudience::new("rotation").unwrap(), + "partition", + &[], + RuntimeCursorProtectionLimits { + max_associated_data_bytes: 8, + ..limits + } + ) + .is_err() + ); + } +} diff --git a/crates/graphql-orm/src/graphql/runtime/execution.rs b/crates/graphql-orm/src/graphql/runtime/execution.rs new file mode 100644 index 00000000..6c68418c --- /dev/null +++ b/crates/graphql-orm/src/graphql/runtime/execution.rs @@ -0,0 +1,961 @@ +use super::plan::{Plan, Planner, invalid, query_error}; +use super::*; +use crate::{db::Database, graphql::orm::*}; +use async_graphql::{ + Data, Name, Request, Response, Value, Variables, extensions::*, + parser::types::ExecutableDocument, +}; +use futures::future::BoxFuture; +use std::{ + collections::BTreeMap, + marker::PhantomData, + sync::{Arc, Mutex}, +}; + +/// All client-requested capabilities for one selected layer, before protected I/O. +pub struct RuntimeReadCheck<'a> { + pub schema: &'a ValidatedRuntimeSchema, + pub collection: &'a RuntimeCollectionHandle, + pub projection: &'a [RuntimeFieldHandle], + pub filter: Option<&'a RuntimePredicate>, + /// Requested field handles and operators retained before predicate constant lowering. + /// Names are eq/ne/lt/lte/gt/gte/contains/startsWith/endsWith/in/notIn/isNull/isNotNull. + pub filter_operations: Vec<(&'a RuntimeFieldHandle, &'static str)>, + /// Distinguish client ordering from trusted effective hidden-key ordering. + pub requested_order: &'a [RuntimeOrderInput], + pub order_was_supplied: bool, + pub order: &'a RuntimeOrder, + /// Explicit Boolean operators in the validated client input: and/or/not. + pub logical_operators: Vec<&'static str>, + pub relation: Option<&'a RuntimeRelationHandle>, + pub include_count: bool, +} +/// Explicit host grant. No provider or error means denial. +pub struct RuntimeReadGrant { + predicate: Option, +} +impl RuntimeReadGrant { + pub fn new(predicate: Option) -> Self { + Self { predicate } + } +} +/// Hosts authorize every field, operator, traversal and count in the check. +pub trait RuntimeReadAuthority: Send + Sync + 'static { + fn authorize<'a>( + &'a self, + check: RuntimeReadCheck<'a>, + ) -> BoxFuture<'a, Result>; +} +/// Trusted request data. Reuse does not reuse execution caches or grants. +pub struct RuntimeGraphqlRequest { + fingerprint: SchemaFingerprint, + authority: Arc, + auth: Option, + budget: RuntimeGraphqlLimits, + scope: Option, + backend: PhantomData, +} +impl RuntimeGraphqlRequest { + pub fn new( + fingerprint: SchemaFingerprint, + authority: Arc, + auth: Option, + budget: RuntimeGraphqlLimits, + ) -> Self { + Self { + fingerprint, + authority, + auth, + budget, + scope: None, + backend: PhantomData, + } + } + /// Bind cursors to a trusted authorization partition, never client input. + pub fn with_cursor_scope(mut self, scope: impl Into) -> Self { + self.scope = Some(scope.into()); + self + } +} +#[derive(Clone)] +pub(super) struct Protection { + pub protector: Arc, + pub audience: RuntimeCursorAudience, + pub limits: RuntimeCursorProtectionLimits, +} +pub(super) struct Results(pub BTreeMap); +pub(super) struct GuardFactory { + pub database: Database, + pub modules: Vec, + pub limits: RuntimeGraphqlLimits, + pub protection: Option, +} +impl ExtensionFactory for GuardFactory { + fn create(&self) -> Arc { + Arc::new(Guard { + database: self.database.clone(), + modules: self.modules.clone(), + limits: self.limits, + protection: self.protection.clone(), + parsed: Mutex::new(None), + }) + } +} +struct Guard { + database: Database, + modules: Vec, + limits: RuntimeGraphqlLimits, + protection: Option, + parsed: Mutex>, +} +fn server(e: RuntimeGraphqlError) -> async_graphql::ServerError { + { + let mut error = async_graphql::ServerError::new(e.code(), None); + let mut extensions = async_graphql::ErrorExtensionValues::default(); + extensions.set("code", e.code()); + error.extensions = Some(extensions); + error + } +} +fn bounded_variables( + variables: &Variables, + limits: RuntimeGraphqlLimits, +) -> Result<(), RuntimeGraphqlError> { + let mut bytes = 0usize; + let mut nodes = 0usize; + let mut stack = Vec::new(); + for (name, value) in variables.iter() { + bytes = bytes.checked_add(name.len()).ok_or_else(invalid)?; + if stack.len() >= limits.max_variable_nodes { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + stack.push((value, 0usize)); + } + while let Some((value, depth)) = stack.pop() { + nodes = nodes.checked_add(1).ok_or_else(invalid)?; + if nodes > limits.max_variable_nodes || depth > limits.max_depth { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + let size = match value { + Value::String(s) => s.len(), + Value::Enum(s) => s.len(), + Value::Binary(v) => v.len(), + _ => 16, + }; + bytes = bytes.checked_add(size).ok_or_else(invalid)?; + if bytes > limits.max_variable_bytes { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + match value { + Value::List(values) => { + for child in values { + if stack.len() >= limits.max_variable_nodes.saturating_sub(nodes) { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + stack.push((child, depth + 1)); + } + } + Value::Object(values) => { + for (name, child) in values { + bytes = bytes.checked_add(name.len()).ok_or_else(invalid)?; + if stack.len() >= limits.max_variable_nodes.saturating_sub(nodes) { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + stack.push((child, depth + 1)); + } + } + _ => {} + } + } + if bytes > limits.max_variable_bytes { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + Ok(()) +} +// Allocation-free lexical nesting guard before the GraphQL parser recurses. +fn bounded_query_nesting(query: &str, maximum: usize) -> Result<(), RuntimeGraphqlError> { + let bytes = query.as_bytes(); + let mut index = 0usize; + let mut depth = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'"' => { + let block = bytes.get(index..index + 3) == Some(b"\"\"\""); + index += if block { 3 } else { 1 }; + while index < bytes.len() { + if bytes[index] == b'\\' { + index = index.saturating_add(2); + continue; + } + if block && bytes.get(index..index + 3) == Some(b"\"\"\"") { + index += 3; + break; + } + if !block && bytes[index] == b'"' { + index += 1; + break; + } + index += 1; + } + } + b'{' | b'[' | b'(' => { + depth += 1; + if depth > maximum { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + index += 1; + } + b'}' | b']' | b')' => { + depth = depth.saturating_sub(1); + index += 1; + } + _ => index += 1, + } + } + Ok(()) +} +#[async_trait::async_trait] +impl Extension for Guard { + async fn prepare_request( + &self, + ctx: &ExtensionContext<'_>, + request: Request, + next: NextPrepareRequest<'_>, + ) -> async_graphql::ServerResult { + let limits = request + .data + .get(&std::any::TypeId::of::>()) + .and_then(|data| data.downcast_ref::>()) + .map_or(self.limits, |request| { + self.limits.restricted_by(request.budget) + }); + if request.query.len() > limits.max_query_bytes { + return Err(server(RuntimeGraphqlError::new("cost_exceeded"))); + } + bounded_variables(&request.variables, limits).map_err(server)?; + next.run(ctx, request).await + } + async fn parse_query( + &self, + ctx: &ExtensionContext<'_>, + query: &str, + variables: &Variables, + next: NextParseQuery<'_>, + ) -> async_graphql::ServerResult { + if query.len() > self.limits.max_query_bytes { + return Err(server(RuntimeGraphqlError::new("cost_exceeded"))); + } + bounded_variables(variables, self.limits).map_err(server)?; + bounded_query_nesting(query, self.limits.max_depth).map_err(server)?; + let document = next + .run(ctx, query, variables) + .await + .map_err(|_| server(invalid()))?; + *self.parsed.lock().map_err(|_| server(invalid()))? = + Some((document.clone(), variables.clone())); + Ok(document) + } + async fn validation( + &self, + ctx: &ExtensionContext<'_>, + next: NextValidation<'_>, + ) -> Result> { + next.run(ctx).await.map_err(|_| vec![server(invalid())]) + } + async fn execute( + &self, + ctx: &ExtensionContext<'_>, + operation_name: Option<&str>, + next: NextExecute<'_>, + ) -> Response { + let result = self.run(ctx, operation_name).await; + match result { + Ok(results) => { + let mut data = Data::default(); + data.insert(results); + next.run_with_data(ctx, operation_name, data).await + } + Err(error) => Response::from_errors(vec![server(error)]), + } + } +} +impl Guard { + async fn run( + &self, + ctx: &ExtensionContext<'_>, + operation_name: Option<&str>, + ) -> Result { + let (document, mut variables) = self + .parsed + .lock() + .map_err(|_| invalid())? + .clone() + .ok_or_else(invalid)?; + let operation = document + .operations + .iter() + .find(|(name, _)| match operation_name { + Some(expected) => name.is_some_and(|n| n == expected), + None => document.operations.iter().len() == 1, + }) + .ok_or_else(invalid)? + .1; + for definition in &operation.node.variable_definitions { + if !variables.contains_key(&definition.node.name.node) { + if let Some(value) = definition.node.default_value() { + variables.insert(definition.node.name.node.clone(), value.clone()); + } + } + } + let request = ctx.data_opt::>(); + let limits = request.map_or(self.limits, |request| { + self.limits.restricted_by(request.budget) + }); + bounded_variables(&variables, limits)?; + let mut planner = Planner { + document: &document, + variables: &variables, + limits, + expanded: 0, + cost: Default::default(), + }; + let root_type = ctx.schema_env.registry.query_type.as_str(); + let selected = planner.selections(&operation.node.selection_set.node, root_type, 0)?; + let mut plans = Vec::new(); + let mut output_shapes = BTreeMap::new(); + for field in selected { + for module in &self.modules { + if let Some(c) = module + .schema + .schema() + .collections + .iter() + .find(|c| module.root_name(c) == field.name.node.as_str()) + { + plans.push(planner.collection(module, c, &field, None, 1, 0)?); + let shape = OutputShape::compile( + &mut planner, + &ctx.schema_env.registry, + &field.selection_set.node, + &format!("{}Connection", module.object_name(c)), + 0, + )?; + output_shapes.insert(field.response_key().node.to_string(), shape); + } + } + } + if plans.is_empty() { + return Ok(Results(BTreeMap::new())); + } + let request = request.ok_or_else(|| RuntimeGraphqlError::new("authorization_missing"))?; + // Complete all layers' grants and cursor framing checks before any ORM I/O. + for plan in &mut plans { + authorize(plan, request, self.protection.as_ref(), limits).await?; + } + let mut executor = Executor { + database: &self.database, + request, + protection: self.protection.as_ref(), + limits, + response_bytes: 0, + crypto_calls: 0, + }; + let mut prepared = Vec::new(); + for plan in &plans { + let page = executor.page(plan, None).await?; + let read = plan + .module + .schema + .runtime_read_request_with_relation_keys( + &plan.collection, + &plan.projection, + plan.predicate.clone(), + plan.order.clone(), + page, + plan.count, + &plan.relations(), + limits.query, + ) + .map_err(relation_error)?; + read.validate_graphql_backend(B::DIALECT) + .map_err(query_error)?; + prepared.push(read); + } + let mut values = BTreeMap::new(); + for (plan, read) in plans.into_iter().zip(prepared) { + let value = executor.root(&plan, read).await?; + values.insert(plan.response_key, value); + } + let mut response_bytes = 0usize; + for (key, value) in &values { + output_shapes.get(key).ok_or_else(invalid)?.measure( + value, + &mut response_bytes, + limits.max_response_bytes, + )?; + } + Ok(Results(values)) + } +} +fn authorize<'a, B: RuntimeReadBackend>( + plan: &'a mut Plan, + request: &'a RuntimeGraphqlRequest, + protection: Option<&'a Protection>, + limits: RuntimeGraphqlLimits, +) -> BoxFuture<'a, Result<(), RuntimeGraphqlError>> { + Box::pin(async move { + if plan.module.schema.fingerprint() != request.fingerprint { + return Err(RuntimeGraphqlError::new("schema_mismatch")); + } + if let Some(token) = page_cursor(&plan.page) { + if plan.module.options.cursor_profile == RuntimeCursorProfile::AuthenticatedEncryption { + let protection = + protection.ok_or_else(|| RuntimeGraphqlError::new("invalid_composition"))?; + cursor::validate_token(token, protection.limits)?; + } else if token.len() > limits.query.max_cursor_bytes { + return Err(RuntimeGraphqlError::new("invalid_cursor")); + } + } + if plan.module.options.cursor_profile == RuntimeCursorProfile::AuthenticatedEncryption + && request + .scope + .as_ref() + .is_none_or(|v| v.is_empty() || v.len() > 4096) + { + return Err(RuntimeGraphqlError::new("authorization_missing")); + } + let grant = request + .authority + .authorize(RuntimeReadCheck { + schema: &plan.module.schema, + collection: &plan.collection, + projection: &plan.fields, + filter: plan.predicate.as_ref(), + filter_operations: plan + .filter_intent + .operations + .iter() + .map(|(field, op)| (field, *op)) + .collect(), + requested_order: &plan.requested_order, + order_was_supplied: plan.order_was_supplied, + order: &plan.order, + logical_operators: plan.filter_intent.logical.clone(), + relation: plan.relation.as_ref(), + include_count: plan.count, + }) + .await?; + if let Some(predicate) = grant.predicate { + if !predicate.belongs_to(&plan.module.schema.fingerprint(), plan.collection.id()) { + return Err(RuntimeGraphqlError::new("invalid_filter")); + } + plan.predicate = Some(match plan.predicate.take() { + Some(filter) => plan + .module + .schema + .runtime_and(&plan.collection, vec![predicate, filter], limits.query) + .map_err(query_error)?, + None => predicate, + }); + } + // Validate the authorized layer through the shared renderer before any + // parent read. Parent-bound cursor decoding is deferred until its anchor exists. + let page = match &plan.page { + RuntimePageRequest::First { size, .. } => RuntimePageRequest::first(*size, None), + RuntimePageRequest::Last { size, .. } => RuntimePageRequest::last(*size, None), + }; + plan.module + .schema + .runtime_read_request( + &plan.collection, + &plan.projection, + plan.predicate.clone(), + plan.order.clone(), + page, + plan.count, + limits.query, + ) + .map_err(query_error)? + .validate_graphql_backend(B::DIALECT) + .map_err(query_error)?; + for child in &mut plan.children { + authorize(child, request, protection, limits).await?; + } + Ok(()) + }) +} +fn page_cursor(page: &RuntimePageRequest) -> Option<&str> { + match page { + RuntimePageRequest::First { after, .. } => after.as_deref(), + RuntimePageRequest::Last { before, .. } => before.as_deref(), + } +} +struct Executor<'a, B: RuntimeReadBackend> { + database: &'a Database, + request: &'a RuntimeGraphqlRequest, + protection: Option<&'a Protection>, + limits: RuntimeGraphqlLimits, + response_bytes: usize, + crypto_calls: usize, +} +fn relation_error(e: RuntimeRelationError) -> RuntimeGraphqlError { + RuntimeGraphqlError::new(e.code().as_str()) +} +fn object(entries: impl IntoIterator) -> Value { + Value::Object( + entries + .into_iter() + .map(|(k, v)| (Name::new(k), v)) + .collect(), + ) +} +impl Executor<'_, B> { + fn context( + &self, + plan: &Plan, + parent: Option<&RuntimeParentAnchor>, + ) -> Result, RuntimeGraphqlError> { + if plan.module.options.cursor_profile == RuntimeCursorProfile::Unprotected { + return Ok(None); + } + let protection = self + .protection + .ok_or_else(|| RuntimeGraphqlError::new("invalid_composition"))?; + let order = plan + .order + .terms() + .iter() + .map(|t| (t.field().id(), t.direction(), t.nulls())) + .collect::>(); + let order = serde_json::to_vec(&order).map_err(|_| invalid())?; + let parent = parent + .map(RuntimeParentAnchor::graphql_cursor_scope) + .transpose() + .map_err(relation_error)? + .unwrap_or_default(); + let fingerprint = plan.module.schema.fingerprint(); + RuntimeCursorContext::new( + &protection.audience, + self.request.scope.as_deref().ok_or_else(invalid)?, + &[ + fingerprint.as_str().as_bytes(), + plan.collection.id().as_str().as_bytes(), + &order, + &parent, + ], + protection.limits, + ) + .map(Some) + } + fn crypto(&mut self) -> Result<(), RuntimeGraphqlError> { + self.crypto_calls = self.crypto_calls.checked_add(1).ok_or_else(invalid)?; + if self.crypto_calls > self.limits.max_crypto_calls { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + Ok(()) + } + async fn page( + &mut self, + plan: &Plan, + parent: Option<&RuntimeParentAnchor>, + ) -> Result { + let mut page = plan.page.clone(); + let token = match &mut page { + RuntimePageRequest::First { after, .. } => after, + RuntimePageRequest::Last { before, .. } => before, + }; + if let Some(raw) = token { + if let Some(context) = self.context(plan, parent)? { + self.crypto()?; + let protection = self.protection.ok_or_else(invalid)?; + *raw = cursor::open( + protection.protector.as_ref(), + &context, + raw, + protection.limits, + ) + .await?; + } + } + Ok(page) + } + async fn connection( + &mut self, + plan: &Plan, + parent: Option<&RuntimeParentAnchor>, + connection: RuntimeConnection, + nodes: Vec, + ) -> Result { + let context = self.context(plan, parent)?; + let mut cursors = Vec::new(); + let mut edges = Vec::new(); + for (edge, node) in connection.edges.into_iter().zip(nodes) { + let token = if let Some(context) = &context { + self.crypto()?; + let protection = self.protection.ok_or_else(invalid)?; + cursor::seal( + protection.protector.as_ref(), + context, + &edge.cursor, + protection.limits, + ) + .await? + } else { + edge.cursor + }; + self.reserve(token.len())?; + cursors.push(token.clone()); + edges.push(object([("cursor", Value::String(token)), ("node", node)])); + } + Ok(object([ + ("edges", Value::List(edges)), + ( + "pageInfo", + object([ + ( + "hasNextPage", + Value::Boolean(connection.page_info.has_next_page), + ), + ( + "hasPreviousPage", + Value::Boolean(connection.page_info.has_previous_page), + ), + ( + "startCursor", + cursors + .first() + .cloned() + .map(Value::String) + .unwrap_or(Value::Null), + ), + ( + "endCursor", + cursors + .last() + .cloned() + .map(Value::String) + .unwrap_or(Value::Null), + ), + ]), + ), + ( + "totalCount", + connection + .total_count + .map(|v| Value::String(v.to_string())) + .unwrap_or(Value::Null), + ), + ])) + } + fn reserve(&mut self, bytes: usize) -> Result<(), RuntimeGraphqlError> { + self.response_bytes = self.response_bytes.checked_add(bytes).ok_or_else(invalid)?; + if self.response_bytes > self.limits.max_response_bytes { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + Ok(()) + } + fn records( + &mut self, + plan: &Plan, + records: &[RuntimeRecord], + ) -> Result, RuntimeGraphqlError> { + let metadata = plan + .module + .schema + .schema() + .collections + .iter() + .find(|c| &c.id == plan.collection.id()) + .ok_or_else(invalid)?; + let mut nodes = Vec::new(); + for record in records { + let mut values = async_graphql::indexmap::IndexMap::new(); + for field in &plan.fields { + let value = match record.state(field).map_err(|_| invalid())? { + RuntimeFieldState::Value(v) => scalar::encode(v, self.limits.scalars)?, + RuntimeFieldState::Null => Value::Null, + RuntimeFieldState::Unloaded => { + return Err(RuntimeGraphqlError::new("projection_mismatch")); + } + }; + let size = match &value { + Value::String(v) => v.len(), + _ => 16, + }; + self.reserve(size)?; + let name = &metadata + .fields + .iter() + .find(|f| &f.id == field.id()) + .ok_or_else(invalid)? + .api_name; + values.insert(Name::new(name), value); + } + nodes.push(Value::Object(values)); + } + Ok(nodes) + } + async fn root( + &mut self, + plan: &Plan, + request: RuntimeAnchoredReadRequest, + ) -> Result { + let connection = self + .database + .execute_runtime_anchored_read(&request, self.request.auth.as_ref()) + .await + .map_err(relation_error)?; + let records = connection + .edges + .iter() + .map(|e| e.node.clone()) + .collect::>(); + let mut nodes = self.records(plan, &records)?; + for child in &plan.children { + let relation = child.relation.as_ref().ok_or_else(invalid)?; + let parents = connection + .relation_parents(relation) + .map_err(relation_error)?; + let values = self.layer(child, parents).await?; + attach( + &mut nodes, + &super::schema::relation_output_key(&child.field_name, &child.response_key), + values, + )?; + } + let connection = RuntimeConnection { + edges: connection + .edges + .into_iter() + .map(|e| RuntimeEdge { + node: e.node, + cursor: e.cursor, + }) + .collect(), + page_info: connection.page_info, + total_count: connection.total_count, + }; + self.connection(plan, None, connection, nodes).await + } + fn layer<'a>( + &'a mut self, + plan: &'a Plan, + parents: Vec, + ) -> BoxFuture<'a, Result, RuntimeGraphqlError>> { + Box::pin(async move { + let relation = plan.relation.as_ref().ok_or_else(invalid)?; + if parents.is_empty() { + return Ok(Vec::new()); + } + let selection = if relation.cardinality() == RelationCardinality::One { + RuntimeRelationSelection::ToOne + } else { + let mut pages = Vec::new(); + for parent in &parents { + pages.push(self.page(plan, Some(parent)).await?); + } + RuntimeRelationSelection::ToMany { + pages, + include_count: plan.count, + } + }; + let request = plan + .module + .schema + .runtime_relation_batch_request_with_relation_keys( + relation, + parents.clone(), + &plan.projection, + plan.predicate.clone(), + plan.order.clone(), + selection, + &plan.relations(), + self.limits.relation, + ) + .map_err(relation_error)?; + let batch = self + .database + .execute_runtime_relation_batch(&request, self.request.auth.as_ref()) + .await + .map_err(relation_error)?; + let records = batch + .results + .iter() + .flat_map(|r| match &r.value { + RuntimeRelationValue::ToOne(v) => v.iter().cloned().collect::>(), + RuntimeRelationValue::ToMany(c) => { + c.edges.iter().map(|e| e.node.clone()).collect() + } + }) + .collect::>(); + let mut nodes = self.records(plan, &records)?; + for child in &plan.children { + let parents = batch + .relation_parents(child.relation.as_ref().ok_or_else(invalid)?) + .map_err(relation_error)?; + let values = self.layer(child, parents).await?; + attach( + &mut nodes, + &super::schema::relation_output_key(&child.field_name, &child.response_key), + values, + )?; + } + let mut nodes = nodes.into_iter(); + let mut output = Vec::new(); + for (result, parent) in batch.results.into_iter().zip(&parents) { + output.push(match result.value { + RuntimeRelationValue::ToOne(v) => { + if v.is_some() { + nodes.next().ok_or_else(invalid)? + } else { + Value::Null + } + } + RuntimeRelationValue::ToMany(connection) => { + let values = nodes.by_ref().take(connection.edges.len()).collect(); + self.connection(plan, Some(parent), connection, values) + .await? + } + }); + } + Ok(output) + }) + } +} +fn attach(nodes: &mut [Value], key: &str, values: Vec) -> Result<(), RuntimeGraphqlError> { + if nodes.len() != values.len() { + return Err(invalid()); + } + for (node, value) in nodes.iter_mut().zip(values) { + let Value::Object(map) = node else { + return Err(invalid()); + }; + map.insert(Name::new(key), value); + } + Ok(()) +} + +struct OutputShape { + fields: Vec<(String, String, OutputShape)>, +} +impl OutputShape { + fn compile( + planner: &mut Planner<'_>, + registry: &async_graphql::registry::Registry, + set: &async_graphql::parser::types::SelectionSet, + ty: &str, + depth: usize, + ) -> Result { + let mut fields = Vec::new(); + for field in planner.selections(set, ty, depth)? { + let child = if field.name.node == "__typename" { + Self { fields: Vec::new() } + } else { + let target = registry + .types + .get(ty) + .and_then(|t| t.field_by_name(field.name.node.as_str())) + .ok_or_else(invalid)?; + let target = target.ty.trim_matches(['[', ']', '!']); + Self::compile( + planner, + registry, + &field.selection_set.node, + target, + depth + 1, + )? + }; + fields.push(( + field.response_key().node.to_string(), + field.name.node.to_string(), + child, + )); + } + Ok(Self { fields }) + } + fn measure( + &self, + value: &Value, + bytes: &mut usize, + maximum: usize, + ) -> Result<(), RuntimeGraphqlError> { + let add = |bytes: &mut usize, size: usize| -> Result<(), RuntimeGraphqlError> { + *bytes = bytes.checked_add(size).ok_or_else(invalid)?; + if *bytes > maximum { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + Ok(()) + }; + add(bytes, 2)?; + match value { + Value::List(values) => { + for value in values { + self.measure(value, bytes, maximum)?; + } + } + Value::Object(values) => { + for (key, name, child) in &self.fields { + add( + bytes, + key.len() + .checked_mul(6) + .and_then(|n| n.checked_add(4)) + .ok_or_else(invalid)?, + )?; + if name == "__typename" { + add(bytes, 128)?; + continue; + } + let value = values + .get(super::schema::relation_output_key(name, key).as_str()) + .or_else(|| values.get(name.as_str())) + .ok_or_else(invalid)?; + child.measure(value, bytes, maximum)?; + } + } + Value::String(value) => add(bytes, value.len().checked_mul(6).ok_or_else(invalid)?)?, + _ => add(bytes, 32)?, + } + Ok(()) + } +} + +#[cfg(test)] +mod alias_tests { + use super::*; + #[test] + fn response_budget_uses_schema_identity_and_separate_relation_slots() { + let scalar = || OutputShape { fields: vec![] }; + let shape = OutputShape { + fields: vec![ + ("caption".into(), "label".into(), scalar()), + ("label".into(), "caption".into(), scalar()), + ("link".into(), "children".into(), scalar()), + ], + }; + let value = object([ + ("label", Value::String("long scalar".repeat(10))), + ("caption", Value::String("c".into())), + ("children:link", Value::String("relation".into())), + ]); + let mut bytes = 0; + shape.measure(&value, &mut bytes, usize::MAX).unwrap(); + let mut exact = 0; + shape.measure(&value, &mut exact, bytes).unwrap(); + assert!(shape.measure(&value, &mut 0, bytes - 1).is_err()); + // Repeating a long scalar under another schema field's alias still charges + // for the long scalar, never the shorter value stored under that name. + let repeated = OutputShape { + fields: vec![ + ("caption".into(), "label".into(), scalar()), + ("other".into(), "label".into(), scalar()), + ], + }; + assert!(repeated.measure(&value, &mut 0, bytes).is_err()); + } +} diff --git a/crates/graphql-orm/src/graphql/runtime/mod.rs b/crates/graphql-orm/src/graphql/runtime/mod.rs new file mode 100644 index 00000000..08210a04 --- /dev/null +++ b/crates/graphql-orm/src/graphql/runtime/mod.rs @@ -0,0 +1,46 @@ +//! Opt-in, authorized dynamic GraphQL registration for owned runtime schemas. +//! +//! The host owns authority and cursor encryption. This module does not publish +//! schemas, persist catalogs, own transport, or alter static GraphQL behavior. + +mod cursor; +mod scalar; +pub use cursor::*; +pub use scalar::RuntimeScalarLimits; + +use std::fmt; + +/// A bounded, value-free dynamic GraphQL error. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RuntimeGraphqlError { + code: &'static str, +} +impl RuntimeGraphqlError { + /// Stable code; no identifiers, query text, cursor or caller values. + pub const fn code(self) -> &'static str { + self.code + } + /// Explicit host denial without exposing policy internals. + pub const fn denied() -> Self { + Self { + code: "authorization_denied", + } + } + pub(crate) const fn new(code: &'static str) -> Self { + Self { code } + } +} +impl fmt::Display for RuntimeGraphqlError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.code) + } +} +impl std::error::Error for RuntimeGraphqlError {} +mod execution; +mod module; +mod plan; +mod schema; +pub use execution::{ + RuntimeGraphqlRequest, RuntimeReadAuthority, RuntimeReadCheck, RuntimeReadGrant, +}; +pub use module::*; diff --git a/crates/graphql-orm/src/graphql/runtime/module.rs b/crates/graphql-orm/src/graphql/runtime/module.rs new file mode 100644 index 00000000..318383e6 --- /dev/null +++ b/crates/graphql-orm/src/graphql/runtime/module.rs @@ -0,0 +1,619 @@ +use super::*; +use crate::{db::Database, graphql::orm::*}; +use async_graphql::{dynamic::*, extensions::ExtensionFactory}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; + +/// Supported runtime read backend; unsupported decoder capabilities fail before I/O. +pub trait RuntimeReadBackend: OrmBackend + RuntimeRowDecoder {} +impl RuntimeReadBackend for B {} + +/// Schema naming and cursor privacy options. +#[derive(Clone, Debug, Default)] +#[non_exhaustive] +pub struct RuntimeGraphqlOptions { + pub type_prefix: String, + pub root_names: BTreeMap, + pub cursor_profile: RuntimeCursorProfile, +} +impl RuntimeGraphqlOptions { + /// Select cursor privacy explicitly. The default requires authenticated encryption. + pub fn with_cursor_profile(mut self, profile: RuntimeCursorProfile) -> Self { + self.cursor_profile = profile; + self + } +} +/// Hard schema and execution bounds. Hosts may lower these limits. +#[derive(Clone, Copy, Debug)] +pub struct RuntimeGraphqlLimits { + pub max_types: usize, + pub max_name_bytes: usize, + pub max_schema_bytes: usize, + pub max_query_bytes: usize, + pub max_variable_bytes: usize, + pub max_variable_nodes: usize, + pub max_depth: usize, + pub max_selections: usize, + pub max_statements: usize, + pub max_materialized_nodes: usize, + pub max_response_bytes: usize, + pub max_crypto_calls: usize, + pub query: RuntimeQueryLimits, + pub relation: RuntimeRelationLimits, + pub scalars: RuntimeScalarLimits, +} +impl Default for RuntimeGraphqlLimits { + fn default() -> Self { + Self { + max_types: 4096, + max_name_bytes: 128, + max_schema_bytes: 4 * 1024 * 1024, + max_query_bytes: 64 * 1024, + max_variable_bytes: 256 * 1024, + max_variable_nodes: 8192, + max_depth: 16, + max_selections: 1024, + max_statements: 64, + max_materialized_nodes: 10_000, + max_response_bytes: 8 * 1024 * 1024, + max_crypto_calls: 20_000, + query: Default::default(), + relation: Default::default(), + scalars: Default::default(), + } + } +} +impl RuntimeGraphqlLimits { + pub(super) fn restricted_by(mut self, other: Self) -> Self { + macro_rules! bound { ($($field:ident),*) => { $(self.$field = self.$field.min(other.$field);)* }; } + bound!( + max_types, + max_name_bytes, + max_schema_bytes, + max_query_bytes, + max_variable_bytes, + max_variable_nodes, + max_depth, + max_selections, + max_statements, + max_materialized_nodes, + max_response_bytes, + max_crypto_calls + ); + macro_rules! nested { ($part:ident; $($field:ident),*) => { $(self.$part.$field = self.$part.$field.min(other.$part.$field);)* }; } + nested!(query; max_predicate_depth, max_predicate_nodes, max_values_per_list, max_bind_parameters, max_order_terms, + max_projection_fields, default_page_size, max_page_size, max_cursor_bytes, max_cursor_values); + nested!(relation; max_parents, max_key_arity, max_page_size, max_bind_parameters, max_cursor_bytes, max_compatible_groups); + nested!(scalars; max_text_bytes, max_decoded_bytes, max_json_depth, max_json_nodes); + self + } +} +/// Checked upper-bound admission estimate, not a bound on database scan rows. +#[derive(Clone, Copy, Debug, Default)] +pub struct RuntimeGraphqlCost { + pub statements: usize, + pub materialized_nodes: usize, + pub crypto_calls: usize, + pub relation_groups: usize, +} +impl RuntimeGraphqlCost { + /// Conservative root-page admission estimate, including one possible resume. + pub fn for_root_page( + size: usize, + count: bool, + protected: bool, + ) -> Result { + let mut cost = Self::for_relation_layer(1, size, count, protected)?; + cost.relation_groups = 0; + Ok(cost) + } + /// One bounded batched layer; this does not estimate database scanned rows. + pub fn for_relation_layer( + parents: usize, + size: usize, + count: bool, + protected: bool, + ) -> Result { + let overflow = || RuntimeGraphqlError::new("cost_exceeded"); + let nodes = parents.checked_mul(size).ok_or_else(overflow)?; + Ok(Self { + statements: if parents == 0 { + 0 + } else { + 1 + usize::from(count) + }, + materialized_nodes: nodes, + crypto_calls: if protected { + nodes.checked_add(parents).ok_or_else(overflow)? + } else { + 0 + }, + relation_groups: usize::from(parents != 0), + }) + } + /// Add independent roots/layers without wrapping admission counters. + pub fn checked_add(self, other: Self) -> Result { + let overflow = || RuntimeGraphqlError::new("cost_exceeded"); + Ok(Self { + statements: self + .statements + .checked_add(other.statements) + .ok_or_else(overflow)?, + materialized_nodes: self + .materialized_nodes + .checked_add(other.materialized_nodes) + .ok_or_else(overflow)?, + crypto_calls: self + .crypto_calls + .checked_add(other.crypto_calls) + .ok_or_else(overflow)?, + relation_groups: self + .relation_groups + .checked_add(other.relation_groups) + .ok_or_else(overflow)?, + }) + } +} +/// Immutable module metadata; contains no authority or catalog state. +#[derive(Clone, Debug)] +pub struct RuntimeGraphqlDescriptor { + pub(super) names: BTreeSet, + cost: RuntimeGraphqlCost, +} +impl RuntimeGraphqlDescriptor { + pub fn type_names(&self) -> impl Iterator { + self.names.iter().map(String::as_str) + } + /// One default 50-row root connection including its optional count. + /// Nested layers/aliases require additional admission estimates. + pub fn cost_metadata(&self) -> RuntimeGraphqlCost { + self.cost + } +} +/// Safe compilation diagnostics. No physical names or caller values are rendered. +#[derive(Clone, Debug)] +pub struct RuntimeGraphqlDiagnostics(pub Vec); +impl std::fmt::Display for RuntimeGraphqlDiagnostics { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("runtime_graphql_compilation_failed") + } +} +impl std::error::Error for RuntimeGraphqlDiagnostics {} +impl From for RuntimeGraphqlDiagnostics { + fn from(e: RuntimeGraphqlError) -> Self { + Self(vec![e]) + } +} + +/// Compiled runtime module, ready for checked installation. +#[derive(Clone)] +pub struct RuntimeGraphqlModule { + pub(super) schema: Arc, + pub(super) options: Arc, + descriptor: Arc, +} +pub(super) fn valid_name(name: &str, upper: bool) -> bool { + !name.is_empty() + && name.len() <= 128 + && !name.starts_with("__") + && name.bytes().all(|b| b.is_ascii_alphanumeric()) + && name.as_bytes()[0].is_ascii_alphabetic() + && if upper { + name.as_bytes()[0].is_ascii_uppercase() + } else { + name.as_bytes()[0].is_ascii_lowercase() + } +} +impl RuntimeGraphqlModule { + pub fn compile( + schema: Arc, + options: RuntimeGraphqlOptions, + ) -> Result { + let cost = RuntimeGraphqlCost::for_root_page( + 50, + true, + options.cursor_profile == RuntimeCursorProfile::AuthenticatedEncryption, + )?; + let mut module = Self { + schema, + options: Arc::new(options), + descriptor: Arc::new(RuntimeGraphqlDescriptor { + names: BTreeSet::new(), + cost, + }), + }; + if module.schema.schema().collections.len() > 512 { + return Err(RuntimeGraphqlError::new("cost_exceeded").into()); + } + bounded_schema( + &module.schema, + RuntimeGraphqlLimits::default().max_schema_bytes, + )?; + let mut roots = BTreeSet::new(); + for c in &module.schema.schema().collections { + if c.fields.len() > 1024 || c.relations.len() > 128 { + return Err(RuntimeGraphqlError::new("cost_exceeded").into()); + } + if c.fields + .iter() + .any(|f| f.filterable && matches!(f.api_name.as_str(), "and" | "or" | "not")) + { + return Err(RuntimeGraphqlError::new("name_collision").into()); + } + + let handle = module.schema.resolve_collection(&c.id).map_err(|_| { + RuntimeGraphqlDiagnostics::from(RuntimeGraphqlError::new("invalid_composition")) + })?; + module + .schema + .runtime_order(&handle, None, RuntimeQueryLimits::default()) + .map_err(|e| { + RuntimeGraphqlDiagnostics::from(RuntimeGraphqlError::new(e.code().as_str())) + })?; + if !valid_name(&module.object_name(c), true) + || !valid_name(&module.root_name(c), false) + || !roots.insert(module.root_name(c)) + { + return Err(RuntimeGraphqlError::new("name_collision").into()); + } + let mut fields = BTreeSet::new(); + for name in c + .fields + .iter() + .map(|f| &f.api_name) + .chain(c.relations.iter().map(|r| &r.api_name)) + { + if !valid_name(name, false) || !fields.insert(name) { + return Err(RuntimeGraphqlError::new("invalid_composition").into()); + } + } + for suffix in [ + "", + "WhereInput", + "OrderInput", + "OrderField", + "Connection", + "Edge", + ] { + let name = format!("{}{suffix}", module.object_name(c)); + if !valid_name(&name, true) + || !Arc::make_mut(&mut module.descriptor).names.insert(name) + { + return Err(RuntimeGraphqlError::new("name_collision").into()); + } + } + } + if module.options.root_names.keys().any(|id| { + !module + .schema + .schema() + .collections + .iter() + .any(|c| &c.id == id) + }) { + return Err(RuntimeGraphqlError::new("invalid_composition").into()); + } + Ok(module) + } + pub fn descriptor(&self) -> &RuntimeGraphqlDescriptor { + &self.descriptor + } + pub(super) fn object_name(&self, c: &RuntimeCollection) -> String { + format!("{}{}", self.options.type_prefix, c.api_type_name) + } + pub(super) fn root_name(&self, c: &RuntimeCollection) -> String { + self.options + .root_names + .get(&c.id) + .cloned() + .unwrap_or_else(|| { + let mut name = c.api_plural_name.clone(); + if let Some(first) = name.get_mut(..1) { + first.make_ascii_lowercase(); + } + name + }) + } +} + +/// Root field whose actual name is checked by the composer. +pub struct RuntimeHostField { + name: String, + field: Field, + arguments: BTreeSet, +} +impl RuntimeHostField { + pub fn new(name: impl Into, ty: TypeRef, resolver: F) -> Self + where + F: for<'a> Fn(ResolverContext<'a>) -> FieldFuture<'a> + Send + Sync + 'static, + { + let name = name.into(); + Self { + field: Field::new(&name, ty, resolver), + name, + arguments: BTreeSet::new(), + } + } + /// Add a checked host argument without exposing the root field's name. + pub fn argument( + mut self, + name: impl Into, + ty: TypeRef, + default: Option, + ) -> Result { + let name = name.into(); + if !valid_name(&name, false) || !self.arguments.insert(name.clone()) { + return Err(RuntimeGraphqlError::new("name_collision")); + } + let mut input = InputValue::new(name, ty); + if let Some(default) = default { + input = input.default_value(default); + } + self.field = self.field.argument(input); + Ok(self) + } +} +/// Checked dynamic composition. It never accepts an opaque raw builder callback. +pub struct RuntimeGraphqlComposer { + database: Database, + query: Object, + query_name: String, + subscription_name: Option, + actions: Vec SchemaBuilder + Send>>, + names: BTreeSet, + roots: BTreeSet, + modules: Vec, + limits: RuntimeGraphqlLimits, + protection: Option, +} +impl RuntimeGraphqlComposer { + pub fn new( + database: Database, + query_name: impl Into, + limits: RuntimeGraphqlLimits, + ) -> Result { + let query_name = query_name.into(); + if !B::RUNTIME_ROW_DECODING_SUPPORTED { + return Err(RuntimeGraphqlError::new("unsupported_backend")); + } + if !valid_name(&query_name, true) { + return Err(RuntimeGraphqlError::new("invalid_composition")); + } + let mut names = ["String", "Int", "Float", "Boolean", "ID"] + .into_iter() + .map(str::to_owned) + .collect::>(); + if !names.insert(query_name.clone()) { + return Err(RuntimeGraphqlError::new("name_collision")); + } + Ok(Self { + database, + query: Object::new(&query_name), + query_name, + subscription_name: None, + actions: Vec::new(), + names, + roots: BTreeSet::new(), + modules: Vec::new(), + limits, + protection: None, + }) + } + pub fn query_field(mut self, spec: RuntimeHostField) -> Result { + if !valid_name(&spec.name, false) || !self.roots.insert(spec.name) { + return Err(RuntimeGraphqlError::new("name_collision")); + } + self.query = self.query.field(spec.field); + Ok(self) + } + pub fn register(mut self, ty: Type) -> Result { + let name = match &ty { + Type::Scalar(t) => t.type_name(), + Type::Object(t) => t.type_name(), + Type::InputObject(t) => t.type_name(), + Type::Enum(t) => t.type_name(), + Type::Interface(t) => t.type_name(), + Type::Union(t) => t.type_name(), + Type::Subscription(t) => t.type_name(), + Type::Upload => "Upload", + }; + if name.len() > self.limits.max_name_bytes + || name.starts_with("__") + || !self.names.insert(name.to_owned()) + || self.names.len() > self.limits.max_types + { + return Err(RuntimeGraphqlError::new("name_collision")); + } + self.actions + .push(Box::new(move |builder| builder.register(ty))); + Ok(self) + } + pub fn install( + mut self, + module: RuntimeGraphqlModule, + ) -> Result { + if module.options.cursor_profile == RuntimeCursorProfile::AuthenticatedEncryption + && self.protection.is_none() + { + return Err(RuntimeGraphqlError::new("invalid_composition").into()); + } + bounded_schema(&module.schema, self.limits.max_schema_bytes)?; + if self.modules.is_empty() { + for ty in super::schema::helpers(self.limits.scalars) { + self = self.register(ty)?; + } + } + for ty in super::schema::types(&module) { + self = self.register(ty)?; + } + for c in &module.schema.schema().collections { + let root = module.root_name(c); + if !self.roots.insert(root.clone()) { + return Err(RuntimeGraphqlError::new("name_collision").into()); + } + self.query = self + .query + .field(super::schema::root_field(&root, &module.object_name(c))); + } + self.modules.push(module); + Ok(self) + } + pub fn cursor_protection( + mut self, + protector: Arc, + audience: RuntimeCursorAudience, + limits: RuntimeCursorProtectionLimits, + ) -> Result { + if limits.max_plaintext_bytes == 0 + || limits.max_key_id_bytes == 0 + || limits.max_token_bytes == 0 + { + return Err(RuntimeGraphqlError::new("invalid_composition")); + } + self.protection = Some(super::execution::Protection { + protector, + audience, + limits, + }); + Ok(self) + } + pub fn data(mut self, data: D) -> Self { + self.actions + .push(Box::new(move |builder| builder.data(data))); + self + } + pub fn extension(mut self, extension: impl ExtensionFactory + 'static) -> Self { + self.actions + .push(Box::new(move |builder| builder.extension(extension))); + self + } + /// Custom directive factories are unsupported by the locked dynamic framework. + /// Rejects during composition; the factory is never registered or invoked. + pub fn directive( + self, + _directive: impl async_graphql::CustomDirectiveFactory, + ) -> Result { + Err(RuntimeGraphqlError::new("unsupported_capability")) + } + + /// Bounds built-in directives per field. This cannot relax the installed budget. + pub fn limit_directives(mut self, limit: usize) -> Self { + let limit = limit.min(self.limits.max_selections); + self.actions + .push(Box::new(move |builder| builder.limit_directives(limit))); + self + } + + pub fn disable_introspection(mut self) -> Self { + self.actions + .push(Box::new(SchemaBuilder::disable_introspection)); + self + } + /// Host-owned subscriptions only; this adds no ORM event or transport service. + pub fn subscription_root(mut self, root: Subscription) -> Result { + if self.subscription_name.is_some() { + return Err(RuntimeGraphqlError::new("name_collision")); + } + self.subscription_name = Some(root.type_name().to_owned()); + self.register(root.into()) + } + pub fn limit_depth(mut self, limit: usize) -> Self { + self.limits.max_depth = self.limits.max_depth.min(limit); + self + } + pub fn limit_complexity(mut self, limit: usize) -> Self { + self.limits.max_selections = self.limits.max_selections.min(limit); + self + } + + pub fn finish(self) -> Result { + if self.protection.is_none() + && self + .modules + .iter() + .any(|m| m.options.cursor_profile == RuntimeCursorProfile::AuthenticatedEncryption) + { + return Err(RuntimeGraphqlError::new("invalid_composition")); + } + let guard = super::execution::GuardFactory { + database: self.database, + modules: self.modules, + limits: self.limits, + protection: self.protection, + }; + self.actions + .into_iter() + .fold( + Schema::build(&self.query_name, None, self.subscription_name.as_deref()) + .extension(guard) + .limit_directives(self.limits.max_selections), + |builder, action| action(builder), + ) + .register(self.query) + .limit_depth(self.limits.max_depth) + .limit_complexity(self.limits.max_selections) + .limit_recursive_depth(self.limits.max_depth) + .finish() + .map_err(|_| RuntimeGraphqlError::new("invalid_composition")) + } +} + +fn bounded_schema( + schema: &ValidatedRuntimeSchema, + maximum: usize, +) -> Result<(), RuntimeGraphqlError> { + struct Counter { + bytes: usize, + maximum: usize, + } + impl std::io::Write for Counter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if bytes.len() > self.maximum.saturating_sub(self.bytes) { + return Err(std::io::Error::other("schema_limit")); + } + self.bytes += bytes.len(); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + serde_json::to_writer(Counter { bytes: 0, maximum }, schema.schema()) + .map_err(|_| RuntimeGraphqlError::new("cost_exceeded")) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn descriptors_are_shareable_and_cost_estimates_do_not_wrap() { + fn shared() {} + shared::(); + shared::(); + shared::(); + shared::(); + let root = RuntimeGraphqlCost::for_root_page(20, true, true).unwrap(); + let child = RuntimeGraphqlCost::for_relation_layer(20, 5, false, true).unwrap(); + let combined = root.checked_add(child).unwrap(); + assert_eq!(combined.statements, 3); + assert_eq!(combined.materialized_nodes, 120); + assert_eq!(combined.crypto_calls, 141); + assert_eq!(combined.relation_groups, 1); + assert_eq!( + RuntimeGraphqlCost::for_root_page(20, false, false) + .unwrap() + .crypto_calls, + 0 + ); + assert!(RuntimeGraphqlCost::for_relation_layer(usize::MAX, 2, false, true).is_err()); + assert!( + root.checked_add(RuntimeGraphqlCost { + statements: usize::MAX, + ..Default::default() + }) + .is_err() + ); + } +} diff --git a/crates/graphql-orm/src/graphql/runtime/plan.rs b/crates/graphql-orm/src/graphql/runtime/plan.rs new file mode 100644 index 00000000..4d3d3036 --- /dev/null +++ b/crates/graphql-orm/src/graphql/runtime/plan.rs @@ -0,0 +1,570 @@ +use super::*; +use crate::graphql::orm::*; +use async_graphql::{ + Value, Variables, + parser::types::{ExecutableDocument, Field, Selection, SelectionSet}, +}; +use std::collections::BTreeMap; + +pub(super) fn invalid() -> RuntimeGraphqlError { + RuntimeGraphqlError::new("invalid_graphql_input") +} +pub(super) fn query_error(e: RuntimeQueryError) -> RuntimeGraphqlError { + RuntimeGraphqlError::new(e.code().as_str()) +} +fn record_error(e: RuntimeRecordError) -> RuntimeGraphqlError { + RuntimeGraphqlError::new(e.code().as_str()) +} + +#[derive(Clone)] +pub(super) struct Plan { + pub module: RuntimeGraphqlModule, + pub collection: RuntimeCollectionHandle, + pub response_key: String, + pub field_name: String, + pub filter_intent: FilterIntent, + pub fields: Vec, + pub projection: RuntimeProjection, + pub predicate: Option, + pub order: RuntimeOrder, + pub requested_order: Vec, + pub order_was_supplied: bool, + pub page: RuntimePageRequest, + pub count: bool, + pub relation: Option, + pub children: Vec, +} +impl Plan { + pub fn relations(&self) -> Vec { + let mut result = Vec::new(); + for child in &self.children { + if let Some(r) = &child.relation { + if !result.contains(r) { + result.push(r.clone()); + } + } + } + result + } +} + +pub(super) struct Planner<'a> { + pub document: &'a ExecutableDocument, + pub variables: &'a Variables, + pub limits: RuntimeGraphqlLimits, + pub expanded: usize, + pub cost: RuntimeGraphqlCost, +} +impl Planner<'_> { + fn resolve( + &self, + v: &async_graphql::parser::types::Field, + ) -> Result, RuntimeGraphqlError> { + v.arguments + .iter() + .map(|(n, v)| { + Ok(( + n.node.to_string(), + v.node.clone().into_const_with(|name| { + self.variables.get(&name).cloned().ok_or_else(invalid) + })?, + )) + }) + .collect() + } + pub fn selections( + &mut self, + set: &SelectionSet, + ty: &str, + depth: usize, + ) -> Result, RuntimeGraphqlError> { + if depth > self.limits.max_depth { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + let mut fields: BTreeMap = BTreeMap::new(); + for selection in &set.items { + self.expanded = self.expanded.checked_add(1).ok_or_else(invalid)?; + if self.expanded > self.limits.max_selections { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + let mut skip = false; + for directive in selection.node.directives() { + let name = directive.node.name.node.as_str(); + if matches!(name, "skip" | "include") { + let arg = directive + .node + .arguments + .iter() + .find(|(n, _)| n.node == "if") + .ok_or_else(invalid)?; + let value = + arg.1.node.clone().into_const_with(|n| { + self.variables.get(&n).cloned().ok_or_else(invalid) + })?; + let Value::Boolean(value) = value else { + return Err(invalid()); + }; + skip |= if name == "skip" { value } else { !value }; + } + } + if skip { + continue; + } + let expanded = match &selection.node { + Selection::Field(f) => vec![f.node.clone()], + Selection::FragmentSpread(f) => { + let fragment = self + .document + .fragments + .get(&f.node.fragment_name.node) + .ok_or_else(invalid)?; + if fragment.node.type_condition.node.on.node.as_str() != ty { + continue; + } + self.selections(&fragment.node.selection_set.node, ty, depth + 1)? + } + Selection::InlineFragment(f) => { + if f.node + .type_condition + .as_ref() + .is_some_and(|t| t.node.on.node.as_str() != ty) + { + continue; + } + self.selections(&f.node.selection_set.node, ty, depth + 1)? + } + }; + for field in expanded { + let key = field.response_key().node.to_string(); + if let Some(existing) = fields.get_mut(&key) { + existing + .selection_set + .node + .items + .extend(field.selection_set.node.items); + } else { + fields.insert(key, field); + } + } + } + Ok(fields.into_values().collect()) + } + pub fn collection( + &mut self, + module: &RuntimeGraphqlModule, + c: &RuntimeCollection, + field: &Field, + relation: Option, + parents: usize, + depth: usize, + ) -> Result { + if depth > self.limits.max_depth { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + let collection = module + .schema + .resolve_collection(&c.id) + .map_err(record_error)?; + let args = self.resolve(field)?; + let mut filter_intent = FilterIntent::default(); + let predicate = match args.get("where") { + Some(value) if value != &Value::Null => Some(filter( + &module.schema, + &collection, + c, + value, + self.limits, + 0, + &mut filter_intent, + )?), + _ => None, + }; + let mut input = Vec::new(); + if let Some(orders) = args.get("orderBy").filter(|v| *v != &Value::Null) { + let orders = list_values(orders); + if orders.len() > self.limits.query.max_order_terms { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + for order in orders { + let Value::Object(values) = order else { + return Err(invalid()); + }; + let f = enum_text(values.get("field").ok_or_else(invalid)?)?; + let field = c + .fields + .iter() + .find(|v| v.api_name == f) + .ok_or_else(invalid)?; + let direction = match values.get("direction") { + None | Some(Value::Null) => RuntimeOrderDirection::Asc, + Some(v) if enum_text(v)? == "ASC" => RuntimeOrderDirection::Asc, + Some(v) if enum_text(v)? == "DESC" => RuntimeOrderDirection::Desc, + _ => return Err(invalid()), + }; + let nulls = match values.get("nulls") { + None | Some(Value::Null) => RuntimeNullPlacement::Last, + Some(v) if enum_text(v)? == "FIRST" => RuntimeNullPlacement::First, + Some(v) if enum_text(v)? == "LAST" => RuntimeNullPlacement::Last, + _ => return Err(invalid()), + }; + input.push(RuntimeOrderInput { + field: module + .schema + .resolve_field(&collection, &field.id) + .map_err(record_error)?, + direction, + nulls, + }); + } + } + let requested_order = input.clone(); + let order_was_supplied = args.get("orderBy").is_some_and(|v| v != &Value::Null); + let order = module + .schema + .runtime_order( + &collection, + if args.get("orderBy").is_some_and(|v| v != &Value::Null) { + Some(input) + } else { + None + }, + self.limits.query, + ) + .map_err(query_error)?; + let to_one = relation + .as_ref() + .is_some_and(|r| r.cardinality() == RelationCardinality::One); + let number = |name: &str| -> Result, RuntimeGraphqlError> { + match args.get(name) { + None | Some(Value::Null) => Ok(None), + Some(Value::Number(v)) => v.as_i64().map(Some).ok_or_else(invalid), + _ => Err(invalid()), + } + }; + let text = |name: &str| -> Result, RuntimeGraphqlError> { + match args.get(name) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(v)) => Ok(Some(v.clone())), + _ => Err(invalid()), + } + }; + let first = number("first")?; + let last = number("last")?; + let after = text("after")?; + let before = text("before")?; + if (first.is_some() || after.is_some()) && (last.is_some() || before.is_some()) { + return Err(invalid()); + } + let size = if to_one { + 1 + } else { + first + .or(last) + .unwrap_or(i64::from(self.limits.query.default_page_size)) + }; + if size <= 0 || size > i64::from(self.limits.query.max_page_size) { + return Err(invalid()); + } + let page = if last.is_some() || before.is_some() { + RuntimePageRequest::last(size, before) + } else { + RuntimePageRequest::first(size, after) + }; + let object = module.object_name(c); + let mut count = false; + let mut node_set = SelectionSet::default(); + if to_one { + node_set = field.selection_set.node.clone(); + } else { + for f in self.selections( + &field.selection_set.node, + &format!("{object}Connection"), + depth + 1, + )? { + if f.name.node == "totalCount" { + count = true; + } + if f.name.node == "edges" { + for edge in + self.selections(&f.selection_set.node, &format!("{object}Edge"), depth + 1)? + { + if edge.name.node == "node" { + node_set.items.extend(edge.selection_set.node.items); + } + } + } + } + } + let protected = !to_one + && module.options.cursor_profile == RuntimeCursorProfile::AuthenticatedEncryption; + let size = usize::try_from(size).map_err(|_| invalid())?; + let cost = if relation.is_some() { + RuntimeGraphqlCost::for_relation_layer(parents, size, count, protected)? + } else { + RuntimeGraphqlCost::for_root_page(size, count, protected)? + }; + let nodes = cost.materialized_nodes; + self.cost = self.cost.checked_add(cost)?; + if self.cost.statements > self.limits.max_statements + || self.cost.materialized_nodes > self.limits.max_materialized_nodes + || self.cost.crypto_calls > self.limits.max_crypto_calls + || self.cost.relation_groups > self.limits.relation.max_compatible_groups + { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + let mut fields = Vec::new(); + let mut children = Vec::new(); + for selected in self.selections(&node_set, &object, depth + 1)? { + if selected.name.node == "__typename" { + continue; + } + if let Some(f) = c + .fields + .iter() + .find(|f| f.api_name == selected.name.node.as_str()) + { + let handle = module + .schema + .resolve_field(&collection, &f.id) + .map_err(record_error)?; + if !fields.contains(&handle) { + fields.push(handle); + } + } else if let Some(r) = c + .relations + .iter() + .find(|r| r.api_name == selected.name.node.as_str()) + { + let handle = module + .schema + .resolve_relation(&collection, &r.id) + .map_err(record_error)?; + let target = module + .schema + .schema() + .collections + .iter() + .find(|c| c.id == r.target) + .ok_or_else(invalid)?; + if nodes > self.limits.relation.max_parents { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + children.push(self.collection( + module, + target, + &selected, + Some(handle), + nodes, + depth + 1, + )?); + } else { + return Err(invalid()); + } + } + // A count-only/relationship-only request still needs an internal row anchor. + let projected = if fields.is_empty() { + vec![ + module + .schema + .resolve_field(&collection, &c.primary_key[0]) + .map_err(record_error)?, + ] + } else { + fields.clone() + }; + let projection = module + .schema + .resolve_projection(&collection, &projected) + .map_err(record_error)?; + Ok(Plan { + module: module.clone(), + collection, + response_key: field.response_key().node.to_string(), + field_name: field.name.node.to_string(), + filter_intent, + fields, + projection, + predicate, + order, + requested_order, + order_was_supplied, + page, + count, + relation, + children, + }) + } +} + +#[derive(Clone, Default)] +pub(super) struct FilterIntent { + pub operations: Vec<(RuntimeFieldHandle, &'static str)>, + pub logical: Vec<&'static str>, +} + +fn filter( + schema: &ValidatedRuntimeSchema, + collection: &RuntimeCollectionHandle, + c: &RuntimeCollection, + value: &Value, + limits: RuntimeGraphqlLimits, + depth: usize, + intent: &mut FilterIntent, +) -> Result { + if depth >= limits.query.max_predicate_depth { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + let Value::Object(entries) = value else { + return Err(invalid()); + }; + let mut predicates = Vec::new(); + for (name, value) in entries { + if value == &Value::Null { + continue; + } + if let Some(logical) = match name.as_str() { + "and" => Some("and"), + "or" => Some("or"), + "not" => Some("not"), + _ => None, + } { + if !intent.logical.contains(&logical) { + intent.logical.push(logical); + } + } + match name.as_str() { + "and" | "or" => { + let values = list_values(value); + if values.len() > limits.query.max_predicate_nodes { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + let parts = values + .into_iter() + .map(|v| filter(schema, collection, c, v, limits, depth + 1, intent)) + .collect::, _>>()?; + predicates.push( + if name == "and" { + schema.runtime_and(collection, parts, limits.query) + } else { + schema.runtime_or(collection, parts, limits.query) + } + .map_err(query_error)?, + ); + } + "not" => predicates.push( + schema + .runtime_not( + collection, + filter(schema, collection, c, value, limits, depth + 1, intent)?, + limits.query, + ) + .map_err(query_error)?, + ), + _ => { + let f = c + .fields + .iter() + .find(|f| f.api_name == name.as_str()) + .ok_or_else(invalid)?; + let handle = schema + .resolve_field(collection, &f.id) + .map_err(record_error)?; + let Value::Object(ops) = value else { + return Err(invalid()); + }; + for (op, value) in ops { + if value == &Value::Null { + continue; + } + // Capture requested capabilities before empty lists lower to constants. + let operation = match op.as_str() { + "eq" => "eq", + "ne" => "ne", + "lt" => "lt", + "lte" => "lte", + "gt" => "gt", + "gte" => "gte", + "contains" => "contains", + "startsWith" => "startsWith", + "endsWith" => "endsWith", + "in" => "in", + "notIn" => "notIn", + "isNull" if value == &Value::Boolean(false) => "isNotNull", + "isNull" => "isNull", + _ => return Err(invalid()), + }; + intent.operations.push((handle.clone(), operation)); + let predicate = match op.as_str() { + "isNull" => { + let Value::Boolean(v) = value else { + return Err(invalid()); + }; + schema.runtime_is_null(collection, &handle, *v, limits.query) + } + "in" | "notIn" => { + let values = list_values(value); + if values.len() > limits.query.max_values_per_list { + return Err(RuntimeGraphqlError::new("cost_exceeded")); + } + let values = values + .into_iter() + .map(|v| scalar::decode(f.value_kind, v, limits.scalars)) + .collect::, _>>()?; + schema.runtime_list( + collection, + &handle, + if op == "in" { + RuntimeListOperator::In + } else { + RuntimeListOperator::NotIn + }, + values, + limits.query, + ) + } + _ => { + let op = match op.as_str() { + "eq" => RuntimeScalarOperator::Eq, + "ne" => RuntimeScalarOperator::Ne, + "lt" => RuntimeScalarOperator::Lt, + "lte" => RuntimeScalarOperator::Lte, + "gt" => RuntimeScalarOperator::Gt, + "gte" => RuntimeScalarOperator::Gte, + "contains" => RuntimeScalarOperator::Contains, + "startsWith" => RuntimeScalarOperator::StartsWith, + "endsWith" => RuntimeScalarOperator::EndsWith, + _ => return Err(invalid()), + }; + schema.runtime_compare( + collection, + &handle, + op, + scalar::decode(f.value_kind, value, limits.scalars)?, + limits.query, + ) + } + } + .map_err(query_error)?; + predicates.push(predicate); + } + } + } + } + schema + .runtime_and(collection, predicates, limits.query) + .map_err(query_error) +} + +fn list_values(value: &Value) -> Vec<&Value> { + match value { + Value::List(values) => values.iter().collect(), + value => vec![value], + } +} +fn enum_text(value: &Value) -> Result<&str, RuntimeGraphqlError> { + match value { + Value::String(v) => Ok(v), + Value::Enum(v) => Ok(v.as_str()), + _ => Err(invalid()), + } +} diff --git a/crates/graphql-orm/src/graphql/runtime/scalar.rs b/crates/graphql-orm/src/graphql/runtime/scalar.rs new file mode 100644 index 00000000..d2fcf2eb --- /dev/null +++ b/crates/graphql-orm/src/graphql/runtime/scalar.rs @@ -0,0 +1,383 @@ +use super::RuntimeGraphqlError; +use crate::graphql::orm::{RuntimeDateTime, RuntimeFloat, RuntimeValue, RuntimeValueKind}; +use async_graphql::Value; +use base64::{Engine, engine::general_purpose::STANDARD}; + +/// Scalar allocation and tree bounds, applied on input and output. +#[derive(Clone, Copy, Debug)] +pub struct RuntimeScalarLimits { + pub max_text_bytes: usize, + pub max_decoded_bytes: usize, + pub max_json_depth: usize, + pub max_json_nodes: usize, +} +impl Default for RuntimeScalarLimits { + fn default() -> Self { + Self { + max_text_bytes: 64 * 1024, + max_decoded_bytes: 32 * 1024, + max_json_depth: 32, + max_json_nodes: 4096, + } + } +} +fn invalid() -> RuntimeGraphqlError { + RuntimeGraphqlError::new("invalid_graphql_input") +} +// serde_json otherwise falls back to f64 for integer tokens outside i64/u64. +// Check number ranges without allocating; serde_json still owns JSON grammar. +fn json_number_bounds(text: &str) -> Result<(), RuntimeGraphqlError> { + let bytes = text.as_bytes(); + let mut index = 0usize; + while index < bytes.len() { + if bytes[index] == b'"' { + index += 1; + while index < bytes.len() { + match bytes[index] { + b'\\' => index = index.saturating_add(2), + b'"' => { + index += 1; + break; + } + _ => index += 1, + } + } + } else if bytes[index] == b'-' || bytes[index].is_ascii_digit() { + let start = index; + index += 1; + while index < bytes.len() + && (bytes[index].is_ascii_digit() + || matches!(bytes[index], b'.' | b'e' | b'E' | b'+' | b'-')) + { + index += 1; + } + let number = &text[start..index]; + if number.contains(['.', 'e', 'E']) { + if !number.parse::().map_err(|_| invalid())?.is_finite() { + return Err(invalid()); + } + } else if number.starts_with('-') { + number.parse::().map_err(|_| invalid())?; + } else { + number.parse::().map_err(|_| invalid())?; + } + } else { + index += 1; + } + } + Ok(()) +} +fn json_bounds( + value: &serde_json::Value, + limits: RuntimeScalarLimits, +) -> Result<(), RuntimeGraphqlError> { + let mut stack = vec![(value, 0usize)]; + let mut nodes = 0usize; + while let Some((value, depth)) = stack.pop() { + nodes = nodes.checked_add(1).ok_or_else(invalid)?; + if nodes > limits.max_json_nodes || depth > limits.max_json_depth { + return Err(invalid()); + } + let children: Box + '_> = match value { + serde_json::Value::Array(values) => Box::new(values.iter()), + serde_json::Value::Object(values) => Box::new(values.values()), + _ => continue, + }; + for child in children { + if stack.len() >= limits.max_json_nodes.saturating_sub(nodes) { + return Err(invalid()); + } + stack.push((child, depth + 1)); + } + } + Ok(()) +} + +pub(super) fn decode( + kind: RuntimeValueKind, + value: &Value, + limits: RuntimeScalarLimits, +) -> Result { + if value == &Value::Null { + return Ok(RuntimeValue::Null); + } + match (kind, value) { + (RuntimeValueKind::Boolean, Value::Boolean(v)) => Ok(RuntimeValue::Boolean(*v)), + (RuntimeValueKind::Float, Value::Number(v)) => { + RuntimeFloat::new(v.as_f64().ok_or_else(invalid)?) + .map(RuntimeValue::Float) + .map_err(|_| invalid()) + } + (_, Value::String(v)) if v.len() <= limits.max_text_bytes => match kind { + RuntimeValueKind::String => Ok(RuntimeValue::String(v.clone())), + RuntimeValueKind::Integer => { + if v.len() > 20 { + return Err(invalid()); + } + let n: i64 = v.parse().map_err(|_| invalid())?; + if n.to_string() != *v { + return Err(invalid()); + } + Ok(RuntimeValue::Integer(n)) + } + RuntimeValueKind::Uuid => uuid::Uuid::parse_str(v) + .map(RuntimeValue::Uuid) + .map_err(|_| invalid()), + RuntimeValueKind::DateTime if v.len() <= 64 => RuntimeDateTime::parse(v) + .map(RuntimeValue::DateTime) + .map_err(|_| invalid()), + RuntimeValueKind::Bytes => { + let max_encoded = limits + .max_decoded_bytes + .checked_add(2) + .and_then(|n| n.checked_div(3)) + .and_then(|n| n.checked_mul(4)) + .ok_or_else(invalid)?; + if v.len() > max_encoded { + return Err(invalid()); + } + let bytes = STANDARD.decode(v).map_err(|_| invalid())?; + if bytes.len() > limits.max_decoded_bytes || STANDARD.encode(&bytes) != *v { + return Err(invalid()); + } + Ok(RuntimeValue::Bytes(bytes)) + } + RuntimeValueKind::Json => { + // serde_json's recursion guard also limits parsing before walking. + json_number_bounds(v)?; + let json = serde_json::from_str(v).map_err(|_| invalid())?; + json_bounds(&json, limits)?; + Ok(RuntimeValue::Json(json)) + } + _ => Err(invalid()), + }, + _ => Err(invalid()), + } +} + +pub(super) fn encode( + value: &RuntimeValue, + limits: RuntimeScalarLimits, +) -> Result { + let output = match value { + RuntimeValue::Null => Value::Null, + RuntimeValue::Boolean(v) => Value::Boolean(*v), + RuntimeValue::Integer(v) => Value::String(v.to_string()), + RuntimeValue::Float(v) => Value::from(v.get()), + RuntimeValue::String(v) => { + if v.len() > limits.max_text_bytes { + return Err(invalid()); + } + Value::String(v.clone()) + } + RuntimeValue::Uuid(v) => Value::String(v.to_string()), + RuntimeValue::DateTime(v) => Value::String(v.as_str().to_owned()), + RuntimeValue::Bytes(v) => { + if v.len() > limits.max_decoded_bytes { + return Err(invalid()); + } + Value::String(STANDARD.encode(v)) + } + RuntimeValue::Json(v) => { + json_bounds(v, limits)?; + // Bound serialization itself, not only the allocated result. + struct Bounded { + bytes: Vec, + limit: usize, + } + impl std::io::Write for Bounded { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if bytes.len() > self.limit.saturating_sub(self.bytes.len()) { + return Err(std::io::Error::other("scalar_limit")); + } + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut writer = Bounded { + bytes: Vec::new(), + limit: limits.max_text_bytes, + }; + serde_json::to_writer(&mut writer, v).map_err(|_| invalid())?; + Value::String(String::from_utf8(writer.bytes).map_err(|_| invalid())?) + } + }; + if let Value::String(v) = &output { + if v.len() > limits.max_text_bytes { + return Err(invalid()); + } + } + Ok(output) +} + +pub(super) const fn name(kind: RuntimeValueKind) -> &'static str { + match kind { + RuntimeValueKind::Boolean => "Boolean", + RuntimeValueKind::Integer => "RuntimeInt64", + RuntimeValueKind::Float => "Float", + RuntimeValueKind::String => "String", + RuntimeValueKind::Uuid => "RuntimeUuid", + RuntimeValueKind::Json => "RuntimeJson", + RuntimeValueKind::Bytes => "RuntimeBytes", + RuntimeValueKind::DateTime => "RuntimeDateTime", + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn lossless_wire_values_and_strict_integer_forms() { + let limits = RuntimeScalarLimits::default(); + for n in [ + i64::MIN, + i64::MAX, + 9_007_199_254_740_993, + -9_007_199_254_740_993, + 0, + ] { + let v = RuntimeValue::Integer(n); + assert_eq!( + decode( + RuntimeValueKind::Integer, + &encode(&v, limits).unwrap(), + limits + ) + .unwrap(), + v + ); + } + for s in ["+1", "01", "-0", "1.0", "1e2", "9223372036854775808"] { + assert!(decode(RuntimeValueKind::Integer, &Value::String(s.into()), limits).is_err()); + } + for v in [ + RuntimeValue::Null, + RuntimeValue::Json(serde_json::Value::Null), + RuntimeValue::Json(serde_json::json!({"π": [true, "hello", 9007199254740993u64]})), + ] { + assert_eq!( + decode(RuntimeValueKind::Json, &encode(&v, limits).unwrap(), limits).unwrap(), + v + ); + } + assert_eq!( + encode(&RuntimeValue::Json(serde_json::Value::Null), limits).unwrap(), + Value::String("null".into()) + ); + assert!(decode(RuntimeValueKind::Integer, &Value::from(1), limits).is_err()); + } + #[test] + fn bytes_datetime_and_json_bounds() { + let limits = RuntimeScalarLimits { + max_decoded_bytes: 2, + ..Default::default() + }; + assert_eq!( + decode( + RuntimeValueKind::Bytes, + &Value::String("AQI=".into()), + limits + ) + .unwrap(), + RuntimeValue::Bytes(vec![1, 2]) + ); + for s in ["AQI", "AQJ=", "AQID"] { + assert!(decode(RuntimeValueKind::Bytes, &Value::String(s.into()), limits).is_err()); + } + let d = decode( + RuntimeValueKind::DateTime, + &Value::String("2026-10-09T01:00:00.123456+01:00".into()), + limits, + ) + .unwrap(); + assert_eq!( + encode(&d, limits).unwrap(), + Value::String("2026-10-09T00:00:00.123456Z".into()) + ); + let limits = RuntimeScalarLimits { + max_json_depth: 1, + ..limits + }; + assert!( + decode( + RuntimeValueKind::Json, + &Value::String("[[[null]]]".into()), + limits + ) + .is_err() + ); + } + #[test] + fn json_integer_overflow_is_not_silently_reinterpreted_as_float() { + let limits = RuntimeScalarLimits::default(); + for text in ["18446744073709551616", "-9223372036854775809", "1e999"] { + assert!(decode(RuntimeValueKind::Json, &Value::String(text.into()), limits).is_err()); + } + for text in [ + "18446744073709551615", + "-9223372036854775808", + "1e200", + r#""18446744073709551616""#, + ] { + let value = + decode(RuntimeValueKind::Json, &Value::String(text.into()), limits).unwrap(); + assert_eq!( + decode( + RuntimeValueKind::Json, + &encode(&value, limits).unwrap(), + limits + ) + .unwrap(), + value + ); + } + } + #[test] + fn uuid_datetime_carry_and_json_text_are_canonical() { + let limits = RuntimeScalarLimits::default(); + let value = decode( + RuntimeValueKind::Uuid, + &Value::String("67E55044-10B1-426F-9247-BB680E5FE0C8".into()), + limits, + ) + .unwrap(); + assert_eq!( + encode(&value, limits).unwrap(), + Value::String("67e55044-10b1-426f-9247-bb680e5fe0c8".into()) + ); + let value = decode( + RuntimeValueKind::DateTime, + &Value::String("2026-10-09T00:00:59.9999996Z".into()), + limits, + ) + .unwrap(); + assert_eq!( + encode(&value, limits).unwrap(), + Value::String("2026-10-09T00:01:00.000000Z".into()) + ); + let value = RuntimeValue::Json(serde_json::Value::String("hello".into())); + assert_eq!( + decode( + RuntimeValueKind::Json, + &encode(&value, limits).unwrap(), + limits + ) + .unwrap(), + value + ); + assert!(decode(RuntimeValueKind::Json, &Value::List(vec![]), limits).is_err()); + assert!( + encode( + &RuntimeValue::Json(serde_json::json!({"long":"text"})), + RuntimeScalarLimits { + max_text_bytes: 4, + ..limits + } + ) + .is_err() + ); + } +} diff --git a/crates/graphql-orm/src/graphql/runtime/schema.rs b/crates/graphql-orm/src/graphql/runtime/schema.rs new file mode 100644 index 00000000..f28b9c26 --- /dev/null +++ b/crates/graphql-orm/src/graphql/runtime/schema.rs @@ -0,0 +1,239 @@ +use super::*; +use crate::graphql::orm::*; +use async_graphql::{Value, dynamic::*}; + +// Only the authorized execution path can construct a generated runtime object. +// Host-owned dynamic types retain their own resolver payloads. +struct AuthorizedOutput(Value); + +pub(super) fn field_value(value: Value) -> FieldValue<'static> { + match value { + Value::Object(_) => FieldValue::owned_any(AuthorizedOutput(value)), + Value::List(values) => FieldValue::list(values.into_iter().map(field_value)), + value => FieldValue::value(value), + } +} +// Colon cannot occur in a GraphQL name. Argument-dependent relation results +// therefore cannot collide with schema-named scalar/helper values. +pub(super) fn relation_output_key(name: &str, response_key: &str) -> String { + format!("{name}:{response_key}") +} +fn output(name: &str, ty: TypeRef) -> Field { + Field::new(name, ty, |ctx| { + FieldFuture::new(async move { + let value = &ctx + .parent_value + .downcast_ref::() + .ok_or_else(|| async_graphql::Error::new("projection_mismatch"))? + .0; + let selection = ctx.field(); + let key = selection.alias().unwrap_or(selection.name()); + let Value::Object(map) = value else { + return Err(async_graphql::Error::new("invalid_composition")); + }; + let value = map + .get(relation_output_key(selection.name(), key).as_str()) + .or_else(|| map.get(selection.name())) + .ok_or_else(|| async_graphql::Error::new("projection_mismatch"))?; + if value == &Value::Null { + return Ok(None); + } + Ok(Some(field_value(value.clone()))) + }) + }) +} +pub(super) fn page_args(field: Field, object: &str) -> Field { + field + .argument(InputValue::new( + "where", + TypeRef::named(format!("{object}WhereInput")), + )) + .argument(InputValue::new( + "orderBy", + TypeRef::named_nn_list(format!("{object}OrderInput")), + )) + .argument(InputValue::new("first", TypeRef::named("Int"))) + .argument(InputValue::new("after", TypeRef::named("String"))) + .argument(InputValue::new("last", TypeRef::named("Int"))) + .argument(InputValue::new("before", TypeRef::named("String"))) +} +pub(super) fn root_field(name: &str, object: &str) -> Field { + page_args( + Field::new( + name, + TypeRef::named_nn(format!("{object}Connection")), + |ctx| { + FieldFuture::new(async move { + let results = ctx.data::()?; + let selection = ctx.field(); + let key = selection.alias().unwrap_or(selection.name()); + let value = results + .0 + .get(key) + .ok_or_else(|| async_graphql::Error::new("authorization_missing"))?; + Ok(Some(field_value(value.clone()))) + }) + }, + ), + object, + ) +} +pub(super) fn helpers(limits: RuntimeScalarLimits) -> Vec { + use RuntimeValueKind::*; + let mut result = Vec::new(); + for kind in [Integer, Uuid, Json, Bytes, DateTime] { + result.push( + Scalar::new(scalar::name(kind)) + .validator(move |value| scalar::decode(kind, value, limits).is_ok()) + .into(), + ); + } + result.push( + Enum::new("RuntimeOrderDirection") + .item(EnumItem::new("ASC")) + .item(EnumItem::new("DESC")) + .into(), + ); + result.push( + Enum::new("RuntimeNullPlacement") + .item(EnumItem::new("FIRST")) + .item(EnumItem::new("LAST")) + .into(), + ); + result.push( + Object::new("RuntimePageInfo") + .field(output("hasNextPage", TypeRef::named_nn("Boolean"))) + .field(output("hasPreviousPage", TypeRef::named_nn("Boolean"))) + .field(output("startCursor", TypeRef::named("String"))) + .field(output("endCursor", TypeRef::named("String"))) + .into(), + ); + for kind in [Boolean, Integer, Float, String, Uuid, Json, Bytes, DateTime] { + let mut filter = InputObject::new(filter_name(kind)) + .field(InputValue::new("isNull", TypeRef::named("Boolean"))); + if kind != Json { + for op in ["eq", "ne"] { + filter = filter.field(InputValue::new(op, TypeRef::named(scalar::name(kind)))); + } + for op in ["in", "notIn"] { + filter = filter.field(InputValue::new( + op, + TypeRef::named_nn_list(scalar::name(kind)), + )); + } + } + if matches!(kind, Integer | Float | String | DateTime) { + for op in ["lt", "lte", "gt", "gte"] { + filter = filter.field(InputValue::new(op, TypeRef::named(scalar::name(kind)))); + } + } + if kind == String { + for op in ["contains", "startsWith", "endsWith"] { + filter = filter.field(InputValue::new(op, TypeRef::named("String"))); + } + } + result.push(filter.into()); + } + result +} +pub(super) fn filter_name(kind: RuntimeValueKind) -> String { + let name = match kind { + RuntimeValueKind::Boolean => "RuntimeBoolean", + RuntimeValueKind::Float => "RuntimeFloat", + RuntimeValueKind::String => "RuntimeString", + _ => scalar::name(kind), + }; + format!("{name}Filter") +} +pub(super) fn types(module: &RuntimeGraphqlModule) -> Vec { + let mut result = Vec::new(); + for c in &module.schema.schema().collections { + let name = module.object_name(c); + let mut object = Object::new(&name); + let mut filter = InputObject::new(format!("{name}WhereInput")) + .field(InputValue::new( + "and", + TypeRef::named_nn_list(format!("{name}WhereInput")), + )) + .field(InputValue::new( + "or", + TypeRef::named_nn_list(format!("{name}WhereInput")), + )) + .field(InputValue::new( + "not", + TypeRef::named(format!("{name}WhereInput")), + )); + let mut order_enum = Enum::new(format!("{name}OrderField")); + for f in &c.fields { + let ty = if f.nullable { + TypeRef::named(scalar::name(f.value_kind)) + } else { + TypeRef::named_nn(scalar::name(f.value_kind)) + }; + object = object.field(output(&f.api_name, ty)); + if f.filterable { + filter = filter.field(InputValue::new( + &f.api_name, + TypeRef::named(filter_name(f.value_kind)), + )); + } + if f.sortable { + order_enum = order_enum.item(EnumItem::new(&f.api_name)); + } + } + for relation in &c.relations { + if let Some(target) = module + .schema + .schema() + .collections + .iter() + .find(|t| t.id == relation.target) + { + let target = module.object_name(target); + let field = match relation.cardinality { + RelationCardinality::One => output(&relation.api_name, TypeRef::named(&target)), + RelationCardinality::Many => page_args( + output( + &relation.api_name, + TypeRef::named_nn(format!("{target}Connection")), + ), + &target, + ), + }; + object = object.field(field); + } + } + result.extend([ + object.into(), + filter.into(), + order_enum.into(), + InputObject::new(format!("{name}OrderInput")) + .field(InputValue::new( + "field", + TypeRef::named_nn(format!("{name}OrderField")), + )) + .field(InputValue::new( + "direction", + TypeRef::named("RuntimeOrderDirection"), + )) + .field(InputValue::new( + "nulls", + TypeRef::named("RuntimeNullPlacement"), + )) + .into(), + Object::new(format!("{name}Edge")) + .field(output("cursor", TypeRef::named_nn("String"))) + .field(output("node", TypeRef::named_nn(&name))) + .into(), + Object::new(format!("{name}Connection")) + .field(output( + "edges", + TypeRef::named_nn_list_nn(format!("{name}Edge")), + )) + .field(output("pageInfo", TypeRef::named_nn("RuntimePageInfo"))) + .field(output("totalCount", TypeRef::named_nn("RuntimeInt64"))) + .into(), + ]); + } + result +} diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index 5e05234c..1bde33bb 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.41.0" +version = "0.42.0" dependencies = [ "agql-auth", "async-graphql", @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.41.0" +version = "0.42.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 3a2d0b81..94be809b 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.41.0" +version = "0.42.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -2531,7 +2531,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.41.0" +version = "0.42.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 cf4a5e33..ab08faea 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.41.0" +version = "0.42.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.41.0" +version = "0.42.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 bb534097..9d578eb4 100644 --- a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock @@ -1004,9 +1004,11 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.41.0" +version = "0.42.0" dependencies = [ "async-graphql", + "async-trait", + "base64 0.22.1", "chacha20poly1305", "chrono", "futures", @@ -1030,7 +1032,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.41.0" +version = "0.42.0" dependencies = [ "convert_case", "proc-macro2", @@ -2014,6 +2016,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" name = "repository-aggregate-consumer" version = "0.0.0" dependencies = [ + "chacha20poly1305", "graphql-orm", "serde", "tokio", diff --git a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml index 0ca1902d..c7208424 100644 --- a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml @@ -9,6 +9,7 @@ resolver = "3" [features] default = ["sqlite"] +runtime-graphql = ["graphql-orm/runtime-graphql"] sqlite = ["graphql-orm/sqlite"] postgres = ["graphql-orm/postgres"] mssql = ["graphql-orm/mssql"] @@ -18,4 +19,5 @@ graphql-orm = { path = "../../..", default-features = false } serde = { version = "1", features = ["derive"] } [dev-dependencies] +chacha20poly1305 = "0.10" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/runtime_graphql_reads.rs b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/runtime_graphql_reads.rs new file mode 100644 index 00000000..85e260b0 --- /dev/null +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/runtime_graphql_reads.rs @@ -0,0 +1,7 @@ +//! Host composition without a direct async-graphql dependency. +#[path = "../../../../examples/runtime_graphql_reads.rs"] +mod host_example; +#[cfg(all(feature = "runtime-graphql", any(feature = "sqlite", feature = "postgres")))] +fn main() -> Result<(), Box> { host_example::main() } +#[cfg(not(all(feature = "runtime-graphql", any(feature = "sqlite", feature = "postgres"))))] +fn main() { host_example::main(); } diff --git a/crates/graphql-orm/tests/runtime_graphql.rs b/crates/graphql-orm/tests/runtime_graphql.rs new file mode 100644 index 00000000..9a1ddf11 --- /dev/null +++ b/crates/graphql-orm/tests/runtime_graphql.rs @@ -0,0 +1,1088 @@ +#![cfg(all( + feature = "runtime-graphql", + any(feature = "sqlite", feature = "postgres") +))] + +use chacha20poly1305::{ + ChaCha20Poly1305, KeyInit, + aead::{Aead, Payload}, +}; +use futures::future::BoxFuture; +use graphql_orm::async_graphql::{ + self, Request, Variables, + dynamic::{Field, FieldFuture, FieldValue, Object, TypeRef}, +}; +use graphql_orm::{ + db::Database, + graphql::{orm::*, runtime::*}, +}; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; +#[cfg(feature = "postgres")] +#[path = "support/owned_postgres.rs"] +mod owned_postgres; +#[cfg(feature = "postgres")] +type Backend = PostgresBackend; +#[cfg(all(feature = "sqlite", not(feature = "postgres")))] +type Backend = SqliteBackend; + +static QUERY_COUNTS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +struct DeferredDirective; +#[async_trait::async_trait] +impl async_graphql::CustomDirective for DeferredDirective {} +#[async_graphql::Directive(location = "Field")] +fn deferred_directive() -> impl async_graphql::CustomDirective { + panic!("unsupported factory must never be invoked"); + #[allow(unreachable_code)] + DeferredDirective +} + +struct ReplaceSelection; +impl async_graphql::extensions::ExtensionFactory for ReplaceSelection { + fn create(&self) -> Arc { + Arc::new(Self) + } +} +#[async_trait::async_trait] +impl async_graphql::extensions::Extension for ReplaceSelection { + async fn parse_query( + &self, + context: &async_graphql::extensions::ExtensionContext<'_>, + query: &str, + variables: &Variables, + next: async_graphql::extensions::NextParseQuery<'_>, + ) -> async_graphql::ServerResult { + let _ = next.run(context, query, variables).await?; + Ok(async_graphql::parser::parse_query("{parents{edges{node{tenant}}}}").unwrap()) + } +} + +struct TestAead; +impl RuntimeCursorProtector for TestAead { + fn seal<'a>( + &'a self, + context: &'a RuntimeCursorContext, + plaintext: &'a str, + limits: RuntimeCursorProtectionLimits, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let cipher = ChaCha20Poly1305::new(&[23; 32].into()); + let nonce = graphql_orm::uuid::Uuid::new_v4(); + let nonce = &nonce.as_bytes()[..12]; + let mut bytes = nonce.to_vec(); + bytes.extend( + cipher + .encrypt( + nonce.into(), + Payload { + msg: plaintext.as_bytes(), + aad: context.associated_data(), + }, + ) + .map_err(|_| RuntimeCursorProtectionError::InvalidCursor)?, + ); + RuntimeSealedCursor::new("test-key", bytes, limits) + }) + } + fn open<'a>( + &'a self, + context: &'a RuntimeCursorContext, + key: &'a str, + bytes: &'a [u8], + _: RuntimeCursorProtectionLimits, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + if key != "test-key" { + return Err(RuntimeCursorProtectionError::CursorUnavailable); + } + if bytes.len() < 28 { + return Err(RuntimeCursorProtectionError::InvalidCursor); + } + let cipher = ChaCha20Poly1305::new(&[23; 32].into()); + let value = cipher + .decrypt( + (&bytes[..12]).into(), + Payload { + msg: &bytes[12..], + aad: context.associated_data(), + }, + ) + .map_err(|_| RuntimeCursorProtectionError::InvalidCursor)?; + String::from_utf8(value).map_err(|_| RuntimeCursorProtectionError::InvalidCursor) + }) + } +} +fn cid(s: &str) -> CollectionId { + CollectionId::new(s).unwrap() +} +fn fid(s: &str) -> FieldId { + FieldId::new(s).unwrap() +} +fn field(id: &str, name: &str, kind: RuntimeValueKind, nullable: bool) -> RuntimeField { + RuntimeField { + id: fid(id), + api_name: name.into(), + physical_column: name.to_lowercase(), + value_kind: kind, + nullable, + unique: name == "id", + filterable: true, + sortable: kind != RuntimeValueKind::Json, + generated: false, + default: None, + } +} +fn schema() -> Arc { + let mut parent = RuntimeCollection { + id: cid("parent"), + api_type_name: "Parent".into(), + api_plural_name: "Parents".into(), + physical_table: "gql_parents".into(), + primary_key: vec![fid("parent.id")], + append_only: false, + retention_purge: false, + fields: vec![ + field("parent.id", "id", RuntimeValueKind::Integer, false), + field("parent.tenant", "tenant", RuntimeValueKind::String, false), + field("parent.label", "label", RuntimeValueKind::String, false), + field("parent.caption", "caption", RuntimeValueKind::String, false), + field("parent.required", "required", RuntimeValueKind::Json, false), + field("parent.document", "document", RuntimeValueKind::Json, true), + ], + relations: vec![], + indexes: vec![], + composite_unique: vec![], + default_order: vec![], + }; + let mut child = RuntimeCollection { + id: cid("child"), + api_type_name: "Child".into(), + api_plural_name: "Children".into(), + physical_table: "gql_children".into(), + primary_key: vec![fid("child.id")], + append_only: false, + retention_purge: false, + fields: vec![ + field("child.id", "id", RuntimeValueKind::Integer, false), + field("child.tenant", "tenant", RuntimeValueKind::String, false), + field("child.owner", "owner", RuntimeValueKind::Integer, false), + field("child.label", "label", RuntimeValueKind::String, false), + ], + relations: vec![], + indexes: vec![], + composite_unique: vec![], + default_order: vec![], + }; + child.relations.push(RuntimeRelation { + id: RelationId::new("parent").unwrap(), + api_name: "parent".into(), + target: parent.id.clone(), + key_pairs: vec![ + RelationKeyPair { + source: fid("child.owner"), + target: fid("parent.id"), + }, + RelationKeyPair { + source: fid("child.tenant"), + target: fid("parent.tenant"), + }, + ], + cardinality: RelationCardinality::One, + enforce_foreign_key: false, + on_delete: None, + }); + parent.relations.push(RuntimeRelation { + id: RelationId::new("children").unwrap(), + api_name: "children".into(), + target: child.id.clone(), + key_pairs: vec![ + RelationKeyPair { + source: fid("parent.id"), + target: fid("child.owner"), + }, + RelationKeyPair { + source: fid("parent.tenant"), + target: fid("child.tenant"), + }, + ], + cardinality: RelationCardinality::Many, + enforce_foreign_key: false, + on_delete: None, + }); + Arc::new( + RuntimeSchema { + format_version: 1, + collections: vec![parent, child], + } + .validate() + .unwrap(), + ) +} +struct Authority { + deny_child: AtomicBool, + deny_count: AtomicBool, + calls: AtomicUsize, + tenant: &'static str, +} +impl RuntimeReadAuthority for Authority { + fn authorize<'a>( + &'a self, + check: RuntimeReadCheck<'a>, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.calls.fetch_add(1, Ordering::SeqCst); + if (self.deny_child.load(Ordering::SeqCst) && check.relation.is_some()) + || (self.deny_count.load(Ordering::SeqCst) && check.include_count) + { + return Err(RuntimeGraphqlError::denied()); + } + // Internal keys may be used for deterministic order; never project tenant. + if check + .projection + .iter() + .any(|f| f.id().as_str().ends_with("tenant")) + { + return Err(RuntimeGraphqlError::denied()); + } + if check + .filter_operations + .iter() + .any(|(field, op)| field.id().as_str().ends_with("tenant") || *op == "contains") + || check + .order + .terms() + .iter() + .any(|term| term.field().id().as_str().ends_with("tenant")) + { + return Err(RuntimeGraphqlError::denied()); + } + if check.requested_order.iter().any(|term| { + term.field.id().as_str().ends_with(".id") && !check.projection.contains(&term.field) + }) { + return Err(RuntimeGraphqlError::denied()); + } + let tenant = check + .schema + .resolve_field( + check.collection, + &fid(&format!("{}.tenant", check.collection.id().as_str())), + ) + .unwrap(); + let predicate = check + .schema + .runtime_compare( + check.collection, + &tenant, + RuntimeScalarOperator::Eq, + RuntimeValue::String(self.tenant.into()), + RuntimeQueryLimits::default(), + ) + .unwrap(); + Ok(RuntimeReadGrant::new(Some(predicate))) + }) + } +} +struct IntentAuthority { + delegate: Arc, + deny: bool, +} +impl RuntimeReadAuthority for IntentAuthority { + fn authorize<'a>( + &'a self, + check: RuntimeReadCheck<'a>, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + assert_eq!( + check + .filter_operations + .iter() + .map(|(field, op)| (field.id().as_str(), *op)) + .collect::>(), + vec![("parent.label", "in"), ("parent.caption", "notIn")] + ); + assert_eq!(check.logical_operators, vec!["and", "not", "or"]); + if self.deny { + return Err(RuntimeGraphqlError::denied()); + } + self.delegate.authorize(check).await + }) + } +} +fn request( + schema: &ValidatedRuntimeSchema, + authority: Arc, + query: &str, + variables: serde_json::Value, +) -> Request { + Request::new(query) + .variables(Variables::from_json(variables)) + .data( + RuntimeGraphqlRequest::::new( + schema.fingerprint(), + authority, + None, + RuntimeGraphqlLimits::default(), + ) + .with_cursor_scope("trusted-north"), + ) +} +#[tokio::test] +async fn authorized_reads_counts_batched_relations_and_confidential_resume() { + let _query_counts = QUERY_COUNTS.lock().await; + #[cfg(feature = "postgres")] + let mut owned = owned_postgres::OwnedPostgres::start("runtime-graphql-c").unwrap(); + #[cfg(feature = "postgres")] + let database = Database::connect_postgres(&owned.url).await.unwrap(); + #[cfg(all(feature = "sqlite", not(feature = "postgres")))] + let database = Database::connect_sqlite("sqlite::memory:").await.unwrap(); + #[cfg(feature = "postgres")] + let json_type = "JSONB"; + #[cfg(not(feature = "postgres"))] + let json_type = "TEXT"; + graphql_orm::sqlx::query(&format!("CREATE TABLE gql_parents (id BIGINT PRIMARY KEY, tenant TEXT NOT NULL, label TEXT NOT NULL, caption TEXT NOT NULL DEFAULT 'caption', document {json_type}, required {json_type} NOT NULL DEFAULT 'null')")).execute(database.pool()).await.unwrap(); + graphql_orm::sqlx::query("CREATE TABLE gql_children (id BIGINT PRIMARY KEY, tenant TEXT NOT NULL, owner BIGINT NOT NULL, label TEXT NOT NULL)").execute(database.pool()).await.unwrap(); + graphql_orm::sqlx::query("INSERT INTO gql_parents(id,tenant,label,document) VALUES (1,'north','first','null'),(2,'north','second',NULL),(3,'south','private',NULL)").execute(database.pool()).await.unwrap(); + graphql_orm::sqlx::query("INSERT INTO gql_children VALUES (11,'north',1,'a'),(12,'north',1,'b'),(21,'north',2,'c'),(22,'north',2,'d'),(31,'south',1,'private'),(41,'north',99,'orphan')").execute(database.pool()).await.unwrap(); + let runtime = schema(); + let module = RuntimeGraphqlModule::compile(runtime.clone(), Default::default()).unwrap(); + let api = RuntimeGraphqlComposer::new(database.clone(), "Query", Default::default()) + .unwrap() + .cursor_protection( + Arc::new(TestAead), + RuntimeCursorAudience::new("synthetic-tests").unwrap(), + Default::default(), + ) + .unwrap() + .query_field(RuntimeHostField::new( + "health", + TypeRef::named_nn("Boolean"), + |_| FieldFuture::new(async { Ok(Some(FieldValue::value(true))) }), + )) + .unwrap() + .install(module) + .unwrap() + .finish() + .unwrap(); + let authority = Arc::new(Authority { + deny_child: AtomicBool::new(false), + deny_count: AtomicBool::new(false), + calls: AtomicUsize::new(0), + tenant: "north", + }); + // Response aliases must never be mistaken for physical/schema field identities. + let aliases = api.execute(request(&runtime, authority.clone(), + "{ parents(first:1) { edges { node { ... Swapped labelChildren:children(first:1) { edges { node { label } } } children:label label:caption caption:label } } total:totalCount pageInfo { endCursor:startCursor startCursor:endCursor previous:hasNextPage next:hasPreviousPage } } } fragment Swapped on Parent { caption:label }", + serde_json::json!({}))).await; + assert!(aliases.errors.is_empty(), "{:?}", aliases.errors); + let aliases = aliases.data.into_json().unwrap(); + let node = &aliases["parents"]["edges"][0]["node"]; + assert_eq!(node["label"], "caption"); + assert_eq!(node["caption"], "first"); + assert_eq!(node["children"], "first"); + assert_eq!(node["labelChildren"]["edges"][0]["node"]["label"], "a"); + assert_eq!(aliases["parents"]["total"], "2"); + assert_eq!(aliases["parents"]["pageInfo"]["previous"], true); + assert_eq!(aliases["parents"]["pageInfo"]["next"], false); + let overlap = api.execute(request(&runtime, authority.clone(), + "{ parents(first:1) { pageInfo:edges { cursor:node { label:children(first:1) { edges { node { parent:label label:parent { label } } } } children:label } node:cursor } edges:pageInfo { startCursor:hasNextPage endCursor:hasPreviousPage } } }", + serde_json::json!({}))).await; + assert!(overlap.errors.is_empty(), "{:?}", overlap.errors); + let overlap = overlap.data.into_json().unwrap(); + let node = &overlap["parents"]["pageInfo"][0]["cursor"]; + assert_eq!(node["children"], "first"); + assert_eq!(node["label"]["edges"][0]["node"]["parent"], "a"); + assert_eq!(node["label"]["edges"][0]["node"]["label"]["label"], "first"); + assert_eq!(overlap["parents"]["edges"]["startCursor"], true); + assert_eq!(overlap["parents"]["edges"]["endCursor"], false); + // Requested fields/operators remain visible even when SQL lowering is constant. + for filter in [ + "{tenant:{in:[]}}", + "{tenant:{notIn:[]}}", + "{not:{tenant:{in:[]}}}", + "{or:[{label:{notIn:[]}},{and:[{tenant:{notIn:[]}}]}]}", + ] { + reset_query_count(); + let denied = api + .execute(request( + &runtime, + authority.clone(), + &format!("{{parents(where:{filter}){{totalCount edges{{node{{label}}}}}}}}"), + serde_json::json!({}), + )) + .await; + assert!(!denied.errors.is_empty(), "{filter}"); + assert_eq!( + query_count(), + 0, + "denied constant filters must precede ORM I/O" + ); + } + for (filter, count) in [ + ("{label:{in:[]}}", 0), + ("{label:{notIn:[]}}", 2), + ("{not:{label:{in:[]}}}", 2), + ("{not:{label:{notIn:[]}}}", 0), + ( + "{and:[{label:{notIn:[]}},{or:[{label:{in:[]}},{caption:{notIn:[]}}]}]}", + 2, + ), + ] { + let allowed = api + .execute(request( + &runtime, + authority.clone(), + &format!("{{parents(where:{filter}){{totalCount edges{{node{{label}}}}}}}}"), + serde_json::json!({}), + )) + .await; + assert!(allowed.errors.is_empty(), "{:?}", allowed.errors); + let allowed = allowed.data.into_json().unwrap(); + assert_eq!(allowed["parents"]["totalCount"], count.to_string()); + assert_eq!(allowed["parents"]["edges"].as_array().unwrap().len(), count); + } + for deny in [true, false] { + reset_query_count(); + let audited = api.execute(request(&runtime, Arc::new(IntentAuthority { + delegate: authority.clone(), deny, + }), "{parents(where:{and:[{not:{label:{in:[]}}},{or:[{caption:{notIn:[]}}]}]}){totalCount edges{node{label}}}}", + serde_json::json!({}))).await; + if deny { + assert!(!audited.errors.is_empty()); + assert_eq!(query_count(), 0); + } else { + assert!(audited.errors.is_empty(), "{:?}", audited.errors); + assert_eq!( + audited.data.into_json().unwrap()["parents"]["totalCount"], + "2" + ); + } + } + reset_query_count(); + let query = "{ health parents(first:2) { edges { cursor node { id label document required children(first:1) { edges { cursor node { label } } totalCount pageInfo { startCursor endCursor hasNextPage } } } } totalCount pageInfo { startCursor endCursor hasNextPage } } }"; + let response = api + .execute(request( + &runtime, + authority.clone(), + query, + serde_json::json!({}), + )) + .await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json().unwrap(); + assert_eq!(data["parents"]["totalCount"], "2"); + assert_eq!(data["parents"]["edges"][0]["node"]["document"], "null"); + assert!(data["parents"]["edges"][1]["node"]["document"].is_null()); + assert_eq!(data["parents"]["edges"][0]["node"]["required"], "null"); + assert_eq!( + data["parents"]["edges"][0]["node"]["children"]["totalCount"], + "2" + ); + assert_eq!( + query_count(), + 4, + "one parent read/count and one batched child read/count" + ); + let cursor = data["parents"]["edges"][0]["cursor"].as_str().unwrap(); + assert!(cursor.starts_with("gormgqlc1.test-key.")); + assert_eq!(data["parents"]["pageInfo"]["startCursor"], cursor); + let forward = api + .execute(request( + &runtime, + authority.clone(), + "query($cursor:String!){ parents(first:1,after:$cursor){edges{node{id}}totalCount}}", + serde_json::json!({"cursor":cursor}), + )) + .await; + assert!(forward.errors.is_empty(), "{:?}", forward.errors); + assert_eq!( + forward.data.into_json().unwrap()["parents"]["edges"][0]["node"]["id"], + "2" + ); + let second_cursor = data["parents"]["edges"][1]["cursor"].as_str().unwrap(); + let backward = api + .execute(request( + &runtime, + authority.clone(), + "query($cursor:String!){parents(last:1,before:$cursor){edges{node{id}}}}", + serde_json::json!({"cursor":second_cursor}), + )) + .await; + assert!(backward.errors.is_empty(), "{:?}", backward.errors); + assert_eq!( + backward.data.into_json().unwrap()["parents"]["edges"][0]["node"]["id"], + "1" + ); + let nested = &data["parents"]["edges"][0]["node"]["children"]["edges"][0]["cursor"]; + let resumed = api.execute(request(&runtime, authority.clone(), "query($cursor:String!){parents(where:{id:{eq:\"1\"}}){edges{node{children(first:1,after:$cursor){edges{node{label}}}}}}}", serde_json::json!({"cursor":nested}))).await; + assert!(resumed.errors.is_empty(), "{:?}", resumed.errors); + assert_eq!( + resumed.data.into_json().unwrap()["parents"]["edges"][0]["node"]["children"]["edges"][0]["node"] + ["label"], + "b" + ); + reset_query_count(); + let wrong_parent = api.execute(request(&runtime, authority.clone(), "query($cursor:String!){parents(where:{id:{eq:\"2\"}}){edges{node{children(first:1,after:$cursor){edges{node{label}}}}}}}", serde_json::json!({"cursor":nested}))).await; + assert!(!wrong_parent.errors.is_empty()); + assert_eq!( + query_count(), + 1, + "parent anchor acquired but child query denied" + ); + authority.deny_child.store(true, Ordering::SeqCst); + reset_query_count(); + let denied = api + .execute(request( + &runtime, + authority.clone(), + query, + serde_json::json!({}), + )) + .await; + assert!(!denied.errors.is_empty()); + assert_eq!( + query_count(), + 0, + "whole subtree authorization before parent I/O" + ); + authority.deny_child.store(false, Ordering::SeqCst); + authority.deny_count.store(true, Ordering::SeqCst); + reset_query_count(); + let denied = api.execute(request(&runtime, authority.clone(), "{parents{... Counts edges{node{label}}}} fragment Counts on ParentConnection { totalCount }", serde_json::json!({}))).await; + assert!(!denied.errors.is_empty()); + assert_eq!(query_count(), 0); + let allowed = api + .execute(request( + &runtime, + authority.clone(), + "query($count:Boolean!){parents{totalCount @include(if:$count) edges{node{label}}}}", + serde_json::json!({"count":false}), + )) + .await; + assert!(allowed.errors.is_empty(), "{:?}", allowed.errors); + assert_eq!(query_count(), 1); + reset_query_count(); + let denied = api + .execute(request( + &runtime, + authority.clone(), + "{parents{edges{node{secret:tenant}}}}", + serde_json::json!({}), + )) + .await; + assert!(!denied.errors.is_empty()); + assert_eq!(query_count(), 0); + for query in [ + "{parents(where:{tenant:{eq:\"north\"}}){edges{node{label}}}}", + "{parents(orderBy:{field:tenant}){edges{node{label}}}}", + "{parents(orderBy:{field:id}){edges{node{label}}}}", + "{parents(where:{label:{contains:\"first\"}}){edges{node{label}}}}", + ] { + assert!( + !api.execute(request( + &runtime, + authority.clone(), + query, + serde_json::json!({}) + )) + .await + .errors + .is_empty() + ); + assert_eq!(query_count(), 0); + } + let raw = api + .execute(request( + &runtime, + authority.clone(), + "{parents(after:\"gormrq1.raw\"){edges{node{label}}}}", + serde_json::json!({}), + )) + .await; + assert!(!raw.errors.is_empty()); + assert_eq!(query_count(), 0); + let absent = api.execute("{parents{edges{node{label}}}}").await; + assert!(!absent.errors.is_empty()); + assert_eq!(query_count(), 0); + authority.deny_count.store(false, Ordering::SeqCst); + reset_query_count(); + let deep = api + .execute(request( + &runtime, + authority.clone(), + "{parents{edges{node{children(first:1){edges{node{parent{label}}}}}}}}", + serde_json::json!({}), + )) + .await; + assert!(deep.errors.is_empty(), "{:?}", deep.errors); + assert_eq!(query_count(), 3); + assert_eq!( + deep.data.into_json().unwrap()["parents"]["edges"][1]["node"]["children"]["edges"][0]["node"] + ["parent"]["label"], + "second" + ); + let missing = api + .execute(request( + &runtime, + authority.clone(), + "{children(where:{id:{eq:\"41\"}}){edges{node{label parent{label}}}}}", + serde_json::json!({}), + )) + .await; + assert!(missing.errors.is_empty(), "{:?}", missing.errors); + assert!(missing.data.into_json().unwrap()["children"]["edges"][0]["node"]["parent"].is_null()); + // Variable enum strings and GraphQL singleton-list coercion use the same typed order. + let ordered = api.execute(request(&runtime, authority.clone(), + "query($order:[ParentOrderInput!]){parents(orderBy:$order,where:{and:{not:{label:{eq:\"absent\"}}}}){edges{node{id}}}}", + serde_json::json!({"order":{"field":"id","direction":"DESC","nulls":"LAST"}}))).await; + assert!(ordered.errors.is_empty(), "{:?}", ordered.errors); + assert_eq!( + ordered.data.into_json().unwrap()["parents"]["edges"][0]["node"]["id"], + "2" + ); + // A later invalid alias cursor must be rejected before an earlier alias reads. + reset_query_count(); + let invalid_alias = api + .execute(request( + &runtime, + authority.clone(), + "query($c:String!){a:parents{edges{node{id}}} b:parents(after:$c){edges{node{id}}}}", + serde_json::json!({"c":format!("{}A", cursor)}), + )) + .await; + assert!(!invalid_alias.errors.is_empty()); + assert_eq!(query_count(), 0); + let wrong_order = api.execute(request(&runtime, authority.clone(), + "query($c:String!){parents(after:$c,orderBy:{field:id,direction:DESC}){edges{node{id}}}}", + serde_json::json!({"c":cursor}))).await; + assert!(!wrong_order.errors.is_empty()); + assert_eq!(query_count(), 0); + let scope_request = Request::new("query($c:String!){parents(after:$c){edges{node{id}}}}") + .variables(Variables::from_json(serde_json::json!({"c":cursor}))) + .data( + RuntimeGraphqlRequest::::new( + runtime.fingerprint(), + authority.clone(), + None, + Default::default(), + ) + .with_cursor_scope("revoked-partition"), + ); + assert!(!api.execute(scope_request).await.errors.is_empty()); + assert_eq!(query_count(), 0); + let mut newer = runtime.schema().clone(); + newer.collections[0].append_only = true; + let newer = Arc::new(newer.validate().unwrap()); + let newer_api = RuntimeGraphqlComposer::new(database.clone(), "Query", Default::default()) + .unwrap() + .cursor_protection( + Arc::new(TestAead), + RuntimeCursorAudience::new("synthetic-tests").unwrap(), + Default::default(), + ) + .unwrap() + .install(RuntimeGraphqlModule::compile(newer.clone(), Default::default()).unwrap()) + .unwrap() + .finish() + .unwrap(); + let stale_cursor = newer_api + .execute(request( + &newer, + authority.clone(), + "query($c:String!){parents(after:$c){edges{node{id}}}}", + serde_json::json!({"c":cursor}), + )) + .await; + assert!(!stale_cursor.errors.is_empty()); + assert_eq!(query_count(), 0); + let other_audience = RuntimeGraphqlComposer::new(database.clone(), "Query", Default::default()) + .unwrap() + .cursor_protection( + Arc::new(TestAead), + RuntimeCursorAudience::new("different-audience").unwrap(), + Default::default(), + ) + .unwrap() + .install(RuntimeGraphqlModule::compile(runtime.clone(), Default::default()).unwrap()) + .unwrap() + .finish() + .unwrap(); + let wrong_audience = other_audience + .execute(request( + &runtime, + authority.clone(), + "query($c:String!){parents(after:$c){edges{node{id}}}}", + serde_json::json!({"c":cursor}), + )) + .await; + assert!(!wrong_audience.errors.is_empty()); + assert_eq!(query_count(), 0); + let south = Arc::new(Authority { + deny_child: AtomicBool::new(false), + deny_count: AtomicBool::new(false), + calls: AtomicUsize::new(0), + tenant: "south", + }); + let other = api + .execute(request( + &runtime, + south.clone(), + "{parents{edges{node{label}}totalCount}}", + serde_json::json!({}), + )) + .await; + assert!(other.errors.is_empty()); + let other = other.data.into_json().unwrap(); + assert_eq!(other["parents"]["totalCount"], "1"); + assert_eq!(other["parents"]["edges"][0]["node"]["label"], "private"); + // Identical trusted cursor partition does not restore old predicates/authority. + let changed = api + .execute(request( + &runtime, + south, + "query($c:String!){parents(after:$c){edges{node{label}}totalCount}}", + serde_json::json!({"c":cursor}), + )) + .await; + assert!(changed.errors.is_empty()); + assert_eq!( + changed.data.into_json().unwrap()["parents"]["totalCount"], + "1" + ); + reset_query_count(); + let limited = RuntimeGraphqlLimits { + max_statements: 1, + ..Default::default() + }; + let limited = Request::new("{parents{edges{node{label}}totalCount}}").data( + RuntimeGraphqlRequest::::new( + runtime.fingerprint(), + authority.clone(), + None, + limited, + ) + .with_cursor_scope("trusted-north"), + ); + assert!(!api.execute(limited).await.errors.is_empty()); + assert_eq!(query_count(), 0); + let mut changed_schema = runtime.schema().clone(); + changed_schema.collections[0].append_only = true; + let stale = Request::new("{parents{edges{node{label}}}}").data( + RuntimeGraphqlRequest::::new( + changed_schema.validate().unwrap().fingerprint(), + authority.clone(), + None, + Default::default(), + ) + .with_cursor_scope("trusted-north"), + ); + assert!(!api.execute(stale).await.errors.is_empty()); + assert_eq!(query_count(), 0); + let invalid_value = api + .execute(request( + &runtime, + authority, + "{parents(where:{id:{eq:\"secret-invalid-value\"}}){edges{node{id}}}}", + serde_json::json!({}), + )) + .await; + assert!(!invalid_value.errors.is_empty()); + assert!(!format!("{:?}", invalid_value.errors).contains("secret-invalid-value")); + assert_eq!(query_count(), 0); + let composer = || { + RuntimeGraphqlComposer::new(database.clone(), "Query", Default::default()) + .unwrap() + .cursor_protection( + Arc::new(TestAead), + RuntimeCursorAudience::new("collisions").unwrap(), + Default::default(), + ) + .unwrap() + }; + let module = || RuntimeGraphqlModule::compile(runtime.clone(), Default::default()).unwrap(); + let colliding = composer() + .register( + Object::new("Parent") + .field(Field::new("ok", TypeRef::named("Boolean"), |_| { + FieldFuture::new(async { Ok(Some(Value::Boolean(true))) }) + })) + .into(), + ) + .unwrap() + .install(module()); + assert!( + matches!(colliding, Err(ref diagnostics) if diagnostics.0[0].code() == "name_collision") + ); + assert!( + composer() + .install(module()) + .unwrap() + .register(Object::new("Parent").into()) + .is_err() + ); + assert!( + composer() + .register(async_graphql::dynamic::Scalar::new("String").into()) + .is_err() + ); + assert!( + composer() + .register(async_graphql::dynamic::Scalar::new("RuntimeJson").into()) + .unwrap() + .install(module()) + .is_err() + ); + assert!( + composer() + .install(module()) + .unwrap() + .query_field(RuntimeHostField::new( + "parents", + TypeRef::named("Boolean"), + |_| FieldFuture::new(async { Ok(Some(Value::Boolean(true))) }) + )) + .is_err() + ); + assert!( + RuntimeGraphqlComposer::new(database.clone(), "Query", Default::default()) + .unwrap() + .install(module()) + .is_err() + ); + assert!(matches!( + composer().directive(deferred_directive), + Err(error) if error.code() == "unsupported_capability" + )); + use async_graphql::dynamic::{Subscription, SubscriptionField, SubscriptionFieldFuture}; + use futures::StreamExt; + let subscriptions = || { + Subscription::new("HostSubscription").field(SubscriptionField::new( + "pulse", + TypeRef::named_nn("Boolean"), + |_| { + SubscriptionFieldFuture::new(async { + Ok(futures::stream::iter([Ok(FieldValue::value(true))])) + }) + }, + )) + }; + assert!( + composer() + .subscription_root(Subscription::new("Query")) + .is_err() + ); + assert!( + composer() + .subscription_root(subscriptions()) + .unwrap() + .subscription_root(subscriptions()) + .is_err() + ); + let composed = composer() + .install(module()) + .unwrap() + .subscription_root(subscriptions()) + .unwrap() + .limit_directives(1) + .finish() + .unwrap(); + let response = composed + .execute_stream("subscription { pulse }") + .next() + .await + .unwrap(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!(response.data, async_graphql::value!({"pulse":true})); + reset_query_count(); + let skipped = composed + .execute("{ parents @skip(if:true) { edges { node { tenant } } } }") + .await; + assert!(skipped.errors.is_empty(), "{:?}", skipped.errors); + assert_eq!(query_count(), 0); + let limited = composed + .execute("{ parents @skip(if:true) @include(if:false) { edges { node { label } } } }") + .await; + assert!(!limited.errors.is_empty()); + assert_eq!(query_count(), 0); + let forged = composer() + .query_field(RuntimeHostField::new( + "forged", + TypeRef::named("Parent"), + |_| { + FieldFuture::new(async { + Ok(Some(FieldValue::owned_any( + async_graphql::value!({"label":"private-forged-value"}), + ))) + }) + }, + )) + .unwrap() + .install(module()) + .unwrap() + .finish() + .unwrap(); + reset_query_count(); + let forged = forged.execute("{forged{label}}").await; + assert!(!forged.errors.is_empty()); + assert!(!format!("{:?}", forged.errors).contains("private-forged-value")); + assert_eq!(query_count(), 0); + let guarded = composer() + .extension(ReplaceSelection) + .install(module()) + .unwrap() + .finish() + .unwrap(); + let guarded_authority = Arc::new(Authority { + deny_child: AtomicBool::new(false), + deny_count: AtomicBool::new(false), + calls: AtomicUsize::new(0), + tenant: "north", + }); + reset_query_count(); + let changed_selection = guarded + .execute(request( + &runtime, + guarded_authority, + "{parents{edges{node{label}}}}", + serde_json::json!({}), + )) + .await; + assert!(!changed_selection.errors.is_empty()); + assert_eq!( + query_count(), + 0, + "host parse extensions cannot bypass final selection preflight" + ); + let unprotected = RuntimeGraphqlModule::compile( + runtime.clone(), + RuntimeGraphqlOptions::default().with_cursor_profile(RuntimeCursorProfile::Unprotected), + ) + .unwrap(); + let unprotected = RuntimeGraphqlComposer::new(database, "Query", Default::default()) + .unwrap() + .install(unprotected) + .unwrap() + .finish() + .unwrap(); + let authority = Arc::new(Authority { + deny_child: AtomicBool::new(false), + deny_count: AtomicBool::new(false), + calls: AtomicUsize::new(0), + tenant: "north", + }); + let raw_response = unprotected + .execute(request( + &runtime, + authority, + "{parents(first:1){edges{cursor node{label}}}}", + serde_json::json!({}), + )) + .await; + assert!(raw_response.errors.is_empty()); + assert!( + raw_response.data.into_json().unwrap()["parents"]["edges"][0]["cursor"] + .as_str() + .unwrap() + .starts_with("gormrq1") + ); + #[cfg(feature = "postgres")] + owned.cleanup().unwrap(); +} +use async_graphql::Value; + +#[cfg(feature = "postgres")] +#[tokio::test] +async fn postgres_rls_context_is_propagated_to_read_count_and_relation_layers() { + let _query_counts = QUERY_COUNTS.lock().await; + struct Public; + impl RuntimeReadAuthority for Public { + fn authorize<'a>( + &'a self, + _: RuntimeReadCheck<'a>, + ) -> BoxFuture<'a, Result> { + Box::pin(async { Ok(RuntimeReadGrant::new(None)) }) + } + } + let mut owned = owned_postgres::OwnedPostgres::start("runtime-gql-rls").unwrap(); + let owner = Database::connect_postgres(&owned.url).await.unwrap(); + for sql in [ + "CREATE TABLE gql_parents(id BIGINT PRIMARY KEY,tenant TEXT NOT NULL,label TEXT NOT NULL,caption TEXT NOT NULL DEFAULT 'caption',document JSONB,required JSONB NOT NULL DEFAULT 'null')", + "CREATE TABLE gql_children(id BIGINT PRIMARY KEY,tenant TEXT NOT NULL,owner BIGINT NOT NULL,label TEXT NOT NULL)", + "INSERT INTO gql_parents(id,tenant,label,document) VALUES(1,'north','n',NULL),(2,'south','s',NULL)", + "INSERT INTO gql_children VALUES(11,'north',1,'n-child'),(21,'south',2,'s-child')", + "CREATE ROLE runtime_gql_reader NOLOGIN", + "GRANT SELECT ON gql_parents,gql_children TO runtime_gql_reader", + "ALTER TABLE gql_parents ENABLE ROW LEVEL SECURITY", + "ALTER TABLE gql_children ENABLE ROW LEVEL SECURITY", + "CREATE POLICY parents_tenant ON gql_parents USING(tenant=current_setting('app.tenant_id',true))", + "CREATE POLICY children_tenant ON gql_children USING(tenant=current_setting('app.tenant_id',true))", + ] { + graphql_orm::sqlx::query(sql) + .execute(owner.pool()) + .await + .unwrap(); + } + let pool = graphql_orm::sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .after_connect(|connection, _| { + Box::pin(async move { + graphql_orm::sqlx::query("SET ROLE runtime_gql_reader") + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect(&owned.url) + .await + .unwrap(); + let runtime = schema(); + let api = RuntimeGraphqlComposer::new( + Database::::new(pool.clone()), + "Query", + Default::default(), + ) + .unwrap() + .cursor_protection( + Arc::new(TestAead), + RuntimeCursorAudience::new("rls-test").unwrap(), + Default::default(), + ) + .unwrap() + .install(RuntimeGraphqlModule::compile(runtime.clone(), Default::default()).unwrap()) + .unwrap() + .finish() + .unwrap(); + for (tenant, label) in [ + (Some("north"), Some("n")), + (Some("south"), Some("s")), + (None, None), + ] { + let auth = tenant.map(|tenant| DbAuthContext { + tenant_id: Some(tenant.into()), + ..Default::default() + }); + let response = api.execute(Request::new("{parents{edges{node{label children{edges{node{label}}totalCount}}}totalCount}}") + .data(RuntimeGraphqlRequest::::new(runtime.fingerprint(), Arc::new(Public), auth, Default::default()).with_cursor_scope(tenant.unwrap_or("anonymous")))).await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json().unwrap(); + if let Some(label) = label { + assert_eq!(data["parents"]["totalCount"], "1"); + assert_eq!(data["parents"]["edges"][0]["node"]["label"], label); + assert_eq!( + data["parents"]["edges"][0]["node"]["children"]["totalCount"], + "1" + ); + assert_eq!( + data["parents"]["edges"][0]["node"]["children"]["edges"][0]["node"]["label"], + format!("{label}-child") + ); + } else { + assert_eq!(data["parents"]["totalCount"], "0"); + } + } + pool.close().await; + owner.pool().close().await; + owned.cleanup().unwrap(); +} diff --git a/crates/graphql-orm/tests/runtime_graphql_unsupported.rs b/crates/graphql-orm/tests/runtime_graphql_unsupported.rs new file mode 100644 index 00000000..c5f9376e --- /dev/null +++ b/crates/graphql-orm/tests/runtime_graphql_unsupported.rs @@ -0,0 +1,12 @@ +#![cfg(all(feature = "runtime-graphql", feature = "mssql"))] +use graphql_orm::{ + db::{Database, mssql::MssqlPool}, + graphql::{orm::MssqlBackend, runtime::RuntimeGraphqlComposer}, +}; +#[test] +fn mssql_runtime_graphql_rejects_before_pool_acquisition() { + let database = + Database::::new(MssqlPool::new(graphql_orm::tiberius::Config::new())); + let result = RuntimeGraphqlComposer::new(database, "Query", Default::default()); + assert!(matches!(result, Err(error) if error.code() == "unsupported_backend")); +} diff --git a/docs/plans/active/runtime-platform-capabilities/README.md b/docs/plans/active/runtime-platform-capabilities/README.md index c05d92cb..0f7d8a85 100644 --- a/docs/plans/active/runtime-platform-capabilities/README.md +++ b/docs/plans/active/runtime-platform-capabilities/README.md @@ -18,9 +18,12 @@ The revised contract at `c53a5de966ca5089a5e4aa2d727e144bae83e6bd` is approved f implementation and merged by PR #86 at `9ed46db5d1c5c6214f83ef9084352030e14905ad`. PR A implements the owned migration slice; its current public mechanics are in [owned runtime migrations](../../../reference/graphql-orm/runtime-migrations.md). -B–D signatures, examples, SDL and feature additions below remain approved -proposals until their separate implementation/test PRs. A proposal is not -execution or downstream adoption evidence. +B is accepted at `04a1216aff86141ff6500b1dd0bf9adb4b0c6447` as C's committed +predecessor. C is being implemented separately behind `runtime-graphql`; its +[current mechanics and approved custom-directive limitation](../../../reference/graphql-orm/runtime-graphql.md) +are documented independently. D remains a proposal gated on reviewed C/B. +Final reconciliation and owner merge/release remain #112 → A → B → C. A proposal +or unpublished implementation is not downstream adoption evidence. The initiating [consumer contract](https://github.com/Dastari/digibase/blob/agent/vertical-slice-plan/docs/coordination/prompts/0013-graphql-orm-runnable-product-prerequisites.md) requires separate reviewable PRs. The host owns catalog persistence, activation, @@ -598,14 +601,23 @@ including builtins, helper/filter/input/connection names, roots, and explicit reserved namespaces. Share ORM helper scalars only by an exact descriptor; arbitrary same-name host types are not treated as equivalent. -Expose checked forwarding methods for host-owned data, extensions, custom -directives, introspection policy, depth/complexity settings and optional +Expose checked forwarding methods for host-owned data, extensions, +introspection policy, depth/complexity settings and optional subscription roots; these do not add ORM subscriptions or transport. Keep host configuration choices while reserving the internal operation preflight and request budget enforcement. Do not require ORM to own the host's entire server configuration or add an opaque builder callback that bypasses collision checks. Generic register support covers host interfaces/unions/scalars too. +Approved implementation adjustment: async-graphql 7.2.1 dynamic SchemaBuilder +cannot register custom-directive factories. C exposes `directive(factory)` only +to reject registration with structured `unsupported_capability` during +composition, before schema publication; it must never silently ignore a factory. +Custom directives are deferred without a fork, framework upgrade or emulation. +Built-in directives and their limits, host extensions, checked subscription +composition, collision checks and mandatory authorization/resource guards remain +required and tested. + Proposed SDL shape for a Customer collection (abbreviated): ```graphql diff --git a/docs/reference/graphql-orm/README.md b/docs/reference/graphql-orm/README.md index ce2f05b2..c8c7a7b9 100644 --- a/docs/reference/graphql-orm/README.md +++ b/docs/reference/graphql-orm/README.md @@ -64,3 +64,5 @@ guidance for the core ORM package. - [Runtime relations](runtime-relations.md) - [Cross-backend tenant module](cross-backend-tenant.md) - [Backup runtime boundary](backup-runtime.md) + +- [Runtime GraphQL reads and checked composition](runtime-graphql.md) diff --git a/docs/reference/graphql-orm/runtime-graphql.md b/docs/reference/graphql-orm/runtime-graphql.md new file mode 100644 index 00000000..3d3edf12 --- /dev/null +++ b/docs/reference/graphql-orm/runtime-graphql.md @@ -0,0 +1,216 @@ +--- +title: Runtime GraphQL reads and checked composition +kind: reference +status: active +owner: graphql-orm-maintainers +last_reviewed: 2026-10-09 +review_by: 2027-02-01 +supersedes: [] +--- + +# Runtime GraphQL reads and checked composition + +The additive `runtime-graphql` feature registers read-only dynamic GraphQL over +an `Arc`. It is off by default. Static derives, SDL, +framework-neutral cursors and repository consumers are unchanged. No data +migration is required. This is C, based on the reviewed B revision; mutation +registration D is not implemented here. + +`RuntimeGraphqlModule::compile` checks public names and deterministic ordering. +`RuntimeGraphqlComposer` checks every root and type name before publication. +Submit host dynamic objects, interfaces, unions, enums, inputs and scalars through +`register`, and root fields through `RuntimeHostField::new`. The composer creates +fresh roots; it cannot safely inventory or extend an arbitrary existing dynamic +SchemaBuilder. Static schemas can coexist in the host; this API does not merge +opaque static Schema objects. Host data, extensions, introspection suppression, +depth/complexity limits and host-owned subscription roots are supported. There +are no ORM subscriptions or transport services. Host-owned resolvers remain +responsible for their own I/O and authorization. Generated runtime object payloads +are private to the authorized executor: arbitrary host/preloaded Values cannot be +used to bypass runtime authorization. Use separately registered host types for +host-owned payloads. + +Names use ASCII camelCase fields/roots and PascalCase types, without underscores. +The default list root lowercases only the first ASCII letter of the collection's +plural API name. Options permit a type prefix and root overrides keyed by stable +collection IDs. Names are never silently rewritten otherwise. Filterable fields +named `and`, `or` or `not` collide with recursive filter operators and are rejected. +Helper types are shared only within this composer; same-name host types collide. + +The locked async-graphql 7.2.1 dynamic builder has no custom-directive factory +registration API. The approved C contract therefore explicitly defers custom +directives: `composer.directive(factory)` returns structured +`unsupported_capability` during composition, before schema publication. It never +ignores registration or invokes the factory. No dependency fork or emulation is +used. Built-in `@skip` and `@include` remain supported, including variable inputs; +`limit_directives` bounds directives per field and cannot relax the installed +selection budget. Host extensions and checked subscription roots remain supported, +with collision checks and mandatory authorization/resource guards intact. + +## Host integration + +The compiled, executable [host example](../../../crates/graphql-orm/examples/runtime_graphql_reads.rs) +uses typed repository setup, checked host composition, an explicit public read +authority and a real host-owned ChaCha20-Poly1305 provider. Its key is ephemeral; +restarting that example intentionally invalidates its cursors. Production hosts +own durable key management, rotation, nonce uniqueness, expiry and revocation. + +```rust,ignore +let module = RuntimeGraphqlModule::compile(schema.clone(), Default::default())?; +let api = RuntimeGraphqlComposer::new(database, "Query", limits)? + .cursor_protection(host_aead, trusted_audience, protection_limits)? + .query_field(RuntimeHostField::new("health", TypeRef::named_nn("Boolean"), + |_| FieldFuture::new(async { Ok(Some(FieldValue::value(true))) })))? + .install(module)? + .finish()?; +let request = Request::new("{ notes(first:20) { edges { node { label } } totalCount } }") + .data(RuntimeGraphqlRequest::::new( + schema.fingerprint(), authority, db_auth, request_budget, + ).with_cursor_scope(trusted_partition)); +let response = api.execute(request).await; +``` + +Install protection before a protected module. The default profile requires it. +Options are non-exhaustive so future D configuration can remain source-compatible; +construct them with `Default` and explicit setters/field updates. +`RuntimeGraphqlOptions::default().with_cursor_profile(RuntimeCursorProfile::Unprotected)` is an explicit alternative for hosts accepting +readable existing cursors; it must not be used for confidential hidden keys. + +## Authorization, pagination and batching + +Each request must provide `RuntimeReadAuthority`. Its borrowed check contains the +schema, collection, selected public fields, typed filter, complete effective order +(including default key tie-breakers), relation traversal and count intent. The host +must grant all those capabilities, even with PostgreSQL RLS. `RuntimeReadGrant::new` +returns a validated structural row predicate, or an explicit unrestricted grant. +No provider, denial, invalid grant or stale schema fingerprint fails closed. + +A mandatory per-execution extension expands aliases/fragments and evaluated +`@skip`/`@include`, resolves variables/defaults and obtains every selected runtime +layer's grant before the first ORM statement. Row predicates are ANDed with client +filters before reads, pagination and counts. Hidden key acquisition is internal to +the granted operation and does not expose those keys as loaded output fields. +Filter/order authorization remains independent of projection authorization. +The check distinguishes client-requested order terms (including explicit empty +ordering) from the effective order's internal keys. It also exposes scalar/list +and logical predicate operators; granting `eq` does not hide an enclosing `not` +from the host's policy check. + +Filters are recursive `and`/`or`/`not` inputs with supported per-kind operators. +SQL NULL uses `isNull`; omitted/null optional filter operands add no condition. +JSON exposes only SQL-null testing, not structural JSON equality. Ordering uses +closed field enums, ASC/DESC and FIRST/LAST null placement. Omitted ordering uses +the existing deterministic runtime default. Page sizes and cursor shapes are +validated, not clamped. To-one targets missing or removed by the target predicate +return null. Collection/field/traversal denial during preflight rejects the +operation before parent reads; it is not treated as a missing target. + +Counts execute only when selected, including through aliases/fragments. Each +compatible relation layer calls the existing bounded relation batch executor; +two parents do not mean two child queries. Different selection shapes consume +separate bounded layers. There is no process-wide cache, detached task or authority +identity inferred from a client principal string. Reusing request data does not +reuse previous execution state or grants. Optional DbAuthContext propagates to +every layer and its count. + +Read/count pairs retain the backend executor's isolation semantics. This does not +provide a frozen snapshot across pages, aliases or nested layers: concurrent +writes can move records between pages or alter counts. Current authorization is +reapplied on every resume. Hosts configure database statement timeouts; a bounded +number of count statements is not a bound on scanned database rows. + +Response aliases are distinct from schema field identities, including scalar/relation +alias overlaps and connection helpers. Response-size accounting follows the same +identities as resolution. Field/operator authorization uses validated requested +capabilities captured before constant lowering: empty `in: []` is false and empty +`notIn: []` is true, but neither bypasses filter-field or operator authorization. +Explicit nested `and`, `or` and `not` capabilities remain visible to the host. +Use `RuntimeReadCheck::filter_operations` for requested filter authorization; +the lowered predicate's referenced fields alone cannot represent constant inputs. + +## Confidential cursors + +Protected output is `gormgqlc1..`. The entire +internal gormrq1/gormrr1 envelope must be authenticated and encrypted by the host; +signing readable contents is insufficient. The ORM owns framing, validates bounds, +and never falls back to plaintext. Every edge and matching page-info cursor uses +the same protected token. Missing page-info cursors remain null. + +Associated data is versioned, length-delimited and constructed privately from +trusted audience, authorization partition, schema fingerprint, collection and +complete logical order. A relation adds its source/target identity and exact typed +parent identity from an opaque authorized anchor. Page direction and size are not +bound, so an edge can resume forwards or backwards. Parent context is obtained +after an authorized parent read; wrong-parent tokens fail before child I/O. + +Default limits are 16 KiB plaintext, 64 ASCII key-ID bytes, 16 KiB associated data, +512 bytes provider overhead and 32 KiB public token. Provider outputs and incoming +frames are bounded before decoding/allocation. Request budgets cap crypto calls +and output bytes. Providers must also honor their supplied limits. + +New cursors use the host's active key; old cursors resume only while their key is +in the bounded allowed ring and host expiry/revocation permits it. Retired keys +and provider unavailability yield `cursor_unavailable`; framing, tampering and +context/authentication failures yield indistinguishable `invalid_cursor`. Errors +carry no ciphertext, plaintext, crypto source error or caller values. Tokens grant +no access. Hosts may include a policy revision in their trusted partition to revoke +old cursors; otherwise current row policies still apply to resumed pages. + +## Exact scalar wire forms + +| Kind | GraphQL wire representation | +| --- | --- | +| Integer and count | `RuntimeInt64`: canonical signed decimal string; full i64 range, no plus/leading zeros/negative zero/numeric coercion | +| JSON | `RuntimeJson`: bounded valid JSON text on input and output; SQL NULL is GraphQL null; JSON null is non-null string `"null"` | +| UUID | `RuntimeUuid`: valid UUID string input, lowercase hyphenated output | +| Bytes | `RuntimeBytes`: canonical padded RFC4648 base64, bounded decoded bytes | +| DateTime | `RuntimeDateTime`: RFC3339 input, UTC `YYYY-MM-DDTHH:MM:SS.ffffffZ` output; existing microsecond rounding | +| Float | finite GraphQL Float, existing RuntimeFloat normalization | +| Boolean/String | ordinary GraphQL Boolean/String, no string normalization | + +JSON whitespace/key spelling need not round-trip; the supported serde_json::Value +must. Integer JSON tokens must fit i64 (negative) or u64 (nonnegative), and +fractional/exponent tokens must fit finite f64. Integer overflow is rejected, +rather than silently falling back to a rounded floating-point value. JSON strings include their JSON quotes in the scalar text. Non-null JSON +fields can contain JSON null. Unloaded fields are errors, never SQL NULL. + +TypeScript scalar mappings are strings for Int64/JSON/UUID/Bytes/DateTime. Use +BigInt for integers, exact JSON parsing where number fidelity matters, Uint8Array +for decoded bytes and a precision-preserving datetime parser rather than Date. +Rust wire DTOs use string newtypes with fallible conversions. SDK generation is +host-owned; static GraphQL scalars are unchanged. + +## Bounds and verification + +`RuntimeGraphqlLimits` bounds types/names/schema bytes, operation/variable input, +recursive selections, predicates, pages/projections, relation parents, statement +work, materialized nodes, output bytes and crypto calls. Request budgets may only +lower installed limits. Variable trees are walked iteratively; query nesting is +bounded before parsing. Output admission conservatively accounts for JSON escaping +and repeated aliased selections. Hosts still bound HTTP allocation before building +an already-owned GraphQL Request. + +`descriptor().cost_metadata()` describes one default root page including count. +Hosts can use `RuntimeGraphqlCost::for_root_page`, `for_relation_layer` and +`checked_add` for overflow-checked admission estimates. Execution computes its own +cost from the selected operation; caller estimates never bypass those checks. + +Run backend lanes sequentially with `CARGO_BUILD_JOBS=2`: + +```sh +cargo test -p graphql-orm --no-default-features --features sqlite,runtime-graphql --lib --test runtime_graphql -- --test-threads=1 +cargo test -p graphql-orm --no-default-features --features postgres,runtime-graphql --lib --test runtime_graphql -- --test-threads=1 +cargo run -p graphql-orm --no-default-features --features sqlite,runtime-graphql --example runtime_graphql_reads +cargo run -p graphql-orm --no-default-features --features postgres,runtime-graphql --example runtime_graphql_reads +``` + +SQLite tests own in-memory databases; PostgreSQL tests/examples create labelled +Docker containers and verify exact ownership before cleanup. MSSQL remains an +unsupported runtime decoder and is rejected before ORM I/O; compilation is not +live MSSQL execution. GitHub Actions remain paused. + +The unreconciled B predecessor does not yet contain #112. Successful PostgreSQL +StartsWith/EndsWith execution and the SQLite empty-suffix correction remain gated +on reviewed #112 → A → B → C reconciliation. This does not weaken those release +acceptance requirements. No merge, publication or downstream pin is implied by an +unpublished C branch. D requires separate reviewed C/B predecessors. diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 3d2d99d3..8a047211 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.41.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.42.0` | `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.41.0` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.42.0` | `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.4` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none |