Skip to content

Commit 89fa5cd

Browse files
authored
feat(router): support authenticated and requiresScopes directives (#538)
The implementation is based on the analysis from issue #351. The authorization logic can be configured to operate in two modes: - **`filter` (default mode):** This mode silently removes any fields from the incoming GraphQL operation that the user is not authorized to access. For each field removed, a corresponding `AuthorizationError` is added to the `errors` array in the GraphQL response, while the rest of the accessible data is returned as requested. - **`reject` mode:** In this mode, the router will reject any GraphQL operation that attempts to access one or more unauthorized fields. The entire request is denied, and a descriptive error is returned. Access control is defined within the supergraph schema using the following directives: - `@authenticated`: Restricts access to a field to only authenticated users. - `@requiresScopes(scopes: [[String]])`: Provides more granular control by requiring the user to possess specific scopes. The directive supports `AND` logic for scopes within a nested list (e.g., `["read:users", "write:users"]`) and `OR` logic for scopes across nested lists (e.g., `[["read:users"], ["admin:users"]]`). ## Key Implementation Details - **Three-Phase Process:** The authorization logic is designed for high performance and is executed in three phases: 1. **Build Authorization Metadata:** when router loads the supergraph, it parses also the supergraph schema to create an in-memory representation of all `@authenticated` and `@requiresScopes` directives. This metadata is built once and reused for all incoming requests. 2. **Collect Unauthorized Paths:** For each incoming operation, the authorization logic traverses the query. It checks each field against the pre-built metadata and the current `UserAuthContext`. An `UnauthorizedPathTree` data structure is used to efficiently collect all field paths that the user is not permitted to access. 3. **Transform or Reject the Operation:** Based on the findings from the previous phase and the configured mode, a final decision is made. In `filter` mode, if any unauthorized paths were found, the original operation is rebuilt, stripping out only the unauthorized fields to create a new, sanitized operation. In `reject` mode, the presence of any unauthorized path leads to an rejection of the entire request. - **Performance-First Design:** Authorization is a critical path in the request pipeline, this feature was developed with efficiency as a core principle. A new suite of benchmarks has been added to measure its performance impact. The results are good, demonstrating that the entire authorization process adds a negligible overhead of just **1-4 microseconds** for medium-sized queries, ensuring that security does not come at the cost of performance. - **Pre-Query Planning Execution:** Authorization is performed on the operation's Abstract Syntax Tree (AST) before the query planning and execution stages. This ensures that unauthorized field requests never reach the subgraphs, preventing potential data leaks and unnecessary processing. - **Decoupled from Authentication:** The core logic operates on a `UserAuthContext` struct, which contains the user's authentication status and a set of scopes. This decouples the authorization mechanism from the specific authentication method (e.g., JWT validation). - **Comprehensive GraphQL Support:** The implementation correctly handles various GraphQL features to ensure authorization is applied accurately, including: - Conditional directives (`@include` and `@skip`), where authorization is bypassed for fields that are not included in the final operation. - Complex inheritance scenarios with interfaces and object types, correctly applying authorization rules defined on both. - **Test Coverage:** Suite of end-to-end tests for both `filter` and `reject` modes, covering various scenarios.
1 parent 5dab3d3 commit 89fa5cd

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+8479
-122
lines changed

.changeset/authz-directives.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
router: minor
3+
---
4+
5+
## Directive-Based Authorization
6+
7+
Introducing directive-based authorization. This allows you to enforce fine-grained access control directly from your subgraph schemas using the `@authenticated` and `@requiresScopes` directives.
8+
9+
This new authorization layer runs before the query planner, ensuring that unauthorized requests are handled efficiently without reaching your subgraphs.
10+
11+
### Configuration
12+
13+
You can configure how the router handles unauthorized requests with two modes:
14+
15+
- **`filter`** (default): Silently removes any fields the user is not authorized to see from the query. The response will contain `null` for the removed fields and an error in the `errors` array.
16+
- **`reject`**: Rejects the entire GraphQL operation if it requests any field the user is not authorized to access.
17+
18+
To configure this, add the following to your `router.yaml` configuration file:
19+
20+
```yaml
21+
authentication:
22+
directives:
23+
unauthorized:
24+
# "filter" (default): Removes unauthorized fields from the query and returns errors.
25+
# "reject": Rejects the entire request if any unauthorized field is requested.
26+
mode: reject
27+
```
28+
29+
If this section is omitted, the router will use `filter` mode by default.
30+
31+
### JWT Scope Requirements
32+
33+
When using the `@requiresScopes` directive, the router expects the user's granted scopes to be present in the JWT payload. The scopes should be in an array of strings or a string (scopes separated by space), within a claim named `scope`.
34+
35+
Here is an example of a JWT payload with the correct format:
36+
37+
```json
38+
{
39+
"sub": "user-123",
40+
"scope": [
41+
"read:products",
42+
"write:reviews"
43+
],
44+
"iat": 1516239022
45+
}
46+
```

Cargo.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bench/subgraphs/accounts.rs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use async_graphql::{EmptyMutation, EmptySubscription, Object, Schema, SimpleObject, ID};
1+
use async_graphql::{
2+
ComplexObject, EmptyMutation, EmptySubscription, Interface, Object, Schema, SimpleObject, ID,
3+
};
24
use lazy_static::lazy_static;
35

46
lazy_static! {
@@ -42,14 +44,71 @@ lazy_static! {
4244
];
4345
}
4446

47+
#[derive(Interface, Clone)]
48+
#[allow(clippy::duplicated_attributes)] // async_graphql needs `ty` "duplicated"
49+
#[graphql(
50+
field(name = "url", ty = "String"),
51+
field(name = "handle", ty = "String")
52+
)]
53+
pub enum SocialAccount {
54+
TwitterAccount(TwitterAccount),
55+
GitHubAccount(GitHubAccount),
56+
}
57+
58+
#[derive(SimpleObject, Clone)]
59+
pub struct TwitterAccount {
60+
url: String,
61+
handle: String,
62+
followers: i32,
63+
}
64+
65+
#[derive(SimpleObject, Clone)]
66+
pub struct GitHubAccount {
67+
url: String,
68+
handle: String,
69+
repo_count: i32,
70+
}
71+
4572
#[derive(SimpleObject, Clone)]
73+
#[graphql(complex)]
4674
pub struct User {
4775
id: ID,
4876
name: Option<String>,
4977
username: Option<String>,
5078
birthday: Option<i32>,
5179
}
5280

