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
@@ -39,11 +39,12 @@ type identityRuntimeStatus struct {
}
type publicIdentityConfiguration struct {
Enabled bool `json:"enabled"`
OIDCLogin bool `json:"oidcLogin"`
LoginURL string `json:"loginUrl,omitempty"`
LogoutURL string `json:"logoutUrl,omitempty"`
Status string `json:"status"`
Enabled bool `json:"enabled"`
OIDCLogin bool `json:"oidcLogin"`
LoginURL string `json:"loginUrl,omitempty"`
LogoutURL string `json:"logoutUrl,omitempty"`
ContextTypes []string `json:"contextTypes,omitempty"`
Status string `json:"status"`
}
type identityPolicyPatch struct {
@@ -99,6 +100,10 @@ func (s *Server) getPublicIdentityConfiguration(w http.ResponseWriter, _ *http.R
view.OIDCLogin = true
view.LoginURL = "/api/v1/auth/oidc/login"
view.LogoutURL = "/api/v1/auth/oidc/logout"
view.ContextTypes = []string{"tenant"}
if runtime.Revision.TenantMode == "multi_tenant" {
view.ContextTypes = []string{"platform", "tenant"}
}
}
writeJSON(w, http.StatusOK, view)
}
+28 -4
View File
@@ -36,6 +36,8 @@ const (
// @Description Gateway 生成 state、nonce 和 PKCE S256 参数,并跳转认证中心;浏览器不接触 Token。
// @Tags auth
// @Param returnTo query string false "登录后返回的站内相对路径"
// @Param contextType query string true "显式登录上下文:platform 或 tenant"
// @Param tenantHint query string false "Tenant 上下文可选的稳定 Tenant UUID"
// @Success 303
// @Failure 400 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
@@ -50,12 +52,22 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
if returnTo == "" {
returnTo = "/"
}
contextType := strings.TrimSpace(r.URL.Query().Get("contextType"))
tenantHint := strings.TrimSpace(r.URL.Query().Get("tenantHint"))
if tenantHint != "" && (runtime.Revision.TenantMode != "multi_tenant" || uuid.Validate(tenantHint) != nil) {
writeError(w, http.StatusBadRequest, "租户提示无效", errorCodeOIDCLoginInvalid)
validContext := contextType == "tenant" ||
contextType == "platform" &&
runtime.Revision.TenantMode == "multi_tenant"
validTenantHint := tenantHint == "" ||
contextType == "tenant" &&
runtime.Revision.TenantMode == "multi_tenant" &&
uuid.Validate(tenantHint) == nil
if !validContext || !validTenantHint {
writeError(w, http.StatusBadRequest, "登录上下文无效", errorCodeOIDCLoginInvalid)
return
}
transaction, err := oidcsession.NewLoginTransactionWithTenantHint(returnTo, tenantHint, time.Now())
transaction, err := oidcsession.NewLoginTransactionWithContext(
returnTo, contextType, tenantHint, time.Now(),
)
if err != nil {
writeError(w, http.StatusBadRequest, "登录后返回地址无效", errorCodeOIDCLoginInvalid)
return
@@ -67,7 +79,9 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
return
}
authorizationURL, err := runtime.PublicClient.AuthorizationURL(
r.Context(), transaction.State, transaction.Nonce, transaction.PKCEVerifier, transaction.TenantHint,
r.Context(), transaction.State, transaction.Nonce,
transaction.PKCEVerifier, transaction.ContextType,
transaction.TenantHint,
)
if err != nil {
s.logger.ErrorContext(r.Context(), "load OIDC authorization endpoint failed", "error", err)
@@ -127,6 +141,16 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
s.writeOIDCTokenFailure(w, r, "ACCESS_TOKEN_INVALID", auth.OIDCValidationCategory(err), "认证中心访问令牌校验失败")
return
}
if identity.ContextType != transaction.ContextType ||
transaction.TenantHint != "" &&
identity.TenantID != transaction.TenantHint {
s.writeOIDCTokenFailure(
w, r, "ACCESS_TOKEN_CONTEXT_MISMATCH",
"STABLE_IDENTITY_CLAIMS_INVALID",
"认证中心访问令牌上下文与登录入口不匹配",
)
return
}
idSubject, err := runtime.PublicClient.VerifyIDToken(r.Context(), tokens.IDToken, transaction.Nonce)
if err != nil || idSubject != identity.ID {
s.writeOIDCTokenFailure(w, r, "ID_TOKEN_INVALID", auth.OIDCValidationCategory(err), "认证中心身份令牌校验失败")
+37 -7
View File
@@ -25,9 +25,10 @@ func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testin
server := &Server{
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: client,
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher,
identityTestRevision: identity.Revision{TenantMode: "multi_tenant"},
identityTestCookieSecure: true, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?returnTo=%2Fworkspace%3Ftab%3Dwallet", nil)
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?contextType=platform&returnTo=%2Fworkspace%3Ftab%3Dwallet", nil)
recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, request)
response := recorder.Result()
@@ -40,10 +41,11 @@ func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testin
t.Fatalf("unsafe login transaction cookie: %#v", cookies)
}
transaction, err := cipher.DecodeLoginTransaction(cookies[0].Value, cookies[0].Expires.Add(-time.Minute))
if err != nil || transaction.ReturnTo != "/workspace?tab=wallet" {
if err != nil || transaction.ContextType != "platform" || transaction.ReturnTo != "/workspace?tab=wallet" {
t.Fatalf("transaction=%#v err=%v", transaction, err)
}
if client.state == "" || client.nonce == "" || client.challenge == "" {
if client.state == "" || client.nonce == "" || client.challenge == "" ||
client.contextType != "platform" {
t.Fatal("authorization redirect omitted state, nonce or PKCE challenge")
}
}
@@ -55,7 +57,7 @@ func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) {
oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?returnTo=https%3A%2F%2Fevil.example", nil))
server.startOIDCLogin(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?contextType=platform&returnTo=https%3A%2F%2Fevil.example", nil))
if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Set-Cookie") != "" {
t.Fatalf("open redirect status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie"))
}
@@ -73,7 +75,7 @@ func TestStartOIDCLoginEncryptsAndForwardsMultiTenantHint(t *testing.T) {
tenantHint := "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, httptest.NewRequest(
http.MethodGet, "/api/v1/auth/oidc/login?tenantHint="+tenantHint, nil,
http.MethodGet, "/api/v1/auth/oidc/login?contextType=tenant&tenantHint="+tenantHint, nil,
))
if recorder.Code != http.StatusSeeOther || client.tenantHint != tenantHint {
t.Fatalf("status=%d forwarded tenantHint=%q", recorder.Code, client.tenantHint)
@@ -103,7 +105,7 @@ func TestStartOIDCLoginRejectsInvalidOrSingleTenantHint(t *testing.T) {
}
recorder := httptest.NewRecorder()
server.startOIDCLogin(recorder, httptest.NewRequest(
http.MethodGet, "/api/v1/auth/oidc/login?tenantHint="+test.hint, nil,
http.MethodGet, "/api/v1/auth/oidc/login?contextType=tenant&tenantHint="+test.hint, nil,
))
if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Set-Cookie") != "" {
t.Fatalf("status=%d cookie=%q", recorder.Code, recorder.Header().Get("Set-Cookie"))
@@ -112,6 +114,32 @@ func TestStartOIDCLoginRejectsInvalidOrSingleTenantHint(t *testing.T) {
}
}
func TestStartOIDCLoginRejectsMissingOrIncompatibleContext(t *testing.T) {
for _, path := range []string{
"/api/v1/auth/oidc/login",
"/api/v1/auth/oidc/login?contextType=account",
"/api/v1/auth/oidc/login?contextType=platform&tenantHint=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
} {
cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
server := &Server{
auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}},
oidcClient: &fakeOIDCClient{}, oidcSessions: &fakeOIDCSessions{},
oidcSessionCipher: cipher,
identityTestRevision: identity.Revision{TenantMode: "multi_tenant"},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
recorder := httptest.NewRecorder()
server.startOIDCLogin(
recorder,
httptest.NewRequest(http.MethodGet, path, nil),
)
if recorder.Code != http.StatusBadRequest ||
recorder.Header().Get("Set-Cookie") != "" {
t.Fatalf("path=%q status=%d cookie=%q", path, recorder.Code, recorder.Header().Get("Set-Cookie"))
}
}
}
func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) {
cipher, err := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32))
if err != nil {
@@ -511,12 +539,14 @@ func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) {
type fakeOIDCClient struct {
authorizationURL string
state, nonce, challenge string
contextType string
tenantHint string
revokedRefreshToken string
}
func (f *fakeOIDCClient) AuthorizationURL(_ context.Context, state, nonce, challenge, tenantHint string) (string, error) {
func (f *fakeOIDCClient) AuthorizationURL(_ context.Context, state, nonce, challenge, contextType, tenantHint string) (string, error) {
f.state, f.nonce, f.challenge = state, nonce, challenge
f.contextType = contextType
f.tenantHint = tenantHint
return f.authorizationURL, nil
}
@@ -70,13 +70,30 @@ func (s *Server) resolveOIDCUserProjectionForRuntime(ctx context.Context, r *htt
return store.ResolveOrProvisionOIDCUserResult{}, errors.New("OIDC user resolver is unavailable")
}
revision := runtime.Revision
switch user.ContextType {
case "platform":
if revision.TenantMode != "multi_tenant" ||
strings.TrimSpace(user.TenantID) != "" {
return store.ResolveOrProvisionOIDCUserResult{},
errors.New("platform OIDC context is incompatible with the active identity revision")
}
case "tenant":
if strings.TrimSpace(user.TenantID) == "" {
return store.ResolveOrProvisionOIDCUserResult{},
errors.New("tenant OIDC context is missing its tenant")
}
default:
return store.ResolveOrProvisionOIDCUserResult{},
errors.New("OIDC context type is missing or invalid")
}
tenantName := ""
tenantSlug := ""
tenantMetadataStatus := ""
tenantMetadataVersion := ""
tenantMetadataETag := ""
var tenantMetadataUpdatedAt time.Time
if revision.TenantMode == "multi_tenant" {
if user.ContextType == "tenant" &&
revision.TenantMode == "multi_tenant" {
if runtime.TenantContext == nil {
return store.ResolveOrProvisionOIDCUserResult{}, errors.New("tenant context runtime is unavailable")
}
@@ -151,6 +168,7 @@ func (s *Server) resolveOIDCUserProjectionWithTenantContext(
Subject: user.ID,
Username: user.Username,
Roles: user.Roles,
ContextType: user.ContextType,
TenantID: user.TenantID,
TenantMode: revision.TenantMode,
TenantName: tenantName,
@@ -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) {
+1 -1
View File
@@ -52,7 +52,7 @@ type Server struct {
}
type oidcPublicClient interface {
AuthorizationURL(context.Context, string, string, string, string) (string, error)
AuthorizationURL(context.Context, string, string, string, string, string) (string, error)
ExchangeCode(context.Context, string, string) (auth.OIDCTokenResponse, error)
VerifyIDToken(context.Context, string, string) (string, error)
Refresh(context.Context, string) (auth.OIDCTokenResponse, error)