feat(identity): 接入认证中心多租户登录
支持 Manifest V2 动态 tid 验证、Tenant Context 同步和租户内 JIT 投影,并保留 Manifest V1 与旧 Session 兼容。\n\n增加 tenantHint、租户切换、普通注册关闭及 application/principal/tenant 两级 SSF 撤销;迁移、定向安全测试和本地双租户跨仓 E2E 已通过。\n\nrelease_required=true;未执行 Release、Staging 或真实链路。
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
@@ -22,6 +23,10 @@ type oidcUserResolver interface {
|
||||
ResolveOrProvisionOIDCUser(context.Context, store.ResolveOrProvisionOIDCUserInput) (store.ResolveOrProvisionOIDCUserResult, error)
|
||||
}
|
||||
|
||||
type oidcTenantBindingReader interface {
|
||||
OIDCTenantBindingContext(context.Context, string, string, string) (store.OIDCTenantBindingContext, error)
|
||||
}
|
||||
|
||||
func (s *Server) requireUser(permission auth.Permission, next http.Handler) http.Handler {
|
||||
return s.auth.Require(permission, s.resolveGatewayUser(next))
|
||||
}
|
||||
@@ -57,26 +62,119 @@ func (s *Server) resolveOIDCUserProjection(ctx context.Context, r *http.Request,
|
||||
if runtime == nil {
|
||||
return store.ResolveOrProvisionOIDCUserResult{}, errors.New("active identity runtime is unavailable")
|
||||
}
|
||||
return s.resolveOIDCUserProjectionForRevision(ctx, r, user, runtime.Revision)
|
||||
return s.resolveOIDCUserProjectionForRuntime(ctx, r, user, runtime)
|
||||
}
|
||||
|
||||
func (s *Server) resolveOIDCUserProjectionForRevision(ctx context.Context, r *http.Request, user *auth.User, revision identity.Revision) (store.ResolveOrProvisionOIDCUserResult, error) {
|
||||
func (s *Server) resolveOIDCUserProjectionForRuntime(ctx context.Context, r *http.Request, user *auth.User, runtime *identityRequestRuntime) (store.ResolveOrProvisionOIDCUserResult, error) {
|
||||
if s.oidcUserResolver == nil {
|
||||
return store.ResolveOrProvisionOIDCUserResult{}, errors.New("OIDC user resolver is unavailable")
|
||||
}
|
||||
revision := runtime.Revision
|
||||
tenantName := ""
|
||||
tenantSlug := ""
|
||||
tenantMetadataStatus := ""
|
||||
tenantMetadataVersion := ""
|
||||
tenantMetadataETag := ""
|
||||
var tenantMetadataUpdatedAt time.Time
|
||||
if revision.TenantMode == "multi_tenant" {
|
||||
if runtime.TenantContext == nil {
|
||||
return store.ResolveOrProvisionOIDCUserResult{}, errors.New("tenant context runtime is unavailable")
|
||||
}
|
||||
var cached store.OIDCTenantBindingContext
|
||||
bindingDisabled := false
|
||||
if reader, ok := s.oidcUserResolver.(oidcTenantBindingReader); ok {
|
||||
cached, _ = reader.OIDCTenantBindingContext(ctx, revision.Issuer, revision.ApplicationID, user.TenantID)
|
||||
bindingDisabled = cached.ID != "" && cached.AccessStatus != "active"
|
||||
if !bindingDisabled && cached.MetadataStatus == "synced" && cached.NextSyncAt.After(time.Now()) {
|
||||
tenantName, tenantSlug = cached.DisplayName, cached.Slug
|
||||
tenantMetadataStatus, tenantMetadataVersion = "synced", cached.Version
|
||||
tenantMetadataETag, tenantMetadataUpdatedAt = cached.ETag, cached.MetadataUpdatedAt
|
||||
}
|
||||
}
|
||||
if tenantMetadataStatus == "synced" {
|
||||
return s.resolveOIDCUserProjectionWithTenantContext(ctx, r, user, revision,
|
||||
tenantName, tenantSlug, tenantMetadataStatus, tenantMetadataVersion, tenantMetadataETag, tenantMetadataUpdatedAt)
|
||||
}
|
||||
etag := cached.ETag
|
||||
if bindingDisabled {
|
||||
// A revoked binding can only be reactivated after a fresh positive
|
||||
// Tenant Context response. Do not let a stale 304 or cached profile
|
||||
// reopen access after reassignment.
|
||||
etag = ""
|
||||
}
|
||||
tenant, unchanged, err := runtime.TenantContext.Get(ctx, user.TenantID, etag)
|
||||
switch {
|
||||
case err == nil && unchanged && !bindingDisabled && cached.MetadataStatus == "synced":
|
||||
tenantName, tenantSlug = cached.DisplayName, cached.Slug
|
||||
tenantMetadataStatus, tenantMetadataVersion = "synced", cached.Version
|
||||
tenantMetadataETag, tenantMetadataUpdatedAt = cached.ETag, cached.MetadataUpdatedAt
|
||||
case err == nil && tenant.Active():
|
||||
tenantName = tenant.DisplayName
|
||||
tenantSlug = tenant.Slug
|
||||
tenantMetadataStatus = "synced"
|
||||
tenantMetadataVersion = tenant.Version
|
||||
tenantMetadataETag = tenant.ETag
|
||||
tenantMetadataUpdatedAt = tenant.UpdatedAt
|
||||
case err == nil, errors.Is(err, identity.ErrTenantContextNotFound):
|
||||
return store.ResolveOrProvisionOIDCUserResult{}, store.ErrOIDCTenantUnavailable
|
||||
case errors.Is(err, identity.ErrTenantContextUnavailable):
|
||||
if bindingDisabled {
|
||||
return store.ResolveOrProvisionOIDCUserResult{}, store.ErrOIDCTenantUnavailable
|
||||
}
|
||||
if cached.MetadataStatus == "synced" {
|
||||
tenantName, tenantSlug = cached.DisplayName, cached.Slug
|
||||
tenantMetadataStatus, tenantMetadataVersion = "synced", cached.Version
|
||||
tenantMetadataETag, tenantMetadataUpdatedAt = cached.ETag, cached.MetadataUpdatedAt
|
||||
} else {
|
||||
tenantName = oidcTenantPlaceholderName(user.TenantID)
|
||||
tenantMetadataStatus = "metadata_pending"
|
||||
}
|
||||
default:
|
||||
return store.ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
}
|
||||
return s.resolveOIDCUserProjectionWithTenantContext(ctx, r, user, revision,
|
||||
tenantName, tenantSlug, tenantMetadataStatus, tenantMetadataVersion, tenantMetadataETag, tenantMetadataUpdatedAt)
|
||||
}
|
||||
|
||||
func (s *Server) resolveOIDCUserProjectionWithTenantContext(
|
||||
ctx context.Context,
|
||||
r *http.Request,
|
||||
user *auth.User,
|
||||
revision identity.Revision,
|
||||
tenantName, tenantSlug, tenantMetadataStatus, tenantMetadataVersion, tenantMetadataETag string,
|
||||
tenantMetadataUpdatedAt time.Time,
|
||||
) (store.ResolveOrProvisionOIDCUserResult, error) {
|
||||
return s.oidcUserResolver.ResolveOrProvisionOIDCUser(ctx, store.ResolveOrProvisionOIDCUserInput{
|
||||
Issuer: revision.Issuer,
|
||||
Subject: user.ID,
|
||||
Username: user.Username,
|
||||
Roles: user.Roles,
|
||||
TenantID: user.TenantID,
|
||||
GatewayTenantKey: revision.LocalTenantKey,
|
||||
ProvisioningEnabled: revision.JITEnabled,
|
||||
RequestIP: limitAuditText(requestIP(r), 128),
|
||||
UserAgent: limitAuditText(r.UserAgent(), 512),
|
||||
Issuer: revision.Issuer,
|
||||
ApplicationID: revision.ApplicationID,
|
||||
Subject: user.ID,
|
||||
Username: user.Username,
|
||||
Roles: user.Roles,
|
||||
TenantID: user.TenantID,
|
||||
TenantMode: revision.TenantMode,
|
||||
TenantName: tenantName,
|
||||
TenantSlug: tenantSlug,
|
||||
TenantMetadataStatus: tenantMetadataStatus,
|
||||
TenantMetadataVersion: tenantMetadataVersion,
|
||||
TenantMetadataETag: tenantMetadataETag,
|
||||
TenantMetadataUpdatedAt: tenantMetadataUpdatedAt,
|
||||
OIDCClientID: user.OIDCClientID,
|
||||
GatewayTenantKey: revision.LocalTenantKey,
|
||||
ProvisioningEnabled: revision.JITEnabled,
|
||||
RequestIP: limitAuditText(requestIP(r), 128),
|
||||
UserAgent: limitAuditText(r.UserAgent(), 512),
|
||||
})
|
||||
}
|
||||
|
||||
func oidcTenantPlaceholderName(tenantID string) string {
|
||||
compact := strings.ReplaceAll(strings.TrimSpace(tenantID), "-", "")
|
||||
if len(compact) > 8 {
|
||||
compact = compact[:8]
|
||||
}
|
||||
return "认证中心租户 " + compact
|
||||
}
|
||||
|
||||
func (s *Server) writeOIDCUserResolutionError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrOIDCUserNotProvisioned):
|
||||
|
||||
Reference in New Issue
Block a user