Default access source org across GraphQL and MCP

Org-defaulting for picker providers only ran in the GraphQL resolver,
so a picker-provider source created or updated through the MCP API
connected fine but resolved no users until the org was picked. Move
the defaulting into the accessreview service as
AutoSelectDefaultOrganization and call it from both surfaces, moving
the providerOrgConfigs picker dispatch alongside it (the three console
picker resolvers now dispatch through service accessors, behavior
unchanged).

Also harden the moved path: resolve the provider from cheap connector
metadata before building the authenticated HTTP client, so the ~50
non-picker providers no longer pay a decrypt/refresh/DB-write on every
create/update; bound the outbound ListOrgs call with a 10s timeout so
a hung provider cannot stall the mutation; and re-check inside the
ConfigureAccessReviewSource tx (OnlyIfUnset) so an org the user picks
while ListOrgs is in flight is not overwritten by the first listed
org.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-07-22 14:46:08 +02:00
parent f711e9d816
commit 2971637c72
4 changed files with 236 additions and 113 deletions

View File

@@ -448,21 +448,7 @@ func (r *accessReviewSourceResolver) ProviderOrganizations(ctx context.Context,
return []*types.ProviderOrganization{}, nil
}
httpClient, dbConnector, err := r.accessReview.ConnectorHTTPClient(ctx, scope, *obj.ConnectorID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return []*types.ProviderOrganization{}, nil
}
return nil, fmt.Errorf("cannot get connector HTTP client: %w", err)
}
cfg, ok := providerOrgConfigs[dbConnector.Provider]
if !ok || cfg.ListOrgs == nil {
return []*types.ProviderOrganization{}, nil
}
orgs, err := cfg.ListOrgs(ctx, httpClient)
orgs, err := r.accessReview.ProviderOrganizations(ctx, scope, *obj.ConnectorID)
if err != nil {
return nil, err
}
@@ -491,23 +477,18 @@ func (r *accessReviewSourceResolver) NeedsConfiguration(ctx context.Context, obj
return false, nil
}
dbConnector, err := r.probo.Connectors.Get(ctx, scope, *obj.ConnectorID)
needsConfiguration, err := r.accessReview.SourceNeedsConfiguration(ctx, scope, *obj.ConnectorID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return false, nil
}
r.logger.ErrorCtx(ctx, "cannot get connector", log.Error(err))
r.logger.ErrorCtx(ctx, "cannot determine access source configuration", log.Error(err))
return false, gqlutils.Internal(ctx)
}
cfg, ok := providerOrgConfigs[dbConnector.Provider]
if !ok || !cfg.NeedsPicker {
return false, nil
}
return cfg.SelectedSlug(dbConnector) == "", nil
return needsConfiguration, nil
}
// ConnectionStatus is the resolver for the connectionStatus field.
@@ -552,23 +533,17 @@ func (r *accessReviewSourceResolver) SelectedOrganization(ctx context.Context, o
return nil, nil
}
dbConnector, err := r.probo.Connectors.Get(ctx, scope, *obj.ConnectorID)
slug, err := r.accessReview.SelectedOrganizationSlug(ctx, scope, *obj.ConnectorID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, nil
}
r.logger.ErrorCtx(ctx, "cannot get connector", log.Error(err))
r.logger.ErrorCtx(ctx, "cannot get selected organization", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
cfg, ok := providerOrgConfigs[dbConnector.Provider]
if !ok {
return nil, nil
}
slug := cfg.SelectedSlug(dbConnector)
if slug == "" {
return nil, nil
}
@@ -628,7 +603,7 @@ func (r *mutationResolver) CreateAccessReviewSource(ctx context.Context, input t
return nil, gqlutils.Internal(ctx)
}
r.autoSelectDefaultOrganization(ctx, scope, source)
r.accessReview.AutoSelectDefaultOrganization(ctx, scope, source)
return &types.CreateAccessReviewSourcePayload{
AccessReviewSourceEdge: types.NewAccessReviewSourceEdge(source, coredata.AccessReviewSourceOrderFieldCreatedAt),
@@ -666,7 +641,7 @@ func (r *mutationResolver) UpdateAccessReviewSource(ctx context.Context, input t
// right away. Skipped on name/CSV-only updates to avoid a needless
// provider round-trip.
if input.ConnectorID.IsSet() {
r.autoSelectDefaultOrganization(ctx, scope, source)
r.accessReview.AutoSelectDefaultOrganization(ctx, scope, source)
}
return &types.UpdateAccessReviewSourcePayload{
@@ -726,80 +701,6 @@ func (r *mutationResolver) ConfigureAccessReviewSource(ctx context.Context, inpu
}, nil
}
// autoSelectDefaultOrganization picks the first workspace/org the connector can
// see for a freshly linked picker-provider source that has none selected yet.
// Without it a connected source stays "needs configuration" until the user
// completes the picker step; if they skip it, the first campaign silently
// resolves no users (the driver requires an org). Defaulting to the first
// available makes the source immediately usable; the picker UI stays available
// to switch when several are listed.
//
// Best-effort: any failure (provider unreachable, nothing listed) leaves the
// source in its existing "needs configuration" state, where the picker is the
// fallback. It never fails the create/update mutation that triggered it.
func (r *mutationResolver) autoSelectDefaultOrganization(
ctx context.Context,
scope coredata.Scoper,
source *coredata.AccessReviewSource,
) {
if source == nil || source.ConnectorID == nil {
return
}
httpClient, dbConnector, err := r.accessReview.ConnectorHTTPClient(ctx, scope, *source.ConnectorID)
if err != nil {
// A missing connector is not an error worth logging: the picker
// simply never surfaces a default.
if !errors.Is(err, coredata.ErrResourceNotFound) {
r.logger.WarnCtx(ctx, "cannot load connector for default organization", log.Error(err))
}
return
}
cfg, ok := providerOrgConfigs[dbConnector.Provider]
if !ok || !cfg.NeedsPicker || cfg.ListOrgs == nil {
return
}
// Never override an org the user (or an earlier default) already picked.
if cfg.SelectedSlug(dbConnector) != "" {
return
}
orgs, err := cfg.ListOrgs(ctx, httpClient)
if err != nil {
r.logger.WarnCtx(
ctx,
"cannot list provider organizations for default selection",
log.String("provider", dbConnector.Provider.String()),
log.Error(err),
)
return
}
if len(orgs) == 0 {
return
}
if _, err := r.accessReview.ConfigureAccessReviewSource(
ctx,
scope,
accessreview.ConfigureAccessReviewSourceRequest{
AccessReviewSourceID: source.ID,
OrganizationSlug: orgs[0].Slug,
},
); err != nil {
r.logger.WarnCtx(
ctx,
"cannot apply default provider organization",
log.String("provider", dbConnector.Provider.String()),
log.Error(err),
)
}
}
// CreateAccessReviewCampaign is the resolver for the createAccessReviewCampaign field.
func (r *mutationResolver) CreateAccessReviewCampaign(ctx context.Context, input types.CreateAccessReviewCampaignInput) (*types.CreateAccessReviewCampaignPayload, error) {
scope, err := r.authorize(ctx, input.OrganizationID, accessreview.ActionCampaignCreate)

View File

@@ -1,156 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package console_v1
import (
"context"
"net/http"
"go.probo.inc/probo/pkg/accessreview/drivers"
"go.probo.inc/probo/pkg/coredata"
)
// providerOrgConfig binds a connector provider to its picker-UI behavior.
//
// ListOrgs returns the orgs/workspaces/teams the authenticated user can
// scope the connector to (nil for Pattern 2-auto providers like
// PagerDuty and Vercel where the value is captured during OAuth).
//
// SelectedSlug returns the currently-configured org identifier for the
// connector (empty string if none).
//
// NeedsPicker reports whether the picker mutation should surface in the
// UI; false for 2-auto providers.
type providerOrgConfig struct {
ListOrgs func(ctx context.Context, httpClient *http.Client) ([]drivers.Organization, error)
SelectedSlug func(c *coredata.Connector) string
NeedsPicker bool
}
// providerOrgConfigs is the single source of truth that the three
// AccessReviewSource picker resolvers (ProviderOrganizations,
// SelectedOrganization, NeedsConfiguration) dispatch through. Adding a
// provider takes one entry here, not three switch arms.
var providerOrgConfigs = map[coredata.ConnectorProvider]providerOrgConfig{
coredata.ConnectorProviderGitHub: {
ListOrgs: drivers.ListGitHubOrganizations,
SelectedSlug: func(c *coredata.Connector) string {
s, _ := coredata.ConnectorSettings[coredata.GitHubConnectorSettings](c)
return s.Organization
},
NeedsPicker: true,
},
coredata.ConnectorProviderSentry: {
ListOrgs: drivers.ListSentryOrganizations,
SelectedSlug: func(c *coredata.Connector) string {
s, _ := coredata.ConnectorSettings[coredata.SentryConnectorSettings](c)
return s.OrganizationSlug
},
NeedsPicker: true,
},
coredata.ConnectorProviderGitLab: {
ListOrgs: drivers.ListGitLabOrganizations,
SelectedSlug: func(c *coredata.Connector) string {
s, _ := coredata.ConnectorSettings[coredata.GitLabConnectorSettings](c)
return s.GroupID
},
NeedsPicker: true,
},
coredata.ConnectorProviderBitbucket: {
ListOrgs: drivers.ListBitbucketOrganizations,
SelectedSlug: func(c *coredata.Connector) string {
s, _ := coredata.ConnectorSettings[coredata.BitbucketConnectorSettings](c)
return s.Workspace
},
NeedsPicker: true,
},
coredata.ConnectorProviderHeroku: {
ListOrgs: drivers.ListHerokuOrganizations,
SelectedSlug: func(c *coredata.Connector) string {
s, _ := coredata.ConnectorSettings[coredata.HerokuConnectorSettings](c)
return s.TeamID
},
NeedsPicker: true,
},
coredata.ConnectorProviderAsana: {
ListOrgs: drivers.ListAsanaOrganizations,
SelectedSlug: func(c *coredata.Connector) string {
s, _ := coredata.ConnectorSettings[coredata.AsanaConnectorSettings](c)
return s.WorkspaceGID
},
NeedsPicker: true,
},
coredata.ConnectorProviderNetlify: {
ListOrgs: drivers.ListNetlifyOrganizations,
SelectedSlug: func(c *coredata.Connector) string {
s, _ := coredata.ConnectorSettings[coredata.NetlifyConnectorSettings](c)
return s.AccountSlug
},
NeedsPicker: true,
},
coredata.ConnectorProviderClickUp: {
ListOrgs: drivers.ListClickUpOrganizations,
SelectedSlug: func(c *coredata.Connector) string {
s, _ := coredata.ConnectorSettings[coredata.ClickUpConnectorSettings](c)
return s.TeamID
},
NeedsPicker: true,
},
coredata.ConnectorProviderDocuSign: {
ListOrgs: drivers.ListDocuSignOrganizations,
SelectedSlug: func(c *coredata.Connector) string {
s, _ := coredata.ConnectorSettings[coredata.DocuSignConnectorSettings](c)
return s.AccountID
},
NeedsPicker: true,
},
// Pattern 2-auto: identifier is captured during the OAuth callback
// (subdomain for PagerDuty, team_id or fallback /v2/user.id for
// Vercel). No picker UI; NeedsPicker = false.
coredata.ConnectorProviderPagerDuty: {
SelectedSlug: func(c *coredata.Connector) string {
s, _ := coredata.ConnectorSettings[coredata.PagerDutyConnectorSettings](c)
return s.Subdomain
},
},
coredata.ConnectorProviderVercel: {
SelectedSlug: func(c *coredata.Connector) string {
s, _ := coredata.ConnectorSettings[coredata.VercelConnectorSettings](c)
return s.TeamID
},
},
// Pattern 2-auto: the API domain is captured during the OAuth
// callback from Datadog's `domain` parameter; no picker UI.
coredata.ConnectorProviderDatadog: {
SelectedSlug: func(c *coredata.Connector) string {
s, _ := coredata.ConnectorSettings[coredata.DatadogConnectorSettings](c)
return s.Domain
},
},
// Pattern 2-auto: the subdomain is collected at initiate and persisted
// from the signed OAuth state on the callback; no picker UI.
coredata.ConnectorProviderZendesk: {
SelectedSlug: func(c *coredata.Connector) string {
s, _ := coredata.ConnectorSettings[coredata.ZendeskConnectorSettings](c)
return s.Subdomain
},
},
}