Promote connector provider infos to a root-level access-review drivers query
Move connectorProviderInfos from Organization to a new root query field accessReviewDrivers, backed by a deployment-scoped policy so any authenticated identity can list it without an org-scoped permission check. Delete the now-unused helper file and update the frontend and e2e tests. Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
@@ -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<string | null>(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$key>(
|
||||
addAccessSourceDialogConnectorProviderInfoFragment,
|
||||
organization.connectorProviderInfos,
|
||||
query.accessReviewDrivers,
|
||||
);
|
||||
|
||||
const {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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} }
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ type Query {
|
||||
node(id: ID!): Node!
|
||||
viewer: Viewer!
|
||||
commonThirdParties(name: String!): [CommonThirdParty!]!
|
||||
accessReviewDrivers: [ConnectorProviderInfo!]! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
type Mutation
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user