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 }