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) }