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