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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user