easyai-ai-gateway/apps/api/internal/identity/pairing_service.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

633 lines
24 KiB
Go

package identity
import (
"context"
"crypto/rand"
"errors"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
type PairingStatus string
const (
PairingMetadataPending PairingStatus = "metadata_pending"
PairingPreparing PairingStatus = "preparing"
PairingReady PairingStatus = "ready"
PairingCredentialsSaved PairingStatus = "credentials_saved"
PairingCompleted PairingStatus = "completed"
PairingFailed PairingStatus = "failed"
PairingExpired PairingStatus = "expired"
PairingCancelled PairingStatus = "cancelled"
)
type PairingCleanupStatus string
const (
PairingCleanupNone PairingCleanupStatus = "none"
PairingCleanupPending PairingCleanupStatus = "pending"
PairingCleanupCompleted PairingCleanupStatus = "completed"
)
var (
ErrPairingInProgress = errors.New("an identity pairing is already in progress")
ErrPairingNotCancellable = errors.New("identity pairing cannot be cancelled")
ErrPairingConflictNotResolvable = errors.New("identity pairing has no resolvable security event conflict")
)
const (
identityPairingStartReservationTTL = 2 * time.Minute
identityPairingStartReleaseTimeout = 5 * time.Second
)
type PairingExchange struct {
ID string `json:"id"`
RevisionID string `json:"revisionId"`
RemoteExchangeID string `json:"remoteExchangeId"`
ExchangeTokenRef string `json:"-"`
Status PairingStatus `json:"status"`
CleanupStatus PairingCleanupStatus `json:"cleanupStatus"`
RemoteVersion int64 `json:"remoteVersion"`
ExpiresAt time.Time `json:"expiresAt"`
Version int64 `json:"version"`
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
AuthCenterAuditID string `json:"authCenterAuditId,omitempty"`
LastTraceID string `json:"lastTraceId,omitempty"`
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
CleanupCompletedAt *time.Time `json:"cleanupCompletedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PairingExchangeUpdate struct {
Status PairingStatus
RemoteVersion int64
LastErrorCategory string
AuthCenterAuditID string
}
type PairingRepository interface {
ReserveIdentityPairingStart(context.Context, string, time.Time) error
ReleaseIdentityPairingStart(context.Context, string) error
CommitIdentityPairingStart(context.Context, Revision, PairingExchange) (PairingExchange, error)
IdentityPairingStartBlocked(context.Context) (bool, error)
IdentityConfigurationRevision(context.Context, string) (Revision, error)
ApplyIdentityManifest(context.Context, string, int64, ManifestApplication) (Revision, error)
IdentityPairingExchange(context.Context, string) (PairingExchange, error)
UpdateIdentityPairingExchange(context.Context, string, int64, PairingExchangeUpdate) (PairingExchange, error)
RecordIdentityPairingRetryFailure(context.Context, string, PairingStatus, string) (PairingExchange, error)
CancelIdentityPairingExchange(context.Context, string, int64, string, string) (PairingExchange, error)
CompleteIdentityPairingCleanup(context.Context, string, int64) (PairingExchange, error)
RecordIdentityPairingCleanupFailure(context.Context, string, string) (PairingExchange, error)
QueueIdentitySecretCleanup(context.Context, string, time.Time) error
RemovePendingIdentitySecretCleanup(context.Context, string) (bool, error)
}
type PairingSecretStore interface {
Put(context.Context, string, []byte) error
Get(context.Context, string) ([]byte, error)
Delete(context.Context, string) error
}
type OnboardingRemote interface {
Claim(context.Context, string) (ClaimedExchange, error)
SubmitMetadata(context.Context, ClaimedExchange, ConsumerMetadata, string) (Exchange, error)
Get(context.Context, string, string) (Exchange, error)
DeliverCredential(context.Context, Exchange, string, string) (CredentialDelivery, error)
Complete(context.Context, string, string, string, int64) error
}
type SecurityEventPreparer interface {
PrepareSecurityEvents(context.Context, Revision, []byte) error
CleanupPreparedSecurityEvents(context.Context, Revision) error
}
type SecurityEventConflictResolver interface {
RetireConflictingSecurityEvents(context.Context, Revision) error
}
type PairingService struct {
repository PairingRepository
secrets PairingSecretStore
remote func(string) (OnboardingRemote, error)
security SecurityEventPreparer
appEnv string
operations sync.Map
}
func NewPairingService(repository PairingRepository, secrets PairingSecretStore, remote func(string) (OnboardingRemote, error), security SecurityEventPreparer, appEnvs ...string) *PairingService {
appEnv := "production"
if len(appEnvs) > 0 {
appEnv = appEnvs[0]
}
return &PairingService{repository: repository, secrets: secrets, remote: remote, security: security, appEnv: appEnv}
}
func (service *PairingService) Start(ctx context.Context, input PairingInput, traceID string) (PairingExchange, error) {
draft, err := NewDraft(input, service.appEnv)
if err != nil {
return PairingExchange{}, err
}
draft.LastTraceID = strings.TrimSpace(traceID)
pairingID := uuid.NewString()
tokenReference := "identity-exchange-" + pairingID
if err := service.repository.ReserveIdentityPairingStart(ctx, pairingID, time.Now().UTC().Add(identityPairingStartReservationTTL)); err != nil {
return PairingExchange{}, err
}
committed := false
defer func() {
if !committed {
releaseContext, cancel := context.WithTimeout(context.Background(), identityPairingStartReleaseTimeout)
defer cancel()
_ = service.repository.ReleaseIdentityPairingStart(releaseContext, pairingID)
}
}()
remote, err := service.remote(draft.AuthCenterURL)
if err != nil {
return PairingExchange{}, err
}
claimed, err := remote.Claim(ctx, strings.TrimSpace(input.OnboardingCode))
if err != nil {
return PairingExchange{}, err
}
token := []byte(claimed.ExchangeToken)
claimed.ExchangeToken = ""
if err := service.stageIdentitySecret(ctx, tokenReference, token); err != nil {
clear(token)
return PairingExchange{}, err
}
clear(token)
pairing := PairingExchange{
ID: pairingID, RevisionID: draft.ID, RemoteExchangeID: claimed.ExchangeID, ExchangeTokenRef: tokenReference,
Status: PairingMetadataPending, CleanupStatus: PairingCleanupNone, RemoteVersion: claimed.Version, ExpiresAt: claimed.ExpiresAt,
Version: 1, LastTraceID: strings.TrimSpace(traceID),
}
pairing, err = service.repository.CommitIdentityPairingStart(ctx, draft, pairing)
if err != nil {
// Commit may have succeeded even when its response was lost. The
// staging row is the source of truth: rollback leaves it queued, while
// a successful commit atomically adopts it. Re-queueing here could
// delete a Secret that the new Pairing already references.
return PairingExchange{}, err
}
committed = true
return pairing, nil
}
func (service *PairingService) Cancel(ctx context.Context, pairingID string, expectedVersion int64, traceID, auditID string) (PairingExchange, error) {
return service.repository.CancelIdentityPairingExchange(ctx, pairingID, expectedVersion, strings.TrimSpace(traceID), strings.TrimSpace(auditID))
}
func (service *PairingService) RetireConflictingSecurityEvents(ctx context.Context, pairingID string, expectedVersion int64) (PairingExchange, error) {
unlock := service.lockPairingOperation(pairingID)
defer unlock()
pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID)
if err != nil {
return PairingExchange{}, err
}
if pairing.Version != expectedVersion {
return pairing, ErrRevisionConflict
}
if pairing.Status != PairingCredentialsSaved || pairing.CleanupStatus != PairingCleanupNone ||
pairing.LastErrorCategory != "security_event_connection_conflict" {
return pairing, ErrPairingConflictNotResolvable
}
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
if err != nil {
return pairing, err
}
if revision.State != RevisionDraft || !revision.SessionRevocation {
return pairing, ErrPairingConflictNotResolvable
}
resolver, ok := service.security.(SecurityEventConflictResolver)
if !ok {
return pairing, errors.New("security event conflict recovery is unavailable")
}
if err := resolver.RetireConflictingSecurityEvents(ctx, revision); err != nil {
return pairing, err
}
return service.repository.RecordIdentityPairingRetryFailure(ctx, pairing.ID, pairing.Status, "security_event_retirement_pending")
}
// RestoreCompletedSecurityEvents reconstructs the prepared Receiver after a
// process restart while a completed Revision is still awaiting validation or
// activation. The machine Secret is read only from SecretStore and cleared
// immediately after use.
func (service *PairingService) RestoreCompletedSecurityEvents(ctx context.Context, pairingID string) error {
unlock := service.lockPairingOperation(pairingID)
defer unlock()
pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID)
if err != nil {
return err
}
if pairing.Status != PairingCompleted {
return ErrRevisionConflict
}
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
if err != nil {
return err
}
if revision.State == RevisionActive {
return nil
}
if revision.State != RevisionDraft && revision.State != RevisionValidated {
return ErrRevisionConflict
}
if !revision.SessionRevocation {
return nil
}
if service.security == nil || revision.MachineCredentialRef == "" {
return errors.New("completed security event configuration is unavailable")
}
secret, err := service.secrets.Get(ctx, revision.MachineCredentialRef)
if err != nil {
return err
}
defer clear(secret)
if err := service.security.PrepareSecurityEvents(ctx, revision, secret); err != nil {
return err
}
currentRevision, revisionErr := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
if revisionErr != nil {
return revisionErr
}
if currentRevision.State == RevisionActive {
return nil
}
currentPairing, pairingErr := service.repository.IdentityPairingExchange(ctx, pairingID)
if pairingErr != nil {
return pairingErr
}
if currentPairing.Status == PairingCompleted &&
(currentRevision.State == RevisionDraft || currentRevision.State == RevisionValidated) {
return nil
}
if cleanupErr := service.security.CleanupPreparedSecurityEvents(ctx, currentRevision); cleanupErr != nil {
return cleanupErr
}
return ErrRevisionConflict
}
func (service *PairingService) Cleanup(ctx context.Context, pairingID string) (PairingExchange, error) {
unlock := service.lockPairingOperation(pairingID)
defer unlock()
pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID)
if err != nil {
return PairingExchange{}, err
}
if pairing.Status != PairingCancelled {
return pairing, ErrPairingNotCancellable
}
if pairing.CleanupStatus == PairingCleanupCompleted {
return pairing, nil
}
if pairing.CleanupStatus != PairingCleanupPending {
return pairing, ErrRevisionConflict
}
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
if err != nil {
return service.recordCleanupFailure(ctx, pairing, "cleanup_revision_unavailable", err)
}
if revision.SessionRevocation {
if service.security == nil {
return service.recordCleanupFailure(ctx, pairing, "cleanup_security_event_unavailable", errors.New("security event cleanup is unavailable"))
}
if err := service.security.CleanupPreparedSecurityEvents(ctx, revision); err != nil {
return service.recordCleanupFailure(ctx, pairing, pairingCleanupFailureCategory(err), err)
}
}
for _, reference := range []string{pairing.ExchangeTokenRef, revision.MachineCredentialRef, revision.SessionEncryptionKeyRef} {
if reference == "" {
continue
}
if err := service.secrets.Delete(ctx, reference); err != nil {
return service.recordCleanupFailure(ctx, pairing, "cleanup_secret_store_failed", err)
}
}
completed, err := service.repository.CompleteIdentityPairingCleanup(ctx, pairing.ID, pairing.Version)
if err != nil {
return service.recordCleanupFailure(ctx, pairing, "cleanup_finalize_failed", err)
}
return completed, nil
}
func (service *PairingService) recordCleanupFailure(ctx context.Context, pairing PairingExchange, category string, cause error) (PairingExchange, error) {
if errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) {
return pairing, cause
}
recorded, err := service.repository.RecordIdentityPairingCleanupFailure(ctx, pairing.ID, category)
if err != nil {
return pairing, cause
}
return recorded, cause
}
func (service *PairingService) Continue(ctx context.Context, pairingID string) (PairingExchange, error) {
unlock := service.lockPairingOperation(pairingID)
defer unlock()
pairing, err := service.continuePairing(ctx, pairingID)
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return pairing, err
}
current, currentErr := service.repository.IdentityPairingExchange(ctx, pairingID)
if currentErr != nil || !isPairingInProgress(current.Status) {
return pairing, err
}
category := pairingRetryFailureCategory(current.Status, err)
recorded, recordErr := service.repository.RecordIdentityPairingRetryFailure(ctx, current.ID, current.Status, category)
if recordErr != nil {
return current, err
}
return recorded, err
}
func (service *PairingService) lockPairingOperation(pairingID string) func() {
value, _ := service.operations.LoadOrStore(pairingID, &sync.Mutex{})
mutex := value.(*sync.Mutex)
mutex.Lock()
return mutex.Unlock
}
func (service *PairingService) continuePairing(ctx context.Context, pairingID string) (PairingExchange, error) {
for step := 0; step < 6; step++ {
pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID)
if err != nil {
return PairingExchange{}, err
}
if pairing.Status == PairingCompleted || pairing.Status == PairingFailed || pairing.Status == PairingExpired || pairing.Status == PairingCancelled {
return pairing, nil
}
if !pairing.ExpiresAt.After(time.Now()) {
expired, updateErr := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
Status: PairingExpired, RemoteVersion: pairing.RemoteVersion, LastErrorCategory: "exchange_expired",
})
if updateErr == nil {
service.deleteRetiredIdentitySecret(ctx, pairing.ExchangeTokenRef)
}
return expired, updateErr
}
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
if err != nil {
return PairingExchange{}, err
}
token, err := service.secrets.Get(ctx, pairing.ExchangeTokenRef)
if err != nil {
return PairingExchange{}, err
}
remote, err := service.remote(revision.AuthCenterURL)
if err != nil {
clear(token)
return PairingExchange{}, err
}
switch pairing.Status {
case PairingMetadataPending:
metadata, metadataErr := PairingInput{
AuthCenterURL: revision.AuthCenterURL, PublicBaseURL: revision.PublicBaseURL,
WebBaseURL: revision.WebBaseURL, LocalTenantKey: revision.LocalTenantKey,
}.ConsumerMetadata(true, service.appEnv)
if metadataErr != nil {
clear(token)
return PairingExchange{}, metadataErr
}
view, submitErr := remote.SubmitMetadata(ctx, ClaimedExchange{
ExchangeID: pairing.RemoteExchangeID, ExchangeToken: string(token), ExpiresAt: pairing.ExpiresAt, Version: pairing.RemoteVersion,
}, metadata, "gateway-pairing-metadata-"+pairing.ID)
clear(token)
if submitErr != nil {
return PairingExchange{}, submitErr
}
if _, err := service.updateFromRemote(ctx, pairing, view); err != nil {
return PairingExchange{}, err
}
case PairingPreparing:
view, getErr := remote.Get(ctx, pairing.RemoteExchangeID, string(token))
clear(token)
if getErr != nil {
return PairingExchange{}, getErr
}
updated, err := service.updateFromRemote(ctx, pairing, view)
if err != nil {
return PairingExchange{}, err
}
if updated.Status == PairingPreparing {
return updated, nil
}
case PairingReady:
delivery, deliveryErr := remote.DeliverCredential(ctx, Exchange{
ExchangeID: pairing.RemoteExchangeID, ApplicationID: revision.ApplicationID,
Status: ExchangeReady, Version: pairing.RemoteVersion, ExpiresAt: pairing.ExpiresAt,
}, string(token), "gateway-pairing-credential-"+uuid.NewString())
clear(token)
if deliveryErr != nil {
return PairingExchange{}, deliveryErr
}
updatedRevision, saveErr := service.saveDelivery(ctx, revision, pairing, delivery)
if saveErr != nil {
return PairingExchange{}, saveErr
}
_ = updatedRevision
if _, err := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
Status: PairingCredentialsSaved, RemoteVersion: delivery.Version,
}); err != nil {
return PairingExchange{}, err
}
case PairingCredentialsSaved:
if revision.SessionRevocation {
if service.security == nil {
clear(token)
return PairingExchange{}, errors.New("security event preparation is unavailable")
}
secret, secretErr := service.secrets.Get(ctx, revision.MachineCredentialRef)
if secretErr != nil {
clear(token)
return PairingExchange{}, secretErr
}
prepareErr := service.security.PrepareSecurityEvents(ctx, revision, secret)
clear(secret)
if prepareErr != nil {
clear(token)
return PairingExchange{}, prepareErr
}
}
completeErr := remote.Complete(ctx, pairing.RemoteExchangeID, string(token), "gateway-pairing-complete-"+pairing.ID, pairing.RemoteVersion)
clear(token)
if completeErr != nil {
return PairingExchange{}, pairingStepError{category: "exchange_completion_failed", cause: completeErr}
}
completed, err := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
Status: PairingCompleted, RemoteVersion: pairing.RemoteVersion,
})
if err != nil {
return PairingExchange{}, err
}
service.deleteRetiredIdentitySecret(ctx, pairing.ExchangeTokenRef)
return completed, nil
default:
clear(token)
return PairingExchange{}, ErrRevisionConflict
}
}
return service.repository.IdentityPairingExchange(ctx, pairingID)
}
func isPairingInProgress(status PairingStatus) bool {
switch status {
case PairingMetadataPending, PairingPreparing, PairingReady, PairingCredentialsSaved:
return true
default:
return false
}
}
type safeErrorCategory interface {
SafeErrorCategory() string
}
type pairingStepError struct {
category string
cause error
}
func (err pairingStepError) Error() string { return "identity pairing step failed: " + err.category }
func (err pairingStepError) Unwrap() error { return err.cause }
func (err pairingStepError) SafeErrorCategory() string { return err.category }
func pairingRetryFailureCategory(status PairingStatus, err error) string {
var categorized safeErrorCategory
if errors.As(err, &categorized) && categorized.SafeErrorCategory() == "exchange_completion_failed" {
return "exchange_completion_failed"
}
switch status {
case PairingMetadataPending:
return "metadata_submission_failed"
case PairingPreparing:
return "exchange_status_unavailable"
case PairingReady:
return "credential_delivery_failed"
case PairingCredentialsSaved:
if errors.As(err, &categorized) {
switch categorized.SafeErrorCategory() {
case "configuration_invalid", "connection_conflict", "discovery_failed", "management_token_failed",
"stream_create_failed", "stream_response_invalid", "receiver_activation_failed", "preparation_failed", "retirement_pending",
"credential_handoff_unsafe", "connection_binding_missing", "connection_binding_unavailable",
"connection_binding_invalid", "connection_binding_mismatch":
return "security_event_" + categorized.SafeErrorCategory()
}
}
return "security_event_preparation_failed"
default:
return "pairing_step_failed"
}
}
func pairingCleanupFailureCategory(err error) string {
var categorized safeErrorCategory
if errors.As(err, &categorized) {
switch categorized.SafeErrorCategory() {
case "configuration_invalid", "retirement_pending", "connection_conflict", "connection_cleanup_failed", "secret_cleanup_failed",
"discovery_failed", "management_token_failed", "stream_create_failed", "stream_response_invalid",
"receiver_activation_failed", "preparation_failed", "credential_handoff_unsafe":
return "cleanup_security_event_" + categorized.SafeErrorCategory()
}
}
return "cleanup_security_event_failed"
}
func (service *PairingService) saveDelivery(ctx context.Context, revision Revision, pairing PairingExchange, delivery CredentialDelivery) (Revision, error) {
if err := delivery.Manifest.Validate(service.appEnv); err != nil {
return Revision{}, err
}
machineReference := ""
if delivery.Manifest.Clients.MachineToMachine != nil {
if delivery.MachineCredential == nil || delivery.MachineCredential.ClientID != delivery.Manifest.Clients.MachineToMachine.ClientID {
return Revision{}, errors.New("onboarding machine credential is invalid")
}
machineReference = "identity-machine-" + uuid.NewString()
secret := []byte(delivery.MachineCredential.ClientSecret)
delivery.MachineCredential.ClientSecret = ""
if err := service.stageIdentitySecret(ctx, machineReference, secret); err != nil {
clear(secret)
return Revision{}, err
}
clear(secret)
}
sessionReference := ""
if delivery.Manifest.Clients.BrowserLogin != nil {
sessionReference = "identity-session-" + uuid.NewString()
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
_ = service.retireIdentitySecret(ctx, machineReference)
return Revision{}, err
}
if err := service.stageIdentitySecret(ctx, sessionReference, key); err != nil {
clear(key)
_ = service.retireIdentitySecret(ctx, machineReference)
return Revision{}, err
}
clear(key)
}
updated, err := service.repository.ApplyIdentityManifest(ctx, revision.ID, revision.Version, ManifestApplication{
Manifest: delivery.Manifest, MachineCredentialRef: machineReference, SessionEncryptionKeyRef: sessionReference,
TraceID: pairing.LastTraceID, AuditID: pairing.AuthCenterAuditID, AppEnv: service.appEnv,
})
if err != nil {
// ApplyIdentityManifest adopts the staged references in the same DB
// transaction as the Revision update. On rollback the staging rows
// remain; on an ambiguous successful commit they are gone. Do not
// re-queue here or a valid active credential could be destroyed.
return Revision{}, err
}
return updated, nil
}
func (service *PairingService) stageIdentitySecret(ctx context.Context, reference string, value []byte) error {
if err := service.repository.QueueIdentitySecretCleanup(ctx, reference, time.Now().UTC().Add(10*time.Minute)); err != nil {
return err
}
if err := service.secrets.Put(ctx, reference, value); err != nil {
deleteErr := service.secrets.Delete(ctx, reference)
if deleteErr == nil {
_, _ = service.repository.RemovePendingIdentitySecretCleanup(ctx, reference)
}
return err
}
return nil
}
func (service *PairingService) retireIdentitySecret(ctx context.Context, reference string) error {
if reference == "" {
return nil
}
return service.repository.QueueIdentitySecretCleanup(ctx, reference, time.Now().UTC())
}
func (service *PairingService) deleteRetiredIdentitySecret(ctx context.Context, reference string) {
if err := service.secrets.Delete(ctx, reference); err == nil {
_, _ = service.repository.RemovePendingIdentitySecretCleanup(ctx, reference)
}
}
func (service *PairingService) updateFromRemote(ctx context.Context, pairing PairingExchange, remote Exchange) (PairingExchange, error) {
status := PairingPreparing
category := ""
switch remote.Status {
case ExchangeReady, ExchangeCredentialDelivered:
status = PairingReady
case ExchangeCompleted:
status = PairingFailed
category = "remote_state_invalid"
case ExchangeFailed:
status = PairingFailed
category = "remote_exchange_failed"
case ExchangeExpired:
status = PairingExpired
category = "exchange_expired"
}
return service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
Status: status, RemoteVersion: remote.Version, LastErrorCategory: category, AuthCenterAuditID: remote.AuditID,
})
}