From b720f5a7ce89ce35be1f977d270713d3cd531e35 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Mon, 24 Aug 2026 10:07:58 +1000 Subject: [PATCH] feat(router): configure exact-only super-scope policy --- Cargo.lock | 6 +- Cargo.toml | 2 +- crates/graphql-orm-router/CHANGELOG.md | 15 +++ crates/graphql-orm-router/Cargo.toml | 2 +- crates/graphql-orm-router/MIGRATION.md | 28 ++++ crates/graphql-orm-router/README.md | 4 +- .../graphql-orm-router/docs/configuration.md | 17 ++- crates/graphql-orm-router/src/file_config.rs | 124 ++++++++++++++++-- .../tests/backend_coexistence_fixture.rs | 6 +- .../fixtures/backend-coexistence/Cargo.lock | 8 +- .../auth-service/Cargo.toml | 2 +- docs/reference/workspace-packages.md | 4 +- 12 files changed, 181 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cf0755f..3a89508 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,8 +87,8 @@ dependencies = [ [[package]] name = "agql-auth" -version = "0.18.0" -source = "git+https://github.com/Dastari/agql-auth.git?rev=527d15d28e3c295a6f6b5e6d74559a7aecdc1322#527d15d28e3c295a6f6b5e6d74559a7aecdc1322" +version = "0.19.0" +source = "git+https://github.com/Dastari/agql-auth.git?rev=1d2e9fe2e1576105212a7b340a11abf8cad0382d#1d2e9fe2e1576105212a7b340a11abf8cad0382d" dependencies = [ "argon2", "async-graphql", @@ -3193,7 +3193,7 @@ dependencies = [ [[package]] name = "graphql-orm-router" -version = "0.4.0" +version = "0.5.0" dependencies = [ "agql-auth", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index ba31b1a..f377d66 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 = "527d15d28e3c295a6f6b5e6d74559a7aecdc1322", version = "0.18.0" } +agql-auth = { git = "https://github.com/Dastari/agql-auth.git", rev = "1d2e9fe2e1576105212a7b340a11abf8cad0382d", version = "0.19.0" } arc-swap = "1.7" async-graphql = { version = "7", features = ["dataloader", "uuid"] } async-graphql-parser = "7" diff --git a/crates/graphql-orm-router/CHANGELOG.md b/crates/graphql-orm-router/CHANGELOG.md index d9c0112..387853e 100644 --- a/crates/graphql-orm-router/CHANGELOG.md +++ b/crates/graphql-orm-router/CHANGELOG.md @@ -10,6 +10,21 @@ supersedes: [] # Changelog +## 0.5.0 - 2026-08-24 + +- Aligned the optional adapter to generic `agql-auth` 0.19.0 at reviewed merged + revision `1d2e9fe2e1576105212a7b340a11abf8cad0382d`. +- Added strict hierarchical `scopeMatcher.allowSuperScopesForExactOnly` file + configuration. It is disabled by default. When enabled, only exact + membership in caller-supplied `superScopes` can satisfy an exact-only + requirement; wildcard-derived, similar, and unrelated grants remain denied. +- Added router-level compatibility and enabled-policy truth tables covering + ordinary requirements, fixed and patterned exact-only requirements, + multiple configured super-scopes, and malformed or unknown configuration. + +No descriptor, GraphQL schema, token wire, role, or stored-data migration is +required. Subgraph resolver authorization remains authoritative. + ## 0.4.0 - 2026-08-22 - Aligned the optional adapter to generic `agql-auth` 0.18.0 at merged revision diff --git a/crates/graphql-orm-router/Cargo.toml b/crates/graphql-orm-router/Cargo.toml index 8d36eef..9acc39b 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.4.0" +version = "0.5.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 036b369..a0d401a 100644 --- a/crates/graphql-orm-router/MIGRATION.md +++ b/crates/graphql-orm-router/MIGRATION.md @@ -10,6 +10,34 @@ supersedes: [] # graphql-orm-router migration guide +## 0.4.0 to 0.5.0 + +Align direct `agql-auth` consumers to 0.19.0 at reviewed merged revision +`1d2e9fe2e1576105212a7b340a11abf8cad0382d` so the process resolves one matcher +type universe. + +Existing router configuration needs no change. Omitted `scopeMatcher`, +`kind: "exact"`, and hierarchical matchers without the new field preserve their +previous decisions. To let caller-supplied super-scopes satisfy exact-only +requirements, build with `auth-agql` and explicitly set: + +```json +{ + "scopeMatcher": { + "kind": "hierarchical", + "superScopes": ["root.admin", "operations.breakglass"], + "allowSuperScopesForExactOnly": true, + "exactOnlyScopes": ["payments.credentials.release"] + } +} +``` + +The option recognizes only exact membership in `superScopes`. Direct exact +grants continue to satisfy exact-only requirements, while universal, trailing, +and segment wildcard matches remain denied. Disable or omit the option to +restore the previous behavior. No descriptor, GraphQL schema, token wire, +role, or stored-data migration is implied. + ## 0.3.1 to 0.4.0 Align direct `agql-auth` consumers to 0.18.0 at merged revision diff --git a/crates/graphql-orm-router/README.md b/crates/graphql-orm-router/README.md index 76b51d1..3536322 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.4.0" } +graphql-orm-router = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.5.0" } ``` Enable `auth-agql` only when adapting a separately configured @@ -68,7 +68,7 @@ composition warnings before serving. | Surface | Default or boundary | | --- | --- | | 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. | +| Scope matching | Exact by default; hierarchical matching and exact-only super-scope policy require `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. | | Dynamic registration | Disabled unless authenticated administration and exact network policy are configured. | diff --git a/crates/graphql-orm-router/docs/configuration.md b/crates/graphql-orm-router/docs/configuration.md index 5ffd366..210df53 100644 --- a/crates/graphql-orm-router/docs/configuration.md +++ b/crates/graphql-orm-router/docs/configuration.md @@ -129,13 +129,16 @@ Scope matching is a separate resource-server policy. Omission or | `wildcardMatchesMultiSegment` | `true`; a trailing wildcard matches the remaining hierarchy. | | `allowUniversalWildcard` | `false`; a bare wildcard has no implicit authority. | | `superScopes` | Empty; each listed grant satisfies ordinary requirements. | -| `exactOnlyScopes` | Empty; each listed requirement accepts only an equal grant. | -| `exactOnlyScopePatterns` | Empty; matching resource-qualified requirements accept only equal grants. | - -Exact-only requirements are evaluated before super-scope and wildcard rules. -Lists are normalized and deduplicated at startup. The router applies this one -matcher to fixed and rendered operation requirements; resolver guards remain -authoritative. +| `allowSuperScopesForExactOnly` | `false`; when enabled, exactly configured super-scopes also satisfy exact-only requirements. | +| `exactOnlyScopes` | Empty; wildcard-derived grants never satisfy a listed requirement. | +| `exactOnlyScopePatterns` | Empty; wildcard-derived grants never satisfy matching resource-qualified requirements. | + +Exact-only requirements always reject wildcard-derived matches. Direct exact +grants remain valid. Configured super-scopes remain excluded by default and +become valid only when `allowSuperScopesForExactOnly` is explicitly enabled; +membership in `superScopes` is exact and case-sensitive. Lists are normalized +and deduplicated at startup. The router applies this one matcher to fixed and +rendered operation requirements; resolver guards remain authoritative. For programmatic setup, `JwksAuthenticationConfig::new` requires a JWKS URL, issuer, and non-empty audiences. It defaults to a 15-minute key cache, diff --git a/crates/graphql-orm-router/src/file_config.rs b/crates/graphql-orm-router/src/file_config.rs index 35ebe2a..07d8983 100644 --- a/crates/graphql-orm-router/src/file_config.rs +++ b/crates/graphql-orm-router/src/file_config.rs @@ -350,6 +350,7 @@ struct FileScopeMatcher { wildcard: Option, wildcard_matches_multi_segment: Option, allow_universal_wildcard: Option, + allow_super_scopes_for_exact_only: Option, #[serde(default)] super_scopes: Vec, #[serde(default)] @@ -373,6 +374,7 @@ impl FileScopeMatcher { || self.wildcard.is_some() || self.wildcard_matches_multi_segment.is_some() || self.allow_universal_wildcard.is_some() + || self.allow_super_scopes_for_exact_only.is_some() || !self.super_scopes.is_empty() || !self.exact_only_scopes.is_empty() || !self.exact_only_scope_patterns.is_empty() @@ -389,6 +391,7 @@ impl FileScopeMatcher { self.wildcard, self.wildcard_matches_multi_segment, self.allow_universal_wildcard, + self.allow_super_scopes_for_exact_only, self.super_scopes, self.exact_only_scopes, self.exact_only_scope_patterns, @@ -405,6 +408,7 @@ fn build_hierarchical_scope_matcher( wildcard: Option, wildcard_matches_multi_segment: Option, allow_universal_wildcard: Option, + allow_super_scopes_for_exact_only: Option, mut super_scopes: Vec, mut exact_only_scopes: Vec, mut exact_only_scope_patterns: Vec, @@ -438,6 +442,9 @@ fn build_hierarchical_scope_matcher( .with_allow_universal_wildcard( allow_universal_wildcard.unwrap_or(defaults.allow_universal_wildcard), ) + .with_allow_super_scopes_for_exact_only( + allow_super_scopes_for_exact_only.unwrap_or(defaults.allow_super_scopes_for_exact_only), + ) .with_super_scopes(super_scopes) .with_exact_only_scopes(exact_only_scopes) .with_exact_only_scope_patterns(exact_only_scope_patterns); @@ -457,6 +464,7 @@ fn build_hierarchical_scope_matcher( _wildcard: Option, _wildcard_matches_multi_segment: Option, _allow_universal_wildcard: Option, + _allow_super_scopes_for_exact_only: Option, _super_scopes: Vec, _exact_only_scopes: Vec, _exact_only_scope_patterns: Vec, @@ -873,61 +881,151 @@ mod tests { #[cfg(feature = "auth-agql")] #[test] - fn hierarchical_file_matcher_applies_super_wildcard_and_exact_only_matrix() { + fn hierarchical_file_matcher_preserves_default_and_flips_only_configured_super_scopes() { let json = FILE.replacen( "\"authentication\":", r#""scopeMatcher": { "kind": "hierarchical", - "superScopes": ["platform.admin"], + "allowUniversalWildcard": true, + "superScopes": ["root.admin", "operations.breakglass"], "exactOnlyScopes": ["payments.credentials.release"], "exactOnlyScopePatterns": ["payments.account.*.credentials.release"] }, "authentication":"#, 1, ); - let config = RouterFileConfig::from_json(&json) + let compatibility_default = RouterFileConfig::from_json(&json) + .unwrap() + .into_router_config_with(|name| { + Ok((name == "PRODUCTS_SCHEMA_TOKEN").then(|| "Bearer secret".to_owned())) + }) + .unwrap(); + let enabled_json = json.replacen( + r#""kind": "hierarchical","#, + r#""kind": "hierarchical", + "allowSuperScopesForExactOnly": true,"#, + 1, + ); + let enabled = RouterFileConfig::from_json(&enabled_json) .unwrap() .into_router_config_with(|name| { Ok((name == "PRODUCTS_SCHEMA_TOKEN").then(|| "Bearer secret".to_owned())) }) .unwrap(); - let matcher = config.scope_matcher; for (granted, required, expected) in [ - ("platform.admin", "orders.read", true), - ("orders.*", "orders.read", true), ("orders.read", "orders.read", true), - ("platform.admin", "payments.credentials.release", false), - ("payments.*", "payments.credentials.release", false), + ("orders.*", "orders.read", true), + ("orders.*", "orders.items.read", true), + ("orders.*.read", "orders.items.read", true), + ("*", "orders.delete", true), + ("root.admin", "orders.delete", true), + ("operations.breakglass", "orders.delete", true), + ("root.admin.copy", "orders.delete", false), + ("unrelated.scope", "orders.delete", false), + ] { + assert_eq!( + compatibility_default + .scope_matcher + .matches(granted, required), + expected, + "default ordinary grant {granted:?} for requirement {required:?}" + ); + assert_eq!( + enabled.scope_matcher.matches(granted, required), + expected, + "enabled ordinary grant {granted:?} for requirement {required:?}" + ); + } + + for (granted, required, default_expected, enabled_expected) in [ ( "payments.credentials.release", "payments.credentials.release", true, + true, + ), + ("payments.*", "payments.credentials.release", false, false), + ( + "payments.*.release", + "payments.credentials.release", + false, + false, + ), + ("*", "payments.credentials.release", false, false), + ("root.admin", "payments.credentials.release", false, true), + ( + "operations.breakglass", + "payments.credentials.release", + false, + true, + ), + ( + "root.admin.copy", + "payments.credentials.release", + false, + false, ), ( - "platform.admin", + "unrelated.scope", + "payments.credentials.release", + false, + false, + ), + ( + "root.admin", "payments.account.7.credentials.release", false, + true, ), ( "payments.account.*", "payments.account.7.credentials.release", false, + false, ), ( "payments.account.7.credentials.release", "payments.account.7.credentials.release", true, + true, ), ] { assert_eq!( - matcher.matches(granted, required), - expected, - "grant {granted:?} for requirement {required:?}" + compatibility_default + .scope_matcher + .matches(granted, required), + default_expected, + "default exact-only grant {granted:?} for requirement {required:?}" + ); + assert_eq!( + enabled.scope_matcher.matches(granted, required), + enabled_expected, + "enabled exact-only grant {granted:?} for requirement {required:?}" ); } } + #[test] + fn exact_only_super_scope_file_option_is_strictly_parsed() { + let malformed = FILE.replacen( + "\"authentication\":", + r#""scopeMatcher": { + "kind": "hierarchical", + "allowSuperScopesForExactOnly": "yes" + }, + "authentication":"#, + 1, + ); + assert!(RouterFileConfig::from_json(&malformed).is_err()); + + let unknown = malformed.replace( + r#""allowSuperScopesForExactOnly": "yes""#, + r#""exactOnlySuperScopes": true"#, + ); + assert!(RouterFileConfig::from_json(&unknown).is_err()); + } + #[cfg(not(feature = "auth-agql"))] #[test] fn hierarchical_file_matcher_requires_auth_agql_feature() { @@ -950,7 +1048,7 @@ mod tests { fn exact_file_matcher_rejects_hierarchical_options() { let json = FILE.replacen( "\"authentication\":", - r#""scopeMatcher": {"kind": "exact", "superScopes": ["platform.admin"]}, + r#""scopeMatcher": {"kind": "exact", "allowSuperScopesForExactOnly": false}, "authentication":"#, 1, ); diff --git a/crates/graphql-orm/tests/backend_coexistence_fixture.rs b/crates/graphql-orm/tests/backend_coexistence_fixture.rs index 5adf2f4..b4e9e67 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.18.0"); + assert_eq!(agql_auth[0]["version"], "0.19.0"); let source = agql_auth[0]["source"] .as_str() .expect("agql-auth source must be present"); assert!( - source.contains("rev=527d15d28e3c295a6f6b5e6d74559a7aecdc1322") - && source.ends_with("#527d15d28e3c295a6f6b5e6d74559a7aecdc1322"), + source.contains("rev=1d2e9fe2e1576105212a7b340a11abf8cad0382d") + && source.ends_with("#1d2e9fe2e1576105212a7b340a11abf8cad0382d"), "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 98b9f58..688e4e7 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.18.0" -source = "git+https://github.com/Dastari/agql-auth.git?rev=527d15d28e3c295a6f6b5e6d74559a7aecdc1322#527d15d28e3c295a6f6b5e6d74559a7aecdc1322" +version = "0.19.0" +source = "git+https://github.com/Dastari/agql-auth.git?rev=1d2e9fe2e1576105212a7b340a11abf8cad0382d#1d2e9fe2e1576105212a7b340a11abf8cad0382d" dependencies = [ "argon2", "async-graphql", @@ -1329,7 +1329,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.91.0" +version = "0.94.0" dependencies = [ "agql-auth", "async-graphql", @@ -1357,7 +1357,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai-tool-profiles" -version = "0.9.0" +version = "0.10.0" dependencies = [ "async-graphql", "async-graphql-parser", 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 dba1097..7de5794 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 = "527d15d28e3c295a6f6b5e6d74559a7aecdc1322", version = "0.18.0" } +agql-auth = { git = "https://github.com/Dastari/agql-auth.git", rev = "1d2e9fe2e1576105212a7b340a11abf8cad0382d", version = "0.19.0" } async-graphql = { version = "7", features = ["dataloader", "uuid"] } graphql-orm = { path = "../../../../", default-features = false, features = [ "sqlite", diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 6835437..6e46ec8 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -24,13 +24,13 @@ changes. | `graphql-orm-backup` | `0.7.1` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | | `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.4.0` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | +| `graphql-orm-router` | `0.5.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.18.0`, source `git+https://github.com/Dastari/agql-auth.git?rev=527d15d28e3c295a6f6b5e6d74559a7aecdc1322`, consumed by `graphql-orm`, `graphql-orm-ai`, `graphql-orm-router`. +- `agql-auth` requirement `^0.19.0`, source `git+https://github.com/Dastari/agql-auth.git?rev=1d2e9fe2e1576105212a7b340a11abf8cad0382d`, consumed by `graphql-orm`, `graphql-orm-ai`, `graphql-orm-router`.