修复 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。
477 lines
17 KiB
Go
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)
|
|
}
|