在设计一个高安全性的多租户 SaaS 平台时,我们面临的核心挑战是如何实现真正意义上的租户隔离。传统的 Kubernetes 网络策略(NetworkPolicy)基于 Pod 标签和命名空间进行隔离,这在基础设施层面提供了基础防护,但在真实项目中,这种隔离粒度远远不够。一个常见的攻击路径是,攻击者通过应用漏洞攻陷某个租户的 Pod,然后利用该 Pod 的 Service Account 权限,在网络策略允许的范围内进行横向移动,探测甚至攻击同一租户或其它租户的其它服务。问题的根源在于,传统网络策略只认“Pod”,不认“用户”。它无法区分一个合法的、由用户操作触发的请求和一个由潜伏在 Pod 内的恶意代码发起的请求。
我们的目标是建立一个更强的安全模型:网络层的流量许可必须与一个经过强身份验证的用户身份直接绑定。这意味着,每一次服务间的调用,不仅要验证来源服务的身份,还必须验证触发这次调用的最终用户的身份和权限。流量的放行与否,应由数据包携带的用户上下文动态决定,而非依赖静态的、基于 Pod IP 或标签的规则。
方案A:传统API网关 + 应用层授权
这是业内最常见的实现方式。其架构通常如下:
graph TD subgraph 浏览器 A[用户] --> B{WebAuthn 登录} end subgraph 集群入口 B --> C[API Gateway] end subgraph Kubernetes 集群 C -- JWT --> D[服务A] D -- 内部调用 --> E[服务B] D -- 内部调用 --> F[服务C] end subgraph 网络策略 G[K8s NetworkPolicy] -.-> D G -.-> E G -.-> F end style G fill:#f9f,stroke:#333,stroke-width:2px
- 身份验证: 用户通过 WebAuthn 进行无密码登录,认证服务验证后,签发一个包含
user_id
和tenant_id
的 JSON Web Token (JWT)。 - 网关验证: API Gateway 接收所有外部请求,校验 JWT 的签名和有效期,然后将请求转发到后端服务。
- 应用层授权: 每个微服务(服务A, B, C)都需要解析请求头中的 JWT,提取
tenant_id
,并在自己的业务逻辑中执行授权检查。例如,服务 B 在处理来自服务 A 的请求时,必须再次验证 JWT,确保操作的数据属于正确的租户。 - 网络隔离: Kubernetes 的 NetworkPolicy 负责定义服务间的可达性。例如,它可能规定只有服务 A 可以访问服务 B。
优点:
- 架构成熟,社区有大量实践。
- 逻辑解耦清晰,身份、网关、业务、网络各司其职。
缺点:
- 安全责任下放: 核心的安全保障——租户数据隔离,完全依赖于每个微服务的开发者正确实现应用层授权逻辑。任何一个服务的代码疏漏都可能导致越权访问。在真实项目中,保证几十上百个微服务的授权逻辑永远正确是一项巨大的挑战。
- 横向移动风险依旧: 如果服务 A 被攻陷,攻击者可以在服务 A 的 Pod 内部,使用其合法的 Service Account,向服务 B 发起请求。由于网络策略是允许
A -> B
的,只要攻击者能伪造或窃取一个有效的 JWT,服务 B 的应用层授权就可能被绕过。即使无法窃取JWT,攻击者也能在服务A的权限范围内对其他服务进行探测。 - 授权逻辑重复: 每个服务都需要重复实现 JWT 解析和租户验证的逻辑,增加了开发和维护成本。
这个方案的本质问题是,网络层和身份层是割裂的。网络策略对用户身份一无所知,它只是机械地执行着 “Pod A 能否访问 Pod B” 的规则。
方案B:Cilium + WebAuthn 身份感知的网络策略
为了解决上述问题,我们构想了一个将强身份验证与内核级网络策略直接绑定的方案。我们选择 Cilium 作为 CNI,因为它基于 eBPF 提供了强大的 L7 策略能力,特别是对 JWT 的原生解析和验证能力。
架构图如下:
sequenceDiagram participant User as 用户 participant FE as 前端 (Pinia) participant AuthSvc as 认证服务 (Go) participant SvcA as 服务A participant SvcB as 服务B (tenant-x-data) participant Cilium as Cilium eBPF User->>FE: 发起 WebAuthn 登录 FE->>AuthSvc: 发送 WebAuthn Assertion AuthSvc->>AuthSvc: 验证 Assertion, 生成 JWT
(含 user_id, tenant_id='tenant-x') AuthSvc-->>FE: 返回 JWT FE->>FE: Pinia Store 存储 JWT FE->>SvcA: API 请求 (Header: Authorization: Bearer) SvcA->>SvcB: 内部调用 (Header: Authorization: Bearer ) Note right of SvcB: Cilium eBPF 拦截流量 Cilium->>Cilium: 1. 解析 HTTP Header
2. 提取并验证 JWT
3. 检查 claim 'tenant_id'=='tenant-x'
4. 检查 SvcB Pod 标签 'tenant.id'=='tenant-x'
5. 匹配成功,放行流量 SvcB-->>SvcA: 响应 SvcA-->>FE: 响应 FE-->>User: 渲染数据
这个方案的核心思想是:将授权逻辑从应用层下沉到 CNI 层。
- 前端身份管理 (Pinia & WebAuthn): 前端使用 WebAuthn 完成注册和登录,获得一个包含租户信息的 JWT。Pinia 状态管理库负责安全地存储和管理这个 Token,并将其自动附加到所有对后端的 API 请求头中。
- 身份凭证传递: 该 JWT 在整个调用链中(从前端到服务A,再到服务B)被完整地传递下去。
- Cilium 策略执行: Cilium Network Policy 被配置为守护在目标服务(如服务 B)之前。它利用 eBPF 在内核空间直接检查进入的数据包。当一个发往服务 B 的 HTTP 请求到达时,Cilium 会:
- 解析 HTTP 头,找到
Authorization
字段。 - 验证其中的 JWT 签名和有效期。
- 提取 JWT 的
claims
,比如tenant_id
。 - 将
tenant_id
的值与目标 Pod(服务 B)自身的标签进行匹配。 - 只有当 JWT 有效,且其
tenant_id
与目标 Pod 的tenant.id
标签完全一致时,流量才被允许通过。否则,请求在进入 Pod 的网络命名空间之前就会被直接丢弃。
- 解析 HTTP 头,找到
最终选择与理由:
我们最终选择了方案 B。尽管它的配置和实现比方案 A 更复杂,但它带来的安全收益是根本性的。
- 最小权限原则的极致体现: 访问权限不再是授予给某个 Pod 或 Service Account,而是精细到由特定用户的特定请求动态授予。
- 防御横向移动: 即使服务 A 被完全攻陷,攻击者也无法在没有合法、有效且租户匹配的 JWT 的情况下访问服务 B。任何从服务 A Pod 内部发起的、没有携带正确 JWT 的伪造请求,都会在网络层被 Cilium 无情地拦截。
- 安全左移,降低业务开发心智负担: 业务开发者不再需要时刻紧绷安全这根弦,担心忘记在某个内部 gRPC 调用中添加授权逻辑。安全隔离的责任被统一收归到基础设施层,由平台工程团队通过 Cilium 策略进行集中管理和审计。这是一个架构层面的决策,它用基础设施的确定性取代了应用层代码的不确定性。
核心实现概览
以下是这个架构中几个关键部分的代码实现。
1. 前端状态与认证流程 (Pinia + WebAuthn)
前端的职责是引导用户完成 WebAuthn 流程,并用 Pinia 管理认证状态。
// src/stores/auth.ts
import { defineStore } from 'pinia';
import * as webauthn from '@simplewebauthn/browser';
interface AuthState {
isAuthenticated: boolean;
user: { id: string; name:string } | null;
token: string | null;
isAuthenticating: boolean;
error: string | null;
}
// 这是一个生产级的Pinia store,包含了完整的状态、错误处理和加载状态。
export const useAuthStore = defineStore('auth', {
state: (): AuthState => ({
isAuthenticated: false,
user: null,
token: null,
isAuthenticating: false,
error: null,
}),
getters: {
// Getter 用于在应用中方便地访问认证头
authorizationHeader: (state): { Authorization?: string } => {
if (state.token) {
return { Authorization: `Bearer ${state.token}` };
}
return {};
},
},
actions: {
// 动作1: 注册新设备
async register(username: string) {
this.isAuthenticating = true;
this.error = null;
try {
// 步骤1: 从后端获取创建挑战
const creationOptions = await fetch('/api/auth/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
}).then(res => res.json());
// 步骤2: 调用浏览器 WebAuthn API 创建凭证
const attestation = await webauthn.startRegistration(creationOptions);
// 步骤3: 将凭证发送到后端完成注册
const verificationResponse = await fetch('/api/auth/register/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, attestation }),
});
if (!verificationResponse.ok) {
throw new Error('Registration failed on server.');
}
// 注册成功后可以提示用户进行登录
console.log('Registration successful!');
} catch (err: any) {
console.error('Registration error:', err);
this.error = err.message || 'An unknown registration error occurred.';
} finally {
this.isAuthenticating = false;
}
},
// 动作2: 登录
async login(username: string) {
this.isAuthenticating = true;
this.error = null;
try {
// 步骤1: 从后端获取登录挑战
const requestOptions = await fetch('/api/auth/login/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
}).then(res => res.json());
// 步骤2: 调用浏览器 WebAuthn API 获取断言
const assertion = await webauthn.startAuthentication(requestOptions);
// 步骤3: 将断言发送到后端进行验证,并换取 JWT
const verificationResponse = await fetch('/api/auth/login/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, assertion }),
});
if (!verificationResponse.ok) {
const errorData = await verificationResponse.json();
throw new Error(errorData.message || 'Login verification failed.');
}
const { token, user } = await verificationResponse.json();
// 步骤4: 登录成功,更新 Pinia store
this.token = token;
this.user = user;
this.isAuthenticated = true;
// 在真实项目中,token 应该存储在 httpOnly cookie 中,这里为简化示例
// localStorage.setItem('jwt', token);
} catch (err: any) {
console.error('Login error:', err);
this.error = err.message || 'An unknown login error occurred.';
this.logout(); // 登录失败时清理状态
} finally {
this.isAuthenticating = false;
}
},
// 动作3: 登出
logout() {
this.isAuthenticated = false;
this.user = null;
this.token = null;
// localStorage.removeItem('jwt');
},
},
});
2. 后端认证服务 (Go)
这个服务负责处理 WebAuthn 流程,并在验证成功后签发包含租户信息的 JWT。我们使用 go-webauthn
库来简化 WebAuthn 的复杂性。
// pkg/auth/handler.go
package auth
import (
"encoding/json"
"net/http"
"time"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/golang-jwt/jwt/v4"
// 假设我们有 user 和 session 的存储服务
"my-saas/pkg/storage"
)
// 生产级的配置应该来自配置文件
var webAuthnConfig = &webauthn.Config{
RPDisplayName: "My SaaS Platform",
RPID: "localhost", // 在生产中必须是你的域名
RPOrigins: []string{"http://localhost:3000"}, // 前端地址
}
var w, _ = webauthn.New(webAuthnConfig)
var userDB = storage.NewUserStore() // 模拟用户数据库
var sessionDB = storage.NewSessionStore() // 模拟会话/凭证存储
// FinishLoginHandler 处理登录验证和JWT签发
func FinishLoginHandler(w http.ResponseWriter, r *http.Request) {
// ... 解析请求体获取 username 和 assertion ...
user, err := userDB.GetUser(username)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
// 从DB加载该用户的凭证
sessionData, err := sessionDB.GetSession(user.ID)
if err != nil {
http.Error(w, "Session data not found", http.StatusInternalServerError)
return
}
// 核心验证逻辑
// 这里的 user 对象需要实现 webauthn.User 接口
_, err = w.ValidateLogin(user, *sessionData, parsedAssertion)
if err != nil {
// 记录详细错误日志,但给前端返回通用错误信息
log.Printf("ERROR: WebAuthn login validation failed for user %s: %v", username, err)
http.Error(w, "Authentication failed", http.StatusUnauthorized)
return
}
// 验证成功,签发JWT
// 在真实项目中,tenantID 来自用户数据库
tenantID := user.TenantID
// 定义JWT的claims
claims := jwt.MapClaims{
"iss": "my-auth-service", // issuer
"sub": user.ID, // subject (user id)
"aud": "my-saas-services", // audience
"exp": time.Now().Add(time.Hour * 1).Unix(), // expiration
"iat": time.Now().Unix(), // issued at
"nbf": time.Now().Unix(), // not before
// --- 关键的自定义 claims ---
"tid": tenantID, // Tenant ID
"rol": "user", // Role
}
// 使用HS256签名,密钥应从安全的地方(如Vault或KMS)获取
jwtSecret := []byte("your-super-secret-key-that-is-at-least-32-bytes-long")
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(jwtSecret)
if err != nil {
http.Error(w, "Failed to sign token", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"token": tokenString,
"user": map[string]string{
"id": user.ID,
"name": user.Name,
},
})
}
3. 身份感知的网络策略 (CiliumNetworkPolicy)
这是整个方案的基石。下面的 CiliumNetworkPolicy
(CNP) 资源定义了一个规则,它只允许携带了有效且 tid
claim 为 tenant-a
的 JWT 的请求访问带有 tenant.id: tenant-a
标签的 Pod。
# cilium-policy-tenant-a.yaml
apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
name: "tenant-a-data-access-policy"
namespace: "multi-tenant-services"
spec:
# 1. 策略应用范围:选择所有带有 tenant.id=tenant-a 标签的 Pod
endpointSelector:
matchLabels:
app: "data-service"
tenant.id: "tenant-a"
# 2. Ingress 规则:定义允许进入这些 Pod 的流量
ingress:
- fromEndpoints:
# 允许来自带有 'app: api-gateway' 或 'app: service-a' 标签的 Pod 的流量
# 这是一个基础的网络层过滤,防止其他不相关的 Pod 发起请求
- matchLabels:
"k8s:io.kubernetes.pod.namespace": "multi-tenant-services"
matchExpressions:
- key: app
operator: In
values: ["api-gateway", "service-a"]
# 3. L7 策略:对允许的流量进行应用层检查
toPorts:
- ports:
- port: "8080"
protocol: TCP
rules:
http:
- method: "GET"
path: "/data/.*"
# 4. 身份验证核心:JWT 校验规则
authentication:
# 模式为 "required",表示必须提供并验证通过 JWT
mode: "required"
jwt:
# JWT 签发者,必须与认证服务签发时使用的 'iss' claim 一致
issuer: "my-auth-service"
# JWKS (JSON Web Key Set) URL,Cilium 会从这里获取公钥来验证 JWT 签名
# 在生产中,这应该是一个暴露 JWKS 的 endpoint
# 为简化,这里使用 secret
jwksFromSecret: "auth-service-jwks"
# 指定从哪个 HTTP Header 中提取 JWT,格式为 "name: prefix"
# "Authorization: Bearer <token>"
header: "Authorization: Bearer "
# 5. Claims 校验:这是将用户身份与资源绑定的关键
requiredClaims:
# 规则:JWT 中必须存在一个名为 'tid' (tenant id) 的 claim
# 并且其值必须是 'tenant-a'
- key: "tid"
value: "tenant-a"
# 还可以添加其他检查,例如角色
- key: "rol"
value: "user"
部署与思考:
- JWKS Secret: 上述策略依赖一个名为
auth-service-jwks
的 Secret,其中包含了用于验证 JWT 签名的公钥。认证服务需要安全地生成密钥对,并将公钥以 JWKS 格式发布或存储在这个 Secret 中。 - 自动化: 在真实的 SaaS 平台中,租户是动态创建和销毁的。为每个租户手动创建这样的 CNP 是不现实的。正确的做法是开发一个 Kubernetes Operator。这个 Operator 会监听租户资源的创建(例如一个
Tenant
CRD),然后根据模板自动生成对应的CiliumNetworkPolicy
、Namespace
和其他资源。当租户被删除时,Operator 会自动清理这些资源。
架构的扩展性与局限性
这个架构具备良好的扩展性。我们可以通过在 JWT 中添加更丰富的 claims
(如 permissions: ["read", "write"]
),并在 Cilium 的 L7 策略中对 HTTP method
或 path
进行更精细的控制,从而实现更细粒度的 API 授权,而这一切都在网络层完成,对应用透明。
局限性与待解决问题:
- 性能开销: 尽管 eBPF 性能极高,但在数据路径上对每个符合条件的包进行 JWT 解析和验证仍然会带来一定的延迟。对于延迟极度敏感的应用,需要进行详尽的性能压测以评估影响。
- 证书与密钥管理: 整个体系的安全性依赖于 JWT 签名密钥的保密性。需要建立一套健壮的密钥轮换和管理机制,例如使用 HashiCorp Vault 或云厂商的 KMS 服务。JWKS 的分发和缓存策略也需要仔细设计。
- 协议限制: Cilium 的 L7 策略目前主要支持 HTTP、gRPC、Kafka 等常见应用协议。对于自定义的 TCP 协议或数据库连接(如 aSQL),这种基于 JWT 的 L7 策略将无法生效。这些场景仍然需要依赖 mTLS 和传统的网络策略进行 L3/L4 层的隔离。
- 调试复杂性: 当请求失败时,问题可能出在应用逻辑、网络策略、JWT claims、密钥或 Cilium 配置等任何一个环节。需要建立完善的可观测性体系,将 Cilium 的可观测性数据(如 Hubble)与应用日志、分布式追踪相结合,才能快速定位问题根源。例如,Hubble 可以明确地告诉你一个请求是因为 JWT 验证失败而被拒绝的。