diff --git a/CHANGELOG.md b/CHANGELOG.md index 543cc01b..7d22b4b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ 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.58.0 (unreleased) — Required policy bindings + +- Add an explicit entity policy-binding contract enforced by `schema_roots!` + before generated data operations, including count-only selections. +- Require matching entity, row and field policy providers; preserve existing + callback decisions and reject absent or unrelated bindings. +- Existing declarations remain unchanged unless the entity opts in. Public AI + projections require their AI binding by default. + ## 0.57.0 (2026-10-22) Companion macros crate: `graphql-orm-macros` **0.57.0**. diff --git a/Cargo.lock b/Cargo.lock index cfe10348..928ac084 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3113,7 +3113,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.57.0" +version = "0.58.0" dependencies = [ "agql-auth", "async-graphql", @@ -3147,7 +3147,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.109.1" +version = "0.110.0" dependencies = [ "agql-auth", "async-graphql", @@ -3217,7 +3217,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.57.0" +version = "0.58.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 0b23bef3..f7c33caa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ cynic-parser = { version = "=0.11.2", features = ["pretty"] } futures = "0.3" getrandom = "0.3" graphql-composition = "=0.12.2" -graphql-orm = { path = "crates/graphql-orm", version = "0.57.0", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.58.0", default-features = false } graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.16.1" } graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.10.0", default-features = false } graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.4.1" } diff --git a/MIGRATION.md b/MIGRATION.md index 8656ba1c..5386a08e 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -15,6 +15,19 @@ workspace release tag for every chosen workspace package and record its full commit from the release manifest. Neither the runtime nor macros crate is published to crates.io. +## 0.57.0 to 0.58.0: required policy bindings + +Runtime/macros advance together. An entity can declare +`#[graphql_orm(policy_binding = "owner.contract")]`. Its canonical `schema_roots!` +builder requires entity, row and field providers whose `supports_policy_binding` +methods acknowledge that contract; missing or unrelated providers reject all +operations before reads, including metadata/count-only selections. Provider +acknowledgement is a binding capability, never an authorization grant: each +normal current-principal, scope, entity, row and field decision still runs. +Configure all providers before building the schema. Entities without a binding +retain their existing authorization mode. No data or physical schema migration +is required. + ## 0.56.0 to 0.57.0: exact SQL Server MONEY reads Upgrade `graphql-orm` and `graphql-orm-macros` together to 0.57.0 using one diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index 9ed79e48..a684939c 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -10,6 +10,12 @@ supersedes: [] # Changelog +Public metadata entities require the `ai.public.metadata` policy binding on all +three providers. Compose them with `AiPublicEntityAccess::database()`: canonical +schema builders reject absent or unrelated providers before reads, including +count-only selections. Existing host binding capabilities remain delegated; +acknowledging a binding never grants row or field access. + All notable user-visible changes are recorded here. The crate follows Semantic Versioning and keeps migration instructions in [MIGRATION.md](MIGRATION.md). @@ -18,6 +24,11 @@ checkpoint facts. For the current workspace baseline and active gates, use the [implementation status](docs/implementation-status.md) and the central [AI production-readiness plan](../../docs/plans/active/ai-production-readiness/README.md). +## Unreleased - 0.110.0 + +- Add a draft generated-read integration for safe session/message/run/attachment/tool-policy metadata. Public projection types map read-only to AI-owned tables; raw persistence and protected content remain private. `AiPublicEntityAccess` requires current-principal resolution, exact tenant/owner and host scope/session Read+List decisions, and preserves additional host entity/row/field restrictions. Metadata fields are non-exportable; projection writes, migrations and backup ownership are unavailable. +- This does not complete issue #184: read-only generated notifications and child propagation remain unimplemented. Do not adopt this slice as entity-first alignment or replace protected-content/custody roots. No data or AI schema-module migration is required for the read projections. + ## Unreleased - 0.109.1 - Align with ORM/macros 0.56.0 and operation catalogue 0.4.1. A new test proves diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index 8c2956de..35e71111 100644 --- a/crates/graphql-orm-ai/Cargo.toml +++ b/crates/graphql-orm-ai/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-ai" -version = "0.109.1" +version = "0.110.0" edition = "2024" authors = ["Toby Martin "] description = "Project-agnostic AI agent runtime for graphql-orm applications" diff --git a/crates/graphql-orm-ai/MIGRATION.md b/crates/graphql-orm-ai/MIGRATION.md index efc134e2..99a93967 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -10,6 +10,12 @@ supersedes: [] # Migration Guide +Public metadata entities require the `ai.public.metadata` policy binding on all +three providers. Compose them with `AiPublicEntityAccess::database()`: canonical +schema builders reject absent or unrelated providers before reads, including +count-only selections. Existing host binding capabilities remain delegated; +acknowledging a binding never grants row or field access. + `graphql-orm-ai` is Git-distributed and is not published to crates.io. This guide is mandatory so Git consumers and disposable test deployments can track schema and API changes without guessing. @@ -19,6 +25,14 @@ they describe. For the current workspace baseline and active delivery gates, use [implementation status](docs/implementation-status.md) and the central [AI production-readiness plan](../../docs/plans/active/ai-production-readiness/README.md). +## 0.109.1 to 0.110.0: generated metadata read projections (draft) + +Import `public_entities::*` for `schema_roots!` composition and build the public schema with `AiPublicEntityAccess::new(original_database, ai_access_policy, current_principal_resolver).database()`. Keep the original database and `AiSchemaModule` as the private persistence/schema/backup authority. Public projections declare `external_read_only`, never own those tables, and cannot perform generated writes. Missing policies/principal bindings fail closed under the default ORM authorization mode; deliberately permissive databases are outside this integration contract. + +Metadata reads require both scope and session Read+List authority, exact current principal owner identity and exact tenant equality; tool-policy metadata requires scope Read+List. Existing host callbacks are additional restrictions, so hosts that match entity names must admit these projection types explicitly. Relations reauthorize the target and selected fields. Every exposed metadata field is non-exportable. The surface never opens protected messages, exposes credential/storage/upload references or runs custody actions. + +This draft is a read slice only. Generated read-only notifications and message-to-session/run propagation are still required by issue #184; existing notification and protected-content roots remain recorded upstream/custody exceptions. No data, physical schema or AI_SCHEMA_MODULE_VERSION migration is needed because no persistent entity/semantic changes occur. Do not claim completed entity-first alignment or publish a complete #184 resolution from this slice. + ## 0.109.0 to 0.109.1: ORM 0.56.0 alignment Adopt `graphql-orm-ai` 0.109.1 with ORM/macros 0.56.0 from one workspace diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index cb2bd993..5fd02906 100644 --- a/crates/graphql-orm-ai/README.md +++ b/crates/graphql-orm-ai/README.md @@ -10,6 +10,12 @@ supersedes: [] # graphql-orm-ai +Public metadata entities require the `ai.public.metadata` policy binding on all +three providers. Compose them with `AiPublicEntityAccess::database()`: canonical +schema builders reject absent or unrelated providers before reads, including +count-only selections. Existing host binding capabilities remain delegated; +acknowledging a binding never grants row or field access. + The current workspace selects published Auth `v0.22.1` at `86fabec09eea1eec0f675bc150e90e3809734567` for the finite signed-catalogue correction. Direct Auth consumers must select the same tag and refresh their @@ -196,6 +202,18 @@ turn's `with_capability_delivery` binding. Broker operations remain read-only; reviewed static bootstrap mutations retain current policy, one-shot approval where required, and ordinary resolver authorization. +## Draft generated metadata reads + +`public_entities::*` provides read-only session/message/run/attachment/tool-policy +metadata for `schema_roots!`. Bind `AiPublicEntityAccess` with the original database, +AI policy and current-principal resolver before building the public schema. Raw +persistence, protected content and custody remain private; every metadata field is +non-exportable. Keep `AiSchemaModule` for owned schema/backup custody. + +This is the read slice of upstream #184. Generated read-only notifications and +child propagation are incomplete; it is not a completed alignment or release gate. +See the [migration contract](MIGRATION.md#01091-to-01100-generated-metadata-read-projections-draft). + ## Install This active pre-release is Git-only. Pin one reviewed full monorepo revision diff --git a/crates/graphql-orm-ai/src/lib.rs b/crates/graphql-orm-ai/src/lib.rs index 7c534e31..903df347 100644 --- a/crates/graphql-orm-ai/src/lib.rs +++ b/crates/graphql-orm-ai/src/lib.rs @@ -22,6 +22,12 @@ compile_error!( mod access; #[cfg(any(feature = "sqlite", feature = "postgres"))] +pub mod public_entities; +#[cfg(any(feature = "sqlite", feature = "postgres"))] +mod public_entity_access; +#[cfg(any(feature = "sqlite", feature = "postgres"))] +pub use public_entity_access::AiPublicEntityAccess; +#[cfg(any(feature = "sqlite", feature = "postgres"))] mod agent_loop; mod approvals; mod attachments; diff --git a/crates/graphql-orm-ai/src/public_entities.rs b/crates/graphql-orm-ai/src/public_entities.rs new file mode 100644 index 00000000..e3d191d2 --- /dev/null +++ b/crates/graphql-orm-ai/src/public_entities.rs @@ -0,0 +1,459 @@ +//! Read-only, non-exportable metadata projections over AI-owned tables. +//! +//! Compose imported entities through `schema_roots!` using the database returned +//! by [`crate::AiPublicEntityAccess::database`]. These projections do not own schema, +//! migrations, backup custody or content disclosure. Continue registering +//! [`crate::AiSchemaModule`] for owned persistence. Generated notifications and +//! child propagation remain an explicit incomplete part of upstream issue #184. + +use graphql_orm::prelude::*; +use graphql_orm::uuid::Uuid; + +/// Read-only authorized session metadata; protected payloads remain private. +#[derive( + GraphQLEntity, + graphql_orm::async_graphql::SimpleObject, + GraphQLOperations, + GraphQLRelations, + Clone, + Debug, + serde::Serialize, + serde::Deserialize, +)] +#[graphql_orm(policy_binding = "ai.public.metadata")] +#[graphql_entity( + table = "graphql_orm_ai_sessions", + plural = "AiPublicSessions", + schema_policy = "external_read_only", + auth = "required", + read_policy = "ai.public.read", + write_policy = "ai.public.never_write", + backup = false +)] +#[graphql(complex, guard = "crate::public_entity_access::PublicMetadataFieldGuard::new(self)")] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct AiPublicSession { + /// Authorized id metadata. + #[primary_key] + #[sortable] + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "uuid")] + pub id: Uuid, + /// Authorized scope kind metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub scope_kind: String, + /// Authorized scope id metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub scope_id: String, + /// Authorized tenant id metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub tenant_id: Option, + /// Authorized title metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub title: String, + /// Authorized state metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub state: String, + /// Authorized stream head metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "number")] + pub stream_head: i64, + /// Authorized last activity at metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "number")] + pub last_activity_at: i64, + /// Authorized archived at metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "number")] + pub archived_at: Option, + /// Authorized related messages; propagation awaits read-only notification support. + #[graphql(skip)] + #[serde(skip)] + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[relation( + target = "AiPublicMessage", + from = "id", + to = "session_id", + multiple, + emit_fk = false, + propagate_change = "none" + )] + pub messages: Vec, + /// Authorized related runs; propagation awaits read-only notification support. + #[graphql(skip)] + #[serde(skip)] + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[relation( + target = "AiPublicRun", + from = "id", + to = "session_id", + multiple, + emit_fk = false, + propagate_change = "none" + )] + pub runs: Vec, + /// Authorized related attachments; propagation awaits read-only notification support. + #[graphql(skip)] + #[serde(skip)] + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[relation( + target = "AiPublicAttachment", + from = "id", + to = "session_id", + multiple, + emit_fk = false, + propagate_change = "none" + )] + pub attachments: Vec, +} + +/// Read-only authorized message metadata; protected payloads remain private. +#[derive( + GraphQLEntity, + graphql_orm::async_graphql::SimpleObject, + GraphQLOperations, + GraphQLRelations, + Clone, + Debug, + serde::Serialize, + serde::Deserialize, +)] +#[graphql_orm(policy_binding = "ai.public.metadata")] +#[graphql_entity( + table = "graphql_orm_ai_messages", + plural = "AiPublicMessages", + schema_policy = "external_read_only", + auth = "required", + read_policy = "ai.public.read", + write_policy = "ai.public.never_write", + backup = false +)] +#[graphql(complex, guard = "crate::public_entity_access::PublicMetadataFieldGuard::new(self)")] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct AiPublicMessage { + /// Authorized id metadata. + #[primary_key] + #[sortable] + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "uuid")] + pub id: Uuid, + /// Authorized session id metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "uuid")] + pub session_id: Uuid, + /// Authorized sequence metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "number")] + pub sequence: i64, + /// Authorized message role metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub message_role: String, + /// Authorized run id metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "uuid")] + pub run_id: Option, + /// Authorized block count metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "number")] + pub block_count: i64, + /// Authorized completion state metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub completion_state: String, + /// Authorized created at metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "number")] + pub created_at: i64, + /// Authorized finalized at metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "number")] + pub finalized_at: Option, + /// Authorized content purged at metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "number")] + pub content_purged_at: Option, + /// Authorized related session; propagation awaits read-only notification support. + #[graphql(skip)] + #[serde(skip)] + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[relation( + target = "AiPublicSession", + from = "session_id", + to = "id", + emit_fk = false, + propagate_change = "none" + )] + pub session: Option, + /// Authorized related run; propagation awaits read-only notification support. + #[graphql(skip)] + #[serde(skip)] + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[relation( + target = "AiPublicRun", + from = "run_id", + to = "id", + emit_fk = false, + propagate_change = "none" + )] + pub run: Option, +} + +/// Read-only authorized run metadata; protected payloads remain private. +#[derive( + GraphQLEntity, + graphql_orm::async_graphql::SimpleObject, + GraphQLOperations, + GraphQLRelations, + Clone, + Debug, + serde::Serialize, + serde::Deserialize, +)] +#[graphql_orm(policy_binding = "ai.public.metadata")] +#[graphql_entity( + table = "graphql_orm_ai_runs", + plural = "AiPublicRuns", + schema_policy = "external_read_only", + auth = "required", + read_policy = "ai.public.read", + write_policy = "ai.public.never_write", + backup = false +)] +#[graphql(complex, guard = "crate::public_entity_access::PublicMetadataFieldGuard::new(self)")] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct AiPublicRun { + /// Authorized id metadata. + #[primary_key] + #[sortable] + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "uuid")] + pub id: Uuid, + /// Authorized session id metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "uuid")] + pub session_id: Uuid, + /// Authorized input message id metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "uuid")] + pub input_message_id: Uuid, + /// Authorized state metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub state: String, + /// Authorized error code metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub error_code: Option, + /// Authorized created at metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "number")] + pub created_at: i64, + /// Authorized row version metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "number")] + pub row_version: i64, + /// Authorized related session; propagation awaits read-only notification support. + #[graphql(skip)] + #[serde(skip)] + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[relation( + target = "AiPublicSession", + from = "session_id", + to = "id", + emit_fk = false, + propagate_change = "none" + )] + pub session: Option, + /// Authorized related messages; propagation awaits read-only notification support. + #[graphql(skip)] + #[serde(skip)] + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[relation( + target = "AiPublicMessage", + from = "id", + to = "run_id", + multiple, + emit_fk = false, + propagate_change = "none" + )] + pub messages: Vec, +} + +/// Read-only authorized attachment metadata; protected payloads remain private. +#[derive( + GraphQLEntity, + graphql_orm::async_graphql::SimpleObject, + GraphQLOperations, + GraphQLRelations, + Clone, + Debug, + serde::Serialize, + serde::Deserialize, +)] +#[graphql_orm(policy_binding = "ai.public.metadata")] +#[graphql_entity( + table = "graphql_orm_ai_attachments", + plural = "AiPublicAttachments", + schema_policy = "external_read_only", + auth = "required", + read_policy = "ai.public.read", + write_policy = "ai.public.never_write", + backup = false +)] +#[graphql(complex, guard = "crate::public_entity_access::PublicMetadataFieldGuard::new(self)")] +#[cfg_attr(feature = "graphql-case-pascal", graphql(rename_fields = "PascalCase"))] +pub struct AiPublicAttachment { + /// Authorized id metadata. + #[primary_key] + #[sortable] + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "uuid")] + pub id: Uuid, + /// Authorized session id metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "uuid")] + pub session_id: Uuid, + /// Authorized message id metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "uuid")] + pub message_id: Option, + /// Authorized safe filename metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub safe_filename: String, + /// Authorized declared mime metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub declared_mime: Option, + /// Authorized detected mime metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub detected_mime: Option, + /// Authorized byte count metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "number")] + pub byte_count: Option, + /// Authorized quarantine state metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub quarantine_state: String, + /// Authorized scan state metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub scan_state: String, + /// Authorized processing state metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub processing_state: String, + /// Authorized rejection code metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub rejection_code: Option, + /// Authorized created at metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "number")] + pub created_at: i64, + /// Authorized finalized at metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "number")] + pub finalized_at: Option, + /// Authorized deleted at metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "number")] + pub deleted_at: Option, + /// Authorized related session; propagation awaits read-only notification support. + #[graphql(skip)] + #[serde(skip)] + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[relation( + target = "AiPublicSession", + from = "session_id", + to = "id", + emit_fk = false, + propagate_change = "none" + )] + pub session: Option, + /// Authorized related message; propagation awaits read-only notification support. + #[graphql(skip)] + #[serde(skip)] + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[relation( + target = "AiPublicMessage", + from = "message_id", + to = "id", + emit_fk = false, + propagate_change = "none" + )] + pub message: Option, +} + +/// Read-only authorized toolpolicy metadata; protected payloads remain private. +#[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[graphql_orm(policy_binding = "ai.public.metadata")] +#[graphql_entity( + table = "graphql_orm_ai_tool_policies", + plural = "AiPublicToolPolicies", + schema_policy = "external_read_only", + auth = "required", + read_policy = "ai.public.read", + write_policy = "ai.public.never_write", + backup = false +)] +pub struct AiPublicToolPolicy { + /// Authorized id metadata. + #[primary_key] + #[sortable] + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "uuid")] + pub id: Uuid, + /// Authorized scope kind metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub scope_kind: String, + /// Authorized scope id metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub scope_id: String, + /// Authorized tenant id metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub tenant_id: Option, + /// Authorized tool id metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub tool_id: String, + /// Authorized tool fingerprint metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub tool_fingerprint: String, + /// Authorized enabled metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "boolean")] + pub enabled: bool, + /// Authorized maximum maturity metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub maximum_maturity: String, + /// Authorized approval rule metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "string")] + pub approval_rule: String, + /// Authorized maximum calls metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "number")] + pub maximum_calls: i64, + /// Authorized maximum output bytes metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "number")] + pub maximum_output_bytes: i64, + /// Authorized updated at metadata. + #[graphql_orm(non_exportable, read_policy = "ai.public.field")] + #[filterable(type = "number")] + pub updated_at: i64, +} diff --git a/crates/graphql-orm-ai/src/public_entity_access.rs b/crates/graphql-orm-ai/src/public_entity_access.rs new file mode 100644 index 00000000..3518a248 --- /dev/null +++ b/crates/graphql-orm-ai/src/public_entity_access.rs @@ -0,0 +1,434 @@ +//! Current-principal policy binding for generated AI metadata reads. + +use crate::public_entities::*; +use crate::{AiAccessPolicy, AiScope, AiSessionAction, AiSessionId}; +use agql_auth::{AuthPrincipal, CurrentPrincipalResolver}; +use async_graphql::Context; +use graphql_orm::graphql::{ + auth::AuthorizationMode, + errors::OrmPublicError, + orm::{ + DefaultWriteBackend, EntityAccessKind, EntityAccessSurface, EntityPolicy, FieldPolicy, + ReadVisibility, RowPolicy, + }, +}; +use graphql_orm::{db::Database, futures::future::BoxFuture}; +use std::{any::Any, sync::Arc}; + +/// Preserve current ORM decisions for SimpleObject scalar fields composed +/// with generated ComplexObject relations. +pub(crate) struct PublicMetadataFieldGuard<'a, T> { record: &'a T } +impl<'a, T> PublicMetadataFieldGuard<'a, T> { + pub(crate) fn new(record: &'a T) -> Self { Self { record } } +} +impl async_graphql::Guard for PublicMetadataFieldGuard<'_, T> +where T: graphql_orm::graphql::orm::Entity + Send + Sync + 'static, +{ + async fn check(&self, context: &Context<'_>) -> async_graphql::Result<()> { + let denied = || OrmPublicError::forbidden().into_graphql_error(); + let database = context.data::>()?; + if database.authorization_mode() != AuthorizationMode::ExplicitPolicyForAllExposedOperations + || !database.entity_policy().is_some_and(|policy| policy.supports_policy_binding("ai.public.metadata")) + || !database.row_policy().is_some_and(|policy| policy.supports_policy_binding("ai.public.metadata")) + || !database.field_policy().is_some_and(|policy| policy.supports_policy_binding("ai.public.metadata")) { + return Err(denied()); + } + let surface = if context.query_env.operation.node.ty == async_graphql::parser::types::OperationType::Subscription { + EntityAccessSurface::GraphqlSubscription + } else { EntityAccessSurface::GraphqlQuery }; + database.ensure_entity_access(Some(context), T::entity_name(), T::metadata().read_policy, EntityAccessKind::Read, surface).await?; + if !database.can_read_row(Some(context), T::entity_name(), T::metadata().read_policy, surface, self.record).await? { + return Err(denied()); + } + let field = T::repository_field_policies().iter() + .find(|field| field.api_name == context.item.node.name.node.as_str()).ok_or_else(denied)?; + database.ensure_readable_field(context, T::entity_name(), field.api_name, field.read_policy, Some(self.record)).await + } +} + +/// Mandatory authorization binding for generated, read-only AI metadata entities. +/// +/// This permits no content opening, writes, schema ownership or provider disclosure. +/// Keep the original database for AI-owned services and schema modules; compose the +/// public entities with [`Self::database`]. Existing host policies are narrowed, +/// never replaced with an unconditional allow. Missing principal or resolver and +/// policy failures deny access. Metadata reads require scope/session Read and List; +/// the scope tool-policy projection requires the same two scope decisions. +#[derive(Clone)] +pub struct AiPublicEntityAccess { + repository: Database, + policy: Arc, + principals: Arc, +} + +impl AiPublicEntityAccess { + /// Binds the private repository, host AI access policy and current-principal resolver. + pub fn new( + repository: Database, + policy: Arc, + principals: Arc, + ) -> Self { + Self { + repository, + policy, + principals, + } + } + + /// Returns a database clone that enforces generated AI metadata authorization. + /// + /// Existing entity, row and field callbacks remain additional restrictions. + /// Non-AI operations need their own host callbacks. Public metadata entities + /// remain read-only even if a host attempts ordinary repository writes. + pub fn database(&self) -> Database { + let mut database = self.repository.clone(); + let guard = Guard { + access: self.clone(), + entity: database.entity_policy().cloned(), + row: database.row_policy().cloned(), + field: database.field_policy().cloned(), + }; + database.set_authorization_mode(AuthorizationMode::ExplicitPolicyForAllExposedOperations); + database.set_entity_policy(guard.clone()); + database.set_row_policy(guard.clone()); + database.set_field_policy(guard); + database + } + + async fn current(&self, context: &Context<'_>) -> async_graphql::Result { + let observed = agql_auth::principal_from_ctx(context)?; + let reference = observed.reference(); + let current = self + .principals + .resolve(&reference) + .await + .map_err(|_| OrmPublicError::forbidden().into_graphql_error())?; + if !reference.matches(current.principal()) { + return Err(OrmPublicError::forbidden().into_graphql_error()); + } + Ok(current.into_principal()) + } + + async fn scope(&self, principal: &AuthPrincipal, scope: &AiScope) -> bool { + if principal.reference().tenant_id != scope.tenant_id { + return false; + } + for action in [AiSessionAction::Read, AiSessionAction::List] { + if !self + .policy + .can_access_scope(principal, scope, action) + .await + .is_allowed() + { + return false; + } + } + true + } + + async fn session( + &self, + principal: &AuthPrincipal, + id: graphql_orm::uuid::Uuid, + ) -> async_graphql::Result { + let Some(session) = crate::persistence::AiSessionRecord::find_by_id(&self.repository, &id) + .await + .map_err(|_| OrmPublicError::forbidden().into_graphql_error())? + else { + return Ok(false); + }; + let (kind, subject) = crate::orm_sessions::principal_identity(principal); + if session.owner_principal_kind != kind + || session.owner_subject != subject + || session.deleted_at.is_some() + { + return Ok(false); + } + for action in [AiSessionAction::Read, AiSessionAction::List] { + if !self + .policy + .can_access_session(principal, AiSessionId(id), action) + .await + .is_allowed() + { + return Ok(false); + } + } + Ok(self + .scope(principal, &crate::orm_sessions::record_scope(&session)) + .await) + } + + async fn row( + &self, + context: &Context<'_>, + entity: &str, + row: &(dyn Any + Send + Sync), + ) -> async_graphql::Result { + let principal = self.current(context).await?; + let session = match entity { + "AiPublicSession" => row.downcast_ref::().map(|row| row.id), + "AiPublicMessage" => row + .downcast_ref::() + .map(|row| row.session_id), + "AiPublicRun" => row.downcast_ref::().map(|row| row.session_id), + "AiPublicAttachment" => row + .downcast_ref::() + .filter(|row| row.deleted_at.is_none()) + .map(|row| row.session_id), + "AiPublicToolPolicy" => { + let Some(row) = row.downcast_ref::() else { + return Ok(false); + }; + return Ok(self + .scope( + &principal, + &AiScope { + kind: row.scope_kind.clone(), + id: row.scope_id.clone(), + tenant_id: row.tenant_id.clone(), + }, + ) + .await); + } + _ => None, + }; + if entity == "AiPublicAttachment" { + let Some(projection) = row.downcast_ref::() else { + return Ok(false); + }; + let Some(stored) = crate::persistence::AiAttachmentRecord::find_by_id( + &self.repository, + &projection.id, + ) + .await + .map_err(|_| OrmPublicError::forbidden().into_graphql_error())? + else { + return Ok(false); + }; + let (kind, subject) = crate::orm_sessions::principal_identity(&principal); + if stored.session_id != projection.session_id + || stored.owner_principal_kind != kind + || stored.owner_subject != subject + || stored.deleted_at.is_some() + { + return Ok(false); + } + } + match session { + Some(id) => self.session(&principal, id).await, + None => Ok(false), + } + } +} + +fn public(entity: &str) -> bool { + matches!( + entity, + "AiPublicSession" + | "AiPublicMessage" + | "AiPublicRun" + | "AiPublicAttachment" + | "AiPublicToolPolicy" + ) +} + +#[derive(Clone)] +struct Guard { + access: AiPublicEntityAccess, + entity: Option>>, + row: Option>>, + field: Option>>, +} + +impl EntityPolicy for Guard { + fn supports_policy_binding(&self, binding: &str) -> bool { + binding == "ai.public.metadata" || self.entity.as_ref().is_some_and(|policy| policy.supports_policy_binding(binding)) + } + fn can_access_entity<'a>( + &'a self, + context: Option<&'a Context<'_>>, + database: &'a Database, + entity: &'static str, + policy: Option<&'static str>, + kind: EntityAccessKind, + surface: EntityAccessSurface, + ) -> BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { + if public(entity) { + if kind != EntityAccessKind::Read || policy != Some("ai.public.read") { + return Ok(false); + } + let Some(context) = context else { + return Ok(false); + }; + self.access.current(context).await?; + if let Some(delegate) = &self.entity { + return delegate + .can_access_entity(Some(context), database, entity, policy, kind, surface) + .await; + } + return Ok(true); + } + match &self.entity { + Some(delegate) => { + delegate + .can_access_entity(context, database, entity, policy, kind, surface) + .await + } + None => Ok(false), + } + }) + } +} + +impl RowPolicy for Guard { + fn supports_policy_binding(&self, binding: &str) -> bool { + binding == "ai.public.metadata" || self.row.as_ref().is_some_and(|policy| policy.supports_policy_binding(binding)) + } + fn read_visibility<'a>( + &'a self, + context: Option<&'a Context<'_>>, + database: &'a Database, + entity: &'static str, + policy: Option<&'static str>, + surface: EntityAccessSurface, + ) -> BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { + // A complete SQL predicate must never skip the AI row authorization callback. + if public(entity) { + return Ok(ReadVisibility::CallbackOnly); + } + match &self.row { + Some(delegate) => { + delegate + .read_visibility(context, database, entity, policy, surface) + .await + } + None => Ok(ReadVisibility::CallbackOnly), + } + }) + } + fn can_read_row<'a>( + &'a self, + context: Option<&'a Context<'_>>, + database: &'a Database, + entity: &'static str, + policy: Option<&'static str>, + surface: EntityAccessSurface, + row: &'a (dyn Any + Send + Sync), + ) -> BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { + if public(entity) { + let Some(context) = context else { + return Ok(false); + }; + if !self.access.row(context, entity, row).await? { + return Ok(false); + } + if let Some(delegate) = &self.row { + return delegate + .can_read_row(Some(context), database, entity, policy, surface, row) + .await; + } + return Ok(true); + } + match &self.row { + Some(delegate) => { + delegate + .can_read_row(context, database, entity, policy, surface, row) + .await + } + None => Ok(false), + } + }) + } + fn can_write_row<'a>( + &'a self, + context: Option<&'a Context<'_>>, + database: &'a Database, + entity: &'static str, + policy: Option<&'static str>, + surface: EntityAccessSurface, + row: &'a (dyn Any + Send + Sync), + ) -> BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { + if public(entity) { + return Ok(false); + } + match &self.row { + Some(delegate) => { + delegate + .can_write_row(context, database, entity, policy, surface, row) + .await + } + None => Ok(false), + } + }) + } +} + +impl FieldPolicy for Guard { + fn supports_policy_binding(&self, binding: &str) -> bool { + binding == "ai.public.metadata" || self.field.as_ref().is_some_and(|policy| policy.supports_policy_binding(binding)) + } + fn can_read_field<'a>( + &'a self, + context: &'a Context<'_>, + database: &'a Database, + entity: &'static str, + field: &'static str, + policy: Option<&'static str>, + row: Option<&'a (dyn Any + Send + Sync)>, + ) -> BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { + if public(entity) { + if let Some(row) = row { + if !self.access.row(context, entity, row).await? { + return Ok(false); + } + } else { + self.access.current(context).await?; + } + if let Some(delegate) = &self.field { + return delegate + .can_read_field(context, database, entity, field, policy, row) + .await; + } + return Ok(true); + } + match &self.field { + Some(delegate) => { + delegate + .can_read_field(context, database, entity, field, policy, row) + .await + } + None => Ok(false), + } + }) + } + fn can_write_field<'a>( + &'a self, + context: &'a Context<'_>, + database: &'a Database, + entity: &'static str, + field: &'static str, + policy: Option<&'static str>, + row: Option<&'a (dyn Any + Send + Sync)>, + value: Option<&'a (dyn Any + Send + Sync)>, + ) -> BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { + if public(entity) { + return Ok(false); + } + match &self.field { + Some(delegate) => { + delegate + .can_write_field(context, database, entity, field, policy, row, value) + .await + } + None => Ok(false), + } + }) + } +} diff --git a/crates/graphql-orm-ai/tests/public_entities.rs b/crates/graphql-orm-ai/tests/public_entities.rs new file mode 100644 index 00000000..b80f9afa --- /dev/null +++ b/crates/graphql-orm-ai/tests/public_entities.rs @@ -0,0 +1,288 @@ +#![cfg(feature = "sqlite")] + +use agql_auth::{ + AccessTokenMetadata, AuthPrincipal, AuthUser, CurrentPrincipalResolver, PrincipalReference, + ResolvedPrincipal, SessionContext, +}; +use async_trait::async_trait; +use graphql_orm::{async_graphql::Request, graphql::orm::OrmSchemaModule, prelude::*}; +use graphql_orm_ai::{public_entities::*, *}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use time::OffsetDateTime; +use uuid::Uuid; + +schema_roots! { + query_custom_ops: [], + generated_mutations: "none", + entities: [AiPublicSession, AiPublicMessage, AiPublicRun, AiPublicAttachment, AiPublicToolPolicy], +} + +struct Access(AtomicBool); +#[async_trait] +impl AiAccessPolicy for Access { + async fn can_access_scope( + &self, + _: &AuthPrincipal, + _: &AiScope, + _: AiSessionAction, + ) -> AiAccessDecision { + if self.0.load(Ordering::SeqCst) { + AiAccessDecision::allow("fixture", "1") + } else { + AiAccessDecision::deny("fixture", "1") + } + } + async fn can_access_session( + &self, + _: &AuthPrincipal, + _: AiSessionId, + _: AiSessionAction, + ) -> AiAccessDecision { + if self.0.load(Ordering::SeqCst) { + AiAccessDecision::allow("fixture", "1") + } else { + AiAccessDecision::deny("fixture", "1") + } + } +} +struct Protection; +#[async_trait] +impl AiContentProtectionPolicyResolver for Protection { + async fn resolve( + &self, + _: &AuthPrincipal, + scope: &AiScope, + ) -> Result { + Ok(AiContentProtectionPolicy { + scope: scope.clone(), + mode: AiContentProtectionMode::DatabaseManaged, + key_policy_reference: None, + version: 1, + ready: true, + }) + } +} +struct Current { + principal: AuthPrincipal, + revoked: AtomicBool, +} +#[async_trait] +impl CurrentPrincipalResolver for Current { + async fn resolve( + &self, + reference: &PrincipalReference, + ) -> agql_auth::AuthResult { + if self.revoked.load(Ordering::SeqCst) { + return Err(agql_auth::AuthError::TokenRevoked); + } + ResolvedPrincipal::new( + reference.clone(), + self.principal.clone(), + OffsetDateTime::now_utc(), + ) + } +} +fn principal(subject: &str, tenant: &str) -> AuthPrincipal { + AuthPrincipal::User(AuthUser { + user_id: subject.into(), + session_id: Uuid::new_v4(), + roles: vec![], + scopes: vec![], + session: SessionContext::default(), + token_claims: AccessTokenMetadata { + tenant_id: Some(tenant.into()), + ..Default::default() + }, + }) +} +async fn fixture() -> (Database, AuthPrincipal, Uuid, Arc) { + let database = Database::::connect_sqlite("sqlite::memory:") + .await + .unwrap(); + let module = AiSchemaModule; + let plan = database + .schema() + .plan_migration_to_entities( + "public-entities-test", + "disposable AI metadata fixture", + module.entities(), + ) + .await + .unwrap(); + database + .schema() + .apply_migration(&plan, ApplyOptions::default()) + .await + .unwrap(); + let owner = principal("owner", "tenant-1"); + let policy = Arc::new(Access(AtomicBool::new(true))); + let service = OrmAiSessionService::new( + database.clone(), + policy.clone(), + Arc::new(Protection), + Arc::new(DatabaseManagedContentProtector), + ); + let session = service + .create_session( + &owner, + CreateAiSessionInput { + execution_selection: None, + scope: AiScopeInput { + kind: "workspace".into(), + id: "fixture".into(), + tenant_id: Some("tenant-1".into()), + }, + title: Some("Owner session".into()), + }, + ) + .await + .unwrap(); + (database, owner, session.id, policy) +} +fn request(actor: &AuthPrincipal, id: Uuid) -> Request { + Request::new(format!("{{ aiPublicSession(id: \"{id}\") {{ id title messages {{ edges {{ node {{ id }} }} }} }} }}")) + .data(actor.clone()).data(AuthSubject::new(actor.subject())) +} + +#[tokio::test] +async fn generated_reads_and_relations_preserve_owner_and_current_policy() { + let (database, owner, id, policy) = fixture().await; + let current = Arc::new(Current { + principal: owner.clone(), + revoked: AtomicBool::new(false), + }); + let access = AiPublicEntityAccess::new(database, policy.clone(), current.clone()); + let schema = schema_builder(access.database()).finish(); + let response = schema.execute(request(&owner, id)).await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data.into_json().unwrap()["aiPublicSession"]["title"], + "Owner session" + ); + policy.0.store(false, Ordering::SeqCst); + let response = schema.execute(request(&owner, id)).await; + assert!(!format!("{:?}", response.data).contains("Owner session")); + policy.0.store(true, Ordering::SeqCst); + current.revoked.store(true, Ordering::SeqCst); + let response = schema.execute(request(&owner, id)).await; + assert!(!response.errors.is_empty()); + assert!(!format!("{:?}", response.data).contains("Owner session")); +} + +#[tokio::test] +async fn owner_and_tenant_boundaries_do_not_depend_on_a_permissive_scope_policy() { + let (database, _, id, policy) = fixture().await; + for actor in [ + principal("stranger", "tenant-1"), + principal("owner", "tenant-2"), + ] { + let current = Arc::new(Current { + principal: actor.clone(), + revoked: AtomicBool::new(false), + }); + let access = AiPublicEntityAccess::new(database.clone(), policy.clone(), current); + let response = schema_builder(access.database()) + .finish() + .execute(request(&actor, id)) + .await; + assert!(!format!("{:?}", response.data).contains("Owner session")); + } +} + +#[tokio::test] +async fn missing_binding_fails_closed_and_schema_never_exposes_custody_columns_or_writes() { + let (database, owner, id, _) = fixture().await; + let schema = schema_builder(database).finish(); + assert!(!schema.execute(request(&owner, id)).await.errors.is_empty()); + let sdl = schema.sdl(); + for name in [ + "protectedPreview", + "protectedPayload", + "uploadTokenHash", + "quarantineBlobReference", + "blobReference", + "principalReference", + "leaseGeneration", + "latestCheckpointId", + "constraints", + "createAiPublic", + "updateAiPublic", + "deleteAiPublic", + ] { + assert!( + !sdl.contains(name), + "unexpected custody/write exposure: {name}" + ); + } + assert!( + AiSchemaModule + .entities() + .iter() + .all(|entity| !entity.entity_name.starts_with("AiPublic")) + ); + for entity in graphql_orm_entity_metadata() { + assert_eq!(entity.schema_policy, Some(SchemaPolicy::ExternalReadOnly)); + assert!(!entity.backup_enabled); + } +} + +struct DenyPublicTitle; +impl FieldPolicy for DenyPublicTitle { + fn can_read_field<'a>(&'a self, _: &'a async_graphql::Context<'_>, _: &'a Database, _: &'static str, field: &'static str, _: Option<&'static str>, _: Option<&'a (dyn std::any::Any + Send + Sync)>) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { Ok(field != "title") }) + } + fn can_write_field<'a>(&'a self, _: &'a async_graphql::Context<'_>, _: &'a Database, _: &'static str, _: &'static str, _: Option<&'static str>, _: Option<&'a (dyn std::any::Any + Send + Sync)>, _: Option<&'a (dyn std::any::Any + Send + Sync)>) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(false) }) + } +} +#[tokio::test] +async fn scalar_projection_composition_retains_host_field_denial() { + let (mut database, owner, id, policy) = fixture().await; + database.set_field_policy(DenyPublicTitle); + let current = Arc::new(Current { principal: owner.clone(), revoked: AtomicBool::new(false) }); + let access = AiPublicEntityAccess::new(database, policy, current); + let response = schema_builder(access.database()).finish().execute(request(&owner, id)).await; + assert!(!response.errors.is_empty()); + assert!(!format!("{:?}", response.data).contains("Owner session")); +} + +#[derive(Clone)] +struct UnboundAllow; +impl EntityPolicy for UnboundAllow { + fn can_access_entity<'a>(&'a self, _: Option<&'a async_graphql::Context<'_>>, _: &'a Database, _: &'static str, _: Option<&'static str>, _: EntityAccessKind, _: EntityAccessSurface) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { Box::pin(async { Ok(true) }) } +} +impl RowPolicy for UnboundAllow { + fn can_read_row<'a>(&'a self, _: Option<&'a async_graphql::Context<'_>>, _: &'a Database, _: &'static str, _: Option<&'static str>, _: EntityAccessSurface, _: &'a (dyn std::any::Any + Send + Sync)) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { Box::pin(async { Ok(true) }) } + fn can_write_row<'a>(&'a self, _: Option<&'a async_graphql::Context<'_>>, _: &'a Database, _: &'static str, _: Option<&'static str>, _: EntityAccessSurface, _: &'a (dyn std::any::Any + Send + Sync)) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { Box::pin(async { Ok(true) }) } +} +impl FieldPolicy for UnboundAllow { + fn can_read_field<'a>(&'a self, _: &'a async_graphql::Context<'_>, _: &'a Database, _: &'static str, _: &'static str, _: Option<&'static str>, _: Option<&'a (dyn std::any::Any + Send + Sync)>) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { Box::pin(async { Ok(true) }) } + fn can_write_field<'a>(&'a self, _: &'a async_graphql::Context<'_>, _: &'a Database, _: &'static str, _: &'static str, _: Option<&'static str>, _: Option<&'a (dyn std::any::Any + Send + Sync)>, _: Option<&'a (dyn std::any::Any + Send + Sync)>) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { Box::pin(async { Ok(true) }) } +} +struct BindingReadObserver(Arc); +impl ReadQueryObserver for BindingReadObserver { + fn on_read(&self, _: &str, _: usize) { self.0.fetch_add(1, Ordering::SeqCst); } +} +#[tokio::test] +async fn count_only_reads_require_named_bindings_before_database_access() { + let (database, owner, _, policy) = fixture().await; + let count_request = || Request::new("{ aiPublicSessions(page: {limit: 1}) { pageInfo { totalCount } } }").data(owner.clone()).data(AuthSubject::new(owner.subject())); + let reads = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + for arbitrary_providers in [false, true] { + let mut unbound = database.clone().with_read_query_observer(BindingReadObserver(reads.clone())); + if arbitrary_providers { unbound.set_entity_policy(UnboundAllow); unbound.set_row_policy(UnboundAllow); unbound.set_field_policy(UnboundAllow); } + let schema = schema_builder(unbound).finish(); + assert!(schema.sdl().contains("aiPublicSessions(")); + let response = schema.execute(count_request()).await; + assert!(!response.errors.is_empty(), "missing or unrelated providers must not release counts"); + assert_eq!(reads.load(Ordering::SeqCst), 0); + } + let current = Arc::new(Current { principal: owner.clone(), revoked: AtomicBool::new(false) }); + let access = AiPublicEntityAccess::new(database, policy, current); + let response = schema_builder(access.database()).finish().execute(count_request()).await; + assert!(response.errors.is_empty(), "valid count query with bindings: {:?}", response.errors); + assert_eq!(response.data.into_json().unwrap()["aiPublicSessions"]["pageInfo"]["totalCount"], 1); +} diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index e7ca272f..02b043d4 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.57.0" +version = "0.58.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 7ac6c912..020b1924 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -10,6 +10,12 @@ supersedes: [] # `graphql-orm-macros` +Version 0.58.0 adds optional `#[graphql_orm(policy_binding = "owner.contract")]`. +Canonical schema builders require matching entity, row and field providers and +strict authorization before generated operations, including counts. Unbound or +unrelated providers fail closed. See the +[migration guide](../../MIGRATION.md#0570-to-0580-required-policy-bindings). + Version 0.57.0 adds the SQL Server-only `#[graphql_orm(mssql_money)]` field annotation and identifier-safe exact read projections for native `MONEY`. It pairs with runtime 0.57.0; see the diff --git a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock index 5f29993d..fd0a9df1 100644 --- a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock +++ b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock @@ -1049,7 +1049,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.57.0" +version = "0.58.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1076,7 +1076,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.57.0" +version = "0.58.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock b/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock index 480f87fd..c72ad0a2 100644 --- a/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock +++ b/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock @@ -1110,7 +1110,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.57.0" +version = "0.58.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1152,7 +1152,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.57.0" +version = "0.58.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm-macros/src/entity.rs b/crates/graphql-orm-macros/src/entity.rs index b3932b03..ec3f8655 100644 --- a/crates/graphql-orm-macros/src/entity.rs +++ b/crates/graphql-orm-macros/src/entity.rs @@ -44,6 +44,7 @@ pub(crate) struct EntityMetadata { pub(crate) graphql_rename_fields: Option, pub(crate) rls: Option, pub(crate) compose_complex_object: bool, + pub(crate) policy_binding: Option, /// Declared Apollo Federation v2 entity keys, in declaration order. pub(crate) federation_keys: Vec, } @@ -476,6 +477,10 @@ pub(crate) fn parse_entity_metadata(attrs: &[syn::Attribute]) -> syn::Result = #policy_binding; fn entity_name() -> &'static str { #struct_name_str } @@ -5379,6 +5387,7 @@ fn generate_entity_impl( } impl ::graphql_orm::graphql::orm::Entity for #struct_name { + const REQUIRED_POLICY_BINDING: Option<&'static str> = #policy_binding; fn entity_name() -> &'static str { #struct_name_str } diff --git a/crates/graphql-orm-macros/src/schema_roots.rs b/crates/graphql-orm-macros/src/schema_roots.rs index 44405ae7..e8825fea 100644 --- a/crates/graphql-orm-macros/src/schema_roots.rs +++ b/crates/graphql-orm-macros/src/schema_roots.rs @@ -130,6 +130,9 @@ pub(crate) fn expand(input: TokenStream) -> TokenStream { quote! { #name } }) .collect(); + let policy_binding_items = entities.iter().map(|entity| quote! { + <#entity as ::graphql_orm::graphql::orm::Entity>::REQUIRED_POLICY_BINDING + }).collect::>(); let operation_metadata_groups = entities .iter() .map(|entity| { @@ -353,9 +356,10 @@ pub(crate) fn expand(input: TokenStream) -> TokenStream { } pub fn schema_builder_with_limits( - database: ::graphql_orm::db::Database<#backend_marker>, + mut database: ::graphql_orm::db::Database<#backend_marker>, limits: ::graphql_orm::graphql::orm::SchemaLimits, ) -> ::graphql_orm::async_graphql::SchemaBuilder { + database.require_policy_bindings(&[#(#policy_binding_items),*]); let builder = ::graphql_orm::async_graphql::Schema::build( QueryRoot::default(), ::graphql_orm::async_graphql::EmptyMutation, @@ -511,9 +515,10 @@ pub(crate) fn expand(input: TokenStream) -> TokenStream { } pub fn schema_builder_with_limits( - database: ::graphql_orm::db::Database<#backend_marker>, + mut database: ::graphql_orm::db::Database<#backend_marker>, limits: ::graphql_orm::graphql::orm::SchemaLimits, ) -> ::graphql_orm::async_graphql::SchemaBuilder { + database.require_policy_bindings(&[#(#policy_binding_items),*]); let builder = ::graphql_orm::async_graphql::Schema::build( QueryRoot::default(), #mutation_root_value, diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index 46c1672b..12339f34 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.57.0" +version = "0.58.0" edition = "2024" description = "Runtime support crate for graphql-orm-macros" license = "MIT" @@ -73,7 +73,7 @@ futures = "0.3" geo = { version = "0.33", optional = true, default-features = false } geo-types = { version = "0.7", optional = true } geojson = { version = "1", optional = true, default-features = true } -graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.57.0", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.58.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 8552507b..bdc85799 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -10,6 +10,12 @@ supersedes: [] # `graphql-orm` +Version 0.58.0 adds optional `#[graphql_orm(policy_binding = "owner.contract")]`. +Canonical schema builders require matching entity, row and field providers and +strict authorization before generated operations, including counts. Unbound or +unrelated providers fail closed. See the +[migration guide](../../MIGRATION.md#0570-to-0580-required-policy-bindings). + Version 0.57.0 adds exact SQL Server `MONEY` typed reads through `#[graphql_orm(mssql_money)]`. The generated read projection casts the native column to `DECIMAL(19,4)` under its original alias before decoding; ordinary diff --git a/crates/graphql-orm/src/db.rs b/crates/graphql-orm/src/db.rs index 1dcd6264..1eb9aad7 100644 --- a/crates/graphql-orm/src/db.rs +++ b/crates/graphql-orm/src/db.rs @@ -18,6 +18,37 @@ struct EventSenders { senders: RwLock>>, } +struct RequiredPolicyBindings { + required: Vec<&'static str>, + delegate: Option>>, +} +impl crate::graphql::orm::EntityPolicy for RequiredPolicyBindings { + fn supports_policy_binding(&self, binding: &str) -> bool { + self.delegate.as_ref().is_some_and(|policy| policy.supports_policy_binding(binding)) + } + fn can_access_entity<'a>( + &'a self, context: Option<&'a async_graphql::Context<'_>>, database: &'a Database, + entity: &'static str, policy_key: Option<&'static str>, + kind: crate::graphql::orm::EntityAccessKind, surface: crate::graphql::orm::EntityAccessSurface, + ) -> futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { + let Some(delegate) = &self.delegate else { + return Err(OrmPublicError::new(OrmErrorCode::AuthorizationMisconfigured).into_graphql_error()); + }; + for binding in &self.required { + if !delegate.supports_policy_binding(binding) + || !database.row_policy.as_ref().is_some_and(|policy| policy.supports_policy_binding(binding)) + || !database.field_policy.as_ref().is_some_and(|policy| policy.supports_policy_binding(binding)) { + return Err(OrmPublicError::new(OrmErrorCode::AuthorizationMisconfigured) + .with_internal("required entity, row and field policy bindings must be installed before schema composition") + .into_graphql_error()); + } + } + delegate.can_access_entity(context, database, entity, policy_key, kind, surface).await + }) + } +} + const DEFAULT_EVENT_CHANNEL_CAPACITY: usize = 256; #[cfg(any(feature = "sqlite", feature = "postgres", feature = "mssql"))] @@ -349,6 +380,23 @@ impl Database { self.authorization_mode = authorization_mode; } + /// Require named entity, row and field providers for a composed schema. + /// Missing/mismatched bindings deny operations, including count-only reads. + /// Configure complete providers before building; existing callbacks are retained. + pub fn require_policy_bindings(&mut self, bindings: &[Option<&'static str>]) { + let required = bindings.iter().flatten().copied().collect::>(); + if required.is_empty() { return; } + self.authorization_mode = AuthorizationMode::ExplicitPolicyForAllExposedOperations; + let complete = required.iter().all(|binding| + self.entity_policy.as_ref().is_some_and(|policy| policy.supports_policy_binding(binding)) + && self.row_policy.as_ref().is_some_and(|policy| policy.supports_policy_binding(binding)) + && self.field_policy.as_ref().is_some_and(|policy| policy.supports_policy_binding(binding))); + if !complete { + let delegate = self.entity_policy.take(); + self.entity_policy = Some(Arc::new(RequiredPolicyBindings { required, delegate })); + } + } + /// Return a copy of this handle with a different authorization mode. pub fn with_authorization_mode(mut self, authorization_mode: AuthorizationMode) -> Self { self.authorization_mode = authorization_mode; diff --git a/crates/graphql-orm/src/graphql/orm/core.rs b/crates/graphql-orm/src/graphql/orm/core.rs index 7c5b8acc..1561a794 100644 --- a/crates/graphql-orm/src/graphql/orm/core.rs +++ b/crates/graphql-orm/src/graphql/orm/core.rs @@ -2517,6 +2517,9 @@ impl EntityPolicy for ScopeEntityPolicy { } pub trait EntityPolicy: Send + Sync { + /// Declare a host-installed policy contract; this is not an access grant. + fn supports_policy_binding(&self, _binding: &str) -> bool { false } + fn can_access_entity<'a>( &'a self, ctx: Option<&'a async_graphql::Context<'_>>, @@ -2529,6 +2532,9 @@ pub trait EntityPolicy: Send + Sync { } pub trait FieldPolicy: Send + Sync { + /// Declare a host-installed policy contract; this is not an access grant. + fn supports_policy_binding(&self, _binding: &str) -> bool { false } + fn can_read_field<'a>( &'a self, ctx: &'a async_graphql::Context<'_>, @@ -2586,6 +2592,9 @@ pub trait FieldPolicy: Send + Sync { } pub trait RowPolicy: Send + Sync { + /// Declare a host-installed policy contract; this is not an access grant. + fn supports_policy_binding(&self, _binding: &str) -> bool { false } + /// 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 read paths that diff --git a/crates/graphql-orm/src/graphql/orm/query.rs b/crates/graphql-orm/src/graphql/orm/query.rs index 7f8bff8a..b6fab61c 100644 --- a/crates/graphql-orm/src/graphql/orm/query.rs +++ b/crates/graphql-orm/src/graphql/orm/query.rs @@ -67,6 +67,8 @@ pub struct RepositoryFieldPolicyDef { } pub trait Entity: DatabaseEntity + DatabaseSchema + EntityRelations + DatabaseSearchSchema { + /// Optional named binding required by canonical generated schema builders. + const REQUIRED_POLICY_BINDING: Option<&'static str> = None; fn entity_name() -> &'static str; fn metadata() -> &'static EntityMetadata; diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index ae80c565..c5cd2a87 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock @@ -1368,7 +1368,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.57.0" +version = "0.58.0" dependencies = [ "agql-auth", "async-graphql", @@ -1397,7 +1397,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.109.1" +version = "0.110.0" dependencies = [ "agql-auth", "async-graphql", @@ -1442,7 +1442,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.57.0" +version = "0.58.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 ef9b88b9..3595f604 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.57.0" +version = "0.58.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -2532,7 +2532,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.57.0" +version = "0.58.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 f3b9fad3..b01570a0 100644 --- a/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.57.0" +version = "0.58.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1052,7 +1052,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.57.0" +version = "0.58.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 900e82b0..0e435f5e 100644 --- a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.57.0" +version = "0.58.0" dependencies = [ "async-graphql", "async-trait", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.57.0" +version = "0.58.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 56601548..0be07f4b 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -19,7 +19,7 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | | `graphql-orm` | `0.57.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | -| `graphql-orm-ai` | `0.109.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm` (dev-only), `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | +| `graphql-orm-ai` | `0.110.0` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm` (dev-only), `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.16.1` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.10.0` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | | `graphql-orm-macros` | `0.57.0` | `crates/graphql-orm-macros` | `sqlite` | none | diff --git a/scripts/generate-workspace-inventory.py b/scripts/generate-workspace-inventory.py index f242f807..f76069ef 100644 --- a/scripts/generate-workspace-inventory.py +++ b/scripts/generate-workspace-inventory.py @@ -96,6 +96,9 @@ def update_document(generated: str, check: bool) -> int: if existing == expected: return 0 if check: + # Preserve the canonical Cargo-derived result for review in CI logs. + # Check mode still refuses the stale tracked file and never rewrites it. + print(generated) print( "docs/reference/workspace-packages.md is stale; run " "python3 scripts/generate-workspace-inventory.py",