Ohhnews

分类导航

$ cd ..
InfoQ Java原文

身份即架构:企业IAM不止于认证

#身份管理#企业架构#访问控制#图数据库#权限撤销

关键要点

  • 在初始设计阶段就确定身份将如何影响你的数据模型。事后进行安全审查可以发现遗漏的检查或错误的范围,但无法重构一个从未设计用来解释谁在何时拥有访问权限的模式。
  • 当你的访问问题涉及所有权和有效性时,图数据库比角色表更合适。在我们的角色表中,“谁拥有这个账户”需要一连串的连接操作。而在 Neo4j 中,只需一次遍历。
  • 大约有八万人拥有我们的服务账户。让这些所有者在其职位变动和离职过程中保持参与,是治理工作的主要部分,因为一个所有者已经离开的账户就是无人看管的凭证。
  • 当撤销操作通过事件流传播时,它是最终一致的。了解你最坏情况下的陈旧度,用最大缓存时长来限制它,并告知你的安全团队这个值是多少。
  • 将证明、轮换和保管库整合到一个工作流中,将撤销时间从大约三天缩短到一天以内。

登录之后

对于单个应用来说,身份主要是一个供应商选择问题。你选择一个身份提供商,配置 SSO,在边缘验证令牌,然后继续开发功能。对于一个应用和一个团队来说,这是正确的做法。但随着组织的发展,这就不够了。登录一直运行良好,但之后的一切都变得困难:谁批准了某项访问,以及当审计员在几个月后询问时,该访问是否还应该存在。

本文基于在受监管的金融服务领域构建和运营身份与访问管理平台的经验,在那里,错误的授权会成为审计发现。我的论点是,在企业规模下,身份必须被视为架构的一部分。后期才加入身份的团队往往会发现,它与他们已经锁定的决策相冲突。我假设你有交付 OAuth 2.0 或 SSO 的经验。接下来讨论的是当涉及众多服务和众多所有者时出现的设计问题。

身份作为架构关注点

身份属于架构讨论的范畴,因为它约束了看似无关的决策。数据模型是首先体现这一点的领域。一旦你必须解释和审计访问权限,“谁能触碰这个资源”就需要成为一种可以查询历史的关系,因为审计员会询问六个月前的情况以及今天的情况。服务边界和事件流会带来各自的问题;下面各节将分别讨论。

相关赞助商

监管增加了另一个约束。这里有一个有用的测试:仅凭你的数据模型,你能回答“3 月 14 日谁有权访问这个系统?”而不需要在多个工具中搜索日志吗?如果不能,那么审计就不是设计的一部分。安全审查可以捕获遗漏的检查或错误配置的范围,但无法改造一个从未设计用来回答该问题的模式。

通过这个测试最便宜的方法是从一开始就将每一次授权、撤销和证明记录为带有时间戳和参与者的事件。这样,审计员的问题就变成了对事件日志的一次查询。

扁平 RBAC 的局限

基于角色的访问控制(RBAC)模型是默认使用的,通常也足够。但当你的问题从“这个用户是否拥有这个角色?”转变为事物之间如何关联时,它就会变得吃力。我们的实体是人、他们拥有的服务账户以及这些账户持有的访问权限。我们不断询问的问题是:“谁拥有这个账户?”、“这个访问是否仍然有效?”以及“这个人控制什么?”这三个都是关系问题。

所有权是一种关系。一个访问权限附带有有效窗口和证明历史。一个离职或角色变动的人会带走一组拥有的账户。在角色表中,每次查询时你都要通过连接操作重建这些链接。在图数据库中,每个链接都是一条边,查询会遍历它们。这就是为什么我们的后端在 Neo4j 中建模这些关系,并通过 GraphQL 查询。GraphQL 适合是因为查询的嵌套结构与遍历过程一致:这个账户,然后它的所有者,然后它的访问权限。调用方请求它所需的那部分图,从一个账户一直到其有效访问权限。没有人需要手写遍历逻辑,或者获取一个大对象然后在内存中过滤。

