GraphOS 路由中的授权
使用集中治理层增强子图安全性
此功能仅在 GraphOS 专用或企业计划中可用。
要比较所有计划类型对 GraphOS 功能的支持,请参阅 定价页面.
API 提供对业务关键数据的访问。未受限制的访问可能导致数据泄露、经济损失或潜在的拒绝服务。即使是内部服务,检查也是必需的,以限制数据仅对有权访问的各方可用。
服务可能有它们自己的访问控制,但在 Apollo 路由器中强制执行授权有以下几个原因::
最佳查询执行:在处理请求之前验证授权允许提前终止未授权请求。在图边缘停止未授权请求可以减少对服务的负载并提高性能。
- 如果特定子查询中的每个字段都需要授权,则路由器的查询规划器可以消除整个子图请求,对于未授权请求。例如,一个请求可能有权查看特定用户在社交媒体平台上的帖子,但没有权限查看该用户的任何个人信息(PII)。了解更多信息,请参阅它是如何工作的。
- 此外,查询去重根据所需的授权将请求组字段进行分组。如果没有正确的授权,可以将整个组从查询计划中删除。
声明性访问规则:您在字段级别定义访问控制,GraphOS 组合跨您的服务来实现这些规则。这些规则创建出图原生的治理,而不需要在额外的编排层中。
原则性架构:通过组合,路由器集中授权逻辑,同时允许在服务级别进行审计。这种集中的授权是其他服务层可以加强的初始检查点。
访问控制是如何工作的
GraphOS路由器提供访问控制通过授权指令,该指令定义了访问您的supergraph中特定字段和类型的权限:
- 指令
@requiresScopes
允许您通过定义的标记来访问细粒度的访问控制。 - 指令
@authenticated
允许仅针对已验证的请求访问注释的字段或类型。 - 指令
@policy
将授权验证卸载到Rhai脚本或协处理器,并将其结果集成到路由器中。当您的授权策略超出了简单的认证和范围之外时,它非常有用。
例如,想象你正在构建一个包含用户
子图的社交媒体平台。你可以使用@requiresScopes
指令来声明查看其他用户的信息需要read:user
作用域:
type Query {users: [User!]! @requiresScopes(scopes: [["read:users"]])}
你可以使用@authenticated
指令来声明用户必须登录才能更新自己的信息:
type Mutation {updateUser(input: UpdateUserInput!): User! @authenticated}
你可以定义指令——一起或分别——在字段级别上微调你的访问控制。当一个字段及其字段类型都声明了指令时,将尝试所有这些指令,如果其中任何一个未授权,则将移除该字段。GraphOS 组合将这些限制集成到全局图架构中,以便每个子图的限制都得到尊重。然后,路由器将在所有传入请求上强制执行这些指令。
先决条件
ⓘ 注意
只有GraphOS Router支持授权指令——@apollo/gateway
不支持。如果你想使用它们,请查看迁移指南。
在使用您的子图架构中的授权指令之前,您必须:
- 验证您的 GraphOS 路由器使用版本
1.29.1
或更高版本,并且 已连接到您的 GraphOS 企业组织 - 在发送到路由器的请求中包含 索赔(对于
@authenticated
和@requiresScopes
)
配置请求索赔
索赔是一个请求的身份验证和范围的详细资料。它们可能包括诸如请求用户的 ID 和分配给该用户的任何授权范围之类的详细信息—例如, read:profiles
—。授权指令使用请求的索赔来评估哪些 字段和类型是受授权的。
为了向路由器提供其所需的索赔,您必须配置 JSON Web Token (JWT) 身份验证或添加一个外部协处理器,该协处理器将索赔添加到请求的上下文中。在某些情况下(如下文所述),您可能需要两者都使用。
- JWT 身份验证配置:如果您配置 JWT 身份验证,GraphOS 路由器 会自动将 JWT 令牌的索赔添加到请求上下文中的
apollo_authentication::JWT::claims
键。 - 通过协处理器添加索赔:如果您无法使用 JWT 身份验证,您可以使用 通过协处理器添加索赔。协处理器允许您使用自定义代码加入到 GraphOS 路由器的请求处理生命周期中。
- 通过协处理器增强 JWT 索赔:您的授权策略可能需要超越您的 JSON Web 令牌提供的信息。例如,令牌的索赔可能包括用户 ID,然后您可以使用该 ID 来检索用户角色。对于这种情况,您可以使用 通过协处理器增强 JSON Web 令牌的索赔。
授权指令
授权 directives默认开启。要禁用它们,请在您的 router's YAML 配置文件 中包含以下内容:
authorization:directives:enabled: false
@requiresScopes
Since1.29.1
The @requiresScopes
directive 标记 fields和 types 为基于所需范围的限制性。指令包含一个带有所需作用域数组 scopes
的参数,以声明所需的作用域:
@requiresScopes(scopes: [["scope1", "scope2", "scope3"]])
💡 提示
使用 @requiresScopes
当访问 field或 type 只取决于与索赔对象或访问令牌关联的索赔时。
如果你的授权验证逻辑或数据更加复杂—例如检查头中的特定值或从数据库等其他来源查找数据—并且不是只基于一个声明对象或访问令牌,请使用 @policy
代替。
根据请求中存在的范围,路由器会筛选掉未授权的 字段 和类型。
您可以使用布尔逻辑来定义所需的范围。有关详细信息,请参阅 组合所需范围。
指令通过在一个请求的上下文中加载 apollo_authentication::JWT::claims
键处的声明对象来验证所需的范围。声明对象的 scope
键的值应该是一个空格分隔的字符串,其格式由 OAuth2 RFC中访问令牌范围的 定义。
claims = context["apollo_authentication::JWT::claims"]claims["scope"] = "scope1 scope2 scope3"
用法
要在子图 中使用 @requiresScopes
指令,您可以像这样从 @link
指令导入它:
extend schema@link(url: "https://specs.apollo.dev/federation/v2.5",import: [..., "@requiresScopes"])
它定义如下
scalar federation__Scopedirective @requiresScopes(scopes: [[federation__Scope!]!]!) on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM
使用 AND
/OR
逻辑组合所需的范围
请求必须包含内层 scopes
数组中的所有元素才能解析相关的 字段 或类型。
@requiresScopes(scopes: [["scope1", "scope2", "scope3"]])
在前面的示例中,请求需要 scope1
AND scope2
AND scope3
以获得授权。
您可以使用嵌套数组引入 OR
@requiresScopes(scopes: [["scope1"], ["scope2"], ["scope3"]])
对于前面的示例,请求需要授权scope1
或 scope2
或 scope3
。
您可以根据需要嵌套数组和元素,以实现所需的逻辑。例如
@requiresScopes(scopes: [["scope1", "scope2"], ["scope3"]])
这种语法要求请求必须包含(scope1
且 并且 scope2
)或 只需 scope3
才能获得授权。
示例 @requiresScopes
应用场景
想象一下,您正在构建一个社交媒体平台,允许用户仅在具备所需的权限的情况下查看其他用户的信息。您的模式可能如下所示
type Query {user(id: ID!): User @requiresScopes(scopes: [["read:others"]])users: [User!]! @requiresScopes(scopes: [["read:others"]])post(id: ID!): Post}type User {id: ID!username: Stringemail: String @requiresScopes(scopes: [["read:email"]])profileImage: Stringposts: [Post!]!}type Post {id: ID!author: User!title: String!content: String!}
根据请求附加的作用域,路由器router会以不同的方式执行以下查询。如果请求仅包含read:others
作用域,那么路由器会执行以下过滤查询:
query {users {usernameprofileImage}}
query {users {usernameprofileImage}}
响应会在 /users/@/email
路径包括一个错误,因为该字段需要 read:emails
作用域。如果请求包含 read:others read:emails
作用域集合,则路由器可以成功执行整个查询。
对于未授权的字段,路由器返回 null
,并应用 标准的 GraphQL null 传播规则。
{"data": {"me": null,"post": {"title": "Securing supergraphs",}},"errors": [{"message": "Unauthorized field or type","path": ["me"],"extensions": {"code": "UNAUTHORIZED_FIELD_OR_TYPE"}},{"message": "Unauthorized field or type","path": ["post","views"],"extensions": {"code": "UNAUTHORIZED_FIELD_OR_TYPE"}}]}
@authenticated
Since1.29.1
@authenticated 指令用于标记特定字段和类型需要身份验证。它通过检查请求上下文中的 apollo_authentication::JWT::claims
键来工作,该键可以是 JWT 身份验证插件在请求包含有效 JWT 时添加,也可以是由身份验证协处理器添加。如果该键存在,则表示请求已验证,路由器会完全执行查询。如果请求没有认证,路由器在规划查询之前会移除 @authenticated
字段,并且仅执行不需要身份验证的查询部分。
用法
要在子图(subgraph)中使用 @authenticated 指令,您可以像这样从 @link
指令导入它:
extend schema@link(url: "https://specs.apollo.dev/federation/v2.5",import: [..., "@authenticated"])
它定义如下
directive @authenticated on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM
示例 @authenticated
用例
深入 社交媒体示例:假设未认证用户可以查看帖子的标题、作者和内容。然而,您只想让认证用户看到帖子收到多少浏览次数。您还需要能够查询认证用户的信息。
您的模式中相关的部分可能如下所示
type Query {me: User @authenticatedpost(id: ID!): Post}type User {id: ID!username: Stringemail: String @requiresScopes(scopes: [["read:email"]])posts: [Post!]!}type Post {id: ID!author: User!title: String!content: String!views: Int @authenticated}
考虑下面的 查询:
query {me {username}post(id: "1234") {titleviews}}
对于认证请求,路由器会执行整个查询。对于未认证请求,路由器会移除 @authenticated
字段并执行筛选后的查询。
query {me {username}post(id: "1234") {titleviews}}
query {post(id: "1234") {title}}
对于未认证请求,路由器不尝试解析顶级 me
查询,也不解析 id: "1234"
的帖子的浏览次数。响应保留了初始请求的形状,但返回 null
以授权字段,并应用 标准的 GraphQL null 传播规则。
{"data": {"me": null,"post": {"title": "Securing supergraphs",}},"errors": [{"message": "Unauthorized field or type","path": ["me"],"extensions": {"code": "UNAUTHORIZED_FIELD_OR_TYPE"}},{"message": "Unauthorized field or type","path": ["post","views"],"extensions": {"code": "UNAUTHORIZED_FIELD_OR_TYPE"}}]}
如果请求的所有字段都需要身份验证且请求未认证,路由器将生成一个错误,指出查询未授权。
@policy
Since1.35.0
@policy 指令标记字段和类型基于在 Rhai 脚本或协处理器中评估的授权策略作为受限。这可以通过自定义授权验证,超出身份验证和作用域的范围。当我们需要比验证列表中声明值存在更复杂的策略评估时很有用(例如:检查头中的特定值)。
💡 提示
如果仅通过声明对象或访问令牌相关的声明访问字段或类型受限,则请考虑使用 @requiresScopes
而不是。
@policy 指令包括一个 policies
参数,它定义了一个字符串数组,表示所需策略,没有任何格式化约束。通常,您可以根据需要将字符串作为任何格式的参数来使用。以下示例展示了可能需要支持角色的策略:
@policy(policies: [["roles:support"]])
使用 @policy 指令需要 Supergraph 插件 来评估授权策略。这在将路由器授权与现有的授权堆栈相结合或与数据库中的查找关联策略执行时很有用。
以下是 @policy 通过路由器请求生命周期处理的概述:
在
RouterService
的级别,GraphOS 路由器从模式中提取与请求相关的策略列表,然后将其存储在请求的上下文中apollo_authorization::policies::required
作为映射策略 -> null|true|false
。在
SupergraphService
的级别,您必须提供 Rhai 脚本或协处理器来评估映射。如果策略得到验证,脚本或协处理器应将其值设置为true
或将其设置为false
。如果值保留为null
,则路由器将 Treat it asfalse
。之后,路由器将过滤请求的类型和字段,只保留那些策略为true
的。如果一个子图查询的任何字段都没有通过其授权策略,则路由器将停止进一步处理该查询并拒绝未经授权的子图请求。这是 @policy 和其他授权指令的关键优势之一。
用法
要在子图中使用 @policy 指令,您可以像这样从 @link
指令 导入它:
extend schema@link(url: "https://specs.apollo.dev/federation/v2.6",import: [..., "@policy"])
@policy 指令定义如下:
scalar federation__Policydirective @policy(policies: [[federation__Policy!]!]!) on OBJECT | FIELD_DEFINITION | INTERFACE | SCALAR | ENUM
使用 @policy
指令需要 Supergraph 插件 来评估授权策略。您可以使用 Rhai 脚本 或 协处理器 来此操作。有关更多信息,请参考以下 示例用例。(尽管原生插件也可以评估授权策略,但我们不建议使用它。)
结合 AND
/OR
逻辑策略
授权验证使用 AND 逻辑在内部级别 policies
数组中的元素之间,其中请求必须包含内部级别 policies
数组中的所有元素以解析相关的字段或类型。以下是示例,请求需要 policy1
AND policy2
AND policy3
才能获得授权:
@policy(policies: [["policy1", "policy2", "policy3"]])
另一方面,要引入 OR 逻辑,可以使用嵌套数组。以下是示例,请求需要 policy1
OR policy2
OR policy3
才能获得授权:
@policy(policies: [["policy1"], ["policy2"], ["policy3"]])
您可以根据需要嵌套数组和元素以实现所需的逻辑。以下是示例,其语法要求请求具有 policy1
AND policy2
) OR 仅 policy3
以获得授权:
@policy(policies: [["policy1", "policy2"], ["policy3"]])
示例 @policy
用法
使用协处理器
更深入地探讨 社交媒体示例:假设您只想让用户访问自己的个人资料和信用卡信息。在可用的授权 指示中,您使用 @policy
而不是 @requiresScopes
,因为在验证逻辑中不仅仅依赖于访问令牌的作用域。
您可以将授权策略添加 read_profile
和 read_credit_card
。您的模式的相关部分可能如下所示:
type Query {me: User @authenticated @policy(policies: [["read_profile"]])post(id: ID!): Post}type User {id: ID!username: Stringemail: String @requiresScopes(scopes: [["read:email"]])posts: [Post!]!credit_card: String @policy(policies: [["read_credit_card"]])}type Post {id: ID!author: User!title: String!content: String!views: Int @authenticated}
您可以使用在 Supergraph 请求阶段调用的称为 协处理器 的东西来接收和执行策略列表。
如果您这样配置您的 router:
coprocessor:url: http://127.0.0.1:8081supergraph:request:context: true
协处理器随后可以接收此格式的请求
{"version": 1,"stage": "SupergraphRequest","control": "continue","id": "d0a8245df0efe8aa38a80dba1147fb2e","context": {"entries": {"apollo_authentication::JWT::claims": {"exp": 10000000000,"sub": "457f6bb6-789c-4e8b-8560-f3943a09e72a"},"apollo_authorization::policies::required": {"read_profile": null,"read_credit_card": null}}},"method": "POST"}
用户可以阅读自己的个人资料,因此 read_profile
将成功。但只有会计系统应该能够查看信用卡,所以 read_credit_card
将失败。协处理器然后将返回:
{"version": 1,"stage": "SupergraphRequest","control": "continue","id": "d0a8245df0efe8aa38a80dba1147fb2e","context": {"entries": {"apollo_authentication::JWT::claims": {"exp": 10000000000,"sub": "457f6bb6-789c-4e8b-8560-f3943a09e72a"},"apollo_authorization::policies::required": {"read_profile": true,"read_credit_card": false}}}}
使用 Rhai 脚本
另一个例子,假设您想限制对支持用户发布的访问。由于 policies
参数是一个字符串,您可以将其设置为 Rhai 脚本可以解析和评估的 "<key>:<value>"
格式。
您的模式中相关的部分可能如下所示
type Query {me: User @policy(policies: [["kind:user"]])}type User {id: ID!username: String @policy(policies: [["roles:support"]])}
然后,您可以使用以下 Rhai 脚本来解析和评估 policies
字符串:
fn supergraph_service(service) {let request_callback = |request| {let claims = request.context["apollo_authentication::JWT::claims"];let policies = request.context["apollo_authorization::policies::required"];if policies != () {for key in policies.keys() {let array = key.split(":");if array.len == 2 {switch array[0] {"kind" => {policies[key] = claims[`kind`] == array[1];}"roles" => {policies[key] = claims[`roles`].contains(array[1]);}_ => {}}}}}request.context["apollo_authorization::policies::required"] = policies;};service.map_request(request_callback);}
订阅的特殊情况
当使用 订阅 和 @policy
授权时,订阅 事件将从执行服务重新启动,这意味着如果订阅会话的授权状态发生变化,则它无法再次通过查询计划,会话应该关闭。为此,应该在执行服务级别再次评估策略,如果它们发生变化,则应该返回错误以停止订阅。
组合和联盟
GraphOS's 对授权指令的组合策略是有意累加的。当您在 子图 中的字段和类型上定义授权指令时,GraphOS 将它们组合到超级图模式中。换句话说,如果子图字段或类型包含 @requiresScopes
、@authenticated
或 @policy
指令,它们也将设置在超级图上。
与 AND
/OR
逻辑的组合
如果共享的 子图 字段 包含多个 指令,组合会合并它们。例如,假设 me
查询在一个子图中需要 @authentication
:
type Query {me: User @authenticated}type User {id: ID!username: Stringemail: String}
并在另一个子图中包含 read:user
权限:
type Query {me: User @requiresScopes(scopes: [["read:user"]])}type User {id: ID!username: Stringemail: String}
请求需要同时被认证 AND 并拥有所需的权限。回想一下,@authenticated
指令只检查请求的上下文中是否存在 apollo_authentication::JWT::claims
键,所以只要请求包含权限,认证就有了保证。
如果多个共享的 子图 字段 包含 @requiresScopes
,超级图模式将以用于将单个 @requiresScopes
用法的 权限组合起来的相同逻辑 合并它们。例如,如果一个子图需要在 users
查询上 read:others
权限:
type Query {users: [User!]! @requiresScopes(scopes: [["read:others"]])}
另一个子图为 users
查询需要 read:profiles
权限:
type Query {users: [User!]! @requiresScopes(scopes: [["read:profiles"]])}
然后,超级图模式需要给它两个作用域。
type Query {users: [User!]! @requiresScopes(scopes: [["read:others", "read:profiles"]])}
与结合单个使用 @requiresScopes 的作用域类似,您可以使用嵌套数组来引入或(OR)逻辑:
type Query {users: [User!]! @requiresScopes(scopes: [["read:others", "read:users"]])}
type Query {users: [User!]! @requiresScopes(scopes: [["read:profiles"]])}
由于两个作用域
数组都是嵌套数组,因此它们将使用或(OR)逻辑组合到超级图模式中:
type Query {users: [User!]! @requiresScopes(scopes: [["read:others", "read:users"], ["read:profiles"]])}
这种语法表示请求需要(读取:其他人
与(AND) 读取:用户
)作用域或仅为读取:轮廓
作用域进行授权。
授权和 @key
字段
与@key
指令类似,它允许您创建一个实体的字段,这些字段可以跨多个子图解析。如果您在@key
指令定义的字段上使用授权指令,Apollo 仍然会使用这些字段在子图之间组合实体,但是客户端无法直接查询它们。
考虑以下示例子图模式:
type Query {product: Product}type Product @key(fields: "id") {id: ID! @authenticatedname: String!price: Int @authenticated}
type Query {product: Product}type Product @key(fields: "id") {id: ID! @authenticatedinStock: Boolean!}
未经身份验证的请求将成功执行此查询:
query {product {nameinStock}}
具体来说,在幕后,路由器将使用id字段来解析Product
实体,但它不会将其返回。
对于以下查询,未经身份验证的请求将为id
解析null
。由于id
是一个非空字段,因此product
将返回null
。
query {product {idusername}}
此行为类似于您可以使用合约和@inaccessible
指令创建的。
授权和接口
如果一个类型实现了接口需要授权,未经授权的请求可以查询接口,但不能查询任何需要授权的类型部分。
例如,考虑这个模式,其中接口Post
不需要身份验证,但实现了接口PrivateBlog
的类型需要:
type Query {posts: [Post!]!}type User {id: ID!username: Stringposts: [Post!]!}interface Post {id: ID!author: User!title: String!content: String!}type PrivateBlog implements Post @authenticated {id: ID!author: User!title: String!content: String!publishAt: StringallowedViewers: [User!]!}
如果未通过验证的请求发出此查询:
query {posts {idauthortitle... on PrivateBlog {allowedViewers}}}
路由器将按以下方式过滤查询:
query {posts {idauthortitle}}
响应将包括一个在/posts/@/allowedViewers
路径处的"UNAUTHORIZED_FIELD_OR_TYPE"
错误。
查询去重
您可以在路由器中启用查询去重以减少对子图查询的重复请求。路由器通过缓冲类似查询并重复使用结果来实现这一点。
查询去重会考虑授权。首先,路由器将未通过验证的查询分组在一起。然后,它根据所需的作用域集将通过验证的查询分组。它在满足请求时使用这些组高效地执行查询。
自省
自省在路由器中默认关闭,这是最佳的生产实践。如果您选择启用它,请注意授权指令不会影响自省。所有需要授权的字段。
关闭自省后,您可以使用GraphOS的模式注册来探索您的超级图模式并让您的同事也能做到这一点。如果您想要从图形中完全删除字段而不是仅阻止访问(即使在自省开启的情况下),请考虑构建一个合同图。
配置选项
授权插件的行为可以用各种选项进行修改。
reject_unauthorized
reject_unauthorized
选项配置是否在授权指令失败或查询的任何部分被授权指令过滤时拒绝整个查询。启用时,响应包含受影响的路径列表。
authorization:directives:enabled: truereject_unauthorized: true # default: false
errors
默认情况下,当查询的一部分通过授权进行筛选时,被筛选的路径列表将添加到响应中,并由路由器记录。这种行为可以根据您的需求进行自定义。
记录
通过启用记录
选项,您可以选择是否将查询筛选的结果输出为一个日志事件。
authorization:directives:errors:log: false # default: true
ⓘ 注意
如果根据客户端权限对查询的部分进行筛选被视为正常的操作,那么应该禁用记录
选项。
响应
您可以通过配置响应
来定义应该将哪些被筛选的路径包含在GraphQL响应中:
错误
(默认):将筛选的路径放置在GraphQL错误中扩展
:将筛选的路径放置在扩展。在客户端抑制异常的同时,仍然提供查询部分被筛选的信息禁用
:隐藏查询被筛选的所有信息。
authorization:directives:errors:response: "errors" # possible values: "errors" (default), "extensions", "disabled"
dry_run
dry_run
选项允许在不修改查询的情况下执行授权指令,并评估授权策略的影响,而不干扰现有的流量。它将生成并作为响应的一部分返回未授权路径的列表。
authorization:directives:enabled: truedry_run: true # default: false