package store import ( "context" "encoding/json" "errors" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" "github.com/jackc/pgx/v5" ) const identityPairingColumns = ` id::text,revision_id::text,remote_exchange_id::text,exchange_token_ref,status,cleanup_status,remote_version,expires_at,version, COALESCE(last_error_category,''),COALESCE(auth_center_audit_id,''),COALESCE(last_trace_id,''),cancelled_at,cleanup_completed_at, created_at,updated_at` func (s *Store) ReserveIdentityPairingStart(ctx context.Context, attemptID string, expiresAt time.Time) error { tx, err := s.beginIdentityConfigurationLifecycleTx(ctx) if err != nil { return err } defer tx.Rollback(ctx) if _, err := tx.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation WHERE state='starting' AND expires_at <= now()`); err != nil { return err } var activeConfigurationExists bool if err := tx.QueryRow(ctx, `SELECT EXISTS( SELECT 1 FROM gateway_identity_configuration_revisions WHERE state='active' )`).Scan(&activeConfigurationExists); err != nil { return err } if activeConfigurationExists { return identity.ErrActiveConfigurationHandoffRequired } var blocked bool if err := tx.QueryRow(ctx, `SELECT EXISTS( SELECT 1 FROM gateway_identity_configuration_revisions revision JOIN gateway_identity_onboarding_exchanges exchange ON exchange.revision_id=revision.id WHERE revision.state IN ('draft','validated','failed') AND NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed') )`).Scan(&blocked); err != nil { return err } if blocked { return identity.ErrPairingInProgress } if _, err := tx.Exec(ctx, `INSERT INTO gateway_identity_pairing_start_reservation(singleton,attempt_id,state,expires_at) VALUES(true,$1::uuid,'starting',$2)`, attemptID, expiresAt); err != nil { if isUniqueViolation(err) { return identity.ErrPairingInProgress } return err } return tx.Commit(ctx) } func (s *Store) ReleaseIdentityPairingStart(ctx context.Context, attemptID string) error { _, err := s.pool.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation WHERE attempt_id=$1::uuid AND state='starting'`, attemptID) return err } func (s *Store) CommitIdentityPairingStart(ctx context.Context, revision identity.Revision, exchange identity.PairingExchange) (identity.PairingExchange, error) { tx, err := s.beginIdentityConfigurationLifecycleTx(ctx) if err != nil { return identity.PairingExchange{}, err } defer tx.Rollback(ctx) var reserved bool if err := tx.QueryRow(ctx, `SELECT true FROM gateway_identity_pairing_start_reservation WHERE singleton=true AND attempt_id=$1::uuid AND state='starting' FOR UPDATE`, exchange.ID).Scan(&reserved); errors.Is(err, pgx.ErrNoRows) { return identity.PairingExchange{}, identity.ErrPairingInProgress } else if err != nil { return identity.PairingExchange{}, err } if !reserved { return identity.PairingExchange{}, identity.ErrPairingInProgress } var activeConfigurationExists bool if err := tx.QueryRow(ctx, `SELECT EXISTS( SELECT 1 FROM gateway_identity_configuration_revisions WHERE state='active' )`).Scan(&activeConfigurationExists); err != nil { return identity.PairingExchange{}, err } if activeConfigurationExists { return identity.PairingExchange{}, identity.ErrActiveConfigurationHandoffRequired } if err := adoptPendingIdentitySecrets(ctx, tx, exchange.ExchangeTokenRef); err != nil { return identity.PairingExchange{}, err } scopes, _ := json.Marshal(revision.Scopes) capabilities, _ := json.Marshal(revision.Capabilities) if _, err := tx.Exec(ctx, `INSERT INTO gateway_identity_configuration_revisions ( id,state,schema_version,auth_center_url,role_prefix,local_tenant_key,public_base_url,web_base_url, jit_enabled,legacy_jwt_enabled,scopes,capabilities,session_idle_seconds,session_absolute_seconds,session_refresh_seconds,last_trace_id ) VALUES ($1::uuid,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12::jsonb,$13,$14,$15,NULLIF($16,''))`, revision.ID, revision.State, revision.SchemaVersion, revision.AuthCenterURL, revision.RolePrefix, revision.LocalTenantKey, revision.PublicBaseURL, revision.WebBaseURL, revision.JITEnabled, revision.LegacyJWTEnabled, string(scopes), string(capabilities), revision.SessionIdleSeconds, revision.SessionAbsoluteSeconds, revision.SessionRefreshSeconds, revision.LastTraceID, ); err != nil { return identity.PairingExchange{}, err } created, err := scanIdentityPairing(tx.QueryRow(ctx, `INSERT INTO gateway_identity_onboarding_exchanges ( id,revision_id,remote_exchange_id,exchange_token_ref,status,cleanup_status,remote_version,expires_at,last_trace_id ) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,$8,NULLIF($9,'')) RETURNING `+identityPairingColumns, exchange.ID, revision.ID, exchange.RemoteExchangeID, exchange.ExchangeTokenRef, exchange.Status, exchange.CleanupStatus, exchange.RemoteVersion, exchange.ExpiresAt, exchange.LastTraceID, )) if err != nil { return identity.PairingExchange{}, err } tag, err := tx.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation SET state='paired',revision_id=$2::uuid,expires_at=$3,updated_at=now() WHERE attempt_id=$1::uuid AND state='starting'`, exchange.ID, revision.ID, exchange.ExpiresAt) if err != nil { return identity.PairingExchange{}, err } if tag.RowsAffected() != 1 { return identity.PairingExchange{}, identity.ErrPairingInProgress } if err := tx.Commit(ctx); err != nil { return identity.PairingExchange{}, err } return created, nil } func (s *Store) IdentityPairingStartBlocked(ctx context.Context) (bool, error) { var blocked bool err := s.pool.QueryRow(ctx, ` SELECT EXISTS( SELECT 1 FROM gateway_identity_configuration_revisions revision JOIN gateway_identity_onboarding_exchanges exchange ON exchange.revision_id=revision.id WHERE revision.state IN ('draft','validated','failed') AND NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed') ) OR EXISTS ( SELECT 1 FROM gateway_identity_pairing_start_reservation WHERE state='paired' OR expires_at > now() )`).Scan(&blocked) return blocked, err } func (s *Store) CreateIdentityPairingExchange(ctx context.Context, exchange identity.PairingExchange) (identity.PairingExchange, error) { return scanIdentityPairing(s.pool.QueryRow(ctx, ` INSERT INTO gateway_identity_onboarding_exchanges ( id,revision_id,remote_exchange_id,exchange_token_ref,status,cleanup_status,remote_version,expires_at,last_trace_id ) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,$8,NULLIF($9,'')) RETURNING `+identityPairingColumns, exchange.ID, exchange.RevisionID, exchange.RemoteExchangeID, exchange.ExchangeTokenRef, exchange.Status, exchange.CleanupStatus, exchange.RemoteVersion, exchange.ExpiresAt, exchange.LastTraceID, )) } func (s *Store) IdentityPairingExchange(ctx context.Context, id string) (identity.PairingExchange, error) { exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `SELECT `+identityPairingColumns+` FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid`, id)) if errors.Is(err, pgx.ErrNoRows) { return identity.PairingExchange{}, identity.ErrRevisionNotFound } return exchange, err } func (s *Store) IdentityPairingExchangeForRevision(ctx context.Context, revisionID string) (identity.PairingExchange, error) { exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `SELECT `+identityPairingColumns+` FROM gateway_identity_onboarding_exchanges WHERE revision_id=$1::uuid`, revisionID)) if errors.Is(err, pgx.ErrNoRows) { return identity.PairingExchange{}, identity.ErrRevisionNotFound } return exchange, err } func (s *Store) PendingIdentityPairingExchanges(ctx context.Context) ([]identity.PairingExchange, error) { rows, err := s.pool.Query(ctx, `SELECT `+identityPairingColumns+` FROM gateway_identity_onboarding_exchanges exchange WHERE exchange.id=( SELECT candidate_exchange.id FROM gateway_identity_onboarding_exchanges candidate_exchange JOIN gateway_identity_configuration_revisions revision ON revision.id=candidate_exchange.revision_id WHERE revision.state IN ('draft','validated','failed') ORDER BY NOT (candidate_exchange.status='cancelled' AND candidate_exchange.cleanup_status='completed') DESC, revision.created_at DESC,candidate_exchange.created_at DESC LIMIT 1 ) AND exchange.status IN ('metadata_pending','preparing','ready','credentials_saved')`) if err != nil { return nil, err } defer rows.Close() exchanges := make([]identity.PairingExchange, 0) for rows.Next() { exchange, scanErr := scanIdentityPairing(rows) if scanErr != nil { return nil, scanErr } exchanges = append(exchanges, exchange) } return exchanges, rows.Err() } func (s *Store) PendingIdentityPairingCleanups(ctx context.Context) ([]identity.PairingExchange, error) { rows, err := s.pool.Query(ctx, `SELECT `+identityPairingColumns+` FROM gateway_identity_onboarding_exchanges WHERE status='cancelled' AND cleanup_status='pending' ORDER BY updated_at`) if err != nil { return nil, err } defer rows.Close() exchanges := make([]identity.PairingExchange, 0) for rows.Next() { exchange, scanErr := scanIdentityPairing(rows) if scanErr != nil { return nil, scanErr } exchanges = append(exchanges, exchange) } return exchanges, rows.Err() } func (s *Store) CompletedIdentityPairingsAwaitingActivation(ctx context.Context) ([]identity.PairingExchange, error) { rows, err := s.pool.Query(ctx, `SELECT `+identityPairingColumns+` FROM gateway_identity_onboarding_exchanges exchange WHERE exchange.id=( SELECT candidate_exchange.id FROM gateway_identity_onboarding_exchanges candidate_exchange JOIN gateway_identity_configuration_revisions revision ON revision.id=candidate_exchange.revision_id WHERE revision.state IN ('draft','validated','failed') ORDER BY NOT (candidate_exchange.status='cancelled' AND candidate_exchange.cleanup_status='completed') DESC, revision.created_at DESC,candidate_exchange.created_at DESC LIMIT 1 ) AND exchange.status='completed' AND EXISTS ( SELECT 1 FROM gateway_identity_configuration_revisions revision WHERE revision.id=exchange.revision_id AND revision.state IN ('draft','validated') )`) if err != nil { return nil, err } defer rows.Close() exchanges := make([]identity.PairingExchange, 0) for rows.Next() { exchange, scanErr := scanIdentityPairing(rows) if scanErr != nil { return nil, scanErr } exchanges = append(exchanges, exchange) } return exchanges, rows.Err() } func (s *Store) UpdateIdentityPairingExchange(ctx context.Context, id string, expectedVersion int64, update identity.PairingExchangeUpdate) (identity.PairingExchange, error) { tx, err := s.pool.Begin(ctx) if err != nil { return identity.PairingExchange{}, err } defer tx.Rollback(ctx) exchange, err := scanIdentityPairing(tx.QueryRow(ctx, ` UPDATE gateway_identity_onboarding_exchanges SET status=$3,remote_version=$4,last_error_category=NULLIF($5,''), auth_center_audit_id=COALESCE(NULLIF($6,''),auth_center_audit_id),version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2 RETURNING `+identityPairingColumns, id, expectedVersion, update.Status, update.RemoteVersion, update.LastErrorCategory, update.AuthCenterAuditID, )) if errors.Is(err, pgx.ErrNoRows) { return identity.PairingExchange{}, identity.ErrRevisionConflict } if err != nil { return identity.PairingExchange{}, err } if update.Status == identity.PairingCompleted || update.Status == identity.PairingFailed || update.Status == identity.PairingExpired { if err := queueIdentitySecretCleanupTx(ctx, tx, exchange.ExchangeTokenRef, time.Now().UTC()); err != nil { return identity.PairingExchange{}, err } } if err := tx.Commit(ctx); err != nil { return identity.PairingExchange{}, err } return exchange, nil } func (s *Store) RecordIdentityPairingRetryFailure(ctx context.Context, id string, status identity.PairingStatus, category string) (identity.PairingExchange, error) { exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, ` UPDATE gateway_identity_onboarding_exchanges SET last_error_category=$3,updated_at=now() WHERE id=$1::uuid AND status=$2 AND cleanup_status='none' RETURNING `+identityPairingColumns, id, status, category)) if errors.Is(err, pgx.ErrNoRows) { return identity.PairingExchange{}, identity.ErrRevisionConflict } return exchange, err } func (s *Store) CancelIdentityPairingExchange(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.PairingExchange, error) { tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) if err != nil { return identity.PairingExchange{}, err } defer tx.Rollback(ctx) exchange, err := scanIdentityPairing(tx.QueryRow(ctx, `SELECT `+identityPairingColumns+` FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid FOR UPDATE`, id)) if errors.Is(err, pgx.ErrNoRows) { return identity.PairingExchange{}, identity.ErrRevisionNotFound } if err != nil { return identity.PairingExchange{}, err } if exchange.Status == identity.PairingCancelled { return exchange, tx.Commit(ctx) } if exchange.Version != expectedVersion { return identity.PairingExchange{}, identity.ErrRevisionConflict } revision, err := scanIdentityRevision(tx.QueryRow(ctx, `SELECT `+identityRevisionColumns+` FROM gateway_identity_configuration_revisions WHERE id=$1::uuid FOR UPDATE`, exchange.RevisionID)) if err != nil { return identity.PairingExchange{}, normalizeIdentityRevisionError(err) } if revision.State != identity.RevisionDraft && revision.State != identity.RevisionValidated && revision.State != identity.RevisionFailed { return identity.PairingExchange{}, identity.ErrPairingNotCancellable } if _, err := tx.Exec(ctx, `UPDATE gateway_identity_configuration_revisions SET state='failed', last_error_category='pairing_cancelled',last_trace_id=NULLIF($2,''),last_audit_id=NULLIF($3,''), version=version+1,updated_at=now() WHERE id=$1::uuid`, exchange.RevisionID, traceID, auditID); err != nil { return identity.PairingExchange{}, err } exchange, err = scanIdentityPairing(tx.QueryRow(ctx, ` UPDATE gateway_identity_onboarding_exchanges SET status='cancelled',cleanup_status='pending',cancelled_at=now(), cleanup_completed_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2 RETURNING `+identityPairingColumns, id, expectedVersion)) if errors.Is(err, pgx.ErrNoRows) { return identity.PairingExchange{}, identity.ErrRevisionConflict } if err != nil { return identity.PairingExchange{}, err } if err := queueIdentitySecretCleanupTx(ctx, tx, exchange.ExchangeTokenRef, time.Now().UTC()); err != nil { return identity.PairingExchange{}, err } if err := tx.Commit(ctx); err != nil { return identity.PairingExchange{}, err } return exchange, nil } func (s *Store) CompleteIdentityPairingCleanup(ctx context.Context, id string, expectedVersion int64) (identity.PairingExchange, error) { tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) if err != nil { return identity.PairingExchange{}, err } defer tx.Rollback(ctx) var revisionID string if err := tx.QueryRow(ctx, `SELECT revision_id::text FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid AND version=$2 AND status='cancelled' AND cleanup_status='pending' FOR UPDATE`, id, expectedVersion).Scan(&revisionID); errors.Is(err, pgx.ErrNoRows) { return identity.PairingExchange{}, identity.ErrRevisionConflict } else if err != nil { return identity.PairingExchange{}, err } tag, err := tx.Exec(ctx, `UPDATE gateway_identity_configuration_revisions SET machine_credential_ref=NULL, session_encryption_key_ref=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND state='failed'`, revisionID) if err != nil { return identity.PairingExchange{}, err } if tag.RowsAffected() != 1 { return identity.PairingExchange{}, identity.ErrRevisionConflict } exchange, err := scanIdentityPairing(tx.QueryRow(ctx, `UPDATE gateway_identity_onboarding_exchanges SET cleanup_status='completed',cleanup_completed_at=now(),version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2 AND status='cancelled' AND cleanup_status='pending' RETURNING `+identityPairingColumns, id, expectedVersion)) if err != nil { return identity.PairingExchange{}, err } if _, err := tx.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation WHERE revision_id=$1::uuid`, revisionID); err != nil { return identity.PairingExchange{}, err } if err := tx.Commit(ctx); err != nil { return identity.PairingExchange{}, err } return exchange, nil } func (s *Store) RecordIdentityPairingCleanupFailure(ctx context.Context, id, category string) (identity.PairingExchange, error) { exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `UPDATE gateway_identity_onboarding_exchanges SET last_error_category=$2,updated_at=now() WHERE id=$1::uuid AND status='cancelled' AND cleanup_status='pending' RETURNING `+identityPairingColumns, id, category)) if errors.Is(err, pgx.ErrNoRows) { return identity.PairingExchange{}, identity.ErrRevisionConflict } return exchange, err } func scanIdentityPairing(row scanner) (identity.PairingExchange, error) { var exchange identity.PairingExchange var status string if err := row.Scan( &exchange.ID, &exchange.RevisionID, &exchange.RemoteExchangeID, &exchange.ExchangeTokenRef, &status, &exchange.CleanupStatus, &exchange.RemoteVersion, &exchange.ExpiresAt, &exchange.Version, &exchange.LastErrorCategory, &exchange.AuthCenterAuditID, &exchange.LastTraceID, &exchange.CancelledAt, &exchange.CleanupCompletedAt, &exchange.CreatedAt, &exchange.UpdatedAt, ); err != nil { return identity.PairingExchange{}, err } exchange.Status = identity.PairingStatus(status) return exchange, nil }