图 1 展示了底层模型的形状。

[LOADING...] 图 1. 在 Neo4j 中建模的所有权与访问图,展示了当访问需要过期和细微差别时,扁平 RBAC 如何失效。(图片来源:作者创建。)

在查询之前,先看看它运行所针对的模型。有三种节点:Person、ServiceAccount 和 Resource。它们之间有两种关联。首先,一个人 OWNS(拥有)一个服务账户;其次,一个服务账户 HAS_ACCESS(拥有访问权限)访问一个资源。关联 HAS_ACCESS 拥有一些属性,例如 expiration(访问不再有效的日期)和 lastAttested(所有者上次证明访问的日期)。有效性是边的属性,而不是任何节点的属性,因为它指的是账户和资源节点的特定组合。

下面的查询提出了我们经常问自己的一个问题:谁是这个账户的所有者,以及它当前有效的访问权限有哪些?

从查询的顶部往下看,第一个 MATCH 根据 ID 获取账户,并沿着 OWNS 关系直到所有者。接下来的 OPTIONAL MATCH 沿着 HAS_ACCESS 关系到达每个资源,附加的 WHERE 子句过滤掉任何已过期的访问。因为它是 OPTIONAL MATCH,所以即使账户没有剩余的访问权限,也仍然会返回其所有者。最后,RETURN 子句在一个数组中返回所有者的名字以及所有剩余的访问权限及其资源、到期日期和上次证明日期。

$ cypher
// 谁拥有这个账户,以及它当前有效的访问权限有哪些?
MATCH (p:Person)-[:OWNS]->(a:ServiceAccount {id: $accountId})
OPTIONAL MATCH (a)-[g:HAS_ACCESS]->(r:Resource)
WHERE g.expires >= date()
RETURN p.name AS owner,
       collect({ resource: r.name, expires: g.expires, lastAttested: g.lastAttested }) AS validAccesses

如果写成针对角色表的 SQL,同样的问题需要手动对齐每个链接:

$ query
-- 谁拥有这个账户,以及哪些访问权限今天仍然有效?
SELECT owner.name, res.name, g.expires, g.last_attested
FROM service_account acct
JOIN account_owner ao ON ao.account_id = acct.id
JOIN person owner ON owner.id = ao.person_id
LEFT JOIN account_grant g ON g.account_id = acct.id AND g.expires >= CURRENT_DATE
LEFT JOIN resource res ON res.id = g.resource_id
WHERE acct.id = :accountId;

即使这样,也很容易出错。如果把到期检查放在 WHERE 子句而不是连接中,那么没有有效授权的账户就会从结果中消失,连所有者一起消失。查询可以工作,但它仍然有三个问题被隐藏了。

在这个例子中,所有权只是一个连接,但所有权可以委托,一旦可以委托,那个连接就变成沿着未知长度的链向上走。在图数据库中,同一条链是一个模式中的可变长度路径。

到期检查还必须在每个涉及授权的查询中重复。任何遗漏它的查询都会返回本应被移除的访问权限。在图数据库中,检查位于遍历内部,因此消费者不会意外跳过它。

最后,当一个人离开时,他们拥有的账户不会摆在你面前,你必须去寻找它们。在图数据库中,它们只有一跳之遥,因为“拥有”是一条边。

这两个查询都是从我们生产环境中运行的查询简化而来的。但 Neo4j 有固有的成本。它是一个需要运维的额外数据存储,遍历性能在很大程度上取决于你如何建模。形状不佳的图可能比它替代的连接更慢。对我们来说仍然值得,因为所有权和有效性是我们不断遍历的东西。扁平版本把困难的部分推给了编写下一个查询的人。

服务账户:八万个所有者,三个工具

比较平台中的所有内容,服务账户花费了最多的治理工作。每个服务账户都由一个真实的人拥有:大约八万人审查、轮换和保管他们拥有的账户。账户的安全性主要取决于所有者:他们是否仍然参与,是否换了团队或离开了公司。一个所有者已经离开的账户正是那种陈旧的、过度授权的凭证,直到被滥用才有人注意到。

