一个共享密钥让订单服务读走了库存
2026 年 8 月,arch-lab 的服务间调用还在用一把 INTERNAL_API_KEY:所有服务在请求头里带同一个值,网关只检查它是否存在。订单服务的一个调试脚本用这把密钥调用了库存的批量导出接口,读走了 12 个租户的库存快照。审计日志只记下“内部调用”,无法区分是订单服务、报表任务还是某台跳板机。
更麻烦的是轮换。这把密钥在开发、预发、生产三个环境共用,换一次要同时改 14 个部署,于是没人愿意换;密钥已经在聊天记录和 CI 变量里各留了一份。事故之后团队才承认:共享密钥既没有回答“你是谁”,也没有回答“你能做什么”,它只证明了“你拿到过这个字符串”。
这一节把问题拆成两道独立的门:认证确认调用方是哪个 workload,授权确认这个 workload 能否对指定资源执行指定动作。零信任的整体原则见零信任架构 ,工作负载身份的基础概念见服务身份:认证、授权与工作负载身份 。
凭据形态决定泄漏半径
| 凭据 | 能证明什么 | 泄漏半径 | 轮换成本 | 适用场景 |
|---|---|---|---|---|
| 共享密钥 | 持有字符串 | 所有服务可互相冒用 | 高,需同步改所有部署 | 仅本地开发 |
| HMAC 令牌 | 签发者签发的 claims | 单个服务、限时 | 中,换签名密钥 | 单集群内调用 |
| mTLS/SPIFFE | workload 证书身份 | 单个 workload | 低到中,证书自动轮换 | 跨集群生产 |
| OIDC/JWT + JWKS | 身份提供方签发 | 限时、可撤销 | 低 | 跨组织 |
共享密钥的问题不是“容易泄漏”,而是泄漏后无法定位、无法限权、无法单独吊销。HMAC 令牌把主体、受众、租户、动作和有效期签进 payload,泄漏后至少能知道是谁、能用到什么时候;代价是签名密钥仍然是对称的,签发方和验证方都能伪造。生产环境更合适的是 mTLS/SPIFFE 或非对称 JWT,本节用 HMAC 是为了只用标准库把校验逻辑跑通。
令牌必须绑定受众。库存服务只接受 aud=inventory 的令牌,订单服务拿到的令牌即使被转发给支付服务也会被拒绝。有效期要短,IssuedAt 与 ExpiresAt 之间通常只留几分钟,并允许有界时钟漂移;没有受众或没有过期的令牌等于永久通行证。
授权是两道门,不是一张更大的白名单
第一道门是令牌里的 Actions:它是 workload 自己被授予的动作集合,代表“这个身份原则上能做什么”。第二道门是策略规则:它代表“当前服务在这条调用链上允许做什么”。两次都通过才放行。只查令牌会让一个被盗令牌按它的全部授权使用;只查策略则会让任何持有合法签名的服务都获得同一套权限。
拒绝必须优先于允许。只要有一条 deny 规则匹配,即使前面已经匹配到 allow,结果也是拒绝;没有任何规则匹配时默认拒绝。资源前缀要按路径边界匹配,/reservations/ 不能匹配 /reservations-secret/1,否则一个前缀规则会悄悄扩大到一个从未审计的接口。
| 请求 | 令牌动作 | 策略 | 结果 |
|---|---|---|---|
订单服务读 /reservations/order-1 | 有 reservation:read | allow | 放行 |
订单服务读 /reservations/secret/1 | 有 reservation:read | deny 命中 | 拒绝 |
订单服务读 /reservations-secret/1 | 有 reservation:read | 前缀不匹配 | 拒绝 |
订单服务以 globex 租户身份读 | 有动作 | 身份租户是 acme | 拒绝 |
平台服务读 /platform/tenants/acme | * | allow * | 放行 |
可运行实现
下面的 identity.go 与 10.1、10.3 共用 tenant 包。SignToken/VerifyToken 负责签名、受众、时间和未来签发检查,Policy.Authorize 负责双门授权与拒绝优先。
// file: internal/platform/tenant/identity.go
// tier: L1
// verify: go test ./internal/platform/tenant/ -count=1
package tenant
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
var (
ErrTokenInvalid = errors.New("identity: invalid token")
ErrTokenExpired = errors.New("identity: token expired")
ErrAudience = errors.New("identity: audience mismatch")
)
// Claims is the signed identity of a calling workload. Actions are the
// workload's own grant; the policy engine checks them again.
type Claims struct {
Subject string `json:"sub"`
Audience string `json:"aud"`
Tenant string `json:"tenant"`
Actions []string `json:"actions"`
IssuedAt time.Time `json:"iat"`
ExpiresAt time.Time `json:"exp"`
}
func macPayload(payload, key []byte) []byte {
mac := hmac.New(sha256.New, key)
mac.Write(payload)
return mac.Sum(nil)
}
// SignToken serializes claims and signs them with HMAC-SHA256. The encoding is
// payload.signature, both base64url without padding.
func SignToken(c Claims, key []byte) (string, error) {
if len(key) == 0 {
return "", errors.New("identity: signing key is empty")
}
if strings.TrimSpace(c.Subject) == "" || strings.TrimSpace(c.Audience) == "" {
return "", errors.New("identity: subject and audience are required")
}
if c.ExpiresAt.IsZero() || !c.ExpiresAt.After(c.IssuedAt) {
return "", errors.New("identity: expiry must be after issue time")
}
payload, err := json.Marshal(c)
if err != nil {
return "", err
}
sig := macPayload(payload, key)
return base64.RawURLEncoding.EncodeToString(payload) + "." +
base64.RawURLEncoding.EncodeToString(sig), nil
}
// VerifyToken checks signature, audience, issue time and expiry. skew allows
// bounded clock drift between the issuer and this process.
func VerifyToken(raw string, key []byte, audience string, now time.Time, skew time.Duration) (Claims, error) {
parts := strings.Split(raw, ".")
if len(parts) != 2 {
return Claims{}, ErrTokenInvalid
}
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return Claims{}, fmt.Errorf("%w: payload encoding", ErrTokenInvalid)
}
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return Claims{}, fmt.Errorf("%w: signature encoding", ErrTokenInvalid)
}
if !hmac.Equal(macPayload(payload, key), sig) {
return Claims{}, fmt.Errorf("%w: signature mismatch", ErrTokenInvalid)
}
var c Claims
if err := json.Unmarshal(payload, &c); err != nil {
return Claims{}, fmt.Errorf("%w: payload json", ErrTokenInvalid)
}
if strings.TrimSpace(c.Subject) == "" || strings.TrimSpace(c.Audience) == "" {
return Claims{}, fmt.Errorf("%w: missing subject or audience", ErrTokenInvalid)
}
if audience == "" || c.Audience != audience {
return Claims{}, fmt.Errorf("%w: want %q got %q", ErrAudience, audience, c.Audience)
}
if !now.Before(c.ExpiresAt.Add(skew)) {
return Claims{}, fmt.Errorf("%w: expired at %s", ErrTokenExpired, c.ExpiresAt.Format(time.RFC3339))
}
if c.IssuedAt.After(now.Add(skew)) {
return Claims{}, fmt.Errorf("%w: issued in the future", ErrTokenInvalid)
}
return c, nil
}
// Effect is the outcome declared by one policy rule.
type Effect string
const (
Allow Effect = "allow"
Deny Effect = "deny"
)
// Rule grants or denies a service a set of actions on a resource prefix. An
// empty or "*" field matches everything.
type Rule struct {
Service string
Actions []string
ResourcePrefix string
Tenant string
Effect Effect
}
// Policy is an ordered rule set, but deny always wins over allow.
type Policy struct {
Rules []Rule
}
// Decision is a fully explained authorization result. Rule is -1 when no rule
// matched or the request was rejected before evaluation.
type Decision struct {
Allowed bool
Reason string
Rule int
}
func matchValue(pattern, value string) bool {
return pattern == "" || pattern == "*" || pattern == value
}
func matchAction(actions []string, action string) bool {
for _, a := range actions {
if a == "*" || a == action {
return true
}
}
return false
}
// matchResource requires a path boundary: /orders/ must not match
// /orders-secret, but /orders must match /orders/123.
func matchResource(prefix, resource string) bool {
if prefix == "" || prefix == "*" || resource == prefix {
return true
}
base := strings.TrimSuffix(prefix, "/")
return base != "" && strings.HasPrefix(resource, base+"/")
}
// Authorize enforces two independent gates: the action must be in the signed
// identity grant, and an allow rule must match after all deny rules are
// considered. Absence of a rule is a denial, never an implicit allow.
func (p Policy) Authorize(c Claims, action, resource, tenantID string) Decision {
if c.Subject == "" || action == "" || resource == "" || tenantID == "" {
return Decision{Reason: "incomplete request", Rule: -1}
}
if c.Tenant != "*" && c.Tenant != tenantID {
return Decision{Reason: "identity tenant does not match request tenant", Rule: -1}
}
if !matchAction(c.Actions, action) {
return Decision{Reason: "action is not in the identity grant", Rule: -1}
}
allowed := -1
for i, r := range p.Rules {
if !matchValue(r.Service, c.Subject) || !matchValue(r.Tenant, tenantID) {
continue
}
if !matchAction(r.Actions, action) || !matchResource(r.ResourcePrefix, resource) {
continue
}
if r.Effect == Deny {
return Decision{Reason: fmt.Sprintf("deny rule %d matched", i), Rule: i}
}
if r.Effect == Allow && allowed < 0 {
allowed = i
}
}
if allowed >= 0 {
return Decision{Allowed: true, Reason: fmt.Sprintf("allow rule %d matched", allowed), Rule: allowed}
}
return Decision{Reason: "no allow rule matched", Rule: -1}
}
三个设计点值得说明。第一,VerifyToken 先验签再解析 JSON,攻击者无法通过畸形 payload 触发解析分支;签名比较用 hmac.Equal,避免逐字节提前返回。第二,受众是必填参数,空字符串不会被当成“跳过检查”。第三,Authorize 把租户匹配放在规则之前,平台服务必须显式持有 Tenant: "*" 才能跨租户,普通服务不能靠一条宽泛规则绕过自己的租户边界。
测试用固定时钟覆盖篡改、换密钥、错误受众、过期、未来签发,以及允许、拒绝优先、前缀边界、租户不匹配、动作越权和默认拒绝。
// file: internal/platform/tenant/identity_test.go
// tier: L1
// verify: go test ./internal/platform/tenant/ -count=1
package tenant
import (
"encoding/base64"
"errors"
"strings"
"testing"
"time"
)
var fixedNow = time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC)
func testClaims() Claims {
return Claims{
Subject: "order-api", Audience: "inventory", Tenant: "acme",
Actions: []string{"reservation:read", "reservation:release"},
IssuedAt: fixedNow.Add(-time.Minute), ExpiresAt: fixedNow.Add(time.Minute),
}
}
func TestTokenVerify(t *testing.T) {
key := []byte("arch-lab-key")
raw, err := SignToken(testClaims(), key)
if err != nil {
t.Fatalf("SignToken: %v", err)
}
got, err := VerifyToken(raw, key, "inventory", fixedNow, 0)
if err != nil {
t.Fatalf("VerifyToken: %v", err)
}
if got.Subject != "order-api" || got.Tenant != "acme" || len(got.Actions) != 2 {
t.Fatalf("claims=%+v", got)
}
if _, err := VerifyToken(raw, []byte("other-key"), "inventory", fixedNow, 0); !errors.Is(err, ErrTokenInvalid) {
t.Fatalf("wrong key err=%v", err)
}
parts := strings.Split(raw, ".")
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
t.Fatalf("decode: %v", err)
}
payload[0] ^= 0x01
tampered := base64.RawURLEncoding.EncodeToString(payload) + "." + parts[1]
if _, err := VerifyToken(tampered, key, "inventory", fixedNow, 0); !errors.Is(err, ErrTokenInvalid) {
t.Fatalf("tampered err=%v", err)
}
if _, err := VerifyToken(raw, key, "payment", fixedNow, 0); !errors.Is(err, ErrAudience) {
t.Fatalf("audience err=%v", err)
}
if _, err := VerifyToken(raw, key, "inventory", fixedNow.Add(2*time.Minute), 0); !errors.Is(err, ErrTokenExpired) {
t.Fatalf("expired err=%v", err)
}
future := testClaims()
future.IssuedAt = fixedNow.Add(10 * time.Minute)
future.ExpiresAt = fixedNow.Add(20 * time.Minute)
rawFuture, _ := SignToken(future, key)
if _, err := VerifyToken(rawFuture, key, "inventory", fixedNow, 0); !errors.Is(err, ErrTokenInvalid) {
t.Fatalf("future err=%v", err)
}
}
func TestAuthorize(t *testing.T) {
policy := Policy{Rules: []Rule{
{Service: "order-api", Actions: []string{"reservation:read"}, ResourcePrefix: "/reservations/", Tenant: "acme", Effect: Allow},
{Service: "order-api", Actions: []string{"reservation:read"}, ResourcePrefix: "/reservations/secret", Tenant: "acme", Effect: Deny},
{Service: "*", Actions: []string{"*"}, ResourcePrefix: "/platform/", Tenant: "*", Effect: Allow},
}}
c := testClaims()
if d := policy.Authorize(c, "reservation:read", "/reservations/order-1", "acme"); !d.Allowed {
t.Fatalf("allow case rejected: %+v", d)
}
if d := policy.Authorize(c, "reservation:read", "/reservations/secret/1", "acme"); d.Allowed || d.Rule != 1 {
t.Fatalf("deny did not win: %+v", d)
}
if d := policy.Authorize(c, "reservation:read", "/reservations-secret/1", "acme"); d.Allowed {
t.Fatalf("prefix boundary leaked: %+v", d)
}
if d := policy.Authorize(c, "reservation:read", "/reservations/order-1", "globex"); d.Allowed {
t.Fatalf("tenant mismatch allowed: %+v", d)
}
if d := policy.Authorize(c, "reservation:write", "/reservations/order-1", "acme"); d.Allowed {
t.Fatalf("grant escape allowed: %+v", d)
}
platform := Claims{Subject: "platform-ops", Audience: "platform", Tenant: "*",
Actions: []string{"*"}, IssuedAt: fixedNow.Add(-time.Minute), ExpiresAt: fixedNow.Add(time.Minute)}
if d := policy.Authorize(platform, "tenant:inspect", "/platform/tenants/acme", "acme"); !d.Allowed {
t.Fatalf("platform case rejected: %+v", d)
}
unknown := Policy{}
if d := unknown.Authorize(c, "reservation:read", "/reservations/order-1", "acme"); d.Allowed {
t.Fatalf("default deny failed: %+v", d)
}
}
本机实测
命令与环境:macOS darwin/arm64、Go 1.26.0。完整输出见 .local/reports/be-arch-gov/ch10-full.txt,下面是身份相关的子集。
$ go version
go version go1.26.0 darwin/arm64
$ go test ./internal/platform/tenant/ -count=1 -v -run 'TestToken|TestAuthorize'
=== RUN TestTokenVerify
--- PASS: TestTokenVerify (0.00s)
=== RUN TestAuthorize
--- PASS: TestAuthorize (0.00s)
PASS
ok archlab/internal/platform/tenant 0.249s
认证层,正确密钥、正确受众、有效时间窗的令牌解析出 order-api、acme 和两个动作;换成 other-key、篡改 payload、换受众、过期 1 分钟、未来签发全部被拒绝,且分别映射到 ErrTokenInvalid、ErrAudience、ErrTokenExpired。授权层,正常读放行;/reservations/secret/1 命中 deny,即使前面 allow 已匹配也返回拒绝;/reservations-secret/1 因路径边界不匹配被拒绝;globex 租户被身份租户挡住;reservation:write 不在令牌动作里被挡住;平台服务凭 Tenant: "*" 与 * 规则放行;空策略默认拒绝。
边界要说清楚:HMAC 是对称签名,能签发令牌的一方也能伪造任意身份,生产应换成 mTLS/SPIFFE 或非对称密钥;本节没有实现吊销列表和密钥轮换,令牌在过期前始终有效;skew 只解决有界时钟漂移,不解决长时间失联;网络层仍需要策略或服务网格做第二道隔离。0.249 秒是本机测试耗时,不代表生产鉴权延迟。
常见错误与边界
- 用共享密钥做服务身份。它能证明“持有字符串”,不能证明 workload、不能限权、泄漏后无法单独吊销。
- 只验签不验受众。给库存的令牌被转发到支付服务仍然有效,签名正确不代表使用场景正确。
- 把令牌动作当成最终授权。令牌是身份的能力上限,策略是当前调用链的许可,两者缺一不可。
- 资源前缀不做路径边界。
/reservations/用裸HasPrefix会匹配/reservations-secret/,把拒绝规则绕过去。
小结
服务间身份要拆成认证与授权两件事:认证用短有效期、绑定受众的签名令牌确认 workload,授权用“令牌动作 + 策略规则”两道门确认这一次调用。拒绝优先、默认拒绝、租户前置和路径边界是同一条原则的四个面:任何不确定都退回拒绝。
本节实现只用标准库,两个测试函数覆盖了篡改、过期、越租户和前缀绕过。它适合单集群内的 L1/L2 验证,不能替代生产级 mTLS 与密钥管理;下一节处理最后一层边界:密钥怎么轮换、审计日志怎么证明没有被改写。
阅读导航:上一节:10.1 租户模型:隔离级别与数据分区 · 下一节:10.3 密钥、审计与合规边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。