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`
|
export const accessReviewSourcesTabQuery = graphql`
|
||||||
query AccessReviewSourcesTabQuery($organizationId: ID!) {
|
query AccessReviewSourcesTabQuery($organizationId: ID!) {
|
||||||
|
accessReviewDrivers {
|
||||||
|
...AddAccessSourceDialogConnectorProviderInfoFragment
|
||||||
|
}
|
||||||
organization: node(id: $organizationId) {
|
organization: node(id: $organizationId) {
|
||||||
__typename
|
__typename
|
||||||
... on Organization {
|
... on Organization {
|
||||||
canCreateSource: permission(action: "core:access-source:create")
|
canCreateSource: permission(action: "core:access-source:create")
|
||||||
connectorProviderInfos {
|
|
||||||
...AddAccessSourceDialogConnectorProviderInfoFragment
|
|
||||||
}
|
|
||||||
...AccessReviewSourcesTabFragment
|
...AccessReviewSourcesTabFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,14 +102,15 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const processedConnectorIdRef = useRef<string | null>(null);
|
const processedConnectorIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const { organization } = usePreloadedQuery(accessReviewSourcesTabQuery, queryRef);
|
const query = usePreloadedQuery(accessReviewSourcesTabQuery, queryRef);
|
||||||
|
const { organization } = query;
|
||||||
if (organization.__typename !== "Organization") {
|
if (organization.__typename !== "Organization") {
|
||||||
throw new Error("Organization not found");
|
throw new Error("Organization not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectorProviderInfos = useFragment<AddAccessSourceDialogConnectorProviderInfoFragment$key>(
|
const connectorProviderInfos = useFragment<AddAccessSourceDialogConnectorProviderInfoFragment$key>(
|
||||||
addAccessSourceDialogConnectorProviderInfoFragment,
|
addAccessSourceDialogConnectorProviderInfoFragment,
|
||||||
organization.connectorProviderInfos,
|
query.accessReviewDrivers,
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -22,19 +22,13 @@ import (
|
|||||||
"go.probo.inc/probo/e2e/internal/testutil"
|
"go.probo.inc/probo/e2e/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConnectorProviderInfos(t *testing.T) {
|
func TestAccessReviewDrivers(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
orgID := owner.GetOrganizationID().String()
|
|
||||||
|
|
||||||
t.Run("returns provider infos", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
query($id: ID!) {
|
query {
|
||||||
node(id: $id) {
|
accessReviewDrivers {
|
||||||
... on Organization {
|
|
||||||
connectorProviderInfos {
|
|
||||||
provider
|
provider
|
||||||
displayName
|
displayName
|
||||||
oauthConfigured
|
oauthConfigured
|
||||||
@@ -47,13 +41,10 @@ func TestConnectorProviderInfos(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
`
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
Node struct {
|
AccessReviewDrivers []struct {
|
||||||
ConnectorProviderInfos []struct {
|
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
OauthConfigured bool `json:"oauthConfigured"`
|
OauthConfigured bool `json:"oauthConfigured"`
|
||||||
@@ -64,64 +55,38 @@ func TestConnectorProviderInfos(t *testing.T) {
|
|||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Required bool `json:"required"`
|
Required bool `json:"required"`
|
||||||
} `json:"extraSettings"`
|
} `json:"extraSettings"`
|
||||||
} `json:"connectorProviderInfos"`
|
} `json:"accessReviewDrivers"`
|
||||||
} `json:"node"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := owner.Execute(query, map[string]any{"id": orgID}, &result)
|
err := owner.Execute(query, nil, &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, result.AccessReviewDrivers)
|
||||||
infos := result.Node.ConnectorProviderInfos
|
|
||||||
assert.NotEmpty(t, infos)
|
|
||||||
|
|
||||||
providerNames := make(map[string]bool)
|
providerNames := make(map[string]bool)
|
||||||
|
for _, info := range result.AccessReviewDrivers {
|
||||||
for _, info := range infos {
|
|
||||||
assert.NotEmpty(t, info.Provider)
|
assert.NotEmpty(t, info.Provider)
|
||||||
assert.NotEmpty(t, info.DisplayName)
|
assert.NotEmpty(t, info.DisplayName)
|
||||||
assert.NotNil(t, info.ExtraSettings)
|
assert.NotNil(t, info.ExtraSettings)
|
||||||
providerNames[info.Provider] = true
|
providerNames[info.Provider] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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["BREX"], "expected BREX provider to be present")
|
||||||
assert.True(t, providerNames["HUBSPOT"], "expected HUBSPOT 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()
|
t.Parallel()
|
||||||
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||||
|
|
||||||
const query = `
|
var viewerResult struct {
|
||||||
query($id: ID!) {
|
AccessReviewDrivers []struct {
|
||||||
node(id: $id) {
|
|
||||||
... on Organization {
|
|
||||||
connectorProviderInfos {
|
|
||||||
provider
|
|
||||||
displayName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
Node struct {
|
|
||||||
ConnectorProviderInfos []struct {
|
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
} `json:"connectorProviderInfos"`
|
} `json:"accessReviewDrivers"`
|
||||||
} `json:"node"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := viewer.Execute(query, map[string]any{
|
err := viewer.Execute(query, nil, &viewerResult)
|
||||||
"id": viewer.GetOrganizationID().String(),
|
|
||||||
}, &result)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotEmpty(t, result.Node.ConnectorProviderInfos)
|
assert.NotEmpty(t, viewerResult.AccessReviewDrivers)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -489,6 +489,7 @@ const (
|
|||||||
// CommonThirdParty actions (global catalog, no organization scope).
|
// CommonThirdParty actions (global catalog, no organization scope).
|
||||||
ActionCommonThirdPartyGet = "core:common-third-party:get"
|
ActionCommonThirdPartyGet = "core:common-third-party:get"
|
||||||
ActionCommonThirdPartyList = "core:common-third-party:list"
|
ActionCommonThirdPartyList = "core:common-third-party:list"
|
||||||
|
ActionAccessReviewDriverCatalogList = "core:access-review-driver-catalog:list"
|
||||||
|
|
||||||
// ElectronicSignature actions (tenant-scoped via the related document
|
// ElectronicSignature actions (tenant-scoped via the related document
|
||||||
// version signature / trust center access).
|
// version signature / trust center access).
|
||||||
|
|||||||
@@ -199,6 +199,18 @@ var CommonThirdPartyCatalogPolicy = policy.NewPolicy(
|
|||||||
).WithSID("read-common-third-party-catalog"),
|
).WithSID("read-common-third-party-catalog"),
|
||||||
).WithDescription("Allows every authenticated user to read the global 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.
|
// EmployeePolicy defines permissions for employee role.
|
||||||
var EmployeePolicy = policy.NewPolicy(
|
var EmployeePolicy = policy.NewPolicy(
|
||||||
"probo:employee",
|
"probo:employee",
|
||||||
@@ -232,5 +244,6 @@ func ProboPolicySet() *iam.PolicySet {
|
|||||||
AddRolePolicy("VIEWER", ViewerPolicy).
|
AddRolePolicy("VIEWER", ViewerPolicy).
|
||||||
AddRolePolicy("AUDITOR", AuditorPolicy).
|
AddRolePolicy("AUDITOR", AuditorPolicy).
|
||||||
AddRolePolicy("EMPLOYEE", EmployeePolicy).
|
AddRolePolicy("EMPLOYEE", EmployeePolicy).
|
||||||
AddIdentityScopedPolicy(CommonThirdPartyCatalogPolicy)
|
AddIdentityScopedPolicy(CommonThirdPartyCatalogPolicy).
|
||||||
|
AddIdentityScopedPolicy(AccessReviewDriverCatalogPolicy)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/agentrun"
|
"go.probo.inc/probo/pkg/agentrun"
|
||||||
@@ -539,6 +541,73 @@ func (r *queryResolver) CommonThirdParties(ctx context.Context, name string) ([]
|
|||||||
return result, nil
|
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.
|
// Mutation returns schema.MutationResolver implementation.
|
||||||
func (r *Resolver) Mutation() schema.MutationResolver { return &mutationResolver{r} }
|
func (r *Resolver) Mutation() schema.MutationResolver { return &mutationResolver{r} }
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type Query {
|
|||||||
node(id: ID!): Node!
|
node(id: ID!): Node!
|
||||||
viewer: Viewer!
|
viewer: Viewer!
|
||||||
commonThirdParties(name: String!): [CommonThirdParty!]!
|
commonThirdParties(name: String!): [CommonThirdParty!]!
|
||||||
|
accessReviewDrivers: [ConnectorProviderInfo!]! @goField(forceResolver: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation
|
type Mutation
|
||||||
|
|||||||
@@ -182,7 +182,6 @@ type Organization implements Node {
|
|||||||
): SlackConnectionConnection! @goField(forceResolver: true)
|
): SlackConnectionConnection! @goField(forceResolver: true)
|
||||||
slackOAuth2Scopes: [String!]! @goField(forceResolver: true)
|
slackOAuth2Scopes: [String!]! @goField(forceResolver: true)
|
||||||
connectors(filter: ConnectorFilter): [Connector!]! @goField(forceResolver: true)
|
connectors(filter: ConnectorFilter): [Connector!]! @goField(forceResolver: true)
|
||||||
connectorProviderInfos: [ConnectorProviderInfo!]! @goField(forceResolver: true)
|
|
||||||
|
|
||||||
controls(
|
controls(
|
||||||
first: Int
|
first: Int
|
||||||
|
|||||||
@@ -540,48 +540,6 @@ func (r *organizationResolver) Connectors(ctx context.Context, obj *types.Organi
|
|||||||
return types.NewConnectors(connectors), nil
|
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.
|
// 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) {
|
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)
|
scope, err := r.authorize(ctx, obj.ID, probo.ActionControlList)
|
||||||
|
|||||||
Reference in New Issue
Block a user