用开源组件搭一个 AWS IAM 风格的授权系统

Posted on 三 29 4月 2026 in Tech

Abstract 用开源组件搭一个 AWS IAM 风格的授权系统
Authors Walter Fan
Category Security
Status v1.0
Updated 2026-04-29
License CC-BY-NC-ND 4.0

用开源组件搭一个 AWS IAM 风格的授权系统

短大纲

  • 为什么授权不是一句 if user.is_admin 能解决的事
  • AWS IAM 的 User、Role、Trust Policy、Permission Policy 给我们什么模型
  • 用 Keycloak/Dex、SPIFFE/SPIRE、OpenFGA、OPA、自建 STS、OpenBao/Vault 和审计系统拼出开源版 IAM
  • SPIFFE/SPIRE 在 workload identity、mTLS 和 service-to-service trust 里做什么
  • OpenFGA 怎么表达 trust policy 和 resource relationship
  • OPA 怎么表达 condition、explicit deny 和全局 guardrail
  • STS 如何实现 AssumeRole 和短期凭证
  • 一个可运行的 HTTP Request 授权例子
  • 一套能落地的授权实施清单

一、先说一个扎心场景

很多系统的授权,都是从一行代码开始失控的:

if user.is_admin:
    allow()

刚开始它很可爱。一个后台页面,两个角色,三个接口,大家都懂。

半年后,产品经理说:“项目 owner 可以改自己项目的配置,但不能删生产环境。”安全同事说:“外包同学只能看脱敏数据。”运营说:“临时活动期间,区域负责人能审批本区域的工单。”老板说:“我都能看,但你们不要在日志里写我是 super admin。”

于是代码开始长蘑菇:

if user.is_admin or user.id == project.owner_id or (
    user.region == ticket.region and ticket.status != "closed"
):
    ...

再过一阵子,没人敢改了。授权逻辑像办公室冰箱里的剩饭,理论上还属于某个人,实际上没人愿意负责。

所以我越来越觉得:授权不是业务代码里的几个条件判断,而是一套独立的决策系统。

AWS IAM 做得好的地方,不是它的 JSON 多优雅。老实说,IAM Policy 的 JSON 有时候像一只章鱼摔进了键盘。它真正有价值的地方,是给了我们一个稳定的授权心智模型:

谁(Principal)能对什么资源(Resource)执行什么动作(Action),在什么条件下(Condition)允许或拒绝。

到了开源世界,自建系统也需要类似能力。但这里有个坑:不要指望某一个开源项目直接变成 AWS IAM。

OpenFGA 很适合表达“谁和什么资源有什么关系”,OPA 很适合表达“在什么条件下允许或拒绝”。但 AWS IAM 还包含身份登录、角色信任、AssumeRole、短期凭证、policy version、resource policy、审计追踪、密钥生命周期。它不是一个库,而是一套系统。

如果要用开源组件搭一个 IAM-like 系统,我会这么拆:

  • Keycloak / Dex / ORY Hydra:负责用户登录、OIDC、身份联邦。
  • SPIFFE / SPIRE:负责 workload identity,让服务、Pod、Job、Agent 拿到可验证的机器身份。
  • 自建 STS 服务:负责 AssumeRole,签发短期 role session token。
  • OpenFGA:负责 trust relationship 和 resource relationship。
  • OPA:负责 permission policy、condition、explicit deny 和全局 guardrail。
  • API Gateway / middleware:负责拦截请求,也就是 PEP。
  • OpenBao / Vault:负责动态密钥和敏感凭证。
  • Audit pipeline:负责记录每一次授权决策。

一句话先说结论:

开源版 IAM 不是“OpenFGA vs OPA”,而是“Identity + STS + Relationship + Policy + Enforcement + Audit”的组合拳。


二、先借 AWS IAM 建一个脑内模型

如果你熟悉 AWS IAM,大概知道它有几个核心概念:

  • Principal:谁在发起请求,比如 user、role、service。
  • Action:要做什么,比如 s3:GetObjectec2:StartInstances
  • Resource:对哪个资源做,比如某个 bucket、某台 EC2。
  • Condition:在什么条件下,比如来源 IP、MFA、tag、时间。
  • Effect:允许还是拒绝,Allow / Deny

