diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a9a38b4..c6715a6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,25 @@ jobs: done cargo clippy --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features sqlite --all-targets -- -D warnings + orm-policy-projections: + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo test -p graphql-orm --locked --no-default-features --features sqlite --test projection_visibility --test read_projections + - name: Execute projection authorization on a test-owned PostgreSQL container + run: cargo test -p graphql-orm --locked --no-default-features --features postgres --test projection_visibility + - run: cargo run --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features sqlite --example policy_projections + - name: Check explicit backend lanes + run: | + for backend in sqlite postgres mssql; do + cargo clippy -p graphql-orm -p graphql-orm-macros --locked --no-default-features --features "$backend" --lib --test projection_visibility -- -D warnings + done + companions: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd645f01..e888e93e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -155,6 +155,10 @@ jobs: cargo test -p graphql-orm --no-default-features --features sqlite,field-case-pascal,argument-case-snake --test federation_entity_keys_case_features --locked cargo check -p graphql-orm --no-default-features --features postgres --locked cargo test -p graphql-orm --no-default-features --features postgres --test federation_entity_keys_postgres --locked + cargo test -p graphql-orm --no-default-features --features postgres --test projection_visibility --test cross_crate_relations_fixture --locked + cargo test -p graphql-orm --no-default-features --features postgres --test grouped_aggregates_postgres --locked -- --ignored --test-threads=1 + cargo run --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features sqlite --example policy_projections + cargo run --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features sqlite --example complete_events cargo check -p graphql-orm --no-default-features --features mssql --locked cargo test -p graphql-orm --no-default-features --features mssql --test federation_entity_keys_mssql --locked cargo clippy -p graphql-orm --all-targets --locked -- -D warnings diff --git a/CHANGELOG.md b/CHANGELOG.md index 194ff840..7c8519b4 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.35.1 - 2026-10-01 + +Companion macros crate: `graphql-orm-macros` **0.35.1**. + +- Allow typed repository projections under an installed global row-policy provider + when current visibility is explicitly unrestricted or complete SQL authorization. + Apply entity-bound predicates before limits and primary/unique-key lookups, + including pinned transactions, without selecting excluded private fields. +- Retain fail-closed callback-only/prefilter policies and normal entity/selected-field + checks. Add a provided generated-identity hook without changing existing trait + implementation requirements, static GraphQL SDL, storage or cursor formats. +- Add executable private repository projection/group examples and isolated + SQLite/PostgreSQL regression evidence; observe actual projection query counts. + ## 0.35.0 - 2026-10-01 Companion macros crate: `graphql-orm-macros` **0.35.0** (alignment only). diff --git a/Cargo.lock b/Cargo.lock index f9e3195e..41c1e4c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3146,7 +3146,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.35.0" +version = "0.35.1" dependencies = [ "agql-auth", "async-graphql", @@ -3245,7 +3245,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.35.0" +version = "0.35.1" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index cda7d0eb..9ed80644 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ cynic-parser = { version = "=0.11.2", features = ["pretty"] } futures = "0.3" getrandom = "0.3" graphql-composition = "=0.12.2" -graphql-orm = { path = "crates/graphql-orm", version = "0.35.0", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.35.1", 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 5828a33e..9310587f 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -13,6 +13,27 @@ supersedes: [] `graphql-orm` is distributed from GitHub only. Use a reviewed full 40-character commit in `rev`; neither the runtime nor macros crate is published to crates.io. +## Policy-aware typed projections (0.35.1) + +Adopt aligned ORM/macros 0.35.1. No schema or GraphQL SDL migration is required. +A global `RowPolicy` no longer automatically rejects every projection: return +`ReadVisibility::Unrestricted` deliberately for an unrestricted model or +`Complete(ReadPredicate::from_filter(...))` for visibility fully expressed in SQL. +This is the same contract supported by complete SQLite group pages. The default +`CallbackOnly` and `Prefilter` continue to reject these reads before query I/O; +there is no full-entity fallback. Entity and selected-field policies remain active. + +Re-evaluate visibility for every call using verified identity/current ownership. +A complete predicate replaces the residual callback on these supported paths; +do not label a partial predicate complete. Generated projections carry their +associated entity identity. Existing handwritten `ReadProjection` implementations +remain source compatible and support unrestricted visibility; complete predicates +require the provided `entity_type_id` hook to return that associated entity's +`TypeId`. No public GraphQL roots or private model fields are added. + +See [typed projections](docs/reference/graphql-orm/read-projections.md) for a +compiled SQL-free private repository example and exact backend/authorization rules. + ## Complete text-group pages (0.35.0) Adopt aligned ORM/macros 0.35.0 after owner review/publication. This is an additive diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index 29c4e1d4..a35e69ca 100644 --- a/crates/graphql-orm-macros/Cargo.toml +++ b/crates/graphql-orm-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-macros" -version = "0.35.0" +version = "0.35.1" 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 c58a01ea..06ea3263 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -16,13 +16,13 @@ macro/runtime versions aligned: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.35.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.35.1", default-features = false, features = ["sqlite"] } ``` Direct use is supported for tooling that needs the macro package: ```toml -graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.35.0", default-features = false, features = ["sqlite"] } +graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.35.1", default-features = false, features = ["sqlite"] } ``` The direct dependency still requires a compatible `graphql-orm` runtime in the @@ -170,6 +170,7 @@ Version 0.33.3 gives the repository aggregate compatibility fix a distinct packa identity after the independent 0.33.2 fixture maintenance. Generated behavior is unchanged from the reviewed aggregate fix. -Version 0.35.0 aligns with the additive core text-group paging API. Existing generated +Version 0.35.1 generates projection identity for complete SQL row visibility and +aligns with the core policy-aware projection fix. Existing generated aggregate enums/builders and ordinary GraphQL aggregate SDL remain unchanged; repository-only consumers still need no direct async-graphql dependency. 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 f65eb9db..8e06e115 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.35.0" +version = "0.35.1" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.35.0" +version = "0.35.1" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm-macros/src/entity.rs b/crates/graphql-orm-macros/src/entity.rs index 8f5c5229..0cb5cf7a 100644 --- a/crates/graphql-orm-macros/src/entity.rs +++ b/crates/graphql-orm-macros/src/entity.rs @@ -5564,6 +5564,9 @@ fn generate_projection_definitions( type Entity = #entity; type Filter = #where_input; type Order = #order_input; + fn entity_type_id() -> Option<::std::any::TypeId> { + Some(::std::any::TypeId::of::<#entity>()) + } const COLUMNS: &'static [&'static str] = &[#(#selected_columns),*]; const FIELD_NAMES: &'static [&'static str] = &[#(#selected_field_names),*]; const UNIQUE_COLUMNS: &'static [&'static str] = &[#(#unique_columns),*]; diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index b71ec7a8..4e375e80 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.35.0" +version = "0.35.1" edition = "2024" description = "Runtime support crate for graphql-orm-macros" license = "MIT" @@ -68,7 +68,7 @@ futures = "0.3" geo = { version = "0.33", optional = true, default-features = false } geo-types = { version = "0.7", optional = true } geojson = { version = "1", optional = true, default-features = true } -graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.35.0", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.35.1", 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 5404741a..08a38f64 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -29,7 +29,7 @@ backend: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.35.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.35.1", default-features = false, features = ["sqlite"] } ``` This unpublished package has no docs.rs release. Use this Git README and the diff --git a/crates/graphql-orm/src/db.rs b/crates/graphql-orm/src/db.rs index 99c85d60..98d20338 100644 --- a/crates/graphql-orm/src/db.rs +++ b/crates/graphql-orm/src/db.rs @@ -975,7 +975,7 @@ impl Database { } /// Observe SQL text (without bind values) and database rows fetched by - /// EntityQuery and aggregate-page reads. Intended for diagnostics and query-behavior tests. + /// EntityQuery, projection and aggregate-page reads. Intended for diagnostics and query-behavior tests. pub fn with_read_query_observer( mut self, observer: impl crate::graphql::orm::ReadQueryObserver + 'static, @@ -996,9 +996,36 @@ impl Database { &self, ctx: Option<&async_graphql::Context<'_>>, surface: crate::graphql::orm::EntityAccessSurface, + ) -> async_graphql::Result { + let visibility = self + .read_visibility_for(T::metadata(), ctx, surface) + .await?; + visibility.validate::()?; + Ok(visibility) + } + + pub(crate) async fn projection_read_visibility>( + &self, + ) -> async_graphql::Result { + use crate::graphql::orm::Entity; + let visibility = self + .read_visibility_for( + P::Entity::metadata(), + None, + crate::graphql::orm::EntityAccessSurface::Repository, + ) + .await?; + visibility.validate_identity(P::entity_type_id(), B::DIALECT)?; + Ok(visibility) + } + + async fn read_visibility_for( + &self, + metadata: &'static crate::graphql::orm::EntityMetadata, + ctx: Option<&async_graphql::Context<'_>>, + surface: crate::graphql::orm::EntityAccessSurface, ) -> async_graphql::Result { use crate::graphql::orm::ReadVisibility; - let metadata = T::metadata(); let visibility = if let Some(policy) = &self.row_policy { policy .read_visibility( @@ -1013,7 +1040,6 @@ impl Database { // No RowPolicy is installed: retain the existing entity-policy-only contract. ReadVisibility::Unrestricted }; - visibility.validate::()?; Ok(visibility) } diff --git a/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs b/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs index ba3f2c07..79bf7e7a 100644 --- a/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs +++ b/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs @@ -147,8 +147,15 @@ impl ReadVisibility { matches!(self, Self::CallbackOnly | Self::Prefilter(_)) } pub(crate) fn validate(&self) -> async_graphql::Result<()> { + self.validate_identity(Some(TypeId::of::()), B::DIALECT) + } + pub(crate) fn validate_identity( + &self, + entity: Option, + backend: DatabaseBackend, + ) -> async_graphql::Result<()> { if let Self::Prefilter(p) | Self::Complete(p) = self - && (p.entity != TypeId::of::() || p.backend != B::DIALECT) + && (Some(p.entity) != entity || p.backend != backend) { return Err(invalid()); } diff --git a/crates/graphql-orm/src/graphql/orm/core.rs b/crates/graphql-orm/src/graphql/orm/core.rs index cbfe6dcf..cb29ef05 100644 --- a/crates/graphql-orm/src/graphql/orm/core.rs +++ b/crates/graphql-orm/src/graphql/orm/core.rs @@ -2511,8 +2511,8 @@ pub trait FieldPolicy: Send + Sync { pub trait RowPolicy: Send + Sync { /// Describe read visibility for this request, entity, and access surface. /// The default preserves callback-only authorization. A complete predicate - /// (or `Unrestricted`) replaces row callbacks only on pagination paths that - /// explicitly support this contract. Errors fail the request closed. + /// (or `Unrestricted`) replaces row callbacks only on read paths that + /// explicitly support this contract (including typed projections and group pages). Errors fail the request closed. fn read_visibility<'a>( &'a self, _ctx: Option<&'a async_graphql::Context<'_>>, diff --git a/crates/graphql-orm/src/graphql/orm/query.rs b/crates/graphql-orm/src/graphql/orm/query.rs index 274d5f1f..27948140 100644 --- a/crates/graphql-orm/src/graphql/orm/query.rs +++ b/crates/graphql-orm/src/graphql/orm/query.rs @@ -141,6 +141,13 @@ pub trait ReadProjection: const COLUMNS: &'static [&'static str]; /// Rust field identities selected by this projection, in decode order. const FIELD_NAMES: &'static [&'static str] = &[]; + /// Generated identity for validating SQL visibility without fetching an entity. + /// Handwritten projections remain compatible; SQL-complete visibility requires + /// opting into the associated entity identity. Unrestricted reads need no token. + #[doc(hidden)] + fn entity_type_id() -> Option { + None + } #[doc(hidden)] const UNIQUE_COLUMNS: &'static [&'static str]; } @@ -2996,6 +3003,7 @@ where &self, pagination_config: PaginationConfig, forced_limit: Option, + visibility: Option<&FilterExpression>, ) -> RenderedQuery { let entity_default = ::DEFAULT_SORT; let mut sorts = if self.order_clauses.is_empty() { @@ -3033,7 +3041,16 @@ where .iter() .map(|column| (*column).to_string()) .collect(), - filter: filter_expression_from_raw_parts(&self.where_clauses, &self.values), + filter: match ( + filter_expression_from_raw_parts(&self.where_clauses, &self.values), + visibility, + ) { + (Some(filter), Some(policy)) => { + Some(FilterExpression::And(vec![filter, policy.clone()])) + } + (filter, None) => filter, + (None, Some(policy)) => Some(policy.clone()), + }, sorts, pagination: Some(pagination_config.resolve_page(Some(&page), true)), count_only: false, @@ -3059,7 +3076,9 @@ where } } -async fn ensure_projection_access(db: &crate::db::Database) -> crate::Result<()> +async fn ensure_projection_access( + db: &crate::db::Database, +) -> crate::Result where B: OrmBackend, P: ReadProjection, @@ -3094,13 +3113,17 @@ where .await .map_err(crate::graphql::errors::sqlx_error_from_public)?; } - if db.row_policy().is_some() { + let visibility = db + .projection_read_visibility::