在重建之前,所有者必须做的三件事(证明、轮换和保管)存在于三个独立的工具中。所有者必须记住哪个工具做什么。对于每天做这件事的人来说,这只是一个小麻烦。但对于一年做两次的人,或者从离职同事那里继承账户的人来说,这是一个真正的故障模式。

重建将这三个所有者要求整合到一个工作流中,因此所有者在一个屏幕上就能看到他们拥有什么以及什么到期。实现这一点不仅仅是把屏幕连接起来。每个工具都建立在各自关于时间表和所有权的假设之上,一旦它们共享一个工作流,这些假设就必须协调一致。图 2 展示了整合前后的撤销工作流:三个独立的工具,撤销大约需要三天,变成了一个统一的工作流,撤销时间不到一天。

[LOADING...] 图 2. 工具整合前后的撤销工作流。(图片来源:作者创建。)

集成一个我们不拥有的保管库

在企业身份工作中,有风险的集成通常是你的系统依赖于一个你不治理的系统。对我们来说,那就是保管。在重建之前,我们已经很好地处理了证明和轮换。那些代码是我们自己写的,所以我们知道它如何失败。保管运行在一个独立的工具中,由另一个团队拥有,在我们无法更改的基础设施上。将其纳入我们的工作流意味着一个关键控制现在依赖于他们的发布计划和他们的停机时间。

困难的部分是决定当他们的 API 不回答、回答缓慢,或者在我们的调用已经超时后才回答时,我们的工作流应该相信什么。当你为成千上万的所有者做这件事时,这些不是边缘情况。它们每天都会在某个地方发生。

因此,我们让集成“失败关闭”。如果保管库不可达,工作流不会报告该步骤完成,所有者永远不会被告知凭证已保管,而实际上并没有。这在纸面上听起来很明显。在实践中,这意味着我们这边不能把“我们发送了请求”当作成功。我们必须确认保管库自身的状态,将其与我们的状态进行核对,并将两者之间的任何差距视为我们这边需要解决的问题。

跨服务边界传播身份

如果每个服务都做出自己的访问决策,授权逻辑最终会分散在几十个代码库中,没有人能够审计它。当规则改变时,你要在每个实现了自己版本的服务中寻找它,并且可能会漏掉一个。有效的方法是让身份上下文跨越边界传播,而决策留在专用的策略决策点中。身份通过使用 OAuth 2.0 令牌交换的作用域声明传播,因此代表调用者行事的服务会获得一个收窄的令牌,而不是重用宽泛的令牌。控制器询问,策略决策点决定,如下面的代码片段所示:

$ java
// 授权委托给策略层,而不是硬编码在控制器中。
// 示意性的 Spring Security 风格;确切 API 因版本而异。
@Bean
SecurityFilterChain filterChain(HttpSecurity http, PolicyDecisionPoint pdp) throws Exception {
    http.authorizeHttpRequests(auth -> auth
        .anyRequest().access((authentication, context) -> {
            AccessRequest req = AccessRequest.from(authentication.get(), context.getRequest());
            return new AuthorizationDecision(pdp.permits(req));
        })
    );
    return http.build();
}

控制器永远看不到规则,因此更改规则只需要更改 PDP,别无其他。跨服务跳转时,传入的令牌在向下游调用之前会交换为作用域令牌:

$ java
// 将调用者的令牌收窄到下游调用所需的范围,
// 通过 OAuth 2.0 令牌交换(RFC 8693)。示意性。
TokenExchangeRequest exchange = TokenExchangeRequest.builder()
    .subjectToken(incomingAccessToken)
    .audience("downstream-service")
    .scope("accounts:read") // 收窄范围,而不是调用者的完整授权
    .build();

String downstreamToken = tokenExchangeClient.exchange(exchange).accessToken();

每一跳的令牌都记录了其受众和范围,因此审计日志会显示每次调用的目的。

