Show reconnect when access-review connectors need new OAuth scopes
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
This commit is contained in:
@@ -1534,7 +1534,7 @@
|
|||||||
"errors": { "delete": "Failed to delete access source", "configure": "Failed to configure source" },
|
"errors": { "delete": "Failed to delete access source", "configure": "Failed to configure source" },
|
||||||
"messages": { "error": "Error", "success": "Success", "organizationUpdated": "Organization updated." },
|
"messages": { "error": "Error", "success": "Success", "organizationUpdated": "Organization updated." },
|
||||||
"deleteConfirmation": "This will permanently delete \"{{name}}\". This action cannot be undone.",
|
"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" },
|
"actions": { "reconnect": "Reconnect", "delete": "Delete" },
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"selectOrganization": "Select organization",
|
"selectOrganization": "Select organization",
|
||||||
|
|||||||
@@ -2957,7 +2957,8 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"connected": "Connecté",
|
"connected": "Connecté",
|
||||||
"disconnected": "Déconnecté",
|
"disconnected": "Déconnecté",
|
||||||
"invalidCredentials": "Identifiants invalides"
|
"invalidCredentials": "Identifiants invalides",
|
||||||
|
"reconnectRequired": "Reconnexion requise"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"reconnect": "Reconnecter",
|
"reconnect": "Reconnecter",
|
||||||
|
|||||||
@@ -243,6 +243,10 @@ export function AccessReviewSourceRow({ fKey, connectionId, organizationId }: Pr
|
|||||||
|
|
||||||
const showOrgSelector = accessSource.needsConfiguration || accessSource.selectedOrganization;
|
const showOrgSelector = accessSource.needsConfiguration || accessSource.selectedOrganization;
|
||||||
const canReconnect = (accessSource.connector?.oauth2Scopes.length ?? 0) > 0;
|
const canReconnect = (accessSource.connector?.oauth2Scopes.length ?? 0) > 0;
|
||||||
|
const showReconnect
|
||||||
|
= canReconnect
|
||||||
|
&& (accessSource.connectionStatus === "DISCONNECTED"
|
||||||
|
|| accessSource.connectionStatus === "RECONNECT_REQUIRED");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr>
|
<Tr>
|
||||||
@@ -258,6 +262,18 @@ export function AccessReviewSourceRow({ fKey, connectionId, organizationId }: Pr
|
|||||||
{t("accessReviewSourceRow.status.connected")}
|
{t("accessReviewSourceRow.status.connected")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{accessSource.connectionStatus === "RECONNECT_REQUIRED" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="warning" size="sm">
|
||||||
|
{t("accessReviewSourceRow.status.reconnectRequired")}
|
||||||
|
</Badge>
|
||||||
|
{showReconnect && (
|
||||||
|
<Button variant="secondary" onClick={handleReconnect}>
|
||||||
|
{t("accessReviewSourceRow.actions.reconnect")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{accessSource.connectionStatus === "DISCONNECTED" && (
|
{accessSource.connectionStatus === "DISCONNECTED" && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="danger" size="sm">
|
<Badge variant="danger" size="sm">
|
||||||
@@ -265,7 +281,7 @@ export function AccessReviewSourceRow({ fKey, connectionId, organizationId }: Pr
|
|||||||
? t("accessReviewSourceRow.status.disconnected")
|
? t("accessReviewSourceRow.status.disconnected")
|
||||||
: t("accessReviewSourceRow.status.invalidCredentials")}
|
: t("accessReviewSourceRow.status.invalidCredentials")}
|
||||||
</Badge>
|
</Badge>
|
||||||
{canReconnect && (
|
{showReconnect && (
|
||||||
<Button variant="secondary" onClick={handleReconnect}>
|
<Button variant="secondary" onClick={handleReconnect}>
|
||||||
{t("accessReviewSourceRow.actions.reconnect")}
|
{t("accessReviewSourceRow.actions.reconnect")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -582,6 +582,49 @@ func (s *Service) SourceNeedsConfiguration(
|
|||||||
return cfg.SelectedSlug(dbConnector) == "", nil
|
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
|
// AutoSelectDefaultOrganization picks the first workspace/org a freshly linked
|
||||||
// picker-provider source can see when none is selected yet, so the source is
|
// 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
|
// usable immediately instead of failing its first campaign fetch. The picker
|
||||||
|
|||||||
@@ -69,6 +69,58 @@ func FormatScopeString(scopes []string) string {
|
|||||||
return strings.Join(sorted, " ")
|
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
|
// UnionScopes returns the sorted, deduplicated union of the given scope
|
||||||
// slices. Empty strings and empty slices are handled gracefully. The
|
// slices. Empty strings and empty slices are handled gracefully. The
|
||||||
// result is a fresh slice and never aliases any input.
|
// result is a fresh slice and never aliases any input.
|
||||||
|
|||||||
@@ -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) {
|
func TestUnionScopes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -493,6 +493,11 @@ func (r *accessReviewSourceResolver) NeedsConfiguration(ctx context.Context, obj
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ConnectionStatus is the resolver for the connectionStatus field.
|
// 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) {
|
func (r *accessReviewSourceResolver) ConnectionStatus(ctx context.Context, obj *types.AccessReviewSource) (types.AccessReviewSourceConnectionStatus, error) {
|
||||||
if obj.ConnectorID == nil {
|
if obj.ConnectorID == nil {
|
||||||
return types.AccessReviewSourceConnectionStatusNotApplicable, nil
|
return types.AccessReviewSourceConnectionStatusNotApplicable, nil
|
||||||
@@ -520,6 +525,21 @@ func (r *accessReviewSourceResolver) ConnectionStatus(ctx context.Context, obj *
|
|||||||
return types.AccessReviewSourceConnectionStatusDisconnected, nil
|
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
|
return types.AccessReviewSourceConnectionStatusConnected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ input AccessReviewCampaignSourceFetchAttemptOrder {
|
|||||||
enum AccessReviewSourceConnectionStatus {
|
enum AccessReviewSourceConnectionStatus {
|
||||||
CONNECTED
|
CONNECTED
|
||||||
DISCONNECTED
|
DISCONNECTED
|
||||||
|
RECONNECT_REQUIRED
|
||||||
NOT_APPLICABLE
|
NOT_APPLICABLE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user