From b1061f929b22355b589495ecdf0dd2c7928c4523 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Sat, 22 Aug 2026 18:39:09 +1000 Subject: [PATCH 1/3] feat(router): expand signed authorization roles --- CHANGELOG.md | 13 + Cargo.lock | 12 +- Cargo.toml | 4 +- MIGRATION.md | 9 + README.md | 4 +- crates/graphql-orm-ai/CHANGELOG.md | 12 + crates/graphql-orm-ai/Cargo.toml | 2 +- crates/graphql-orm-ai/MIGRATION.md | 8 + 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 | 17 + crates/graphql-orm-router/Cargo.toml | 2 +- crates/graphql-orm-router/MIGRATION.md | 20 + crates/graphql-orm-router/README.md | 4 +- .../graphql-orm-router/docs/configuration.md | 16 + .../examples/router.example.json | 8 +- crates/graphql-orm-router/src/file_config.rs | 83 +++- crates/graphql-orm-router/src/jwt.rs | 405 +++++++++++++++--- crates/graphql-orm-router/src/jwt/tests.rs | 147 +++++++ crates/graphql-orm-router/src/lib.rs | 2 +- crates/graphql-orm/Cargo.toml | 4 +- crates/graphql-orm/README.md | 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 +- 28 files changed, 717 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ffd88ff..8040d5a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,19 @@ 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.25.0 - 2026-08-22 + +Companion macros crate: `graphql-orm-macros` **0.25.0**. Generated ORM and +database behavior are unchanged. + +- Aligned the optional `auth-agql` bridge to `agql-auth` 0.17.0 at merged full + revision `82650a447f3b6875160254fa1293b3a0e305c224` so hosts share the neutral + role-scope catalogue and principal type universe. +- The bridge projection, assurance evaluation, database policy, schema, and + generated resolver behavior are unchanged. +- The companion macro version remains aligned as required by the workspace + release contract; its emitted code and public behavior are unchanged. + ## 0.24.0 - 2026-08-22 Companion macros crate: `graphql-orm-macros` **0.24.0**. Generated ORM and diff --git a/Cargo.lock b/Cargo.lock index e5b8afac..ad8697a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,8 +87,8 @@ dependencies = [ [[package]] name = "agql-auth" -version = "0.16.0" -source = "git+https://github.com/Dastari/agql-auth.git?rev=3bc38cd94794f1e868a9cc3a5551047b95a32105#3bc38cd94794f1e868a9cc3a5551047b95a32105" +version = "0.17.0" +source = "git+https://github.com/Dastari/agql-auth.git?rev=82650a447f3b6875160254fa1293b3a0e305c224#82650a447f3b6875160254fa1293b3a0e305c224" dependencies = [ "argon2", "async-graphql", @@ -3075,7 +3075,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.24.0" +version = "0.25.0" dependencies = [ "agql-auth", "async-graphql", @@ -3104,7 +3104,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.89.0" +version = "0.90.0" dependencies = [ "agql-auth", "async-graphql", @@ -3172,7 +3172,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.24.0" +version = "0.25.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", @@ -3193,7 +3193,7 @@ dependencies = [ [[package]] name = "graphql-orm-router" -version = "0.2.0" +version = "0.3.0" dependencies = [ "agql-auth", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 2aad7630..42cd2956 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 = "3bc38cd94794f1e868a9cc3a5551047b95a32105", version = "0.16.0" } +agql-auth = { git = "https://github.com/Dastari/agql-auth.git", rev = "82650a447f3b6875160254fa1293b3a0e305c224", version = "0.17.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.24.0", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.25.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" } diff --git a/MIGRATION.md b/MIGRATION.md index 6f7f8c97..81ca4a54 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -13,6 +13,15 @@ supersedes: [] `graphql-orm` is distributed from GitHub only. Use a reviewed full 40-character commit in `rev`; neither the runtime nor macros crate is published to crates.io. +## 0.24.0 to 0.25.0: agql-auth 0.17 alignment + +Git consumers using `auth-agql` must align direct `agql-auth` dependencies to +0.17.0 at merged revision `82650a447f3b6875160254fa1293b3a0e305c224`. +The role-scope expansion contract is additive and opt-in; the ORM bridge does +not fetch catalogues or change authorization decisions. No schema, migration, +generated code, or database-policy change is required. Adopt aligned +`graphql-orm` and `graphql-orm-macros` 0.25.0 from the same reviewed revision. + ## 0.23.0 to 0.24.0: agql-auth 0.16 alignment Hosts enabling `auth-agql` or depending directly on `agql-auth` must align to diff --git a/README.md b/README.md index ecca1eb5..59032dee 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.23.0. Replace the placeholder below with the final +`graphql-orm` version is 0.25.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.24.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.25.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 bace13c6..bea03d56 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -18,6 +18,18 @@ 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.90.0] - 2026-08-22 + +Persistent schema module: **0.63.0** (unchanged from 0.89.0). + +### Changed + +- Aligned the public `agql-auth` type universe to 0.17.0 at merged revision + `82650a447f3b6875160254fa1293b3a0e305c224`. Consumers using the AI prelude's + re-exported principal types must resolve the same full revision. +- AI authorization, principal rehydration, providers, persistence, GraphQL SDL, + backup, and restore behavior are unchanged. + ## [0.89.0] - 2026-08-22 Persistent schema module: **0.63.0** (unchanged from 0.88.0). diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index 282daf02..b0fcc4b3 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.89.0" +version = "0.90.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 02e2db84..07366a2d 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -19,6 +19,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.89.0 to 0.90.0: agql-auth 0.17 type-universe alignment + +Adopt `graphql-orm-ai` 0.90.0 from one reviewed full monorepo revision and +align every direct `agql-auth` dependency to version 0.17.0 at merged revision +`82650a447f3b6875160254fa1293b3a0e305c224`. The change is dependency-only for +the AI crate; no schema, data, provider, backup, restore, or runtime migration +is required. + ## 0.88.0 to 0.89.0: agql-auth 0.16 type-universe alignment Adopt `graphql-orm-ai` 0.89.0 from one reviewed full monorepo revision and diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index 826460f3..806f585a 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.89.0", default-features = false, features = ["sqlite"] } +graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.90.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 faba2761..6d1ef806 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.89.0` with AI schema module -`0.63.0`. It uses workspace `graphql-orm` `0.24.0`, backend-neutral +`graphql-orm-ai` is at crate version `0.90.0` with AI schema module +`0.63.0`. It uses workspace `graphql-orm` `0.25.0`, backend-neutral `graphql-orm-ai-tool-profiles` `0.9.0`, and external `agql-auth` -`0.16.0` at `3bc38cd94794f1e868a9cc3a5551047b95a32105`. +`0.17.0` at `82650a447f3b6875160254fa1293b3a0e305c224`. 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 a7bbe756..9b61dc5a 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.24.0" +version = "0.25.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 e6fde125..6828b069 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.24.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.25.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.24.0", default-features = false, features = ["sqlite"] } +graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.25.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 ca5507e0..68438880 100644 --- a/crates/graphql-orm-router/CHANGELOG.md +++ b/crates/graphql-orm-router/CHANGELOG.md @@ -10,6 +10,23 @@ supersedes: [] # Changelog +## 0.3.0 - 2026-08-22 + +- Added optional strict `authentication.roleScopeCatalogue` configuration for + a bounded, signed, versioned role-to-scope catalogue fetched alongside JWKS. +- Access-token roles are validated and expanded through the neutral + `agql-auth` 0.17.0 provider contract. Expanded scopes are unioned with direct + scopes before the existing preflight matcher runs. +- Initial retrieval, signature/key/issuer/audience/purpose binding, catalogue + structure, and lifetime are mandatory before readiness. Failed refresh keeps + a still-current complete snapshot; role-bearing credentials fail closed once + it is stale or expired. Direct-scope-only credentials remain independent. + +No membership, role name, scope value, private key, issuer endpoint, or cache +authority is supplied by the router. Resolver authorization remains +authoritative. Omission preserves 0.2.0 behavior; the optional catalogue +requires the `auth-agql` feature. + ## 0.2.0 - 2026-08-22 - Added strict `scopeMatcher` file configuration. Omission and `kind: exact` diff --git a/crates/graphql-orm-router/Cargo.toml b/crates/graphql-orm-router/Cargo.toml index b336356f..c15ab119 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.2.0" +version = "0.3.0" edition = "2024" rust-version = "1.90" description = "Federated GraphQL router for graphql-orm and project-neutral subgraphs" diff --git a/crates/graphql-orm-router/MIGRATION.md b/crates/graphql-orm-router/MIGRATION.md index 98b15912..c4a10f94 100644 --- a/crates/graphql-orm-router/MIGRATION.md +++ b/crates/graphql-orm-router/MIGRATION.md @@ -10,6 +10,26 @@ supersedes: [] # graphql-orm-router migration guide +## 0.2.0 to 0.3.0 + +Align direct `agql-auth` consumers to 0.17.0 at the reviewed full revision. +Existing configurations remain unchanged when `roleScopeCatalogue` is omitted. + +To adopt compact role grants, first publish a signed +`agql_auth::SignedRoleScopeCatalogue`, then build the router with `auth-agql` +and configure `authentication.roleScopeCatalogue` with its HTTPS URL, exact +signature audience, cache TTL, and body limit. The authentication refresh +interval must be shorter than the catalogue TTL. Run `--check` while the +catalogue and JWKS endpoints are available; initial verification is a readiness +gate. + +Only after every resource server expands the same verified role IDs should an +issuer remove expanded scopes from access tokens. A role-bearing token is +denied when the catalogue is absent, forged, expired, stale, or invalid. A +failed refresh preserves a still-current snapshot. Roll back by restoring +expanded scope issuance and removing `roleScopeCatalogue` after all compact +tokens expire. + ## 0.1.4 to 0.2.0 Replace the reviewed full-revision pin and rebuild the router. Hosts that also diff --git a/crates/graphql-orm-router/README.md b/crates/graphql-orm-router/README.md index 2a3a888a..94326376 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.2.0" } +graphql-orm-router = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.3.0" } ``` Enable `auth-agql` only when adapting a separately configured @@ -67,7 +67,7 @@ composition warnings before serving. | Surface | Default or boundary | | --- | --- | -| Authentication | Fail-closed; anonymous mode is explicit development-only. | +| Authentication | Fail-closed; optional signed role expansion requires `auth-agql`. | | Scope matching | Exact by default; hierarchical matching requires `auth-agql` and explicit file or programmatic configuration. | | Public path | `/graphql`; `/health` and `/readiness` are also exposed. | | Subgraphs | At least one static source; file configuration requires strict JSON. | diff --git a/crates/graphql-orm-router/docs/configuration.md b/crates/graphql-orm-router/docs/configuration.md index 98ae3628..2dc8df4c 100644 --- a/crates/graphql-orm-router/docs/configuration.md +++ b/crates/graphql-orm-router/docs/configuration.md @@ -43,6 +43,7 @@ The public field-level source contracts are: | [`RouterTelemetryConfig`](../src/config.rs) | Log and optional Prometheus listener settings. | | [`AdminConfig`](../src/config.rs) | Separate authenticated administration listener and scopes. | | [`JwksAuthenticationConfig`](../src/jwt.rs) | RS256 public-key verification, issuer/audience, cache, and bounded JWKS fetch. | +| [`RoleScopeCatalogueConfig`](../src/jwt.rs) | Optional signed role-to-scope catalogue retrieval and cache bounds. | | [`NetworkPolicy`](../src/network.rs) | Dynamic destination host/port/CIDR/DNS policy. | | [`RouterFileConfig`](../src/file_config.rs) | Strict file representation and environment-secret mapping. | @@ -86,6 +87,21 @@ 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. + 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/examples/router.example.json b/crates/graphql-orm-router/examples/router.example.json index 7fa1dde6..34680e5b 100644 --- a/crates/graphql-orm-router/examples/router.example.json +++ b/crates/graphql-orm-router/examples/router.example.json @@ -6,7 +6,13 @@ "issuer": "https://identity.example", "audiences": ["graphql-router"], "refreshIntervalSeconds": 300, - "cacheTtlSeconds": 900 + "cacheTtlSeconds": 900, + "roleScopeCatalogue": { + "url": "https://identity.example/auth/catalogue", + "audience": "graphql-resource-servers", + "cacheTtlSeconds": 900, + "maxBodyBytes": 1048576 + } }, "scopeMatcher": { "kind": "exact" diff --git a/crates/graphql-orm-router/src/file_config.rs b/crates/graphql-orm-router/src/file_config.rs index d066cf3b..1ee4e5f4 100644 --- a/crates/graphql-orm-router/src/file_config.rs +++ b/crates/graphql-orm-router/src/file_config.rs @@ -12,9 +12,9 @@ use agql_auth::{HierarchicalScopeMatch, HierarchicalScopeOptions}; use crate::AgqlScopeMatcher; use crate::{ AdminConfig, ExactScopeMatcher, JwksAuthenticationConfig, JwksAuthenticationProvider, - LegacyScopeClaims, NetworkCidr, NetworkPolicy, RequestLimits, RouterConfig, RouterError, - RouterErrorKind, RouterLogLevel, RouterTelemetryConfig, StaticSubgraph, SubscriptionConfig, - TrustedSubgraph, + LegacyScopeClaims, NetworkCidr, NetworkPolicy, RequestLimits, RoleScopeCatalogueConfig, + RouterConfig, RouterError, RouterErrorKind, RouterLogLevel, RouterTelemetryConfig, + StaticSubgraph, SubscriptionConfig, TrustedSubgraph, }; const MAX_CONFIG_BYTES: u64 = 1024 * 1024; @@ -129,6 +129,20 @@ impl RouterFileConfig { .allow_insecure_loopback_http_for_development( authentication.allow_insecure_loopback_jwks, ); + if let Some(catalogue) = authentication.role_scope_catalogue { + let mut role_scope = + 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(bytes) = catalogue.max_body_bytes { + role_scope = role_scope.with_max_body_bytes(bytes); + } + role_scope = role_scope.allow_insecure_loopback_http_for_development( + catalogue.allow_insecure_loopback_http, + ); + jwks = jwks.with_role_scope_catalogue(role_scope); + } config = config .with_authentication_provider(Arc::new(JwksAuthenticationProvider::new(jwks)?)); } @@ -271,6 +285,18 @@ struct FileAuthentication { accept_legacy_scopes: bool, #[serde(default)] allow_insecure_loopback_jwks: bool, + role_scope_catalogue: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct FileRoleScopeCatalogue { + url: String, + audience: String, + cache_ttl_seconds: Option, + max_body_bytes: Option, + #[serde(default)] + allow_insecure_loopback_http: bool, } /// File-owned scope policy. Omission and `kind: exact` preserve the router's @@ -846,6 +872,57 @@ mod tests { ); } + #[cfg(feature = "auth-agql")] + #[test] + fn signed_role_scope_catalogue_file_configuration_is_strict_and_opt_in() { + let json = FILE.replacen( + r#""audiences": ["router"]"#, + r#""audiences": ["router"], + "refreshIntervalSeconds": 30, + "roleScopeCatalogue": { + "url": "https://identity.example/role-scopes", + "audience": "resource-servers", + "cacheTtlSeconds": 120, + "maxBodyBytes": 262144 + }"#, + 1, + ); + RouterFileConfig::from_json(&json) + .unwrap() + .into_router_config_with(|name| { + Ok((name == "PRODUCTS_SCHEMA_TOKEN").then(|| "Bearer secret".to_owned())) + }) + .unwrap(); + + let unknown = json.replacen( + r#""maxBodyBytes": 262144"#, + r#""maxBodyBytes": 262144, "consumerPolicy": true"#, + 1, + ); + assert!(RouterFileConfig::from_json(&unknown).is_err()); + } + + #[cfg(not(feature = "auth-agql"))] + #[test] + fn signed_role_scope_catalogue_requires_auth_agql_feature() { + let json = FILE.replacen( + r#""audiences": ["router"]"#, + r#""audiences": ["router"], + "roleScopeCatalogue": { + "url": "https://identity.example/role-scopes", + "audience": "resource-servers" + }"#, + 1, + ); + let error = RouterFileConfig::from_json(&json) + .unwrap() + .into_router_config_with(|name| { + Ok((name == "PRODUCTS_SCHEMA_TOKEN").then(|| "Bearer secret".to_owned())) + }) + .unwrap_err(); + assert!(error.to_string().contains("auth-agql")); + } + #[cfg(feature = "auth-agql")] #[test] fn hierarchical_file_matcher_rejects_bare_wildcard_exact_only_pattern() { diff --git a/crates/graphql-orm-router/src/jwt.rs b/crates/graphql-orm-router/src/jwt.rs index 16b222a8..bba845cd 100644 --- a/crates/graphql-orm-router/src/jwt.rs +++ b/crates/graphql-orm-router/src/jwt.rs @@ -6,6 +6,11 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; +#[cfg(feature = "auth-agql")] +use agql_auth::{ + RoleScopeCatalogueClaims, RoleScopeExpansionProvider, SignedRoleScopeCatalogue, + StaticRoleScopeExpansion, effective_scopes, +}; use futures::{StreamExt, future::BoxFuture}; use jsonwebtoken::{ Algorithm, DecodingKey, Validation, decode, decode_header, @@ -25,8 +30,97 @@ const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(15 * 60); 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 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; +#[cfg(feature = "auth-agql")] +const MAX_ACCESS_TOKEN_ROLE_LENGTH: usize = 512; + +/// Remote signed role-scope catalogue configuration. +#[derive(Clone)] +pub struct RoleScopeCatalogueConfig { + url: Url, + audience: String, + cache_ttl: Duration, + max_body_bytes: usize, + allow_insecure_loopback_http: bool, +} + +impl RoleScopeCatalogueConfig { + /// Creates secure defaults for one catalogue URL and signature audience. + pub fn new(url: impl AsRef, audience: impl Into) -> Result { + let url = Url::parse(url.as_ref()).map_err(|_| { + invalid_configuration("role-scope catalogue URL is not a valid absolute URL") + })?; + Ok(Self { + url, + audience: audience.into(), + cache_ttl: DEFAULT_CACHE_TTL, + max_body_bytes: DEFAULT_MAX_ROLE_SCOPE_CATALOGUE_BYTES, + allow_insecure_loopback_http: false, + }) + } + + /// Sets the maximum age and signed lifetime accepted for one snapshot. + pub fn with_cache_ttl(mut self, cache_ttl: Duration) -> Self { + self.cache_ttl = cache_ttl; + 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; + self + } + + /// Permits plain HTTP only for an explicit loopback development endpoint. + pub fn allow_insecure_loopback_http_for_development(mut self, allow: bool) -> Self { + self.allow_insecure_loopback_http = allow; + self + } + + #[cfg(feature = "auth-agql")] + fn validate(&self, refresh_interval: Duration) -> Result<(), RouterError> { + if self.audience.trim().is_empty() { + return Err(invalid_configuration( + "role-scope catalogue audience must not be empty", + )); + } + if self.cache_ttl.is_zero() || refresh_interval >= self.cache_ttl { + return Err(invalid_configuration( + "role-scope catalogue cache TTL must be greater than the authentication refresh interval", + )); + } + if self.max_body_bytes == 0 { + return Err(invalid_configuration( + "role-scope catalogue body limit must be greater than zero", + )); + } + validate_public_resource_url( + &self.url, + self.allow_insecure_loopback_http, + "role-scope catalogue", + ) + } +} + +impl fmt::Debug for RoleScopeCatalogueConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RoleScopeCatalogueConfig") + .field("origin", &redacted_origin(&self.url)) + .field("audience", &self.audience) + .field("cache_ttl", &self.cache_ttl) + .field("max_body_bytes", &self.max_body_bytes) + .field( + "allow_insecure_loopback_http", + &self.allow_insecure_loopback_http, + ) + .finish() + } +} /// Whether the project-specific JWT `scopes` array is accepted during migration. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -69,6 +163,7 @@ pub struct JwksAuthenticationConfig { legacy_scope_claims: LegacyScopeClaims, allow_insecure_loopback_http: bool, clock: Arc, + role_scope_catalogue: Option, } impl JwksAuthenticationConfig { @@ -93,6 +188,7 @@ impl JwksAuthenticationConfig { legacy_scope_claims: LegacyScopeClaims::Reject, allow_insecure_loopback_http: false, clock: Arc::new(SystemAuthenticationClock), + role_scope_catalogue: None, }) } @@ -146,6 +242,12 @@ impl JwksAuthenticationConfig { self } + /// Enables verified role-to-scope expansion from a remote signed catalogue. + pub fn with_role_scope_catalogue(mut self, catalogue: RoleScopeCatalogueConfig) -> Self { + self.role_scope_catalogue = Some(catalogue); + self + } + fn validate(&self) -> Result<(), RouterError> { if self.issuer.trim().is_empty() { return Err(invalid_configuration( @@ -187,23 +289,20 @@ impl JwksAuthenticationConfig { "authentication clock leeway must not exceed 300 seconds", )); } - if !self.jwks_url.username().is_empty() - || self.jwks_url.password().is_some() - || self.jwks_url.query().is_some() - || self.jwks_url.fragment().is_some() - { + validate_public_resource_url( + &self.jwks_url, + self.allow_insecure_loopback_http, + "authentication JWKS", + )?; + #[cfg(not(feature = "auth-agql"))] + if self.role_scope_catalogue.is_some() { return Err(invalid_configuration( - "authentication JWKS URL must not contain credentials, a query, or a fragment", + "role-scope catalogue expansion requires the auth-agql feature", )); } - match self.jwks_url.scheme() { - "https" => {} - "http" if self.allow_insecure_loopback_http && is_loopback_url(&self.jwks_url) => {} - _ => { - return Err(invalid_configuration( - "authentication JWKS URL must use HTTPS (plain HTTP is available only for explicit loopback development)", - )); - } + #[cfg(feature = "auth-agql")] + if let Some(catalogue) = &self.role_scope_catalogue { + catalogue.validate(self.refresh_interval)?; } Ok(()) } @@ -222,6 +321,7 @@ impl fmt::Debug for JwksAuthenticationConfig { .field("max_jwks_bytes", &self.max_jwks_bytes) .field("leeway", &self.leeway) .field("legacy_scope_claims", &self.legacy_scope_claims) + .field("role_scope_catalogue", &self.role_scope_catalogue) .field( "allow_insecure_loopback_http", &self.allow_insecure_loopback_http, @@ -236,6 +336,8 @@ pub struct JwksAuthenticationProvider { config: JwksAuthenticationConfig, client: Client, cache: Arc>>, + #[cfg(feature = "auth-agql")] + role_scope_cache: Arc>>, } impl JwksAuthenticationProvider { @@ -254,50 +356,153 @@ impl JwksAuthenticationProvider { config, client, cache: Arc::new(RwLock::new(None)), + #[cfg(feature = "auth-agql")] + role_scope_cache: Arc::new(RwLock::new(None)), }) } async fn refresh_keys(&self) -> Result<(), AuthenticationError> { - let response = self - .client - .get(self.config.jwks_url.clone()) - .send() - .await - .map_err(|_| AuthenticationError::unavailable("JWKS retrieval failed"))?; + let body = self + .fetch_bounded( + self.config.jwks_url.clone(), + self.config.max_jwks_bytes, + "JWKS", + ) + .await?; + let document = serde_json::from_slice::(&body) + .map_err(|_| AuthenticationError::unavailable("JWKS document is malformed"))?; + let keys = validate_jwks(document)?; + let loaded_at = self.config.clock.now(); + let mut cache = self + .cache + .write() + .map_err(|_| AuthenticationError::unavailable("JWKS cache is unavailable"))?; + *cache = Some(JwksCache { loaded_at, keys }); + Ok(()) + } + + async fn fetch_bounded( + &self, + url: Url, + maximum_bytes: usize, + resource: &'static str, + ) -> Result, AuthenticationError> { + let response = self.client.get(url).send().await.map_err(|_| { + AuthenticationError::unavailable(format!("{resource} retrieval failed")) + })?; if !response.status().is_success() { - return Err(AuthenticationError::unavailable( - "JWKS endpoint returned an unsuccessful status", - )); + return Err(AuthenticationError::unavailable(format!( + "{resource} endpoint returned an unsuccessful status" + ))); } if response .content_length() - .is_some_and(|length| length > self.config.max_jwks_bytes as u64) + .is_some_and(|length| length > maximum_bytes as u64) { - return Err(AuthenticationError::unavailable( - "JWKS response exceeded its configured body limit", - )); + return Err(AuthenticationError::unavailable(format!( + "{resource} response exceeded its configured body limit" + ))); } let mut body = Vec::new(); let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { - let chunk = - chunk.map_err(|_| AuthenticationError::unavailable("JWKS response read failed"))?; - if body.len().saturating_add(chunk.len()) > self.config.max_jwks_bytes { - return Err(AuthenticationError::unavailable( - "JWKS response exceeded its configured body limit", - )); + let chunk = chunk.map_err(|_| { + AuthenticationError::unavailable(format!("{resource} response read failed")) + })?; + if body.len().saturating_add(chunk.len()) > maximum_bytes { + return Err(AuthenticationError::unavailable(format!( + "{resource} response exceeded its configured body limit" + ))); } body.extend_from_slice(&chunk); } - let document = serde_json::from_slice::(&body) - .map_err(|_| AuthenticationError::unavailable("JWKS document is malformed"))?; - let keys = validate_jwks(document)?; - let loaded_at = self.config.clock.now(); - let mut cache = self - .cache - .write() - .map_err(|_| AuthenticationError::unavailable("JWKS cache is unavailable"))?; - *cache = Some(JwksCache { loaded_at, keys }); + Ok(body) + } + + #[cfg(feature = "auth-agql")] + async fn refresh_role_scope_catalogue(&self) -> Result<(), AuthenticationError> { + let Some(config) = &self.config.role_scope_catalogue else { + return Ok(()); + }; + let body = self + .fetch_bounded( + config.url.clone(), + config.max_body_bytes, + "role-scope catalogue", + ) + .await?; + let envelope = serde_json::from_slice::(&body).map_err(|_| { + AuthenticationError::unavailable("role-scope catalogue document is malformed") + })?; + envelope.validate_structure().map_err(|_| { + AuthenticationError::unavailable("role-scope catalogue document is invalid") + })?; + let header = decode_header(&envelope.signature).map_err(|_| { + AuthenticationError::unavailable("role-scope catalogue signature is malformed") + })?; + if header.alg != Algorithm::RS256 { + return Err(AuthenticationError::unavailable( + "role-scope catalogue signature algorithm is invalid", + )); + } + let kid = header + .kid + .as_deref() + .filter(|kid| !kid.is_empty()) + .ok_or_else(|| { + AuthenticationError::unavailable("role-scope catalogue signature has no key ID") + })?; + let key = self.decoding_key(kid)?; + let mut validation = Validation::new(Algorithm::RS256); + validation.set_required_spec_claims(&["exp", "iat", "iss", "aud"]); + validation.set_issuer(&[self.config.issuer.as_str()]); + validation.set_audience(&[config.audience.as_str()]); + validation.validate_exp = false; + validation.validate_nbf = false; + let claims = decode::(&envelope.signature, &key, &validation) + .map_err(|_| { + AuthenticationError::unavailable("role-scope catalogue signature is invalid") + })? + .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") + })?; + claims + .validate_binding( + &envelope, + &self.config.issuer, + &config.audience, + now, + maximum_lifetime, + ) + .map_err(|_| { + AuthenticationError::unavailable("role-scope catalogue signature is not bound") + })?; + let provider = StaticRoleScopeExpansion::new(&envelope.catalogue).map_err(|_| { + AuthenticationError::unavailable("role-scope catalogue expansion is invalid") + })?; + let expires_at = u64::try_from(claims.exp) + .ok() + .and_then(|value| UNIX_EPOCH.checked_add(Duration::from_secs(value))) + .ok_or_else(|| { + AuthenticationError::unavailable("role-scope catalogue expiry is invalid") + })?; + let mut cache = self.role_scope_cache.write().map_err(|_| { + AuthenticationError::unavailable("role-scope catalogue cache is unavailable") + })?; + *cache = Some(RoleScopeCache { + loaded_at: self.config.clock.now(), + expires_at, + provider, + }); + Ok(()) + } + + async fn refresh_verification_state(&self) -> Result<(), AuthenticationError> { + self.refresh_keys().await?; + #[cfg(feature = "auth-agql")] + self.refresh_role_scope_catalogue().await?; Ok(()) } @@ -320,6 +525,41 @@ impl JwksAuthenticationProvider { AuthenticationError::invalid_credential("bearer credential references an unknown key") }) } + + #[cfg(feature = "auth-agql")] + fn expand_roles( + &self, + 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 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 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 expansion = cache.provider.expand_roles(roles).map_err(|_| { + AuthenticationError::unavailable("role-scope catalogue expansion failed") + })?; + Ok(effective_scopes(direct_scopes, &expansion)) + } } impl fmt::Debug for JwksAuthenticationProvider { @@ -339,7 +579,7 @@ impl fmt::Debug for JwksAuthenticationProvider { impl AuthenticationProvider for JwksAuthenticationProvider { fn initialize(&self) -> BoxFuture<'_, Result<(), AuthenticationError>> { - Box::pin(async move { self.refresh_keys().await }) + Box::pin(async move { self.refresh_verification_state().await }) } fn authenticate_bearer( @@ -367,6 +607,8 @@ impl AuthenticationProvider for JwksAuthenticationProvider { .claims; 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 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)) @@ -381,7 +623,7 @@ impl AuthenticationProvider for JwksAuthenticationProvider { } fn refresh(&self) -> BoxFuture<'_, Result<(), AuthenticationError>> { - Box::pin(async move { self.refresh_keys().await }) + Box::pin(async move { self.refresh_verification_state().await }) } } @@ -390,16 +632,48 @@ struct JwksCache { keys: BTreeMap, } +#[cfg(feature = "auth-agql")] +struct RoleScopeCache { + loaded_at: SystemTime, + expires_at: SystemTime, + provider: StaticRoleScopeExpansion, +} + #[derive(Deserialize)] struct JwtClaims { sub: String, exp: u64, #[serde(default)] nbf: Option, + #[cfg(feature = "auth-agql")] + #[serde(default, deserialize_with = "deserialize_roles")] + roles: Vec, #[serde(flatten)] additional: BTreeMap, } +#[cfg(feature = "auth-agql")] +fn deserialize_roles<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let mut roles = Vec::::deserialize(deserializer)?; + if roles.len() > MAX_ACCESS_TOKEN_ROLES + || roles.iter().any(|role| { + role.is_empty() + || role.len() > MAX_ACCESS_TOKEN_ROLE_LENGTH + || role + .bytes() + .any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control()) + }) + { + return Err(serde::de::Error::custom("invalid access-token roles")); + } + roles.sort(); + roles.dedup(); + Ok(roles) +} + fn validate_jwks(document: JwkSet) -> Result, AuthenticationError> { if document.keys.is_empty() || document.keys.len() > MAX_JWKS_KEYS { return Err(AuthenticationError::unavailable( @@ -450,11 +724,9 @@ fn validate_time_claims( clock: &dyn AuthenticationClock, leeway: Duration, ) -> Result<(), AuthenticationError> { - let now = clock - .now() - .duration_since(UNIX_EPOCH) - .map_err(|_| AuthenticationError::unavailable("authentication clock precedes unix epoch"))? - .as_secs(); + let now = u64::try_from(unix_timestamp(clock)?).map_err(|_| { + AuthenticationError::unavailable("authentication clock precedes unix epoch") + })?; let skew = leeway.as_secs(); if claims.exp.saturating_add(skew) <= now { return Err(AuthenticationError::invalid_credential( @@ -470,6 +742,16 @@ fn validate_time_claims( Ok(()) } +fn unix_timestamp(clock: &dyn AuthenticationClock) -> Result { + let seconds = clock + .now() + .duration_since(UNIX_EPOCH) + .map_err(|_| AuthenticationError::unavailable("authentication clock precedes unix epoch"))? + .as_secs(); + i64::try_from(seconds) + .map_err(|_| AuthenticationError::unavailable("authentication clock is out of range")) +} + pub(crate) fn parse_scope_claims( claims: &BTreeMap, legacy_mode: LegacyScopeClaims, @@ -542,7 +824,7 @@ fn valid_scope_token(scope: &str) -> bool { .all(|byte| matches!(byte, 0x21 | 0x23..=0x5b | 0x5d..=0x7e)) } -fn invalid_configuration(message: &'static str) -> RouterError { +fn invalid_configuration(message: impl Into) -> RouterError { RouterError::new(RouterErrorKind::InvalidConfiguration, message) } @@ -550,6 +832,29 @@ fn invalid_token() -> AuthenticationError { AuthenticationError::invalid_credential("invalid bearer credential") } +fn validate_public_resource_url( + url: &Url, + allow_insecure_loopback_http: bool, + resource: &'static str, +) -> Result<(), RouterError> { + if !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(invalid_configuration(format!( + "{resource} URL must not contain credentials, a query, or a fragment" + ))); + } + match url.scheme() { + "https" => Ok(()), + "http" if allow_insecure_loopback_http && is_loopback_url(url) => Ok(()), + _ => Err(invalid_configuration(format!( + "{resource} URL must use HTTPS (plain HTTP is available only for explicit loopback development)" + ))), + } +} + fn is_loopback_url(url: &Url) -> bool { match url.host_str() { Some("localhost") => true, diff --git a/crates/graphql-orm-router/src/jwt/tests.rs b/crates/graphql-orm-router/src/jwt/tests.rs index 142e1273..b132d17e 100644 --- a/crates/graphql-orm-router/src/jwt/tests.rs +++ b/crates/graphql-orm-router/src/jwt/tests.rs @@ -12,6 +12,12 @@ use std::{ use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, jwk::Jwk}; use serde_json::{Value as JsonValue, json}; +#[cfg(feature = "auth-agql")] +use agql_auth::{ + RoleScopeCatalogue, RoleScopeCatalogueClaims, RoleScopeDefinition, RoleScopeGrant, + SignedRoleScopeCatalogue, +}; + use super::*; use crate::{AuthenticationErrorKind, AuthenticationProvider}; @@ -159,6 +165,32 @@ fn token(kid: &str, claims: JsonValue) -> String { .unwrap() } +#[cfg(feature = "auth-agql")] +fn signed_catalogue(kid: &str, issued_at: i64, expires_at: i64) -> String { + let catalogue = RoleScopeCatalogue::new( + "revision-7", + [ + RoleScopeDefinition::new("inventory.read"), + RoleScopeDefinition::new("inventory.write").exact_only(), + RoleScopeDefinition::new("profile.read"), + ], + [RoleScopeGrant::new( + "inventory-operator", + "Inventory operator", + ["inventory.read", "inventory.write"], + )], + ); + let claims = RoleScopeCatalogueClaims::new( + catalogue.clone(), + "https://issuer.test", + "role-catalogue-clients", + issued_at, + expires_at, + ); + let signature = token(kid, serde_json::to_value(claims).unwrap()); + serde_json::to_string(&SignedRoleScopeCatalogue::new(catalogue, signature)).unwrap() +} + fn claims(exp: u64) -> JsonValue { json!({ "sub": "user-7", @@ -186,6 +218,27 @@ fn provider( JwksAuthenticationProvider::new(config).unwrap() } +#[cfg(feature = "auth-agql")] +fn provider_with_catalogue( + keys: &JwksFixture, + catalogue: &JwksFixture, + clock: Arc, +) -> JwksAuthenticationProvider { + 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); + 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(clock) + .allow_insecure_loopback_http_for_development(true) + .with_role_scope_catalogue(role_scope); + JwksAuthenticationProvider::new(config).unwrap() +} + fn initialize(provider: &JwksAuthenticationProvider) -> Result<(), AuthenticationError> { let provider = provider.clone(); ntex::rt::System::build() @@ -412,3 +465,97 @@ fn configuration_and_debug_output_keep_secure_defaults_and_redact_keys() { assert!(!diagnostics.contains("BEGIN RSA")); assert!(diagnostics.contains("cached_key_count")); } + +#[cfg(feature = "auth-agql")] +#[test] +fn signed_role_catalogue_expands_roles_and_preserves_direct_scopes() { + let keys = JwksFixture::start(jwks("key-a")); + let catalogue = JwksFixture::start(signed_catalogue("key-a", 1_000, 1_010)); + let clock = Arc::new(TestClock::at(1_000)); + 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), 1); + + let credential = token( + "key-a", + json!({ + "sub": "user-7", "iss": "https://issuer.test", "aud": "graphql-router", + "exp": 1_100, "scope": "profile.read", + "roles": ["inventory-operator", "unknown-role"] + }), + ); + assert_eq!( + provider.authenticate_bearer(&credential).unwrap().scopes(), + &["inventory.read", "inventory.write", "profile.read"] + ); +} + +#[cfg(feature = "auth-agql")] +#[test] +fn role_catalogue_is_mandatory_current_and_verified_for_role_bearers() { + let keys = JwksFixture::start(jwks("key-a")); + let catalogue = JwksFixture::start(signed_catalogue("key-a", 1_000, 1_010)); + let clock = Arc::new(TestClock::at(1_000)); + let provider = provider_with_catalogue(&keys, &catalogue, clock.clone()); + initialize(&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"] + }), + ); + provider.authenticate_bearer(&role_credential).unwrap(); + + catalogue.set_status(503); + assert_eq!( + refresh(&provider).unwrap_err().kind(), + AuthenticationErrorKind::Unavailable + ); + provider.authenticate_bearer(&role_credential).unwrap(); + clock.set(1_010); + assert_eq!( + provider + .authenticate_bearer(&role_credential) + .unwrap_err() + .kind(), + AuthenticationErrorKind::Unavailable + ); + + let direct_only = token( + "key-a", + json!({ + "sub": "user-7", "iss": "https://issuer.test", "aud": "graphql-router", + "exp": 1_100, "scope": "profile.read" + }), + ); + assert_eq!( + provider.authenticate_bearer(&direct_only).unwrap().scopes(), + &["profile.read"] + ); + + let malformed = token( + "key-a", + json!({ + "sub": "user-7", "iss": "https://issuer.test", "aud": "graphql-router", + "exp": 1_100, "roles": ["bad role"] + }), + ); + assert_eq!( + provider.authenticate_bearer(&malformed).unwrap_err().kind(), + AuthenticationErrorKind::InvalidCredential + ); +} + +#[cfg(feature = "auth-agql")] +#[test] +fn forged_role_catalogue_fails_router_initialization() { + 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))); + assert_eq!( + initialize(&provider).unwrap_err().kind(), + AuthenticationErrorKind::InvalidCredential + ); +} diff --git a/crates/graphql-orm-router/src/lib.rs b/crates/graphql-orm-router/src/lib.rs index 279c191d..5805aa69 100644 --- a/crates/graphql-orm-router/src/lib.rs +++ b/crates/graphql-orm-router/src/lib.rs @@ -81,7 +81,7 @@ pub use auth::{ }; pub use jwt::{ AuthenticationClock, JwksAuthenticationConfig, JwksAuthenticationProvider, LegacyScopeClaims, - SystemAuthenticationClock, + RoleScopeCatalogueConfig, SystemAuthenticationClock, }; pub use lifecycle::{ RouterHandle, RouterStatus, SchemaRefreshOutcome, SubgraphRuntimeState, SubgraphSourceKind, diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index d971757f..885cb296 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.24.0" +version = "0.25.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.24.0", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.25.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 6317338c..7ae74c69 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.24.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.25.0", default-features = false, features = ["sqlite"] } ``` This unpublished package has no docs.rs release. Use this Git README and the diff --git a/docs/reference/graphql-orm/agql-auth-bridge.md b/docs/reference/graphql-orm/agql-auth-bridge.md index 2d486b36..3d65d29b 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.24.0", features = ["sqlite", "auth-agql"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.25.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 b022fb32..2b02faa0 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.24.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.25.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 1ef26a01..61d1ebb1 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.24.0", default-features = false, features = ["mssql"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.25.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 1a4ca3d2..693a95c5 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.24.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | -| `graphql-orm-ai` | `0.89.0` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | +| `graphql-orm` | `0.25.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm-ai` | `0.90.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.24.0` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.25.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.2.0` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | +| `graphql-orm-router` | `0.3.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.16.0`, source `git+https://github.com/Dastari/agql-auth.git?rev=3bc38cd94794f1e868a9cc3a5551047b95a32105`, consumed by `graphql-orm`, `graphql-orm-ai`, `graphql-orm-router`. +- `agql-auth` requirement `^0.17.0`, source `git+https://github.com/Dastari/agql-auth.git?rev=82650a447f3b6875160254fa1293b3a0e305c224`, consumed by `graphql-orm`, `graphql-orm-ai`, `graphql-orm-router`. From cc5c9b2adf3ffc207eaa83f54a93fa07dc89ceb4 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Sat, 22 Aug 2026 18:55:31 +1000 Subject: [PATCH 2/3] test: refresh retention diagnostic golden --- .../tests/ui/retention_wrong_entity.stderr | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/crates/graphql-orm/tests/ui/retention_wrong_entity.stderr b/crates/graphql-orm/tests/ui/retention_wrong_entity.stderr index e9cc9d05..f003f072 100644 --- a/crates/graphql-orm/tests/ui/retention_wrong_entity.stderr +++ b/crates/graphql-orm/tests/ui/retention_wrong_entity.stderr @@ -2,13 +2,7 @@ error[E0277]: the trait bound `OrdinaryEvent: RetentionPurge` is not satisfied --> tests/ui/retention_wrong_entity.rs:18:22 | 18 | .purge::( - | ^^^^^ unsatisfied trait bound - | -help: the trait `RetentionPurge` is not implemented for `OrdinaryEvent` - --> tests/ui/retention_wrong_entity.rs:5:1 - | - 5 | struct OrdinaryEvent { - | ^^^^^^^^^^^^^^^^^^^^ + | ^^^^^ the trait `RetentionPurge` is not implemented for `OrdinaryEvent` error[E0277]: the trait bound `OrdinaryEvent: RetentionPurge` is not satisfied --> tests/ui/retention_wrong_entity.rs:17:17 @@ -18,13 +12,8 @@ error[E0277]: the trait bound `OrdinaryEvent: RetentionPurge` is not satisfied 19 | | OrdinaryEventWhereInput::default(), 20 | | MutationLimit::new(1)?, 21 | | ) - | |_____________________^ unsatisfied trait bound + | |_____________________^ the trait `RetentionPurge` is not implemented for `OrdinaryEvent` | -help: the trait `RetentionPurge` is not implemented for `OrdinaryEvent` - --> tests/ui/retention_wrong_entity.rs:5:1 - | - 5 | struct OrdinaryEvent { - | ^^^^^^^^^^^^^^^^^^^^ note: required by a bound in `RetentionContext::<'tx, B>::purge` --> src/graphql/orm/core.rs | @@ -38,13 +27,8 @@ error[E0277]: the trait bound `OrdinaryEvent: RetentionPurge` is not satisfied --> tests/ui/retention_wrong_entity.rs:22:22 | 22 | .await - | ^^^^^ unsatisfied trait bound - | -help: the trait `RetentionPurge` is not implemented for `OrdinaryEvent` - --> tests/ui/retention_wrong_entity.rs:5:1 + | ^^^^^ the trait `RetentionPurge` is not implemented for `OrdinaryEvent` | - 5 | struct OrdinaryEvent { - | ^^^^^^^^^^^^^^^^^^^^ note: required by a bound in `RetentionContext::<'tx, B>::purge` --> src/graphql/orm/core.rs | From cf42580a0ad8ef4e4dbaafcd148ce6ae0350a92d Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Sat, 22 Aug 2026 19:04:22 +1000 Subject: [PATCH 3/3] test: align coexistence fixture auth dependency --- .../tests/backend_coexistence_fixture.rs | 6 ++--- .../fixtures/backend-coexistence/Cargo.lock | 10 ++++----- .../auth-service/Cargo.toml | 2 +- .../tests/ui/retention_wrong_entity.stderr | 22 ++++++++++++++++--- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/crates/graphql-orm/tests/backend_coexistence_fixture.rs b/crates/graphql-orm/tests/backend_coexistence_fixture.rs index 53c58559..02ed363a 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.16.0"); + assert_eq!(agql_auth[0]["version"], "0.17.0"); let source = agql_auth[0]["source"] .as_str() .expect("agql-auth source must be present"); assert!( - source.contains("rev=3bc38cd94794f1e868a9cc3a5551047b95a32105") - && source.ends_with("#3bc38cd94794f1e868a9cc3a5551047b95a32105"), + source.contains("rev=82650a447f3b6875160254fa1293b3a0e305c224") + && source.ends_with("#82650a447f3b6875160254fa1293b3a0e305c224"), "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 9b77c1d5..4babb076 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.16.0" -source = "git+https://github.com/Dastari/agql-auth.git?rev=3bc38cd94794f1e868a9cc3a5551047b95a32105#3bc38cd94794f1e868a9cc3a5551047b95a32105" +version = "0.17.0" +source = "git+https://github.com/Dastari/agql-auth.git?rev=82650a447f3b6875160254fa1293b3a0e305c224#82650a447f3b6875160254fa1293b3a0e305c224" dependencies = [ "argon2", "async-graphql", @@ -1304,7 +1304,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.24.0" +version = "0.25.0" dependencies = [ "agql-auth", "async-graphql", @@ -1329,7 +1329,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.89.0" +version = "0.90.0" dependencies = [ "agql-auth", "async-graphql", @@ -1372,7 +1372,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.24.0" +version = "0.25.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 63a48838..6b1003c8 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 = "3bc38cd94794f1e868a9cc3a5551047b95a32105", version = "0.16.0" } +agql-auth = { git = "https://github.com/Dastari/agql-auth.git", rev = "82650a447f3b6875160254fa1293b3a0e305c224", version = "0.17.0" } async-graphql = { version = "7", features = ["dataloader", "uuid"] } graphql-orm = { path = "../../../../", default-features = false, features = [ "sqlite", diff --git a/crates/graphql-orm/tests/ui/retention_wrong_entity.stderr b/crates/graphql-orm/tests/ui/retention_wrong_entity.stderr index f003f072..e9cc9d05 100644 --- a/crates/graphql-orm/tests/ui/retention_wrong_entity.stderr +++ b/crates/graphql-orm/tests/ui/retention_wrong_entity.stderr @@ -2,7 +2,13 @@ error[E0277]: the trait bound `OrdinaryEvent: RetentionPurge` is not satisfied --> tests/ui/retention_wrong_entity.rs:18:22 | 18 | .purge::( - | ^^^^^ the trait `RetentionPurge` is not implemented for `OrdinaryEvent` + | ^^^^^ unsatisfied trait bound + | +help: the trait `RetentionPurge` is not implemented for `OrdinaryEvent` + --> tests/ui/retention_wrong_entity.rs:5:1 + | + 5 | struct OrdinaryEvent { + | ^^^^^^^^^^^^^^^^^^^^ error[E0277]: the trait bound `OrdinaryEvent: RetentionPurge` is not satisfied --> tests/ui/retention_wrong_entity.rs:17:17 @@ -12,8 +18,13 @@ error[E0277]: the trait bound `OrdinaryEvent: RetentionPurge` is not satisfied 19 | | OrdinaryEventWhereInput::default(), 20 | | MutationLimit::new(1)?, 21 | | ) - | |_____________________^ the trait `RetentionPurge` is not implemented for `OrdinaryEvent` + | |_____________________^ unsatisfied trait bound | +help: the trait `RetentionPurge` is not implemented for `OrdinaryEvent` + --> tests/ui/retention_wrong_entity.rs:5:1 + | + 5 | struct OrdinaryEvent { + | ^^^^^^^^^^^^^^^^^^^^ note: required by a bound in `RetentionContext::<'tx, B>::purge` --> src/graphql/orm/core.rs | @@ -27,8 +38,13 @@ error[E0277]: the trait bound `OrdinaryEvent: RetentionPurge` is not satisfied --> tests/ui/retention_wrong_entity.rs:22:22 | 22 | .await - | ^^^^^ the trait `RetentionPurge` is not implemented for `OrdinaryEvent` + | ^^^^^ unsatisfied trait bound + | +help: the trait `RetentionPurge` is not implemented for `OrdinaryEvent` + --> tests/ui/retention_wrong_entity.rs:5:1 | + 5 | struct OrdinaryEvent { + | ^^^^^^^^^^^^^^^^^^^^ note: required by a bound in `RetentionContext::<'tx, B>::purge` --> src/graphql/orm/core.rs |