() + .await + .map_err(crate::graphql::errors::sqlx_error_from_graphql)?; + if visibility.requires_residual_checks() { return Err(sqlx::Error::Protocol( - "projection reads are denied when an application row policy requires a full entity; use database RLS or a typed SQL-renderable filter" + "projection reads are denied when an application row policy requires a full entity; use complete SQL visibility or explicit unrestricted visibility" .to_string(), )); } - Ok(()) + Ok(visibility) } /// Backend-neutral repository query for one macro-generated projection DTO. @@ -3167,9 +3190,13 @@ where } pub async fn fetch_all(self) -> crate::Result> { - ensure_projection_access::(self.db).await?; + let visibility = ensure_projection_access::(self.db).await?; self.state.validate()?; - let rendered = self.state.render(self.db.pagination_config(), None); + let rendered = self.state.render( + self.db.pagination_config(), + None, + visibility.predicate_expression(), + ); let rows = B::fetch_rows_with_auth( self.db.pool(), &rendered.sql, @@ -3177,13 +3204,18 @@ where self.auth.as_ref(), ) .await?; + self.db.observe_read(&rendered.sql, rows.len()); ProjectionQueryState::::decode(rows) } pub async fn fetch_first(self) -> crate::Result> { - ensure_projection_access::(self.db).await?; + let visibility = ensure_projection_access::(self.db).await?; self.state.validate()?; - let rendered = self.state.render(self.db.pagination_config(), Some(1)); + let rendered = self.state.render( + self.db.pagination_config(), + Some(1), + visibility.predicate_expression(), + ); let rows = B::fetch_rows_with_auth( self.db.pool(), &rendered.sql, @@ -3191,15 +3223,20 @@ where self.auth.as_ref(), ) .await?; + self.db.observe_read(&rendered.sql, rows.len()); Ok(ProjectionQueryState::::decode(rows)? .into_iter() .next()) } pub async fn fetch_optional_one(self) -> crate::Result> { - ensure_projection_access::(self.db).await?; + let visibility = ensure_projection_access::(self.db).await?; self.state.validate()?; - let rendered = self.state.render(self.db.pagination_config(), Some(2)); + let rendered = self.state.render( + self.db.pagination_config(), + Some(2), + visibility.predicate_expression(), + ); let rows = B::fetch_rows_with_auth( self.db.pool(), &rendered.sql, @@ -3207,6 +3244,7 @@ where self.auth.as_ref(), ) .await?; + self.db.observe_read(&rendered.sql, rows.len()); let mut values = ProjectionQueryState::::decode(rows)?; if values.len() > 1 { return Err(sqlx::Error::Protocol( @@ -3273,15 +3311,20 @@ where } async fn fetch(self, forced_limit: Option) -> crate::Result> { - ensure_projection_access::(self.context.database()).await?; + let visibility = ensure_projection_access::(self.context.database()).await?; self.state.validate()?; - let rendered = self - .state - .render(self.context.database().pagination_config(), forced_limit); + let rendered = self.state.render( + self.context.database().pagination_config(), + forced_limit, + visibility.predicate_expression(), + ); let rows = self .context .fetch_rows(&rendered.sql, &rendered.values) .await?; + self.context + .database() + .observe_read(&rendered.sql, rows.len()); ProjectionQueryState::::decode(rows) } diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index 704dd20b..203def38 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.35.0" +version = "0.35.1" dependencies = [ "agql-auth", "async-graphql", @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.35.0" +version = "0.35.1" 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 dc99b852..ba322910 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.35.0" +version = "0.35.1" dependencies = [ "async-graphql", "chacha20poly1305", @@ -2531,7 +2531,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.35.0" +version = "0.35.1" 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 bbdec42a..05d83ffe 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.35.0" +version = "0.35.1" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.35.0" +version = "0.35.1" 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 baf9dce2..4b63d5f9 100644 --- a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock @@ -1004,7 +1004,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.35.0" +version = "0.35.1" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.35.0" +version = "0.35.1" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/policy_projections.rs b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/policy_projections.rs new file mode 100644 index 00000000..c486411f --- /dev/null +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/policy_projections.rs @@ -0,0 +1,165 @@ +//! Safe public projections and group pages with a globally installed row policy. +//! Run with --no-default-features --features sqlite --example policy_projections. +#[cfg(feature = "sqlite")] +pub mod example { + use graphql_orm::{async_graphql, prelude::*}; + #[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] + #[repository_entity( + backend = "sqlite", + table = "private_issuers", + plural = "PrivateIssuers" + )] + #[graphql_orm(projection(name = "PublicIssuer", fields = [id, tenant, certificate_pem], private = true))] + pub struct PrivateIssuer { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: String, + #[filterable(type = "string")] + tenant: String, + certificate_pem: String, + #[graphql_orm(private, sensitive)] + encrypted_private_key: String, + } + enum Visibility { + Complete { verified_tenant: String }, + Unrestricted, + Residual, + } + impl RowPolicy for Visibility { + fn read_visibility<'a>( + &'a self, + _ctx: Option<&'a async_graphql::Context<'_>>, + _db: &'a Database, + entity: &'static str, + _policy: Option<&'static str>, + surface: EntityAccessSurface, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> + { + assert_eq!(surface, EntityAccessSurface::Repository); + Box::pin(async move { + // A global provider must explicitly decide for each model; unknown models fail closed. + if entity != "PrivateIssuer" { + return Ok(ReadVisibility::CallbackOnly); + } + Ok(match self { + Self::Unrestricted => ReadVisibility::Unrestricted, + Self::Residual => ReadVisibility::CallbackOnly, + Self::Complete { verified_tenant } => ReadVisibility::Complete( + ReadPredicate::from_filter::(&PrivateIssuerWhereInput { + tenant: Some(StringFilter { + eq: Some(verified_tenant.clone()), + ..Default::default() + }), + ..Default::default() + })?, + ), + }) + }) + } + fn can_read_row<'a>( + &'a self, + _ctx: Option<&'a async_graphql::Context<'_>>, + _db: &'a Database, + _entity: &'static str, + _policy: Option<&'static str>, + _surface: EntityAccessSurface, + _row: &'a (dyn std::any::Any + Send + Sync), + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(false) }) + } + fn can_write_row<'a>( + &'a self, + _ctx: Option<&'a async_graphql::Context<'_>>, + _db: &'a Database, + _entity: &'static str, + _policy: Option<&'static str>, + _surface: EntityAccessSurface, + _row: &'a (dyn std::any::Any + Send + Sync), + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(false) }) + } + } + pub async fn run() -> graphql_orm::Result<()> { + let mut db = Database::::connect_sqlite("sqlite::memory:").await?; + // The example owns its synthetic database. External-read-only models do not use this setup. + let plan = db + .schema() + .plan_migration_to_entities( + "example", + "owned issuer fixture", + &[PrivateIssuer::metadata()], + ) + .await?; + db.schema() + .apply_migration(&plan, Default::default()) + .await?; + for (id, tenant) in [("a", "beta"), ("b", "alpha"), ("c", "alpha")] { + PrivateIssuer::insert( + &db, + CreatePrivateIssuerInput { + id: id.into(), + tenant: tenant.into(), + certificate_pem: format!("PUBLIC-{id}"), + encrypted_private_key: "synthetic-custody".into(), + }, + ) + .await?; + } + db.set_row_policy(Visibility::Complete { + verified_tenant: "alpha".into(), + }); + // No full entity, excluded private-key column, application query SQL, or GraphQL roots. + let public = PublicIssuer::query(&db).limit(1).fetch_all().await?; + assert_eq!(public[0].id, "b"); + assert!(PublicIssuer::find_by_id(&db, &"a".into()).await?.is_none()); + let options = AggregateGroupPageOptions { + order: AggregateGroupOrder::Binary, + exclude_blank: true, + context: "verified-tenant-alpha:public-revision-1".into(), + }; + let page = PrivateIssuer::aggregate(&db) + .group_by(PrivateIssuerAggregateField::Tenant)? + .count_rows()? + .fetch_group_page(options.clone(), None) + .await?; + assert_eq!(page.rows.len(), 1); + assert_eq!(page.rows[0].metrics[0].value, AggregateValue::Count(2)); + // An intentionally unrestricted model remains usable under an installed global provider. + db.set_row_policy(Visibility::Unrestricted); + assert_eq!( + PublicIssuer::query(&db).limit(10).fetch_all().await?.len(), + 3 + ); + assert_eq!( + PrivateIssuer::aggregate(&db) + .group_by(PrivateIssuerAggregateField::Tenant)? + .fetch_group_page(options.clone(), None) + .await? + .rows + .len(), + 2 + ); + db.set_row_policy(Visibility::Residual); + assert!(PublicIssuer::query(&db).fetch_first().await.is_err()); + assert!( + PrivateIssuer::aggregate(&db) + .group_by(PrivateIssuerAggregateField::Tenant)? + .fetch_group_page(options, None) + .await + .is_err() + ); + println!( + "Public projections and complete groups retain global row authorization without loading custody fields" + ); + Ok(()) + } +} +#[cfg(feature = "sqlite")] +#[tokio::main] +async fn main() -> graphql_orm::Result<()> { + example::run().await +} +#[cfg(not(feature = "sqlite"))] +fn main() { + println!("This runnable private repository example uses SQLite"); +} diff --git a/crates/graphql-orm/tests/projection_visibility.rs b/crates/graphql-orm/tests/projection_visibility.rs new file mode 100644 index 00000000..1cec4c2d --- /dev/null +++ b/crates/graphql-orm/tests/projection_visibility.rs @@ -0,0 +1,512 @@ +//! Executes against memory SQLite or an independently owned PostgreSQL container. +#![cfg(any(feature = "sqlite", feature = "postgres"))] +use graphql_orm::prelude::*; +use std::sync::{Arc, Mutex}; + +#[cfg(feature = "postgres")] +#[path = "support/owned_postgres.rs"] +mod owned_postgres; +#[cfg(feature = "sqlite")] +type Backend = SqliteBackend; +#[cfg(feature = "postgres")] +type Backend = PostgresBackend; + +type TestResult = Result<(), Box>; + +#[derive(RepositoryEntity, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[cfg_attr( + feature = "sqlite", + repository_entity( + backend = "sqlite", + table = "private_issuers", + plural = "PrivateIssuers", + schema_policy = "external_read_only", + read_policy = "issuers.read" + ) +)] +#[cfg_attr( + feature = "postgres", + repository_entity( + backend = "postgres", + table = "private_issuers", + plural = "PrivateIssuers", + schema_policy = "external_read_only", + read_policy = "issuers.read" + ) +)] +#[graphql_orm(projection(name = "PublicIssuer", fields = [id, tenant, certificate_pem], private = true))] +#[graphql_orm(projection(name = "CustodyIssuer", fields = [id, encrypted_private_key], private = true))] +struct PrivateIssuer { + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "string")] + #[sortable] + id: String, + #[filterable(type = "string")] + tenant: String, + certificate_pem: String, + #[graphql_orm(private, sensitive, read_policy = "custody.read")] + encrypted_private_key: String, +} + +#[derive(RepositoryEntity, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[cfg_attr( + feature = "sqlite", + repository_entity(backend = "sqlite", table = "other_source", plural = "OtherSources") +)] +#[cfg_attr( + feature = "postgres", + repository_entity(backend = "postgres", table = "other_source", plural = "OtherSources") +)] +struct OtherSource { + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "string")] + id: String, +} + +#[derive(Clone, Copy)] +enum Mode { + Unrestricted, + Complete, + CallbackOnly, + Prefilter, + WrongEntity, +} +#[derive(Clone)] +struct Visibility { + mode: Arc>, + tenant: Arc>, +} +impl RowPolicy for Visibility { + fn read_visibility<'a>( + &'a self, + _ctx: Option<&'a async_graphql::Context<'_>>, + _db: &'a Database, + entity: &'static str, + policy: Option<&'static str>, + surface: EntityAccessSurface, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + assert_eq!(entity, "PrivateIssuer"); + assert_eq!(policy, Some("issuers.read")); + assert_eq!(surface, EntityAccessSurface::Repository); + let mode = *self.mode.lock().unwrap(); + let tenant = self.tenant.lock().unwrap().clone(); + Box::pin(async move { + Ok(match mode { + Mode::Unrestricted => ReadVisibility::Unrestricted, + Mode::CallbackOnly => ReadVisibility::CallbackOnly, + Mode::WrongEntity => { + ReadVisibility::Complete(ReadPredicate::from_filter::( + &OtherSourceWhereInput { + id: Some(StringFilter { + eq: Some(tenant), + ..Default::default() + }), + ..Default::default() + }, + )?) + } + Mode::Complete | Mode::Prefilter => { + let predicate = + ReadPredicate::from_filter::(&PrivateIssuerWhereInput { + tenant: Some(StringFilter { + eq: Some(tenant), + ..Default::default() + }), + ..Default::default() + })?; + if matches!(mode, Mode::Complete) { + ReadVisibility::Complete(predicate) + } else { + ReadVisibility::Prefilter(predicate) + } + } + }) + }) + } + fn can_read_row<'a>( + &'a self, + _ctx: Option<&'a async_graphql::Context<'_>>, + _db: &'a Database, + _entity: &'static str, + _policy: Option<&'static str>, + _surface: EntityAccessSurface, + _row: &'a (dyn std::any::Any + Send + Sync), + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { + panic!("projection or group must never load a full entity for a residual callback") + }) + } + fn can_write_row<'a>( + &'a self, + _ctx: Option<&'a async_graphql::Context<'_>>, + _db: &'a Database, + _entity: &'static str, + _policy: Option<&'static str>, + _surface: EntityAccessSurface, + _row: &'a (dyn std::any::Any + Send + Sync), + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(false) }) + } +} +struct EntityAccess(bool); +impl EntityPolicy for EntityAccess { + fn can_access_entity<'a>( + &'a self, + _ctx: Option<&'a async_graphql::Context<'_>>, + _db: &'a Database, + _entity: &'static str, + _policy: Option<&'static str>, + _kind: EntityAccessKind, + _surface: EntityAccessSurface, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(self.0) }) + } +} +#[derive(Clone, Default)] +struct Reads(Arc>>); +impl ReadQueryObserver for Reads { + fn on_read(&self, sql: &str, _rows: usize) { + self.0.lock().unwrap().push(sql.into()); + } +} +struct Fixture { + db: Database, + visibility: Visibility, + reads: Reads, + #[cfg(feature = "postgres")] + owned: owned_postgres::OwnedPostgres, +} +impl Fixture { + async fn start() -> Result> { + #[cfg(feature = "sqlite")] + let mut db = Database::::connect_sqlite("sqlite::memory:").await?; + #[cfg(feature = "postgres")] + let mut owned = owned_postgres::OwnedPostgres::start("projection-visibility")?; + #[cfg(feature = "postgres")] + let mut db = match Database::::connect_postgres(&owned.url).await { + Ok(db) => db, + Err(error) => { + owned.cleanup()?; + return Err(error.into()); + } + }; + sqlx::query("CREATE TABLE issuer_base(id TEXT PRIMARY KEY, tenant TEXT NOT NULL, certificate_pem TEXT NOT NULL)").execute(db.pool()).await?; + sqlx::query("INSERT INTO issuer_base VALUES ('a','beta','PUBLIC-B'), ('b','alpha','PUBLIC-A'), ('c','alpha','PUBLIC-C')").execute(db.pool()).await?; + #[cfg(feature = "sqlite")] + sqlx::query("CREATE VIEW private_issuers AS SELECT id,tenant,certificate_pem,json_extract('invalid','$') AS encrypted_private_key FROM issuer_base").execute(db.pool()).await?; + #[cfg(feature = "postgres")] + { + sqlx::query("CREATE FUNCTION forbidden_secret() RETURNS TEXT LANGUAGE plpgsql STABLE AS $$ BEGIN RAISE EXCEPTION 'private material fetched'; END; $$").execute(db.pool()).await?; + sqlx::query("CREATE VIEW private_issuers AS SELECT id,tenant,certificate_pem,forbidden_secret() AS encrypted_private_key FROM issuer_base").execute(db.pool()).await?; + } + // An oracle that proves fetching the excluded expression really fails. + assert!( + sqlx::query("SELECT encrypted_private_key FROM private_issuers") + .fetch_all(db.pool()) + .await + .is_err() + ); + let visibility = Visibility { + mode: Arc::new(Mutex::new(Mode::Unrestricted)), + tenant: Arc::new(Mutex::new("alpha".into())), + }; + db.set_entity_policy(EntityAccess(true)); + db.set_row_policy(visibility.clone()); + db.set_authorization_mode(AuthorizationMode::DeclaredPoliciesRequired); + let reads = Reads::default(); + let db = db.with_read_query_observer(reads.clone()); + Ok(Self { + db, + visibility, + reads, + #[cfg(feature = "postgres")] + owned, + }) + } + async fn close(self) -> TestResult { + self.db.pool().close().await; + #[cfg(feature = "postgres")] + { + let mut owned = self.owned; + owned.cleanup()?; + } + Ok(()) + } +} + +#[tokio::test] +async fn global_unrestricted_and_complete_visibility_project_without_private_material() -> TestResult +{ + let fixture = Fixture::start().await?; + let db = &fixture.db; + let rows = PublicIssuer::query(db).limit(2).fetch_all().await?; + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].id, "a"); + assert_eq!( + PublicIssuer::query(db).fetch_first().await?.unwrap().id, + "a" + ); + assert!(PublicIssuer::query(db).fetch_optional_one().await.is_err()); + *fixture.visibility.mode.lock().unwrap() = Mode::Complete; + let rows = PublicIssuer::query(db).limit(1).fetch_all().await?; + assert_eq!( + rows[0].id, "b", + "authorization precedes LIMIT, even when an unauthorized row sorts first" + ); + assert_eq!( + PublicIssuer::query(db).fetch_first().await?.unwrap().id, + "b" + ); + assert!(PublicIssuer::find_by_id(db, &"a".into()).await?.is_none()); + let row = PublicIssuer::find_by_id(db, &"b".into()).await?.unwrap(); + assert_eq!(row.certificate_pem, "PUBLIC-A"); + let tx_rows = db + .transaction(TransactionMode::Default, |tx| { + Box::pin(async move { + assert!( + PublicIssuer::find_by_id_in(tx, &"a".into()) + .await + .map_err(OrmPublicError::from)? + .is_none() + ); + assert_eq!( + tx.project::() + .fetch_first() + .await + .map_err(OrmPublicError::from)? + .unwrap() + .id, + "b" + ); + assert!( + tx.project::() + .fetch_optional_one() + .await + .is_err() + ); + tx.project::() + .limit(1) + .fetch_all() + .await + .map_err(OrmPublicError::from) + }) + }) + .await?; + assert_eq!(tx_rows[0].id, "b"); + *fixture.visibility.tenant.lock().unwrap() = "beta".into(); + assert!( + PublicIssuer::find_by_id(db, &"b".into()).await?.is_none(), + "previously returned values confer no authority" + ); + assert_eq!( + PublicIssuer::query(db) + .fetch_optional_one() + .await? + .unwrap() + .id, + "a" + ); + let reads = fixture.reads.0.lock().unwrap().clone(); + assert_eq!( + reads.len(), + 13, + "one bounded SELECT per projection operation including transaction reads" + ); + assert!( + reads + .iter() + .all(|sql| !sql.contains("encrypted_private_key") && !sql.contains("SELECT *")) + ); + fixture.close().await +} + +#[tokio::test] +async fn residual_wrong_entity_entity_denial_and_selected_field_denial_fail_before_io() -> TestResult +{ + let mut fixture = Fixture::start().await?; + for mode in [Mode::CallbackOnly, Mode::Prefilter, Mode::WrongEntity] { + *fixture.visibility.mode.lock().unwrap() = mode; + assert!(PublicIssuer::query(&fixture.db).fetch_all().await.is_err()); + assert!( + PublicIssuer::find_by_id(&fixture.db, &"b".into()) + .await + .is_err() + ); + fixture + .db + .transaction(TransactionMode::Default, |tx| { + Box::pin(async move { + assert!(tx.project::().fetch_first().await.is_err()); + Ok(()) + }) + }) + .await?; + } + *fixture.visibility.mode.lock().unwrap() = Mode::Unrestricted; + assert!( + CustodyIssuer::query(&fixture.db).fetch_all().await.is_err(), + "declared selected field policy has no provider" + ); + fixture.db.set_entity_policy(EntityAccess(false)); + assert!( + PublicIssuer::query(&fixture.db) + .fetch_first() + .await + .is_err() + ); + assert!(fixture.reads.0.lock().unwrap().is_empty()); + fixture.close().await +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn group_pages_under_global_provider_use_only_authorized_source_rows() -> TestResult { + let fixture = Fixture::start().await?; + let options = AggregateGroupPageOptions { + order: AggregateGroupOrder::Binary, + exclude_blank: true, + context: "trusted-public-revision".into(), + }; + let page = PrivateIssuer::aggregate(&fixture.db) + .group_by(PrivateIssuerAggregateField::Tenant)? + .count_rows()? + .fetch_group_page(options.clone(), None) + .await?; + assert_eq!(page.rows.len(), 2); + *fixture.visibility.mode.lock().unwrap() = Mode::Complete; + let page = PrivateIssuer::aggregate(&fixture.db) + .group_by(PrivateIssuerAggregateField::Tenant)? + .count_rows()? + .fetch_group_page(options.clone(), None) + .await?; + assert_eq!(page.rows.len(), 1); + assert_eq!( + page.rows[0].groups[0].value, + AggregateValue::Text("alpha".into()) + ); + assert_eq!(page.rows[0].metrics[0].value, AggregateValue::Count(2)); + for mode in [Mode::CallbackOnly, Mode::Prefilter, Mode::WrongEntity] { + *fixture.visibility.mode.lock().unwrap() = mode; + assert!( + PrivateIssuer::aggregate(&fixture.db) + .group_by(PrivateIssuerAggregateField::Tenant)? + .count_rows()? + .fetch_group_page(options.clone(), None) + .await + .is_err() + ); + } + assert_eq!(fixture.reads.0.lock().unwrap().len(), 2); + fixture.close().await +} + +// Existing external implementations need no new required method or 'static Entity bound. +#[derive(Clone)] +struct LegacyPublicIssuer(PublicIssuer); +impl FromSqlRow for LegacyPublicIssuer { + fn from_row(row: &::Row) -> graphql_orm::Result { + PublicIssuer::from_row(row).map(Self) + } +} +impl ReadProjection for LegacyPublicIssuer { + type Entity = PrivateIssuer; + type Filter = PrivateIssuerWhereInput; + type Order = PrivateIssuerOrderByInput; + const COLUMNS: &'static [&'static str] = >::COLUMNS; + const FIELD_NAMES: &'static [&'static str] = + >::FIELD_NAMES; + const UNIQUE_COLUMNS: &'static [&'static str] = + >::UNIQUE_COLUMNS; +} + +#[tokio::test] +async fn legacy_projection_implementation_stays_compatible_and_missing_identity_is_fail_closed() +-> TestResult { + let fixture = Fixture::start().await?; + let rows = ProjectionQuery::::new(&fixture.db) + .limit(1) + .fetch_all() + .await?; + assert_eq!(rows[0].0.id, "a"); + *fixture.visibility.mode.lock().unwrap() = Mode::Complete; + assert!( + ProjectionQuery::::new(&fixture.db) + .fetch_first() + .await + .is_err() + ); + assert_eq!(fixture.reads.0.lock().unwrap().len(), 1); + fixture.close().await +} + +#[cfg(feature = "postgres")] +#[tokio::test] +async fn current_sql_visibility_intersects_rls_and_preserves_pool_and_pinned_auth() -> TestResult { + use std::str::FromStr; + let fixture = Fixture::start().await?; + for ddl in [ + "ALTER TABLE issuer_base ENABLE ROW LEVEL SECURITY", + "ALTER TABLE issuer_base FORCE ROW LEVEL SECURITY", + "CREATE POLICY issuer_partition ON issuer_base FOR SELECT USING (tenant = current_setting('app.tenant_id', true))", + "ALTER VIEW private_issuers SET (security_invoker = true)", + "CREATE ROLE projection_reader LOGIN PASSWORD 'owned-fixture-only'", + "GRANT USAGE ON SCHEMA public TO projection_reader", + "GRANT SELECT ON issuer_base, private_issuers TO projection_reader", + ] { + sqlx::query(ddl).execute(fixture.db.pool()).await?; + } + let options = sqlx::postgres::PgConnectOptions::from_str(&fixture.owned.url)? + .username("projection_reader") + .password("owned-fixture-only"); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect_with(options) + .await?; + let mut reader = Database::::new(pool.clone()); + reader.set_entity_policy(EntityAccess(true)); + reader.set_authorization_mode(AuthorizationMode::DeclaredPoliciesRequired); + reader.set_row_policy(fixture.visibility.clone()); + assert!(PublicIssuer::query(&reader).fetch_all().await?.is_empty()); + let mut auth = DbAuthContext { + tenant_id: Some("beta".into()), + ..Default::default() + }; + assert_eq!( + PublicIssuer::query_with_auth(&reader, Some(&auth)) + .fetch_first() + .await? + .unwrap() + .id, + "a" + ); + *fixture.visibility.mode.lock().unwrap() = Mode::Complete; + assert!( + PublicIssuer::query_with_auth(&reader, Some(&auth)) + .fetch_all() + .await? + .is_empty(), + "SQL row policy does not override RLS" + ); + auth.tenant_id = Some("alpha".into()); + let public = reader + .transaction_with_auth(TransactionMode::Default, Some(&auth), |tx| { + Box::pin(async move { + tx.project::() + .limit(1) + .fetch_all() + .await + .map_err(OrmPublicError::from) + }) + }) + .await?; + assert_eq!(public.len(), 1); + assert_eq!(public[0].id, "b"); + assert!( + PublicIssuer::query(&reader).fetch_all().await?.is_empty(), + "auth settings do not leak after commit" + ); + pool.close().await; + fixture.close().await +} diff --git a/docs/reference/graphql-orm/read-projections.md b/docs/reference/graphql-orm/read-projections.md index 742431b0..41006b10 100644 --- a/docs/reference/graphql-orm/read-projections.md +++ b/docs/reference/graphql-orm/read-projections.md @@ -3,7 +3,7 @@ title: "Typed Read Projections" kind: reference status: active owner: graphql-orm-maintainers -last_reviewed: 2026-08-01 +last_reviewed: 2026-10-01 review_by: 2027-02-01 supersedes: [] --- @@ -16,7 +16,7 @@ discarded—would be unacceptable. ## Declaration -Declare one or more projections on a `GraphQLEntity`: +Declare one or more projections on a `GraphQLEntity` or private `RepositoryEntity`: ```rust #[derive(GraphQLEntity, GraphQLOperations, Clone, Debug)] @@ -130,11 +130,50 @@ explicit-policy modes therefore fail closed exactly as full-entity repository re `query_with_auth` uses backend-neutral `DbAuthContext`, and `transaction_with_auth` installs the same transaction-local PostgreSQL settings used by generated RLS reads. -An application `RowPolicy` receives a full Rust entity. Evaluating it would require selecting every -field, defeating a projection's memory boundary, so projection reads fail closed whenever an -application row-policy provider is registered. Use PostgreSQL RLS or an explicit generated typed -filter for projection-compatible tenant/soft-delete enforcement. Filters requiring residual -in-memory entity evaluation are likewise rejected rather than silently loading excluded columns. +With a global `RowPolicy`, projections use its current `read_visibility` decision +for the entity and `Repository` surface on every call: + +| Visibility | Projection/group-page behavior | +| --- | --- | +| `Unrestricted` | Explicitly permit all matching rows; retain entity/selected-field checks. | +| `Complete(predicate)` | Validate entity/backend and parameterize the typed predicate before ordering/limits/grouping. No residual callback. | +| `CallbackOnly` (default), `Prefilter(predicate)` | Reject before query I/O: evaluating a full entity would violate the excluded-column boundary. | + +For example, a global provider can deliberately mark public inventory unrestricted, +or return `ReadVisibility::Complete(ReadPredicate::from_filter::( +&PrivateIssuerWhereInput { tenant: Some(StringFilter { eq: Some(verified_tenant), +..Default::default() }), ..Default::default() })?)` for a verified tenant. Never +mark a partial predicate complete. Reapply current policy/ownership each request; +previous projection values and cursors do not grant access. Filters requiring +residual in-memory evaluation remain rejected. Repository field checks still run +without a full-record value; policies needing that value must fail closed. + +Pool and pinned-transaction projections have the same behavior, including generated +primary/unique-key helpers. SQLite and PostgreSQL generated projections execute this +contract; PostgreSQL `DbAuthContext`/RLS handling remains unchanged. Generated +MSSQL projections are still unsupported; this change does not add that macro profile. +Complete group pages remain SQLite-only, independently of projection support. + +Generated projections supply the associated entity's identity to validate SQL +visibility. The provided `ReadProjection::entity_type_id` defaults to `None` for +existing handwritten implementations, keeping them source compatible. Those +implementations can use unrestricted reads; complete visibility requires returning +`Some(TypeId::of::())`. An absent/mismatched identity fails closed. + +Run the standalone dependency-minimal private repository example (no application +query SQL, GraphQL roots or direct async-graphql dependency): + +```sh +cargo run --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features sqlite --example policy_projections +cargo test -p graphql-orm --locked --no-default-features --features sqlite --test projection_visibility +cargo test -p graphql-orm --locked --no-default-features --features postgres --test projection_visibility +``` + +The PostgreSQL command creates and removes its own labelled disposable container +using `tests/support/owned_postgres.rs`; it never consumes an application URL and +fails if infrastructure is unavailable. Regression views raise an error if the +excluded private-key expression is selected, proving the projection boundary. +The observer records actual bounded projection SELECTs without bind values. Projections and their methods are never added to GraphQL schemas. `private = true` is the only supported mode in this release; `private = false` is a compile error. Existing GraphQL field-policy diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 70ec98a7..d6e51d84 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -18,11 +18,11 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | -| `graphql-orm` | `0.35.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.35.1` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-ai` | `0.106.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.15.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.7.2` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | -| `graphql-orm-macros` | `0.35.0` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.35.1` | `crates/graphql-orm-macros` | `sqlite` | none | | `graphql-orm-operation-catalog` | `0.4.0` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | | `graphql-orm-router` | `0.5.3` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none |