Show reconnect when access-review connectors need new OAuth scopes

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
This commit is contained in:
Sacha Al Himdani
2026-07-29 15:55:07 +02:00
parent 51a748c625
commit 573549fcec
8 changed files with 166 additions and 3 deletions

View File

@@ -582,6 +582,49 @@ func (s *Service) SourceNeedsConfiguration(
return cfg.SelectedSlug(dbConnector) == "", nil
}
// SourceNeedsReconnect reports whether the connector is missing OAuth scopes
// required by the current provider registration. Only OAuth2 connectors are
// checked: API-key (and other non-OAuth) credentials have no grant scopes and
// cannot be repaired by an OAuth reconnect, even when the provider also
// advertises OAuth2Scopes for its dual-auth path. ErrResourceNotFound is
// propagated for a missing connector.
func (s *Service) SourceNeedsReconnect(
ctx context.Context,
scope coredata.Scoper,
connectorID gid.GID,
) (bool, error) {
var dbConnector coredata.Connector
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := dbConnector.LoadByID(ctx, conn, scope, connectorID, s.encryptionKey); err != nil {
return err
}
return nil
},
)
if err != nil {
return false, err
}
if dbConnector.Protocol != coredata.ConnectorProtocolOAuth2 {
return false, nil
}
required := s.providerRegistry.ProviderOAuth2Scopes(dbConnector.Provider)
if len(required) == 0 {
return false, nil
}
if dbConnector.Connection == nil {
return true, nil
}
return len(connector.MissingScopes(required, dbConnector.Connection.Scopes())) > 0, nil
}
// AutoSelectDefaultOrganization picks the first workspace/org a freshly linked
// picker-provider source can see when none is selected yet, so the source is
// usable immediately instead of failing its first campaign fetch. The picker

View File

@@ -69,6 +69,58 @@ func FormatScopeString(scopes []string) string {
return strings.Join(sorted, " ")
}
// microsoftGraphScopePrefix is stripped when comparing scopes so that
// Microsoft's short permission names (User.Read.All) match the full
// resource URIs we request (https://graph.microsoft.com/User.Read.All).
const microsoftGraphScopePrefix = "https://graph.microsoft.com/"
// canonicalizeScope normalizes a scope string for equality checks.
func canonicalizeScope(scope string) string {
return strings.TrimPrefix(scope, microsoftGraphScopePrefix)
}
// MissingScopes returns the sorted list of scopes present in required but
// absent from granted. Empty strings in either input are ignored. Scope
// comparison is canonicalized so Microsoft Graph short names and full
// resource URIs are treated as equivalent. The result uses the required
// scope strings as provided and never aliases any input.
func MissingScopes(required, granted []string) []string {
grantedSet := make(map[string]struct{}, len(granted))
for _, s := range granted {
if s == "" {
continue
}
grantedSet[canonicalizeScope(s)] = struct{}{}
}
missing := make([]string, 0)
seenMissing := make(map[string]struct{})
for _, s := range required {
if s == "" {
continue
}
key := canonicalizeScope(s)
if _, ok := grantedSet[key]; ok {
continue
}
if _, ok := seenMissing[key]; ok {
continue
}
seenMissing[key] = struct{}{}
missing = append(missing, s)
}
sort.Strings(missing)
return missing
}
// UnionScopes returns the sorted, deduplicated union of the given scope
// slices. Empty strings and empty slices are handled gracefully. The
// result is a fresh slice and never aliases any input.

View File

@@ -53,6 +53,36 @@ func TestParseScopeString(t *testing.T) {
}
}
func TestMissingScopes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
required []string
granted []string
want []string
}{
{"both empty", nil, nil, []string{}},
{"none missing", []string{"a", "b"}, []string{"b", "a"}, []string{}},
{"some missing", []string{"a", "b", "c"}, []string{"a"}, []string{"b", "c"}},
{"all missing", []string{"a", "b"}, nil, []string{"a", "b"}},
{"drops empty strings", []string{"a", ""}, []string{""}, []string{"a"}},
{"sorted output", []string{"z", "a"}, nil, []string{"a", "z"}},
{
"microsoft graph short vs full uri",
[]string{"https://graph.microsoft.com/User.Read.All", "https://graph.microsoft.com/AuditLog.Read.All"},
[]string{"User.Read.All"},
[]string{"https://graph.microsoft.com/AuditLog.Read.All"},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.want, MissingScopes(c.required, c.granted))
})
}
}
func TestUnionScopes(t *testing.T) {
t.Parallel()

View File

@@ -493,6 +493,11 @@ func (r *accessReviewSourceResolver) NeedsConfiguration(ctx context.Context, obj
}
// ConnectionStatus is the resolver for the connectionStatus field.
//
// Returns RECONNECT_REQUIRED when the connector's stored OAuth grant is
// missing scopes required by the current provider registration (e.g. a newly
// added Graph permission), DISCONNECTED when the credential probe fails, and
// CONNECTED when the grant is usable as-is.
func (r *accessReviewSourceResolver) ConnectionStatus(ctx context.Context, obj *types.AccessReviewSource) (types.AccessReviewSourceConnectionStatus, error) {
if obj.ConnectorID == nil {
return types.AccessReviewSourceConnectionStatusNotApplicable, nil
@@ -520,6 +525,21 @@ func (r *accessReviewSourceResolver) ConnectionStatus(ctx context.Context, obj *
return types.AccessReviewSourceConnectionStatusDisconnected, nil
}
needsReconnect, err := r.accessReview.SourceNeedsReconnect(ctx, scope, *obj.ConnectorID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return types.AccessReviewSourceConnectionStatusNotApplicable, nil
}
r.logger.ErrorCtx(ctx, "cannot determine access source reconnect requirement", log.Error(err))
return types.AccessReviewSourceConnectionStatusNotApplicable, gqlutils.Internal(ctx)
}
if needsReconnect {
return types.AccessReviewSourceConnectionStatusReconnectRequired, nil
}
return types.AccessReviewSourceConnectionStatusConnected, nil
}

View File

@@ -247,6 +247,7 @@ input AccessReviewCampaignSourceFetchAttemptOrder {
enum AccessReviewSourceConnectionStatus {
CONNECTED
DISCONNECTED
RECONNECT_REQUIRED
NOT_APPLICABLE
}