From 6c5620d8d96a6f76189804abd353b00ff13b5cfc Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Sat, 22 Aug 2026 23:09:51 +1000 Subject: [PATCH] Make role catalogue expansion resilient --- CHANGELOG.md | 15 + Cargo.lock | 13 +- Cargo.toml | 5 +- MIGRATION.md | 10 + README.md | 4 +- crates/graphql-orm-ai/CHANGELOG.md | 13 + crates/graphql-orm-ai/Cargo.toml | 2 +- crates/graphql-orm-ai/MIGRATION.md | 9 + crates/graphql-orm-ai/README.md | 2 +- .../docs/implementation-status.md | 6 +- crates/graphql-orm-macros/Cargo.toml | 2 +- crates/graphql-orm-macros/README.md | 4 +- crates/graphql-orm-router/CHANGELOG.md | 19 ++ crates/graphql-orm-router/Cargo.toml | 3 +- crates/graphql-orm-router/MIGRATION.md | 21 ++ crates/graphql-orm-router/README.md | 2 +- .../graphql-orm-router/docs/configuration.md | 41 ++- crates/graphql-orm-router/docs/operations.md | 10 +- crates/graphql-orm-router/src/file_config.rs | 131 ++++++- crates/graphql-orm-router/src/jwt.rs | 288 ++++++++++++++-- crates/graphql-orm-router/src/jwt/tests.rs | 322 +++++++++++++++++- crates/graphql-orm/Cargo.toml | 4 +- crates/graphql-orm/README.md | 2 +- .../tests/backend_coexistence_fixture.rs | 6 +- .../fixtures/backend-coexistence/Cargo.lock | 10 +- .../auth-service/Cargo.toml | 2 +- .../reference/graphql-orm/agql-auth-bridge.md | 2 +- docs/reference/graphql-orm/backends.md | 2 +- docs/reference/graphql-orm/mssql.md | 2 +- docs/reference/workspace-packages.md | 10 +- 30 files changed, 851 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bade477e..3716d79b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,21 @@ 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.26.0 - 2026-08-22 + +Companion macros crate: `graphql-orm-macros` **0.26.0**. Generated ORM and +database behavior are unchanged. + +- Aligned the optional `auth-agql` bridge to generic `agql-auth` 0.18.0 at + merged full revision `527d15d28e3c295a6f6b5e6d74559a7aecdc1322`, retaining + one public auth type universe for direct consumers. +- The upstream release adds a typed authorization-role claim and issuer hook, + explicit unknown-role expansion errors, and independent catalogue lifetime + validation. The ORM bridge remains a projection of an accepted principal; + database policy and generated resolver behavior are unchanged. + +No database, GraphQL SDL, generated-code, or stored-data migration is required. + ## 0.25.1 - 2026-08-22 Companion macros crate: `graphql-orm-macros` **0.25.1**. Generated ORM and diff --git a/Cargo.lock b/Cargo.lock index 20d3ec20..ff4827ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,8 +87,8 @@ dependencies = [ [[package]] name = "agql-auth" -version = "0.17.1" -source = "git+https://github.com/Dastari/agql-auth.git?rev=b792241b7d9bb46bad81dde4534ae5b39857f614#b792241b7d9bb46bad81dde4534ae5b39857f614" +version = "0.18.0" +source = "git+https://github.com/Dastari/agql-auth.git?rev=527d15d28e3c295a6f6b5e6d74559a7aecdc1322#527d15d28e3c295a6f6b5e6d74559a7aecdc1322" dependencies = [ "argon2", "async-graphql", @@ -3075,7 +3075,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.25.1" +version = "0.26.0" dependencies = [ "agql-auth", "async-graphql", @@ -3104,7 +3104,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.90.1" +version = "0.91.0" dependencies = [ "agql-auth", "async-graphql", @@ -3172,7 +3172,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.25.1" +version = "0.26.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", @@ -3193,7 +3193,7 @@ dependencies = [ [[package]] name = "graphql-orm-router" -version = "0.3.1" +version = "0.4.0" dependencies = [ "agql-auth", "arc-swap", @@ -3214,6 +3214,7 @@ dependencies = [ "sha2 0.10.9", "signal-hook", "thiserror 2.0.18", + "tracing", "url", ] diff --git a/Cargo.toml b/Cargo.toml index 1ac13623..f207195b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ license = "MIT" repository = "https://github.com/Dastari/graphql-orm" [workspace.dependencies] -agql-auth = { git = "https://github.com/Dastari/agql-auth.git", rev = "b792241b7d9bb46bad81dde4534ae5b39857f614", version = "0.17.1" } +agql-auth = { git = "https://github.com/Dastari/agql-auth.git", rev = "527d15d28e3c295a6f6b5e6d74559a7aecdc1322", version = "0.18.0" } arc-swap = "1.7" async-graphql = { version = "7", features = ["dataloader", "uuid"] } async-graphql-parser = "7" @@ -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.25.1", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.26.0", default-features = false } graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.9.0" } graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.7.1", default-features = false } graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.3.0" } @@ -62,5 +62,6 @@ thiserror = "2" tokio = { version = "1" } tokio-stream = { version = "0.1", features = ["sync"] } tokio-util = { version = "0.7", features = ["compat"] } +tracing = "0.1" uuid = { version = "1", features = ["v4"] } url = "2" diff --git a/MIGRATION.md b/MIGRATION.md index 4d2a4f2e..6c5e0086 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -13,6 +13,16 @@ supersedes: [] `graphql-orm` is distributed from GitHub only. Use a reviewed full 40-character commit in `rev`; neither the runtime nor macros crate is published to crates.io. +## 0.25.1 to 0.26.0: agql-auth 0.18.0 alignment + +Git consumers using `auth-agql` must align direct `agql-auth` dependencies to +0.18.0 at merged revision `527d15d28e3c295a6f6b5e6d74559a7aecdc1322`. +Adopt aligned `graphql-orm` and `graphql-orm-macros` 0.26.0 from the same +reviewed monorepo revision. The bridge remains projection-only. Hosts adopting +typed authorization-role grants follow the upstream issuer/resource-server +rollout; no ORM schema, migration, generated-code, database-policy, or +stored-data change is required. + ## 0.25.0 to 0.25.1: agql-auth 0.17.1 alignment Git consumers using `auth-agql` must align direct `agql-auth` dependencies to diff --git a/README.md b/README.md index 955afab1..973e0275 100644 --- a/README.md +++ b/README.md @@ -42,12 +42,12 @@ schema changes explicitly. Packages are distributed from this repository, not crates.io. Pin the reviewed release revision, not a moving branch or tag. The current coordinated -`graphql-orm` version is 0.25.1. Replace the placeholder below with the final +`graphql-orm` version is 0.26.0. Replace the placeholder below with the final reviewed full SHA for the release: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.25.1", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.26.0", default-features = false, features = ["sqlite"] } ``` Choose exactly the backend support needed by each service. Cargo can unify diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index cfa106aa..406fef78 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -18,6 +18,19 @@ 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). +## [0.91.0] - 2026-08-22 + +Persistent schema module: **0.63.0** (unchanged from 0.90.1). + +### Changed + +- Aligned the public `agql-auth` type universe to 0.18.0 at merged revision + `527d15d28e3c295a6f6b5e6d74559a7aecdc1322`. Consumers using re-exported + principal types must resolve the same full revision. +- The additive typed authorization-role metadata is available through the + shared principal type. AI authorization, principal rehydration, providers, + persistence, GraphQL SDL, backup, and restore behavior are unchanged. + ## [0.90.1] - 2026-08-22 Persistent schema module: **0.63.0** (unchanged from 0.90.0). diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index eabb562a..05006dd1 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.90.1" +version = "0.91.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 5ae918db..a8636e7c 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -19,6 +19,15 @@ 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.90.1 to 0.91.0: agql-auth 0.18.0 type-universe alignment + +Adopt `graphql-orm-ai` 0.91.0 from one reviewed full monorepo revision and +align every direct `agql-auth` dependency to version 0.18.0 at merged revision +`527d15d28e3c295a6f6b5e6d74559a7aecdc1322`. The new typed authorization-role +metadata is additive to the public principal type. The schema module remains +**0.63.0**; no database, data, provider, GraphQL SDL, protected-payload, +backup, restore, or runtime migration is required. + ## 0.90.0 to 0.90.1: agql-auth 0.17.1 type-universe alignment Adopt `graphql-orm-ai` 0.90.1 from one reviewed full monorepo revision and diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index cfa32701..0137f752 100644 --- a/crates/graphql-orm-ai/README.md +++ b/crates/graphql-orm-ai/README.md @@ -28,7 +28,7 @@ for AI, ORM, storage, backup, and tool-profile packages: ```toml [dependencies] -graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.90.1", default-features = false, features = ["sqlite"] } +graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.91.0", default-features = false, features = ["sqlite"] } ``` Exactly one persistence backend is required: `sqlite` (default), `postgres`, diff --git a/crates/graphql-orm-ai/docs/implementation-status.md b/crates/graphql-orm-ai/docs/implementation-status.md index d42cbb1e..b738f76a 100644 --- a/crates/graphql-orm-ai/docs/implementation-status.md +++ b/crates/graphql-orm-ai/docs/implementation-status.md @@ -10,10 +10,10 @@ supersedes: [] # Implementation Status -`graphql-orm-ai` is at crate version `0.90.1` with AI schema module -`0.63.0`. It uses workspace `graphql-orm` `0.25.1`, backend-neutral +`graphql-orm-ai` is at crate version `0.91.0` with AI schema module +`0.63.0`. It uses workspace `graphql-orm` `0.26.0`, backend-neutral `graphql-orm-ai-tool-profiles` `0.9.0`, and external `agql-auth` -`0.17.1` at `b792241b7d9bb46bad81dde4534ae5b39857f614`. +`0.18.0` at `527d15d28e3c295a6f6b5e6d74559a7aecdc1322`. Completed stateless local-provider turns can carry a proof-bearing contained native-item refusal after authoritative usage settlement. Those runs close as diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index 4a68c356..6b018362 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.25.1" +version = "0.26.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 c2f71b85..81eb877c 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.25.1", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.26.0", default-features = false, features = ["sqlite"] } ``` Direct use is supported for tooling that needs the macro package: ```toml -graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.25.1", default-features = false, features = ["sqlite"] } +graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.26.0", default-features = false, features = ["sqlite"] } ``` The direct dependency still requires a compatible `graphql-orm` runtime in the diff --git a/crates/graphql-orm-router/CHANGELOG.md b/crates/graphql-orm-router/CHANGELOG.md index a2e9f8fb..d9c01127 100644 --- a/crates/graphql-orm-router/CHANGELOG.md +++ b/crates/graphql-orm-router/CHANGELOG.md @@ -10,6 +10,25 @@ supersedes: [] # Changelog +## 0.4.0 - 2026-08-22 + +- Aligned the optional adapter to generic `agql-auth` 0.18.0 at merged revision + `527d15d28e3c295a6f6b5e6d74559a7aecdc1322` and expand only the distinct typed + `authorization_roles` claim. Application roles keep their existing meaning. +- Role-catalogue URLs and request headers can be sourced from named environment + variables. Header values are omitted from file configuration and diagnostics. +- Role-catalogue fetch is lazy and uses configurable bounded exponential retry + backoff rather than becoming a startup dependency. Unknown role IDs trigger + an immediate refresh and fail the affected request. +- Failed refresh preserves the last signature-verified snapshot past its soft + age or signed expiry, with warning logs and a process-local stale-serve + counter. Signed maximum lifetime, clock leeway, and local refresh age are + independent settings. + +No descriptor, GraphQL schema, membership, or stored-data migration is +required. Issuers adopting compact grants must emit the typed +`authorization_roles` claim before relying on this expansion path. + ## 0.3.1 - 2026-08-22 - Aligned the optional resource-server adapter to `agql-auth` 0.17.1 at merged diff --git a/crates/graphql-orm-router/Cargo.toml b/crates/graphql-orm-router/Cargo.toml index 593b4209..8d36eef6 100644 --- a/crates/graphql-orm-router/Cargo.toml +++ b/crates/graphql-orm-router/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-router" -version = "0.3.1" +version = "0.4.0" edition = "2024" rust-version = "1.90" description = "Federated GraphQL router for graphql-orm and project-neutral subgraphs" @@ -31,6 +31,7 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } url = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/crates/graphql-orm-router/MIGRATION.md b/crates/graphql-orm-router/MIGRATION.md index ecb7b6f4..036b369f 100644 --- a/crates/graphql-orm-router/MIGRATION.md +++ b/crates/graphql-orm-router/MIGRATION.md @@ -10,6 +10,27 @@ supersedes: [] # graphql-orm-router migration guide +## 0.3.1 to 0.4.0 + +Align direct `agql-auth` consumers to 0.18.0 at merged revision +`527d15d28e3c295a6f6b5e6d74559a7aecdc1322`. Issuers place catalogue-backed +grants in the distinct `authorization_roles` claim; ordinary application +`roles` are no longer an expansion input. + +Deploy resource-server support before minting that claim. Configure a literal +`url` or `urlFromEnv`, and load any request credentials with +`requestHeadersFromEnv`. `maximumSignedLifetimeSeconds` and +`clockSkewLeewaySeconds` validate newly fetched signatures independently of +the local `cacheTtlSeconds` refresh age. Optional `retryBackoffSeconds` and +`maximumRetryBackoffSeconds` bound lazy-fetch retries. The role catalogue is no longer a +startup readiness gate. Until the lazy fetch succeeds, role-bearing requests +fail closed while direct-scope-only requests continue. After one successful +verification, refresh failures preserve the last-good snapshot and surface +warning/metric signals. + +No schema or stored-data migration is implied. Roll back only after restoring +expanded direct scopes and allowing typed compact tokens to expire. + ## 0.3.0 to 0.3.1 Align direct `agql-auth` consumers to 0.17.1 at reviewed full revision diff --git a/crates/graphql-orm-router/README.md b/crates/graphql-orm-router/README.md index 3bd2d946..76b51d17 100644 --- a/crates/graphql-orm-router/README.md +++ b/crates/graphql-orm-router/README.md @@ -26,7 +26,7 @@ This unpublished package is Git-only: ```toml [dependencies] -graphql-orm-router = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.3.1" } +graphql-orm-router = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.4.0" } ``` Enable `auth-agql` only when adapting a separately configured diff --git a/crates/graphql-orm-router/docs/configuration.md b/crates/graphql-orm-router/docs/configuration.md index 2dc8df4c..5ffd3669 100644 --- a/crates/graphql-orm-router/docs/configuration.md +++ b/crates/graphql-orm-router/docs/configuration.md @@ -87,20 +87,33 @@ array. JWKS uses HTTPS. Plain HTTP is accepted only for loopback when `cacheTtlSeconds`, `refreshIntervalSeconds`, `requestTimeoutMs`, `maxJwksBytes`, and `leewaySeconds`. `acceptLegacyScopes` defaults false. -`roleScopeCatalogue` is optional and requires `auth-agql`. It accepts `url`, -`audience`, `cacheTtlSeconds`, `maxBodyBytes`, and -`allowInsecureLoopbackHttp`. Its URL is HTTPS-only except for explicit -loopback development, has no credentials/query/fragment, and uses the -authentication request timeout and refresh interval. The refresh interval must -be shorter than the catalogue TTL. Startup verifies the RS256 signature with -the JWKS cache and binds the exact catalogue, issuer, audience, purpose, and -signed lifetime before readiness. - -When configured, well-formed token roles expand through the current verified -catalogue and are unioned with direct token scopes. Unknown roles grant -nothing. Role-bearing credentials fail closed when no current verified -catalogue exists; direct-scope-only credentials do not depend on that optional -authority source. +`roleScopeCatalogue` is optional and requires `auth-agql`. Select exactly one +of literal `url` or `urlFromEnv`. Request headers are loaded only through +`requestHeadersFromEnv`; values never appear in JSON or diagnostics. Optional +bounds are `cacheTtlSeconds`, `maximumSignedLifetimeSeconds`, +`clockSkewLeewaySeconds`, `retryBackoffSeconds`, +`maximumRetryBackoffSeconds`, and `maxBodyBytes`. Its URL is HTTPS-only except for +explicit loopback development. The literal `allowInsecureLoopbackHttp` flag or +`allowInsecureLoopbackHttpFromEnv` can opt into that development exception, +but both cannot be set together. + +JWKS remains the startup authentication dependency. The role catalogue is +fetched lazily on the first authorization-role-bearing request and retried with +bounded exponential backoff as well as by the ordinary authentication refresh +loop. A fetch verifies the RS256 signature +with the JWKS cache and binds the exact catalogue, issuer, audience, purpose, +and independently configured signed lifetime. `cacheTtlSeconds` is only the +soft refresh age; it does not cap issuer lifetime. + +When configured, the distinct typed `authorization_roles` claim expands +through the last signature-verified catalogue and is unioned with direct token +scopes. Application `roles` are not expanded. Unknown authorization roles fail +explicitly and request an immediate refresh. A failed refresh preserves and +continues serving the last verified snapshot past its soft age or signed +expiry, increments `role_scope_stale_serve_total`, and emits rate-limited warning +logs. A role-bearing credential fails closed only when no verified snapshot is +available or its role remains unknown; direct-scope-only credentials do not +depend on the optional authority source. The router validates RS256 public keys only. Configuration has no private-key, token-signing, session, refresh-token, or RSA-decryption field. diff --git a/crates/graphql-orm-router/docs/operations.md b/crates/graphql-orm-router/docs/operations.md index afd12010..59960031 100644 --- a/crates/graphql-orm-router/docs/operations.md +++ b/crates/graphql-orm-router/docs/operations.md @@ -20,10 +20,12 @@ supersedes: [] 5. Execute a representative authenticated query and subscription before accepting traffic. -Startup is fail closed. JWKS initialization, all source fetches, descriptor +Startup is fail closed for JWKS initialization, all source fetches, descriptor binding, complete composition, authorization-catalog validation, and runtime -construction finish before the public listener binds. Do not treat liveness as -readiness. +construction before the public listener binds. The optional role-scope +catalogue is deliberately not a startup dependency: its first fetch is lazy, +and only authorization-role-bearing requests fail while no verified snapshot +exists. Do not treat liveness as readiness. The optional admin listener provides authenticated `GET /_router/status`, `GET /_router/metrics`, `POST /_router/refresh`, @@ -59,6 +61,8 @@ Hive Prometheus exporter adds execution histograms and the pinned subscription counters `hive.router.subscriptions.clients.lagged_messages_total` and `hive.router.subscriptions.subgraphs.dropped_messages_total`. +Embedding hosts can also read `role_scope_stale_serve_total` from the JWKS +provider and alert on any non-zero increase; stale serves emit warning logs. ## Reload and recovery diff --git a/crates/graphql-orm-router/src/file_config.rs b/crates/graphql-orm-router/src/file_config.rs index 1ee4e5f4..35ebe2a1 100644 --- a/crates/graphql-orm-router/src/file_config.rs +++ b/crates/graphql-orm-router/src/file_config.rs @@ -130,17 +130,50 @@ impl RouterFileConfig { authentication.allow_insecure_loopback_jwks, ); if let Some(catalogue) = authentication.role_scope_catalogue { + let catalogue_url = resolve_configured_value( + catalogue.url, + catalogue.url_from_env, + "role-scope catalogue URL", + &environment, + )?; let mut role_scope = - RoleScopeCatalogueConfig::new(catalogue.url, catalogue.audience)?; + RoleScopeCatalogueConfig::new(catalogue_url, catalogue.audience)?; if let Some(seconds) = catalogue.cache_ttl_seconds { role_scope = role_scope.with_cache_ttl(Duration::from_secs(seconds)); } + if let Some(seconds) = catalogue.maximum_signed_lifetime_seconds { + role_scope = + role_scope.with_maximum_signed_lifetime(Duration::from_secs(seconds)); + } + if let Some(seconds) = catalogue.clock_skew_leeway_seconds { + role_scope = role_scope.with_clock_skew_leeway(Duration::from_secs(seconds)); + } + if catalogue.retry_backoff_seconds.is_some() + || catalogue.maximum_retry_backoff_seconds.is_some() + { + role_scope = role_scope.with_retry_backoff( + Duration::from_secs(catalogue.retry_backoff_seconds.unwrap_or(1)), + Duration::from_secs(catalogue.maximum_retry_backoff_seconds.unwrap_or(60)), + ); + } if let Some(bytes) = catalogue.max_body_bytes { role_scope = role_scope.with_max_body_bytes(bytes); } - role_scope = role_scope.allow_insecure_loopback_http_for_development( + for (name, value) in resolve_headers_for( + catalogue.request_headers_from_env, + &environment, + "role-scope catalogue request header", + )? { + role_scope = role_scope.with_request_header(name, value)?; + } + let allow_insecure_loopback_http = resolve_optional_bool( catalogue.allow_insecure_loopback_http, - ); + catalogue.allow_insecure_loopback_http_from_env, + "role-scope catalogue insecure-loopback policy", + &environment, + )?; + role_scope = role_scope + .allow_insecure_loopback_http_for_development(allow_insecure_loopback_http); jwks = jwks.with_role_scope_catalogue(role_scope); } config = config @@ -291,12 +324,20 @@ struct FileAuthentication { #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct FileRoleScopeCatalogue { - url: String, + url: Option, + url_from_env: Option, audience: String, cache_ttl_seconds: Option, + maximum_signed_lifetime_seconds: Option, + clock_skew_leeway_seconds: Option, + retry_backoff_seconds: Option, + maximum_retry_backoff_seconds: Option, max_body_bytes: Option, #[serde(default)] allow_insecure_loopback_http: bool, + allow_insecure_loopback_http_from_env: Option, + #[serde(default)] + request_headers_from_env: BTreeMap, } /// File-owned scope policy. Omission and `kind: exact` preserve the router's @@ -651,18 +692,26 @@ struct FilePrometheus { fn resolve_headers( headers: BTreeMap, environment: &impl Fn(&str) -> Result, RouterError>, +) -> Result, RouterError> { + resolve_headers_for(headers, environment, "schema header") +} + +fn resolve_headers_for( + headers: BTreeMap, + environment: &impl Fn(&str) -> Result, RouterError>, + resource: &'static str, ) -> Result, RouterError> { headers .into_iter() .map(|(name, variable)| { let value = environment(&variable)?.ok_or_else(|| { invalid(format!( - "required schema header environment variable `{variable}` is missing" + "required {resource} environment variable `{variable}` is missing" )) })?; if value.is_empty() { return Err(invalid(format!( - "required schema header environment variable `{variable}` is empty" + "required {resource} environment variable `{variable}` is empty" ))); } Ok((name, value)) @@ -670,6 +719,52 @@ fn resolve_headers( .collect() } +fn resolve_configured_value( + literal: Option, + from_environment: Option, + resource: &'static str, + environment: &impl Fn(&str) -> Result, RouterError>, +) -> Result { + match (literal, from_environment) { + (Some(value), None) if !value.is_empty() => Ok(value), + (None, Some(variable)) => environment(&variable)? + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + invalid(format!( + "required {resource} environment variable `{variable}` is missing or empty" + )) + }), + (Some(_), Some(_)) => Err(invalid(format!( + "{resource} must select exactly one of a literal value or an environment variable" + ))), + _ => Err(invalid(format!("{resource} is required"))), + } +} + +fn resolve_optional_bool( + literal: bool, + from_environment: Option, + resource: &'static str, + environment: &impl Fn(&str) -> Result, RouterError>, +) -> Result { + let Some(variable) = from_environment else { + return Ok(literal); + }; + if literal { + return Err(invalid(format!( + "{resource} must select either the literal flag or an environment variable" + ))); + } + match environment(&variable)?.as_deref() { + Some("1" | "true" | "TRUE") => Ok(true), + Some("0" | "false" | "FALSE") => Ok(false), + Some(_) => Err(invalid(format!( + "{resource} environment variable `{variable}` must be true or false" + ))), + None => Ok(false), + } +} + fn read_environment(name: &str) -> Result, RouterError> { match std::env::var(name) { Ok(value) => Ok(Some(value)), @@ -880,20 +975,38 @@ mod tests { r#""audiences": ["router"], "refreshIntervalSeconds": 30, "roleScopeCatalogue": { - "url": "https://identity.example/role-scopes", + "urlFromEnv": "ROLE_SCOPE_URL", "audience": "resource-servers", "cacheTtlSeconds": 120, - "maxBodyBytes": 262144 + "maximumSignedLifetimeSeconds": 86400, + "clockSkewLeewaySeconds": 30, + "retryBackoffSeconds": 2, + "maximumRetryBackoffSeconds": 30, + "maxBodyBytes": 262144, + "requestHeadersFromEnv": {"authorization": "ROLE_SCOPE_TOKEN"} }"#, 1, ); RouterFileConfig::from_json(&json) .unwrap() .into_router_config_with(|name| { - Ok((name == "PRODUCTS_SCHEMA_TOKEN").then(|| "Bearer secret".to_owned())) + Ok(match name { + "PRODUCTS_SCHEMA_TOKEN" => Some("Bearer schema-secret".to_owned()), + "ROLE_SCOPE_URL" => Some("https://identity.example/role-scopes".to_owned()), + "ROLE_SCOPE_TOKEN" => Some("Bearer catalogue-secret".to_owned()), + _ => None, + }) }) .unwrap(); + let missing = RouterFileConfig::from_json(&json) + .unwrap() + .into_router_config_with(|name| { + Ok((name == "PRODUCTS_SCHEMA_TOKEN").then(|| "Bearer schema-secret".to_owned())) + }) + .unwrap_err(); + assert!(missing.to_string().contains("ROLE_SCOPE_URL")); + let unknown = json.replacen( r#""maxBodyBytes": 262144"#, r#""maxBodyBytes": 262144, "consumerPolicy": true"#, diff --git a/crates/graphql-orm-router/src/jwt.rs b/crates/graphql-orm-router/src/jwt.rs index bba845cd..bb78a69b 100644 --- a/crates/graphql-orm-router/src/jwt.rs +++ b/crates/graphql-orm-router/src/jwt.rs @@ -6,17 +6,25 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; +#[cfg(feature = "auth-agql")] +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + #[cfg(feature = "auth-agql")] use agql_auth::{ - RoleScopeCatalogueClaims, RoleScopeExpansionProvider, SignedRoleScopeCatalogue, - StaticRoleScopeExpansion, effective_scopes, + RoleScopeCatalogueClaims, RoleScopeCatalogueValidationOptions, RoleScopeExpansionError, + RoleScopeExpansionProvider, SignedRoleScopeCatalogue, StaticRoleScopeExpansion, + effective_scopes, }; use futures::{StreamExt, future::BoxFuture}; use jsonwebtoken::{ Algorithm, DecodingKey, Validation, decode, decode_header, jwk::{AlgorithmParameters, JwkSet, KeyAlgorithm, KeyOperations, PublicKeyUse}, }; -use reqwest::{Client, redirect::Policy}; +use reqwest::{ + Client, + header::{HeaderMap, HeaderName, HeaderValue}, + redirect::Policy, +}; use serde::Deserialize; use serde_json::Value as JsonValue; use url::Url; @@ -31,12 +39,16 @@ const DEFAULT_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); const DEFAULT_MAX_JWKS_BYTES: usize = 1024 * 1024; const DEFAULT_MAX_ROLE_SCOPE_CATALOGUE_BYTES: usize = 1024 * 1024; +const DEFAULT_ROLE_SCOPE_SIGNED_LIFETIME: Duration = Duration::from_secs(24 * 60 * 60); +const DEFAULT_ROLE_SCOPE_CLOCK_LEEWAY: Duration = Duration::from_secs(30); +const DEFAULT_ROLE_SCOPE_RETRY_BACKOFF: Duration = Duration::from_secs(1); +const DEFAULT_ROLE_SCOPE_MAXIMUM_RETRY_BACKOFF: Duration = Duration::from_secs(60); const MAX_JWKS_KEYS: usize = 128; const MAX_LEEWAY: Duration = Duration::from_secs(5 * 60); #[cfg(feature = "auth-agql")] -const MAX_ACCESS_TOKEN_ROLES: usize = 256; +const MAX_ACCESS_TOKEN_AUTHORIZATION_ROLES: usize = 256; #[cfg(feature = "auth-agql")] -const MAX_ACCESS_TOKEN_ROLE_LENGTH: usize = 512; +const MAX_ACCESS_TOKEN_AUTHORIZATION_ROLE_LENGTH: usize = 512; /// Remote signed role-scope catalogue configuration. #[derive(Clone)] @@ -44,8 +56,13 @@ pub struct RoleScopeCatalogueConfig { url: Url, audience: String, cache_ttl: Duration, + maximum_signed_lifetime: Duration, + clock_skew_leeway: Duration, + retry_backoff: Duration, + maximum_retry_backoff: Duration, max_body_bytes: usize, allow_insecure_loopback_http: bool, + request_headers: HeaderMap, } impl RoleScopeCatalogueConfig { @@ -58,17 +75,43 @@ impl RoleScopeCatalogueConfig { url, audience: audience.into(), cache_ttl: DEFAULT_CACHE_TTL, + maximum_signed_lifetime: DEFAULT_ROLE_SCOPE_SIGNED_LIFETIME, + clock_skew_leeway: DEFAULT_ROLE_SCOPE_CLOCK_LEEWAY, + retry_backoff: DEFAULT_ROLE_SCOPE_RETRY_BACKOFF, + maximum_retry_backoff: DEFAULT_ROLE_SCOPE_MAXIMUM_RETRY_BACKOFF, max_body_bytes: DEFAULT_MAX_ROLE_SCOPE_CATALOGUE_BYTES, allow_insecure_loopback_http: false, + request_headers: HeaderMap::new(), }) } - /// Sets the maximum age and signed lifetime accepted for one snapshot. + /// Sets the soft age after which a verified snapshot is served stale while + /// a refresh is requested. It does not constrain issuer-signed lifetime. pub fn with_cache_ttl(mut self, cache_ttl: Duration) -> Self { self.cache_ttl = cache_ttl; self } + /// Sets the longest issuer-signed catalogue lifetime accepted on fetch. + pub fn with_maximum_signed_lifetime(mut self, lifetime: Duration) -> Self { + self.maximum_signed_lifetime = lifetime; + self + } + + /// Sets bounded clock-skew leeway for catalogue `iat` and `exp` checks. + pub fn with_clock_skew_leeway(mut self, leeway: Duration) -> Self { + self.clock_skew_leeway = leeway; + self + } + + /// Sets the initial and maximum delays for retries after a failed lazy + /// catalogue refresh. Delays grow exponentially between these bounds. + pub fn with_retry_backoff(mut self, initial: Duration, maximum: Duration) -> Self { + self.retry_backoff = initial; + self.maximum_retry_backoff = maximum; + self + } + /// Sets the maximum accepted response body size. pub fn with_max_body_bytes(mut self, max_body_bytes: usize) -> Self { self.max_body_bytes = max_body_bytes; @@ -81,6 +124,22 @@ impl RoleScopeCatalogueConfig { self } + /// Adds one request header without exposing its value through diagnostics. + pub fn with_request_header( + mut self, + name: impl AsRef, + value: impl AsRef, + ) -> Result { + let name = HeaderName::from_bytes(name.as_ref().as_bytes()).map_err(|_| { + invalid_configuration("role-scope catalogue request header name is invalid") + })?; + let value = HeaderValue::from_str(value.as_ref()).map_err(|_| { + invalid_configuration("role-scope catalogue request header value is invalid") + })?; + self.request_headers.insert(name, value); + Ok(self) + } + #[cfg(feature = "auth-agql")] fn validate(&self, refresh_interval: Duration) -> Result<(), RouterError> { if self.audience.trim().is_empty() { @@ -98,6 +157,21 @@ impl RoleScopeCatalogueConfig { "role-scope catalogue body limit must be greater than zero", )); } + if self.maximum_signed_lifetime.is_zero() { + return Err(invalid_configuration( + "role-scope catalogue maximum signed lifetime must be greater than zero", + )); + } + if self.clock_skew_leeway > MAX_LEEWAY { + return Err(invalid_configuration( + "role-scope catalogue clock leeway must not exceed 300 seconds", + )); + } + if self.retry_backoff.is_zero() || self.maximum_retry_backoff < self.retry_backoff { + return Err(invalid_configuration( + "role-scope catalogue retry backoff must be greater than zero and not exceed its maximum", + )); + } validate_public_resource_url( &self.url, self.allow_insecure_loopback_http, @@ -113,7 +187,15 @@ impl fmt::Debug for RoleScopeCatalogueConfig { .field("origin", &redacted_origin(&self.url)) .field("audience", &self.audience) .field("cache_ttl", &self.cache_ttl) + .field("maximum_signed_lifetime", &self.maximum_signed_lifetime) + .field("clock_skew_leeway", &self.clock_skew_leeway) + .field("retry_backoff", &self.retry_backoff) + .field("maximum_retry_backoff", &self.maximum_retry_backoff) .field("max_body_bytes", &self.max_body_bytes) + .field( + "request_header_names", + &self.request_headers.keys().collect::>(), + ) .field( "allow_insecure_loopback_http", &self.allow_insecure_loopback_http, @@ -337,7 +419,15 @@ pub struct JwksAuthenticationProvider { client: Client, cache: Arc>>, #[cfg(feature = "auth-agql")] - role_scope_cache: Arc>>, + role_scope_cache: Arc>>>, + #[cfg(feature = "auth-agql")] + role_scope_refresh_in_flight: Arc, + #[cfg(feature = "auth-agql")] + role_scope_refresh_failures: Arc, + #[cfg(feature = "auth-agql")] + role_scope_retry_after: Arc>>, + #[cfg(feature = "auth-agql")] + role_scope_stale_serves: Arc, } impl JwksAuthenticationProvider { @@ -358,6 +448,14 @@ impl JwksAuthenticationProvider { cache: Arc::new(RwLock::new(None)), #[cfg(feature = "auth-agql")] role_scope_cache: Arc::new(RwLock::new(None)), + #[cfg(feature = "auth-agql")] + role_scope_refresh_in_flight: Arc::new(AtomicBool::new(false)), + #[cfg(feature = "auth-agql")] + role_scope_refresh_failures: Arc::new(AtomicU64::new(0)), + #[cfg(feature = "auth-agql")] + role_scope_retry_after: Arc::new(RwLock::new(None)), + #[cfg(feature = "auth-agql")] + role_scope_stale_serves: Arc::new(AtomicU64::new(0)), }) } @@ -367,6 +465,7 @@ impl JwksAuthenticationProvider { self.config.jwks_url.clone(), self.config.max_jwks_bytes, "JWKS", + None, ) .await?; let document = serde_json::from_slice::(&body) @@ -386,8 +485,13 @@ impl JwksAuthenticationProvider { url: Url, maximum_bytes: usize, resource: &'static str, + request_headers: Option<&HeaderMap>, ) -> Result, AuthenticationError> { - let response = self.client.get(url).send().await.map_err(|_| { + let mut request = self.client.get(url); + if let Some(headers) = request_headers { + request = request.headers(headers.clone()); + } + let response = request.send().await.map_err(|_| { AuthenticationError::unavailable(format!("{resource} retrieval failed")) })?; if !response.status().is_success() { @@ -429,6 +533,7 @@ impl JwksAuthenticationProvider { config.url.clone(), config.max_body_bytes, "role-scope catalogue", + Some(&config.request_headers), ) .await?; let envelope = serde_json::from_slice::(&body).map_err(|_| { @@ -465,16 +570,25 @@ impl JwksAuthenticationProvider { })? .claims; let now = unix_timestamp(self.config.clock.as_ref())?; - let maximum_lifetime = i64::try_from(config.cache_ttl.as_secs()).map_err(|_| { - AuthenticationError::unavailable("role-scope catalogue cache TTL is invalid") - })?; + let maximum_lifetime = + i64::try_from(config.maximum_signed_lifetime.as_secs()).map_err(|_| { + AuthenticationError::unavailable( + "role-scope catalogue maximum signed lifetime is invalid", + ) + })?; + let clock_skew_leeway = + i64::try_from(config.clock_skew_leeway.as_secs()).map_err(|_| { + AuthenticationError::unavailable("role-scope catalogue clock leeway is invalid") + })?; claims - .validate_binding( + .validate_binding_with_options( &envelope, &self.config.issuer, &config.audience, now, - maximum_lifetime, + RoleScopeCatalogueValidationOptions::default() + .with_maximum_lifetime_seconds(maximum_lifetime) + .with_clock_skew_leeway_seconds(clock_skew_leeway), ) .map_err(|_| { AuthenticationError::unavailable("role-scope catalogue signature is not bound") @@ -491,14 +605,79 @@ impl JwksAuthenticationProvider { let mut cache = self.role_scope_cache.write().map_err(|_| { AuthenticationError::unavailable("role-scope catalogue cache is unavailable") })?; - *cache = Some(RoleScopeCache { + *cache = Some(Arc::new(RoleScopeCache { loaded_at: self.config.clock.now(), expires_at, provider, - }); + })); + self.role_scope_refresh_failures.store(0, Ordering::Release); + *self.role_scope_retry_after.write().map_err(|_| { + AuthenticationError::unavailable("role-scope catalogue retry state is unavailable") + })? = None; Ok(()) } + #[cfg(feature = "auth-agql")] + fn request_role_scope_refresh(&self, bypass_backoff: bool) { + if !bypass_backoff { + let retry_after = self + .role_scope_retry_after + .read() + .ok() + .and_then(|retry_after| *retry_after); + if retry_after.is_some_and(|retry_after| self.config.clock.now() < retry_after) { + return; + } + } + if self + .role_scope_refresh_in_flight + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + let Ok(handle) = hive_router::tokio::runtime::Handle::try_current() else { + self.role_scope_refresh_in_flight + .store(false, Ordering::Release); + return; + }; + let provider = self.clone(); + handle.spawn(async move { + if let Err(error) = provider.refresh_role_scope_catalogue().await { + let failure_count = provider + .role_scope_refresh_failures + .fetch_add(1, Ordering::AcqRel) + .saturating_add(1); + let exponent = failure_count.saturating_sub(1).min(63) as u32; + let multiplier = 1_u64.checked_shl(exponent).unwrap_or(u64::MAX); + let delay = provider + .config + .role_scope_catalogue + .as_ref() + .map(|config| { + config + .retry_backoff + .saturating_mul(multiplier.try_into().unwrap_or(u32::MAX)) + .min(config.maximum_retry_backoff) + }) + .unwrap_or_default(); + if let Some(retry_after) = provider.config.clock.now().checked_add(delay) + && let Ok(mut state) = provider.role_scope_retry_after.write() + { + *state = Some(retry_after); + } + tracing::warn!( + error = %error, + retry_delay_seconds = delay.as_secs(), + "role-scope catalogue refresh failed; preserving the last verified snapshot" + ); + } + provider + .role_scope_refresh_in_flight + .store(false, Ordering::Release); + }); + } + async fn refresh_verification_state(&self) -> Result<(), AuthenticationError> { self.refresh_keys().await?; #[cfg(feature = "auth-agql")] @@ -532,34 +711,62 @@ impl JwksAuthenticationProvider { roles: &[String], direct_scopes: Vec, ) -> Result, AuthenticationError> { - let Some(config) = &self.config.role_scope_catalogue else { - return Ok(direct_scopes); - }; if roles.is_empty() { return Ok(direct_scopes); } + let Some(config) = &self.config.role_scope_catalogue else { + return Err(AuthenticationError::unavailable( + "role-scope catalogue is not configured for authorization-role grants", + )); + }; let now = self.config.clock.now(); - let cache = self.role_scope_cache.read().map_err(|_| { - AuthenticationError::unavailable("role-scope catalogue cache is unavailable") - })?; - let cache = cache.as_ref().ok_or_else(|| { - AuthenticationError::unavailable("role-scope catalogue cache is not initialized") - })?; + let cache = self + .role_scope_cache + .read() + .map_err(|_| { + AuthenticationError::unavailable("role-scope catalogue cache is unavailable") + })? + .clone(); + let Some(cache) = cache else { + self.request_role_scope_refresh(false); + return Err(AuthenticationError::unavailable( + "role-scope catalogue cache is not initialized", + )); + }; let age = now.duration_since(cache.loaded_at).map_err(|_| { AuthenticationError::unavailable( "authentication clock moved before role-scope catalogue load time", ) })?; if age >= config.cache_ttl || now >= cache.expires_at { - return Err(AuthenticationError::unavailable( - "role-scope catalogue cache is stale", - )); + let count = self + .role_scope_stale_serves + .fetch_add(1, Ordering::Relaxed) + .saturating_add(1); + if count == 1 || count.is_power_of_two() { + tracing::warn!( + stale_serve_total = count, + stale_seconds = age.as_secs(), + "serving a stale signature-verified role-scope catalogue while refreshing" + ); + } + self.request_role_scope_refresh(false); } - let expansion = cache.provider.expand_roles(roles).map_err(|_| { + let expansion = cache.provider.expand_roles(roles).map_err(|error| { + if matches!(error, RoleScopeExpansionError::UnknownRole(_)) { + self.request_role_scope_refresh(true); + } AuthenticationError::unavailable("role-scope catalogue expansion failed") })?; Ok(effective_scopes(direct_scopes, &expansion)) } + + /// Returns the process-local count of requests served from a stale but + /// signature-verified role-scope snapshot. + #[cfg(feature = "auth-agql")] + pub fn role_scope_stale_serve_total(&self) -> u64 { + self.role_scope_stale_serves.load(Ordering::Relaxed) + } } impl fmt::Debug for JwksAuthenticationProvider { @@ -579,7 +786,7 @@ impl fmt::Debug for JwksAuthenticationProvider { impl AuthenticationProvider for JwksAuthenticationProvider { fn initialize(&self) -> BoxFuture<'_, Result<(), AuthenticationError>> { - Box::pin(async move { self.refresh_verification_state().await }) + Box::pin(async move { self.refresh_keys().await }) } fn authenticate_bearer( @@ -608,7 +815,7 @@ impl AuthenticationProvider for JwksAuthenticationProvider { validate_time_claims(&claims, self.config.clock.as_ref(), self.config.leeway)?; let scopes = parse_scope_claims(&claims.additional, self.config.legacy_scope_claims)?; #[cfg(feature = "auth-agql")] - let scopes = self.expand_roles(&claims.roles, scopes)?; + let scopes = self.expand_roles(&claims.authorization_roles, scopes)?; let expires_at = UNIX_EPOCH.checked_add(Duration::from_secs(claims.exp)); let expires_at = expires_at.ok_or_else(invalid_token)?; AuthenticatedPrincipal::new(claims.sub, scopes, Some(expires_at)) @@ -623,7 +830,16 @@ impl AuthenticationProvider for JwksAuthenticationProvider { } fn refresh(&self) -> BoxFuture<'_, Result<(), AuthenticationError>> { - Box::pin(async move { self.refresh_verification_state().await }) + Box::pin(async move { + let result = self.refresh_verification_state().await; + if let Err(error) = &result { + tracing::warn!( + error = %error, + "authentication refresh failed; preserving last-known-good verification state" + ); + } + result + }) } } @@ -647,7 +863,7 @@ struct JwtClaims { nbf: Option, #[cfg(feature = "auth-agql")] #[serde(default, deserialize_with = "deserialize_roles")] - roles: Vec, + authorization_roles: Vec, #[serde(flatten)] additional: BTreeMap, } @@ -658,16 +874,18 @@ where D: serde::Deserializer<'de>, { let mut roles = Vec::::deserialize(deserializer)?; - if roles.len() > MAX_ACCESS_TOKEN_ROLES + if roles.len() > MAX_ACCESS_TOKEN_AUTHORIZATION_ROLES || roles.iter().any(|role| { role.is_empty() - || role.len() > MAX_ACCESS_TOKEN_ROLE_LENGTH + || role.len() > MAX_ACCESS_TOKEN_AUTHORIZATION_ROLE_LENGTH || role .bytes() .any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control()) }) { - return Err(serde::de::Error::custom("invalid access-token roles")); + return Err(serde::de::Error::custom( + "invalid access-token authorization roles", + )); } roles.sort(); roles.dedup(); diff --git a/crates/graphql-orm-router/src/jwt/tests.rs b/crates/graphql-orm-router/src/jwt/tests.rs index b132d17e..cc7a6ecb 100644 --- a/crates/graphql-orm-router/src/jwt/tests.rs +++ b/crates/graphql-orm-router/src/jwt/tests.rs @@ -73,6 +73,8 @@ struct JwksFixture { body: Arc>, status: Arc, requests: Arc, + #[cfg(feature = "auth-agql")] + last_request: Arc>, stop: Arc, thread: Option>, } @@ -85,10 +87,14 @@ impl JwksFixture { let body = Arc::new(Mutex::new(body)); let status = Arc::new(AtomicU16::new(200)); let requests = Arc::new(AtomicUsize::new(0)); + #[cfg(feature = "auth-agql")] + let last_request = Arc::new(Mutex::new(String::new())); let stop = Arc::new(AtomicBool::new(false)); let thread_body = body.clone(); let thread_status = status.clone(); let thread_requests = requests.clone(); + #[cfg(feature = "auth-agql")] + let thread_last_request = last_request.clone(); let thread_stop = stop.clone(); let thread = thread::spawn(move || { while !thread_stop.load(Ordering::Relaxed) { @@ -96,7 +102,15 @@ impl JwksFixture { Ok((mut stream, _)) => { thread_requests.fetch_add(1, Ordering::Relaxed); let mut request = [0_u8; 2048]; + #[cfg(feature = "auth-agql")] + let bytes_read = stream.read(&mut request).unwrap_or_default(); + #[cfg(not(feature = "auth-agql"))] let _ = stream.read(&mut request); + #[cfg(feature = "auth-agql")] + { + *thread_last_request.lock().unwrap() = + String::from_utf8_lossy(&request[..bytes_read]).into_owned(); + } let body = thread_body.lock().unwrap().clone(); let status = thread_status.load(Ordering::Relaxed); let reason = if status == 200 { "OK" } else { "Unavailable" }; @@ -118,6 +132,8 @@ impl JwksFixture { body, status, requests, + #[cfg(feature = "auth-agql")] + last_request, stop, thread: Some(thread), } @@ -167,6 +183,11 @@ fn token(kid: &str, claims: JsonValue) -> String { #[cfg(feature = "auth-agql")] fn signed_catalogue(kid: &str, issued_at: i64, expires_at: i64) -> String { + signed_catalogue_with_role(kid, issued_at, expires_at, "inventory-operator") +} + +#[cfg(feature = "auth-agql")] +fn signed_catalogue_with_role(kid: &str, issued_at: i64, expires_at: i64, role_id: &str) -> String { let catalogue = RoleScopeCatalogue::new( "revision-7", [ @@ -175,7 +196,7 @@ fn signed_catalogue(kid: &str, issued_at: i64, expires_at: i64) -> String { RoleScopeDefinition::new("profile.read"), ], [RoleScopeGrant::new( - "inventory-operator", + role_id, "Inventory operator", ["inventory.read", "inventory.write"], )], @@ -255,6 +276,15 @@ fn refresh(provider: &JwksAuthenticationProvider) -> Result<(), AuthenticationEr .block_on(async move { provider.refresh().await }) } +#[cfg(feature = "auth-agql")] +fn refresh_catalogue(provider: &JwksAuthenticationProvider) -> Result<(), AuthenticationError> { + let provider = provider.clone(); + ntex::rt::System::build() + .name("role-scope-refresh-test") + .build(ntex::rt::DefaultRuntime) + .block_on(async move { provider.refresh_role_scope_catalogue().await }) +} + #[test] fn jwks_provider_validates_signature_identity_audience_time_and_scope() { let fixture = JwksFixture::start(jwks("key-a")); @@ -475,6 +505,8 @@ fn signed_role_catalogue_expands_roles_and_preserves_direct_scopes() { let provider = provider_with_catalogue(&keys, &catalogue, clock); initialize(&provider).unwrap(); assert_eq!(keys.requests.load(Ordering::Relaxed), 1); + assert_eq!(catalogue.requests.load(Ordering::Relaxed), 0); + refresh_catalogue(&provider).unwrap(); assert_eq!(catalogue.requests.load(Ordering::Relaxed), 1); let credential = token( @@ -482,7 +514,8 @@ fn signed_role_catalogue_expands_roles_and_preserves_direct_scopes() { json!({ "sub": "user-7", "iss": "https://issuer.test", "aud": "graphql-router", "exp": 1_100, "scope": "profile.read", - "roles": ["inventory-operator", "unknown-role"] + "roles": ["application-admin"], + "authorization_roles": ["inventory-operator"] }), ); assert_eq!( @@ -491,6 +524,34 @@ fn signed_role_catalogue_expands_roles_and_preserves_direct_scopes() { ); } +#[cfg(feature = "auth-agql")] +#[test] +fn role_catalogue_request_headers_are_applied_and_redacted() { + let keys = JwksFixture::start(jwks("key-a")); + let catalogue = JwksFixture::start(signed_catalogue("key-a", 1_000, 1_010)); + let role_scope = RoleScopeCatalogueConfig::new(catalogue.url(), "role-catalogue-clients") + .unwrap() + .with_cache_ttl(Duration::from_secs(10)) + .allow_insecure_loopback_http_for_development(true) + .with_request_header("x-catalogue-token", "catalogue-secret") + .unwrap(); + let config = + JwksAuthenticationConfig::new(keys.url(), "https://issuer.test", ["graphql-router"]) + .unwrap() + .with_cache_ttl(Duration::from_secs(100)) + .with_refresh_interval(Duration::from_secs(5)) + .with_clock(Arc::new(TestClock::at(1_000))) + .allow_insecure_loopback_http_for_development(true) + .with_role_scope_catalogue(role_scope); + let provider = JwksAuthenticationProvider::new(config).unwrap(); + assert!(!format!("{provider:?}").contains("catalogue-secret")); + + initialize(&provider).unwrap(); + refresh_catalogue(&provider).unwrap(); + let request = catalogue.last_request.lock().unwrap().to_ascii_lowercase(); + assert!(request.contains("x-catalogue-token: catalogue-secret")); +} + #[cfg(feature = "auth-agql")] #[test] fn role_catalogue_is_mandatory_current_and_verified_for_role_bearers() { @@ -499,11 +560,12 @@ fn role_catalogue_is_mandatory_current_and_verified_for_role_bearers() { let clock = Arc::new(TestClock::at(1_000)); let provider = provider_with_catalogue(&keys, &catalogue, clock.clone()); initialize(&provider).unwrap(); + refresh_catalogue(&provider).unwrap(); let role_credential = token( "key-a", json!({ "sub": "user-7", "iss": "https://issuer.test", "aud": "graphql-router", - "exp": 1_100, "roles": ["inventory-operator"] + "exp": 1_100, "authorization_roles": ["inventory-operator"] }), ); provider.authenticate_bearer(&role_credential).unwrap(); @@ -518,10 +580,11 @@ fn role_catalogue_is_mandatory_current_and_verified_for_role_bearers() { assert_eq!( provider .authenticate_bearer(&role_credential) - .unwrap_err() - .kind(), - AuthenticationErrorKind::Unavailable + .unwrap() + .scopes(), + &["inventory.read", "inventory.write"] ); + assert_eq!(provider.role_scope_stale_serve_total(), 1); let direct_only = token( "key-a", @@ -539,7 +602,7 @@ fn role_catalogue_is_mandatory_current_and_verified_for_role_bearers() { "key-a", json!({ "sub": "user-7", "iss": "https://issuer.test", "aud": "graphql-router", - "exp": 1_100, "roles": ["bad role"] + "exp": 1_100, "authorization_roles": ["bad role"] }), ); assert_eq!( @@ -550,12 +613,251 @@ fn role_catalogue_is_mandatory_current_and_verified_for_role_bearers() { #[cfg(feature = "auth-agql")] #[test] -fn forged_role_catalogue_fails_router_initialization() { +fn forged_role_catalogue_does_not_block_startup_and_role_bearers_fail_closed() { let keys = JwksFixture::start(jwks("key-a")); let catalogue = JwksFixture::start(signed_catalogue("other-key", 1_000, 1_010)); let provider = provider_with_catalogue(&keys, &catalogue, Arc::new(TestClock::at(1_000))); + initialize(&provider).unwrap(); + assert_eq!(catalogue.requests.load(Ordering::Relaxed), 0); + let role_credential = token( + "key-a", + json!({ + "sub": "user-7", "iss": "https://issuer.test", "aud": "graphql-router", + "exp": 1_100, "authorization_roles": ["inventory-operator"] + }), + ); assert_eq!( - initialize(&provider).unwrap_err().kind(), - AuthenticationErrorKind::InvalidCredential + provider + .authenticate_bearer(&role_credential) + .unwrap_err() + .kind(), + AuthenticationErrorKind::Unavailable + ); +} + +#[cfg(feature = "auth-agql")] +#[test] +fn first_role_catalogue_fetch_is_lazy_and_retried_on_demand() { + let keys = JwksFixture::start(jwks("key-a")); + let catalogue = JwksFixture::start(signed_catalogue("key-a", 1_000, 1_010)); + catalogue.set_status(503); + let provider = provider_with_catalogue(&keys, &catalogue, Arc::new(TestClock::at(1_000))); + initialize(&provider).unwrap(); + assert_eq!(catalogue.requests.load(Ordering::Relaxed), 0); + catalogue.set_status(200); + + let credential = token( + "key-a", + json!({ + "sub": "user-7", "iss": "https://issuer.test", "aud": "graphql-router", + "exp": 1_100, "authorization_roles": ["inventory-operator"] + }), + ); + let provider_for_request = provider.clone(); + let credential_for_request = credential.clone(); + let request_count = catalogue.requests.clone(); + let request_count_for_wait = request_count.clone(); + let first = ntex::rt::System::build() + .name("lazy-role-scope-fetch-test") + .build(ntex::rt::DefaultRuntime) + .block_on(async move { + let result = provider_for_request.authenticate_bearer(&credential_for_request); + for _ in 0..100 { + if request_count_for_wait.load(Ordering::Relaxed) >= 1 + && !provider_for_request + .role_scope_refresh_in_flight + .load(Ordering::Acquire) + { + break; + } + hive_router::tokio::time::sleep(Duration::from_millis(2)).await; + } + result + }); + assert_eq!( + first.unwrap_err().kind(), + AuthenticationErrorKind::Unavailable + ); + assert_eq!(request_count.load(Ordering::Relaxed), 1); + assert_eq!( + provider.authenticate_bearer(&credential).unwrap().scopes(), + &["inventory.read", "inventory.write"] + ); +} + +#[cfg(feature = "auth-agql")] +#[test] +fn failed_lazy_catalogue_fetch_observes_retry_backoff() { + let keys = JwksFixture::start(jwks("key-a")); + let catalogue = JwksFixture::start(signed_catalogue("key-a", 1_000, 1_010)); + catalogue.set_status(503); + let clock = Arc::new(TestClock::at(1_000)); + let provider = provider_with_catalogue(&keys, &catalogue, clock.clone()); + initialize(&provider).unwrap(); + let credential = token( + "key-a", + json!({ + "sub": "user-7", "iss": "https://issuer.test", "aud": "graphql-router", + "exp": 1_100, "authorization_roles": ["inventory-operator"] + }), + ); + + let provider_for_request = provider.clone(); + let credential_for_request = credential.clone(); + let request_count = catalogue.requests.clone(); + ntex::rt::System::build() + .name("role-scope-backoff-test") + .build(ntex::rt::DefaultRuntime) + .block_on(async move { + assert!( + provider_for_request + .authenticate_bearer(&credential_for_request) + .is_err() + ); + for _ in 0..100 { + if request_count.load(Ordering::Relaxed) >= 1 + && !provider_for_request + .role_scope_refresh_in_flight + .load(Ordering::Acquire) + { + break; + } + hive_router::tokio::time::sleep(Duration::from_millis(2)).await; + } + assert!( + provider_for_request + .authenticate_bearer(&credential_for_request) + .is_err() + ); + hive_router::tokio::time::sleep(Duration::from_millis(5)).await; + }); + assert_eq!(catalogue.requests.load(Ordering::Relaxed), 1); + + catalogue.set_status(200); + clock.set(1_001); + let provider_for_request = provider.clone(); + let credential_for_request = credential.clone(); + let request_count = catalogue.requests.clone(); + ntex::rt::System::build() + .name("role-scope-backoff-retry-test") + .build(ntex::rt::DefaultRuntime) + .block_on(async move { + assert!( + provider_for_request + .authenticate_bearer(&credential_for_request) + .is_err() + ); + for _ in 0..100 { + if request_count.load(Ordering::Relaxed) >= 2 + && !provider_for_request + .role_scope_refresh_in_flight + .load(Ordering::Acquire) + { + break; + } + hive_router::tokio::time::sleep(Duration::from_millis(2)).await; + } + }); + assert_eq!(catalogue.requests.load(Ordering::Relaxed), 2); + assert_eq!( + provider.authenticate_bearer(&credential).unwrap().scopes(), + &["inventory.read", "inventory.write"] + ); +} + +#[cfg(feature = "auth-agql")] +#[test] +fn application_roles_do_not_require_or_feed_catalogue_expansion() { + let keys = JwksFixture::start(jwks("key-a")); + let provider = provider( + &keys, + Arc::new(TestClock::at(1_000)), + LegacyScopeClaims::Reject, + ); + initialize(&provider).unwrap(); + let application_role = token( + "key-a", + json!({ + "sub": "user-7", "iss": "https://issuer.test", "aud": "graphql-router", + "exp": 1_100, "scope": "profile.read", "roles": ["application-admin"] + }), + ); + assert_eq!( + provider + .authenticate_bearer(&application_role) + .unwrap() + .scopes(), + &["profile.read"] + ); + + let authorization_role = token( + "key-a", + json!({ + "sub": "user-7", "iss": "https://issuer.test", "aud": "graphql-router", + "exp": 1_100, "scope": "profile.read", + "authorization_roles": ["inventory-operator"] + }), + ); + assert_eq!( + provider + .authenticate_bearer(&authorization_role) + .unwrap_err() + .kind(), + AuthenticationErrorKind::Unavailable + ); +} + +#[cfg(feature = "auth-agql")] +#[test] +fn unknown_authorization_role_fails_instead_of_dropping_inherited_scopes() { + let keys = JwksFixture::start(jwks("key-a")); + let catalogue = JwksFixture::start(signed_catalogue("key-a", 1_000, 1_010)); + let provider = provider_with_catalogue(&keys, &catalogue, Arc::new(TestClock::at(1_000))); + initialize(&provider).unwrap(); + refresh_catalogue(&provider).unwrap(); + catalogue.replace(signed_catalogue_with_role( + "key-a", + 1_000, + 1_010, + "replacement-role", + )); + let credential = token( + "key-a", + json!({ + "sub": "user-7", "iss": "https://issuer.test", "aud": "graphql-router", + "exp": 1_100, "scope": "profile.read", + "authorization_roles": ["replacement-role"] + }), + ); + let provider_for_request = provider.clone(); + let credential_for_request = credential.clone(); + let request_count = catalogue.requests.clone(); + let request_count_for_wait = request_count.clone(); + let request_result = ntex::rt::System::build() + .name("role-scope-refresh-request-test") + .build(ntex::rt::DefaultRuntime) + .block_on(async move { + let result = provider_for_request.authenticate_bearer(&credential_for_request); + for _ in 0..100 { + if request_count_for_wait.load(Ordering::Relaxed) >= 2 + && !provider_for_request + .role_scope_refresh_in_flight + .load(Ordering::Acquire) + { + break; + } + hive_router::tokio::time::sleep(Duration::from_millis(2)).await; + } + result + }); + assert_eq!( + request_result.unwrap_err().kind(), + AuthenticationErrorKind::Unavailable + ); + assert!(request_count.load(Ordering::Relaxed) >= 2); + + assert_eq!( + provider.authenticate_bearer(&credential).unwrap().scopes(), + &["inventory.read", "inventory.write", "profile.read"] ); } diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index a2b5310a..fe550540 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.25.1" +version = "0.26.0" edition = "2024" description = "Runtime support crate for graphql-orm-macros" license = "MIT" @@ -65,7 +65,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.25.1", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.26.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 c4fcbc93..1b0650a9 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.25.1", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.26.0", 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/tests/backend_coexistence_fixture.rs b/crates/graphql-orm/tests/backend_coexistence_fixture.rs index 7ab70502..5adf2f42 100644 --- a/crates/graphql-orm/tests/backend_coexistence_fixture.rs +++ b/crates/graphql-orm/tests/backend_coexistence_fixture.rs @@ -66,13 +66,13 @@ fn assert_direct_host_dependency_resolves_one_exact_agql_auth_universe() { .filter(|package| package["name"] == "agql-auth") .collect::>(); assert_eq!(agql_auth.len(), 1, "resolved metadata:\n{metadata}"); - assert_eq!(agql_auth[0]["version"], "0.17.1"); + assert_eq!(agql_auth[0]["version"], "0.18.0"); let source = agql_auth[0]["source"] .as_str() .expect("agql-auth source must be present"); assert!( - source.contains("rev=b792241b7d9bb46bad81dde4534ae5b39857f614") - && source.ends_with("#b792241b7d9bb46bad81dde4534ae5b39857f614"), + source.contains("rev=527d15d28e3c295a6f6b5e6d74559a7aecdc1322") + && source.ends_with("#527d15d28e3c295a6f6b5e6d74559a7aecdc1322"), "unexpected agql-auth source: {source}", ); } diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index 06f00ed2..98b9f585 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock @@ -10,8 +10,8 @@ checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" [[package]] name = "agql-auth" -version = "0.17.1" -source = "git+https://github.com/Dastari/agql-auth.git?rev=b792241b7d9bb46bad81dde4534ae5b39857f614#b792241b7d9bb46bad81dde4534ae5b39857f614" +version = "0.18.0" +source = "git+https://github.com/Dastari/agql-auth.git?rev=527d15d28e3c295a6f6b5e6d74559a7aecdc1322#527d15d28e3c295a6f6b5e6d74559a7aecdc1322" dependencies = [ "argon2", "async-graphql", @@ -1304,7 +1304,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.25.1" +version = "0.26.0" dependencies = [ "agql-auth", "async-graphql", @@ -1329,7 +1329,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.90.1" +version = "0.91.0" dependencies = [ "agql-auth", "async-graphql", @@ -1372,7 +1372,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.25.1" +version = "0.26.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/auth-service/Cargo.toml b/crates/graphql-orm/tests/fixtures/backend-coexistence/auth-service/Cargo.toml index 73834438..dba1097f 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/auth-service/Cargo.toml +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/auth-service/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -agql-auth = { git = "https://github.com/Dastari/agql-auth.git", rev = "b792241b7d9bb46bad81dde4534ae5b39857f614", version = "0.17.1" } +agql-auth = { git = "https://github.com/Dastari/agql-auth.git", rev = "527d15d28e3c295a6f6b5e6d74559a7aecdc1322", version = "0.18.0" } async-graphql = { version = "7", features = ["dataloader", "uuid"] } graphql-orm = { path = "../../../../", default-features = false, features = [ "sqlite", diff --git a/docs/reference/graphql-orm/agql-auth-bridge.md b/docs/reference/graphql-orm/agql-auth-bridge.md index da3c03d5..de6ec1df 100644 --- a/docs/reference/graphql-orm/agql-auth-bridge.md +++ b/docs/reference/graphql-orm/agql-auth-bridge.md @@ -16,7 +16,7 @@ supersedes: [] ## Dependency ```toml -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.25.1", features = ["sqlite", "auth-agql"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.26.0", features = ["sqlite", "auth-agql"] } # Host applications may depend on agql-auth directly as well. The optional # graphql-orm auth-agql feature pins the exact upstream release: # git = "https://github.com/Dastari/agql-auth.git" diff --git a/docs/reference/graphql-orm/backends.md b/docs/reference/graphql-orm/backends.md index a2015cb5..e2ba4aba 100644 --- a/docs/reference/graphql-orm/backends.md +++ b/docs/reference/graphql-orm/backends.md @@ -17,7 +17,7 @@ database schema. Schema ownership and migration behavior are controlled by runti ## Features ```toml -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.25.1", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.26.0", default-features = false, features = ["sqlite"] } ``` Available backend features: diff --git a/docs/reference/graphql-orm/mssql.md b/docs/reference/graphql-orm/mssql.md index a0e5ec1a..bd91bd07 100644 --- a/docs/reference/graphql-orm/mssql.md +++ b/docs/reference/graphql-orm/mssql.md @@ -23,7 +23,7 @@ not provide an MSSQL driver. For a service that only uses SQL Server, select the `mssql` backend feature: ```toml -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.25.1", default-features = false, features = ["mssql"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.26.0", default-features = false, features = ["mssql"] } ``` When exactly one of `sqlite`, `postgres`, or `mssql` is enabled, the legacy implicit backend remains diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 0de8af57..8fb08d60 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -18,19 +18,19 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | -| `graphql-orm` | `0.25.1` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | -| `graphql-orm-ai` | `0.90.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | +| `graphql-orm` | `0.26.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm-ai` | `0.91.0` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.9.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.7.1` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | -| `graphql-orm-macros` | `0.25.1` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.26.0` | `crates/graphql-orm-macros` | `sqlite` | none | | `graphql-orm-operation-catalog` | `0.3.0` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | -| `graphql-orm-router` | `0.3.1` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | +| `graphql-orm-router` | `0.4.0` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none | | `graphql-orm-storage` | `0.6.1` | `crates/graphql-orm-storage` | `local` | none | External exact-revision dependency: -- `agql-auth` requirement `^0.17.1`, source `git+https://github.com/Dastari/agql-auth.git?rev=b792241b7d9bb46bad81dde4534ae5b39857f614`, consumed by `graphql-orm`, `graphql-orm-ai`, `graphql-orm-router`. +- `agql-auth` requirement `^0.18.0`, source `git+https://github.com/Dastari/agql-auth.git?rev=527d15d28e3c295a6f6b5e6d74559a7aecdc1322`, consumed by `graphql-orm`, `graphql-orm-ai`, `graphql-orm-router`.