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:
2026-07-28 17:28:35 +08:00
parent 0b02e62c72
commit 5c679ff13f
45 changed files with 2986 additions and 139 deletions
+29 -6
View File
@@ -99,14 +99,18 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi
if err := identity.ValidateRevisionURLs(revision, builder.config.AppEnv); err != nil {
return nil, err
}
if builder.store == nil || builder.secrets == nil || revision.Issuer == "" || revision.TenantID == "" ||
revision.Audience == "" || revision.RolePrefix == "" {
if builder.store == nil || builder.secrets == nil || revision.Issuer == "" ||
revision.Audience == "" || revision.RolePrefix == "" ||
revision.TenantMode != "multi_tenant" && revision.TenantID == "" ||
revision.TenantMode == "multi_tenant" && (revision.ApplicationID == "" || revision.TenantContextEndpoint == "") {
return nil, errors.New("identity runtime configuration is incomplete")
}
if exists, err := builder.store.HasActiveTenantKey(ctx, revision.LocalTenantKey); err != nil {
return nil, err
} else if !exists {
return nil, identity.ErrLocalTenantInvalid
if revision.TenantMode != "multi_tenant" {
if exists, err := builder.store.HasActiveTenantKey(ctx, revision.LocalTenantKey); err != nil {
return nil, err
} else if !exists {
return nil, identity.ErrLocalTenantInvalid
}
}
runtimeCtx, cancel := context.WithCancel(builder.ctx)
runtime := &Runtime{Revision: revision, CookieSecure: secureCookieFor(revision.PublicBaseURL), close: cancel}
@@ -151,6 +155,7 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi
verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{
AppEnv: builder.config.AppEnv,
Issuer: revision.Issuer, Audience: revision.Audience, TenantID: revision.TenantID,
TenantMode: revision.TenantMode, ApplicationID: revision.ApplicationID, ClientID: revision.BrowserClientID,
RolePrefix: revision.RolePrefix, RequiredScopes: append([]string(nil), revision.Scopes...),
JWKSCacheTTL: builder.config.JWKSCacheTTL, IntrospectionEnabled: revision.TokenIntrospection,
IntrospectionCredentialProvider: credentialProvider, SecurityEventEvaluator: evaluator,
@@ -166,6 +171,23 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi
return nil, err
}
runtime.Verifier = verifier
if revision.TenantMode == "multi_tenant" {
tenantContext, err := identity.NewTenantContextClient(
builder.config.AppEnv, revision.Issuer, revision.ApplicationID,
revision.TenantContextEndpoint, revision.TenantContextAudience, revision.TenantContextScope,
credentialProvider, nil,
)
if err != nil {
cancel()
return nil, err
}
runtime.TenantContext = tenantContext
runtime.start = func() {
go runTenantContextSynchronizer(
runtimeCtx, builder.store, tenantContext, revision.Issuer, revision.ApplicationID,
)
}
}
if slices.Contains(revision.Capabilities, "oidc_login") {
if revision.BrowserClientID == "" || revision.SessionEncryptionKeyRef == "" {
@@ -396,6 +418,7 @@ func (builder *RuntimeBuilder) newSecurityEventManager(ctx context.Context, revi
func (builder *RuntimeBuilder) securityEventManagerConfig(revision identity.Revision, strictRevisionBinding bool) securityevents.ConnectionManagerConfig {
return securityevents.ConnectionManagerConfig{
AppEnv: builder.config.AppEnv, OIDCEnabled: true, OIDCIssuer: revision.Issuer, OIDCTenantID: revision.TenantID,
OIDCApplicationID: revision.ApplicationID, OIDCTenantMode: revision.TenantMode,
ManagementClientID: revision.MachineClientID, PublicBaseURL: revision.PublicBaseURL,
ExpectedTransmitterIssuer: strings.TrimRight(strings.TrimSpace(revision.SecurityEventIssuer), "/"),
ExpectedAudience: revision.SecurityEventAudience,
@@ -40,11 +40,20 @@ type Runtime struct {
Sessions *oidcsession.Service
SessionCipher *oidcsession.Cipher
SecurityEvents *securityevents.ConnectionManager
TenantContext *identity.TenantContextClient
CookieSecure bool
close func()
start func()
startOnce sync.Once
securityEventDisconnector SecurityEventDisconnector
}
func (runtime *Runtime) Start() {
if runtime != nil && runtime.start != nil {
runtime.startOnce.Do(runtime.start)
}
}
func (runtime *Runtime) Close() {
if runtime != nil && runtime.close != nil {
runtime.close()
@@ -378,6 +387,7 @@ func (manager *Manager) publishRuntime(candidate *Runtime, revision identity.Rev
manager.rememberTrustedWebBaseURL(revision.WebBaseURL)
manager.legacyJWTAllowed.Store(revision.LegacyJWTEnabled)
old := manager.current.Swap(candidate)
candidate.Start()
manager.adoptPreparedSecurityEvents(candidate, revision.ID)
manager.reconciliationRequired.Store(false)
retireRuntime(old)
@@ -0,0 +1,76 @@
package identityruntime
import (
"context"
"errors"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type tenantContextSyncRepository interface {
DueOIDCTenantBindingSyncs(context.Context, string, string, int) ([]store.OIDCTenantBindingSyncTarget, error)
ApplyOIDCTenantBindingSync(context.Context, string, identity.TenantContext, bool, time.Time) error
RejectOIDCTenantBinding(context.Context, string, string, time.Time) error
FailOIDCTenantBindingSync(context.Context, string, string, time.Time) error
}
type tenantContextSyncReader interface {
Get(context.Context, string, string) (identity.TenantContext, bool, error)
}
func runTenantContextSynchronizer(ctx context.Context, repository tenantContextSyncRepository, reader tenantContextSyncReader, issuer, applicationID string) {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
_ = synchronizeDueTenantContexts(ctx, repository, reader, issuer, applicationID, now.UTC())
}
}
}
func synchronizeDueTenantContexts(ctx context.Context, repository tenantContextSyncRepository, reader tenantContextSyncReader, issuer, applicationID string, now time.Time) error {
targets, err := repository.DueOIDCTenantBindingSyncs(ctx, issuer, applicationID, 50)
if err != nil {
return err
}
for _, target := range targets {
tenant, unchanged, readErr := reader.Get(ctx, target.ExternalTenantID, target.ETag)
switch {
case readErr == nil && unchanged:
err = repository.ApplyOIDCTenantBindingSync(ctx, target.ID, identity.TenantContext{}, true, now)
case readErr == nil && tenant.Active():
err = repository.ApplyOIDCTenantBindingSync(ctx, target.ID, tenant, false, now)
case readErr == nil:
err = repository.RejectOIDCTenantBinding(ctx, target.ID, "tenant_inactive", now)
case errors.Is(readErr, identity.ErrTenantContextNotFound):
err = repository.RejectOIDCTenantBinding(ctx, target.ID, "tenant_not_found", now)
default:
err = repository.FailOIDCTenantBindingSync(
ctx, target.ID, "tenant_context_unavailable", now.Add(tenantContextRetryDelay(target.FailureCount)),
)
}
if err != nil && !errors.Is(err, store.ErrOIDCTenantUnavailable) {
return err
}
}
return nil
}
func tenantContextRetryDelay(failureCount int) time.Duration {
if failureCount < 0 {
failureCount = 0
}
delay := 30 * time.Second
for index := 0; index < failureCount && delay < 5*time.Minute; index++ {
delay *= 2
}
if delay > 5*time.Minute {
return 5 * time.Minute
}
return delay
}
@@ -0,0 +1,113 @@
package identityruntime
import (
"context"
"errors"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type tenantSyncRepository struct {
targets []store.OIDCTenantBindingSyncTarget
synced []string
unchanged []string
rejected []string
failed []string
failureNext time.Time
}
func (repository *tenantSyncRepository) DueOIDCTenantBindingSyncs(context.Context, string, string, int) ([]store.OIDCTenantBindingSyncTarget, error) {
return repository.targets, nil
}
func (repository *tenantSyncRepository) ApplyOIDCTenantBindingSync(_ context.Context, id string, _ identity.TenantContext, unchanged bool, _ time.Time) error {
if unchanged {
repository.unchanged = append(repository.unchanged, id)
} else {
repository.synced = append(repository.synced, id)
}
return nil
}
func (repository *tenantSyncRepository) RejectOIDCTenantBinding(_ context.Context, id, _ string, _ time.Time) error {
repository.rejected = append(repository.rejected, id)
return nil
}
func (repository *tenantSyncRepository) FailOIDCTenantBindingSync(_ context.Context, id, _ string, next time.Time) error {
repository.failed = append(repository.failed, id)
repository.failureNext = next
return nil
}
type tenantSyncReader struct {
tenants map[string]identity.TenantContext
unchanged map[string]bool
errors map[string]error
}
func (reader tenantSyncReader) Get(_ context.Context, tenantID, _ string) (identity.TenantContext, bool, error) {
return reader.tenants[tenantID], reader.unchanged[tenantID], reader.errors[tenantID]
}
func TestSynchronizeDueTenantContextsAppliesETagRejectsAndBacksOff(t *testing.T) {
now := time.Unix(1_780_000_000, 0).UTC()
repository := &tenantSyncRepository{targets: []store.OIDCTenantBindingSyncTarget{
{ID: "binding-a", ExternalTenantID: "tenant-a", ETag: `"a"`, FailureCount: 0},
{ID: "binding-b", ExternalTenantID: "tenant-b", ETag: `"b"`, FailureCount: 0},
{ID: "binding-c", ExternalTenantID: "tenant-c", FailureCount: 3},
{ID: "binding-d", ExternalTenantID: "tenant-d", FailureCount: 0},
}}
reader := tenantSyncReader{
tenants: map[string]identity.TenantContext{
"tenant-a": {TenantStatus: "active", TenantApplicationStatus: "active"},
"tenant-d": {TenantStatus: "suspended", TenantApplicationStatus: "active"},
},
unchanged: map[string]bool{"tenant-b": true},
errors: map[string]error{
"tenant-c": identity.ErrTenantContextUnavailable,
},
}
if err := synchronizeDueTenantContexts(context.Background(), repository, reader, "issuer", "application", now); err != nil {
t.Fatal(err)
}
if len(repository.synced) != 1 || repository.synced[0] != "binding-a" ||
len(repository.unchanged) != 1 || repository.unchanged[0] != "binding-b" ||
len(repository.rejected) != 1 || repository.rejected[0] != "binding-d" ||
len(repository.failed) != 1 || repository.failed[0] != "binding-c" {
t.Fatalf("sync outcomes=%#v", repository)
}
if repository.failureNext.Sub(now) != 4*time.Minute {
t.Fatalf("retry delay=%s", repository.failureNext.Sub(now))
}
}
func TestSynchronizeDueTenantContextsTreatsNotFoundAsRevocation(t *testing.T) {
repository := &tenantSyncRepository{targets: []store.OIDCTenantBindingSyncTarget{
{ID: "binding-a", ExternalTenantID: "tenant-a"},
}}
reader := tenantSyncReader{
tenants: map[string]identity.TenantContext{}, unchanged: map[string]bool{},
errors: map[string]error{"tenant-a": identity.ErrTenantContextNotFound},
}
if err := synchronizeDueTenantContexts(
context.Background(), repository, reader, "issuer", "application", time.Now(),
); err != nil {
t.Fatal(err)
}
if len(repository.rejected) != 1 || len(repository.failed) != 0 {
t.Fatalf("not-found outcome=%#v", repository)
}
}
func TestTenantContextRetryDelayIsBounded(t *testing.T) {
if got := tenantContextRetryDelay(0); got != 30*time.Second {
t.Fatalf("first retry=%s", got)
}
if got := tenantContextRetryDelay(100); got != 5*time.Minute {
t.Fatalf("bounded retry=%s", got)
}
if !errors.Is(identity.ErrTenantContextUnavailable, identity.ErrTenantContextUnavailable) {
t.Fatal("sentinel error changed unexpectedly")
}
}