用开源组件搭一个 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:GetObject、ec2: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-gateway、agent-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=admin、MFA=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 很好,但单靠角色很快会失控。
admin、super_admin、project_admin、regional_admin、temporary_admin、readonly_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 输入框。
- 先接身份源:用 Keycloak / Dex / ORY Hydra 接 OIDC / SAML,先解决“你是谁”。
- 接 workload identity:用 SPIFFE/SPIRE 给服务、Pod、Job、Agent 发 SVID,先解决“这个工作负载是谁”。
- 设计 role 和 resource 模型:用户、workload、角色、组织、项目、文档、secret、环境,分别是什么对象。
- 实现最小 STS:支持
AssumeRole/AssumeRoleWithSVID,签发 15 分钟短期 token,别发长期万能 token。 - 用 OpenFGA 表达 trust 和 resource relationship:谁能 assume role,role 对哪些资源有关系。
- 用 OPA 表达 condition 和 explicit deny:MFA、VPN、ticket、risk、维护窗口、高危操作、SPIFFE trust domain。
- 定义 PEP 位置:API Gateway、middleware、service interceptor,至少有一个统一入口。
- 策略版本化:Rego、FGA model、migration、tuple 写入都进 Git 或变更系统。
- 加测试:授权策略必须有单元测试,覆盖 allow 和 deny,不要只测阳光路径。
- 加解释和审计:记录 subject、workload、session、role、action、resource、decision、reason、policy version、request id。
- 做性能预算:明确每次授权 check 的延迟目标、缓存策略和降级策略。
- 先从一个高价值场景试点:比如 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

扩展阅读
- AWS IAM: Policies and permissions
- Open Policy Agent Documentation
- OPA Policy Language
- OpenFGA Concepts
- OpenFGA Modeling: Getting Started
- OpenFGA Perform a Check
- SPIFFE Overview
- SPIRE Concepts
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。