fix(identity): 支持平台用户显式登录 AI Gateway

修复多租户身份配置将所有人类登录都强制解释为租户上下文的问题。Web 现在提供平台与租户两个受控入口,API 严格绑定 context_type、tid、issuer、application 和 subject,并为平台用户建立独立本地投影与可刷新会话。\n\n风险:新增会话身份列保持旧会话可读,新会话一律使用严格约束;未改变租户数据隔离和 API Key 行为。\n\n验证:Go 全量测试与 go vet 通过;PostgreSQL 平台投影、会话和安全事件集成测试通过;前端 lint、141 项测试与生产 build 通过;OpenAPI 生成和迁移安全测试通过。
This commit is contained in:
2026-07-31 14:24:40 +08:00
parent 88c971564a
commit c0296dbf06
29 changed files with 1061 additions and 84 deletions
+87
View File
@@ -99,6 +99,8 @@ func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) {
{"missing issuer", "ISSUER_MISSING", func(claims jwt.MapClaims) { delete(claims, "iss") }},
{"missing audience", "AUDIENCE_MISSING", func(claims jwt.MapClaims) { delete(claims, "aud") }},
{"wrong audience", "AUDIENCE_INVALID", func(claims jwt.MapClaims) { claims["aud"] = "other-api" }},
{"missing context type", "STABLE_IDENTITY_CLAIMS_INVALID", func(claims jwt.MapClaims) { delete(claims, "context_type") }},
{"unknown context type", "STABLE_IDENTITY_CLAIMS_INVALID", func(claims jwt.MapClaims) { claims["context_type"] = "account" }},
{"wrong tenant", "STABLE_IDENTITY_CLAIMS_INVALID", func(claims jwt.MapClaims) { claims["tid"] = "tenant-2" }},
{"missing scope", "REQUIRED_SCOPE_MISSING", func(claims jwt.MapClaims) { claims["scope"] = "openid" }},
{"unmapped role", "MAPPED_ROLE_MISSING", func(claims jwt.MapClaims) { claims["roles"] = []string{"other.admin"} }},
@@ -120,6 +122,90 @@ func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) {
}
}
func TestOIDCVerifierAcceptsExplicitPlatformAndTenantContextsForMultiTenantApplication(t *testing.T) {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
var issuer string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/.well-known/openid-configuration" {
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": issuer,
"jwks_uri": issuer + "/jwks",
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"keys": []any{ecJWK("ec-key", &key.PublicKey)},
})
}))
defer server.Close()
issuer = server.URL
verifier, err := NewOIDCVerifier(OIDCConfig{
AppEnv: "test",
Issuer: issuer, Audience: "gateway-api",
TenantMode: "multi_tenant",
ApplicationID: "11111111-1111-4111-8111-111111111111",
RolePrefix: "gateway.", HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
platformToken := signedOIDCToken(
t,
issuer,
"ec-key",
jwt.SigningMethodES256,
key,
func(claims jwt.MapClaims) {
claims["context_type"] = "platform"
delete(claims, "tid")
},
)
platformUser, err := verifier.Verify(context.Background(), platformToken)
if err != nil {
t.Fatalf("platform context rejected: %v", err)
}
if platformUser.ContextType != "platform" || platformUser.TenantID != "" {
t.Fatalf("platform user=%#v", platformUser)
}
tenantID := "22222222-2222-4222-8222-222222222222"
tenantToken := signedOIDCToken(
t,
issuer,
"ec-key",
jwt.SigningMethodES256,
key,
func(claims jwt.MapClaims) {
claims["context_type"] = "tenant"
claims["tid"] = tenantID
},
)
tenantUser, err := verifier.Verify(context.Background(), tenantToken)
if err != nil {
t.Fatalf("tenant context rejected: %v", err)
}
if tenantUser.ContextType != "tenant" || tenantUser.TenantID != tenantID {
t.Fatalf("tenant user=%#v", tenantUser)
}
for name, mutate := range map[string]func(jwt.MapClaims){
"platform with tenant": func(claims jwt.MapClaims) {
claims["context_type"] = "platform"
},
"tenant without tenant": func(claims jwt.MapClaims) {
claims["context_type"] = "tenant"
delete(claims, "tid")
},
} {
t.Run(name, func(t *testing.T) {
raw := signedOIDCToken(
t, issuer, "ec-key", jwt.SigningMethodES256, key, mutate,
)
if _, err := verifier.Verify(context.Background(), raw); err == nil {
t.Fatal("context/tenant mismatch was accepted")
}
})
}
}
func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing.T) {
key, _ := rsa.GenerateKey(rand.Reader, 2048)
active := true
@@ -312,6 +398,7 @@ func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod,
now := time.Now()
claims := jwt.MapClaims{
"iss": issuer, "aud": "gateway-api", "sub": "platform-subject", "tid": "tenant-1",
"context_type": "tenant",
"preferred_username": "acceptance", "roles": []string{"gateway.admin"},
"scope": "openid gateway.access", "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(),
"exp": now.Add(time.Hour).Unix(),