81+
#[ComplexObject]
82+
impl User {
83+
async fn social_accounts(&self) -> Vec<SocialAccount> {
84+
vec![
85+
SocialAccount::TwitterAccount(TwitterAccount {
86+
url: format!(
87+
"https://twitter.com/{}",
88+
self.username.as_ref().unwrap_or(&"unknown".to_string())
89+
),
90+
handle: format!(
91+
"@{}",
92+
self.username.as_ref().unwrap_or(&"unknown".to_string())
93+
),
94+
followers: 1000,
95+
}),
96+
SocialAccount::GitHubAccount(GitHubAccount {
97+
url: format!(
98+
"https://github.com/{}",
99+
self.username.as_ref().unwrap_or(&"unknown".to_string())
100+
),
101+
handle: self
102+
.username
103+
.as_ref()
104+
.unwrap_or(&"unknown".to_string())
105+
.clone(),
106+
repo_count: 42,
107+
}),
108+
]
109+
}
110+
}
111+
53112
impl User {
54113
fn me() -> User {
55114
USERS[0].clone()

bench/subgraphs/products.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,54 +8,72 @@ lazy_static! {
88
name: Some("Table".to_string()),
99
price: Some(899),
1010
weight: Some(100),
11+
notes: Some("Notes for table".to_string()),
12+
internal: Some("Internal for table".to_string()),
1113
},
1214
Product {
1315
upc: "2".to_string(),
1416
name: Some("Couch".to_string()),
1517
price: Some(1299),
1618
weight: Some(1000),
19+
notes: Some("Notes for couch".to_string()),
20+
internal: Some("Internal for couch".to_string()),
1721
},
1822
Product {
1923
upc: "3".to_string(),
2024
name: Some("Glass".to_string()),
2125
price: Some(15),
2226
weight: Some(20),
27+
notes: Some("Notes for glass".to_string()),
28+
internal: Some("Internal for glass".to_string()),
2329
},
2430
Product {
2531
upc: "4".to_string(),
2632
name: Some("Chair".to_string()),
2733
price: Some(499),
2834
weight: Some(100),
35+
notes: Some("Notes for chair".to_string()),
36+
internal: Some("Internal for chair".to_string()),
2937
},
3038
Product {
3139
upc: "5".to_string(),
3240
name: Some("TV".to_string()),
3341
price: Some(1299),
3442
weight: Some(1000),
43+
notes: Some("Notes for TV".to_string()),
44+
internal: Some("Internal for TV".to_string()),
3545
},
3646
Product {
3747
upc: "6".to_string(),
3848
name: Some("Lamp".to_string()),
3949
price: Some(6999),
4050
weight: Some(300),
51+
notes: Some("Notes for lamp".to_string()),
52+
internal: Some("Internal for lamp".to_string()),
4153
},
4254
Product {
4355
upc: "7".to_string(),
4456
name: Some("Grill".to_string()),
4557
price: Some(3999),
4658
weight: Some(2000),
59+
notes: Some("Notes for grill".to_string()),
60+
internal: Some("Internal for grill".to_string()),
4761
},
4862
Product {
4963
upc: "8".to_string(),
5064
name: Some("Fridge".to_string()),
5165
price: Some(100000),
5266
weight: Some(6000),
67+
notes: Some("Notes for fridge".to_string()),
68+
internal: Some("Internal for fridge".to_string()),
5369
},
5470
Product {
5571
upc: "9".to_string(),
5672
name: Some("Sofa".to_string()),
5773
price: Some(9999),
5874
weight: Some(800),
75+
notes: Some("Notes for sofa".to_string()),
76+
internal: Some("Internal for sofa".to_string()),
5977
}
6078
];
6179
}
@@ -65,6 +83,8 @@ pub struct Product {
6583
name: Option<String>,
6684
price: Option<i64>,
6785
weight: Option<i64>,
86+
notes: Option<String>,
87+
internal: Option<String>,
6888
}
6989

7090
pub struct Query;

bench/supergraph.graphql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ type Product
8282
shippingEstimate: Int @join__field(graph: INVENTORY, requires: "price weight")
8383
name: String @join__field(graph: PRODUCTS)
8484
reviews: [Review] @join__field(graph: REVIEWS)
85+
notes: String @join__field(graph: PRODUCTS)
86+
internal: String @join__field(graph: PRODUCTS)
8587
}
8688

8789
type Query

bin/router/Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,13 @@ tokio-util = "0.7.16"
5454
cookie = "0.18.1"
5555
regex-automata = "0.4.10"
5656
arc-swap = "1.7.1"
57+
lasso2 = "0.8.2"
58+
ahash = "0.8.12"
59+
60+
[dev-dependencies]
61+
criterion = { workspace = true }
62+
insta = { workspace = true }
63+
64+
[[bench]]
65+
name = "router_benches"
66+
harness = false

0 commit comments

Comments
 (0)