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
@@ -2,8 +2,10 @@ package store
import (
"context"
"crypto/sha256"
"os"
"path/filepath"
"reflect"
"runtime"
"sort"
"strings"
@@ -35,7 +37,8 @@ func TestResolveOrProvisionOIDCMultiTenantUserIsIdempotentIsolatedAndReusable(t
input := func(tenantID, name, slug string) ResolveOrProvisionOIDCUserInput {
return ResolveOrProvisionOIDCUserInput{
Issuer: issuer, ApplicationID: applicationID, Subject: subject, Username: "shared-subject",
Roles: []string{"basic"}, TenantID: tenantID, TenantMode: "multi_tenant",
Roles: []string{"basic"}, ContextType: "tenant",
TenantID: tenantID, TenantMode: "multi_tenant",
TenantName: name, TenantSlug: slug, TenantMetadataStatus: "synced",
TenantMetadataVersion: "v1", TenantMetadataETag: `"v1"`,
TenantMetadataUpdatedAt: time.Unix(1_780_000_000, 0).UTC(),
@@ -173,7 +176,9 @@ func TestResolveOrProvisionOIDCUserLifecycleAndConcurrency(t *testing.T) {
Subject: subject,
Username: "jit-user-" + suffix,
Roles: []string{"basic"},
ContextType: "tenant",
TenantID: "auth-center-test-tenant",
TenantMode: "single_tenant",
GatewayTenantKey: "default",
ProvisioningEnabled: true,
}
@@ -299,6 +304,120 @@ FROM gateway_users WHERE id = $1::uuid`, firstID).Scan(&displayName, &email, &ma
}
}
func TestResolveOrProvisionOIDCPlatformUserUsesExplicitContextWithoutExternalTenant(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run OIDC JIT PostgreSQL integration tests")
}
ctx := context.Background()
applyOIDCJITTestMigrations(t, ctx, databaseURL)
db, err := Connect(ctx, databaseURL)
if err != nil {
t.Fatalf("connect store: %v", err)
}
defer db.Close()
suffix := time.Now().UTC().Format("20060102150405.000000000")
subject := "platform-context-" + suffix
applicationID := uuid.NewString()
t.Cleanup(func() {
_, _ = db.pool.Exec(context.Background(), `
DELETE FROM gateway_audit_logs
WHERE target_gateway_user_id IN (
SELECT id FROM gateway_users
WHERE source='oidc_v2_platform'
AND user_key=$1
)`, deriveOIDCPlatformUserKey(
"https://auth.test.example/issuer/shared",
applicationID,
subject,
))
_, _ = db.pool.Exec(context.Background(), `
DELETE FROM gateway_users
WHERE source='oidc_v2_platform'
AND user_key=$1`, deriveOIDCPlatformUserKey(
"https://auth.test.example/issuer/shared",
applicationID,
subject,
))
})
result, err := db.ResolveOrProvisionOIDCUser(
ctx,
ResolveOrProvisionOIDCUserInput{
Issuer: "https://auth.test.example/issuer/shared",
ApplicationID: applicationID,
Subject: subject,
Username: "platform-user-" + suffix,
Roles: []string{"admin"},
ContextType: "platform",
TenantMode: "multi_tenant",
ProvisioningEnabled: true,
},
)
if err != nil {
t.Fatalf("resolve platform user: %v", err)
}
if !result.Created || result.User == nil ||
result.User.ID != subject ||
result.User.ContextType != "platform" ||
result.User.TenantID != "" ||
result.User.TenantKey != oidcPlatformTenantKey ||
result.User.GatewayTenantID == "" ||
result.AuditID == "" {
t.Fatalf("platform projection=%#v", result)
}
now := time.Now().UTC()
sessionHash := sha256.Sum256([]byte("platform-session-" + suffix))
session, err := db.CreateOIDCSession(ctx, CreateOIDCSessionInput{
SessionTokenHash: sessionHash[:],
GatewayUserID: result.User.GatewayUserID,
GatewayTenantID: result.User.GatewayTenantID,
OIDCClientID: "gateway-browser",
Issuer: result.User.Issuer,
ApplicationID: applicationID,
ContextType: "platform",
Subject: subject,
TokenCiphertext: []byte("encrypted-test-token"),
AccessTokenExpiresAt: now.Add(time.Hour),
LastSeenAt: now,
IdleExpiresAt: now.Add(time.Hour),
AbsoluteExpiresAt: now.Add(2 * time.Hour),
})
if err != nil {
t.Fatalf("create platform session: %v", err)
}
if session.ContextType != "platform" ||
session.ExternalUserID != subject ||
session.Issuer != result.User.Issuer ||
session.ApplicationID != applicationID ||
session.TenantID != "" ||
session.OIDCUserBindingID != "" {
t.Fatalf("platform session=%#v", session)
}
repeated, err := db.ResolveOrProvisionOIDCUser(
ctx,
ResolveOrProvisionOIDCUserInput{
Issuer: "https://auth.test.example/issuer/shared",
ApplicationID: applicationID,
Subject: subject,
Username: "platform-user-renamed-" + suffix,
Roles: []string{"viewer"},
ContextType: "platform",
TenantMode: "multi_tenant",
ProvisioningEnabled: false,
},
)
if err != nil || repeated.Created ||
repeated.User.GatewayUserID != result.User.GatewayUserID ||
!reflect.DeepEqual(repeated.User.Roles, []string{"viewer"}) {
t.Fatalf("repeated platform projection=%#v err=%v", repeated, err)
}
}
func TestResolveOrProvisionOIDCUserRejectsMissingMappingWithoutWrites(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
@@ -318,7 +437,9 @@ func TestResolveOrProvisionOIDCUserRejectsMissingMappingWithoutWrites(t *testing
Subject: subject,
Username: "missing-user",
Roles: []string{"basic"},
ContextType: "tenant",
TenantID: "auth-center-test-tenant",
TenantMode: "single_tenant",
GatewayTenantKey: "missing-tenant-key",
}