diff --git a/pkg/accessreview/review_engine.go b/pkg/accessreview/review_engine.go index 63792efe8..12edaf9b6 100644 --- a/pkg/accessreview/review_engine.go +++ b/pkg/accessreview/review_engine.go @@ -308,13 +308,13 @@ func (e *ReviewEngine) resolveDriver( // Client credentials grant -> Users API driver (to be created in Phase 5). // Authorization code / SCIM grant -> existing SCIM-based driver. if oauth2Conn, ok := dbConnector.Connection.(*connector.OAuth2Connection); ok && oauth2Conn.GrantType == connector.OAuth2GrantTypeClientCredentials { - settings, err := dbConnector.OnePasswordUsersAPISettings() + settings, err := coredata.ConnectorSettings[coredata.OnePasswordUsersAPISettings](dbConnector) if err != nil { return nil, fmt.Errorf("cannot read 1password users api settings: %w", err) } return drivers.NewOnePasswordUsersAPIDriver(httpClient, settings.AccountID, settings.Region), nil } - onePasswordSettings, err := dbConnector.OnePasswordSettings() + onePasswordSettings, err := coredata.ConnectorSettings[coredata.OnePasswordConnectorSettings](dbConnector) if err != nil { return nil, fmt.Errorf("cannot read 1password connector settings: %w", err) } @@ -331,7 +331,7 @@ func (e *ReviewEngine) resolveDriver( case coredata.ConnectorProviderBrex: return drivers.NewBrexDriver(httpClient), nil case coredata.ConnectorProviderTally: - tallySettings, err := dbConnector.TallySettings() + tallySettings, err := coredata.ConnectorSettings[coredata.TallyConnectorSettings](dbConnector) if err != nil { return nil, fmt.Errorf("cannot read tally connector settings: %w", err) } @@ -344,14 +344,14 @@ func (e *ReviewEngine) resolveDriver( case coredata.ConnectorProviderOpenAI: return drivers.NewOpenAIDriver(httpClient), nil case coredata.ConnectorProviderSentry: - sentrySettings, err := dbConnector.SentrySettings() + sentrySettings, err := coredata.ConnectorSettings[coredata.SentryConnectorSettings](dbConnector) if err != nil { return nil, fmt.Errorf("cannot read sentry connector settings: %w", err) } // OrganizationSlug may be empty for OAuth connections; the driver auto-discovers it. return drivers.NewSentryDriver(httpClient, sentrySettings.OrganizationSlug), nil case coredata.ConnectorProviderSupabase: - supabaseSettings, err := dbConnector.SupabaseSettings() + supabaseSettings, err := coredata.ConnectorSettings[coredata.SupabaseConnectorSettings](dbConnector) if err != nil { return nil, fmt.Errorf("cannot read supabase connector settings: %w", err) } @@ -360,7 +360,7 @@ func (e *ReviewEngine) resolveDriver( } return drivers.NewSupabaseDriver(httpClient, supabaseSettings.OrganizationSlug), nil case coredata.ConnectorProviderGitHub: - githubSettings, err := dbConnector.GitHubSettings() + githubSettings, err := coredata.ConnectorSettings[coredata.GitHubConnectorSettings](dbConnector) if err != nil { return nil, fmt.Errorf("cannot read github connector settings: %w", err) } @@ -375,7 +375,7 @@ func (e *ReviewEngine) resolveDriver( case coredata.ConnectorProviderMicrosoft365: return drivers.NewMicrosoft365Driver(httpClient), nil case coredata.ConnectorProviderGitLab: - gitlabSettings, err := dbConnector.GitLabSettings() + gitlabSettings, err := coredata.ConnectorSettings[coredata.GitLabConnectorSettings](dbConnector) if err != nil { return nil, fmt.Errorf("cannot read gitlab connector settings: %w", err) } @@ -384,7 +384,7 @@ func (e *ReviewEngine) resolveDriver( } return drivers.NewGitLabDriver(httpClient, gitlabSettings.GroupID), nil case coredata.ConnectorProviderBitbucket: - bitbucketSettings, err := dbConnector.BitbucketSettings() + bitbucketSettings, err := coredata.ConnectorSettings[coredata.BitbucketConnectorSettings](dbConnector) if err != nil { return nil, fmt.Errorf("cannot read bitbucket connector settings: %w", err) } @@ -393,7 +393,7 @@ func (e *ReviewEngine) resolveDriver( } return drivers.NewBitbucketDriver(httpClient, bitbucketSettings.Workspace), nil case coredata.ConnectorProviderHeroku: - herokuSettings, err := dbConnector.HerokuSettings() + herokuSettings, err := coredata.ConnectorSettings[coredata.HerokuConnectorSettings](dbConnector) if err != nil { return nil, fmt.Errorf("cannot read heroku connector settings: %w", err) } @@ -406,7 +406,7 @@ func (e *ReviewEngine) resolveDriver( // itself does not need it because PagerDuty's REST API uses the // regional api.pagerduty.com host. We still surface a clear // error if the OAuth callback failed to capture the subdomain. - pdSettings, err := dbConnector.PagerDutySettings() + pdSettings, err := coredata.ConnectorSettings[coredata.PagerDutyConnectorSettings](dbConnector) if err != nil { return nil, fmt.Errorf("cannot read pagerduty connector settings: %w", err) } @@ -415,7 +415,7 @@ func (e *ReviewEngine) resolveDriver( } return drivers.NewPagerDutyDriver(httpClient), nil case coredata.ConnectorProviderAsana: - asanaSettings, err := dbConnector.AsanaSettings() + asanaSettings, err := coredata.ConnectorSettings[coredata.AsanaConnectorSettings](dbConnector) if err != nil { return nil, fmt.Errorf("cannot read asana connector settings: %w", err) } @@ -424,7 +424,7 @@ func (e *ReviewEngine) resolveDriver( } return drivers.NewAsanaDriver(httpClient, asanaSettings.WorkspaceGID), nil case coredata.ConnectorProviderSnyk: - snykSettings, err := dbConnector.SnykSettings() + snykSettings, err := coredata.ConnectorSettings[coredata.SnykConnectorSettings](dbConnector) if err != nil { return nil, fmt.Errorf("cannot read snyk connector settings: %w", err) } @@ -433,7 +433,7 @@ func (e *ReviewEngine) resolveDriver( } return drivers.NewSnykDriver(httpClient, snykSettings.OrgID), nil case coredata.ConnectorProviderNetlify: - netlifySettings, err := dbConnector.NetlifySettings() + netlifySettings, err := coredata.ConnectorSettings[coredata.NetlifyConnectorSettings](dbConnector) if err != nil { return nil, fmt.Errorf("cannot read netlify connector settings: %w", err) } @@ -444,7 +444,7 @@ func (e *ReviewEngine) resolveDriver( case coredata.ConnectorProviderRamp: return drivers.NewRampDriver(httpClient), nil case coredata.ConnectorProviderClickUp: - clickupSettings, err := dbConnector.ClickUpSettings() + clickupSettings, err := coredata.ConnectorSettings[coredata.ClickUpConnectorSettings](dbConnector) if err != nil { return nil, fmt.Errorf("cannot read clickup connector settings: %w", err) } @@ -453,7 +453,7 @@ func (e *ReviewEngine) resolveDriver( } return drivers.NewClickUpDriver(httpClient, clickupSettings.TeamID), nil case coredata.ConnectorProviderVercel: - vercelSettings, err := dbConnector.VercelSettings() + vercelSettings, err := coredata.ConnectorSettings[coredata.VercelConnectorSettings](dbConnector) if err != nil { return nil, fmt.Errorf("cannot read vercel connector settings: %w", err) } diff --git a/pkg/accessreview/source_name_worker.go b/pkg/accessreview/source_name_worker.go index 1efc6ba88..0bfdae6fa 100644 --- a/pkg/accessreview/source_name_worker.go +++ b/pkg/accessreview/source_name_worker.go @@ -242,7 +242,7 @@ func (h *sourceNameHandler) buildResolver( case coredata.ConnectorProviderBrex: return drivers.NewBrexNameResolver(httpClient) case coredata.ConnectorProviderTally: - tallySettings, err := dbConnector.TallySettings() + tallySettings, err := coredata.ConnectorSettings[coredata.TallyConnectorSettings](dbConnector) if err != nil { h.logger.Error("cannot read tally connector settings", log.Error(err)) return nil @@ -255,21 +255,21 @@ func (h *sourceNameHandler) buildResolver( case coredata.ConnectorProviderOpenAI: return drivers.NewOpenAINameResolver(httpClient) case coredata.ConnectorProviderSentry: - sentrySettings, err := dbConnector.SentrySettings() + 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 := dbConnector.GitHubSettings() + 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 := dbConnector.SupabaseSettings() + supabaseSettings, err := coredata.ConnectorSettings[coredata.SupabaseConnectorSettings](dbConnector) if err != nil { h.logger.Error("cannot read supabase connector settings", log.Error(err)) return nil @@ -284,49 +284,49 @@ func (h *sourceNameHandler) buildResolver( case coredata.ConnectorProviderMicrosoft365: return drivers.NewMicrosoft365NameResolver(httpClient) case coredata.ConnectorProviderGitLab: - gitlabSettings, err := dbConnector.GitLabSettings() + 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 := dbConnector.BitbucketSettings() + 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 := dbConnector.HerokuSettings() + 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 := dbConnector.PagerDutySettings() + 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 := dbConnector.AsanaSettings() + 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.ConnectorProviderSnyk: - snykSettings, err := dbConnector.SnykSettings() + snykSettings, err := coredata.ConnectorSettings[coredata.SnykConnectorSettings](dbConnector) if err != nil { h.logger.Error("cannot read snyk connector settings", log.Error(err)) return nil } return drivers.NewSnykNameResolver(httpClient, snykSettings.OrgID) case coredata.ConnectorProviderNetlify: - netlifySettings, err := dbConnector.NetlifySettings() + netlifySettings, err := coredata.ConnectorSettings[coredata.NetlifyConnectorSettings](dbConnector) if err != nil { h.logger.Error("cannot read netlify connector settings", log.Error(err)) return nil @@ -335,14 +335,14 @@ func (h *sourceNameHandler) buildResolver( case coredata.ConnectorProviderRamp: return drivers.NewRampNameResolver(httpClient) case coredata.ConnectorProviderClickUp: - clickupSettings, err := dbConnector.ClickUpSettings() + 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 := dbConnector.VercelSettings() + vercelSettings, err := coredata.ConnectorSettings[coredata.VercelConnectorSettings](dbConnector) if err != nil { h.logger.Error("cannot read vercel connector settings", log.Error(err)) return nil diff --git a/pkg/connector/vercel.go b/pkg/connector/vercel.go new file mode 100644 index 000000000..4211a0083 --- /dev/null +++ b/pkg/connector/vercel.go @@ -0,0 +1,101 @@ +// 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 connector + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "go.gearno.de/kit/httpclient" +) + +// VercelUser is the projection of Vercel's /v2/user response that Probo +// consumes: the personal-account UID (used as a synthetic TeamID) and +// the human-readable display fields (used by the source-name resolver +// when a connector targets a personal account rather than a team). +type VercelUser struct { + ID string `json:"id"` + Username string `json:"username"` + Name string `json:"name"` +} + +// FetchVercelUser calls Vercel's /v2/user with the provided client. The +// client is expected to carry valid Bearer auth (either via an OAuth2 +// round-tripper, as used by the source-name worker, or via a per-request +// header set by the caller). +func FetchVercelUser(ctx context.Context, client *http.Client) (VercelUser, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.vercel.com/v2/user", nil) + if err != nil { + return VercelUser{}, fmt.Errorf("cannot create vercel user request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return VercelUser{}, fmt.Errorf("cannot execute vercel user request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return VercelUser{}, fmt.Errorf("cannot fetch vercel user: unexpected status %d", resp.StatusCode) + } + + var body struct { + User VercelUser `json:"user"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return VercelUser{}, fmt.Errorf("cannot decode vercel user response: %w", err) + } + return body.User, nil +} + +// FetchVercelUserID is the OAuth-callback variant that builds its own +// one-shot SSRF-protected client and applies the freshly-minted access +// token as a Bearer header on the request. The OAuth callback handler +// uses the returned UID as a synthetic TeamID when the install targets +// a personal account (no team_id surfaced by the callback). +func FetchVercelUserID(ctx context.Context, accessToken string) (string, error) { + reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, "https://api.vercel.com/v2/user", nil) + if err != nil { + return "", fmt.Errorf("cannot create vercel user request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := httpclient.DefaultClient(httpclient.WithSSRFProtection()) + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("cannot execute vercel user request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("cannot fetch vercel user: unexpected status %d", resp.StatusCode) + } + + var body struct { + User VercelUser `json:"user"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", fmt.Errorf("cannot decode vercel user response: %w", err) + } + return body.User.ID, nil +} diff --git a/pkg/coredata/connector.go b/pkg/coredata/connector.go index 28b88e036..1c90ebaf3 100644 --- a/pkg/coredata/connector.go +++ b/pkg/coredata/connector.go @@ -214,7 +214,7 @@ func (c *Connector) LoadByID( if c.Provider == ConnectorProviderSlack { if slackConn, ok := c.Connection.(*connector.SlackConnection); ok { - settings, _ := c.SlackSettings() + settings, _ := ConnectorSettings[SlackConnectorSettings](c) slackConn.Settings.Channel = settings.Channel slackConn.Settings.ChannelID = settings.ChannelID } @@ -613,7 +613,7 @@ func (c *Connectors) decryptConnections(encryptionKey cipher.EncryptionKey) erro if cnnctr.Provider == ConnectorProviderSlack { if slackConn, ok := cnnctr.Connection.(*connector.SlackConnection); ok { - settings, _ := cnnctr.SlackSettings() + settings, _ := ConnectorSettings[SlackConnectorSettings](cnnctr) slackConn.Settings.Channel = settings.Channel slackConn.Settings.ChannelID = settings.ChannelID } diff --git a/pkg/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index e51175d20..fb9297aa2 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -250,3 +250,16 @@ func (c *Connector) unmarshalSettings(v any) error { } return nil } + +// ConnectorSettings unmarshals the connector's RawSettings into the +// requested settings struct. Empty or null RawSettings yields the zero +// value with no error. Use as: +// +// settings, err := coredata.ConnectorSettings[coredata.GitHubConnectorSettings](dbConnector) +func ConnectorSettings[T any](c *Connector) (T, error) { + var s T + if err := c.unmarshalSettings(&s); err != nil { + return s, err + } + return s, nil +} diff --git a/pkg/server/api/console/v1/access_review_campaign_resolvers.go b/pkg/server/api/console/v1/access_review_campaign_resolvers.go index 90314d6a1..88e6a30f3 100644 --- a/pkg/server/api/console/v1/access_review_campaign_resolvers.go +++ b/pkg/server/api/console/v1/access_review_campaign_resolvers.go @@ -469,6 +469,10 @@ func (r *accessSourceResolver) ProviderOrganizations(ctx context.Context, obj *t // NeedsConfiguration is the resolver for the needsConfiguration field. func (r *accessSourceResolver) NeedsConfiguration(ctx context.Context, obj *types.AccessSource) (bool, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceGet); err != nil { + return false, err + } + if obj.ConnectorID == nil { return false, nil } @@ -560,6 +564,10 @@ func (r *accessSourceResolver) ConnectionStatus(ctx context.Context, obj *types. // SelectedOrganization is the resolver for the selectedOrganization field. func (r *accessSourceResolver) SelectedOrganization(ctx context.Context, obj *types.AccessSource) (*string, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceGet); err != nil { + return nil, err + } + if obj.ConnectorID == nil { return nil, nil } diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index df2364869..3af3a4f38 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -18,14 +18,11 @@ package console_v1 import ( "context" - "encoding/json" "fmt" "net/http" "net/url" - "time" "github.com/go-chi/chi/v5" - "go.gearno.de/kit/httpclient" "go.gearno.de/kit/httpserver" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/accessreview" @@ -242,7 +239,7 @@ func handleConnectorComplete( teamID := query.Get("team_id") if teamID == "" { if oauth2Conn, ok := connection.(*connector.OAuth2Connection); ok && oauth2Conn.AccessToken != "" { - if uid, err := fetchVercelUserID(r.Context(), oauth2Conn.AccessToken); err == nil { + if uid, err := connector.FetchVercelUserID(r.Context(), oauth2Conn.AccessToken); err == nil { teamID = uid } else { logger.WarnCtx(r.Context(), "cannot fetch vercel user id for personal-account fallback", log.Error(err)) @@ -322,43 +319,6 @@ func handleConnectorOAuth2Error( safeRedirect.Redirect(w, r, parsedURL.String(), "/", http.StatusSeeOther) } -// fetchVercelUserID calls Vercel's /v2/user with the freshly-minted access -// token to retrieve the user's UID. This is used as a synthetic TeamID -// when the OAuth callback omits team_id (personal-account installs). -func fetchVercelUserID(ctx context.Context, accessToken string) (string, error) { - reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, "https://api.vercel.com/v2/user", nil) - if err != nil { - return "", fmt.Errorf("cannot create vercel user request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+accessToken) - - client := httpclient.DefaultClient(httpclient.WithSSRFProtection()) - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("cannot execute vercel user request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("cannot fetch vercel user: unexpected status %d", resp.StatusCode) - } - - var body struct { - User struct { - ID string `json:"id"` - } `json:"user"` - } - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - return "", fmt.Errorf("cannot decode vercel user response: %w", err) - } - - return body.User.ID, nil -} - // isValidPagerDutySubdomain reports whether s is a single DNS label // (RFC 1035 ยง2.3.1). PagerDuty subdomains are tenant identifiers that // will be embedded in API URLs; the OAuth callback is the only place diff --git a/pkg/server/api/console/v1/types/slack_connection.go b/pkg/server/api/console/v1/types/slack_connection.go index a62202a89..1edb201af 100644 --- a/pkg/server/api/console/v1/types/slack_connection.go +++ b/pkg/server/api/console/v1/types/slack_connection.go @@ -47,7 +47,7 @@ func NewSlackConnection(c *coredata.Connector) *SlackConnection { } // Extract channel information from typed settings - settings, _ := c.SlackSettings() + settings, _ := coredata.ConnectorSettings[coredata.SlackConnectorSettings](c) if settings.Channel != "" { conn.Channel = &settings.Channel }