通过 Kafka 进行撤销

授权变更作为事件通过 Kafka 传播。授权、撤销或所有权变更都会变成一条消息,感兴趣的服务会消费它。对于分布式系统来说,这是一个合理的设计,它表明撤销是最终一致的。一旦消费者处理事件并使任何缓存的决策失效,访问就会停止,这发生在撤销被记录之后的某个时间。在任何人向安全利益相关者描述撤销之前,这个差距应该被测量和限制。

处理撤销的消费者会使缓存的决策失效,因此下一次检查会从真相源重新解析:

$ java
// Kafka 消费者:授权撤销使缓存的决策失效。
// 示意性;针对你自己的 broker 和缓存运行。
@KafkaListener(topics = "entitlement-events", groupId = "authz-cache")
public void onEntitlementEvent(EntitlementEvent event) {
    if (event.type() == REVOKED) {
        decisionCache.invalidate(event.accountId(), event.resourceId());
        auditLog.record(event); // 撤销本身就是一个被审计的事实
    }
}

事件可能会丢失,因此缓存也有时间限制。缓存的决策只有在它比最大年龄更年轻时才会被提供。之后,无论事件是否到达,都会从真相源重新解析。那个最大年龄就是事件丢失的最坏情况。在我们依赖这个设计之前,我模拟了消费者和缓存,以确认错过的事件绝不会让访问在超过该时间后仍然有效。

选择最大年龄涉及一个真正的权衡。较短的最大年龄会使陈旧访问迅速过期,这是安全想要的,但每次过期都会向真相源发送另一个检查。在我们的数据量下,这种负载并不免费。较长的最大年龄对系统来说更轻松,但对撤销更不利,因为丢失的事件有更长的时间来产生影响。你选择一个双方都能接受的数字,然后明确说明:丢失的事件最多让访问保持有效那么长时间。这是安全团队可以据此规划的句子。

不过,缓存窗口只是小部分。更大的延迟是程序性的。在重建之前,完全撤销访问大约需要三天,大部分时间花在人员必须在单独工具中完成的步骤上。通过单一工作流,访问撤销花费不到一天。

何时这是过度的

对于一个只有几个角色和一个团队的单一应用,你可以跳过所有这些。主数据库中的扁平 RBAC 是正确的选择,而 Neo4j、Kafka 和策略层将是过度工程,你会后悔维护它们。门槛是组织性的:许多服务做出访问决策,服务账户的数量超过并比照看它们的人更长寿,所有权必须重新验证,以及有实质约束的审计义务。

以下是我如何权衡每个部分:

组件值得使用的情况跳过的情况
图数据库你不断遍历所有权和有效性你只需要询问用户是否拥有角色
用于授权的事件流访问变更必须到达许多服务,并且你可以说明陈旧度界限少数几个服务从一个数据库读取访问权限
中央策略层授权逻辑否则会分散在许多代码库中一个应用拥有其所有规则

下一步

授权正在从单个服务中移出,进入可以独立测试的策略引擎。对于服务账户,零信任版本是持续验证:账户按计划重新证明其授权,而不是默认保留它们。我们的服务账户重建是同一想法的手动版本,因为证明就是有人参与的重新验证。有趣的问题是,在不失去具名所有者提供的问责制的情况下,有多少人工步骤可以自动化。

关于作者

[LOADING...]

Vaishnavi Khosla

Vaishnavi Khosla 是一名软件开发人员,在全栈开发和企业安全方面拥有超过五年的经验。她目前在金融服务领域构建身份与访问管理平台。她的工作涵盖 Java 和 Spring Boot 后端、Angular 和 React 前端,以及使用 Neo4j 和 Kafka 的图和事件驱动系统,专注于企业规模的身份验证、授权和访问治理。她拥有德克萨斯大学阿灵顿分校的计算机科学硕士学位,并为包括加州大学伯克利分校、佐治亚理工学院和堪萨斯州立大学在内的大学项目担任黑客松评委。

显示更多 显示更少