@@ -0,0 +1,436 @@
# SPIRE 与 OpenBao workload identity runbook
本文记录 homelab 中 workload 如何取得 SPIFFE 身份、如何把 JWT-SVID 交换成
OpenBao 短期 token,以及相关的部署、接入、验证和恢复操作。这里的命令默认在
`laptop` 上执行。
## 1. 当前架构
``` text
Kubernetes Pod
│ Pod ServiceAccount + Pod UID
▼
SPIRE Agent(每节点 DaemonSet, k8s workload attestor)
│ Unix socket: /spiffe-workload-api/spire-agent.sock
│ Agent 自身通过 k8s_psat 向 Server 证明节点身份
▼
SPIRE Server( trust domain: ddupan.top)
├─ registration state → shared PostgreSQL
├─ signing keys → localpv-zfs-ceph PVC
└─ JWT public keys → OIDC Discovery Provider
│
▼
https://spire-oidc.ad.ddupan.top
│ discovery + JWKS
▼
OpenBao auth/jwt-spire/login
│ exact sub + aud + role
▼
短期、最小权限 Bao token
```
各层职责必须保持分离:
- Kubernetes ServiceAccount 是 Pod 的初始身份证明,不是跨平台 IAM token;
- SPIRE 负责 workload 身份、证明和 SVID 签发,不保存业务 secret;
- OpenBao 验证 JWT-SVID,并把身份映射为本地 policy;
- 目标服务最终仍负责自己的资源授权,SPIFFE ID 本身不等于权限;
- 人类身份继续使用 Samba AD + Authelia OIDC,不经过 `jwt-spire` 。
## 2. 线上对象与稳定标识
| 项目 | 当前值 |
|---|---|
| SPIRE chart | `0.30.2` |
| SPIRE | `1.15.3` |
| SPIRE CRDs chart | `0.6.1` |
| trust domain | `ddupan.top` |
| Kubernetes cluster name | `homelab` |
| Controller Manager class | `spire-mgmt-spire` |
| JWT issuer | `https://spire-oidc.ad.ddupan.top` |
| OpenBao auth mount | `jwt-spire` |
| OpenBao login endpoint | `auth/jwt-spire/login` |
| SPIRE Server namespace | `spire-server` |
| Agent/CSI namespace | `spire-system` |
| Helm management namespace | `spire-mgmt` |
`trustDomain` 、`clusterName` 、issuer URL 与 Controller Manager class 都进入身份或
下游信任配置。修改它们不是普通 rename,必须按 trust-domain migration 处理。
## 3. 身份与授权模型
Kubernetes workload 的默认 SPIFFE ID 约定为:
``` text
spiffe://ddupan.top/ns/<namespace>/sa/<service-account>
```
身份必须同时在两侧声明:
1. SPIRE `ClusterSPIFFEID` 决定哪些 Pod 可以取得该身份;
2. OpenBao JWT role 决定该 `sub` 、`aud` 能换取哪些 policy。
这两个声明是有意的双重门:只有 SPIRE entry 而没有 Bao role 时,workload 能取得
SVID,但不能登录 Bao;只有 Bao role 而没有 SPIRE entry 时,没有 workload 能铸造
满足条件的 JWT。
禁止使用以下宽泛规则:
- 给所有 Pod 启用 fallback `ClusterSPIFFEID` ;
- OpenBao role 接受整个 `spiffe://ddupan.top/*` ;
- 仅按 namespace 匹配高权限身份,却不限制 ServiceAccount 和 Pod labels;
- 多个安全边界不同的 workload 共用同一个 ServiceAccount;
- 给 workload token 附带 `default` 或 `admin` policy。
## 4. 新 workload 接入流程
以下示例为 namespace `example` 中的 ServiceAccount `example-worker` 。
### 4.1 创建专用 ServiceAccount
``` yaml
apiVersion : v1
kind : ServiceAccount
metadata :
name : example-worker
namespace : example
```
不要使用 namespace 的 `default` ServiceAccount。
### 4.2 声明 ClusterSPIFFEID
``` yaml
apiVersion : spire.spiffe.io/v1alpha1
kind : ClusterSPIFFEID
metadata :
name : example-worker
labels :
spire.spiffe.io/class-name : spire-mgmt-spire
spec :
className : spire-mgmt-spire
namespaceSelector :
matchLabels :
kubernetes.io/metadata.name : example
podSelector :
matchLabels :
app.kubernetes.io/name : example-worker
spiffeIDTemplate : "spiffe://{{ .TrustDomain }}/ns/{{ .PodMeta.Namespace }}/sa/{{ .PodSpec.ServiceAccountName }}"
```
Controller Manager 最终会为具体 Pod UID 创建 registration entry。检查状态:
``` bash
sudo k3s kubectl get clusterspiffeid example-worker -o yaml
sudo k3s kubectl -n spire-server exec statefulset/spire-server -c spire-server -- \
/opt/spire/bin/spire-server entry show \
-spiffeID spiffe://ddupan.top/ns/example/sa/example-worker
```
`status.stats.entryFailures` 必须是 `0` 。Pod 重建后 UID 会变化,短暂看到旧 entry
属于正常收敛过程。
### 4.3 挂载 Workload API
``` yaml
spec :
serviceAccountName : example-worker
containers :
- name : worker
volumeMounts :
- name : spiffe-workload-api
mountPath : /spiffe-workload-api
readOnly : true
env :
- name : SPIFFE_ENDPOINT_SOCKET
value : unix:///spiffe-workload-api/spire-agent.sock
volumes :
- name : spiffe-workload-api
csi :
driver : csi.spiffe.io
readOnly : true
```
挂载 socket 不会自动获得身份。Agent 会对调用进程执行 workload attestation,只有
selector 命中 registration entry 才签发 SVID。
应用应优先使用 SPIFFE SDK,通过 Workload API 按需取得并自动轮换 SVID。不要把
JWT-SVID 写入 Kubernetes Secret、镜像、持久卷、CI artifact 或日志。
### 4.4 声明 OpenBao policy
在 `infrastructure/openbao/terraform/policies/` 中为 workload 建独立 policy。例如:
``` hcl
path "kv/data/apps/example/*" {
capabilities = [ "read" ]
}
path "auth/token/lookup-self" {
capabilities = [ "read" ]
}
path "auth/token/revoke-self" {
capabilities = [ "update" ]
}
```
不要直接复用 `admin` 。动态数据库凭据、SSH 签名和 KV 应分别授权到精确 path。
### 4.5 声明 OpenBao JWT role
在 `infrastructure/openbao/terraform/auth-spire.tf` 增加 role:
``` hcl
resource "vault_jwt_auth_backend_role" "example_worker" {
backend = vault_jwt_auth_backend . spire . path
role_name = "example-worker"
role_type = "jwt"
user_claim = "sub"
bound_audiences = [ "openbao" ]
bound_claims = {
sub = "spiffe://ddupan.top/ns/example/sa/example-worker"
}
token_policies = [ vault_policy . example_worker . name ]
token_no_default_policy = true
token_ttl = 300
token_max_ttl = 900
}
```
同一个 JWT 可以请求多个 audience,但 Bao role 只接受 `openbao` 。未来接入其他服务时
应给对应服务使用独立 audience,不能把 `openbao` 当作通用 audience。
Terraform apply 必须遵循 `infrastructure/openbao/README.md` 的 remote-state 和认证
流程。任何包含 destroy/replace 的 plan 都应停止审查;JWT role/policy 的正常新增应为
纯 `add` 。
## 5. JWT-SVID 交换流程
逻辑请求如下:
``` http
P O S T / v 1 / a u t h / j w t - s p i r e / l o g i n
C o n t e n t - T y p e : a p p l i c a t i o n / j s o n
{
" r o l e " : " e x a m p l e - w o r k e r " ,
" j w t " : " < a u d = o p e n b a o 的 J W T - S V I D > "
}
```
成功响应中的 `auth.client_token` 是短期 Bao token。它只应存在于进程内存或
job-scoped `tmpfs` ;通常无需主动续期,过期前重新用 Workload API 获取 JWT-SVID 并
登录即可。
如果使用 SPIRE CLI 调试,`1.15.3` 的 JSON 输出顶层是数组,JWT 位于:
``` text
.[0].svids[0].svid
```
调试脚本必须把 JSON 捕获到变量中,禁止直接输出:
``` bash
set -euo pipefail
JWT_RESPONSE = " $( spire-agent api fetch jwt \
-audience openbao \
-socketPath /spiffe-workload-api/spire-agent.sock \
-output json) "
JWT_SVID = " $( printf '%s' " $JWT_RESPONSE " | jq -er '.[0].svids[0].svid' ) "
LOGIN_PAYLOAD = " $( jq -nc \
--arg role example-worker \
--arg jwt " $JWT_SVID " \
'{role:$role,jwt:$jwt}' ) "
LOGIN_RESPONSE = " $( curl --fail-with-body --silent --show-error \
-H 'Content-Type: application/json' \
--data " $LOGIN_PAYLOAD " \
https://bao.ad.ddupan.top:8200/v1/auth/jwt-spire/login) "
export BAO_ADDR = https://bao.ad.ddupan.top:8200
export BAO_TOKEN = " $( printf '%s' " $LOGIN_RESPONSE " | jq -er '.auth.client_token' ) "
```
不要在 shell 中启用 `set -x` ,不要 `echo "$JWT_SVID"` 或输出完整 login response。
cleanup 阶段可以尽力主动吊销;短 TTL 仍是主要安全边界:
``` bash
bao token revoke -self
unset BAO_TOKEN JWT_SVID JWT_RESPONSE LOGIN_RESPONSE LOGIN_PAYLOAD
```
## 6. CI 与 AI Agent 使用方式
CI job/Agent 不应接收长期 `BAO_TOKEN` 。标准启动顺序是:
1. 调度到带 SPIRE Agent 与 CSI Driver 的节点;
2. 以专用 ServiceAccount 启动,挂载 Workload API socket;
3. 获取目标 audience 的 JWT-SVID;
4. 用对应 Bao role 换取短期 token;
5. 在同一进程树中以环境变量调用 `tofu` 、Ansible 或其他工具;
6. cleanup 尝试 `revoke-self` ,随后销毁 job/VM/容器。
通用 credential-exec 包装器未来应负责步骤 3–6。它必须满足:
- 不把 JWT-SVID 或 Bao token 写到 stdout/stderr;
- 不把凭据传入命令行参数,避免出现在进程列表;
- 子进程退出后清除环境和临时文件;
- 不尝试把短期 token 上传到 Actions Secret 或 artifact;
- role、audience 和目标命令由受审查的 pipeline 配置决定。
Kubernetes 以外的执行环境不能伪造 ServiceAccount。未来应分别使用 host SPIRE
Agent、TPM/DevID、cloud instance identity、GitHub OIDC 等初始证明接入同一信任模型。
## 7. 日常检查
### Flux 与 Helm
``` bash
sudo k3s kubectl -n flux-system get kustomization spire
sudo k3s kubectl -n spire-mgmt get helmrepository,helmrelease
```
### Server、Agent 与 CSI
``` bash
sudo k3s kubectl -n spire-server get pods,pvc
sudo k3s kubectl -n spire-system get daemonset,pods
sudo k3s kubectl -n spire-server exec statefulset/spire-server -c spire-server -- \
/opt/spire/bin/spire-server agent list
```
Agent 应显示 `Attestation type: k8s_psat` 与 `Can re-attest: true` 。
### OIDC discovery 与 JWKS
``` bash
dig @192.168.10.5 spire-oidc.ad.ddupan.top A +short
dig @192.168.10.127 spire-oidc.ad.ddupan.top A +short
curl --fail --silent \
https://spire-oidc.ad.ddupan.top/.well-known/openid-configuration | jq
curl --fail --silent https://spire-oidc.ad.ddupan.top/keys | jq '.keys | length'
```
discovery 的 `issuer` 必须严格等于
`https://spire-oidc.ad.ddupan.top` 。路径、scheme、hostname 或尾部 `/` 的差异都会
导致 JWT 验证失败。JWKS 是公开验证材料,不是 secret。
### OpenBao
管理员只检查非敏感配置:
``` bash
export BAO_ADDR = https://bao.ad.ddupan.top:8200
bao auth list
bao read auth/jwt-spire/role/<role-name>
bao policy read <policy-name>
```
## 8. 故障排查
### `no identity issued`
依次检查:
``` bash
sudo k3s kubectl get clusterspiffeid <name> -o yaml
sudo k3s kubectl -n <namespace> get pod <pod> \
-o custom-columns= NAME:.metadata.name,UID:.metadata.uid,SA:.spec.serviceAccountName,LABELS:.metadata.labels
sudo k3s kubectl -n spire-server exec statefulset/spire-server -c spire-server -- \
/opt/spire/bin/spire-server entry show -spiffeID <expected-spiffe-id>
```
确认 namespace、ServiceAccount、Pod labels 与 entry 中的 `k8s:pod-uid` 。刚启动的
一次性 Job 可能在 Controller Manager 建 entry 前请求身份并失败;生产客户端应重试
Workload API,而不是假设 Pod 一启动身份就已可用。
### `auth/jwt-spire/login` 返回 400
常见原因:
- JWT audience 不是 role 的 `bound_audiences` ;
- JWT `sub` 与 role 的 `bound_claims.sub` 不完全一致;
- issuer 与 `bound_issuer` 不一致;
- Bao 无法解析或验证 `spire-oidc.ad.ddupan.top` ;
- issuer/JWKS route 被 Authelia forward-auth 拦截;
- SVID 已过期,或者节点与 Bao 时钟偏差过大;
- 脚本错误解析 CLI JSON,向 Bao 发送了空 JWT。
排查时只解码 JWT header/claims,禁止记录原始 token。公开 endpoint 可单独验证:
``` bash
curl --fail https://spire-oidc.ad.ddupan.top/.well-known/openid-configuration
curl --fail https://spire-oidc.ad.ddupan.top/keys
```
### CSI mount 失败
如果事件包含 `driver name csi.spiffe.io not found` :
``` bash
sudo k3s kubectl get csidriver csi.spiffe.io
sudo k3s kubectl -n spire-system get daemonset spire-spiffe-csi-driver
sudo k3s kubectl -n spire-system logs daemonset/spire-spiffe-csi-driver \
-c spiffe-csi-driver --tail= 100
```
首次安装时 OIDC Provider/业务 Pod 可能早于 CSI registration,短暂 mount retry 正常;
持续失败才需要处理。
### Server 无法连接 PostgreSQL
``` bash
sudo k3s kubectl -n shared-db get cluster shared-postgresql
sudo k3s kubectl -n spire-server get secret spire-postgresql \
-o go-template= '{{range $k, $_ := .data}}{{$k}}{{"\n"}}{{end}}'
sudo k3s kubectl -n spire-server logs statefulset/spire-server \
-c spire-server --tail= 100
```
Secret 必须有 `password` key。禁止为排障直接输出或提交其值。
## 9. 轮换、备份与恢复
SPIRE 会自动轮换 X.509 CA 与 JWT signing key; OIDC Provider 从 Workload API 获取
当前 JWKS,下游按 `kid` 验证。正常轮换不应要求更新 Bao role。
必须备份两类不同状态:
- PostgreSQL: registration entries、agent state 和 SPIRE metadata;
- `spire-data-spire-server-0` PVC: disk KeyManager 的 trust-domain signing keys。
仅恢复 PostgreSQL 而丢失 PVC,不等于恢复 SPIRE。签名密钥丢失会使既有 SVID/JWKS
信任链失效。恢复顺序:
1. 恢复共享 PostgreSQL;
2. 恢复 signing-key PVC;
3. 启动 SPIRE Server;
4. 确认 bundle/JWKS 后启动或恢复 Agent;
5. 最后恢复依赖 SPIRE 登录 Bao 的 workload。
不要以空数据库或空 PVC“修复”启动失败。若确实需要重建 trust domain,应把它作为
全体下游重新建立信任的灾难恢复事件处理。
## 10. 当前 PoC 结论与后续工作
2026-09-14 已完成并清理一次临时 PoC:
- workload 取得 `aud=openbao` JWT-SVID;
- 精确 subject 成功登录 `jwt-spire/spire-poc` ;
- 返回 token 仅含 `spire-poc` policy, TTL 为 300 秒,无 default policy;
- `lookup-self` 成功,随后 `revoke-self` 并验证 token 已失效;
- 临时 Namespace、Pod/Job、ServiceAccount、`ClusterSPIFFEID` 与 registration entries
均已删除;
- Terraform 完整 plan 最终为 `No changes` 。
下一步不是重复 PoC,而是为真实 CI/AI Agent 定义:
- 独立 ServiceAccount 和稳定 SPIFFE ID;
- 按能力拆分的 OpenBao policy(例如 SSH CA、S3 state、数据库动态凭据);
- 通用 credential-exec 包装器;
- token 获取失败、过期与 cleanup 的客户端重试语义;
- Kubernetes 外 host/VM/microVM 的 SPIRE Agent attestation 方案。