easyai-ai-gateway/apps/api/internal/identityruntime/manager.go
chengcheng a312ad880d fix(identity): 完善统一认证配对恢复与安全退役
修复 credentials_saved 状态无法恢复、配对与激活并发冲突,以及 SSF 和身份 Secret 生命周期不完整的问题。新增持久化协调器、取消与清理状态机、事务级并发门禁、受控 SSF 凭据交接、禁用后的延迟 Secret 清理,并对生产环境统一认证及 Discovery 端点强制 HTTPS。

验证:go test ./...;go test -race ./internal/auth ./internal/identity ./internal/identityruntime ./internal/securityevents ./internal/httpapi ./internal/store -count=1;go vet ./...;真实 PostgreSQL 并发及清理成功/冲突回滚测试;pnpm openapi。
2026-07-17 18:31:12 +08:00

477 lines
17 KiB
Go

package identityruntime
import (
"context"
"errors"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
)
type Repository interface {
IdentityConfigurationRevision(context.Context, string) (identity.Revision, error)
ActiveIdentityConfigurationRevision(context.Context) (identity.Revision, error)
MarkIdentityRevisionValidated(context.Context, string, int64, string, string) (identity.Revision, error)
RevalidateActiveIdentityRevision(context.Context, string, int64, string, string) (identity.Revision, error)
MarkIdentityRevisionFailed(context.Context, string, int64, string, string, string) (identity.Revision, error)
ActivateIdentityRevision(context.Context, string, int64, string, string) (identity.Revision, bool, error)
DisableActiveIdentityRevision(context.Context, int64, string, string) (identity.Revision, error)
}
type Builder interface {
Build(context.Context, identity.Revision) (*Runtime, error)
}
type SecurityEventDisconnector interface {
Disconnect(context.Context) (securityevents.ConnectionView, error)
}
type Runtime struct {
Revision identity.Revision
Verifier *auth.OIDCVerifier
PublicClient *auth.OIDCPublicClient
Sessions *oidcsession.Service
SessionCipher *oidcsession.Cipher
SecurityEvents *securityevents.ConnectionManager
CookieSecure bool
close func()
securityEventDisconnector SecurityEventDisconnector
}
func (runtime *Runtime) Close() {
if runtime != nil && runtime.close != nil {
runtime.close()
}
}
type Manager struct {
repository Repository
builder Builder
operation sync.Mutex
current atomic.Pointer[Runtime]
reconciliationRequired atomic.Bool
legacyJWTAllowed atomic.Bool
trustedWebBaseURL atomic.Pointer[string]
}
const identityRuntimeReconciliationTimeout = 5 * time.Second
func NewManager(repository Repository, builder Builder) *Manager {
manager := &Manager{repository: repository, builder: builder}
manager.legacyJWTAllowed.Store(true)
return manager
}
func (manager *Manager) Current() *Runtime {
return manager.current.Load()
}
func (manager *Manager) ReconciliationRequired() bool {
return manager.reconciliationRequired.Load()
}
func (manager *Manager) LegacyJWTEnabled() bool {
return manager.legacyJWTAllowed.Load()
}
// TrustedWebBaseURL is the persisted Active Revision's exact browser origin.
// It intentionally survives a fail-closed Runtime build so the local
// break-glass manager can still repair or disable a broken Active Revision.
func (manager *Manager) TrustedWebBaseURL() string {
value := manager.trustedWebBaseURL.Load()
if value == nil {
return ""
}
return *value
}
// SecurityEventReceiver resolves the request-time receiver. During first
// onboarding there is no Active Runtime yet, so the builder-owned prepared
// receiver must remain reachable for SSF Verification callbacks.
func (manager *Manager) SecurityEventReceiver() http.Handler {
provider, ok := manager.builder.(interface{ PreparedSecurityEventReceiver() http.Handler })
if ok {
if prepared := provider.PreparedSecurityEventReceiver(); prepared != nil {
return prepared
}
}
if runtime := manager.Current(); runtime != nil && runtime.SecurityEvents != nil {
return runtime.SecurityEvents
}
return manager.SecurityEventManager()
}
// SecurityEventManager resolves the manager used by administrative recovery
// operations. A prepared manager may represent an older persisted connection
// that must be retired before the first identity Runtime can be activated.
func (manager *Manager) SecurityEventManager() *securityevents.ConnectionManager {
if runtime := manager.Current(); runtime != nil && runtime.SecurityEvents != nil {
return runtime.SecurityEvents
}
managerProvider, ok := manager.builder.(interface {
PreparedSecurityEventManager() *securityevents.ConnectionManager
})
if ok {
return managerProvider.PreparedSecurityEventManager()
}
return nil
}
func (manager *Manager) LoadActive(ctx context.Context) error {
manager.operation.Lock()
defer manager.operation.Unlock()
revision, err := manager.repository.ActiveIdentityConfigurationRevision(ctx)
if errors.Is(err, identity.ErrRevisionNotFound) {
manager.publishDisabledRuntime()
return nil
}
if err != nil {
manager.failClosedRuntime()
return err
}
manager.rememberTrustedWebBaseURL(revision.WebBaseURL)
runtime, err := manager.builder.Build(ctx, revision)
if err != nil {
manager.failClosedRuntime()
return err
}
manager.publishRuntime(runtime, revision)
return nil
}
// ReconcileActive restores a fail-closed Runtime after an uncertain mutation
// outcome. It is safe to call periodically: a matching Active Runtime is left
// untouched, while a changed or missing Active Revision is published atomically.
func (manager *Manager) ReconcileActive(ctx context.Context) error {
manager.operation.Lock()
defer manager.operation.Unlock()
revision, err := manager.repository.ActiveIdentityConfigurationRevision(ctx)
if errors.Is(err, identity.ErrRevisionNotFound) {
manager.publishDisabledRuntime()
return nil
}
if err != nil {
manager.reconciliationRequired.Store(true)
return err
}
manager.rememberTrustedWebBaseURL(revision.WebBaseURL)
if current := manager.current.Load(); current != nil && current.Revision.ID == revision.ID && current.Revision.Version == revision.Version {
manager.reconciliationRequired.Store(false)
return nil
}
runtime, err := manager.builder.Build(ctx, revision)
if err != nil {
manager.reconciliationRequired.Store(true)
return err
}
manager.publishRuntime(runtime, revision)
return nil
}
func (manager *Manager) Validate(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
manager.operation.Lock()
defer manager.operation.Unlock()
revision, err := manager.repository.IdentityConfigurationRevision(ctx, id)
if err != nil {
return identity.Revision{}, err
}
if revision.Version != expectedVersion || revision.State != identity.RevisionDraft && revision.State != identity.RevisionActive {
return identity.Revision{}, identity.ErrRevisionConflict
}
candidate, buildErr := manager.builder.Build(ctx, revision)
if buildErr != nil {
if revision.State != identity.RevisionActive {
_, _ = manager.repository.MarkIdentityRevisionFailed(ctx, id, expectedVersion, "validation_failed", traceID, auditID)
}
return identity.Revision{}, buildErr
}
if revision.State == identity.RevisionActive {
revalidated, err := manager.repository.RevalidateActiveIdentityRevision(ctx, id, expectedVersion, traceID, auditID)
if err != nil {
candidate.Close()
return identity.Revision{}, err
}
manager.publishRuntime(candidate, revalidated)
return revalidated, nil
}
candidate.Close()
return manager.repository.MarkIdentityRevisionValidated(ctx, id, expectedVersion, traceID, auditID)
}
func (manager *Manager) Activate(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
manager.operation.Lock()
defer manager.operation.Unlock()
revision, err := manager.repository.IdentityConfigurationRevision(ctx, id)
if err != nil {
return identity.Revision{}, err
}
if revision.Version != expectedVersion || revision.State != identity.RevisionValidated {
return identity.Revision{}, identity.ErrRevisionConflict
}
candidate, err := manager.builder.Build(ctx, revision)
if err != nil {
return identity.Revision{}, err
}
activated, _, err := manager.repository.ActivateIdentityRevision(ctx, id, expectedVersion, traceID, auditID)
if err != nil {
return manager.reconcileActivationMutation(ctx, id, candidate, err)
}
manager.publishRuntime(candidate, activated)
return activated, nil
}
func (manager *Manager) Rollback(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
manager.operation.Lock()
defer manager.operation.Unlock()
revision, err := manager.repository.IdentityConfigurationRevision(ctx, id)
if err != nil {
return identity.Revision{}, err
}
if revision.Version != expectedVersion || revision.State != identity.RevisionSuperseded {
return identity.Revision{}, identity.ErrRevisionConflict
}
// Auth Center currently reuses and mutates OAuth/SSF resources. An older
// local Revision therefore cannot prove that its remote redirect URIs,
// scopes, audiences, or credentials still match. Require a fresh onboarding
// exchange until a versioned remote-resource handoff exists.
return identity.Revision{}, identity.ErrRollbackConfigurationHandoffRequired
}
func (manager *Manager) Disable(ctx context.Context, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
manager.operation.Lock()
defer manager.operation.Unlock()
if err := manager.retireActiveSecurityEventsBeforeDisable(ctx, expectedVersion); err != nil {
return identity.Revision{}, err
}
disabled, err := manager.repository.DisableActiveIdentityRevision(ctx, expectedVersion, traceID, auditID)
if err != nil {
return manager.reconcileDisableMutation(ctx, expectedVersion, err)
}
manager.publishDisabledRuntime()
return disabled, nil
}
func (manager *Manager) retireActiveSecurityEventsBeforeDisable(ctx context.Context, expectedVersion int64) error {
active, err := manager.repository.ActiveIdentityConfigurationRevision(ctx)
if err != nil {
return err
}
if active.Version != expectedVersion {
return identity.ErrRevisionConflict
}
if !active.SessionRevocation {
return nil
}
runtime := manager.current.Load()
var disconnector SecurityEventDisconnector
if runtime != nil && runtime.Revision.ID == active.ID {
disconnector = runtime.securityEventDisconnector
if disconnector == nil && runtime.SecurityEvents != nil {
disconnector = runtime.SecurityEvents
}
}
if disconnector == nil {
recoverer, ok := manager.builder.(interface {
RecoverSecurityEventDisconnector(context.Context, identity.Revision) (SecurityEventDisconnector, error)
})
if !ok {
return identity.ErrSecurityEventRetirementPending
}
var recoverErr error
disconnector, recoverErr = recoverer.RecoverSecurityEventDisconnector(ctx, active)
if recoverErr != nil {
return recoverErr
}
// A missing local connection means there is no bound local Stream or
// credential left to retire. The already-required audit record makes
// this fail-closed recovery decision traceable.
if disconnector == nil {
return nil
}
}
connection, err := disconnector.Disconnect(ctx)
if err != nil {
return err
}
if connection.LifecycleStatus != "retiring" {
return identity.ErrSecurityEventRetirementPending
}
return nil
}
func (manager *Manager) reconcileActivationMutation(ctx context.Context, targetID string, candidate *Runtime, mutationErr error) (identity.Revision, error) {
reconcileCtx, cancel := identityRuntimeReconciliationContext(ctx)
defer cancel()
active, err := manager.repository.ActiveIdentityConfigurationRevision(reconcileCtx)
if err == nil && active.ID == targetID && active.State == identity.RevisionActive {
manager.publishRuntime(candidate, active)
return active, nil
}
candidate.Close()
if errors.Is(err, identity.ErrRevisionNotFound) {
manager.publishDisabledRuntime()
return identity.Revision{}, mutationErr
}
if err != nil {
manager.failClosedRuntime()
return identity.Revision{}, mutationErr
}
current := manager.current.Load()
if current == nil || current.Revision.ID != active.ID {
manager.failClosedRuntime()
}
return identity.Revision{}, mutationErr
}
func (manager *Manager) reconcileRollbackValidation(ctx context.Context, targetID string, expectedVersion int64, mutationErr error) (identity.Revision, error) {
reconcileCtx, cancel := identityRuntimeReconciliationContext(ctx)
defer cancel()
revision, err := manager.repository.IdentityConfigurationRevision(reconcileCtx, targetID)
if err == nil && revision.State == identity.RevisionValidated && revision.Version == expectedVersion+1 {
return revision, nil
}
return identity.Revision{}, mutationErr
}
func (manager *Manager) reconcileDisableMutation(ctx context.Context, expectedVersion int64, mutationErr error) (identity.Revision, error) {
previous := manager.current.Load()
reconcileCtx, cancel := identityRuntimeReconciliationContext(ctx)
defer cancel()
active, err := manager.repository.ActiveIdentityConfigurationRevision(reconcileCtx)
if err == nil {
if previous == nil || active.ID != previous.Revision.ID {
manager.failClosedRuntime()
}
return identity.Revision{}, mutationErr
}
if !errors.Is(err, identity.ErrRevisionNotFound) {
manager.failClosedRuntime()
return identity.Revision{}, mutationErr
}
manager.publishDisabledRuntime()
if previous == nil || previous.Revision.ID == "" {
return identity.Revision{}, mutationErr
}
disabled, lookupErr := manager.repository.IdentityConfigurationRevision(reconcileCtx, previous.Revision.ID)
if lookupErr == nil && disabled.State == identity.RevisionSuperseded && disabled.Version == expectedVersion+1 {
return disabled, nil
}
return identity.Revision{}, mutationErr
}
func identityRuntimeReconciliationContext(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), identityRuntimeReconciliationTimeout)
}
func (manager *Manager) publishRuntime(candidate *Runtime, revision identity.Revision) {
candidate.Revision = revision
manager.rememberTrustedWebBaseURL(revision.WebBaseURL)
manager.legacyJWTAllowed.Store(revision.LegacyJWTEnabled)
old := manager.current.Swap(candidate)
manager.adoptPreparedSecurityEvents(candidate, revision.ID)
manager.reconciliationRequired.Store(false)
retireRuntime(old)
}
func (manager *Manager) publishDisabledRuntime() {
old := manager.current.Swap(nil)
manager.trustedWebBaseURL.Store(nil)
manager.legacyJWTAllowed.Store(true)
manager.reconciliationRequired.Store(false)
retireRuntime(old)
}
func (manager *Manager) failClosedRuntime() {
manager.legacyJWTAllowed.Store(false)
manager.reconciliationRequired.Store(true)
old := manager.current.Swap(nil)
retireRuntime(old)
}
func (manager *Manager) rememberTrustedWebBaseURL(value string) {
normalized := strings.TrimSpace(value)
if normalized == "" {
manager.trustedWebBaseURL.Store(nil)
return
}
manager.trustedWebBaseURL.Store(&normalized)
}
func (manager *Manager) PrepareSecurityEvents(ctx context.Context, revision identity.Revision, secret []byte) error {
manager.operation.Lock()
defer manager.operation.Unlock()
current, err := manager.repository.IdentityConfigurationRevision(ctx, revision.ID)
if err != nil {
return err
}
if current.State == identity.RevisionActive {
// Activation already crossed the prepared-manager adoption point. A
// stale restart restore must not insert a second Receiver afterwards.
return nil
}
if current.Version != revision.Version || current.State != identity.RevisionDraft && current.State != identity.RevisionValidated {
return identity.ErrRevisionConflict
}
preparer, ok := manager.builder.(interface {
PrepareSecurityEvents(context.Context, identity.Revision, []byte) error
})
if !ok {
return errors.New("security event runtime preparation is unavailable")
}
return preparer.PrepareSecurityEvents(ctx, current, secret)
}
func (manager *Manager) CleanupPreparedSecurityEvents(ctx context.Context, revision identity.Revision) error {
cleaner, ok := manager.builder.(interface {
CleanupPreparedSecurityEvents(context.Context, identity.Revision) error
})
if !ok {
return errors.New("security event runtime cleanup is unavailable")
}
return cleaner.CleanupPreparedSecurityEvents(ctx, revision)
}
func (manager *Manager) RetireConflictingSecurityEvents(ctx context.Context, revision identity.Revision) error {
resolver, ok := manager.builder.(interface {
RetireConflictingSecurityEvents(context.Context, identity.Revision) error
})
if !ok {
return errors.New("security event conflict recovery is unavailable")
}
return resolver.RetireConflictingSecurityEvents(ctx, revision)
}
func (manager *Manager) adoptPreparedSecurityEvents(runtime *Runtime, revisionID string) {
adopter, ok := manager.builder.(interface {
AdoptPreparedSecurityEvents(string) context.CancelFunc
})
if !ok {
return
}
cancel := adopter.AdoptPreparedSecurityEvents(revisionID)
if cancel == nil {
return
}
closeRuntime := runtime.close
runtime.close = func() {
if closeRuntime != nil {
closeRuntime()
}
cancel()
}
}
func retireRuntime(runtime *Runtime) {
if runtime == nil || runtime.close == nil {
return
}
time.AfterFunc(30*time.Second, runtime.Close)
}