From a308eb2a751a962c3b8d40001faf9e61974b966d Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Fri, 21 Nov 2025 17:36:29 +0100 Subject: [PATCH 1/6] Add authorization documentation and specification --- .../router/configuration/authorization.mdx | 171 ++++ .../content/router/configuration/index.mdx | 23 +- .../docs/src/content/router/security/_meta.ts | 1 + .../content/router/security/authorization.mdx | 728 ++++++++++++++++++ 4 files changed, 912 insertions(+), 11 deletions(-) create mode 100644 packages/web/docs/src/content/router/configuration/authorization.mdx create mode 100644 packages/web/docs/src/content/router/security/authorization.mdx diff --git a/packages/web/docs/src/content/router/configuration/authorization.mdx b/packages/web/docs/src/content/router/configuration/authorization.mdx new file mode 100644 index 00000000000..93da8e29158 --- /dev/null +++ b/packages/web/docs/src/content/router/configuration/authorization.mdx @@ -0,0 +1,171 @@ +--- +title: 'authorization' +--- + +# authorization + +The `authorization` configuration lets you control fine-grained access to your GraphQL schema using +directives. This allows you to restrict which fields authenticated users can access based on their +authentication status or specific scopes. + +## How Authorization Works + +The router supports two modes for handling unauthorized field access: + +- **`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 + authorization error is added to the `errors` list 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. + +## Directives + +Access control is defined within the supergraph schema using the following directives: + +### `@authenticated` + +Restricts access to a field to only authenticated users. Any request without valid authentication +(such as requests without a JWT token) will be prevented from accessing the field. + +**Usage:** + +```graphql +type Query { + me: User @authenticated + publicData: String +} +``` + +### `@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"]]`) - the + user must have all scopes in the list +- **`OR` logic** for scopes across nested lists (e.g., `[["read:users"], ["admin:users"]]`) - the + user must have scopes from at least one of the nested lists + +**Usage:** + +```graphql +type Query { + # User must have both scopes + users: [User] @requiresScopes(scopes: [["read:users", "write:users"]]) + + # User must have either scope + admin: AdminPanel @requiresScopes(scopes: [["admin:users"], ["admin:system"]]) + + # User must have read:users AND (admin:users OR admin:system) + reports: [Report] @requiresScopes(scopes: [["read:users", "admin:users"], ["read:users", "admin:system"]]) +} +``` + +## Configuration Options + +### `enabled` + +- **Type:** `boolean` +- **Default:** `true` + +Whether to enable authorization directives processing. Set to `false` to disable authorization checks entirely. + +### `unauthorized.mode` + +- **Type:** `string` +- **Allowed values:** `"filter"` or `"reject"` +- **Default:** `"filter"` + +Controls how the router handles unauthorized field access: + +- `"filter"`: Remove unauthorized fields and continue processing (returns errors for removed fields) +- `"reject"`: Reject the entire request if any unauthorized fields are accessed + +## Examples + +### Filter Mode (Default) + +With `filter` mode, unauthorized fields are silently removed from the operation, but the query +continues to execute and return the data the user can access: + +```yaml filename="router.config.yaml" +authorization: + directives: + enabled: true + unauthorized: + mode: filter +``` + +**Request:** + +```graphql +query { + publicData + me { + name + email + } +} +``` + +If the user is not authenticated, the response might look like: + +```json +{ + "data": { + "publicData": "available", + "me": null + }, + "errors": [ + { + "message": "Unauthorized field or type", + "extensions": { + "code": "UNAUTHORIZED_FIELD_OR_TYPE", + "affectedPath": "me" + } + } + ] +} +``` + +### Reject Mode + +With `reject` mode, if any field is unauthorized, the entire request is rejected: + +```yaml filename="router.config.yaml" +authorization: + directives: + enabled: true + unauthorized: + mode: reject +``` + +**Request (same as above):** + +```graphql +query { + publicData + me { + name + email + } +} +``` + +**Response:** + +```json +{ + "data": null, + "errors": [ + { + "message": "Unauthorized field or type", + "extensions": { + "code": "UNAUTHORIZED_FIELD_OR_TYPE" + } + } + ] +} +``` diff --git a/packages/web/docs/src/content/router/configuration/index.mdx b/packages/web/docs/src/content/router/configuration/index.mdx index 0090314a06b..f668d33732f 100644 --- a/packages/web/docs/src/content/router/configuration/index.mdx +++ b/packages/web/docs/src/content/router/configuration/index.mdx @@ -13,18 +13,19 @@ Some configuration variables can be overridden at runtime using This page covers all the main configuration options available. Each option links to a detailed page that explains how to use that feature. -- [`cors`](./configuration/cors): Control cross-origin requests to your API. -- [`csrf`](./configuration/csrf): Protect against cross-site request forgery attacks. -- [`headers`](./configuration/headers): Modify HTTP headers between clients and subgraphs. -- [`http`](./configuration/http): Set the host and port for your router. -- [`jwt`](./configuration/jwt): Add JWT-based authentication to secure your endpoint. -- [`log`](./configuration/log): Control what the router logs and how it formats log messages. -- [`override_subgraph_urls`](./configuration/override_subgraph_urls): Route requests to different +- [`authorization`](./authorization): Define field-level access control using directives. +- [`cors`](./cors): Control cross-origin requests to your API. +- [`csrf`](./csrf): Protect against cross-site request forgery attacks. +- [`headers`](./headers): Modify HTTP headers between clients and subgraphs. +- [`http`](./http): Set the host and port for your router. +- [`jwt`](./jwt): Add JWT-based authentication to secure your endpoint. +- [`log`](./log): Control what the router logs and how it formats log messages. +- [`override_subgraph_urls`](./override_subgraph_urls): Route requests to different subgraph URLs dynamically. -- [`override_labels`](./configuration/override_labels): Dynamically activate or deactivate +- [`override_labels`](./override_labels): Dynamically activate or deactivate progressive override labels. -- [`query_planner`](./configuration/query_planner): Add safety limits and debugging for query +- [`query_planner`](./query_planner): Add safety limits and debugging for query planning. -- [`supergraph`](./configuration/supergraph): Tell the router where to find your supergraph schema. -- [`traffic_shaping`](./configuration/traffic_shaping): Manage connection pooling and request +- [`supergraph`](./supergraph): Tell the router where to find your supergraph schema. +- [`traffic_shaping`](./traffic_shaping): Manage connection pooling and request handling to subgraphs. diff --git a/packages/web/docs/src/content/router/security/_meta.ts b/packages/web/docs/src/content/router/security/_meta.ts index b8b80ada399..cf4b2a616d1 100644 --- a/packages/web/docs/src/content/router/security/_meta.ts +++ b/packages/web/docs/src/content/router/security/_meta.ts @@ -1,4 +1,5 @@ export default { + authorization: 'Authorization', cors: 'Configuring CORS', csrf: 'CSRF Prevention', 'jwt-authentication': 'JWT Authentication', diff --git a/packages/web/docs/src/content/router/security/authorization.mdx b/packages/web/docs/src/content/router/security/authorization.mdx new file mode 100644 index 00000000000..5e57898d1ac --- /dev/null +++ b/packages/web/docs/src/content/router/security/authorization.mdx @@ -0,0 +1,728 @@ +--- +title: 'Authorization' +--- + +# Authorization + +Authorization directives allow you to define fine-grained access control directly in your GraphQL schema. Instead of handling authorization logic in resolvers or middleware, you declare which fields and types require authentication or specific scopes using directives. The router enforces these rules at the router level, ensuring consistent protection across your entire federated graph. + +This guide explains the core concepts and shows you how to implement authorization directives. For the complete configuration reference, see [`authorization` configuration](../configuration/authorization). + +## How Authorization Directives Work + +When a GraphQL request comes to the router, it goes through these steps: + +1. **Request arrives** with user credentials (typically a JWT token) +2. **Router extracts user information** from the token (authentication status and scopes) +3. **Router checks each field** in the requested query against authorization directives +4. **Access is allowed or denied** based on the field's directive requirements and the user's credentials +5. **Response is returned** with either the requested data, errors, or a full rejection + +The key insight is that authorization happens **before** your subgraphs are even called. This protects sensitive fields at the router level. + +## The Two Authorization Directives + +### `@authenticated` + +The `@authenticated` directive marks a field or type as requiring the user to be authenticated. Anonymous requests (without a valid token) cannot access these fields. + +**Example:** + +```graphql +type Query { + # Anyone can search public posts + searchPublicPosts(query: String!): [Post] + + # Only logged-in users can access their drafts + myDraftPosts: [Post] @authenticated + + # Only logged-in users can see their profile + me: User @authenticated +} + +type User { + id: ID! + username: String! + + # Public information + bio: String + + # Private information - requires authentication + email: String @authenticated + notifications: [Notification!] @authenticated +} +``` + +In this example: +- `searchPublicPosts` is accessible to everyone +- `myDraftPosts` requires authentication +- `me` requires authentication +- On `User`, `email` and `notifications` require authentication, but `id`, `username`, and `bio` don't + +### `@requiresScopes` + +The `@requiresScopes` directive provides more granular control by requiring specific scopes. Scopes are permissions granted to a user, typically stored in their JWT token (under `scope` claim as string - separated by space). This is how you implement role-based and permission-based access control. + +**Scope logic:** + +- **Within a single list** (AND logic): User must have ALL scopes + - Example: `@requiresScopes(scopes: [["read:users", "write:users"]])` means the user needs both scopes + +- **Across multiple lists** (OR logic): User must satisfy at least ONE complete list + - Example: `@requiresScopes(scopes: [["read:users"], ["admin"]])` means the user needs either the `read:users` scope OR the `admin` scope + +**Example:** + +```graphql +type Query { + # Anyone can view public users + users: [User] + + # Requires read:users scope + userDetails(id: ID!): User @requiresScopes(scopes: [["read:users"]]) + + # Requires either read:admin OR manage:users scope + allUsers: [User] @requiresScopes(scopes: [["read:admin"], ["manage:users"]]) + + # Requires both admin scope AND billing:read scope + billingReport: String @requiresScopes(scopes: [["admin", "billing:read"]]) +} + +type Mutation { + # Requires write:users scope + updateUser(id: ID!, input: UserInput!): User + @requiresScopes(scopes: [["write:users"]]) + + # Requires admin scope + deleteUser(id: ID!): Boolean + @requiresScopes(scopes: [["admin"]]) + + # Requires delete:orders scope + deleteOrder(id: ID!): Boolean + @requiresScopes(scopes: [["delete:orders"]]) +} + +type User { + id: ID! + username: String! + + # Public information - no restriction + bio: String + + # Private information - requires read:email scope + email: String @requiresScopes(scopes: [["read:email"]]) + + # Admin-only information - requires admin scope + internalNotes: String @requiresScopes(scopes: [["admin"]]) + + # Requires either admin scope OR support:user:read scope + supportTickets: [SupportTicket!] + @requiresScopes(scopes: [["admin"], ["support:user:read"]]) +} +``` + +## Combining Directives Across Types + +When a type is defined across multiple subgraphs (federation), authorization requirements are combined using logical `AND`. This means a user must satisfy all requirements from all subgraphs to access that type. + +**Example:** + +Imagine a `Product` type that exists in multiple services: + +`inventory` subgraph: +```graphql +type Product @key(fields: "id") @authenticated { + id: ID! + sku: String! + inStock: Int +} +``` + +`pricing` subgraph: +```graphql +type Product @key(fields: "id") @requiresScopes(scopes: [["pricing:read"]]) { + id: ID! + price: Float + discounts: [Discount!] +} +``` + +**Resulting requirement:** To access any `Product` field, a user must be: +- `@authenticated` (from inventory subgraph) +- Have `pricing:read` scope (from pricing subgraph) + +So querying `product.inStock` requires both authentication and the pricing scope. + +## Type-Level vs Field-Level Directives + +Authorization can be applied at two levels: + +### Type-Level Protection + +When you put a directive on a type, it protects ALL fields of that type by default: + +```graphql +type AdminPanel @authenticated { + users: [User!] + logs: [String!] + settings: SystemSettings +} +``` + +Any request trying to access `users`, `logs`, or `settings` must be authenticated. + +### Field-Level Protection + +When you put a directive on a specific field, it adds additional restrictions beyond any type-level protection: + +```graphql +type User @authenticated { + id: ID! + username: String! + + # This field requires authentication (from type) PLUS email:read scope + email: String @requiresScopes(scopes: [["email:read"]]) + + # This field requires only authentication (from type) + name: String +} +``` + +**The key principle:** Field-level requirements are combined with type-level requirements using `AND` logic. The field is more restrictive. + +## Handling Authorization Errors + +The router supports two modes for handling authorization violations: + +### Filter Mode (Default) + +When a user tries to access an unauthorized field in **filter mode**, the router removes that field from the response but continues processing the rest of the query. An error is added for the removed field, but the query doesn't completely fail. + +```graphql +# User Query (user has authentication but not admin scope) +query { + dashboard { + publicMetrics + adminPanel # User not authorized + } +} +``` + +**Response:** +```json +{ + "data": { + "dashboard": { + "publicMetrics": { ... }, + "adminPanel": null + } + }, + "errors": [ + { + "message": "Unauthorized field or type", + "extensions": { + "code": "UNAUTHORIZED_FIELD_OR_TYPE", + } + } + ] +} +``` + +### Reject Mode + +In **reject mode**, if a user tries to access any unauthorized field, the entire request is rejected. No data is returned, only an error. + +```graphql +# Same query as above, but with reject mode enabled +query { + dashboard { + publicMetrics + adminPanel # User not authorized + } +} +``` + +**Response:** +```json +{ + "data": null, + "errors": [ + { + "message": "Unauthorized field or type", + "extensions": { + "code": "UNAUTHORIZED_FIELD_OR_TYPE", + } + } + ] +} +``` + +## Integration with JWT Authentication + +Authorization directives work alongside your [JWT authentication setup](./jwt-authentication.mdx). Here's the flow: + +1. **Client sends request** with JWT token in the `Authorization` header +2. **Router validates JWT** using your configured JWKS provider +3. **Router extracts scopes** from the JWT claims (`scope` field) +4. **Router checks authorization directives** against the extracted scopes +5. **Query proceeds or fails** based on authorization result + +**Configuration example:** + +```yaml filename="router.config.yaml" +jwt: + require_authentication: false # Allow anonymous requests + jwks_providers: + - source: remote + url: https://your-auth-provider.com/.well-known/jwks.json + +authorization: + directives: + enabled: true + unauthorized: + mode: filter # Or 'reject' for stricter enforcement +``` + +With this setup, your GraphQL API allows both anonymous and authenticated requests, but authorization directives control which fields each user can access. + + +## Specification + +### 1. Overview + +This document specifies the behavior and application of authorization directives. +It defines the rules for applying these directives to various type definitions to ensure consistent and predictable security enforcement across all subgraphs. + +### 2. Authorization Directives + +The primary authorization directives are: + +| `@authenticated` | Requires the requesting client to be authenticated. | +| --- | --- | +| `@requiresScopes` | Requires the authenticated client's access token to possess a specific set of scopes. | +| `@policy` | Requires the request to satisfy a specific authorization policy, evaluated by the gateway. The logic is provided by the user, typically as a gateway plugin. | + +Collectively, these are referred to as "auth directives". + +### 3. General Principles + +#### 3.1. Composition Logic + +When auth directives are applied at multiple levels (e.g., on a type and its field) or across different subgraphs for the same type, their requirements are combined using a logical `AND`. +A request must satisfy the combined set of all applicable authorization policies to access the protected resource. + +#### 3.2. Scope of Protection + +Auth directives protect **output types**. They are applied when a field, type, enum, or scalar is being returned in a response. +They do not apply to their usage in input types (e.g., arguments), as this constitutes data provided by the client. + +#### 3.3 Merging and Normalizing `@requiresScopes` + +When an entity (like a type or field) is protected by multiple `@requiresScopes` directives, typically from different subgraphs, the resulting policy is the logical `AND` of all individual scope policies. The composition must compute a single, normalized `scopes` argument that represents this combined requirement. + +For clarity, remember the logic of the `scopes` argument: + +- The outer list represents a logical `OR`. +- The inner list of scopes represents a logical `AND`. +- Example: `scopes: [["user:read", "user:email"], ["admin"]]` means the client must have (`user:read` AND `user:email`) OR (`admin`). + +##### 3.3.1. The Merging Algorithm + +The process involves two main steps: creating a combined set of scope groups, and then pruning redundant groups from the result. + +**Step 1: Create Combined Groups** + +To satisfy `PolicyA AND PolicyB`, a client must satisfy at least one scope group from `PolicyA` and at least one scope group from `PolicyB`. The new, combined scope groups are formed by taking the **union** of every possible pairing of a scope group from the first policy with a scope group from the second. + +**Step 2: Prune Redundant Groups** + +After generating the combined list, simplify it by removing any group that is a superset of another group in the list. A scope group is considered redundant if another, more permissive group (i.e., a subset with fewer requirements) exists. If a client can satisfy the subset, they can automatically satisfy the superset, making the superset unnecessary to list. + +##### 3.3.2. Example + +Let's apply this algorithm to a `User` type that is defined across an `accounts` service and a `billing` service. + +`accounts` subgraph + +```graphql +@requiresScopes(scopes: + [ + ["user:read", "user:email:read"], + ["admin"] + ] +) + +``` + +Logical meaning + +```graphql +("user:read" AND "user:email:read") + OR +("admin") +``` + +`billing` subgraph + +```graphql +@requiresScopes(scopes: + [ + ["user:read", "billing:read"], + ["admin", "billing:invoice:read"], + ["support:user:read"] + ] +) +``` + +Logical meaning + +```graphql +("user:read" AND "billing:read") + OR +("admin" AND "billing:invoice:read") + OR +("support:user:read") +``` + +Applying the Algorithm: + +**1. Create the Cross-Product:** + +We will pair each of the 2 groups from the `accounts` policy with each of the 3 groups from the `billing` policy, creating 2 * 3 = 6 new combined groups. + +```graphql +["user:read", "user:email:read"] ∪ ["user:read", "billing:read"] + -> ["user:read", "user:email:read", "billing:read"] + +["user:read", "user:email:read"] ∪ ["admin", "billing:invoice:read"] + -> ["user:read", "user:email:read", "admin", "billing:invoice:read"] + +["user:read", "user:email:read"] ∪ ["support:user:read"] + -> ["user:read", "user:email:read", "support:user:read"]` + +["admin"] ∪ ["user:read", "billing:read"] + -> ["admin", "user:read", "billing:read"] + +["admin"] ∪ ["admin", "billing:invoice:read"] + -> ["admin", "billing:invoice:read"] + +["admin"] ∪ ["support:user:read"] + -> ["admin", "support:user:read"] +``` + +**2. Prune Redundant Groups:** + +Now we examine the raw list for groups that are supersets of others. + +The group `["user:read", "user:email:read", "admin", "billing:invoice:read"]` contains all the scopes from `["admin", "billing:invoice:read"]`. Therefore, the first group is a redundant superset and is **removed**. + +The final, merged directive on the global `User` type is: + +```graphql +@requiresScopes(scopes: [ + ["user:read", "user:email:read", "billing:read"], + ["user:read", "user:email:read", "support:user:read"], + ["admin", "user:read", "billing:read"], + ["admin", "billing:invoice:read"], + ["admin", "support:user:read"] +]) +``` + +This deterministic process ensures that the combined policy is both logically correct and expressed in its simplest form. + +### 4. Rules by GraphQL Type + +#### 4.1. Object Types + +The use of auth directives on object types and their fields is **allowed**. + +- **Type-Level Application**: When an auth directive is applied to an object type, it establishes a baseline authorization requirement for accessing any field on that type. +- **Field-Level Application**: Directives on a specific field add to any requirements inherited from the object type. +- **Composition**: The effective authorization policy for a field is the logical `AND` of its own directives and any directives applied to its parent object type. + +##### 4.1.1. Federated Object Type Scenarios + +When an object type is extended across multiple subgraphs, the type-level auth directives from all definitions are combined using `AND` logic to form a global baseline requirement for that type. + +**Scenario: Merging Type-Level Directives** + +Consider a `Product` type defined in an `inventory` subgraph and extended in a `reviews` subgraph. + +`inventory` subgraph + +```graphql +type Product + @key(fields: "upc") + @authenticated +{ + upc: ID! + inStock: Int +} +``` + +`reviews` subgraph + +```graphql +type Product + @key(fields: "upc") + @requiresScopes(scopes: [["product:read"]]) +{ + upc: ID! + reviews: [Review!] +} +``` + +**Resulting Policy:** + +The global `Product` type effectively has `@authenticated AND @requiresScopes(scopes: [["product:read"]])` applied. + +- To query `Product.inStock`, the client must be authenticated **AND** have the `product:read` scope. +- To query `Product.reviews`, the client must also be authenticated **AND** have the `product:read` scope. + +**Scenario: Field-Level Directives on Federated Types** + +Field-level directives are combined with the *globally merged* type-level directives. + +`accounts` subgraph + +```graphql +type User @key(fields: "id") + @authenticated +{ + id: ID! + email: String + @requiresScopes(scopes: [["email:read"]]) +} +``` + +`profiles` subgraph + +```graphql +type User @key(fields: "id") + @policy(policies: [["PublicProfile"]]) +{ + id: ID! + profile: Profile +} +``` + +**Resulting Policy:** + +The global `User` type has a baseline policy of `@authenticated AND @policy(policies: [["PublicProfile"]])`. + +- To query `User.profile`, the client must be authenticated **AND** satisfy the `PublicProfile` policy. +- To query `User.email`, the client must be authenticated, satisfy the `PublicProfile` policy, **AND** have the `email:read` scope. + +#### 4.2. Enums and Scalars + +The use of auth directives on enum and scalar types is **allowed**. + +##### 4.2.1. Federated Enum and Scalar Scenarios + +If multiple subgraphs define the same custom scalar or enum with different auth directives, the requirements are combined globally using `AND` logic. +Any field in the supergraph that returns that type will be protected by the combined policy. + +**Scenario: Merging Scalar Directives** + +Consider a `SensitiveString` scalar defined with different protections in two subgraphs. + +`pii` subgraph + +```graphql +scalar SensitiveString + @requiresScopes(scopes: [["pii:read"]]) +``` + +`compliance` subgraph + +```graphql +scalar SensitiveString + @policy(policies: [["GDPR_Compliant"]]) +``` + +**Resulting Policy:** + +Any field across the entire federated graph that returns a `SensitiveString` will require the client to have the `pii:read` scope **AND** satisfy the `GDPR_Compliant` policy. + +```graphql +# In a third subgraph (e.g., users) +type User { + nationalId: SensitiveString # Accessing this field requires both protections +} + +``` + +#### 4.3. Interface + +The use of auth directives on interface types and its fields is **disallowed**. + +Instead, they are applied to the concrete `type` definitions that implement the interface. +The composition is responsible for computing the effective policy for the interface based on its implementing types. + +**Interface Type Policy** +The effective authorization policy for an interface type is the logical `AND` of the policies of all its implementing object types across the entire federation. + +**Interface Field Policy** +The effective authorization policy for a field on an interface is the logical `AND` of the policies on that same field across all corresponding implementing object types. + +**Rationale** +This aligns with limitations in the `@apollo/subgraph` library and avoids ambiguity in policy enforcement across implementing types. Authorization should be defined on the concrete object types that implement the interface. + +##### 4.3.1. Example Schema + +Consider an `Item` interface implemented by `Book` and `Video` across two subgraphs. We will add a field unique to each implementing type (`author` and `director`) to better illustrate query behavior. + +`books` subgraph + +```graphql +interface Item { + id: ID! + title: String +} + +type Book implements Item + @authenticated +{ + id: ID! + title: String + @requiresScopes(scopes: [["book:read"]]) + author: String + # Inherits @authenticated from the Book type +} +``` + +`videos` subgraph + +```graphql +type Video implements Item + @policy(policies: [["VideoAccess"]]) +{ + id: ID! + title: String + @requiresScopes(scopes: [["video:read"]]) + director: String + @requiresScopes(scopes: [["video:metadata"]]) +} + +``` + +**Resulting Effective Policies on the `Item` Interface:** + +- **`Item` Interface Type**: The effective policy is `@authenticated AND @policy(policies: [["VideoAccess"]])`. +- **`Item.title` Field**: The effective policy is `@requiresScopes(scopes: [["book:read"]]) AND @requiresScopes(scopes: [["video:read"]])`. + +##### 4.3.2. Query Scenarios with Interfaces + +Here is how these policies are applied to different queries. + +**Scenario 1: Querying a Common Interface Field** + +When you query a field directly on the interface, the most restrictive, combined policy is applied. + +```graphql +query GetItemTitle { + item(id: "123") { + title + } +} +``` + +**Authorization Analysis:** + +1. The `item` field returns the `Item` interface, so its type policy (`@authenticated AND @policy(policies: [["VideoAccess"]])`) is checked first. +2. The `title` field is being accessed on the `Item` interface, so its combined field policy (`@requiresScopes(scopes: [["book:read"]]) AND @requiresScopes(scopes: [["video:read"]])`) is checked. +3. **Total Requirement**: The client must be authenticated, satisfy the `VideoAccess` policy, and possess **both** the `book:read` and `video:read` scopes to execute this query successfully, regardless of whether the returned item is a `Book` or a `Video`. + +**Scenario 2: Querying with Inline Fragments** + +When you use inline fragments, authorization is applied based on the concrete type within the fragment. This allows for more granular access. + +```graphql +query GetSpecificItem { + item(id: "123") { + ... on Book { + author + } + ... on Video { + director + } + } +} +``` + +**Authorization Analysis:** + +1. The `item` field's type policy (`@authenticated AND @policy(policies: [["VideoAccess"]])`) is always checked first. +2. The gateway resolves `item(id: "123")` and determines its concrete type. +3. **If the item is a `Book`**: + - The `... on Book` fragment is entered. + - The policy for `Book.author` is checked, which is `@authenticated` (inherited from the `Book` type). + - **Total Requirement**: `@authenticated AND @policy(policies: [["VideoAccess"]])`. +4. **If the item is a `Video`**: + - The `... on Video` fragment is entered. + - The policy for `Video.director` is checked, which is `@requiresScopes(scopes: [["video:metadata"]])`. + - **Total Requirement**: `@authenticated AND @policy(policies: [["VideoAccess"]]) AND @requiresScopes(scopes: [["video:metadata"]])`. + +This demonstrates how inline fragments allow clients to access data for which they are specifically authorized, even if they don't have the superset of permissions required to query all fields on the interface directly. + +**Scenario 3: Querying Both Common and Specific Fields** + +When a query asks for a common field and also uses an inline fragment, all applicable policies are checked. + +```graphql +query GetItemDetails { + item(id: "456") { + title + ... on Book { + author + } + } +} +``` + +**Authorization Analysis:** + +1. The `item` field's type policy is checked first: `@authenticated AND @policy(policies: [][["VideoAccess"]])`. +2. The `title` field's interface-level policy is checked: `@requiresScopes(scopes: [["book:read"]]) AND @requiresScopes(scopes: [["video:read"]])`. +3. The gateway resolves the item's type. If it's a `Book`, the `... on Book` fragment is entered, and the policy for `Book.author` (`@authenticated`) is also confirmed. +4. **Total Requirement**: A client must satisfy the policies from steps 1 and 2 to even attempt this query. If the resolved item is a `Book`, the policies from step 3 are also relevant, but they are already covered by the more restrictive policies from the preceding steps. + +#### 4.4. Unions + +The use of auth directives on union types is **prohibited**. + +**Rationale**: Union types do not have fields and cannot be queried directly. Authorization policies should be placed on the concrete object types that constitute the union's possible members. + +### 5. Field-level Dependencies (`@requires`) + +A field utilizing the `@requires` directive to access fields from another entity must define an authorization policy that is a superset of the policies on all the required fields. +This ensures that a federated query does not create a loophole to bypass the security policies of the underlying fields. + +Example + +Assume the `products` subgraph defines `Product.price`. + +```graphql +type Product @key(fields: "id") { + id: ID! + price: Float @requiresScopes(scopes: [["read:price"]]) +} +``` + +In the `reviews` subgraph, the `Review` type must ensure its own authorization accommodates the `read:price` requirement if it needs to access `Product.price`. + +```graphql +type Product @key(fields: "id") { + id: ID! + price: Float @external +} + +type Review { + body: String + # This field's policy MUST be a superset of Product.price's policy. + # The following is valid because it requires the necessary scope. + productPrice: Float + @requires(fields: "product { price }") + @requiresScopes(scopes: [["read:price"]]) +} +``` From fbb22ee00e91fb0babb5dc8dff6ee0c2df2419f5 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Fri, 21 Nov 2025 17:47:14 +0100 Subject: [PATCH 2/6] Add JWT integration section and fix config links --- .../router/configuration/authorization.mdx | 3 + .../content/router/configuration/index.mdx | 24 ++++---- .../content/router/security/authorization.mdx | 56 +++++++++---------- 3 files changed, 43 insertions(+), 40 deletions(-) diff --git a/packages/web/docs/src/content/router/configuration/authorization.mdx b/packages/web/docs/src/content/router/configuration/authorization.mdx index 93da8e29158..a0b546b95bd 100644 --- a/packages/web/docs/src/content/router/configuration/authorization.mdx +++ b/packages/web/docs/src/content/router/configuration/authorization.mdx @@ -8,6 +8,9 @@ The `authorization` configuration lets you control fine-grained access to your G directives. This allows you to restrict which fields authenticated users can access based on their authentication status or specific scopes. +For practical examples and a guide to authorization concepts, see +**["Authorization"](../security/authorization)** in the documentation. + ## How Authorization Works The router supports two modes for handling unauthorized field access: diff --git a/packages/web/docs/src/content/router/configuration/index.mdx b/packages/web/docs/src/content/router/configuration/index.mdx index f668d33732f..fec98ef48f9 100644 --- a/packages/web/docs/src/content/router/configuration/index.mdx +++ b/packages/web/docs/src/content/router/configuration/index.mdx @@ -13,19 +13,19 @@ Some configuration variables can be overridden at runtime using This page covers all the main configuration options available. Each option links to a detailed page that explains how to use that feature. -- [`authorization`](./authorization): Define field-level access control using directives. -- [`cors`](./cors): Control cross-origin requests to your API. -- [`csrf`](./csrf): Protect against cross-site request forgery attacks. -- [`headers`](./headers): Modify HTTP headers between clients and subgraphs. -- [`http`](./http): Set the host and port for your router. -- [`jwt`](./jwt): Add JWT-based authentication to secure your endpoint. -- [`log`](./log): Control what the router logs and how it formats log messages. -- [`override_subgraph_urls`](./override_subgraph_urls): Route requests to different +- [`authorization`](./configuration/authorization): Define field-level access control using directives. +- [`cors`](./configuration/cors): Control cross-origin requests to your API. +- [`csrf`](./configuration/csrf): Protect against cross-site request forgery attacks. +- [`headers`](./configuration/headers): Modify HTTP headers between clients and subgraphs. +- [`http`](./configuration/http): Set the host and port for your router. +- [`jwt`](./configuration/jwt): Add JWT-based authentication to secure your endpoint. +- [`log`](./configuration/log): Control what the router logs and how it formats log messages. +- [`override_subgraph_urls`](./configuration/override_subgraph_urls): Route requests to different subgraph URLs dynamically. -- [`override_labels`](./override_labels): Dynamically activate or deactivate +- [`override_labels`](./configuration/override_labels): Dynamically activate or deactivate progressive override labels. -- [`query_planner`](./query_planner): Add safety limits and debugging for query +- [`query_planner`](./configuration/query_planner): Add safety limits and debugging for query planning. -- [`supergraph`](./supergraph): Tell the router where to find your supergraph schema. -- [`traffic_shaping`](./traffic_shaping): Manage connection pooling and request +- [`supergraph`](./configuration/supergraph): Tell the router where to find your supergraph schema. +- [`traffic_shaping`](./configuration/traffic_shaping): Manage connection pooling and request handling to subgraphs. diff --git a/packages/web/docs/src/content/router/security/authorization.mdx b/packages/web/docs/src/content/router/security/authorization.mdx index 5e57898d1ac..269c2e2c03e 100644 --- a/packages/web/docs/src/content/router/security/authorization.mdx +++ b/packages/web/docs/src/content/router/security/authorization.mdx @@ -20,6 +20,34 @@ When a GraphQL request comes to the router, it goes through these steps: The key insight is that authorization happens **before** your subgraphs are even called. This protects sensitive fields at the router level. +## Integration with JWT Authentication + +Authorization directives work alongside your [JWT authentication setup](./jwt-authentication.mdx). Here's the flow: + +1. **Client sends request** with JWT token in the `Authorization` header +2. **Router validates JWT** using your configured JWKS provider +3. **Router extracts scopes** from the JWT claims (`scope` field) +4. **Router checks authorization directives** against the extracted scopes +5. **Query proceeds or fails** based on authorization result + +**Configuration example:** + +```yaml filename="router.config.yaml" +jwt: + require_authentication: false # Allow anonymous requests + jwks_providers: + - source: remote + url: https://your-auth-provider.com/.well-known/jwks.json + +authorization: + directives: + enabled: true + unauthorized: + mode: filter # Or 'reject' for stricter enforcement +``` + +With this setup, your GraphQL API allows both anonymous and authenticated requests, but authorization directives control which fields each user can access. + ## The Two Authorization Directives ### `@authenticated` @@ -257,34 +285,6 @@ query { } ``` -## Integration with JWT Authentication - -Authorization directives work alongside your [JWT authentication setup](./jwt-authentication.mdx). Here's the flow: - -1. **Client sends request** with JWT token in the `Authorization` header -2. **Router validates JWT** using your configured JWKS provider -3. **Router extracts scopes** from the JWT claims (`scope` field) -4. **Router checks authorization directives** against the extracted scopes -5. **Query proceeds or fails** based on authorization result - -**Configuration example:** - -```yaml filename="router.config.yaml" -jwt: - require_authentication: false # Allow anonymous requests - jwks_providers: - - source: remote - url: https://your-auth-provider.com/.well-known/jwks.json - -authorization: - directives: - enabled: true - unauthorized: - mode: filter # Or 'reject' for stricter enforcement -``` - -With this setup, your GraphQL API allows both anonymous and authenticated requests, but authorization directives control which fields each user can access. - ## Specification From 93447547880127c865aebf279a905daa66a415c7 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Mon, 24 Nov 2025 12:02:34 +0100 Subject: [PATCH 3/6] asd --- .../content/router/security/authorization.mdx | 712 ++++-------------- 1 file changed, 152 insertions(+), 560 deletions(-) diff --git a/packages/web/docs/src/content/router/security/authorization.mdx b/packages/web/docs/src/content/router/security/authorization.mdx index 269c2e2c03e..c41ba3eb5ff 100644 --- a/packages/web/docs/src/content/router/security/authorization.mdx +++ b/packages/web/docs/src/content/router/security/authorization.mdx @@ -2,15 +2,17 @@ title: 'Authorization' --- +import { Callout } from '#components/callout'; + # Authorization -Authorization directives allow you to define fine-grained access control directly in your GraphQL schema. Instead of handling authorization logic in resolvers or middleware, you declare which fields and types require authentication or specific scopes using directives. The router enforces these rules at the router level, ensuring consistent protection across your entire federated graph. +Authorization directives allow you to define fine-grained access control directly in your GraphQL schema. Instead of handling authorization logic in resolvers, you declare which fields and types require authentication or specific scopes using directives. The router enforces these rules before calling your subgraphs, ensuring consistent protection across your federated graph. -This guide explains the core concepts and shows you how to implement authorization directives. For the complete configuration reference, see [`authorization` configuration](../configuration/authorization). +For the complete configuration reference, see [`authorization` configuration](../configuration/authorization). ## How Authorization Directives Work -When a GraphQL request comes to the router, it goes through these steps: +When a GraphQL request arrives at the router, it goes through these steps: 1. **Request arrives** with user credentials (typically a JWT token) 2. **Router extracts user information** from the token (authentication status and scopes) @@ -18,23 +20,18 @@ When a GraphQL request comes to the router, it goes through these steps: 4. **Access is allowed or denied** based on the field's directive requirements and the user's credentials 5. **Response is returned** with either the requested data, errors, or a full rejection -The key insight is that authorization happens **before** your subgraphs are even called. This protects sensitive fields at the router level. - -## Integration with JWT Authentication -Authorization directives work alongside your [JWT authentication setup](./jwt-authentication.mdx). Here's the flow: + + Authorization happens **before** your subgraphs are called, protecting sensitive fields at the router level. + -1. **Client sends request** with JWT token in the `Authorization` header -2. **Router validates JWT** using your configured JWKS provider -3. **Router extracts scopes** from the JWT claims (`scope` field) -4. **Router checks authorization directives** against the extracted scopes -5. **Query proceeds or fails** based on authorization result +## Integration with JWT Authentication -**Configuration example:** +Authorization directives work alongside your [JWT authentication setup](./jwt-authentication.mdx). When a request arrives, the router validates the JWT token and extracts scopes from the JWT claims (in the `scope` field). It then checks the authorization directives in your schema against these extracted scopes to determine if the query should proceed or fail. ```yaml filename="router.config.yaml" jwt: - require_authentication: false # Allow anonymous requests + require_authentication: false jwks_providers: - source: remote url: https://your-auth-provider.com/.well-known/jwks.json @@ -43,686 +40,281 @@ authorization: directives: enabled: true unauthorized: - mode: filter # Or 'reject' for stricter enforcement + mode: filter # Or 'reject' ``` -With this setup, your GraphQL API allows both anonymous and authenticated requests, but authorization directives control which fields each user can access. - -## The Two Authorization Directives +## Authorization Directives ### `@authenticated` -The `@authenticated` directive marks a field or type as requiring the user to be authenticated. Anonymous requests (without a valid token) cannot access these fields. - -**Example:** +Marks a field or type as requiring authentication. Anonymous requests cannot access these fields. ```graphql -type Query { - # Anyone can search public posts - searchPublicPosts(query: String!): [Post] - - # Only logged-in users can access their drafts - myDraftPosts: [Post] @authenticated +extend schema @link( + url: "https://specs.apollo.dev/federation/v2.9" + import: ["@authenticated"] +) - # Only logged-in users can see their profile - me: User @authenticated +type Query { + publicPosts: [Post] # Anyone + myDrafts: [Post] @authenticated # Authenticated users only } type User { id: ID! username: String! - - # Public information bio: String - - # Private information - requires authentication - email: String @authenticated - notifications: [Notification!] @authenticated + email: String @authenticated # Private field } ``` -In this example: -- `searchPublicPosts` is accessible to everyone -- `myDraftPosts` requires authentication -- `me` requires authentication -- On `User`, `email` and `notifications` require authentication, but `id`, `username`, and `bio` don't - ### `@requiresScopes` -The `@requiresScopes` directive provides more granular control by requiring specific scopes. Scopes are permissions granted to a user, typically stored in their JWT token (under `scope` claim as string - separated by space). This is how you implement role-based and permission-based access control. - -**Scope logic:** +Provides granular control by requiring specific scopes (permissions stored in JWT token). -- **Within a single list** (AND logic): User must have ALL scopes - - Example: `@requiresScopes(scopes: [["read:users", "write:users"]])` means the user needs both scopes - -- **Across multiple lists** (OR logic): User must satisfy at least ONE complete list - - Example: `@requiresScopes(scopes: [["read:users"], ["admin"]])` means the user needs either the `read:users` scope OR the `admin` scope - -**Example:** +- **Single list** (AND logic): User must have **all** scopes in the list +- **Multiple lists** (OR logic): User must satisfy **any** complete list ```graphql +extend schema @link( + url: "https://specs.apollo.dev/federation/v2.9" + import: ["@requiresScopes"] +) + type Query { - # Anyone can view public users users: [User] - - # Requires read:users scope - userDetails(id: ID!): User @requiresScopes(scopes: [["read:users"]]) - - # Requires either read:admin OR manage:users scope - allUsers: [User] @requiresScopes(scopes: [["read:admin"], ["manage:users"]]) - - # Requires both admin scope AND billing:read scope + # Requires both scopes billingReport: String @requiresScopes(scopes: [["admin", "billing:read"]]) -} - -type Mutation { - # Requires write:users scope - updateUser(id: ID!, input: UserInput!): User - @requiresScopes(scopes: [["write:users"]]) - - # Requires admin scope - deleteUser(id: ID!): Boolean - @requiresScopes(scopes: [["admin"]]) - - # Requires delete:orders scope - deleteOrder(id: ID!): Boolean - @requiresScopes(scopes: [["delete:orders"]]) + # Requires either scope + allUsers: [User] @requiresScopes(scopes: [["read:admin"], ["manage:users"]]) } type User { id: ID! username: String! - - # Public information - no restriction - bio: String - - # Private information - requires read:email scope - email: String @requiresScopes(scopes: [["read:email"]]) - - # Admin-only information - requires admin scope - internalNotes: String @requiresScopes(scopes: [["admin"]]) - - # Requires either admin scope OR support:user:read scope - supportTickets: [SupportTicket!] - @requiresScopes(scopes: [["admin"], ["support:user:read"]]) -} -``` - -## Combining Directives Across Types - -When a type is defined across multiple subgraphs (federation), authorization requirements are combined using logical `AND`. This means a user must satisfy all requirements from all subgraphs to access that type. - -**Example:** - -Imagine a `Product` type that exists in multiple services: - -`inventory` subgraph: -```graphql -type Product @key(fields: "id") @authenticated { - id: ID! - sku: String! - inStock: Int -} -``` - -`pricing` subgraph: -```graphql -type Product @key(fields: "id") @requiresScopes(scopes: [["pricing:read"]]) { - id: ID! - price: Float - discounts: [Discount!] + email: String @requiresScopes(scopes: [["email:read"]]) } ``` -**Resulting requirement:** To access any `Product` field, a user must be: -- `@authenticated` (from inventory subgraph) -- Have `pricing:read` scope (from pricing subgraph) - -So querying `product.inStock` requires both authentication and the pricing scope. - -## Type-Level vs Field-Level Directives +## Directives on Types and Fields -Authorization can be applied at two levels: - -### Type-Level Protection - -When you put a directive on a type, it protects ALL fields of that type by default: +**Type-level directives** protect all fields of that type: ```graphql type AdminPanel @authenticated { users: [User!] logs: [String!] - settings: SystemSettings } ``` -Any request trying to access `users`, `logs`, or `settings` must be authenticated. - -### Field-Level Protection - -When you put a directive on a specific field, it adds additional restrictions beyond any type-level protection: +**Field-level directives** add additional restrictions beyond type-level protection: ```graphql type User @authenticated { id: ID! username: String! - - # This field requires authentication (from type) PLUS email:read scope - email: String @requiresScopes(scopes: [["email:read"]]) - - # This field requires only authentication (from type) - name: String + email: String @requiresScopes(scopes: [["email:read"]]) # Requires auth + scope + name: String # Requires auth only } ``` -**The key principle:** Field-level requirements are combined with type-level requirements using `AND` logic. The field is more restrictive. - -## Handling Authorization Errors - -The router supports two modes for handling authorization violations: - -### Filter Mode (Default) +Field requirements are combined with type requirements using `AND` logic. -When a user tries to access an unauthorized field in **filter mode**, the router removes that field from the response but continues processing the rest of the query. An error is added for the removed field, but the query doesn't completely fail. +## Combining Directives Across Federated Types -```graphql -# User Query (user has authentication but not admin scope) -query { - dashboard { - publicMetrics - adminPanel # User not authorized - } -} -``` - -**Response:** -```json -{ - "data": { - "dashboard": { - "publicMetrics": { ... }, - "adminPanel": null - } - }, - "errors": [ - { - "message": "Unauthorized field or type", - "extensions": { - "code": "UNAUTHORIZED_FIELD_OR_TYPE", - } - } - ] -} -``` +When a type is defined across multiple subgraphs, all authorization requirements are combined using `AND`. A user must satisfy requirements from all subgraphs. -### Reject Mode +### Type-Level Composition -In **reject mode**, if a user tries to access any unauthorized field, the entire request is rejected. No data is returned, only an error. +Consider a `Product` type split across inventory and pricing services. Each subgraph applies its own authorization directive, and when the type is composed in the supergraph, both requirements must be met. -```graphql -# Same query as above, but with reject mode enabled -query { - dashboard { - publicMetrics - adminPanel # User not authorized - } +```graphql filename="Inventory subgraph" +type Product @key(fields: "id") @authenticated { + id: ID! + inStock: Int } ``` -**Response:** -```json -{ - "data": null, - "errors": [ - { - "message": "Unauthorized field or type", - "extensions": { - "code": "UNAUTHORIZED_FIELD_OR_TYPE", - } - } - ] +```graphql filename="Pricing subgraph" +type Product @key(fields: "id") @requiresScopes(scopes: [["pricing:read"]]) { + id: ID! + price: Float } ``` +To access `Product`, user must be authenticated **AND** have `pricing:read` scope. -## Specification - -### 1. Overview - -This document specifies the behavior and application of authorization directives. -It defines the rules for applying these directives to various type definitions to ensure consistent and predictable security enforcement across all subgraphs. - -### 2. Authorization Directives - -The primary authorization directives are: - -| `@authenticated` | Requires the requesting client to be authenticated. | -| --- | --- | -| `@requiresScopes` | Requires the authenticated client's access token to possess a specific set of scopes. | -| `@policy` | Requires the request to satisfy a specific authorization policy, evaluated by the gateway. The logic is provided by the user, typically as a gateway plugin. | - -Collectively, these are referred to as "auth directives". - -### 3. General Principles - -#### 3.1. Composition Logic - -When auth directives are applied at multiple levels (e.g., on a type and its field) or across different subgraphs for the same type, their requirements are combined using a logical `AND`. -A request must satisfy the combined set of all applicable authorization policies to access the protected resource. - -#### 3.2. Scope of Protection - -Auth directives protect **output types**. They are applied when a field, type, enum, or scalar is being returned in a response. -They do not apply to their usage in input types (e.g., arguments), as this constitutes data provided by the client. - -#### 3.3 Merging and Normalizing `@requiresScopes` - -When an entity (like a type or field) is protected by multiple `@requiresScopes` directives, typically from different subgraphs, the resulting policy is the logical `AND` of all individual scope policies. The composition must compute a single, normalized `scopes` argument that represents this combined requirement. - -For clarity, remember the logic of the `scopes` argument: - -- The outer list represents a logical `OR`. -- The inner list of scopes represents a logical `AND`. -- Example: `scopes: [["user:read", "user:email"], ["admin"]]` means the client must have (`user:read` AND `user:email`) OR (`admin`). - -##### 3.3.1. The Merging Algorithm - -The process involves two main steps: creating a combined set of scope groups, and then pruning redundant groups from the result. - -**Step 1: Create Combined Groups** - -To satisfy `PolicyA AND PolicyB`, a client must satisfy at least one scope group from `PolicyA` and at least one scope group from `PolicyB`. The new, combined scope groups are formed by taking the **union** of every possible pairing of a scope group from the first policy with a scope group from the second. - -**Step 2: Prune Redundant Groups** - -After generating the combined list, simplify it by removing any group that is a superset of another group in the list. A scope group is considered redundant if another, more permissive group (i.e., a subset with fewer requirements) exists. If a client can satisfy the subset, they can automatically satisfy the superset, making the superset unnecessary to list. - -##### 3.3.2. Example - -Let's apply this algorithm to a `User` type that is defined across an `accounts` service and a `billing` service. - -`accounts` subgraph - -```graphql -@requiresScopes(scopes: - [ - ["user:read", "user:email:read"], - ["admin"] - ] -) - -``` - -Logical meaning - -```graphql -("user:read" AND "user:email:read") - OR -("admin") -``` - -`billing` subgraph - -```graphql -@requiresScopes(scopes: - [ - ["user:read", "billing:read"], - ["admin", "billing:invoice:read"], - ["support:user:read"] - ] -) -``` - -Logical meaning - -```graphql -("user:read" AND "billing:read") - OR -("admin" AND "billing:invoice:read") - OR -("support:user:read") -``` - -Applying the Algorithm: - -**1. Create the Cross-Product:** - -We will pair each of the 2 groups from the `accounts` policy with each of the 3 groups from the `billing` policy, creating 2 * 3 = 6 new combined groups. - -```graphql -["user:read", "user:email:read"] ∪ ["user:read", "billing:read"] - -> ["user:read", "user:email:read", "billing:read"] - -["user:read", "user:email:read"] ∪ ["admin", "billing:invoice:read"] - -> ["user:read", "user:email:read", "admin", "billing:invoice:read"] - -["user:read", "user:email:read"] ∪ ["support:user:read"] - -> ["user:read", "user:email:read", "support:user:read"]` - -["admin"] ∪ ["user:read", "billing:read"] - -> ["admin", "user:read", "billing:read"] - -["admin"] ∪ ["admin", "billing:invoice:read"] - -> ["admin", "billing:invoice:read"] - -["admin"] ∪ ["support:user:read"] - -> ["admin", "support:user:read"] -``` - -**2. Prune Redundant Groups:** - -Now we examine the raw list for groups that are supersets of others. - -The group `["user:read", "user:email:read", "admin", "billing:invoice:read"]` contains all the scopes from `["admin", "billing:invoice:read"]`. Therefore, the first group is a redundant superset and is **removed**. - -The final, merged directive on the global `User` type is: - -```graphql -@requiresScopes(scopes: [ - ["user:read", "user:email:read", "billing:read"], - ["user:read", "user:email:read", "support:user:read"], - ["admin", "user:read", "billing:read"], - ["admin", "billing:invoice:read"], - ["admin", "support:user:read"] -]) -``` - -This deterministic process ensures that the combined policy is both logically correct and expressed in its simplest form. - -### 4. Rules by GraphQL Type - -#### 4.1. Object Types - -The use of auth directives on object types and their fields is **allowed**. - -- **Type-Level Application**: When an auth directive is applied to an object type, it establishes a baseline authorization requirement for accessing any field on that type. -- **Field-Level Application**: Directives on a specific field add to any requirements inherited from the object type. -- **Composition**: The effective authorization policy for a field is the logical `AND` of its own directives and any directives applied to its parent object type. - -##### 4.1.1. Federated Object Type Scenarios - -When an object type is extended across multiple subgraphs, the type-level auth directives from all definitions are combined using `AND` logic to form a global baseline requirement for that type. - -**Scenario: Merging Type-Level Directives** - -Consider a `Product` type defined in an `inventory` subgraph and extended in a `reviews` subgraph. - -`inventory` subgraph - -```graphql -type Product - @key(fields: "upc") - @authenticated -{ - upc: ID! +```graphql filename="Supergraph" +type Product @authenticated @requiresScopes(scopes: [["pricing:read"]]) { + id: ID! + price: Float inStock: Int } ``` -`reviews` subgraph +### Field-Level Composition -```graphql -type Product - @key(fields: "upc") - @requiresScopes(scopes: [["product:read"]]) -{ - upc: ID! - reviews: [Review!] +When the same field exists in multiple subgraphs with different authorization directives, the requirements are merged. Consider a `username` field that appears in both the accounts and profile services with different access controls: + +```graphql filename="Accounts subgraph" +type User @key(fields: "id") { + id: ID! + username: String @shareable @requiresScopes(scopes: [["user:read"]]) } ``` -**Resulting Policy:** - -The global `Product` type effectively has `@authenticated AND @requiresScopes(scopes: [["product:read"]])` applied. - -- To query `Product.inStock`, the client must be authenticated **AND** have the `product:read` scope. -- To query `Product.reviews`, the client must also be authenticated **AND** have the `product:read` scope. - -**Scenario: Field-Level Directives on Federated Types** - -Field-level directives are combined with the *globally merged* type-level directives. - -`accounts` subgraph - -```graphql -type User @key(fields: "id") - @authenticated -{ +```graphql filename="Profile subgraph" +type User @key(fields: "id") { id: ID! - email: String - @requiresScopes(scopes: [["email:read"]]) + username: String @shareable @requiresScopes(scopes: [["profile:read"]]) } ``` -`profiles` subgraph +In the supergraph, both requirements are combined using `AND`: -```graphql -type User @key(fields: "id") - @policy(policies: [["PublicProfile"]]) -{ +```graphql filename="Supergraph" +type User { id: ID! - profile: Profile + username: String @requiresScopes(scopes: [["user:read", "profile:read"]]) } ``` -**Resulting Policy:** - -The global `User` type has a baseline policy of `@authenticated AND @policy(policies: [["PublicProfile"]])`. - -- To query `User.profile`, the client must be authenticated **AND** satisfy the `PublicProfile` policy. -- To query `User.email`, the client must be authenticated, satisfy the `PublicProfile` policy, **AND** have the `email:read` scope. - -#### 4.2. Enums and Scalars +To access `User.username`, a client must have **both** `user:read` AND `profile:read` scopes, ensuring they meet the access requirements from all subgraphs where the field is defined. -The use of auth directives on enum and scalar types is **allowed**. +### Merging `@requiresScopes` -##### 4.2.1. Federated Enum and Scalar Scenarios +When `@requiresScopes` appears on the same entity across multiple subgraphs, the policies are merged by combining scope groups with logical AND: -If multiple subgraphs define the same custom scalar or enum with different auth directives, the requirements are combined globally using `AND` logic. -Any field in the supergraph that returns that type will be protected by the combined policy. +1. **Combine groups**: For each pair of scope groups (one from each policy), create a new group containing the union of both. +2. **Remove redundant groups**: Drop any group that is a superset of another - this eliminates overly permissive conditions that would make stricter requirements redundant. -**Scenario: Merging Scalar Directives** +Let's illustrate this with an example, where two subgraphs define different scope requirements for the same entity. -Consider a `SensitiveString` scalar defined with different protections in two subgraphs. - -`pii` subgraph +```graphql filename="Subgraph A" +@requiresScopes(scopes:[["user:read", "user:email:read"], ["admin"]]) +``` -```graphql -scalar SensitiveString - @requiresScopes(scopes: [["pii:read"]]) +```graphql filename="Subgraph B" +@requiresScopes(scopes:[["user:read", "billing:read"], ["admin", "billing:invoice:read"]]) ``` -`compliance` subgraph +The merged state of intermediate groups before simplification: ```graphql -scalar SensitiveString - @policy(policies: [["GDPR_Compliant"]]) +@requiresScopes(scopes: [ + ["user:read", "user:email:read", "billing:read"], + ["user:read", "user:email:read", "admin", "billing:invoice:read"], + ["admin", "user:read", "billing:read"], + ["admin", "billing:invoice:read"] +]) ``` -**Resulting Policy:** - -Any field across the entire federated graph that returns a `SensitiveString` will require the client to have the `pii:read` scope **AND** satisfy the `GDPR_Compliant` policy. +As you can see, the second group is a superset of the first and third groups, so they can be removed. ```graphql -# In a third subgraph (e.g., users) -type User { - nationalId: SensitiveString # Accessing this field requires both protections -} - +@requiresScopes(scopes: [ + ["user:read", "user:email:read", "billing:read"], + ["admin", "billing:invoice:read"] +]) ``` -#### 4.3. Interface - -The use of auth directives on interface types and its fields is **disallowed**. - -Instead, they are applied to the concrete `type` definitions that implement the interface. -The composition is responsible for computing the effective policy for the interface based on its implementing types. - -**Interface Type Policy** -The effective authorization policy for an interface type is the logical `AND` of the policies of all its implementing object types across the entire federation. +The composition process automatically simplified the merged policy by removing redundant scope groups. -**Interface Field Policy** -The effective authorization policy for a field on an interface is the logical `AND` of the policies on that same field across all corresponding implementing object types. +## Authorization Rules by GraphQL Type -**Rationale** -This aligns with limitations in the `@apollo/subgraph` library and avoids ambiguity in policy enforcement across implementing types. Authorization should be defined on the concrete object types that implement the interface. +### Object Types +Auth directives can be applied at type and field levels. Requirements are combined using AND. -##### 4.3.1. Example Schema +### Scalars and Enums +Can have auth directives applied. When returned in a field, combined with field-level requirements. -Consider an `Item` interface implemented by `Book` and `Video` across two subgraphs. We will add a field unique to each implementing type (`author` and `director`) to better illustrate query behavior. - -`books` subgraph +### Interface Types +Auth directives on interfaces are **disallowed**. Instead, apply directives to concrete implementing types. The effective policy for an interface is the logical `AND` of all implementing types' policies. ```graphql -interface Item { +# Disallowed +interface Item @authenticated { ... } + +# Allowed - apply to concrete types +type Book implements Item @authenticated { id: ID! - title: String + title: String @requiresScopes(scopes: [["book:read"]]) } -type Book implements Item - @authenticated -{ +type Video implements Item @requiresScopes(scopes: [["video:read"]]) { id: ID! title: String - @requiresScopes(scopes: [["book:read"]]) - author: String - # Inherits @authenticated from the Book type } ``` -`videos` subgraph +When querying the interface directly, the combined policy from all implementing types is applied. -```graphql -type Video implements Item - @policy(policies: [["VideoAccess"]]) -{ - id: ID! - title: String - @requiresScopes(scopes: [["video:read"]]) - director: String - @requiresScopes(scopes: [["video:metadata"]]) -} +### Union Types +Auth directives on unions are **prohibited**. Apply directives to concrete member types instead. This aligns with federation rules and avoids ambiguity. -``` - -**Resulting Effective Policies on the `Item` Interface:** +## Field-Level Dependencies (`@requires`) -- **`Item` Interface Type**: The effective policy is `@authenticated AND @policy(policies: [["VideoAccess"]])`. -- **`Item.title` Field**: The effective policy is `@requiresScopes(scopes: [["book:read"]]) AND @requiresScopes(scopes: [["video:read"]])`. +A field using `@requires` to access fields from another entity must define an authorization policy that is a **superset** of the policies on all required fields. This prevents bypassing security policies through federated queries. -##### 4.3.2. Query Scenarios with Interfaces +## Handling Authorization Errors -Here is how these policies are applied to different queries. +The router supports two modes for handling authorization violations: -**Scenario 1: Querying a Common Interface Field** +### Filter Mode (Default) -When you query a field directly on the interface, the most restrictive, combined policy is applied. +Unauthorized fields are removed from the response, but the query continues processing. An error is added for each removed field. ```graphql -query GetItemTitle { - item(id: "123") { - title +query { + dashboard { + publicMetrics # Authorized ✓ + adminPanel # Unauthorized - filtered out } } ``` -**Authorization Analysis:** - -1. The `item` field returns the `Item` interface, so its type policy (`@authenticated AND @policy(policies: [["VideoAccess"]])`) is checked first. -2. The `title` field is being accessed on the `Item` interface, so its combined field policy (`@requiresScopes(scopes: [["book:read"]]) AND @requiresScopes(scopes: [["video:read"]])`) is checked. -3. **Total Requirement**: The client must be authenticated, satisfy the `VideoAccess` policy, and possess **both** the `book:read` and `video:read` scopes to execute this query successfully, regardless of whether the returned item is a `Book` or a `Video`. - -**Scenario 2: Querying with Inline Fragments** - -When you use inline fragments, authorization is applied based on the concrete type within the fragment. This allows for more granular access. - -```graphql -query GetSpecificItem { - item(id: "123") { - ... on Book { - author +Response: +```json +{ + "data": { + "dashboard": { + "publicMetrics": { ... }, + "adminPanel": null } - ... on Video { - director + }, + "errors": [ + { + "message": "Unauthorized field or type", + "extensions": { "code": "UNAUTHORIZED_FIELD_OR_TYPE" } } - } + ] } ``` -**Authorization Analysis:** - -1. The `item` field's type policy (`@authenticated AND @policy(policies: [["VideoAccess"]])`) is always checked first. -2. The gateway resolves `item(id: "123")` and determines its concrete type. -3. **If the item is a `Book`**: - - The `... on Book` fragment is entered. - - The policy for `Book.author` is checked, which is `@authenticated` (inherited from the `Book` type). - - **Total Requirement**: `@authenticated AND @policy(policies: [["VideoAccess"]])`. -4. **If the item is a `Video`**: - - The `... on Video` fragment is entered. - - The policy for `Video.director` is checked, which is `@requiresScopes(scopes: [["video:metadata"]])`. - - **Total Requirement**: `@authenticated AND @policy(policies: [["VideoAccess"]]) AND @requiresScopes(scopes: [["video:metadata"]])`. - -This demonstrates how inline fragments allow clients to access data for which they are specifically authorized, even if they don't have the superset of permissions required to query all fields on the interface directly. - -**Scenario 3: Querying Both Common and Specific Fields** +### Reject Mode -When a query asks for a common field and also uses an inline fragment, all applicable policies are checked. +If a user tries to access any unauthorized field, the entire request is rejected and no data is returned. ```graphql -query GetItemDetails { - item(id: "456") { - title - ... on Book { - author - } +query { + dashboard { + publicMetrics # Authorized + adminPanel # Unauthorized - entire request rejected } } ``` -**Authorization Analysis:** - -1. The `item` field's type policy is checked first: `@authenticated AND @policy(policies: [][["VideoAccess"]])`. -2. The `title` field's interface-level policy is checked: `@requiresScopes(scopes: [["book:read"]]) AND @requiresScopes(scopes: [["video:read"]])`. -3. The gateway resolves the item's type. If it's a `Book`, the `... on Book` fragment is entered, and the policy for `Book.author` (`@authenticated`) is also confirmed. -4. **Total Requirement**: A client must satisfy the policies from steps 1 and 2 to even attempt this query. If the resolved item is a `Book`, the policies from step 3 are also relevant, but they are already covered by the more restrictive policies from the preceding steps. - -#### 4.4. Unions - -The use of auth directives on union types is **prohibited**. - -**Rationale**: Union types do not have fields and cannot be queried directly. Authorization policies should be placed on the concrete object types that constitute the union's possible members. - -### 5. Field-level Dependencies (`@requires`) - -A field utilizing the `@requires` directive to access fields from another entity must define an authorization policy that is a superset of the policies on all the required fields. -This ensures that a federated query does not create a loophole to bypass the security policies of the underlying fields. - -Example - -Assume the `products` subgraph defines `Product.price`. - -```graphql -type Product @key(fields: "id") { - id: ID! - price: Float @requiresScopes(scopes: [["read:price"]]) +Response: +```json +{ + "data": null, + "errors": [ + { + "message": "Unauthorized field or type", + "extensions": { "code": "UNAUTHORIZED_FIELD_OR_TYPE" } + } + ] } ``` -In the `reviews` subgraph, the `Review` type must ensure its own authorization accommodates the `read:price` requirement if it needs to access `Product.price`. - -```graphql -type Product @key(fields: "id") { - id: ID! - price: Float @external -} - -type Review { - body: String - # This field's policy MUST be a superset of Product.price's policy. - # The following is valid because it requires the necessary scope. - productPrice: Float - @requires(fields: "product { price }") - @requiresScopes(scopes: [["read:price"]]) -} -``` +Enable reject mode by setting `unauthorized.mode: reject` in your router configuration. From d0c9307a113813c9453b525f5a3990f4055b905f Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Mon, 24 Nov 2025 12:31:21 +0100 Subject: [PATCH 4/6] asd --- .../content/router/security/authorization.mdx | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/web/docs/src/content/router/security/authorization.mdx b/packages/web/docs/src/content/router/security/authorization.mdx index c41ba3eb5ff..bf39e425741 100644 --- a/packages/web/docs/src/content/router/security/authorization.mdx +++ b/packages/web/docs/src/content/router/security/authorization.mdx @@ -220,22 +220,22 @@ As you can see, the second group is a superset of the first and third groups, so The composition process automatically simplified the merged policy by removing redundant scope groups. -## Authorization Rules by GraphQL Type +### Usage on Interface Types -### Object Types -Auth directives can be applied at type and field levels. Requirements are combined using AND. +Auth directives **cannot be applied directly to interface definitions**. Instead, authorization rules are **inherited** from the concrete types that implement the interface. When you query an interface or its fields, the authorization check applies the combined policies from all implementing types using logical `AND`. -### Scalars and Enums -Can have auth directives applied. When returned in a field, combined with field-level requirements. + + Interface authorization is computed during composition of the supergraph schema. + -### Interface Types -Auth directives on interfaces are **disallowed**. Instead, apply directives to concrete implementing types. The effective policy for an interface is the logical `AND` of all implementing types' policies. +Look at this example with an `Item` interface implemented by `Book` and `Video` types, each with its own authorization requirements: -```graphql -# Disallowed -interface Item @authenticated { ... } +```graphql filename="Subgraph Schema" +interface Item { + id: ID! + title: String +} -# Allowed - apply to concrete types type Book implements Item @authenticated { id: ID! title: String @requiresScopes(scopes: [["book:read"]]) @@ -247,14 +247,22 @@ type Video implements Item @requiresScopes(scopes: [["video:read"]]) { } ``` -When querying the interface directly, the combined policy from all implementing types is applied. +After the composition phase, the `Item` interface will have the combined authorization requirements from both `Book` and `Video`. + +```graphql filename="Supergraph Schema" +interface Item @authenticated @requiresScopes(scopes: [["video:read"]]) { + id: ID! + title: String @requiresScopes(scopes: [["book:read"]]) +} +``` + +In this example, when querying items through the `Item` interface, both the `Book` and `Video` type requirements must be satisfied. + +### Fields with `@requires` -### Union Types -Auth directives on unions are **prohibited**. Apply directives to concrete member types instead. This aligns with federation rules and avoids ambiguity. +A field using `@requires` to access fields from another entity must define an authorization policy that is a **superset** of the policies on all required fields. This prevents bypassing security policies by accessing protected fields through other fields. -## Field-Level Dependencies (`@requires`) -A field using `@requires` to access fields from another entity must define an authorization policy that is a **superset** of the policies on all required fields. This prevents bypassing security policies through federated queries. ## Handling Authorization Errors From 4952391007007eebaf190c5bf2d52e72ac66599d Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Mon, 24 Nov 2025 13:16:04 +0100 Subject: [PATCH 5/6] asd --- .../router/configuration/authorization.mdx | 10 +- .../content/router/configuration/index.mdx | 3 +- .../content/router/security/authorization.mdx | 220 +++++++++++++----- 3 files changed, 169 insertions(+), 64 deletions(-) diff --git a/packages/web/docs/src/content/router/configuration/authorization.mdx b/packages/web/docs/src/content/router/configuration/authorization.mdx index a0b546b95bd..8b4b67cd261 100644 --- a/packages/web/docs/src/content/router/configuration/authorization.mdx +++ b/packages/web/docs/src/content/router/configuration/authorization.mdx @@ -17,8 +17,8 @@ The router supports two modes for handling unauthorized field access: - **`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 - authorization error is added to the `errors` list in the GraphQL response, while the rest of - the accessible data is returned as requested. + authorization error is added to the `errors` list 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. @@ -62,7 +62,8 @@ type Query { admin: AdminPanel @requiresScopes(scopes: [["admin:users"], ["admin:system"]]) # User must have read:users AND (admin:users OR admin:system) - reports: [Report] @requiresScopes(scopes: [["read:users", "admin:users"], ["read:users", "admin:system"]]) + reports: [Report] + @requiresScopes(scopes: [["read:users", "admin:users"], ["read:users", "admin:system"]]) } ``` @@ -73,7 +74,8 @@ type Query { - **Type:** `boolean` - **Default:** `true` -Whether to enable authorization directives processing. Set to `false` to disable authorization checks entirely. +Whether to enable authorization directives processing. Set to `false` to disable authorization +checks entirely. ### `unauthorized.mode` diff --git a/packages/web/docs/src/content/router/configuration/index.mdx b/packages/web/docs/src/content/router/configuration/index.mdx index fec98ef48f9..dbbb625d434 100644 --- a/packages/web/docs/src/content/router/configuration/index.mdx +++ b/packages/web/docs/src/content/router/configuration/index.mdx @@ -13,7 +13,8 @@ Some configuration variables can be overridden at runtime using This page covers all the main configuration options available. Each option links to a detailed page that explains how to use that feature. -- [`authorization`](./configuration/authorization): Define field-level access control using directives. +- [`authorization`](./configuration/authorization): Define field-level access control using + directives. - [`cors`](./configuration/cors): Control cross-origin requests to your API. - [`csrf`](./configuration/csrf): Protect against cross-site request forgery attacks. - [`headers`](./configuration/headers): Modify HTTP headers between clients and subgraphs. diff --git a/packages/web/docs/src/content/router/security/authorization.mdx b/packages/web/docs/src/content/router/security/authorization.mdx index bf39e425741..30e6da6366d 100644 --- a/packages/web/docs/src/content/router/security/authorization.mdx +++ b/packages/web/docs/src/content/router/security/authorization.mdx @@ -2,13 +2,18 @@ title: 'Authorization' --- -import { Callout } from '#components/callout'; +import { Callout } from '#components/callout' +import { Tabs } from '@theguild/components' # Authorization -Authorization directives allow you to define fine-grained access control directly in your GraphQL schema. Instead of handling authorization logic in resolvers, you declare which fields and types require authentication or specific scopes using directives. The router enforces these rules before calling your subgraphs, ensuring consistent protection across your federated graph. +Authorization directives allow you to define fine-grained access control directly in your GraphQL +schema. Instead of handling authorization logic in resolvers, you declare which fields and types +require authentication or specific scopes using directives. The router enforces these rules before +calling your subgraphs, ensuring consistent protection across your federated graph. -For the complete configuration reference, see [`authorization` configuration](../configuration/authorization). +For the complete configuration reference, see +[`authorization` configuration](../configuration/authorization). ## How Authorization Directives Work @@ -17,17 +22,21 @@ When a GraphQL request arrives at the router, it goes through these steps: 1. **Request arrives** with user credentials (typically a JWT token) 2. **Router extracts user information** from the token (authentication status and scopes) 3. **Router checks each field** in the requested query against authorization directives -4. **Access is allowed or denied** based on the field's directive requirements and the user's credentials +4. **Access is allowed or denied** based on the field's directive requirements and the user's + credentials 5. **Response is returned** with either the requested data, errors, or a full rejection - - Authorization happens **before** your subgraphs are called, protecting sensitive fields at the router level. + Authorization happens **before** your subgraphs are called, protecting sensitive fields at the + router level. ## Integration with JWT Authentication -Authorization directives work alongside your [JWT authentication setup](./jwt-authentication.mdx). When a request arrives, the router validates the JWT token and extracts scopes from the JWT claims (in the `scope` field). It then checks the authorization directives in your schema against these extracted scopes to determine if the query should proceed or fail. +Authorization directives work alongside your [JWT authentication setup](./jwt-authentication.mdx). +When a request arrives, the router validates the JWT token and extracts scopes from the JWT claims +(in the `scope` field). It then checks the authorization directives in your schema against these +extracted scopes to determine if the query should proceed or fail. ```yaml filename="router.config.yaml" jwt: @@ -40,9 +49,15 @@ authorization: directives: enabled: true unauthorized: - mode: filter # Or 'reject' + mode: filter # Or 'reject' ``` + + Ensure your JWT tokens include the necessary scopes in the `scope` claim as a space-separated + string (e.g., `"read:users write:posts"`) or an array of scopes (e.g., `["read:users", + "write:posts"]`). + + ## Authorization Directives ### `@authenticated` @@ -50,24 +65,29 @@ authorization: Marks a field or type as requiring authentication. Anonymous requests cannot access these fields. ```graphql -extend schema @link( - url: "https://specs.apollo.dev/federation/v2.9" - import: ["@authenticated"] -) +extend schema @link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@authenticated"]) type Query { - publicPosts: [Post] # Anyone - myDrafts: [Post] @authenticated # Authenticated users only + publicPosts: [Post] # Anyone + myDrafts: [Post] @authenticated # Authenticated users only +} + +type Post { + title: String! + content: String! + author: User! } type User { - id: ID! - username: String! + name: String! bio: String - email: String @authenticated # Private field + email: String @authenticated # Private field } ``` +In this example, `myDrafts` and `email` will only be accessible to authenticated users. +Unauthenticated requests will receive an error and have these fields filtered out. + ### `@requiresScopes` Provides granular control by requiring specific scopes (permissions stored in JWT token). @@ -76,10 +96,7 @@ Provides granular control by requiring specific scopes (permissions stored in JW - **Multiple lists** (OR logic): User must satisfy **any** complete list ```graphql -extend schema @link( - url: "https://specs.apollo.dev/federation/v2.9" - import: ["@requiresScopes"] -) +extend schema @link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@requiresScopes"]) type Query { users: [User] @@ -96,9 +113,13 @@ type User { } ``` +In this example, `billingReport` requires a user to have both `admin` and `billing:read` scopes. The +`allUsers` field requires either the `read:admin` or the `manage:users` scope, but `email` only the +`email:read` scope. + ## Directives on Types and Fields -**Type-level directives** protect all fields of that type: +**Type-level directives** protect all fields of that type. ```graphql type AdminPanel @authenticated { @@ -107,26 +128,32 @@ type AdminPanel @authenticated { } ``` -**Field-level directives** add additional restrictions beyond type-level protection: +When querying `AdminPanel`, the user must be authenticated to access any of its fields. + +**Field-level directives** add additional restrictions beyond type-level protection. ```graphql type User @authenticated { id: ID! username: String! - email: String @requiresScopes(scopes: [["email:read"]]) # Requires auth + scope - name: String # Requires auth only + email: String @requiresScopes(scopes: [["email:read"]]) # Requires auth + scope + name: String # Requires auth only } ``` -Field requirements are combined with type requirements using `AND` logic. +Field requirements are combined with type requirements using `AND` logic, so accessing `email` +requires both authentication (to access the `User` type) and the `email:read` scope. ## Combining Directives Across Federated Types -When a type is defined across multiple subgraphs, all authorization requirements are combined using `AND`. A user must satisfy requirements from all subgraphs. +When a type is defined across multiple subgraphs, all authorization requirements are combined using +`AND`. A user must satisfy requirements from all subgraphs. ### Type-Level Composition -Consider a `Product` type split across inventory and pricing services. Each subgraph applies its own authorization directive, and when the type is composed in the supergraph, both requirements must be met. +Consider a `Product` type split across inventory and pricing services. Each subgraph applies its own +authorization directive, and when the type is composed in the supergraph, both requirements must be +met. ```graphql filename="Inventory subgraph" type Product @key(fields: "id") @authenticated { @@ -154,7 +181,9 @@ type Product @authenticated @requiresScopes(scopes: [["pricing:read"]]) { ### Field-Level Composition -When the same field exists in multiple subgraphs with different authorization directives, the requirements are merged. Consider a `username` field that appears in both the accounts and profile services with different access controls: +When the same field exists in multiple subgraphs with different authorization directives, the +requirements are merged. Consider a `username` field that appears in both the accounts and profile +services with different access controls: ```graphql filename="Accounts subgraph" type User @key(fields: "id") { @@ -167,6 +196,7 @@ type User @key(fields: "id") { type User @key(fields: "id") { id: ID! username: String @shareable @requiresScopes(scopes: [["profile:read"]]) + age: Int } ``` @@ -176,19 +206,25 @@ In the supergraph, both requirements are combined using `AND`: type User { id: ID! username: String @requiresScopes(scopes: [["user:read", "profile:read"]]) + age: Int } ``` -To access `User.username`, a client must have **both** `user:read` AND `profile:read` scopes, ensuring they meet the access requirements from all subgraphs where the field is defined. +To access `User.username`, a client must have **both** `user:read` and `profile:read` scopes, +ensuring they meet the access requirements from all subgraphs where the field is defined. ### Merging `@requiresScopes` -When `@requiresScopes` appears on the same entity across multiple subgraphs, the policies are merged by combining scope groups with logical AND: +When `@requiresScopes` appears on the same entity across multiple subgraphs, the policies are merged +by combining scope groups with logical AND. The simplified composition process is as follows: -1. **Combine groups**: For each pair of scope groups (one from each policy), create a new group containing the union of both. -2. **Remove redundant groups**: Drop any group that is a superset of another - this eliminates overly permissive conditions that would make stricter requirements redundant. +1. **Combine groups**: For each pair of scope groups (one from each policy), create a new group + containing the union of both. +2. **Remove redundant groups**: Drop any group that is a superset of another - this eliminates + overly permissive conditions that would make stricter requirements redundant. -Let's illustrate this with an example, where two subgraphs define different scope requirements for the same entity. +Let's illustrate this with an example, where two subgraphs define different scope requirements for +the same entity. ```graphql filename="Subgraph A" @requiresScopes(scopes:[["user:read", "user:email:read"], ["admin"]]) @@ -209,7 +245,8 @@ The merged state of intermediate groups before simplification: ]) ``` -As you can see, the second group is a superset of the first and third groups, so they can be removed. +As you can see, the second group is a superset of the first and third groups, so they can be +removed. ```graphql @requiresScopes(scopes: [ @@ -218,17 +255,23 @@ As you can see, the second group is a superset of the first and third groups, so ]) ``` -The composition process automatically simplified the merged policy by removing redundant scope groups. +The composition process automatically simplified the merged policy by removing redundant scope +groups. ### Usage on Interface Types -Auth directives **cannot be applied directly to interface definitions**. Instead, authorization rules are **inherited** from the concrete types that implement the interface. When you query an interface or its fields, the authorization check applies the combined policies from all implementing types using logical `AND`. +Auth directives **cannot be applied directly to interface definitions**. Instead, authorization +rules are **inherited** from the concrete types that implement the interface. When you query an +interface or its fields, the authorization check applies the combined policies from all implementing +types using logical `AND`. - - Interface authorization is computed during composition of the supergraph schema. + + Interface authorization is computed during composition of the supergraph schema, based on the + implementing types' directives. -Look at this example with an `Item` interface implemented by `Book` and `Video` types, each with its own authorization requirements: +Look at this example with an `Item` interface implemented by `Book` and `Video` types, each with its +own authorization requirements: ```graphql filename="Subgraph Schema" interface Item { @@ -247,7 +290,8 @@ type Video implements Item @requiresScopes(scopes: [["video:read"]]) { } ``` -After the composition phase, the `Item` interface will have the combined authorization requirements from both `Book` and `Video`. +After the composition phase, the `Item` interface will have the combined authorization requirements +from both `Book` and `Video`. ```graphql filename="Supergraph Schema" interface Item @authenticated @requiresScopes(scopes: [["video:read"]]) { @@ -256,33 +300,78 @@ interface Item @authenticated @requiresScopes(scopes: [["video:read"]]) { } ``` -In this example, when querying items through the `Item` interface, both the `Book` and `Video` type requirements must be satisfied. +In this example, when querying items through the `Item` interface, both the `Book` and `Video` type +requirements must be satisfied. ### Fields with `@requires` -A field using `@requires` to access fields from another entity must define an authorization policy that is a **superset** of the policies on all required fields. This prevents bypassing security policies by accessing protected fields through other fields. +A field using `@requires` to access fields from another subgraph must define an authorization policy +that is a **superset** of the policies on all required fields. This prevents bypassing security +policies by accessing protected fields through other fields. +```graphql filename="Users subgraph" +type User @key(fields: "id") @authenticated { + id: ID! + email: String @requiresScopes(scopes: [["email:read"]]) +} +``` +```graphql filename="Orders subgraph" +type Order { + id: ID! + cost: Float +} + +type User @key(fields: "id") { + id: ID! + email: String @external + orders: [Order!]! + @requires(fields: "email") + @authenticated + @requiresScopes(scopes: [["email:read"]]) +} +``` + +In this example, the `User.orders` field uses `@requires` to fetch the user's email from another +subgraph. + +Since `User.email` requires authentication, the `User.orders` field must also require authentication +(or have stricter requirements) to prevent clients from bypassing the email field's authorization by +accessing it through the `orders`. + +Without this rule, a client could potentially access the `orders` field without being authenticated, +thus indirectly accessing the protected `email` field. + + + Console will raise a `MISSING_TRANSITIVE_AUTH_REQUIREMENTS` error during [Schema + Check](../../schema-registry#check-a-schema) or [Schema + Publish](../../schema-registry#publish-a-schema) if a field with `@requires` lacks sufficient + authorization requirements, protecting you from security gaps. + ## Handling Authorization Errors -The router supports two modes for handling authorization violations: +The router supports two modes for handling authorization violations. Both serve to protect sensitive +data, but they differ in how they communicate issues back to the client and how much data is +returned. -### Filter Mode (Default) + -Unauthorized fields are removed from the response, but the query continues processing. An error is added for each removed field. + -```graphql +Unauthorized fields are removed from the response, but the query continues processing. An error is +added for each removed field. + +```graphql filename="GraphQL query" query { dashboard { - publicMetrics # Authorized ✓ - adminPanel # Unauthorized - filtered out + publicMetrics { ... } # Authorized + adminPanel # Unauthorized - filtered out } } ``` -Response: -```json +```json filename="GraphQL response" { "data": { "dashboard": { @@ -299,21 +388,23 @@ Response: } ``` -### Reject Mode + -If a user tries to access any unauthorized field, the entire request is rejected and no data is returned. + -```graphql +If a user tries to access any unauthorized field, the entire request is rejected and no data is +returned. + +```graphql filename="GraphQL query" query { dashboard { - publicMetrics # Authorized - adminPanel # Unauthorized - entire request rejected + publicMetrics { ... } # Authorized + adminPanel # Unauthorized - entire request rejected } } ``` -Response: -```json +```json filename="GraphQL response" { "data": null, "errors": [ @@ -325,4 +416,15 @@ Response: } ``` -Enable reject mode by setting `unauthorized.mode: reject` in your router configuration. +Enable reject mode by setting `unauthorized.mode: reject` in your router configuration: + +```yaml filename="router.config.yaml" +authorization: + directives: + unauthorized: + mode: reject +``` + + + + From 5c34e961a70147b83210030edbcfd63a64459e8a Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Wed, 26 Nov 2025 09:48:50 +0100 Subject: [PATCH 6/6] Simplify authorization docs --- .../router/configuration/authorization.mdx | 57 ++----------------- 1 file changed, 4 insertions(+), 53 deletions(-) diff --git a/packages/web/docs/src/content/router/configuration/authorization.mdx b/packages/web/docs/src/content/router/configuration/authorization.mdx index 8b4b67cd261..718695fe8b6 100644 --- a/packages/web/docs/src/content/router/configuration/authorization.mdx +++ b/packages/web/docs/src/content/router/configuration/authorization.mdx @@ -5,67 +5,18 @@ title: 'authorization' # authorization The `authorization` configuration lets you control fine-grained access to your GraphQL schema using -directives. This allows you to restrict which fields authenticated users can access based on their -authentication status or specific scopes. +directives like `@authenticated` and `@requiresScopes`. This allows you to restrict which fields +authenticated users can access based on their authentication status or specific scopes. For practical examples and a guide to authorization concepts, see **["Authorization"](../security/authorization)** in the documentation. -## How Authorization Works - -The router supports two modes for handling unauthorized field access: - -- **`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 - authorization error is added to the `errors` list 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. - ## Directives Access control is defined within the supergraph schema using the following directives: -### `@authenticated` - -Restricts access to a field to only authenticated users. Any request without valid authentication -(such as requests without a JWT token) will be prevented from accessing the field. - -**Usage:** - -```graphql -type Query { - me: User @authenticated - publicData: String -} -``` - -### `@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"]]`) - the - user must have all scopes in the list -- **`OR` logic** for scopes across nested lists (e.g., `[["read:users"], ["admin:users"]]`) - the - user must have scopes from at least one of the nested lists - -**Usage:** - -```graphql -type Query { - # User must have both scopes - users: [User] @requiresScopes(scopes: [["read:users", "write:users"]]) - - # User must have either scope - admin: AdminPanel @requiresScopes(scopes: [["admin:users"], ["admin:system"]]) - - # User must have read:users AND (admin:users OR admin:system) - reports: [Report] - @requiresScopes(scopes: [["read:users", "admin:users"], ["read:users", "admin:system"]]) -} -``` +- `@authenticated` - restricts access to authenticated users only +- `@requiresScopes(scopes: [[String]])` - restricts access based on specific scopes ## Configuration Options