// Copyright (c) 2026 Probo Inc . // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above // copyright notice and this permission notice appear in all copies. // // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH // REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY // AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, // INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM // LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. package accessreview import ( "context" "errors" "fmt" "net/http" "time" "go.gearno.de/kit/log" "go.gearno.de/kit/pg" "go.gearno.de/kit/worker" "go.probo.inc/probo/pkg/accessreview/drivers" "go.probo.inc/probo/pkg/connector" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/crypto/cipher" ) // sourceNameHandler polls for access sources that have a connector but no // synced name, resolves the provider instance name, and updates the source. type sourceNameHandler struct { pg *pg.Client encryptionKey cipher.EncryptionKey connectorRegistry *connector.ConnectorRegistry logger *log.Logger } func NewSourceNameWorker( pgClient *pg.Client, encryptionKey cipher.EncryptionKey, connectorRegistry *connector.ConnectorRegistry, logger *log.Logger, opts ...worker.Option, ) *worker.Worker[coredata.AccessSource] { h := &sourceNameHandler{ pg: pgClient, encryptionKey: encryptionKey, connectorRegistry: connectorRegistry, logger: logger, } defaultOpts := []worker.Option{ worker.WithInterval(10 * time.Second), worker.WithMaxConcurrency(1), } return worker.New( "source-name-worker", h, logger, append(defaultOpts, opts...)..., ) } func (h *sourceNameHandler) Claim(ctx context.Context) (coredata.AccessSource, error) { var source coredata.AccessSource err := h.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { return source.LoadNextUnsyncedNameForUpdateSkipLocked(ctx, tx) }, ) if err != nil { if errors.Is(err, coredata.ErrNoAccessSourceNameSyncAvailable) { return coredata.AccessSource{}, worker.ErrNoTask } return coredata.AccessSource{}, err } return source, nil } func (h *sourceNameHandler) Process(ctx context.Context, source coredata.AccessSource) error { h.logger.InfoCtx( ctx, "syncing source name", log.String("source_id", source.ID.String()), log.String("current_name", source.Name), ) var ( dbConnector coredata.Connector resolver drivers.NameResolver ) err := h.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { scope := coredata.NewScopeFromObjectID(source.ID) if source.ConnectorID == nil { return fmt.Errorf("source %s has no connector", source.ID) } if err := dbConnector.LoadByID(ctx, tx, scope, *source.ConnectorID, h.encryptionKey); err != nil { return fmt.Errorf("cannot load connector %s: %w", *source.ConnectorID, err) } var tokenBefore string if oauth2Conn, ok := dbConnector.Connection.(*connector.OAuth2Connection); ok { tokenBefore = oauth2Conn.AccessToken } httpClient, err := h.connectorHTTPClient(ctx, &dbConnector) if err != nil { return fmt.Errorf("cannot create HTTP client for connector: %w", err) } if oauth2Conn, ok := dbConnector.Connection.(*connector.OAuth2Connection); ok { if oauth2Conn.AccessToken != tokenBefore { dbConnector.UpdatedAt = time.Now() if err := dbConnector.Update(ctx, tx, scope, h.encryptionKey); err != nil { return fmt.Errorf("cannot persist refreshed token for connector %s: %w", *source.ConnectorID, err) } } } resolver = h.buildResolver(&dbConnector, httpClient) return nil }, ) if err != nil { h.logger.ErrorCtx( ctx, "cannot load connector for source name sync", log.String("source_id", source.ID.String()), log.Error(err), ) return nil } if resolver == nil { h.logger.InfoCtx( ctx, "no name resolver for provider, keeping generic name", log.String("source_id", source.ID.String()), log.String("provider", dbConnector.Provider.String()), ) return h.markNameSynced(ctx, &source) } resolveCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() instanceName, err := resolver.ResolveInstanceName(resolveCtx) if err != nil { h.logger.ErrorCtx( ctx, "cannot resolve instance name", log.String("source_id", source.ID.String()), log.String("provider", dbConnector.Provider.String()), log.Error(err), ) return fmt.Errorf("cannot resolve instance name for source %s: %w", source.ID, err) } if instanceName == "" { h.logger.InfoCtx( ctx, "instance name is empty, keeping generic name", log.String("source_id", source.ID.String()), log.String("provider", dbConnector.Provider.String()), ) return h.markNameSynced(ctx, &source) } displayName := drivers.ProviderDisplayName(dbConnector.Provider) newName := displayName + " " + instanceName h.logger.InfoCtx( ctx, "resolved source name", log.String("source_id", source.ID.String()), log.String("old_name", source.Name), log.String("new_name", newName), ) source.Name = newName return h.markNameSynced(ctx, &source) } func (h *sourceNameHandler) markNameSynced( ctx context.Context, source *coredata.AccessSource, ) error { return h.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { scope := coredata.NewScopeFromObjectID(source.ID) now := time.Now() source.NameSyncedAt = new(now) source.UpdatedAt = now if err := source.Update(ctx, tx, scope); err != nil { return fmt.Errorf("cannot update access source: %w", err) } return nil }, ) } // connectorHTTPClient returns an HTTP client for the given connector. // For OAuth2 connections it uses RefreshableClient when a refresh config // is registered for the provider, so that short-lived tokens are // transparently refreshed. func (h *sourceNameHandler) connectorHTTPClient( ctx context.Context, dbConnector *coredata.Connector, ) (*http.Client, error) { oauth2Conn, ok := dbConnector.Connection.(*connector.OAuth2Connection) if !ok { return dbConnector.Connection.Client(ctx) } if h.connectorRegistry != nil { refreshCfg := h.connectorRegistry.GetOAuth2RefreshConfig(string(dbConnector.Provider)) if refreshCfg != nil { return oauth2Conn.RefreshableClient(ctx, *refreshCfg) } } return oauth2Conn.Client(ctx) } func (h *sourceNameHandler) buildResolver( dbConnector *coredata.Connector, httpClient *http.Client, ) drivers.NameResolver { switch dbConnector.Provider { case coredata.ConnectorProviderSlack: return drivers.NewSlackNameResolver(httpClient) case coredata.ConnectorProviderGoogleWorkspace: return drivers.NewGoogleWorkspaceNameResolver(httpClient) case coredata.ConnectorProviderLinear: return drivers.NewLinearNameResolver(httpClient) case coredata.ConnectorProviderCloudflare: return drivers.NewCloudflareNameResolver(httpClient) case coredata.ConnectorProviderBrex: return drivers.NewBrexNameResolver(httpClient) case coredata.ConnectorProviderTally: tallySettings, err := coredata.ConnectorSettings[coredata.TallyConnectorSettings](dbConnector) if err != nil { h.logger.Error("cannot read tally connector settings", log.Error(err)) return nil } return drivers.NewTallyNameResolver(httpClient, tallySettings.OrganizationID) case coredata.ConnectorProviderHubSpot: return drivers.NewHubSpotNameResolver(httpClient) case coredata.ConnectorProviderDocuSign: return drivers.NewDocuSignNameResolver(httpClient) case coredata.ConnectorProviderOpenAI: return drivers.NewOpenAINameResolver(httpClient) case coredata.ConnectorProviderSentry: sentrySettings, err := coredata.ConnectorSettings[coredata.SentryConnectorSettings](dbConnector) if err != nil { h.logger.Error("cannot read sentry connector settings", log.Error(err)) return nil } return drivers.NewSentryNameResolver(httpClient, sentrySettings.OrganizationSlug) case coredata.ConnectorProviderGitHub: githubSettings, err := coredata.ConnectorSettings[coredata.GitHubConnectorSettings](dbConnector) if err != nil { h.logger.Error("cannot read github connector settings", log.Error(err)) return nil } return drivers.NewGitHubNameResolver(httpClient, githubSettings.Organization) case coredata.ConnectorProviderSupabase: supabaseSettings, err := coredata.ConnectorSettings[coredata.SupabaseConnectorSettings](dbConnector) if err != nil { h.logger.Error("cannot read supabase connector settings", log.Error(err)) return nil } return drivers.NewSupabaseNameResolver(supabaseSettings.OrganizationSlug) case coredata.ConnectorProviderIntercom: return drivers.NewIntercomNameResolver(httpClient) case coredata.ConnectorProviderNotion: return drivers.NewNotionNameResolver(httpClient) case coredata.ConnectorProviderResend: return drivers.NewResendNameResolver() case coredata.ConnectorProviderMicrosoft365: return drivers.NewMicrosoft365NameResolver(httpClient) case coredata.ConnectorProviderGitLab: gitlabSettings, err := coredata.ConnectorSettings[coredata.GitLabConnectorSettings](dbConnector) if err != nil { h.logger.Error("cannot read gitlab connector settings", log.Error(err)) return nil } return drivers.NewGitLabNameResolver(httpClient, gitlabSettings.GroupID) case coredata.ConnectorProviderBitbucket: bitbucketSettings, err := coredata.ConnectorSettings[coredata.BitbucketConnectorSettings](dbConnector) if err != nil { h.logger.Error("cannot read bitbucket connector settings", log.Error(err)) return nil } return drivers.NewBitbucketNameResolver(httpClient, bitbucketSettings.Workspace) case coredata.ConnectorProviderHeroku: herokuSettings, err := coredata.ConnectorSettings[coredata.HerokuConnectorSettings](dbConnector) if err != nil { h.logger.Error("cannot read heroku connector settings", log.Error(err)) return nil } return drivers.NewHerokuNameResolver(httpClient, herokuSettings.TeamID) case coredata.ConnectorProviderPagerDuty: pdSettings, err := coredata.ConnectorSettings[coredata.PagerDutyConnectorSettings](dbConnector) if err != nil { h.logger.Error("cannot read pagerduty connector settings", log.Error(err)) return nil } return drivers.NewPagerDutyNameResolver(pdSettings.Subdomain) case coredata.ConnectorProviderAsana: asanaSettings, err := coredata.ConnectorSettings[coredata.AsanaConnectorSettings](dbConnector) if err != nil { h.logger.Error("cannot read asana connector settings", log.Error(err)) return nil } return drivers.NewAsanaNameResolver(httpClient, asanaSettings.WorkspaceGID) case coredata.ConnectorProviderNetlify: netlifySettings, err := coredata.ConnectorSettings[coredata.NetlifyConnectorSettings](dbConnector) if err != nil { h.logger.Error("cannot read netlify connector settings", log.Error(err)) return nil } return drivers.NewNetlifyNameResolver(httpClient, netlifySettings.AccountSlug) case coredata.ConnectorProviderClickUp: clickupSettings, err := coredata.ConnectorSettings[coredata.ClickUpConnectorSettings](dbConnector) if err != nil { h.logger.Error("cannot read clickup connector settings", log.Error(err)) return nil } return drivers.NewClickUpNameResolver(httpClient, clickupSettings.TeamID) case coredata.ConnectorProviderVercel: vercelSettings, err := coredata.ConnectorSettings[coredata.VercelConnectorSettings](dbConnector) if err != nil { h.logger.Error("cannot read vercel connector settings", log.Error(err)) return nil } return drivers.NewVercelNameResolver(httpClient, vercelSettings.TeamID) case coredata.ConnectorProviderMonday: return drivers.NewMondayNameResolver(httpClient) default: return nil } }