Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions crates/graphql-orm-router/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/graphql-orm-router/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
28 changes: 28 additions & 0 deletions crates/graphql-orm-router/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions crates/graphql-orm-router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ This unpublished package is Git-only:

```toml
[dependencies]
graphql-orm-router = { git = "https://github.com/Dastari/graphql-orm.git", rev = "<reviewed-full-40-character-commit-sha>", version = "0.4.0" }
graphql-orm-router = { git = "https://github.com/Dastari/graphql-orm.git", rev = "<reviewed-full-40-character-commit-sha>", version = "0.5.0" }
```

Enable `auth-agql` only when adapting a separately configured
Expand Down Expand Up @@ -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. |
Expand Down
17 changes: 10 additions & 7 deletions crates/graphql-orm-router/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
124 changes: 111 additions & 13 deletions crates/graphql-orm-router/src/file_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ struct FileScopeMatcher {
wildcard: Option<String>,
wildcard_matches_multi_segment: Option<bool>,
allow_universal_wildcard: Option<bool>,
allow_super_scopes_for_exact_only: Option<bool>,
#[serde(default)]
super_scopes: Vec<String>,
#[serde(default)]
Expand All @@ -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()
Expand All @@ -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,
Expand All @@ -405,6 +408,7 @@ fn build_hierarchical_scope_matcher(
wildcard: Option<String>,
wildcard_matches_multi_segment: Option<bool>,
allow_universal_wildcard: Option<bool>,
allow_super_scopes_for_exact_only: Option<bool>,
mut super_scopes: Vec<String>,
mut exact_only_scopes: Vec<String>,
mut exact_only_scope_patterns: Vec<String>,
Expand Down Expand Up @@ -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);
Expand All @@ -457,6 +464,7 @@ fn build_hierarchical_scope_matcher(
_wildcard: Option<String>,
_wildcard_matches_multi_segment: Option<bool>,
_allow_universal_wildcard: Option<bool>,
_allow_super_scopes_for_exact_only: Option<bool>,
_super_scopes: Vec<String>,
_exact_only_scopes: Vec<String>,
_exact_only_scope_patterns: Vec<String>,
Expand Down Expand Up @@ -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() {
Expand All @@ -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,
);
Expand Down
6 changes: 3 additions & 3 deletions crates/graphql-orm/tests/backend_coexistence_fixture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,13 @@ fn assert_direct_host_dependency_resolves_one_exact_agql_auth_universe() {
.filter(|package| package["name"] == "agql-auth")
.collect::<Vec<_>>();
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}",
);
}
Loading
Loading