The console previously dispatched per-provider logic through a fan of init()-side-effect maps (driver names, OAuth2 metadata, probe URLs, display names, settings switches), spread across pkg/connector, pkg/accessreview/drivers and the console v1 resolvers. Adding a new provider required edits in every one of those places and a corresponding switch arm in CreateConnectorRequest. The same per-provider knowledge also leaked into Helm templates as hand-rolled environment-variable blocks per connector. This commit collapses the dispatch surface into a single typed *provider.Registry. The registry is constructed once by NewBuiltinRegistry at probod startup and threaded as an explicit dependency into every consumer (accessreview service, console v1 resolver, OAuth2 wiring). There is no package-level state. Each provider lives in one file under pkg/connector/provider/ that exposes a private xxxRegistration() *Registration constructor; NewBuiltinRegistry enumerates them. CreateConnectorRequest loses its per-provider settings fields and takes a single RawSettings json.RawMessage produced by the per-provider MarshalSettings closure. The 1Password SCIM bridge URL is validated at create time (http(s) scheme + non-empty host) so a malformed value fails fast at the resolver boundary. The Helm chart gains probo.connectorEnv and probo.connectorSecretEntries templates so adding a connector requires zero Helm changes. Access-review name resolution moves into the same Registration value to keep one authoritative dispatch table. Tests cover every Registration (DisplayName, NewDriver wired), Register error paths (nil, empty Provider, empty DisplayName, duplicate), All / ProviderDisplayName / ProviderOAuth2Scopes / ProbeURL hit and miss paths, the ApplyOAuth2Defaults templating and PKCE branches, and ConnectorSettings[T] round-trip plus malformed-JSON error path. The pre-refactor ApplyProviderDefaults test in pkg/connector is replaced by the equivalent in pkg/connector/provider. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
987 lines
33 KiB
Go
987 lines
33 KiB
Go
package console_v1
|
|
|
|
// This file will be automatically regenerated based on the schema, any resolver
|
|
// implementations
|
|
// will be copied through when generating and any unknown code will be moved to the end.
|
|
// Code generated by github.com/99designs/gqlgen version v0.17.90
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/vikstrous/dataloadgen"
|
|
"go.gearno.de/kit/log"
|
|
"go.probo.inc/probo/pkg/accessreview"
|
|
"go.probo.inc/probo/pkg/coredata"
|
|
"go.probo.inc/probo/pkg/gid"
|
|
"go.probo.inc/probo/pkg/page"
|
|
"go.probo.inc/probo/pkg/probo"
|
|
"go.probo.inc/probo/pkg/server/api/authn"
|
|
"go.probo.inc/probo/pkg/server/api/console/v1/dataloader"
|
|
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
|
|
"go.probo.inc/probo/pkg/server/api/console/v1/types"
|
|
"go.probo.inc/probo/pkg/server/gqlutils"
|
|
)
|
|
|
|
// Campaign is the resolver for the campaign field.
|
|
func (r *accessEntryResolver) Campaign(ctx context.Context, obj *types.AccessEntry) (*types.AccessReviewCampaign, error) {
|
|
scope, err := r.authorize(ctx, obj.Campaign.ID, probo.ActionAccessReviewCampaignGet)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
campaign, err := r.accessReview.Campaigns(scope).Get(ctx, obj.Campaign.ID)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return nil, gqlutils.NotFound(ctx, err)
|
|
}
|
|
|
|
panic(fmt.Errorf("cannot get access review campaign: %w", err))
|
|
}
|
|
|
|
return types.NewAccessReviewCampaign(campaign), nil
|
|
}
|
|
|
|
// AccessSource is the resolver for the accessSource field.
|
|
func (r *accessEntryResolver) AccessSource(ctx context.Context, obj *types.AccessEntry) (*types.AccessSource, error) {
|
|
scope, err := r.authorize(ctx, obj.AccessSource.ID, probo.ActionAccessSourceGet)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
source, err := r.accessReview.Sources(scope).Get(ctx, obj.AccessSource.ID)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return nil, gqlutils.NotFound(ctx, err)
|
|
}
|
|
|
|
panic(fmt.Errorf("cannot get access source: %w", err))
|
|
}
|
|
|
|
return types.NewAccessSource(source), nil
|
|
}
|
|
|
|
// DecisionHistory is the resolver for the decisionHistory field.
|
|
func (r *accessEntryResolver) DecisionHistory(ctx context.Context, obj *types.AccessEntry) ([]*types.AccessEntryDecisionHistoryEntry, error) {
|
|
scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessEntryGet)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
histories, err := r.accessReview.Entries(scope).DecisionHistory(ctx, obj.ID)
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot get decision history: %w", err))
|
|
}
|
|
|
|
result := make([]*types.AccessEntryDecisionHistoryEntry, len(histories))
|
|
for i, h := range histories {
|
|
result[i] = types.NewAccessEntryDecisionHistoryEntry(h)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// Permission is the resolver for the permission field.
|
|
func (r *accessEntryResolver) Permission(ctx context.Context, obj *types.AccessEntry, action string) (bool, error) {
|
|
return r.Resolver.Permission(ctx, obj, action)
|
|
}
|
|
|
|
// TotalCount is the resolver for the totalCount field.
|
|
func (r *accessEntryConnectionResolver) TotalCount(ctx context.Context, obj *types.AccessEntryConnection) (int, error) {
|
|
scope, err := r.authorize(ctx, obj.ParentID, probo.ActionAccessEntryList)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
switch obj.Resolver.(type) {
|
|
case *accessReviewCampaignResolver:
|
|
if obj.SourceID != nil {
|
|
count, err := r.accessReview.Entries(scope).CountForCampaignIDAndSourceID(ctx, obj.ParentID, *obj.SourceID, obj.Filter)
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot count access entries: %w", err))
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
count, err := r.accessReview.Entries(scope).CountForCampaignID(ctx, obj.ParentID, obj.Filter)
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot count access entries: %w", err))
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
|
|
}
|
|
|
|
// Organization is the resolver for the organization field.
|
|
func (r *accessReviewCampaignResolver) Organization(ctx context.Context, obj *types.AccessReviewCampaign) (*types.Organization, error) {
|
|
if _, err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
loaders := dataloader.FromContext(ctx)
|
|
|
|
organization, err := loaders.Organization.Load(ctx, obj.Organization.ID)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) {
|
|
return nil, gqlutils.NotFound(ctx, err)
|
|
}
|
|
|
|
r.logger.ErrorCtx(ctx, "cannot load organization", log.Error(err))
|
|
|
|
return nil, gqlutils.Internal(ctx)
|
|
}
|
|
|
|
return types.NewOrganization(organization), nil
|
|
}
|
|
|
|
// ScopeSources is the resolver for the scopeSources field.
|
|
func (r *accessReviewCampaignResolver) ScopeSources(ctx context.Context, obj *types.AccessReviewCampaign) ([]*types.AccessReviewCampaignScopeSource, error) {
|
|
scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceList)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sources, err := r.accessReview.Sources(scope).ListScopeSourcesForCampaignID(ctx, obj.ID)
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot list scope sources: %w", err))
|
|
}
|
|
|
|
fetches, err := r.accessReview.Campaigns(scope).ListSourceFetches(ctx, obj.ID)
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot list source fetch states: %w", err))
|
|
}
|
|
|
|
fetchBySourceID := make(map[gid.GID]*coredata.AccessReviewCampaignSourceFetch, len(fetches))
|
|
for _, fetch := range fetches {
|
|
fetchBySourceID[fetch.AccessSourceID] = fetch
|
|
}
|
|
|
|
result := make([]*types.AccessReviewCampaignScopeSource, len(sources))
|
|
for i, s := range sources {
|
|
result[i] = types.NewAccessReviewCampaignScopeSource(obj.ID, s, fetchBySourceID[s.ID])
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// Entries is the resolver for the entries field.
|
|
func (r *accessReviewCampaignResolver) Entries(ctx context.Context, obj *types.AccessReviewCampaign, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessEntryOrder, accessSourceID *gid.GID, filter *coredata.AccessEntryFilter) (*types.AccessEntryConnection, error) {
|
|
scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessEntryList)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pageOrderBy := page.OrderBy[coredata.AccessEntryOrderField]{
|
|
Field: coredata.AccessEntryOrderFieldCreatedAt,
|
|
Direction: page.OrderDirectionDesc,
|
|
}
|
|
|
|
if orderBy != nil {
|
|
pageOrderBy = page.OrderBy[coredata.AccessEntryOrderField]{
|
|
Field: orderBy.Field,
|
|
Direction: orderBy.Direction,
|
|
}
|
|
}
|
|
|
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
|
|
|
var (
|
|
p *page.Page[*coredata.AccessEntry, coredata.AccessEntryOrderField]
|
|
)
|
|
|
|
if accessSourceID != nil {
|
|
p, err = r.accessReview.Entries(scope).ListForCampaignIDAndSourceID(ctx, obj.ID, *accessSourceID, cursor, filter)
|
|
} else {
|
|
p, err = r.accessReview.Entries(scope).ListForCampaignID(ctx, obj.ID, cursor, filter)
|
|
}
|
|
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot list access entries: %w", err))
|
|
}
|
|
|
|
return types.NewAccessEntryConnection(p, r, obj.ID, accessSourceID, filter), nil
|
|
}
|
|
|
|
// PendingEntryCount is the resolver for the pendingEntryCount field.
|
|
func (r *accessReviewCampaignResolver) PendingEntryCount(ctx context.Context, obj *types.AccessReviewCampaign) (int, error) {
|
|
scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessEntryList)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
count, err := r.accessReview.Entries(scope).CountPendingForCampaignID(ctx, obj.ID)
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot count pending access entries: %w", err))
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
// Statistics is the resolver for the statistics field.
|
|
func (r *accessReviewCampaignResolver) Statistics(ctx context.Context, obj *types.AccessReviewCampaign) (*types.AccessReviewCampaignStatistics, error) {
|
|
scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessEntryList)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
stats, err := r.accessReview.Entries(scope).Statistics(ctx, obj.ID)
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot get campaign statistics: %w", err))
|
|
}
|
|
|
|
return types.NewAccessReviewCampaignStatistics(stats), nil
|
|
}
|
|
|
|
// Permission is the resolver for the permission field.
|
|
func (r *accessReviewCampaignResolver) Permission(ctx context.Context, obj *types.AccessReviewCampaign, action string) (bool, error) {
|
|
return r.Resolver.Permission(ctx, obj, action)
|
|
}
|
|
|
|
// TotalCount is the resolver for the totalCount field.
|
|
func (r *accessReviewCampaignConnectionResolver) TotalCount(ctx context.Context, obj *types.AccessReviewCampaignConnection) (int, error) {
|
|
scope, err := r.authorize(ctx, obj.ParentID, probo.ActionAccessReviewCampaignList)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
switch obj.Resolver.(type) {
|
|
case *organizationResolver:
|
|
count, err := r.accessReview.Campaigns(scope).CountForOrganizationID(ctx, obj.ParentID)
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot count access review campaigns: %w", err))
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
|
|
}
|
|
|
|
// Entries is the resolver for the entries field.
|
|
func (r *accessReviewCampaignScopeSourceResolver) Entries(ctx context.Context, obj *types.AccessReviewCampaignScopeSource, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessEntryOrder, filter *coredata.AccessEntryFilter) (*types.AccessEntryConnection, error) {
|
|
scope, err := r.authorize(ctx, obj.CampaignID, probo.ActionAccessEntryList)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pageOrderBy := page.OrderBy[coredata.AccessEntryOrderField]{
|
|
Field: coredata.AccessEntryOrderFieldCreatedAt,
|
|
Direction: page.OrderDirectionDesc,
|
|
}
|
|
|
|
if orderBy != nil {
|
|
pageOrderBy = page.OrderBy[coredata.AccessEntryOrderField]{
|
|
Field: orderBy.Field,
|
|
Direction: orderBy.Direction,
|
|
}
|
|
}
|
|
|
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
|
|
|
p, err := r.accessReview.Entries(scope).ListForCampaignIDAndSourceID(ctx, obj.CampaignID, obj.ID, cursor, filter)
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot list access entries: %w", err))
|
|
}
|
|
|
|
sourceID := obj.ID
|
|
|
|
return types.NewAccessEntryConnection(p, r, obj.CampaignID, &sourceID, filter), nil
|
|
}
|
|
|
|
// Statistics is the resolver for the statistics field.
|
|
func (r *accessReviewCampaignScopeSourceResolver) Statistics(ctx context.Context, obj *types.AccessReviewCampaignScopeSource) (*types.AccessReviewCampaignStatistics, error) {
|
|
scope, err := r.authorize(ctx, obj.CampaignID, probo.ActionAccessEntryList)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
stats, err := r.accessReview.Entries(scope).StatisticsForSource(ctx, obj.CampaignID, obj.ID)
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot get source statistics: %w", err))
|
|
}
|
|
|
|
return types.NewAccessReviewCampaignStatistics(stats), nil
|
|
}
|
|
|
|
// Organization is the resolver for the organization field.
|
|
func (r *accessSourceResolver) Organization(ctx context.Context, obj *types.AccessSource) (*types.Organization, error) {
|
|
if _, err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
loaders := dataloader.FromContext(ctx)
|
|
|
|
organization, err := loaders.Organization.Load(ctx, obj.Organization.ID)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) {
|
|
return nil, gqlutils.NotFound(ctx, err)
|
|
}
|
|
|
|
r.logger.ErrorCtx(ctx, "cannot load organization", log.Error(err))
|
|
|
|
return nil, gqlutils.Internal(ctx)
|
|
}
|
|
|
|
return types.NewOrganization(organization), nil
|
|
}
|
|
|
|
// Connector is the resolver for the connector field.
|
|
func (r *accessSourceResolver) Connector(ctx context.Context, obj *types.AccessSource) (*types.Connector, error) {
|
|
if obj.ConnectorID == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceGet)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
connector, err := r.probo.Connectors.Get(ctx, scope, *obj.ConnectorID)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return nil, nil
|
|
}
|
|
|
|
panic(fmt.Errorf("cannot get connector: %w", err))
|
|
}
|
|
|
|
return types.NewConnector(connector), nil
|
|
}
|
|
|
|
// ProviderOrganizations is the resolver for the providerOrganizations field.
|
|
func (r *accessSourceResolver) ProviderOrganizations(ctx context.Context, obj *types.AccessSource) ([]*types.ProviderOrganization, error) {
|
|
scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceGet)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if obj.ConnectorID == nil {
|
|
return []*types.ProviderOrganization{}, nil
|
|
}
|
|
|
|
httpClient, dbConnector, err := r.accessReview.Sources(scope).ConnectorHTTPClient(ctx, *obj.ConnectorID)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return []*types.ProviderOrganization{}, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("cannot get connector HTTP client: %w", err)
|
|
}
|
|
|
|
cfg, ok := providerOrgConfigs[dbConnector.Provider]
|
|
if !ok || cfg.ListOrgs == nil {
|
|
return []*types.ProviderOrganization{}, nil
|
|
}
|
|
|
|
orgs, err := cfg.ListOrgs(ctx, httpClient)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]*types.ProviderOrganization, len(orgs))
|
|
for i, o := range orgs {
|
|
result[i] = &types.ProviderOrganization{Slug: o.Slug, DisplayName: o.DisplayName}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// NeedsConfiguration is the resolver for the needsConfiguration field.
|
|
//
|
|
// True when the provider has a picker UI (NeedsPicker) AND the user has
|
|
// not yet picked an org. 2-auto providers (PagerDuty, Vercel) always
|
|
// return false: the identifier is captured during the OAuth callback,
|
|
// not via a follow-up configure mutation.
|
|
func (r *accessSourceResolver) NeedsConfiguration(ctx context.Context, obj *types.AccessSource) (bool, error) {
|
|
scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceGet)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if obj.ConnectorID == nil {
|
|
return false, nil
|
|
}
|
|
|
|
dbConnector, err := r.probo.Connectors.Get(ctx, scope, *obj.ConnectorID)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return false, nil
|
|
}
|
|
|
|
panic(fmt.Errorf("cannot get connector: %w", err))
|
|
}
|
|
|
|
cfg, ok := providerOrgConfigs[dbConnector.Provider]
|
|
if !ok || !cfg.NeedsPicker {
|
|
return false, nil
|
|
}
|
|
|
|
return cfg.SelectedSlug(dbConnector) == "", nil
|
|
}
|
|
|
|
// ConnectionStatus is the resolver for the connectionStatus field.
|
|
func (r *accessSourceResolver) ConnectionStatus(ctx context.Context, obj *types.AccessSource) (types.AccessSourceConnectionStatus, error) {
|
|
if obj.ConnectorID == nil {
|
|
return types.AccessSourceConnectionStatusNotApplicable, nil
|
|
}
|
|
|
|
scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceGet)
|
|
if err != nil {
|
|
return types.AccessSourceConnectionStatusNotApplicable, err
|
|
}
|
|
|
|
httpClient, dbConnector, err := r.accessReview.Sources(scope).ConnectorHTTPClient(ctx, *obj.ConnectorID)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return types.AccessSourceConnectionStatusNotApplicable, nil
|
|
}
|
|
|
|
return types.AccessSourceConnectionStatusDisconnected, nil
|
|
}
|
|
|
|
if dbConnector.Protocol != coredata.ConnectorProtocolOAuth2 {
|
|
return types.AccessSourceConnectionStatusConnected, nil
|
|
}
|
|
|
|
// Creating an HTTP client may succeed even with an expired token
|
|
// (e.g. no refresh token available). Make a lightweight probe
|
|
// request to verify the token is actually valid.
|
|
probeURL := r.providerRegistry.ProbeURL(string(dbConnector.Provider))
|
|
if err := probeConnection(ctx, httpClient, probeURL); err != nil {
|
|
return types.AccessSourceConnectionStatusDisconnected, nil
|
|
}
|
|
|
|
return types.AccessSourceConnectionStatusConnected, nil
|
|
}
|
|
|
|
// SelectedOrganization is the resolver for the selectedOrganization field.
|
|
func (r *accessSourceResolver) SelectedOrganization(ctx context.Context, obj *types.AccessSource) (*string, error) {
|
|
scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceGet)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if obj.ConnectorID == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
dbConnector, err := r.probo.Connectors.Get(ctx, scope, *obj.ConnectorID)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return nil, nil
|
|
}
|
|
|
|
panic(fmt.Errorf("cannot get connector: %w", err))
|
|
}
|
|
|
|
cfg, ok := providerOrgConfigs[dbConnector.Provider]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
|
|
slug := cfg.SelectedSlug(dbConnector)
|
|
if slug == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
return &slug, nil
|
|
}
|
|
|
|
// Permission is the resolver for the permission field.
|
|
func (r *accessSourceResolver) Permission(ctx context.Context, obj *types.AccessSource, action string) (bool, error) {
|
|
return r.Resolver.Permission(ctx, obj, action)
|
|
}
|
|
|
|
// TotalCount is the resolver for the totalCount field.
|
|
func (r *accessSourceConnectionResolver) TotalCount(ctx context.Context, obj *types.AccessSourceConnection) (int, error) {
|
|
scope, err := r.authorize(ctx, obj.ParentID, probo.ActionAccessSourceList)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
switch obj.Resolver.(type) {
|
|
case *organizationResolver:
|
|
count, err := r.accessReview.Sources(scope).CountForOrganizationID(ctx, obj.ParentID)
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot count access sources: %w", err))
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
|
|
}
|
|
|
|
// CreateAccessSource is the resolver for the createAccessSource field.
|
|
func (r *mutationResolver) CreateAccessSource(ctx context.Context, input types.CreateAccessSourceInput) (*types.CreateAccessSourcePayload, error) {
|
|
scope, err := r.authorize(ctx, input.OrganizationID, probo.ActionAccessSourceCreate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
source, err := r.accessReview.Sources(scope).Create(ctx, accessreview.CreateAccessSourceRequest{
|
|
OrganizationID: input.OrganizationID,
|
|
ConnectorID: input.ConnectorID,
|
|
Name: input.Name,
|
|
Category: coredata.AccessSourceCategorySaaS,
|
|
CsvData: input.CSVData,
|
|
})
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot create access source: %w", err))
|
|
}
|
|
|
|
return &types.CreateAccessSourcePayload{
|
|
AccessSourceEdge: types.NewAccessSourceEdge(source, coredata.AccessSourceOrderFieldCreatedAt),
|
|
}, nil
|
|
}
|
|
|
|
// UpdateAccessSource is the resolver for the updateAccessSource field.
|
|
func (r *mutationResolver) UpdateAccessSource(ctx context.Context, input types.UpdateAccessSourceInput) (*types.UpdateAccessSourcePayload, error) {
|
|
scope, err := r.authorize(ctx, input.AccessSourceID, probo.ActionAccessSourceUpdate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req := accessreview.UpdateAccessSourceRequest{
|
|
AccessSourceID: input.AccessSourceID,
|
|
}
|
|
|
|
if input.Name.IsSet() {
|
|
req.Name = input.Name.Value()
|
|
}
|
|
|
|
if input.ConnectorID.IsSet() {
|
|
req.ConnectorID = gqlutils.UnwrapOmittable(input.ConnectorID)
|
|
}
|
|
|
|
if input.CSVData.IsSet() {
|
|
req.CsvData = gqlutils.UnwrapOmittable(input.CSVData)
|
|
}
|
|
|
|
source, err := r.accessReview.Sources(scope).Update(ctx, req)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return nil, gqlutils.NotFound(ctx, err)
|
|
}
|
|
|
|
panic(fmt.Errorf("cannot update access source: %w", err))
|
|
}
|
|
|
|
return &types.UpdateAccessSourcePayload{
|
|
AccessSource: types.NewAccessSource(source),
|
|
}, nil
|
|
}
|
|
|
|
// DeleteAccessSource is the resolver for the deleteAccessSource field.
|
|
func (r *mutationResolver) DeleteAccessSource(ctx context.Context, input types.DeleteAccessSourceInput) (*types.DeleteAccessSourcePayload, error) {
|
|
scope, err := r.authorize(ctx, input.AccessSourceID, probo.ActionAccessSourceDelete)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := r.accessReview.Sources(scope).Delete(ctx, input.AccessSourceID); err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return nil, gqlutils.NotFound(ctx, err)
|
|
}
|
|
|
|
panic(fmt.Errorf("cannot delete access source: %w", err))
|
|
}
|
|
|
|
return &types.DeleteAccessSourcePayload{
|
|
DeletedAccessSourceID: input.AccessSourceID,
|
|
}, nil
|
|
}
|
|
|
|
// ConfigureAccessSource is the resolver for the configureAccessSource field.
|
|
func (r *mutationResolver) ConfigureAccessSource(ctx context.Context, input types.ConfigureAccessSourceInput) (*types.ConfigureAccessSourcePayload, error) {
|
|
scope, err := r.authorize(ctx, input.AccessSourceID, probo.ActionAccessSourceUpdate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
source, err := r.accessReview.Sources(scope).ConfigureAccessSource(
|
|
ctx,
|
|
accessreview.ConfigureAccessSourceRequest{
|
|
AccessSourceID: input.AccessSourceID,
|
|
OrganizationSlug: input.OrganizationSlug,
|
|
},
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return nil, gqlutils.NotFound(ctx, err)
|
|
}
|
|
|
|
panic(fmt.Errorf("cannot configure access source: %w", err))
|
|
}
|
|
|
|
return &types.ConfigureAccessSourcePayload{
|
|
AccessSource: types.NewAccessSource(source),
|
|
}, nil
|
|
}
|
|
|
|
// CreateAccessReviewCampaign is the resolver for the createAccessReviewCampaign field.
|
|
func (r *mutationResolver) CreateAccessReviewCampaign(ctx context.Context, input types.CreateAccessReviewCampaignInput) (*types.CreateAccessReviewCampaignPayload, error) {
|
|
scope, err := r.authorize(ctx, input.OrganizationID, probo.ActionAccessReviewCampaignCreate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var description string
|
|
if input.Description != nil {
|
|
description = *input.Description
|
|
}
|
|
|
|
campaign, err := r.accessReview.Campaigns(scope).Create(ctx, accessreview.CreateAccessReviewCampaignRequest{
|
|
OrganizationID: input.OrganizationID,
|
|
Name: input.Name,
|
|
Description: description,
|
|
FrameworkControls: input.FrameworkControls,
|
|
AccessSourceIDs: input.AccessSourceIds,
|
|
})
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot create access review campaign: %w", err))
|
|
}
|
|
|
|
return &types.CreateAccessReviewCampaignPayload{
|
|
AccessReviewCampaignEdge: types.NewAccessReviewCampaignEdge(campaign, coredata.AccessReviewCampaignOrderFieldCreatedAt),
|
|
}, nil
|
|
}
|
|
|
|
// UpdateAccessReviewCampaign is the resolver for the updateAccessReviewCampaign field.
|
|
func (r *mutationResolver) UpdateAccessReviewCampaign(ctx context.Context, input types.UpdateAccessReviewCampaignInput) (*types.UpdateAccessReviewCampaignPayload, error) {
|
|
scope, err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignUpdate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req := accessreview.UpdateAccessReviewCampaignRequest{
|
|
CampaignID: input.AccessReviewCampaignID,
|
|
}
|
|
|
|
if input.Name.IsSet() {
|
|
req.Name = input.Name.Value()
|
|
}
|
|
|
|
if input.Description.IsSet() {
|
|
req.Description = input.Description.Value()
|
|
}
|
|
|
|
if input.FrameworkControls.IsSet() {
|
|
controls := input.FrameworkControls.Value()
|
|
req.FrameworkControls = &controls
|
|
}
|
|
|
|
campaign, err := r.accessReview.Campaigns(scope).Update(ctx, req)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return nil, gqlutils.NotFound(ctx, err)
|
|
}
|
|
|
|
panic(fmt.Errorf("cannot update access review campaign: %w", err))
|
|
}
|
|
|
|
return &types.UpdateAccessReviewCampaignPayload{
|
|
AccessReviewCampaign: types.NewAccessReviewCampaign(campaign),
|
|
}, nil
|
|
}
|
|
|
|
// DeleteAccessReviewCampaign is the resolver for the deleteAccessReviewCampaign field.
|
|
func (r *mutationResolver) DeleteAccessReviewCampaign(ctx context.Context, input types.DeleteAccessReviewCampaignInput) (*types.DeleteAccessReviewCampaignPayload, error) {
|
|
scope, err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignDelete)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := r.accessReview.Campaigns(scope).Delete(ctx, input.AccessReviewCampaignID); err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return nil, gqlutils.NotFound(ctx, err)
|
|
}
|
|
|
|
panic(fmt.Errorf("cannot delete access review campaign: %w", err))
|
|
}
|
|
|
|
return &types.DeleteAccessReviewCampaignPayload{
|
|
DeletedAccessReviewCampaignID: input.AccessReviewCampaignID,
|
|
}, nil
|
|
}
|
|
|
|
// StartAccessReviewCampaign is the resolver for the startAccessReviewCampaign field.
|
|
func (r *mutationResolver) StartAccessReviewCampaign(ctx context.Context, input types.StartAccessReviewCampaignInput) (*types.StartAccessReviewCampaignPayload, error) {
|
|
scope, err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignStart)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
campaign, err := r.accessReview.Campaigns(scope).Start(ctx, input.AccessReviewCampaignID)
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot start access review campaign: %w", err))
|
|
}
|
|
|
|
return &types.StartAccessReviewCampaignPayload{
|
|
AccessReviewCampaign: types.NewAccessReviewCampaign(campaign),
|
|
}, nil
|
|
}
|
|
|
|
// CloseAccessReviewCampaign is the resolver for the closeAccessReviewCampaign field.
|
|
func (r *mutationResolver) CloseAccessReviewCampaign(ctx context.Context, input types.CloseAccessReviewCampaignInput) (*types.CloseAccessReviewCampaignPayload, error) {
|
|
scope, err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignClose)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
campaign, err := r.accessReview.Campaigns(scope).Close(ctx, input.AccessReviewCampaignID)
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot close access review campaign: %w", err))
|
|
}
|
|
|
|
return &types.CloseAccessReviewCampaignPayload{
|
|
AccessReviewCampaign: types.NewAccessReviewCampaign(campaign),
|
|
}, nil
|
|
}
|
|
|
|
// CancelAccessReviewCampaign is the resolver for the cancelAccessReviewCampaign field.
|
|
func (r *mutationResolver) CancelAccessReviewCampaign(ctx context.Context, input types.CancelAccessReviewCampaignInput) (*types.CancelAccessReviewCampaignPayload, error) {
|
|
scope, err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignCancel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
campaign, err := r.accessReview.Campaigns(scope).Cancel(ctx, input.AccessReviewCampaignID)
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot cancel access review campaign: %w", err))
|
|
}
|
|
|
|
return &types.CancelAccessReviewCampaignPayload{
|
|
AccessReviewCampaign: types.NewAccessReviewCampaign(campaign),
|
|
}, nil
|
|
}
|
|
|
|
// AddAccessReviewCampaignScopeSource is the resolver for the addAccessReviewCampaignScopeSource field.
|
|
func (r *mutationResolver) AddAccessReviewCampaignScopeSource(ctx context.Context, input types.AddAccessReviewCampaignScopeSourceInput) (*types.AddAccessReviewCampaignScopeSourcePayload, error) {
|
|
scope, err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignAddScopeSource)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
campaign, err := r.accessReview.Campaigns(scope).AddScopeSource(ctx, accessreview.AddCampaignScopeSourceRequest{
|
|
CampaignID: input.AccessReviewCampaignID,
|
|
AccessSourceID: input.AccessSourceID,
|
|
})
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot add scope source to access review campaign: %w", err))
|
|
}
|
|
|
|
return &types.AddAccessReviewCampaignScopeSourcePayload{
|
|
AccessReviewCampaign: types.NewAccessReviewCampaign(campaign),
|
|
}, nil
|
|
}
|
|
|
|
// RemoveAccessReviewCampaignScopeSource is the resolver for the removeAccessReviewCampaignScopeSource field.
|
|
func (r *mutationResolver) RemoveAccessReviewCampaignScopeSource(ctx context.Context, input types.RemoveAccessReviewCampaignScopeSourceInput) (*types.RemoveAccessReviewCampaignScopeSourcePayload, error) {
|
|
scope, err := r.authorize(ctx, input.AccessReviewCampaignID, probo.ActionAccessReviewCampaignRemoveScopeSource)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
campaign, err := r.accessReview.Campaigns(scope).RemoveScopeSource(ctx, accessreview.RemoveCampaignScopeSourceRequest{
|
|
CampaignID: input.AccessReviewCampaignID,
|
|
AccessSourceID: input.AccessSourceID,
|
|
})
|
|
if err != nil {
|
|
panic(fmt.Errorf("cannot remove scope source from access review campaign: %w", err))
|
|
}
|
|
|
|
return &types.RemoveAccessReviewCampaignScopeSourcePayload{
|
|
AccessReviewCampaign: types.NewAccessReviewCampaign(campaign),
|
|
}, nil
|
|
}
|
|
|
|
// RecordAccessEntryDecision is the resolver for the recordAccessEntryDecision field.
|
|
func (r *mutationResolver) RecordAccessEntryDecision(ctx context.Context, input types.RecordAccessEntryDecisionInput) (*types.RecordAccessEntryDecisionPayload, error) {
|
|
scope, err := r.authorize(ctx, input.AccessEntryID, probo.ActionAccessEntryDecide)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Resolve the profile ID from the session's identity.
|
|
// The profile may not exist for every identity, in which
|
|
// case decided_by will be left nil.
|
|
identity := authn.IdentityFromContext(ctx)
|
|
if identity == nil {
|
|
return nil, fmt.Errorf("no identity in context")
|
|
}
|
|
|
|
req := accessreview.RecordAccessEntryDecisionRequest{
|
|
EntryID: input.AccessEntryID,
|
|
Decision: input.Decision,
|
|
DecisionNote: input.DecisionNote,
|
|
}
|
|
|
|
organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, input.AccessEntryID)
|
|
if err == nil {
|
|
profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, organizationID)
|
|
if err == nil {
|
|
req.DecidedByID = &profile.ID
|
|
}
|
|
}
|
|
|
|
entry, err := r.accessReview.Entries(scope).RecordDecision(ctx, req)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return nil, gqlutils.NotFound(ctx, err)
|
|
}
|
|
|
|
panic(fmt.Errorf("cannot record access entry decision: %w", err))
|
|
}
|
|
|
|
return &types.RecordAccessEntryDecisionPayload{
|
|
AccessEntry: types.NewAccessEntry(entry),
|
|
}, nil
|
|
}
|
|
|
|
// RecordAccessEntryDecisions is the resolver for the recordAccessEntryDecisions field.
|
|
func (r *mutationResolver) RecordAccessEntryDecisions(ctx context.Context, input types.RecordAccessEntryDecisionsInput) (*types.RecordAccessEntryDecisionsPayload, error) {
|
|
if len(input.Decisions) == 0 {
|
|
return &types.RecordAccessEntryDecisionsPayload{
|
|
AccessEntries: []*types.AccessEntry{},
|
|
}, nil
|
|
}
|
|
|
|
const maxBatchSize = 100
|
|
if len(input.Decisions) > maxBatchSize {
|
|
return nil, fmt.Errorf("cannot record decisions: batch size %d exceeds maximum of %d", len(input.Decisions), maxBatchSize)
|
|
}
|
|
|
|
// Authorize each entry individually to prevent cross-org bypass.
|
|
for _, d := range input.Decisions {
|
|
_, err := r.authorize(ctx, d.AccessEntryID, probo.ActionAccessEntryDecide)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
identity := authn.IdentityFromContext(ctx)
|
|
if identity == nil {
|
|
return nil, fmt.Errorf("no identity in context")
|
|
}
|
|
|
|
tenantID := input.Decisions[0].AccessEntryID.TenantID()
|
|
scope := coredata.NewScope(tenantID)
|
|
|
|
// Cache profile lookups per organization so we resolve the correct
|
|
// decidedByID for each entry even when a batch spans multiple orgs.
|
|
profileCache := make(map[gid.GID]*gid.GID)
|
|
|
|
decisions := make([]accessreview.RecordAccessEntryDecisionRequest, len(input.Decisions))
|
|
for i, d := range input.Decisions {
|
|
var decidedByID *gid.GID
|
|
|
|
organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, d.AccessEntryID)
|
|
if err == nil {
|
|
if cached, ok := profileCache[organizationID]; ok {
|
|
decidedByID = cached
|
|
} else {
|
|
profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, organizationID)
|
|
if err == nil {
|
|
decidedByID = &profile.ID
|
|
}
|
|
|
|
profileCache[organizationID] = decidedByID
|
|
}
|
|
}
|
|
|
|
decisions[i] = accessreview.RecordAccessEntryDecisionRequest{
|
|
EntryID: d.AccessEntryID,
|
|
Decision: d.Decision,
|
|
DecisionNote: d.DecisionNote,
|
|
DecidedByID: decidedByID,
|
|
}
|
|
}
|
|
|
|
entries, err := r.accessReview.Entries(scope).RecordDecisions(ctx, decisions)
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return nil, gqlutils.NotFound(ctx, err)
|
|
}
|
|
|
|
panic(fmt.Errorf("cannot record access entry decisions: %w", err))
|
|
}
|
|
|
|
accessEntries := make([]*types.AccessEntry, len(entries))
|
|
for i, e := range entries {
|
|
accessEntries[i] = types.NewAccessEntry(e)
|
|
}
|
|
|
|
return &types.RecordAccessEntryDecisionsPayload{
|
|
AccessEntries: accessEntries,
|
|
}, nil
|
|
}
|
|
|
|
// FlagAccessEntry is the resolver for the flagAccessEntry field.
|
|
func (r *mutationResolver) FlagAccessEntry(ctx context.Context, input types.FlagAccessEntryInput) (*types.FlagAccessEntryPayload, error) {
|
|
scope, err := r.authorize(ctx, input.AccessEntryID, probo.ActionAccessEntryFlag)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
entry, err := r.accessReview.Entries(scope).FlagEntry(ctx, accessreview.FlagAccessEntryRequest{
|
|
EntryID: input.AccessEntryID,
|
|
Flags: input.Flags,
|
|
FlagReasons: input.FlagReasons,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
|
return nil, gqlutils.NotFound(ctx, err)
|
|
}
|
|
|
|
panic(fmt.Errorf("cannot flag access entry: %w", err))
|
|
}
|
|
|
|
return &types.FlagAccessEntryPayload{
|
|
AccessEntry: types.NewAccessEntry(entry),
|
|
}, nil
|
|
}
|
|
|
|
// AccessEntry returns schema.AccessEntryResolver implementation.
|
|
func (r *Resolver) AccessEntry() schema.AccessEntryResolver { return &accessEntryResolver{r} }
|
|
|
|
// AccessEntryConnection returns schema.AccessEntryConnectionResolver implementation.
|
|
func (r *Resolver) AccessEntryConnection() schema.AccessEntryConnectionResolver {
|
|
return &accessEntryConnectionResolver{r}
|
|
}
|
|
|
|
// AccessReviewCampaign returns schema.AccessReviewCampaignResolver implementation.
|
|
func (r *Resolver) AccessReviewCampaign() schema.AccessReviewCampaignResolver {
|
|
return &accessReviewCampaignResolver{r}
|
|
}
|
|
|
|
// AccessReviewCampaignConnection returns schema.AccessReviewCampaignConnectionResolver implementation.
|
|
func (r *Resolver) AccessReviewCampaignConnection() schema.AccessReviewCampaignConnectionResolver {
|
|
return &accessReviewCampaignConnectionResolver{r}
|
|
}
|
|
|
|
// AccessReviewCampaignScopeSource returns schema.AccessReviewCampaignScopeSourceResolver implementation.
|
|
func (r *Resolver) AccessReviewCampaignScopeSource() schema.AccessReviewCampaignScopeSourceResolver {
|
|
return &accessReviewCampaignScopeSourceResolver{r}
|
|
}
|
|
|
|
// AccessSource returns schema.AccessSourceResolver implementation.
|
|
func (r *Resolver) AccessSource() schema.AccessSourceResolver { return &accessSourceResolver{r} }
|
|
|
|
// AccessSourceConnection returns schema.AccessSourceConnectionResolver implementation.
|
|
func (r *Resolver) AccessSourceConnection() schema.AccessSourceConnectionResolver {
|
|
return &accessSourceConnectionResolver{r}
|
|
}
|
|
|
|
type accessEntryResolver struct{ *Resolver }
|
|
type accessEntryConnectionResolver struct{ *Resolver }
|
|
type accessReviewCampaignResolver struct{ *Resolver }
|
|
type accessReviewCampaignConnectionResolver struct{ *Resolver }
|
|
type accessReviewCampaignScopeSourceResolver struct{ *Resolver }
|
|
type accessSourceResolver struct{ *Resolver }
|
|
type accessSourceConnectionResolver struct{ *Resolver }
|