diff --git a/apps/console/src/pages/organizations/access-reviews/sources/AccessReviewSourcesTab.tsx b/apps/console/src/pages/organizations/access-reviews/sources/AccessReviewSourcesTab.tsx index 38ca35cae..498f50e8a 100644 --- a/apps/console/src/pages/organizations/access-reviews/sources/AccessReviewSourcesTab.tsx +++ b/apps/console/src/pages/organizations/access-reviews/sources/AccessReviewSourcesTab.tsx @@ -43,13 +43,13 @@ import { AddAccessSourceDialog, addAccessSourceDialogConnectorProviderInfoFragme export const accessReviewSourcesTabQuery = graphql` query AccessReviewSourcesTabQuery($organizationId: ID!) { + accessReviewDrivers { + ...AddAccessSourceDialogConnectorProviderInfoFragment + } organization: node(id: $organizationId) { __typename ... on Organization { canCreateSource: permission(action: "core:access-source:create") - connectorProviderInfos { - ...AddAccessSourceDialogConnectorProviderInfoFragment - } ...AccessReviewSourcesTabFragment } } @@ -102,14 +102,15 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) { const [searchParams, setSearchParams] = useSearchParams(); const processedConnectorIdRef = useRef(null); - const { organization } = usePreloadedQuery(accessReviewSourcesTabQuery, queryRef); + const query = usePreloadedQuery(accessReviewSourcesTabQuery, queryRef); + const { organization } = query; if (organization.__typename !== "Organization") { throw new Error("Organization not found"); } const connectorProviderInfos = useFragment( addAccessSourceDialogConnectorProviderInfoFragment, - organization.connectorProviderInfos, + query.accessReviewDrivers, ); const { diff --git a/e2e/console/connector_test.go b/e2e/console/connector_test.go index a0a1eda87..db1c14b3c 100644 --- a/e2e/console/connector_test.go +++ b/e2e/console/connector_test.go @@ -22,106 +22,71 @@ import ( "go.probo.inc/probo/e2e/internal/testutil" ) -func TestConnectorProviderInfos(t *testing.T) { +func TestAccessReviewDrivers(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) - orgID := owner.GetOrganizationID().String() - t.Run("returns provider infos", func(t *testing.T) { - t.Parallel() - - const query = ` - query($id: ID!) { - node(id: $id) { - ... on Organization { - connectorProviderInfos { - provider - displayName - oauthConfigured - apiKeySupported - clientCredentialsSupported - extraSettings { - key - label - required - } - } - } + const query = ` + query { + accessReviewDrivers { + provider + displayName + oauthConfigured + apiKeySupported + clientCredentialsSupported + extraSettings { + key + label + required } } - ` - - var result struct { - Node struct { - ConnectorProviderInfos []struct { - Provider string `json:"provider"` - DisplayName string `json:"displayName"` - OauthConfigured bool `json:"oauthConfigured"` - APIKeySupported bool `json:"apiKeySupported"` - ClientCredentialsSupported bool `json:"clientCredentialsSupported"` - ExtraSettings []struct { - Key string `json:"key"` - Label string `json:"label"` - Required bool `json:"required"` - } `json:"extraSettings"` - } `json:"connectorProviderInfos"` - } `json:"node"` } + ` - err := owner.Execute(query, map[string]any{"id": orgID}, &result) - require.NoError(t, err) + var result struct { + AccessReviewDrivers []struct { + Provider string `json:"provider"` + DisplayName string `json:"displayName"` + OauthConfigured bool `json:"oauthConfigured"` + APIKeySupported bool `json:"apiKeySupported"` + ClientCredentialsSupported bool `json:"clientCredentialsSupported"` + ExtraSettings []struct { + Key string `json:"key"` + Label string `json:"label"` + Required bool `json:"required"` + } `json:"extraSettings"` + } `json:"accessReviewDrivers"` + } - infos := result.Node.ConnectorProviderInfos - assert.NotEmpty(t, infos) + err := owner.Execute(query, nil, &result) + require.NoError(t, err) + assert.NotEmpty(t, result.AccessReviewDrivers) - providerNames := make(map[string]bool) + providerNames := make(map[string]bool) + for _, info := range result.AccessReviewDrivers { + assert.NotEmpty(t, info.Provider) + assert.NotEmpty(t, info.DisplayName) + assert.NotNil(t, info.ExtraSettings) + providerNames[info.Provider] = true + } - for _, info := range infos { - assert.NotEmpty(t, info.Provider) - assert.NotEmpty(t, info.DisplayName) - assert.NotNil(t, info.ExtraSettings) - providerNames[info.Provider] = true - } + assert.True(t, providerNames["BREX"], "expected BREX provider to be present") + assert.True(t, providerNames["HUBSPOT"], "expected HUBSPOT provider to be present") - // OAuth-only providers (e.g. SLACK) are hidden when the deployment - // has no OAuth credentials configured, as is the case in e2e. - // Assert on providers that support API keys, which are connectable - // regardless of OAuth configuration. - assert.True(t, providerNames["BREX"], "expected BREX provider to be present") - assert.True(t, providerNames["HUBSPOT"], "expected HUBSPOT provider to be present") - }) - - t.Run("viewer can list provider infos", func(t *testing.T) { + t.Run("viewer can list access review drivers", func(t *testing.T) { t.Parallel() viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) - const query = ` - query($id: ID!) { - node(id: $id) { - ... on Organization { - connectorProviderInfos { - provider - displayName - } - } - } - } - ` - - var result struct { - Node struct { - ConnectorProviderInfos []struct { - Provider string `json:"provider"` - DisplayName string `json:"displayName"` - } `json:"connectorProviderInfos"` - } `json:"node"` + var viewerResult struct { + AccessReviewDrivers []struct { + Provider string `json:"provider"` + DisplayName string `json:"displayName"` + } `json:"accessReviewDrivers"` } - err := viewer.Execute(query, map[string]any{ - "id": viewer.GetOrganizationID().String(), - }, &result) + err := viewer.Execute(query, nil, &viewerResult) require.NoError(t, err) - assert.NotEmpty(t, result.Node.ConnectorProviderInfos) + assert.NotEmpty(t, viewerResult.AccessReviewDrivers) }) } diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index b6119ae37..4c178c463 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -487,8 +487,9 @@ const ( ActionCookieConsentRecordList = "core:cookie-consent-record:list" // CommonThirdParty actions (global catalog, no organization scope). - ActionCommonThirdPartyGet = "core:common-third-party:get" - ActionCommonThirdPartyList = "core:common-third-party:list" + ActionCommonThirdPartyGet = "core:common-third-party:get" + ActionCommonThirdPartyList = "core:common-third-party:list" + ActionAccessReviewDriverCatalogList = "core:access-review-driver-catalog:list" // ElectronicSignature actions (tenant-scoped via the related document // version signature / trust center access). diff --git a/pkg/probo/policies.go b/pkg/probo/policies.go index 292defd12..b74266b5f 100644 --- a/pkg/probo/policies.go +++ b/pkg/probo/policies.go @@ -199,6 +199,18 @@ var CommonThirdPartyCatalogPolicy = policy.NewPolicy( ).WithSID("read-common-third-party-catalog"), ).WithDescription("Allows every authenticated user to read the global common third-party catalog") +// AccessReviewDriverCatalogPolicy grants every authenticated identity +// read access to the global access-review driver catalog. The catalog +// is deployment-scoped and has no organization scoping, so the allow +// has no condition. +var AccessReviewDriverCatalogPolicy = policy.NewPolicy( + "probo:access-review-driver-catalog", + "Probo Access Review Driver Catalog", + policy.Allow( + ActionAccessReviewDriverCatalogList, + ).WithSID("read-access-review-driver-catalog"), +).WithDescription("Allows every authenticated user to read the global access-review driver catalog") + // EmployeePolicy defines permissions for employee role. var EmployeePolicy = policy.NewPolicy( "probo:employee", @@ -232,5 +244,6 @@ func ProboPolicySet() *iam.PolicySet { AddRolePolicy("VIEWER", ViewerPolicy). AddRolePolicy("AUDITOR", AuditorPolicy). AddRolePolicy("EMPLOYEE", EmployeePolicy). - AddIdentityScopedPolicy(CommonThirdPartyCatalogPolicy) + AddIdentityScopedPolicy(CommonThirdPartyCatalogPolicy). + AddIdentityScopedPolicy(AccessReviewDriverCatalogPolicy) } diff --git a/pkg/server/api/console/v1/base_resolvers.go b/pkg/server/api/console/v1/base_resolvers.go index 82121c57a..0f11be47b 100644 --- a/pkg/server/api/console/v1/base_resolvers.go +++ b/pkg/server/api/console/v1/base_resolvers.go @@ -9,6 +9,8 @@ import ( "context" "errors" "fmt" + "slices" + "strings" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/agentrun" @@ -539,6 +541,73 @@ func (r *queryResolver) CommonThirdParties(ctx context.Context, name string) ([] return result, nil } +// AccessReviewDrivers is the resolver for the accessReviewDrivers field. +func (r *queryResolver) AccessReviewDrivers(ctx context.Context) ([]*types.ConnectorProviderInfo, error) { + identity := authn.IdentityFromContext(ctx) + + if _, err := r.authorize(ctx, identity.ID, probo.ActionAccessReviewDriverCatalogList); err != nil { + return nil, err + } + + registrations := r.providerRegistry.All() + infos := make([]*types.ConnectorProviderInfo, 0, len(registrations)) + + for _, reg := range registrations { + if reg == nil || reg.NewDriver == nil { + continue + } + + provider := reg.Provider + _, oauthErr := r.connectorRegistry.Get(string(provider)) + oauthConfigured := oauthErr == nil + apiKeySupported := reg.SupportsAPIKey + clientCredentialsSupported := reg.SupportsClientCredentials + + // Skip providers that cannot be connected in this deployment: no + // OAuth client credentials configured and no key-based fallback + // (API key or client credentials) supported. + if !oauthConfigured && !apiKeySupported && !clientCredentialsSupported { + continue + } + + scopes := r.providerRegistry.ProviderOAuth2Scopes(provider) + if scopes == nil { + scopes = []string{} + } + + extraSettings := make([]*types.ConnectorProviderSettingInfo, 0, len(reg.ExtraSettings)) + for _, setting := range reg.ExtraSettings { + extraSettings = append( + extraSettings, + &types.ConnectorProviderSettingInfo{ + Key: setting.Key, + Label: setting.Label, + Required: setting.Required, + }, + ) + } + + infos = append(infos, &types.ConnectorProviderInfo{ + Provider: provider, + DisplayName: reg.DisplayName, + OauthConfigured: oauthConfigured, + APIKeySupported: apiKeySupported, + ClientCredentialsSupported: clientCredentialsSupported, + Oauth2Scopes: scopes, + ExtraSettings: extraSettings, + }) + } + + slices.SortFunc( + infos, + func(a, b *types.ConnectorProviderInfo) int { + return strings.Compare(a.DisplayName, b.DisplayName) + }, + ) + + return infos, nil +} + // Mutation returns schema.MutationResolver implementation. func (r *Resolver) Mutation() schema.MutationResolver { return &mutationResolver{r} } diff --git a/pkg/server/api/console/v1/graphql/base.graphql b/pkg/server/api/console/v1/graphql/base.graphql index fdb7fed66..572136280 100644 --- a/pkg/server/api/console/v1/graphql/base.graphql +++ b/pkg/server/api/console/v1/graphql/base.graphql @@ -26,6 +26,7 @@ type Query { node(id: ID!): Node! viewer: Viewer! commonThirdParties(name: String!): [CommonThirdParty!]! + accessReviewDrivers: [ConnectorProviderInfo!]! @goField(forceResolver: true) } type Mutation diff --git a/pkg/server/api/console/v1/graphql/organization.graphql b/pkg/server/api/console/v1/graphql/organization.graphql index 0c245eb9e..4ba0fd45c 100644 --- a/pkg/server/api/console/v1/graphql/organization.graphql +++ b/pkg/server/api/console/v1/graphql/organization.graphql @@ -182,7 +182,6 @@ type Organization implements Node { ): SlackConnectionConnection! @goField(forceResolver: true) slackOAuth2Scopes: [String!]! @goField(forceResolver: true) connectors(filter: ConnectorFilter): [Connector!]! @goField(forceResolver: true) - connectorProviderInfos: [ConnectorProviderInfo!]! @goField(forceResolver: true) controls( first: Int diff --git a/pkg/server/api/console/v1/organization_resolvers.go b/pkg/server/api/console/v1/organization_resolvers.go index d23f62cf6..64e4041f8 100644 --- a/pkg/server/api/console/v1/organization_resolvers.go +++ b/pkg/server/api/console/v1/organization_resolvers.go @@ -540,48 +540,6 @@ func (r *organizationResolver) Connectors(ctx context.Context, obj *types.Organi return types.NewConnectors(connectors), nil } -// ConnectorProviderInfos is the resolver for the connectorProviderInfos field. -func (r *organizationResolver) ConnectorProviderInfos(ctx context.Context, obj *types.Organization) ([]*types.ConnectorProviderInfo, error) { - if _, err := r.authorize(ctx, obj.ID, probo.ActionConnectorList); err != nil { - return nil, err - } - - var infos []*types.ConnectorProviderInfo - - for _, p := range coredata.ConnectorProviders() { - _, oauthErr := r.connectorRegistry.Get(string(p)) - oauthConfigured := oauthErr == nil - apiKeySupported := r.providerSupportsAPIKey(p) - clientCredentialsSupported := r.providerSupportsClientCredentials(p) - - // Skip providers that cannot be connected in this deployment: no - // OAuth client credentials configured and no key-based fallback - // (API key or client credentials) supported. Surfacing them would - // render dead entries the operator has no way to use. - if !oauthConfigured && !apiKeySupported && !clientCredentialsSupported { - continue - } - - scopes := r.providerRegistry.ProviderOAuth2Scopes(p) - if scopes == nil { - scopes = []string{} - } - - info := &types.ConnectorProviderInfo{ - Provider: p, - DisplayName: r.providerDisplayName(p), - OauthConfigured: oauthConfigured, - APIKeySupported: apiKeySupported, - ClientCredentialsSupported: clientCredentialsSupported, - Oauth2Scopes: scopes, - ExtraSettings: r.providerExtraSettings(p), - } - infos = append(infos, info) - } - - return infos, nil -} - // Controls is the resolver for the controls field. func (r *organizationResolver) Controls(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ControlOrderBy, filter *types.ControlFilter) (*types.ControlConnection, error) { scope, err := r.authorize(ctx, obj.ID, probo.ActionControlList)