一个 IAM policy 大概长这样:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::example-bucket/reports/*",
      "Condition": {
        "StringEquals": {
          "aws:PrincipalTag/team": "finance"
        }
      }
    }
  ]
}

这套模型很有用,因为它把授权问题拆成了几块:

subject + action + resource + context -> decision

翻译成人话:

张三能不能在周五晚上,用公司 VPN,从北京办公室,删除生产项目里的密钥?

授权系统要回答的不是“张三是不是管理员”这么粗糙的问题,而是“在这个上下文里,这个动作是否被允许”。

开源世界里的 OPA 和 OpenFGA,也是在回答这类问题,只是切入点不一样。


三、开源版 IAM 的总体架构

如果把 AWS IAM 拆开看,它至少有八块能力:

AWS IAM 能力 开源实现建议 说明
User / Federation Keycloak、Dex、ORY Hydra 负责登录、OIDC、SAML、企业身份接入
Workload Identity SPIFFE / SPIRE 给服务、Pod、VM、Agent 签发 X.509-SVID 或 JWT-SVID
Role IAM 元数据服务 + OpenFGA object role 是一个可被 assume 的身份和权限边界
Trust Policy OpenFGA can_assume + OPA condition 谁可以 assume role,以及在什么条件下可以
Permission Policy OPA policy + OpenFGA relation role/session 能对资源做什么
Resource Policy OpenFGA resource relation + OPA condition 资源自己声明谁能访问
AssumeRole / STS 自建 STS 服务 校验 trust,签发短期 token
Temporary Credentials JWT / opaque token / mTLS cert 有 TTL、scope、session id
CloudTrail PostgreSQL、ClickHouse、OpenSearch 记录 every decision,不只是成功请求

架构可以画成这样:

User / Workload
  |
  v
Identity Provider / SPIRE
  |  OIDC login or SVID
  v
STS Service  <----> OpenFGA: can_assume role?
  |           <----> OPA: trust policy conditions?
  | issues short-lived role session
  v
Client with STS token
  |
  v
API Gateway / Middleware (PEP)
  |----> OpenFGA: role/session relation to resource?
  |----> OPA: permission policy, condition, explicit deny?
  |----> Audit: who/action/resource/decision/reason
  v
Business Service

这个设计里,最关键的是不要把所有职责塞进一个组件。

  • Keycloak 负责“你是谁”。
  • SPIRE 负责“这个工作负载是谁”。
  • STS 负责“你现在扮演什么角色”。
  • OpenFGA 负责“你和这个 role / resource 有什么关系”。
  • OPA 负责“这个上下文下是否允许”。
  • PEP 负责“真的拦住或放行请求”。
  • Audit 负责“以后能不能说清楚发生了什么”。

人话版:

OpenFGA 管关系,OPA 管规矩,STS 管临时身份,Gateway 管拦门,Audit 管翻账。

这里 SPIFFE/SPIRE 的位置很容易被低估。Keycloak 解决的是 human identity,SPIFFE/SPIRE 解决的是 workload identity。人登录系统靠 OIDC token;服务调用服务、Agent 调 API、Job 调 STS,最好不要再靠一串长期 API key,而是用可自动轮换、可验证、可绑定运行环境的 SVID。

一句话:

Keycloak 给人发工牌,SPIRE 给工作负载发工牌。

SPIFFE/SPIRE 在这里做什么

SPIFFE 是规范,定义工作负载身份的格式和获取方式;SPIRE 是实现,负责做 node attestation、workload attestation,并给工作负载签发 SVID。SVID 可以是:

  • X.509-SVID:常用于 mTLS,服务之间互相验证身份。
  • JWT-SVID:常用于向 STS、Vault、网关这类服务证明“我是某个 workload”。

放到 IAM-like 系统里,它主要解决四件事。

第一,让服务调用 STS 时不用长期密钥

workload -> SPIRE Workload API -> JWT-SVID
workload -> STS: AssumeRoleWithSVID(JWT-SVID, role)
STS -> verify SVID trust domain and SPIFFE ID
STS -> OpenFGA + OPA
STS -> short-lived role session token

比如某个 Kubernetes Job 的 SPIFFE ID 是:

spiffe://example.org/ns/billing/sa/report-generator

STS 可以把它当成 principal:

{
  "principal": "spiffe://example.org/ns/billing/sa/report-generator",
  "role": "role:billing-report-reader",
  "context": {
    "cluster": "prod-us-west",
    "mfa": false
  }
}

然后 OpenFGA 表达它能不能 assume role:

workload:spiffe://example.org/ns/billing/sa/report-generator trusted role:billing-report-reader

OPA 再判断这个 workload 的 trust domain、namespace、service account、cluster、时间窗口是否满足策略。

第二,让 PEP 到 PDP 的调用有服务身份

API Gateway 调 OPA、OpenFGA、STS,不应该靠共享密码。可以用 SPIRE 发的 X.509-SVID 做 mTLS:

api-gateway --mTLS--> opa
api-gateway --mTLS--> openfga
api-gateway --mTLS--> sts

这样 OPA / OpenFGA / STS 可以知道调用方到底是 api-gatewayagent-runtime,还是某个不该来的 Pod。

第三,让 OPA policy 能基于 workload identity 做条件判断

OPA 的 input 可以带上 SPIFFE ID:

{
  "workload": {
    "spiffe_id": "spiffe://example.org/ns/agent/sa/coding-agent",
    "trust_domain": "example.org"
  },
  "action": "tool.execute",
  "resource": "tool:shell"
}

策略可以写成:

deny contains "coding agent cannot execute shell outside sandbox" if {
  input.workload.spiffe_id == "spiffe://example.org/ns/agent/sa/coding-agent"
  input.action == "tool.execute"
  input.resource == "tool:shell"
  input.context.sandbox != true
}

第四,替代一部分“机器账号 + 静态 token”的老路

很多公司做内部 IAM 时,最容易留下的洞就是 service account token、API key、机器人账号密码。SPIRE 的价值是让这些工作负载身份变成短期、自动轮换、可 attestation 的凭证,而不是一条躺在配置文件里三年没人敢删的 token。

Trust policy 怎么做

AWS IAM 的 trust policy 回答的是:

谁可以 assume 这个 role?

开源版可以用 OpenFGA 表达基础信任关系:

type user

type role
  relations
    define trusted: [user]
    define can_assume: trusted

写入关系:

user:alice trusted role:prod-admin

然后 STS 收到请求:

POST /assume-role
{
  "user": "user:alice",
  "role": "role:prod-admin"
}

它先问 OpenFGA:

Can user:alice can_assume role:prod-admin?

但这还不够。AWS trust policy 里常常还有 condition,比如 MFA、来源账号、外部 ID、设备、网络。这个部分更适合交给 OPA:

package iam.trust

default allow := false

allow if {
  input.relationship.allowed == true
  input.role == "role:prod-admin"
  input.context.mfa == true
  input.context.source == "vpn"
  input.context.ticket != ""
}

deny contains "assume prod-admin requires MFA" if {
  input.role == "role:prod-admin"
  input.context.mfa != true
}

所以 trust policy 的开源实现不是单点:

OpenFGA: 是否存在信任关系
OPA: 当前上下文是否允许 assume
STS: 通过后签发短期 token

Permission policy 怎么做

AWS permission policy 回答的是:

拿到这个身份以后,可以对哪些资源做哪些动作?

OpenFGA 可以表达 role 和 resource 的关系:

type user

type role
  relations
    define assignee: [user]

type secret
  relations
    define reader: [user, role#assignee]
    define deleter: [role#assignee]
    define can_read: reader
    define can_delete: deleter

写入关系:

user:alice assignee role:prod-admin
role:prod-admin#assignee deleter secret:prod-123_db-password

这表达的是“扮演 prod-admin 这个 role 的主体,可以删除这个 secret”。

但 permission policy 里的 condition、explicit deny、全局 guardrail 仍然更适合 OPA:

package iam.permission

default allow := false

allow if {
  input.relationship.allowed == true
  input.action == "secret.delete"
  input.resource.env == "prod"
  input.session.role == "role:prod-admin"
  input.context.mfa == true
  input.context.ticket != ""
}

deny contains "contractor cannot access confidential secrets" if {
  input.subject.type == "contractor"
  input.resource.classification == "confidential"
}

这就是 IAM-like 系统里最重要的分层:

relationship says: 有资格
policy says: 此时此地可以做
explicit deny says: 就算有资格也不行

AssumeRole 的最小流程

一个最小 STS 流程可以这样做:

1. Alice 通过 Keycloak 登录,拿到 user token。
2. Alice 调 STS:AssumeRole(role:prod-admin)。
3. STS 校验 user token。
4. STS 调 OpenFGA:user:alice can_assume role:prod-admin?
5. STS 调 OPA:MFA、VPN、ticket、risk 是否满足 trust policy?
6. 通过后,STS 签发 15 分钟短期 token。
7. 后续请求用这个 token 访问业务 API。

短期 token 里不要塞太多权限细节,只放身份和会话信息:

{
  "sub": "user:alice",
  "role": "role:prod-admin",
  "session_id": "sess_01HV...",
  "scope": ["secret.read", "secret.delete"],
  "iat": 1714380000,
  "exp": 1714380900
}

真正的授权仍然由 PEP 每次请求时调用 OpenFGA + OPA 决策。不要因为有了 STS token,就把它当万能钥匙。


四、OPA:把策略变成代码

OPA 的定位是 policy engine。你把输入交给它,它根据策略返回决策。这个决策可以用于 API 网关、Kubernetes admission、微服务接口、CI/CD、Terraform、数据访问、Agent 工具调用,等等。

它的核心形态很简单:

input + data + policy -> decision
  • input:本次请求,比如用户、动作、资源、HTTP method、环境。
  • data:外部数据,比如用户组、资源标签、风险等级。
  • policy:用 Rego 写的规则。
  • decision:允许、拒绝、原因、附加约束。

一个 OPA 小例子

假设我们有一个内部 API:

DELETE /projects/prod-123/secrets/db-password

请求上下文如下:

{
  "user": {
    "id": "alice",
    "role": "developer",
    "groups": ["team-a"]
  },
  "action": "secret.delete",
  "resource": {
    "type": "secret",
    "project": "prod-123",
    "env": "prod",
    "owner_group": "team-a"
  },
  "request": {
    "mfa": true,
    "source_ip": "10.0.1.23"
  }
}

一段 Rego 可以这样写:

package authz

default allow := false

allow if {
  input.user.role == "admin"
  input.request.mfa == true
}

allow if {
  input.action == "secret.read"
  input.resource.owner_group in input.user.groups
}

deny_reason contains "developers cannot delete production secrets" if {
  input.action == "secret.delete"
  input.resource.env == "prod"
  input.user.role == "developer"
}

这段策略表达了几件事:

  • admin 开了 MFA,可以通过;
  • 同组成员可以读 secret;
  • developer 删除生产 secret,明确拒绝并给原因。

OPA 的好处是:策略从业务代码里抽出来了。你可以 review、测试、版本化、灰度发布。授权不再是散落在 17 个 service 里的祖传 if

OPA 擅长什么

OPA 特别适合这些问题:

  • 这个请求是否满足环境约束?
  • 这个部署是否符合安全基线?
  • 这个 API 调用是否来自允许的网络、租户、设备?
  • 这个 Agent 是否能调用某个工具?
  • 这个 Terraform plan 是否允许创建公网资源?
  • 这个 Kubernetes Pod 是否允许使用 privileged mode?

它擅长 ABAC:Attribute-Based Access Control。也就是基于属性做决策。

例如:

  • 用户属性:部门、岗位、风险等级、是否 MFA。
  • 资源属性:环境、数据分级、owner、region。
  • 请求属性:来源 IP、时间、设备、ticket id。
  • 系统属性:是否生产环境、是否维护窗口、是否高危操作。

OPA 像一个铁面无私的门卫。它不会替你维护组织关系图,但你把证件、工牌、申请单、当前时间都递给它,它能按规则给你判断。


五、OpenFGA:把关系变成图

OpenFGA 解决的是另一类痛点:对象级授权。

比如:

  • Alice 能不能读 document:roadmap
  • Bob 能不能编辑 folder:finance 里的文件?
  • Carol 是不是 org:acme 的 admin?
  • Dave 能不能邀请别人加入 project:csms

这些问题只靠用户角色不够。因为权限来自关系:

  • Alice 是这个文档的 owner。
  • Bob 是这个 folder 的 viewer。
  • Carol 是这个 org 的 admin。
  • Dave 是项目 owner 所在 group 的 member。

OpenFGA 的模型来自 Google Zanzibar 一类思想。它用三元组描述关系:

user, relation, object

比如:

user:alice reader document:roadmap
user:bob member group:security
group:security#member viewer folder:prod-secrets
folder:prod-secrets parent organization:zoom

它回答的问题通常是:

Can user:alice read document:roadmap?

一个 OpenFGA 小例子

授权模型可以这样写:

model
  schema 1.1

type user

type group
  relations
    define member: [user]

type document
  relations
    define owner: [user]
    define viewer: [user, group#member]
    define editor: [user, group#member] or owner
    define can_read: viewer or editor or owner
    define can_write: editor or owner

然后写入关系 tuple:

{
  "user": "user:alice",
  "relation": "owner",
  "object": "document:roadmap"
}

或者:

{
  "user": "group:security#member",
  "relation": "viewer",
  "object": "document:incident-runbook"
}

检查权限时:

{
  "user": "user:alice",
  "relation": "can_read",
  "object": "document:roadmap"
}

OpenFGA 返回:

{
  "allowed": true
}

这就是它最强的地方:它不是在问 Alice 是不是 admin,而是在沿着关系图寻找一条授权路径。

OpenFGA 擅长什么

OpenFGA 特别适合:

  • 文档、项目、文件夹、组织、团队这种层级资源。
  • SaaS 多租户系统。
  • 用户与资源关系复杂,且经常变化。
  • 需要回答“某人对某对象是否有某关系”。
  • 需要 list objects:列出某用户可访问的对象。
  • 需要 explain:解释权限来自哪条关系链。

这类授权常叫 ReBAC:Relationship-Based Access Control。

如果 OPA 像门卫,OpenFGA 更像一本不断更新的通讯录和组织关系图。它知道谁属于哪个组,哪个组拥有哪个文档,哪个文档继承哪个 folder 的权限。


六、OPA 和 OpenFGA 到底怎么选

先给一个粗暴但有用的判断:

问题 更适合
“这个请求是否符合策略?” OPA
“这个用户和这个对象之间有没有关系?” OpenFGA
“生产环境删除操作必须 MFA + 工单” OPA
“Alice 是否能编辑 document:123?” OpenFGA
“Kubernetes Pod 是否允许 hostNetwork?” OPA
“项目 owner 是否继承 folder admin 权限?” OpenFGA
“高风险操作需要审批,且只能在维护窗口执行” OPA
“列出 Bob 能访问的所有文档” OpenFGA

一个常见误区是想让其中一个工具包打天下。

让 OPA 维护海量对象关系,可以做,但会很累。你要自己设计数据加载、缓存、增量同步、关系推导。最后你可能写出了半个 OpenFGA。

让 OpenFGA 做所有环境条件判断,也不自然。比如“来源 IP 必须是 VPN、设备风险分低于 30、生产删除必须在维护窗口、Agent 工具调用必须经过审批”,这些更像策略,不像关系。

所以我更推荐这样分工:

OPA:判断请求是否符合上下文策略
OpenFGA:判断主体是否和对象存在授权关系
应用 / 网关:负责执行拦截,也就是 PEP

这里有几个缩写值得记一下:

  • PEP:Policy Enforcement Point,策略执行点,比如 API Gateway、middleware、sidecar。
  • PDP:Policy Decision Point,策略决策点,比如 OPA、OpenFGA。
  • PIP:Policy Information Point,策略信息点,比如用户目录、资源标签、风控系统。
  • PAP:Policy Administration Point,策略管理点,比如策略仓库、授权后台。

不要被缩写吓到。人话版就是:

拦截请求的人,不一定是做决策的人;做决策的人,也不应该偷偷改业务数据。


七、一套组合架构:OPA 管条件,OpenFGA 管关系

假设我们做一个内部 Secret 管理系统。

需求是:

  • 项目 owner 可以读写本项目 secret。
  • 项目 viewer 只能读。
  • 生产环境 secret 删除必须开 MFA。
  • 删除生产 secret 必须带审批工单。
  • 外包用户不能访问 confidential 级别 secret。
  • Agent 只能在 sandbox 中调用 read-only 工具。

这时可以这样设计:

Client
  |
  v
API Gateway / Middleware  <-- PEP
  |
  +--> OPA:检查上下文策略
  |
  +--> OpenFGA:检查对象关系
  |
  v
Business Service

一次请求大概是:

{
  "subject": "user:alice",
  "action": "secret.delete",
  "object": "secret:prod-db-password",
  "context": {
    "env": "prod",
    "mfa": true,
    "ticket": "SEC-12345",
    "data_classification": "confidential",
    "source": "vpn"
  }
}

OpenFGA 先回答:

Can user:alice delete secret:prod-db-password?

OPA 再回答:

这个 delete 操作在 prod + confidential + 当前上下文下是否允许?

最后 PEP 合并结果:

allow = openfga.allowed && opa.allow && not opa.deny

为什么要分两步?

因为“你是不是这个对象的 owner”和“你现在能不能执行这个高危动作”不是同一个问题。

owner 也不应该随时随地删除生产密钥。就像你是房主,也不能在半夜三点把承重墙拆了,说“这是我家”。物业会来,楼上楼下也会来。


八、一个可运行的 HTTP Request 授权例子

上面讲了这么多模型,读者很容易点头:“嗯,有道理。”然后回到项目里,继续写:

if user.is_admin:
    ...

所以这里给一个最小可运行例子。我们用 docker-compose 一次性启动三类服务:

  • api:FastAPI 业务服务,也就是 PEP
  • opa:OPA 策略决策服务,判断上下文条件。
  • openfga:OpenFGA 关系授权服务,判断用户和对象之间的关系。

最终请求路径是:

HTTP Request -> FastAPI PEP -> OpenFGA Check -> OPA Decision -> allow / deny

这个 demo 的业务规则是:

  • alice 可以读取 secret:prod-123_db-password
  • admin 可以删除 secret:prod-123_db-password
  • 删除生产 secret 还必须满足:role=adminMFA=true、带审批工单。

1. 写 OPA 策略

新建 policy.rego

package http.authz

default allow := false

allow if {
  input.action == "secret.read"
  input.relationship.allowed == true
}

allow if {
  input.action == "secret.delete"
  input.relationship.allowed == true
  input.resource.env == "prod"
  input.user.role == "admin"
  input.request.mfa == true
  input.request.ticket != ""
}

deny contains "OpenFGA relationship check failed" if {
  input.relationship.allowed != true
}

deny contains "read requires can_read relationship" if {
  input.action == "secret.read"
  input.relationship.allowed != true
}

deny contains "production delete requires admin" if {
  input.action == "secret.delete"
  input.resource.env == "prod"
  input.user.role != "admin"
}

deny contains "production delete requires MFA" if {
  input.action == "secret.delete"
  input.resource.env == "prod"
  input.request.mfa != true
}

deny contains "production delete requires approved ticket" if {
  input.action == "secret.delete"
  input.resource.env == "prod"
  input.request.ticket == ""
}

decision := {
  "allow": allow,
  "deny": deny,
}

这个策略表达了两个规则:

  • 读 secret:OpenFGA 必须确认用户对 secret 有 can_read 关系。
  • 删除生产 secret:OpenFGA 必须确认 can_delete 关系,同时 OPA 要求 admin、MFA 和审批工单。

2. 写 FastAPI 业务服务

新建 requirements.txt

fastapi
uvicorn
httpx

新建 app.py

import os
import time
from typing import Any

import httpx
from fastapi import FastAPI, HTTPException, Request


OPA_URL = os.getenv(
    "OPA_URL",
    "http://opa:8181/v1/data/http/authz/decision",
)
OPENFGA_URL = os.getenv("OPENFGA_URL", "http://openfga:8080")

app = FastAPI()

FGA_STORE_ID = ""
FGA_MODEL_ID = ""


FGA_MODEL = {
    "schema_version": "1.1",
    "type_definitions": [
        {"type": "user"},
        {
            "type": "secret",
            "relations": {
                "reader": {"this": {}},
                "deleter": {"this": {}},
                "can_read": {
                    "union": {
                        "child": [
                            {"computedUserset": {"relation": "reader"}},
                            {"computedUserset": {"relation": "deleter"}},
                        ]
                    }
                },
                "can_delete": {"computedUserset": {"relation": "deleter"}},
            },
            "metadata": {
                "relations": {
                    "reader": {
                        "directly_related_user_types": [{"type": "user"}]
                    },
                    "deleter": {
                        "directly_related_user_types": [{"type": "user"}]
                    },
                }
            },
        },
    ],
}


FGA_TUPLES = [
    {
        "user": "user:alice",
        "relation": "reader",
        "object": "secret:prod-123_db-password",
    },
    {
        "user": "user:admin",
        "relation": "deleter",
        "object": "secret:prod-123_db-password",
    },
]


def secret_object(project: str, secret_name: str) -> str:
    return f"secret:{project}_{secret_name}"


def parse_bool(value: str | None) -> bool:
    return str(value).lower() in {"1", "true", "yes", "y"}


async def wait_for_openfga() -> None:
    for _ in range(30):
        try:
            async with httpx.AsyncClient(timeout=2.0) as client:
                response = await client.post(
                    f"{OPENFGA_URL}/stores",
                    json={"name": "authz-demo"},
                )
                response.raise_for_status()
                global FGA_STORE_ID
                FGA_STORE_ID = response.json()["id"]
                return
        except Exception:
            time.sleep(1)
    raise RuntimeError("OpenFGA is not ready")


async def init_openfga() -> None:
    await wait_for_openfga()

    async with httpx.AsyncClient(timeout=5.0) as client:
        model_response = await client.post(
            f"{OPENFGA_URL}/stores/{FGA_STORE_ID}/authorization-models",
            json=FGA_MODEL,
        )
        model_response.raise_for_status()

        global FGA_MODEL_ID
        FGA_MODEL_ID = model_response.json()["authorization_model_id"]

        tuple_response = await client.post(
            f"{OPENFGA_URL}/stores/{FGA_STORE_ID}/write",
            json={
                "authorization_model_id": FGA_MODEL_ID,
                "writes": {"tuple_keys": FGA_TUPLES},
            },
        )
        tuple_response.raise_for_status()


@app.on_event("startup")
async def startup() -> None:
    await init_openfga()


def build_authz_input(
    request: Request,
    project: str,
    secret_name: str,
    action: str,
) -> dict[str, Any]:
    return {
        "user": {
            "id": request.headers.get("x-user", "anonymous"),
            "role": request.headers.get("x-role", "developer"),
        },
        "action": action,
        "resource": {
            "type": "secret",
            "project": project,
            "name": secret_name,
            "env": "prod" if project.startswith("prod") else "dev",
            "object": secret_object(project, secret_name),
        },
        "request": {
            "mfa": parse_bool(request.headers.get("x-mfa")),
            "ticket": request.headers.get("x-ticket", ""),
            "source_ip": request.client.host if request.client else "",
        },
    }


async def check_openfga(input_doc: dict[str, Any]) -> dict[str, Any]:
    relation = "can_read"
    if input_doc["action"] == "secret.delete":
        relation = "can_delete"

    body = {
        "authorization_model_id": FGA_MODEL_ID,
        "tuple_key": {
            "user": f"user:{input_doc['user']['id']}",
            "relation": relation,
            "object": input_doc["resource"]["object"],
        },
    }

    async with httpx.AsyncClient(timeout=2.0) as client:
        response = await client.post(
            f"{OPENFGA_URL}/stores/{FGA_STORE_ID}/check",
            json=body,
        )
        response.raise_for_status()

    return {
        "relation": relation,
        "allowed": response.json().get("allowed", False),
    }


async def authorize(input_doc: dict[str, Any]) -> dict[str, Any]:
    input_doc["relationship"] = await check_openfga(input_doc)

    async with httpx.AsyncClient(timeout=2.0) as client:
        response = await client.post(OPA_URL, json={"input": input_doc})
        response.raise_for_status()

    decision = response.json().get("result", {})
    if not decision.get("allow", False):
        raise HTTPException(
            status_code=403,
            detail={
                "message": "forbidden",
                "decision": decision,
                "input": input_doc,
            },
        )

    return decision


@app.get("/projects/{project}/secrets/{secret_name}")
async def read_secret(project: str, secret_name: str, request: Request):
    input_doc = build_authz_input(request, project, secret_name, "secret.read")
    decision = await authorize(input_doc)
    return {
        "secret": f"{project}/{secret_name}",
        "value": "__REDACTED__",
        "decision": decision,
    }


@app.delete("/projects/{project}/secrets/{secret_name}")
async def delete_secret(project: str, secret_name: str, request: Request):
    input_doc = build_authz_input(request, project, secret_name, "secret.delete")
    decision = await authorize(input_doc)
    return {
        "deleted": f"{project}/{secret_name}",
        "decision": decision,
    }

这段代码启动时会自动初始化 OpenFGA:

  • 创建一个 store;
  • 写入 authorization model;
  • 写入两条关系 tuple:alice 能读,admin 能删;
  • 每个 HTTP 请求先调用 OpenFGA /check,再调用 OPA /decision

3. 写 Dockerfile

新建 Dockerfile

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

4. 写 docker-compose.yml

新建 docker-compose.yml

services:
  opa:
    image: openpolicyagent/opa:latest
    command:
      - run
      - --server
      - --addr
      - 0.0.0.0:8181
      - /policy.rego
    volumes:
      - ./policy.rego:/policy.rego:ro
    ports:
      - "8181:8181"

  openfga:
    image: openfga/openfga:latest
    command: ["run"]
    ports:
      - "8080:8080"
      - "8081:8081"
      - "3000:3000"

  api:
    build: .
    environment:
      OPA_URL: http://opa:8181/v1/data/http/authz/decision
      OPENFGA_URL: http://openfga:8080
    depends_on:
      - opa
      - openfga
    ports:
      - "8000:8000"

启动:

docker compose up --build

几个端口分别是:

  • 8000:FastAPI 业务 API。
  • 8181:OPA REST API。
  • 8080:OpenFGA HTTP API。
  • 3000:OpenFGA Playground。

5. 发 HTTP Request 验证授权

读 secret,alice 在 OpenFGA 里有 reader 关系,允许:

curl -i \
  -H 'X-User: alice' \
  -H 'X-Role: developer' \
  http://localhost:8000/projects/prod-123/secrets/db-password

预期是 200 OK

读 secret,bob 没有任何关系,OpenFGA check 不通过,拒绝:

curl -i \
  -H 'X-User: bob' \
  -H 'X-Role: developer' \
  http://localhost:8000/projects/prod-123/secrets/db-password

预期是 403 Forbidden,返回里会看到:

{
  "message": "forbidden",
  "decision": {
    "allow": false,
    "deny": [
      "OpenFGA relationship check failed",
      "read requires can_read relationship"
    ]
  }
}

删除生产 secret,developer 即使开了 MFA,也拒绝:

curl -i -X DELETE \
  -H 'X-User: alice' \
  -H 'X-Role: developer' \
  -H 'X-MFA: true' \
  -H 'X-Ticket: SEC-12345' \
  http://localhost:8000/projects/prod-123/secrets/db-password

预期是 403 Forbidden

删除生产 secret,admin + MFA + ticket,允许:

curl -i -X DELETE \
  -H 'X-User: admin' \
  -H 'X-Role: admin' \
  -H 'X-MFA: true' \
  -H 'X-Ticket: SEC-12345' \
  http://localhost:8000/projects/prod-123/secrets/db-password

预期是 200 OK

这个例子里,HTTP 请求没有直接进入业务逻辑。它先被 authorize() 拦住,变成标准的:

subject + action + resource + context

然后交给 OPA 判断。业务代码只关心“决策结果是什么”,不再把授权规则写成一堆散落的 if

6. 这个 demo 里三者怎么分工

这套 docker-compose 跑起来后,分工已经很接近真实系统:

FastAPI PEP
  -> OpenFGA check(user, can_read/can_delete, secret)
  -> OPA eval(subject/action/resource/context + relationship.allowed)
  -> allow / deny

也就是说:

  • OpenFGA 负责回答“这个用户和这个 secret 有没有授权关系”。
  • OPA 负责回答“在当前上下文里,这个动作是否允许”。
  • FastAPI / Gateway 负责拦截 HTTP request 并执行决策。

这就是 IAM 心智模型在自建系统里的落地版。


九、落地时最容易踩的坑

1. 把认证当授权

登录成功只说明“你是谁”,不说明“你能干什么”。

很多事故的根源就是:

JWT valid -> allow

这不叫授权,这叫验票后直接让乘客开火车。

正确做法是:

Authentication -> Identity
Authorization -> Decision

身份只是授权输入的一部分。

2. 把角色当万能钥匙

RBAC 很好,但单靠角色很快会失控。

adminsuper_adminproject_adminregional_admintemporary_adminreadonly_admin……最后 admin 像便利店会员卡,人人都有一张。

角色适合表达粗粒度职责;对象关系和上下文条件,要交给更细的模型。

3. 把策略写死在业务服务里

散落在代码里的授权逻辑,很难统一审计,也很难回答:

  • 哪些接口允许外包访问?
  • 哪些操作需要 MFA?
  • 某个用户为什么能看到这个文档?
  • 某条策略是谁改的,什么时候上线的?

OPA 和 OpenFGA 的价值之一,就是把授权逻辑变成可管理资产。

4. 忘了性能和缓存

授权是热路径。每个 API 都查两三个远程系统,延迟会很感人。

需要提前设计:

  • OPA sidecar / library / centralized service 怎么部署。
  • OpenFGA check 是否要批量调用。
  • 哪些决策能缓存,缓存 key 是什么。
  • 关系变更后如何失效。
  • 拒绝结果能不能缓存,缓存多久。

授权系统不能只在安全评审里显得优雅,还要在 p99 延迟里活下来。

5. 没有解释能力

一个好的授权系统,不只返回:

{ "allowed": false }

还应该告诉你:

{
  "allowed": false,
  "reason": "production delete requires approved ticket"
}

否则排障时大家只能围着屏幕念咒:“为什么 403?”


十、明天就能做的实施清单

如果你准备搭一个开源版 IAM,我建议按这个顺序来。别一上来就写策略编辑器,那个东西最容易让人误以为自己在造 IAM,实际上只是在造一个漂亮的 JSON 输入框。

  1. 先接身份源:用 Keycloak / Dex / ORY Hydra 接 OIDC / SAML,先解决“你是谁”。
  2. 接 workload identity:用 SPIFFE/SPIRE 给服务、Pod、Job、Agent 发 SVID,先解决“这个工作负载是谁”。
  3. 设计 role 和 resource 模型:用户、workload、角色、组织、项目、文档、secret、环境,分别是什么对象。
  4. 实现最小 STS:支持 AssumeRole / AssumeRoleWithSVID,签发 15 分钟短期 token,别发长期万能 token。
  5. 用 OpenFGA 表达 trust 和 resource relationship:谁能 assume role,role 对哪些资源有关系。
  6. 用 OPA 表达 condition 和 explicit deny:MFA、VPN、ticket、risk、维护窗口、高危操作、SPIFFE trust domain。
  7. 定义 PEP 位置:API Gateway、middleware、service interceptor,至少有一个统一入口。
  8. 策略版本化:Rego、FGA model、migration、tuple 写入都进 Git 或变更系统。
  9. 加测试:授权策略必须有单元测试,覆盖 allow 和 deny,不要只测阳光路径。
  10. 加解释和审计:记录 subject、workload、session、role、action、resource、decision、reason、policy version、request id。
  11. 做性能预算:明确每次授权 check 的延迟目标、缓存策略和降级策略。
  12. 先从一个高价值场景试点:比如 secret 管理、文档权限、Agent 工具调用,不要一口吃全公司。

一个最小的上线门槛可以是:

所有高危 API:
1. 必须经过统一 PEP;
2. 必须有 subject/workload/session/role/action/resource/context;
3. 服务间调用必须有可验证 workload identity,例如 SPIFFE ID;
4. 必须检查 OpenFGA relationship;
5. 必须检查 OPA condition 和 explicit deny;
6. 必须记录 allow/deny 和 reason;
7. deny 默认安全,不允许策略服务失败时放行;
8. 策略变更必须可 review、可回滚。

这几条不华丽,但够硬。


总结

AWS IAM 给我们的启发,不是“所有系统都要写 JSON policy”,而是:授权应该被建模、被版本化、被审计、被测试,还要有短期凭证和清晰的信任边界。

如果用开源组件搭一个 IAM-like 系统,我会把它拆成几块:

  • Keycloak / Dex 负责身份登录。
  • SPIFFE / SPIRE 负责工作负载身份和服务间 mTLS。
  • STS 负责 AssumeRole 和短期 role session。
  • OpenFGA 负责 trust relationship 和 resource relationship。
  • OPA 负责 permission policy、condition、explicit deny 和 guardrail。
  • PEP 负责在网关或服务入口真正拦截请求。
  • Audit 负责把每一次授权判断变成可追溯证据。

OpenFGA、OPA 和 SPIRE 不是互相替代,而是回答不同问题。OpenFGA 问“这个主体和对象之间有授权关系吗?”OPA 问“这个请求符合当前策略和条件吗?”SPIRE 问“这个工作负载是不是它声称的那个工作负载?”STS 则回答“这个主体现在能不能临时扮演某个角色?”

真正成熟的授权系统,不是把所有人都变成 admin,也不是把所有判断都塞进业务代码。它应该像一个可靠的门禁系统:知道谁来了,知道他临时拿了哪张工牌,知道他要去哪,知道现在是不是合适的时间,也知道出了问题该翻哪本账。

思维导图

@startmindmap
* OSS IAM-like 授权系统
** AWS IAM 心智模型
*** Principal
*** Role / AssumeRole
*** Trust Policy
*** Permission Policy
*** Resource Policy
*** Condition / Explicit Deny
*** CloudTrail
** 开源组件
*** Keycloak / Dex / ORY
**** 身份登录和联邦
*** SPIFFE / SPIRE
**** workload identity
**** X.509-SVID / JWT-SVID
**** mTLS
*** 自建 STS
**** AssumeRole
**** AssumeRoleWithSVID
**** 短期 session token
*** OpenFGA
**** can_assume trust relationship
**** resource relationship
**** Check / Batch Check
*** OPA
**** permission policy
**** condition
**** explicit deny
**** guardrail
*** PEP
**** API Gateway
**** middleware
*** Audit
**** decision log
**** reason / policy version
** 授权流程
*** Login
*** Workload gets SVID
*** AssumeRole
*** STS verifies SPIFFE ID
*** STS check OpenFGA
*** STS eval OPA trust policy
*** Issue short-lived token
*** API PEP checks OpenFGA + OPA
** 可运行 HTTP 示例
*** docker-compose 启动 api / opa / openfga
*** FastAPI 作为 PEP
*** OpenFGA 初始化关系模型
*** OPA REST API 决策
*** curl 验证 200 / 403
** 落地要点
*** 先接身份源
*** 接 SPIFFE/SPIRE
*** 设计 role/resource 模型
*** 最小 STS
*** 策略版本化
*** 单元测试
*** 审计与解释
*** 性能与缓存
** 常见坑
*** 认证当授权
*** 长期 token 当 session
*** OpenFGA 承担所有条件策略
*** OPA 变成关系数据库
*** 策略服务失败时默认放行
@endmindmap

OSS IAM-like 授权系统思维导图

扩展阅读


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。