diff --git a/apps/console/src/_locales/en-US.json b/apps/console/src/_locales/en-US.json
index 5b53aec9e..4a67715f9 100644
--- a/apps/console/src/_locales/en-US.json
+++ b/apps/console/src/_locales/en-US.json
@@ -1534,7 +1534,7 @@
"errors": { "delete": "Failed to delete access source", "configure": "Failed to configure source" },
"messages": { "error": "Error", "success": "Success", "organizationUpdated": "Organization updated." },
"deleteConfirmation": "This will permanently delete \"{{name}}\". This action cannot be undone.",
- "status": { "connected": "Connected", "disconnected": "Disconnected", "invalidCredentials": "Invalid credentials" },
+ "status": { "connected": "Connected", "disconnected": "Disconnected", "invalidCredentials": "Invalid credentials", "reconnectRequired": "Reconnect required" },
"actions": { "reconnect": "Reconnect", "delete": "Delete" },
"loading": "Loading...",
"selectOrganization": "Select organization",
diff --git a/apps/console/src/_locales/fr-FR.json b/apps/console/src/_locales/fr-FR.json
index 443ccd480..bd23b8e66 100644
--- a/apps/console/src/_locales/fr-FR.json
+++ b/apps/console/src/_locales/fr-FR.json
@@ -2957,7 +2957,8 @@
"status": {
"connected": "Connecté",
"disconnected": "Déconnecté",
- "invalidCredentials": "Identifiants invalides"
+ "invalidCredentials": "Identifiants invalides",
+ "reconnectRequired": "Reconnexion requise"
},
"actions": {
"reconnect": "Reconnecter",
diff --git a/apps/console/src/pages/organizations/access-reviews/_components/AccessReviewSourceRow.tsx b/apps/console/src/pages/organizations/access-reviews/_components/AccessReviewSourceRow.tsx
index e92ceedf9..57d22f0d0 100644
--- a/apps/console/src/pages/organizations/access-reviews/_components/AccessReviewSourceRow.tsx
+++ b/apps/console/src/pages/organizations/access-reviews/_components/AccessReviewSourceRow.tsx
@@ -243,6 +243,10 @@ export function AccessReviewSourceRow({ fKey, connectionId, organizationId }: Pr
const showOrgSelector = accessSource.needsConfiguration || accessSource.selectedOrganization;
const canReconnect = (accessSource.connector?.oauth2Scopes.length ?? 0) > 0;
+ const showReconnect
+ = canReconnect
+ && (accessSource.connectionStatus === "DISCONNECTED"
+ || accessSource.connectionStatus === "RECONNECT_REQUIRED");
return (
@@ -258,6 +262,18 @@ export function AccessReviewSourceRow({ fKey, connectionId, organizationId }: Pr
{t("accessReviewSourceRow.status.connected")}
)}
+ {accessSource.connectionStatus === "RECONNECT_REQUIRED" && (
+
+
+ {t("accessReviewSourceRow.status.reconnectRequired")}
+
+ {showReconnect && (
+
+ )}
+
+ )}
{accessSource.connectionStatus === "DISCONNECTED" && (
@@ -265,7 +281,7 @@ export function AccessReviewSourceRow({ fKey, connectionId, organizationId }: Pr
? t("accessReviewSourceRow.status.disconnected")
: t("accessReviewSourceRow.status.invalidCredentials")}
- {canReconnect && (
+ {showReconnect && (
diff --git a/pkg/accessreview/source_service.go b/pkg/accessreview/source_service.go
index d3667e869..ed30f7def 100644
--- a/pkg/accessreview/source_service.go
+++ b/pkg/accessreview/source_service.go
@@ -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
diff --git a/pkg/connector/scopes.go b/pkg/connector/scopes.go
index d0f1b1f01..ec08acc82 100644
--- a/pkg/connector/scopes.go
+++ b/pkg/connector/scopes.go
@@ -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.
diff --git a/pkg/connector/scopes_test.go b/pkg/connector/scopes_test.go
index 9707ac320..c94ca8f58 100644
--- a/pkg/connector/scopes_test.go
+++ b/pkg/connector/scopes_test.go
@@ -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()
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 84059aa52..5bc15da5c 100644
--- a/pkg/server/api/console/v1/access_review_campaign_resolvers.go
+++ b/pkg/server/api/console/v1/access_review_campaign_resolvers.go
@@ -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
}
diff --git a/pkg/server/api/console/v1/graphql/access_review_campaign.graphql b/pkg/server/api/console/v1/graphql/access_review_campaign.graphql
index ca0351d57..ea50aa7ab 100644
--- a/pkg/server/api/console/v1/graphql/access_review_campaign.graphql
+++ b/pkg/server/api/console/v1/graphql/access_review_campaign.graphql
@@ -247,6 +247,7 @@ input AccessReviewCampaignSourceFetchAttemptOrder {
enum AccessReviewSourceConnectionStatus {
CONNECTED
DISCONNECTED
+ RECONNECT_REQUIRED
NOT_APPLICABLE
}