服务网格授权与租户策略:从默认拒绝到可回滚边界
订单接口刚完成网格接入,安全面板显示 mTLS 已开启,值班工程师便认为只有受信服务能够访问它。一次联调却发现:带着任意有效网格身份的 client 都能调用管理路径。证书确实证明了调用者是谁,传输也确实加密了,但数据面从未收到“这个身份可以做什么”的规则。绿色的 mTLS 指标回答的是认证问题,不是授权问题。
另一次事故更隐蔽。平台团队发布了一条 namespace 级默认拒绝策略,控制器显示对象已接受,orders-v1 的普通查询却和敏感操作一起变成 403。回滚时只删除了 ALLOW 规则,默认拒绝仍留在代理中,故障继续。真正需要追踪的是一条连续链路:策略作者是否有写权限,声明对象是否选中目标,配置是否到达执行点,请求携带了什么身份和 L7 属性,哪条规则作出决定,以及既有连接是否还保留旧语义。
先把四道门分开
服务间请求至少经过四套相互独立的判断。第一道是认证:目标代理能否验证来源证书、信任链和身份,是否接受明文。第二道是数据面授权:来源身份能否连接目标端口,或调用指定 method、path。第三道是配置写权限:谁能创建、修改和删除策略。第四道是业务授权:用户能否读取某个订单、操作哪个组织的数据。网格看到的通常是工作负载身份和请求元数据,它既不知道最终用户是否拥有订单,也不能替代应用内的领域检查。
L4 规则在连接层判断来源、目标和端口。它适合回答“client 能否连接 orders 服务的 HTTP 端口”,却无法在复用连接上区分 GET /orders 与 POST /orders。L7 规则在 HTTP、gRPC 等协议被解析后按 method、path、host 或 route 决策。它更精细,却要求流量经过真正具备 L7 能力的代理:Istio sidecar 的 Envoy 可以执行;ambient 中只有 ztunnel 时主要是 L4,L7 授权需要 waypoint。把一条 L7 策略下发给没有对应执行点的数据路径,控制面对象存在也不会凭空产生请求级判断。
身份输入也不能混写。Kubernetes ServiceAccount 是集群内工作负载凭据来源之一;Istio principal 常把 trust domain、namespace 和 ServiceAccount 编入工作负载身份;Cilium 的 security identity 来自标签集合;Consul intention 使用服务身份;SPIFFE trust domain 只是可验证身份的信任边界。共享 trust domain 或建立 federation 只代表能够验证对方身份,不代表自动允许访问,更不代表两个组织已经成为同一安全租户。
在隔离集群建立可辨认基线
下面用 Istio 的稳定发行线演示通用决策方法,因为它同时提供明确的身份、L4/L7 授权和 dry-run 证据。先从 Istio 发布页 选择仍受支持的 minor 和 patch,再按 官方安装入口 获取同版本 istioctl。不要把文档地址里的 latest 当成版本锁;把 CLI、控制面镜像、Kubernetes 版本和安装 profile 记录到实验变更中。
在一次性本地 Kubernetes 集群执行:
istioctl version --remote=false
kubectl auth can-i create namespaces
istioctl x precheck
istioctl install --set profile=demo -y
kubectl create namespace mesh-lab
kubectl label namespace mesh-lab istio-injection=enabledprofile=demo 便于观察,不是生产容量模板;它会改变控制面副本数、资源和部分默认设置。istio-injection=enabled 只影响之后创建的 Pod,旧 Pod 必须重建。若团队使用 revision,应该改用与目标控制面匹配的 revision 标签,并记录标签优先级。预期证据不是“namespace 有标签”,而是 istioctl x precheck 无阻断项、控制面 Pod Ready、新建业务 Pod 出现代理、代理获得身份并同步配置。
创建两个可区分后端和一个调用端。镜像应替换为团队允许的、固定 digest 的测试镜像;环境变量只是约定响应文本,不应包含凭据:
apiVersion: v1
kind: ServiceAccount
metadata:
name: client
namespace: mesh-lab
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: orders-v1
namespace: mesh-lab
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: orders-v2
namespace: mesh-lab
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: wrong-client
namespace: mesh-lab为四个 Deployment 分别设置同名 serviceAccountName:orders-v1 与 orders-v2 同时成为 Service orders-v1 的 endpoint,client 与 wrong-client 使用同一个固定 digest 的 curl 镜像,但携带不同 ServiceAccount。上面的对象只是身份层,不能直接接着运行下面的命令;必须先从实验仓库应用完整的 Deployment、Service、端口、探针和资源清单,并确认所有 image 都是 registry/repository@sha256:<64 位摘要>。这样既避免复制漂移 tag,也让错误身份反例真正可执行。
kubectl -n mesh-lab rollout status deployment/client
kubectl -n mesh-lab rollout status deployment/orders-v1
kubectl -n mesh-lab rollout status deployment/orders-v2
kubectl -n mesh-lab rollout status deployment/wrong-client
kubectl -n mesh-lab get pods -o custom-columns=NAME:.metadata.name,SA:.spec.serviceAccountName,CONTAINERS:.spec.containers[*].name
istioctl proxy-status
kubectl -n mesh-lab exec deployment/client -c client -- curl -sS -D- http://orders-v1/预期请求返回一个可辨认的后端版本,且目标访问日志能关联 request ID、来源 principal 和目标 workload。单次 200 只建立无策略基线;还要保存代理同步状态和双方身份。若请求成功但目标日志没有代理证据,优先检查 Service endpoint、端口协议命名、注入时机和旁路路径,而不是继续加授权规则。
从观测模式走到默认拒绝
先用 shadow DENY 观察敏感操作,而不是立即阻断。Istio AuthorizationPolicy 的 selector 在 sidecar 模式选择同 namespace 的目标 workload;source.principals 匹配经过 mTLS 验证的来源;operation.methods 和 paths 是 L7 输入。principal 必须从目标代理实际看到的身份取值生成,不能凭 Service 名猜测。ambient waypoint 上的策略必须按目标版本使用指向 Service 的 targetRefs,selector 策略会被 waypoint 忽略,不能把 sidecar 示例原样外推。
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: orders-v1
namespace: mesh-lab
annotations:
istio.io/dry-run: "true"
spec:
selector:
matchLabels:
app: orders-v1
action: DENY
rules:
- to:
- operation:
methods: ["POST"]
paths: ["/orders"]dry-run 只生成“本应拒绝”的观察证据,不阻断流量,而且该注解当前仍是 Alpha,日志、指标和 Trace 标签只能作为人工排障信号,不能当稳定 API。按照 Istio dry-run 指引 临时把目标代理的 RBAC 日志级别调到 debug,再用连续请求确认命中来源、method、path 和目标 workload:
istioctl proxy-config log deployment/orders-v1.mesh-lab --level 'rbac:debug'
kubectl -n mesh-lab logs deployment/orders-v1 -c istio-proxy | grep 'shadow denied'判据应是:GET / 的成功率与基线一致;POST /orders 仍得到应用响应,但每次都产生对应 shadow deny;没有规则的其他目标不产生误命中。测试完成后恢复原日志级别,避免 debug 日志持续暴露身份并放大存储。若日志中 principal 为空,先修复 mTLS/纳管链,不要把匿名来源加入白名单掩盖故障。
观察稳定后删除 dry-run 注解,使 DENY 真正生效。Istio 的决策不是按 YAML 创建时间或对象名称覆盖,而是固定执行 CUSTOM -> DENY -> ALLOW:CUSTOM 命中且外部授权返回拒绝时立即拒绝;任一 DENY 命中即拒绝;目标上不存在 ALLOW 时默认允许;只要存在 ALLOW,就必须至少命中一条,否则拒绝。CUSTOM 返回允许也不能绕过后续 DENY,多个 ALLOW 之间则是并集而不是“更具体者优先”。AUDIT 只标记请求,仍需要启用相应审计插件,不能改变允许或拒绝结果。详细语义应对照目标版本的 AuthorizationPolicy 参考。正向请求和反向请求必须成对运行:
kubectl -n mesh-lab exec deployment/client -c client -- curl -sS -o /dev/null -w '%{http_code}\n' http://orders-v1/
kubectl -n mesh-lab exec deployment/client -c client -- curl -sS -o /dev/null -w '%{http_code}\n' -X POST http://orders-v1/orders预期第一条仍为应用成功码,第二条由代理返回拒绝码,且 orders 应用没有对应业务处理记录。若应用记录到了 POST 后又返回 403,拒绝点可能在应用或更下游;若连接 reset 而非 HTTP 403,可能命中了 L4 拒绝、协议无法解析或请求没有经过 L7 代理。状态码、response flag、命中规则和应用日志必须一起保存。
接下来建立默认拒绝。空 spec 的 ALLOW 策略会选中目标却不允许任何请求;空规则的 DENY 则不会拒绝任何请求,两者不是同义模板。DENY 对 TCP 流量无法取得 HTTP method、path 等属性时,会把缺失属性按匹配处理,因此带 HTTP 条件的 DENY 必须同时约束目标端口;否则一条原本想拒绝 POST 的规则可能拒绝该端口的全部 TCP 流量。先只选 app: orders-v1,不要一上来覆盖整个 namespace:
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: orders-v1-default-deny
namespace: mesh-lab
spec:
selector:
matchLabels:
app: orders-v1
action: ALLOW应用后,基线 GET 应被拒绝。再创建一条 ALLOW,仅允许 mesh-lab/client 对 GET /;serviceAccounts 直接表达 namespace/ServiceAccount,且不接受通配符,比手写 trust domain principal 更不容易漂移:
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: orders-v1-allow-client
namespace: mesh-lab
spec:
selector:
matchLabels:
app: orders-v1
action: ALLOW
rules:
- from:
- source:
serviceAccounts: ["mesh-lab/client"]
to:
- operation:
methods: ["GET"]
paths: ["/"]这样默认拒绝与允许例外是两个可独立审查、独立回滚的对象。策略应用后同时验证正确身份、错误身份和错误 method:
kubectl -n mesh-lab exec deployment/client -c client -- curl -sS -o /dev/null -w '%{http_code}\n' http://orders-v1/
kubectl -n mesh-lab exec deployment/wrong-client -c wrong-client -- curl -sS -o /dev/null -w '%{http_code}\n' http://orders-v1/
kubectl -n mesh-lab exec deployment/client -c client -- curl -sS -o /dev/null -w '%{http_code}\n' -X POST http://orders-v1/orders连续至少 100 次正向 GET 与各 20 次反例,用于排除传播窗口内偶然命中旧配置;正向必须全部得到约定应用成功码,两组反例必须全部被目标代理拒绝,orders 应用处理计数分别为 100、0、0。演示阈值不是生产 SLO,生产样本量和观察窗口应由请求基线与变更风险决定。
这组 method/path 规则是 L7 授权。若目标只是 TCP 或无法解析协议,应把边界收敛为来源身份加目标 workload port,例如 operation.ports: ["8080"],并以新连接成功/失败和 reset 证据验证;不要在 TCP 路径上写 methods/paths 后期待它自动降级成 L4。L4 规则只决定能否建立连接,不能替代同一连接上每次 HTTP 请求的 L7 判断。
namespace 是管理边界,不天然是强租户
namespace 能隔离对象名、RBAC 和 namespace-scoped policy 的写入,但同一网格中的网络、控制面、CA、节点代理、遥测和容量仍可能共享。Istio 多集群中同名 namespace 还可能被视作同一身份空间;Consul Community Edition 只有 default partition/namespace,而额外多租户能力与商业 edition 相关,需查 Consul multi-tenancy 和目标许可;Kong Mesh 则把 mesh、zone、Kubernetes RBAC 与产品 RBAC 叠加。因而“每个团队一个 namespace”最多是隔离设计的一个输入。
Istio 的 root namespace 是另一条必须单独管理的影响边界。放在 root namespace 的 AuthorizationPolicy 可作用于网格内所有 namespace,selector 也会跨 namespace 选择同标签 workload;一个看似普通的标签规则可能同时命中多个租户。root namespace 的写权限不应授予应用团队,发布前应枚举全网格命中集合,并拒绝没有 owner、到期时间和恢复 revision 的全局策略。ambient 中面向 waypoint 的策略使用 targetRefs,且 selector 与 targetRefs 不能同时设置;多修订升级若仍有 1.22 之前的控制面,旧控制面可能把不认识的 targetRefs 误解为 namespace 级策略,因此必须用 istio.io/rev 将对象固定到能够理解该字段的修订,再逐批迁移数据面。
强度应由威胁模型决定。内部协作环境可以共享控制面和根信任,以 namespace、ServiceAccount、默认拒绝和配额形成软隔离。受监管或互不信任的组织若不能接受共享 CA、节点管理员、策略控制器或遥测管理员,就应考虑独立 mesh、cluster、trust domain,甚至独立基础设施。代价是更多控制面、副本、证书体系、跨域 federation 和升级事务。访问隔离通过不等于容量隔离:一个租户制造连接风暴,仍可能耗尽共享代理或节点。
策略所有权要同时约束“谁能写”和“写了影响谁”。应用团队可以拥有本 namespace 内特定 workload 的 ALLOW;平台安全团队拥有 root namespace、网格级 DENY、外部授权 provider 和 admission 规则;业务团队拥有应用内用户与资源授权。因为 DENY 不会被局部 ALLOW 覆盖,平台规则与服务规则发生冲突时,服务 owner 不能靠增加一条“更具体”的允许规则自救;必须由全局策略 owner 缩小 DENY 的目标、端口或条件。反过来,新增任意 ALLOW 会把该目标从“无 ALLOW 时默认允许”切换为“未命中即拒绝”,也应被视为默认行为变更。用 Kubernetes RBAC 验证策略作者不能修改不属于自己的对象,再用真实请求验证数据面拒绝。只有前者通过,攻击者仍可能利用已有宽松策略;只有后者通过,越权作者仍可在下一次变更中放开边界。
kubectl auth can-i --as=system:serviceaccount:tenant-a:policy-deployer create authorizationpolicies.security.istio.io -n tenant-a
kubectl auth can-i --as=system:serviceaccount:tenant-a:policy-deployer create authorizationpolicies.security.istio.io -n tenant-b预期第一条按租户角色设计返回 yes,第二条返回 no;执行者还必须具备 impersonate 权限才能运行这项审计,不能为方便而给普通策略作者集群级 impersonate。随后仍要从 tenant-a 的正确/错误 ServiceAccount 分别请求 tenant-b 服务,证明“不能改对方策略”和“数据面不能越权”两条边界都成立。
一个可审查的项目目录可将策略与 Deployment 一起评审:
mesh-lab/
workloads/
client.yaml
orders-v1.yaml
orders-v2.yaml
policies/
orders-v1-default-deny.yaml
orders-v1-allow-client.yaml
tests/
authorization-smoke.shCI 先做 schema 校验和 server-side dry-run,再检查 selector 是否能选中预期 workload、ALLOW 是否声明来源身份、DENY 是否遗漏端口或 method。Kubernetes server-side dry-run 只能证明 API 接受与 admission 结果,不能证明 selector 命中或数据面决策。Istio AuthorizationPolicy 没有可供依赖的通用 status.observedGeneration;部署工具应记录 Git revision、metadata.generation、目标 Pod 集合、代理同步状态、shadow/拒绝证据和正反请求结果。策略文件不应包含 Token、私钥、真实租户名、内部域名或证书;配置转储、访问日志和 principal 清单本身也可能泄露拓扑与身份,归档前要脱敏并限制访问。
渐进发布与可证明回滚
授权变更的发布单位不是一份 YAML,而是“策略 revision + 目标集合 + 决策层 + 正反请求 + 回滚对象”。同一请求要分别证明 CUSTOM、DENY、ALLOW 的命中结果,避免只观察最终 403 却不知道拒绝来自外部授权、平台 DENY 还是 ALLOW 缺失。先缩小 selector 到 orders-v1,在 dry-run 中观察误命中,再启用真实 DENY,最后才扩大到 namespace。隔离实验的量化停止条件可以是:100 次正常请求出现任意非预期拒绝、20 次错误身份或错误 path 出现任意成功、任一目标 proxy 在两倍于历史 P99 传播时间后仍未同步,或 shadow 命中出现在目标集合之外。命中任一条件便停止扩大并恢复上一 revision;生产窗口和样本量再由流量基线与 SLO 收紧,不能照搬演示数字。
回滚不要临场删除所有策略。若新 DENY 误伤,优先恢复上一个已审查 revision;若默认拒绝已经启用,就同时确认旧 ALLOW 仍存在。删除唯一 ALLOW 会使拒绝持续,删除默认拒绝又可能把目标恢复成默认允许。回滚后重跑同一组正反请求,观察拒绝计数回到基线、允许请求恢复、错误身份仍失败,并检查所有目标 proxy 都加载了同一策略版本。
L4 撤权还有连接存活问题。Consul 官方 intentions 明确区分 TCP 连接级与 HTTP 请求级语义,修改 L4 intention 不会自动切断既有连接。其他实现也应实际验证:建立长连接,发布拒绝,比较旧连接上的后续请求与新连接。需要立即撤销时,除了策略更新,可能还要排空或重启连接、撤销证书或在更低网络层封口;这些动作故障面更大,必须独立审批。
从症状反推决策点
对象已接受,请求仍全部放行。 先看 selector/targetRef 是否选中目标,再看目标代理是否同步对应策略,随后核对流量是否经过执行点。ambient 只有 ztunnel 却配置 path 规则,是典型的 L7 执行点缺失。修复 waypoint 或改为诚实的 L4 规则后,重跑错误 path 反例。
正确身份也被拒绝。 从目标代理观测到的 principal 出发比较,不从 Deployment 名推导。常见原因是 trust domain、namespace、ServiceAccount 拼写或多集群身份空间与假设不同,也可能是 DENY 先于 ALLOW 命中。修复最小字段后,必须确认错误身份仍失败,避免把规则放宽成任意来源。
偶发 403,只发生在部分 Pod。 对比每个目标 proxy 的配置版本、策略 revision 和 endpoint;若新旧配置并存,是传播或失联问题,不是随机网络抖动。控制面断连时数据面可能继续使用 last-known policy,但新策略不会到达,证书续期也有边界。恢复后应看到版本收敛,不能只用一次成功请求收尾。
策略拒绝与应用拒绝混在一起。 代理本地拒绝通常没有上游处理记录;应用拒绝会有业务日志和上游响应。用 request ID、response flag、upstream cluster 和应用审计关联。不要在公开工单中粘贴完整证书、JWT、Authorization header、配置转储或真实 principal。
策略作者能修改其他团队规则。 这是配置写权限故障,即使数据面当前仍拒绝也必须处理。收紧 Role/RoleBinding 或产品 ACL/RBAC,撤销共享 kubeconfig 和长期 Token,使用短期凭据与独立 ServiceAccount,再用 kubectl auth can-i 的正反身份测试证明边界。
容量、成本与长期治理
L7 授权会增加协议解析、规则匹配、日志和外部授权调用成本;外部授权服务还进入请求关键路径。容量实验至少比较无策略、L4、L7 和外部授权四组的吞吐、P50/P95/P99、CPU、内存、连接数与拒绝率,并在证书轮换和策略批量发布时观察长尾。不能把另一个集群或厂商基准直接当预算。规则数量、principal 集合和 path 匹配复杂度增长时,要测最坏匹配而非平均命中。
成本不仅是代理资源。每个独立 mesh/control plane 会增加副本、CA、升级、遥测和审计存储;共享控制面则增加故障域与组织协调成本。策略日志若记录 method、path、身份和请求属性,索引费用和敏感数据暴露都会上升。对允许、拒绝和 shadow 指标设置保留期与采样规则,安全拒绝可保留聚合计数,详细事件只在受控窗口开启。
长期治理需要一张可执行责任表:平台 owner 管安装、执行点与版本兼容;安全 owner 管默认拒绝、全局 DENY 和例外期限;服务 owner 管最小 ALLOW 与正反契约;业务 owner 管用户到资源的领域授权。每条例外记录调用方、目标、协议、端口/path、理由、到期时间和回滚 revision。季度复查长期未命中的 ALLOW、无 owner 的策略、跨 namespace 通配符和失效 ServiceAccount;删除前先 dry-run 观察,再做真实撤销。
跨产品选型时,比较的是输入和故障语义。Istio 适合需要丰富 L4/L7 与 sidecar/ambient 形态的团队;Linkerd 以 inbound Server/route policy 和 ServiceAccount identity 组织授权,安装默认 inbound policy 必须先读取;Cilium 把 L3/L4 identity policy 放在 eBPF 路径、L7 交给 Envoy;Consul intentions 适合 Kubernetes 与 VM 服务身份,但 ACL 与通信授权必须分开;Kuma/Kong Mesh 用 MeshTrafficPermission 与 mesh/zone 管理边界。产品选择不能消除业务授权,也不能把 namespace 变成强租户。
安全退出实验环境
先恢复策略到无实验对象的状态,再移出工作负载,最后卸载控制面。这样可以避免先删控制面、业务 Pod 却仍带代理和旧网络规则:
kubectl -n mesh-lab delete authorizationpolicy --all
kubectl -n mesh-lab delete deployment --all
kubectl -n mesh-lab delete service --all
kubectl -n mesh-lab delete serviceaccount client wrong-client orders-v1 orders-v2
kubectl delete namespace mesh-lab
istioctl uninstall --purge -y
kubectl get mutatingwebhookconfigurations
kubectl get validatingwebhookconfigurations共享集群不得照抄 --purge:它可能删除其他实验仍依赖的集群级对象。执行前用安装 revision、Helm release 或资源标签确认所有权;生产环境应先取消选择、重建并排空工作负载,验证直连与策略语义,再卸载控制面。清理完成的判据是 namespace 已终止、无测试 ServiceAccount 与策略、无残留代理 Pod、webhook/CRD/RBAC 属于预期 owner,节点网络规则与遥测索引没有遗留实验数据;任何一项不清楚,都不应继续做集群级删除。
