| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345 |
- package service
- import (
- "context"
- "errors"
- "fmt"
- "strings"
- "time"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- )
- const (
- defaultGrokOAuthReconcilePageSize = 50
- maxGrokOAuthReconcilePageSize = 500
- maxGrokOAuthReconcileWindow = 24 * time.Hour
- GrokOAuthReconcileReasonMissingRefreshToken = "missing_refresh_token"
- GrokOAuthReconcileReasonMissingAccessToken = "missing_access_token"
- GrokOAuthReconcileReasonMissingExpiry = "missing_expiry"
- GrokOAuthReconcileReasonInvalidExpiry = "invalid_expiry"
- GrokOAuthReconcileReasonNearExpiry = "near_expiry"
- GrokOAuthReconcileReasonCredentialRejected = "credential_rejected"
- GrokOAuthReconcileActionBlock = "block_account"
- GrokOAuthReconcileActionRefresh = "refresh_credentials"
- GrokOAuthReconcileOutcomePlanned = "planned"
- GrokOAuthReconcileOutcomeApplied = "applied"
- GrokOAuthReconcileOutcomeSkipped = "skipped"
- GrokOAuthReconcileOutcomeFailed = "failed"
- GrokOAuthReconcileOutcomePartial = "partial"
- )
- var (
- ErrGrokOAuthReconcileMode = infraerrors.BadRequest(
- "GROK_OAUTH_RECONCILE_MODE_INVALID",
- "apply requires dry_run=false and apply=true",
- )
- ErrGrokOAuthReconcileCursor = infraerrors.BadRequest(
- "GROK_OAUTH_RECONCILE_CURSOR_INVALID",
- "after_id must be non-negative",
- )
- ErrGrokOAuthReconcileLimit = infraerrors.BadRequest(
- "GROK_OAUTH_RECONCILE_LIMIT_INVALID",
- "limit is outside the allowed reconciliation page range",
- )
- ErrGrokOAuthReconcileWindow = infraerrors.BadRequest(
- "GROK_OAUTH_RECONCILE_WINDOW_INVALID",
- "refresh_window_seconds is outside the allowed range",
- )
- )
- // GrokOAuthReconciler is the narrow admin-facing reconciliation port.
- type GrokOAuthReconciler interface {
- ReconcileGrokOAuth(ctx context.Context, input GrokOAuthReconcileInput) (*GrokOAuthReconcileResult, error)
- }
- // GrokOAuthConditionalErrorRepository is the narrow compare-and-set mutation
- // used by reconciliation. The repository must only transition an active Grok
- // OAuth account when its credential document still exactly matches the state
- // observed immediately before the mutation.
- type GrokOAuthConditionalErrorRepository interface {
- SetGrokOAuthErrorIfCredentialsUnchanged(ctx context.Context, id int64, expectedCredentials map[string]any, errorMsg string) (bool, error)
- }
- type GrokOAuthReconcileInput struct {
- DryRun bool
- Apply bool
- AfterID int64
- Limit int
- RefreshWindow time.Duration
- }
- // GrokOAuthReconcileItem is deliberately metadata-only. Credentials, account
- // identity fields, provider response bodies, and raw errors never cross this API.
- type GrokOAuthReconcileItem struct {
- AccountID int64 `json:"account_id"`
- Reason string `json:"reason"`
- Action string `json:"action"`
- Outcome string `json:"outcome"`
- }
- type GrokOAuthReconcileResult struct {
- DryRun bool `json:"dry_run"`
- Scanned int `json:"scanned"`
- Actionable int `json:"actionable"`
- WouldBlock int `json:"would_block"`
- WouldRefresh int `json:"would_refresh"`
- Blocked int `json:"blocked"`
- Refreshed int `json:"refreshed"`
- Skipped int `json:"skipped"`
- Failed int `json:"failed"`
- Partial int `json:"partial"`
- Items []GrokOAuthReconcileItem `json:"items"`
- NextAfterID int64 `json:"next_after_id"`
- HasMore bool `json:"has_more"`
- }
- func (s *TokenRefreshService) ReconcileGrokOAuth(ctx context.Context, input GrokOAuthReconcileInput) (*GrokOAuthReconcileResult, error) {
- if ctx == nil {
- ctx = context.Background()
- }
- if input.Apply && input.DryRun {
- return nil, ErrGrokOAuthReconcileMode
- }
- if input.AfterID < 0 {
- return nil, ErrGrokOAuthReconcileCursor
- }
- limit := input.Limit
- maxPageSize := s.grokOAuthReconcileMaxPageSize()
- if limit == 0 {
- limit = min(defaultGrokOAuthReconcilePageSize, maxPageSize)
- }
- if limit < 1 || limit > maxPageSize {
- return nil, ErrGrokOAuthReconcileLimit
- }
- refreshWindow := input.RefreshWindow
- if refreshWindow == 0 {
- refreshWindow = grokTokenRefreshSkew
- }
- if refreshWindow < 0 || refreshWindow > maxGrokOAuthReconcileWindow {
- return nil, ErrGrokOAuthReconcileWindow
- }
- if refreshWindow < grokTokenRefreshSkew {
- refreshWindow = grokTokenRefreshSkew
- }
- dryRun := !input.Apply
- pager := s.candidatePager
- if pager == nil {
- pager, _ = s.accountRepo.(OAuthRefreshCandidatePager)
- }
- if pager == nil {
- return nil, errors.New("OAuth refresh candidate pager is not configured")
- }
- page, err := pager.ListOAuthRefreshCandidatePage(ctx, OAuthRefreshPageOptions{
- Platforms: []string{PlatformGrok},
- AfterID: input.AfterID,
- Limit: limit,
- ActiveOnly: true,
- // Reconciliation scans OAuth only and intentionally does not require a
- // refresh token so structurally invalid rows remain discoverable.
- IncludeSetupToken: false,
- RequireRefreshToken: false,
- })
- if err != nil {
- return nil, err
- }
- if page == nil {
- return nil, errors.New("OAuth reconciliation repository returned a nil cursor page")
- }
- accounts := page.Accounts
- if !isStrictlyIncreasingAccountPage(accounts, input.AfterID) {
- return nil, errors.New("OAuth reconciliation repository returned an invalid cursor page")
- }
- result := &GrokOAuthReconcileResult{
- DryRun: dryRun,
- Scanned: len(accounts),
- Items: make([]GrokOAuthReconcileItem, 0, len(accounts)),
- HasMore: page.HasMore,
- }
- if result.HasMore {
- if page.NextAfterID <= input.AfterID {
- return nil, errors.New("OAuth reconciliation repository returned invalid cursor metadata")
- }
- result.NextAfterID = page.NextAfterID
- }
- registration, ok := s.grokRegistration()
- if !ok {
- return nil, errors.New("grok OAuth refresher is not registered")
- }
- conditionalErrorRepo, supportsConditionalError := s.accountRepo.(GrokOAuthConditionalErrorRepository)
- if input.Apply && !supportsConditionalError {
- return nil, errors.New("grok OAuth conditional error mutation is not configured")
- }
- providerState := &tokenRefreshProviderState{
- service: s,
- registration: registration,
- rateGate: s.providerRateGate(PlatformGrok),
- poolGate: s.providerConcurrencyGate(PlatformGrok),
- }
- for i := range accounts {
- if err := ctx.Err(); err != nil {
- return nil, err
- }
- account := &accounts[i]
- reason, action, actionable := classifyGrokOAuthReconcileAccount(account, refreshWindow)
- if !actionable {
- result.Skipped++
- continue
- }
- result.Actionable++
- item := GrokOAuthReconcileItem{
- AccountID: account.ID,
- Reason: reason,
- Action: action,
- Outcome: GrokOAuthReconcileOutcomePlanned,
- }
- if action == GrokOAuthReconcileActionBlock {
- result.WouldBlock++
- } else {
- result.WouldRefresh++
- }
- if dryRun {
- result.Items = append(result.Items, item)
- continue
- }
- switch action {
- case GrokOAuthReconcileActionBlock:
- latest, err := s.accountRepo.GetByID(ctx, account.ID)
- if err != nil || latest == nil {
- item.Outcome = GrokOAuthReconcileOutcomeFailed
- result.Failed++
- break
- }
- latestReason, latestAction, stillActionable := classifyGrokOAuthReconcileAccount(latest, refreshWindow)
- if !stillActionable || latestAction != GrokOAuthReconcileActionBlock {
- // The account changed after page hydration (for example, an admin
- // reauthorized it). Never apply a stale destructive action; the next
- // resumable scan can plan the fresh state.
- item.Outcome = GrokOAuthReconcileOutcomeSkipped
- result.Skipped++
- break
- }
- account = latest
- item.Reason = latestReason
- applied, err := conditionalErrorRepo.SetGrokOAuthErrorIfCredentialsUnchanged(
- ctx,
- account.ID,
- account.Credentials,
- "Grok OAuth credential reconciliation: missing refresh token",
- )
- if err != nil {
- item.Outcome = GrokOAuthReconcileOutcomeFailed
- result.Failed++
- break
- }
- if !applied {
- // Reauthorization won the compare-and-set race after the final
- // reread. The runtime fast path is installed only after the CAS
- // succeeds, so the fresh active account remains untouched.
- item.Outcome = GrokOAuthReconcileOutcomeSkipped
- result.Skipped++
- break
- }
- s.notifyAccountSchedulingBlocked(account, time.Time{}, "grok_oauth_reconcile_invalid")
- account.Status = StatusError
- account.Schedulable = false
- cacheInvalidationFailed := s.cacheInvalidator == nil
- if s.cacheInvalidator != nil {
- if err := s.cacheInvalidator.InvalidateToken(ctx, account); err != nil {
- cacheInvalidationFailed = true
- }
- }
- result.Blocked++
- if cacheInvalidationFailed {
- item.Outcome = GrokOAuthReconcileOutcomePartial
- result.Partial++
- } else {
- item.Outcome = GrokOAuthReconcileOutcomeApplied
- }
- case GrokOAuthReconcileActionRefresh:
- if providerState.isTripped() {
- item.Outcome = GrokOAuthReconcileOutcomeSkipped
- result.Skipped++
- break
- }
- if providerState.isTripped() {
- item.Outcome = GrokOAuthReconcileOutcomeSkipped
- result.Skipped++
- break
- }
- refreshErr := s.refreshWithRetryWithRateGate(ctx, account, registration.refresher, registration.executor, refreshWindow, providerState)
- providerState.recordResult(refreshErr)
- var permanentErr *accountPermanentRefreshError
- switch {
- case refreshErr == nil:
- item.Outcome = GrokOAuthReconcileOutcomeApplied
- result.Refreshed++
- case errors.Is(refreshErr, errRefreshSkipped):
- item.Outcome = GrokOAuthReconcileOutcomeSkipped
- result.Skipped++
- case errors.As(refreshErr, &permanentErr) && permanentErr.persistentlyBlocked:
- item.Reason = GrokOAuthReconcileReasonCredentialRejected
- item.Action = GrokOAuthReconcileActionBlock
- result.Blocked++
- if permanentErr.cacheInvalidationFailed {
- item.Outcome = GrokOAuthReconcileOutcomePartial
- result.Partial++
- } else {
- item.Outcome = GrokOAuthReconcileOutcomeApplied
- }
- default:
- item.Outcome = GrokOAuthReconcileOutcomeFailed
- result.Failed++
- }
- default:
- return nil, fmt.Errorf("unsupported Grok OAuth reconciliation action")
- }
- result.Items = append(result.Items, item)
- }
- return result, nil
- }
- func (s *TokenRefreshService) grokOAuthReconcileMaxPageSize() int {
- return maxGrokOAuthReconcilePageSize
- }
- func (s *TokenRefreshService) grokRegistration() (tokenRefreshRegistration, bool) {
- for _, registration := range s.registrations {
- if registration.platform == PlatformGrok && registration.refresher != nil {
- return registration, true
- }
- }
- return tokenRefreshRegistration{}, false
- }
- func classifyGrokOAuthReconcileAccount(account *Account, refreshWindow time.Duration) (reason, action string, actionable bool) {
- if account == nil || !account.IsGrokOAuth() || account.Status != StatusActive {
- return "", "", false
- }
- if strings.TrimSpace(account.GetGrokRefreshToken()) == "" {
- return GrokOAuthReconcileReasonMissingRefreshToken, GrokOAuthReconcileActionBlock, true
- }
- if strings.TrimSpace(account.GetGrokAccessToken()) == "" {
- return GrokOAuthReconcileReasonMissingAccessToken, GrokOAuthReconcileActionRefresh, true
- }
- rawExpiry := strings.TrimSpace(account.GetCredential("expires_at"))
- if rawExpiry == "" {
- return GrokOAuthReconcileReasonMissingExpiry, GrokOAuthReconcileActionRefresh, true
- }
- expiresAt := account.GetCredentialAsTime("expires_at")
- if expiresAt == nil {
- return GrokOAuthReconcileReasonInvalidExpiry, GrokOAuthReconcileActionRefresh, true
- }
- if time.Until(*expiresAt) <= refreshWindow {
- return GrokOAuthReconcileReasonNearExpiry, GrokOAuthReconcileActionRefresh, true
- }
- return "", "", false
- }
|