我不断遇到的一个问题
每当我部署新的持续集成和持续部署(CI/CD)工具,或者向第三方授予 GCP 项目的访问权限时,最终都需要做同样的事情:导航到身份与访问管理(IAM)页面,创建一个服务账户,下载一个 JSON 密钥,然后将其粘贴到某个地方。虽然这样能行得通,但我总觉得哪里不对劲。
你可以为服务账户密钥设置过期日期;为了提高安全性,你应该这样做。但这又会引发新的问题。每次密钥过期时,你都必须为同一个服务账户生成一个新的密钥,然后追踪所有使用旧密钥的系统和团队,并将新密钥分享给他们。实际上,这种运维开销相当大,以至于许多团队干脆完全跳过过期设置,让密钥永久有效,而这显然是一个更糟糕的解决方案。无论是否设置过期时间,密钥都存储在密钥库和环境变量中。有时候,如果有人操作失误,甚至会出现在版本控制系统中。虽然可以审计何时使用了哪个密钥,但这取决于是否预先启用了审计日志。如果密钥泄露,通常只有在仔细查阅这些日志后,才能了解其影响范围。
当我第一次接触 Workload Identity Federation(WIF)时,我是持怀疑态度的。又一个 IAM 抽象层?但在将其部署到多个生产工作负载中,并将 Harness 管道、GitHub Actions 工作流以及亚马逊云科技的 Lambda 函数与 GCP 连接起来之后,我确信这是机器间身份验证的正确模型。
本文并非又一篇关于“什么是 WIF”的概述。相反,本文将探讨在大规模推行该方案时的实际情况究竟如何,包括我的组织如何在六个月内将基于 WIF 的项目的数量从零提升至 120 个以上;在多组织 Harness 部署中需要警惕的范围界定失误;我们为何选择“模拟身份”而非谷歌推荐的默认“直接访问”方式;以及当 AWS 安全令牌服务(STS)在后台向 GCP 验证身份时,其内部机制究竟是如何运作的。
Workload Identity Federation 究竟是什么
我最初怀疑是因为 WIF 的设计形式:必须存在三个独立的对象,系统才能正常运行。单个对象本身并不能发挥任何实际的作用。与下载一个 JSON 密钥相比,似乎这种设计为了达到相同的效果增加了许多繁琐的步骤。
我之所以改变看法,是因为我意识到这些繁文缛节本身就是其关键所在。服务账户密钥是一个你现在拥有而且必须永久保护的密钥。WIF 取代了这种设计,转而采用一种信任关系——你只需要声明一次,此后便无需再进行任何操作。
其机制非常简单。与其将凭据交给外部系统,不如提前告知 GCP 你信任哪些外部身份提供商以及在什么条件下信任它们。当外部工作负载需要访问权限时,它会向 GCP 的安全令牌服务(STS)提交其原生令牌。GCP 会根据你的配置验证该令牌,若验证通过,则签发一个短效的 GCP 访问令牌。任务执行完毕后,令牌即失效,没有任何数据被存储,也无需进行轮换。该配置包含三个组成部分。
工作负载身份池
这是 GCP 项目中的一个命名容器,用于将外部身份配置进行分组。你可以将其视为一个存放信任配置的文件夹。它本身并不执行任何操作。
提供商
该连接器位于身份池内,并定义了实际的信任关系。你需要针对每个外部身份系统配置一个提供商。提供商会告知 GCP 令牌的来源、验证方式、如何将令牌的声明映射到 GCP 可识别的属性,以及允许哪些特定的身份通过。
服务账户绑定
这是最后一步。当外部身份通过验证后,它就需要实际的 GCP 权限。你将特定身份池中的身份绑定到服务账户,对应的工作负载便会临时继承该服务账户的 IAM 权限。
Workload Identity Federation 并非 GCP 独有的概念。例如,Microsoft Entra Workload ID 在 Azure 中实现了相同的理念,利用基于 OpenID Connect (OIDC) 的联合凭据,消除了外部工作负载中存储的密钥。其底层的信任关系模型是一样的,即输入外部令牌、输出短效平台令牌。本文所述的机制(包括身份池、提供商、属性条件及其通用表达式语言(CEL)语法)均特定于 GCP 的实现。
你可以将 Workload Identity Federation 用于以下场景:使用 X.509 客户端证书进行身份验证的工作负载;运行在 AWS 或 Azure 上的工作负载;本地 Active Directory;GitHub 和 GitLab 等部署服务;以及任何支持 OpenID Connect 或安全断言标记语言 (SAML) V2.0 的身份提供商 (IdP)。
我们是如何采用 WIF 的
我们并没有进行迁移。这一点有必要直截了当地说明一下,因为“淘汰环境中的所有密钥”是一个与本文截然不同且难度大得多的课题。
我们的环境中存在着数百个未设置过期时间的服务账户密钥,其中一些已有数年之久。要对这些密钥进行追溯处理,需要协调所有持有密钥副本的团队和系统,每个步骤都存在服务中断的风险,而且无法明确证明某个密钥是否确实已经失效,还是仅仅在那一周未被使用。
因此,我们转而在密钥创建环节划定了界限。作为更广泛的云现代化计划的一部分,WIF 成为每个新建 GCP 项目的强制要求。任何新项目都不会获得服务账户密钥。部署通过使用联合身份认证的 Harness 管道进行。这一要求在项目配置阶段就已经强制执行,而非交由各团队自行选择是否采用。在六个月内,已有超过 120 个项目采用这种方式进行身份验证。
旧密钥并没有消失,但它们不会再持续扩散。这就把一个原本“不断扩张”的问题,转化成了一个“规模固定且持续收缩”的问题,让整个风险治理工作变得可控。
在早期,强制推行这一变更还带来了一项我未曾预料到的好处。由于每个项目都继承了相同的模式,范围界定规范在项目伊始就已经确定了,而非在各团队之间各行其是。在接入前几个服务提供商之后,每一项新集成都只是在复制我们已经信任的配置,而非重新做出设计决策。
OIDC 连接器:教 GCP 读取外部 Token
大多数现代 CI/CD 平台都支持 OIDC。当工作流或管道运行时,平台会为这次运行自动生成一个短效的 JSON Web Token(JWT)。该令牌由平台自身的 OIDC 签发者进行加密签名;其中包含描述身份的声明,例如这次运行源自哪个存储库、哪个组织或哪个管道账户。
OIDC 连接器是让 GCP 理解这些令牌的关键。当你在池中配置 OIDC 类型的提供商时,需要定义以下三项内容。
签发者 URL
这是外部平台的 OIDC 端点。GCP 会从该 URL 获取该平台的公钥,并利用这些公钥验证传入的令牌是否真实且未被篡改。
属性映射
这是一个转换层。GCP 本身无法识别 Harness 声明名称或 GitHub 声明名称。这些映射告诉 GCP:“当你在令牌中看到 assertion.account_id 时,将其识别为 attribute.account_id”。预留映射关系 google.subject = assertion.sub 是必需的,并将作为主体身份标识。
属性条件
这是一个充当安全门的 CEL 表达式,用于过滤实际有哪些身份可以通过。如果没有这个表达式,来自签发者的任何有效令牌都将可以通过。有了它,你可以将访问权限限制为特定的组织、账户、存储库或令牌中包含的任何声明。
具体来说,在我最近参与的一个 Harness 实现中,属性条件如下:
attribute.account_id == "abcdrfghijklmnop" &&attribute.organization_id == "my_org_platform"
该配置规定,即使某人持有来自其他 Harness 账户的有效 Harness JWT,GCP 也会在这个步骤将其拒绝。该条件是你身份层上的最后一道防线。
谷歌关于 Workload Identity Federation 与其他身份提供商配置集成的文档,详细介绍了所有受支持的属性映射和条件。
GitHub Actions 到 GCP
GitHub Actions 是最常见的 WIF 用例之一。每个 GitHub Actions 作业都会自动收到一个包含存储库、分支、工作流名称和环境的 OIDC 令牌。该功能已经内置其中,GitHub 端无需进行任何配置。
第一步是在 GCP 端创建身份池和 OIDC 连接器。
gcloud iam workload-identity-pools create github-pool \ --location="global" \ --project=YOUR_PROJECT gcloud iam workload-identity-pools providers create-oidc github-connector \ --location="global" \ --workload-identity-pool=github-pool \ --issuer-uri="https://token.actions.githubusercontent.com" \ --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \ --attribute-condition="assertion.repository=='your-org/your-repo'" \ --project=YOUR_PROJECT
接下来是将身份池绑定到一个服务账号。
gcloud iam service-accounts add-iam-policy-binding deploy-sa@YOUR_PROJECT.iam.gserviceaccount.com \--member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/attribute.repository/your-org/your-repo" \--role="roles/iam.workloadIdentityUser"
最后,在 GitHub Actions 工作流中,添加 OIDC Token 权限,并使用 google-github-actions/auth 操作。
permissions:id-token: writecontents: read- uses: google-github-actions/auth@v2with:workload_identity_provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/providers/github-connectorservice_account: deploy-sa@YOUR_PROJECT.iam.gserviceaccount.com
整个配置流程就是这样。GitHub 中不会存储任何密钥。该令牌的有效期仅限于工作流运行期间,并且会自动过期。
如需查看完整的配置步骤(包括 Terraform 示例以及 GitHub 以外的其他 CI/CD 平台),请参阅 谷歌发布的 “Workload Identity Federation 对接部署流水线配置指南”。
Harness Pipelines 到 GCP
Harness 还原生支持 OIDC。当 Harness 管道运行时,它会生成一个 JWT 令牌,其中包含账户 ID、组织 ID、项目 ID 和管道标识符等声明信息。其结构完全遵循三段式 WIF 模型,但声明名称为 Harness 专有的名称。
Harness 的签发者 URL 与你的账户相关联:
https://app.harness.io/ng/api/oidc/account/YOUR_HARNESS_ACCOUNT_ID
属性映射会从 JWT 中提取 Harness 特有的字段:
google.subject=assertion.subattribute.account_id=assertion.account_idattribute.organization_id=assertion.organization_idattribute.project_id=assertion.project_id
该属性条件随后会将其锁定到你的特定 Harness 账户和组织。我经常看到的一个错误是,省略对 organization_id 的检查,而仅根据 account_id 进行筛选。如果你的 Harness 账户中只有一个组织,这种做法尚且可以接受,但在多组织账户中,这种做法的权限控制就过于宽松了。
将身份池绑定到服务账户时,如果使用包含 attribute.account_id 的 principalSet 而不是特定的 subject,那么该账户内的任何管道都可以冒充该服务账户。如果你希望实施更严格的控制(例如,仅限特定管道),请改用 subject 属性。此时,权限范围将被限定为该管道的运行身份。
AWS 工作负载到 GCP
亚马逊云科技的实现方式最让人感到意外。他们并未在其工作负载令牌中使用 OIDC,而是采用了基于 AWS STS AssumeRole 的自有格式。WIF 通过专用的提供商类型来处理这种格式,而非 OIDC。
这里的信任锚点是 AWS 账户 ID,而非颁发者 URL。你需要告知 GCP 信任来自该特定 AWS 账户的令牌。随后,GCP 将使用 AWS 自身的验证机制,验证传入的令牌是否确实是来自该账户的真实 AWS 令牌。
AWS 令牌如何工作的
当代码在 AWS 工作负载(例如 EC2 实例、Lambda 函数或 ECS 任务)上运行时,AWS 会自动为其分配与某个 IAM 角色关联的临时凭证。该凭证由以下三个部分组成:
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_SESSION_TOKEN
当该工作负载需要向外部系统证明其身份时,它便会使用这些凭证调用 AWS STS GetCallerIdentity 接口。这是一个 AWS API 调用,会返回一个带签名的响应,表明:“该请求确实来自 AWS 账户 123456789 中的 IAM 角色 X”。
关键在于,该响应上的签名只能由 AWS 自身生成。AWS 之外的任何人都无法伪造它。
GCP 究竟做了什么验证
当 AWS 工作负载将其令牌发送至 GCP 的 STS 时,GCP 并不会直接采信 AWS 账户 ID。以下是它的具体工作原理:
步骤 1:GCP 接收 AWS 凭证包
工作负载将 AWS 访问密钥、密钥和会话令牌发送至 GCP,并非以原始凭证的形式,而是打包为 AWS STS GetCallerIdentity 调用的签名请求。
步骤 2:GCP 代表你调用 AWS STS
实际上,GCP 会使用工作负载提供的签名请求,亲自向 AWS 发起 GetCallerIdentity API 调用。本质上,GCP 是在询问 AWS:“这个令牌是真的吗?”
步骤 3:AWS 返回身份信息
AWS 在内部验证签名后,会返回该令牌所属的 IAM 角色 ARN 以及 AWS 账户 ID。
步骤 4:GCP 根据你的配置验证响应
GCP 会检查返回的 AWS 账户 ID。如果它与你在 AWS 提供商中配置的账户 ID 匹配,则该令牌为真。否则,请求将被拒绝。
实现
gcloud iam workload-identity-pools create aws-pool \--location="global" \--project=YOUR_GCP_PROJECTgcloud iam workload-identity-pools providers create-aws aws-connector \--location="global" \--workload-identity-pool=aws-pool \--account-id="AWS_ACCOUNT_ID" \--project=YOUR_GCP_PROJECT
AWS 服务账户的绑定范围不限于整个账户,还细化到了特定的 IAM 角色:
gcloud iam service-accounts add-iam-policy-binding \aws-accessor@YOUR_GCP_PROJECT.iam.gserviceaccount.com \--member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/aws-pool/attribute.aws_role/arn:aws:sts::AWS_ACCOUNT_ID:assumed-role/ROLE_NAME" \--role="roles/iam.workloadIdentityUser"
该 AWS 工作负载使用通过 gcloud iam workload-identity-pools create-cred-config 生成的 GCP 凭据配置文件,并将其设置为 GOOGLE_APPLICATION_CREDENTIALS 环境变量。GCP 客户端库会自动识别该文件格式,并透明地处理 AWS STS 令牌交换。工作负载不需要了解 WIF 的内部实现细节。
谷歌文档“面向 AWS 或 Azure VM 的 Workload Identity Federation”对此配置进行了更深入的说明。
安全考量
WIF 虽然解决了密钥泄露问题,但如果配置不当,也会引入新的攻击面。
在生产环境中,属性条件绝非可选项
默认行为(或条件)允许来自提供商的任何经过身份验证的身份进行令牌交换。对于 GitHub 而言,这意味着如果签发者 URL 匹配,那么该平台上的任何公共存储库都可能向你的身份池做身份验证。请务必定义一个条件。
谨慎使用 principalSet 作用域
principal://(单个特定主体)与 principalSet://(所有匹配某个属性的身份)之间的区别,会对攻击范围产生重大的影响。建议从范围较窄的场景开始(例如特定的存储库或管道),仅在运营确实需要时再扩大范围。
有效期短不等于零风险
WIF 令牌的有效期通常为一小时。如果令牌在此期间内被截获,则仍然可以被使用。可以通过将服务账户的权限限制在最低必要的范围内,并在适当的情况下启用虚拟私有云(VPC)服务控制来缓解该风险。
审计令牌交换
GCP 控制台中的 WIF 身份池详情页面会显示一个图表,其中是交换成功的令牌的使用情况。请在 IAM API 上启用云审计日志,以便捕获包含完整元数据的令牌交换事件:涉及哪个主体、哪个提供商以及冒充了哪个服务账户。这相当于你的门禁日志。
身份池和提供商的删除并非即时生效
删除一个身份池会将其置于为期三十天的软删除状态,可以通过撤销删除操作恢复,但无论如何,令牌交换都会立即失败。三十天后,删除将变为永久性操作。请像删除服务账户一样谨慎对待身份池的管理。
三种实现对比
直接访问与模拟身份:这是一个真正的选择,而非风格偏好
除了通过服务账户进行路由外,WIF 还允许你直接向资源上的联合主体授予 IAM 角色,这被称为“直接资源访问”。谷歌建议将此作为默认设置:这样既可以将服务账户从流程中移除,又能确保每个外部身份的访问权限仅限于其被授予的资源。
相反,本文出于两个原因的考虑,在全文中均采用了“模拟身份”的方式。首先,外部身份只需要进行一次联合认证,但随着时间的推移,往往需要访问多个项目和管道。谷歌自身的最佳实践警告称,将一个身份映射到多个 IAM 主体(当同一身份为每个项目获取独立的直接访问绑定时,就会发生这种情况)会使实际可以访问的范围难以把握。这在身份下线时也会带来实际的风险。如果在撤销过程中遗漏了某个绑定,访问权限就会在不知不觉中持续存在。而将所有操作都通过服务账户进行时,则只需执行一次撤销操作,不用对该身份曾经涉及的每个项目逐一进行审核。
其次,直接访问会为管道调用的每项服务清除“per-API 限制”表,而不是仅在初始化时清除一次。此外,在跨 VPC 服务控制边界时,这种方式也更不稳定。在这两种情况下,谷歌都指出“模拟身份”是更合适的选择。
有一种操作,直接访问不仅存在限制,甚至完全无法进行:Cloud Storage 签名 URL。签名 URL 并非经过身份验证的 API 调用,而是你提供给没有 GCP 身份的用户的 URL。使其值得信赖的关键在于与特定服务账户绑定的加密签名。生成该签名需要服务账户的私钥或 iam.serviceAccounts.signBlob 权限,而单纯的联合主体既不具备私钥,也不拥有该权限。在模拟身份的情况下,签名机制能够正常工作,因为你的联合令牌会调用你所模拟的服务账户上的 signBlob 方法。谷歌会使用其托管账户中的密钥对有效载荷进行签名。实际上,这属于一种特殊情况。这些管道的大部分操作都不受其影响,但在适用范围内,这是一项硬性限制,而非可选设置。
WIF 何时不适用
WIF 是工作负载与 GCP 之间身份验证的理想解决方案,但它并非适用于所有场景。人类用户在向 GCP 进行身份验证时应使用 Cloud Identity,这是一项相关但独立的功能,专为人类而非机器而设计。WIF 则专门针对非人类调用者:管道、服务和自动化系统。
某些 GCP 服务和客户端库在处理联合凭据时存在限制。在假设 WIF 能实现端到端工作之前,请先查阅 GCP 文档中的支持服务列表。Access Context Manager 就是其中一个例子。尽管该产品的其余部分运行正常,但其 v1alpha API 目前尚不支持联合身份。
“身份联合产品与限制”页面列出了每个产品的当前支持级别。在基于 GitHub Actions 或 Google Kubernetes Engine (GKE) 之外的其他服务进行开发之前,建议先查阅该页面。
如果你在 GCP 内部(例如在 Compute Engine、GKE 或 Cloud Run 上)运行工作负载,则无需使用 WIF。这些工作负载已经通过元数据服务器拥有了 GCP 的第一类身份。WIF 专门用于运行在 GCP 外部的工作负载。
小结
从服务账户密钥转向 WIF 不仅仅是一项安全改进。它改变了你对机器身份的认知。密钥是你需要管理的机密;联合身份则是你需要配置的信任关系。前者需要严格的运维纪律才能确保安全;后者则具备“安全由设计而定”的特性。
其实际意义在于可重复性。创建一个身份池,添加一个具有严格属性条件的提供商,并绑定一个遵循最小权限原则的服务账户。这一流程贯穿了我们进行的每一次集成,这也是能够将其强制应用于所有新项目的唯一原因。一种每次都需要重新思考的模式,无法扩展到上百个团队。
为了实现“零凭证存储”这一结果,克服一下初期的学习曲线是值得的。既无需轮换凭证,也不会意外提交凭证,即使凭证被截获,攻击者也无法获得永久访问权限。对于任何运行 CI/CD 管道或将第三方服务连接到 GCP 的团队而言,WIF 都应该是默认选择。