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:
@@ -62,6 +62,7 @@ func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) {
|
||||
ID: "platform-user",
|
||||
Username: "alice",
|
||||
Roles: []string{"basic"},
|
||||
ContextType: "tenant",
|
||||
TenantID: "external-tenant",
|
||||
Source: "oidc",
|
||||
GatewayUserID: "21dd9ccb-3793-4023-ab31-4d04982ca4d3",
|
||||
@@ -86,11 +87,12 @@ func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) {
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
request = request.WithContext(auth.WithUser(request.Context(), &auth.User{
|
||||
ID: "platform-user",
|
||||
Username: "alice",
|
||||
Roles: []string{"basic"},
|
||||
TenantID: "external-tenant",
|
||||
Source: "oidc",
|
||||
ID: "platform-user",
|
||||
Username: "alice",
|
||||
Roles: []string{"basic"},
|
||||
ContextType: "tenant",
|
||||
TenantID: "external-tenant",
|
||||
Source: "oidc",
|
||||
}))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
@@ -146,7 +148,8 @@ func TestResolveOIDCMultiTenantProjectionUsesRuntimeTenantContext(t *testing.T)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
_, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
|
||||
ID: "shared-subject", TenantID: tenantID, Username: "alice", Roles: []string{"basic"},
|
||||
ID: "shared-subject", ContextType: "tenant", TenantID: tenantID,
|
||||
Username: "alice", Roles: []string{"basic"},
|
||||
}, runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve multi-tenant projection: %v", err)
|
||||
@@ -159,6 +162,40 @@ func TestResolveOIDCMultiTenantProjectionUsesRuntimeTenantContext(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOIDCPlatformProjectionSkipsTenantContext(t *testing.T) {
|
||||
applicationID := "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8"
|
||||
resolver := &fakeOIDCUserResolver{
|
||||
result: store.ResolveOrProvisionOIDCUserResult{
|
||||
User: &auth.User{GatewayUserID: "local-platform-user"},
|
||||
},
|
||||
}
|
||||
server := &Server{oidcUserResolver: resolver}
|
||||
runtime := &identityRequestRuntime{
|
||||
Revision: identity.Revision{
|
||||
Issuer: "https://auth.test.example", ApplicationID: applicationID,
|
||||
TenantMode: "multi_tenant", JITEnabled: true,
|
||||
},
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
_, err := server.resolveOIDCUserProjectionForRuntime(
|
||||
request.Context(),
|
||||
request,
|
||||
&auth.User{
|
||||
ID: "platform-subject", ContextType: "platform",
|
||||
Username: "platform-admin", Roles: []string{"admin"},
|
||||
},
|
||||
runtime,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve platform projection: %v", err)
|
||||
}
|
||||
if resolver.input.ContextType != "platform" ||
|
||||
resolver.input.TenantID != "" ||
|
||||
resolver.input.ApplicationID != applicationID {
|
||||
t.Fatalf("unexpected platform projection input: %+v", resolver.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOIDCMultiTenantProjectionTreatsTemporaryContextFailureAsPending(t *testing.T) {
|
||||
tenantID := "d9dcb4e7-6938-4547-af68-10ea404aa4b0"
|
||||
resolver := &fakeOIDCUserResolver{result: store.ResolveOrProvisionOIDCUserResult{User: &auth.User{GatewayUserID: "local-user"}}}
|
||||
@@ -172,7 +209,7 @@ func TestResolveOIDCMultiTenantProjectionTreatsTemporaryContextFailureAsPending(
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
|
||||
ID: "subject", TenantID: tenantID,
|
||||
ID: "subject", ContextType: "tenant", TenantID: tenantID,
|
||||
}, runtime); err != nil {
|
||||
t.Fatalf("temporary tenant context failure should reach fail-closed store projection: %v", err)
|
||||
}
|
||||
@@ -206,7 +243,7 @@ func TestResolveOIDCMultiTenantProjectionUsesFreshLocalTenantCache(t *testing.T)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
|
||||
ID: "subject", TenantID: tenantID,
|
||||
ID: "subject", ContextType: "tenant", TenantID: tenantID,
|
||||
}, runtime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -228,7 +265,7 @@ func TestResolveOIDCMultiTenantProjectionFallsBackToSyncedCacheOnTemporaryFailur
|
||||
server := &Server{oidcUserResolver: resolver}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
if _, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
|
||||
ID: "subject", TenantID: tenantID,
|
||||
ID: "subject", ContextType: "tenant", TenantID: tenantID,
|
||||
}, &identityRequestRuntime{
|
||||
Revision: identity.Revision{
|
||||
Issuer: "https://auth.test.example", ApplicationID: "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8",
|
||||
@@ -257,7 +294,7 @@ func TestResolveOIDCMultiTenantProjectionRevalidatesDisabledBindingBeforeReassig
|
||||
server := &Server{oidcUserResolver: resolver}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
_, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
|
||||
ID: "subject", TenantID: tenantID,
|
||||
ID: "subject", ContextType: "tenant", TenantID: tenantID,
|
||||
}, &identityRequestRuntime{
|
||||
Revision: identity.Revision{
|
||||
Issuer: "https://auth.test.example", ApplicationID: applicationID,
|
||||
@@ -283,7 +320,8 @@ func TestResolveOIDCMultiTenantProjectionKeepsDisabledBindingClosedDuringContext
|
||||
server := &Server{oidcUserResolver: resolver}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
_, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
|
||||
ID: "subject", TenantID: "d9dcb4e7-6938-4547-af68-10ea404aa4b0",
|
||||
ID: "subject", ContextType: "tenant",
|
||||
TenantID: "d9dcb4e7-6938-4547-af68-10ea404aa4b0",
|
||||
}, &identityRequestRuntime{
|
||||
Revision: identity.Revision{
|
||||
Issuer: "https://auth.test.example", ApplicationID: "0a3565fa-c0b0-4862-be20-d3a7b37bb7c8",
|
||||
@@ -313,7 +351,7 @@ func TestResolveOIDCMultiTenantProjectionRejectsMissingOrInactiveTenant(t *testi
|
||||
server := &Server{oidcUserResolver: resolver}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
_, err := server.resolveOIDCUserProjectionForRuntime(request.Context(), request, &auth.User{
|
||||
ID: "subject", TenantID: tenantID,
|
||||
ID: "subject", ContextType: "tenant", TenantID: tenantID,
|
||||
}, &identityRequestRuntime{
|
||||
Revision: identity.Revision{TenantMode: "multi_tenant"},
|
||||
TenantContext: test.reader,
|
||||
@@ -346,7 +384,8 @@ func TestResolveGatewayUserReturnsStructuredErrors(t *testing.T) {
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
request = request.WithContext(auth.WithUser(request.Context(), &auth.User{
|
||||
ID: "platform-user", Source: "oidc", TenantID: "external-tenant",
|
||||
ID: "platform-user", Source: "oidc",
|
||||
ContextType: "tenant", TenantID: "external-tenant",
|
||||
}))
|
||||
recorder := httptest.NewRecorder()
|
||||
server.resolveGatewayUser(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user