Files
probo/pkg/server/api/console/v1/access_review_campaign_resolvers.go
Aurélien Sibiril aeb7a1c8e6 Default first workspace for picker access sources
Picker providers (GitHub, Sentry, GitLab, Bitbucket, Heroku, Asana,
Netlify, ClickUp, DocuSign) require the user to pick an org/workspace in
a follow-up step after connecting. When that step is skipped, the source
stays connected but unconfigured, and its first campaign silently
resolves no users because the driver needs an org — the same "connects
fine, campaign fetches nobody" symptom seen on Sentry.

When a connector is linked to a source, auto-select the first workspace
the connection can list, so the source is usable immediately. An org the
user already chose is never overridden, and the picker stays visible
(the frontend shows the selector whenever an org is selected) so they
can switch when several are listed.

Best-effort: a provider that is unreachable or lists nothing leaves the
source in its existing needs-configuration state rather than failing the
create/update mutation.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
2026-07-22 17:27:55 +02:00

1168 lines
39 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.94
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/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"
)
// 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
}
// Sources is the resolver for the sources field.
func (r *accessReviewCampaignResolver) Sources(ctx context.Context, obj *types.AccessReviewCampaign) ([]*types.AccessReviewCampaignSource, error) {
scope, err := r.authorize(ctx, obj.ID, accessreview.ActionCampaignGet)
if err != nil {
return nil, err
}
campaignSources, err := r.accessReview.ListCampaignSources(ctx, scope, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list campaign sources", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
result := make([]*types.AccessReviewCampaignSource, len(campaignSources))
for i, campaignSource := range campaignSources {
result[i] = types.NewAccessReviewCampaignSource(campaignSource)
}
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.AccessReviewEntryOrder, filter *coredata.AccessReviewEntryFilter) (*types.AccessReviewEntryConnection, error) {
scope, err := r.authorize(ctx, obj.ID, accessreview.ActionEntryList)
if err != nil {
return nil, err
}
pageOrderBy := page.OrderBy[coredata.AccessReviewEntryOrderField]{
Field: coredata.AccessReviewEntryOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.AccessReviewEntryOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
p, err := r.accessReview.ListEntriesForCampaignID(ctx, scope, obj.ID, cursor, filter)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list access entries", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewAccessReviewEntryConnection(p, r, obj.ID, nil, filter), nil
}
// Statistics is the resolver for the statistics field.
func (r *accessReviewCampaignResolver) Statistics(ctx context.Context, obj *types.AccessReviewCampaign) (*types.AccessReviewStatistics, error) {
scope, err := r.authorize(ctx, obj.ID, accessreview.ActionEntryList)
if err != nil {
return nil, err
}
stats, err := r.accessReview.CampaignStatistics(ctx, scope, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get campaign statistics", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewAccessReviewStatistics(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, accessreview.ActionCampaignList)
if err != nil {
return 0, err
}
switch obj.Resolver.(type) {
case *organizationResolver:
count, err := r.accessReview.CountCampaignsForOrganizationID(ctx, scope, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count access review campaigns", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
r.logger.ErrorCtx(ctx, "unsupported resolver", log.String("resolver", fmt.Sprintf("%T", obj.Resolver)))
return 0, gqlutils.Internal(ctx)
}
// Campaign is the resolver for the campaign field.
func (r *accessReviewCampaignSourceResolver) Campaign(ctx context.Context, obj *types.AccessReviewCampaignSource) (*types.AccessReviewCampaign, error) {
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet)
if err != nil {
return nil, err
}
campaign, err := r.accessReview.GetCampaign(ctx, scope, obj.Campaign.ID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot get access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewAccessReviewCampaign(campaign), nil
}
// Source is the resolver for the source field.
func (r *accessReviewCampaignSourceResolver) Source(ctx context.Context, obj *types.AccessReviewCampaignSource) (*types.AccessReviewSource, error) {
if obj.SourceID == nil {
return nil, nil
}
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet)
if err != nil {
return nil, err
}
source, err := r.accessReview.GetSource(ctx, scope, *obj.SourceID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, nil
}
r.logger.ErrorCtx(ctx, "cannot get access review source", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewAccessReviewSource(source), nil
}
// FetchAttempts is the resolver for the fetchAttempts field.
func (r *accessReviewCampaignSourceResolver) FetchAttempts(ctx context.Context, obj *types.AccessReviewCampaignSource, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessReviewCampaignSourceFetchAttemptOrder) (*types.AccessReviewCampaignSourceFetchAttemptConnection, error) {
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet)
if err != nil {
return nil, err
}
pageOrderBy := page.OrderBy[coredata.AccessReviewCampaignSourceFetchAttemptOrderField]{
Field: coredata.AccessReviewCampaignSourceFetchAttemptOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.AccessReviewCampaignSourceFetchAttemptOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
p, err := r.accessReview.ListFetchAttemptsForCampaignSourceID(ctx, scope, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list fetch attempts", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewAccessReviewCampaignSourceFetchAttemptConnection(p, r, obj.ID), nil
}
// Entries is the resolver for the entries field.
func (r *accessReviewCampaignSourceResolver) Entries(ctx context.Context, obj *types.AccessReviewCampaignSource, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessReviewEntryOrder, filter *coredata.AccessReviewEntryFilter) (*types.AccessReviewEntryConnection, error) {
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionEntryList)
if err != nil {
return nil, err
}
pageOrderBy := page.OrderBy[coredata.AccessReviewEntryOrderField]{
Field: coredata.AccessReviewEntryOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.AccessReviewEntryOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
p, err := r.accessReview.ListEntriesForCampaignIDAndSourceID(ctx, scope, obj.Campaign.ID, obj.ID, cursor, filter)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list access entries", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
sourceID := obj.ID
return types.NewAccessReviewEntryConnection(p, r, obj.Campaign.ID, &sourceID, filter), nil
}
// Statistics is the resolver for the statistics field.
func (r *accessReviewCampaignSourceResolver) Statistics(ctx context.Context, obj *types.AccessReviewCampaignSource) (*types.AccessReviewStatistics, error) {
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionEntryList)
if err != nil {
return nil, err
}
stats, err := r.accessReview.CampaignSourceStatistics(ctx, scope, obj.Campaign.ID, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get source statistics", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewAccessReviewStatistics(stats), nil
}
// TotalCount is the resolver for the totalCount field.
func (r *accessReviewCampaignSourceFetchAttemptConnectionResolver) TotalCount(ctx context.Context, obj *types.AccessReviewCampaignSourceFetchAttemptConnection) (int, error) {
scope, err := r.authorize(ctx, obj.ParentID, accessreview.ActionCampaignGet)
if err != nil {
return 0, err
}
count, err := r.accessReview.CountFetchAttemptsForCampaignSourceID(ctx, scope, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count fetch attempts", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
// Campaign is the resolver for the campaign field.
func (r *accessReviewEntryResolver) Campaign(ctx context.Context, obj *types.AccessReviewEntry) (*types.AccessReviewCampaign, error) {
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet)
if err != nil {
return nil, err
}
campaign, err := r.accessReview.GetCampaign(ctx, scope, obj.Campaign.ID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot get access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewAccessReviewCampaign(campaign), nil
}
// CampaignSource is the resolver for the campaignSource field.
func (r *accessReviewEntryResolver) CampaignSource(ctx context.Context, obj *types.AccessReviewEntry) (*types.AccessReviewCampaignSource, error) {
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet)
if err != nil {
return nil, err
}
campaignSource, err := r.accessReview.GetCampaignSource(ctx, scope, obj.CampaignSource.ID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot get campaign source", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewAccessReviewCampaignSource(campaignSource), nil
}
// DecisionHistory is the resolver for the decisionHistory field.
func (r *accessReviewEntryResolver) DecisionHistory(ctx context.Context, obj *types.AccessReviewEntry) ([]*types.AccessReviewEntryDecisionHistoryEntry, error) {
scope, err := r.authorize(ctx, obj.ID, accessreview.ActionEntryGet)
if err != nil {
return nil, err
}
histories, err := r.accessReview.EntryDecisionHistory(ctx, scope, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get decision history", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
result := make([]*types.AccessReviewEntryDecisionHistoryEntry, len(histories))
for i, h := range histories {
result[i] = types.NewAccessReviewEntryDecisionHistoryEntry(h)
}
return result, nil
}
// Permission is the resolver for the permission field.
func (r *accessReviewEntryResolver) Permission(ctx context.Context, obj *types.AccessReviewEntry, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
}
// TotalCount is the resolver for the totalCount field.
func (r *accessReviewEntryConnectionResolver) TotalCount(ctx context.Context, obj *types.AccessReviewEntryConnection) (int, error) {
scope, err := r.authorize(ctx, obj.ParentID, accessreview.ActionEntryList)
if err != nil {
return 0, err
}
switch obj.Resolver.(type) {
case *accessReviewCampaignResolver:
count, err := r.accessReview.CountEntriesForCampaignID(ctx, scope, obj.ParentID, obj.Filter)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count access entries", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
case *accessReviewCampaignSourceResolver:
count, err := r.accessReview.CountEntriesForCampaignIDAndSourceID(ctx, scope, obj.ParentID, *obj.SourceID, obj.Filter)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count access entries", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
r.logger.ErrorCtx(ctx, "unsupported resolver", log.String("resolver", fmt.Sprintf("%T", obj.Resolver)))
return 0, gqlutils.Internal(ctx)
}
// Organization is the resolver for the organization field.
func (r *accessReviewSourceResolver) Organization(ctx context.Context, obj *types.AccessReviewSource) (*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 *accessReviewSourceResolver) Connector(ctx context.Context, obj *types.AccessReviewSource) (*types.Connector, error) {
if obj.ConnectorID == nil {
return nil, nil
}
scope, err := r.authorize(ctx, obj.ID, accessreview.ActionSourceGet)
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
}
r.logger.ErrorCtx(ctx, "cannot get connector", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewConnector(connector), nil
}
// ProviderOrganizations is the resolver for the providerOrganizations field.
func (r *accessReviewSourceResolver) ProviderOrganizations(ctx context.Context, obj *types.AccessReviewSource) ([]*types.ProviderOrganization, error) {
scope, err := r.authorize(ctx, obj.ID, accessreview.ActionSourceGet)
if err != nil {
return nil, err
}
if obj.ConnectorID == nil {
return []*types.ProviderOrganization{}, nil
}
httpClient, dbConnector, err := r.accessReview.ConnectorHTTPClient(ctx, scope, *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 *accessReviewSourceResolver) NeedsConfiguration(ctx context.Context, obj *types.AccessReviewSource) (bool, error) {
scope, err := r.authorize(ctx, obj.ID, accessreview.ActionSourceGet)
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
}
r.logger.ErrorCtx(ctx, "cannot get connector", log.Error(err))
return false, gqlutils.Internal(ctx)
}
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 *accessReviewSourceResolver) ConnectionStatus(ctx context.Context, obj *types.AccessReviewSource) (types.AccessReviewSourceConnectionStatus, error) {
if obj.ConnectorID == nil {
return types.AccessReviewSourceConnectionStatusNotApplicable, nil
}
scope, err := r.authorize(ctx, obj.ID, accessreview.ActionSourceGet)
if err != nil {
return types.AccessReviewSourceConnectionStatusNotApplicable, err
}
httpClient, dbConnector, err := r.accessReview.ConnectorHTTPClient(ctx, scope, *obj.ConnectorID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return types.AccessReviewSourceConnectionStatusNotApplicable, nil
}
return types.AccessReviewSourceConnectionStatusDisconnected, nil
}
// Creating an HTTP client may succeed even with an expired or invalid
// credential (e.g. no refresh token available, or a dead API key).
// When the provider registers a probe, make a lightweight request to
// verify the credential is actually accepted.
if err := r.providerRegistry.ProbeConnection(ctx, httpClient, dbConnector); err != nil {
return types.AccessReviewSourceConnectionStatusDisconnected, nil
}
return types.AccessReviewSourceConnectionStatusConnected, nil
}
// SelectedOrganization is the resolver for the selectedOrganization field.
func (r *accessReviewSourceResolver) SelectedOrganization(ctx context.Context, obj *types.AccessReviewSource) (*string, error) {
scope, err := r.authorize(ctx, obj.ID, accessreview.ActionSourceGet)
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
}
r.logger.ErrorCtx(ctx, "cannot get connector", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
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 *accessReviewSourceResolver) Permission(ctx context.Context, obj *types.AccessReviewSource, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
}
// TotalCount is the resolver for the totalCount field.
func (r *accessReviewSourceConnectionResolver) TotalCount(ctx context.Context, obj *types.AccessReviewSourceConnection) (int, error) {
scope, err := r.authorize(ctx, obj.ParentID, accessreview.ActionSourceList)
if err != nil {
return 0, err
}
switch obj.Resolver.(type) {
case *organizationResolver:
count, err := r.accessReview.CountSourcesForOrganizationID(ctx, scope, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count access sources", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
r.logger.ErrorCtx(ctx, "unsupported resolver", log.String("resolver", fmt.Sprintf("%T", obj.Resolver)))
return 0, gqlutils.Internal(ctx)
}
// CreateAccessReviewSource is the resolver for the createAccessReviewSource field.
func (r *mutationResolver) CreateAccessReviewSource(ctx context.Context, input types.CreateAccessReviewSourceInput) (*types.CreateAccessReviewSourcePayload, error) {
scope, err := r.authorize(ctx, input.OrganizationID, accessreview.ActionSourceCreate)
if err != nil {
return nil, err
}
source, err := r.accessReview.CreateSource(
ctx,
scope,
accessreview.CreateAccessReviewSourceRequest{
OrganizationID: input.OrganizationID,
ConnectorID: input.ConnectorID,
Name: input.Name,
CsvData: input.CSVData,
},
)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot create access source", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
r.autoSelectDefaultOrganization(ctx, scope, source)
return &types.CreateAccessReviewSourcePayload{
AccessReviewSourceEdge: types.NewAccessReviewSourceEdge(source, coredata.AccessReviewSourceOrderFieldCreatedAt),
}, nil
}
// UpdateAccessReviewSource is the resolver for the updateAccessReviewSource field.
func (r *mutationResolver) UpdateAccessReviewSource(ctx context.Context, input types.UpdateAccessReviewSourceInput) (*types.UpdateAccessReviewSourcePayload, error) {
scope, err := r.authorize(ctx, input.AccessReviewSourceID, accessreview.ActionSourceUpdate)
if err != nil {
return nil, err
}
source, err := r.accessReview.UpdateSource(
ctx,
scope,
accessreview.UpdateAccessReviewSourceRequest{
AccessReviewSourceID: input.AccessReviewSourceID,
Name: gqlutils.UnwrapOmittable(input.Name),
ConnectorID: gqlutils.UnwrapOmittable(input.ConnectorID),
CsvData: gqlutils.UnwrapOmittable(input.CSVData),
},
)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot update access source", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
// A connector was just (re)linked: default its org so the source is usable
// right away. Skipped on name/CSV-only updates to avoid a needless
// provider round-trip.
if input.ConnectorID.IsSet() {
r.autoSelectDefaultOrganization(ctx, scope, source)
}
return &types.UpdateAccessReviewSourcePayload{
AccessReviewSource: types.NewAccessReviewSource(source),
}, nil
}
// DeleteAccessReviewSource is the resolver for the deleteAccessReviewSource field.
func (r *mutationResolver) DeleteAccessReviewSource(ctx context.Context, input types.DeleteAccessReviewSourceInput) (*types.DeleteAccessReviewSourcePayload, error) {
scope, err := r.authorize(ctx, input.AccessReviewSourceID, accessreview.ActionSourceDelete)
if err != nil {
return nil, err
}
if err := r.accessReview.DeleteSource(ctx, scope, input.AccessReviewSourceID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot delete access source", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.DeleteAccessReviewSourcePayload{
DeletedAccessReviewSourceID: input.AccessReviewSourceID,
}, nil
}
// ConfigureAccessReviewSource is the resolver for the configureAccessReviewSource field.
func (r *mutationResolver) ConfigureAccessReviewSource(ctx context.Context, input types.ConfigureAccessReviewSourceInput) (*types.ConfigureAccessReviewSourcePayload, error) {
scope, err := r.authorize(ctx, input.AccessReviewSourceID, accessreview.ActionSourceUpdate)
if err != nil {
return nil, err
}
source, err := r.accessReview.ConfigureAccessReviewSource(
ctx,
scope,
accessreview.ConfigureAccessReviewSourceRequest{
AccessReviewSourceID: input.AccessReviewSourceID,
OrganizationSlug: input.OrganizationSlug,
},
)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot configure access source", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.ConfigureAccessReviewSourcePayload{
AccessReviewSource: types.NewAccessReviewSource(source),
}, nil
}
// autoSelectDefaultOrganization picks the first workspace/org the connector can
// see for a freshly linked picker-provider source that has none selected yet.
// Without it a connected source stays "needs configuration" until the user
// completes the picker step; if they skip it, the first campaign silently
// resolves no users (the driver requires an org). Defaulting to the first
// available makes the source immediately usable; the picker UI stays available
// to switch when several are listed.
//
// Best-effort: any failure (provider unreachable, nothing listed) leaves the
// source in its existing "needs configuration" state, where the picker is the
// fallback. It never fails the create/update mutation that triggered it.
func (r *mutationResolver) autoSelectDefaultOrganization(
ctx context.Context,
scope coredata.Scoper,
source *coredata.AccessReviewSource,
) {
if source == nil || source.ConnectorID == nil {
return
}
httpClient, dbConnector, err := r.accessReview.ConnectorHTTPClient(ctx, scope, *source.ConnectorID)
if err != nil {
// A missing connector is not an error worth logging: the picker
// simply never surfaces a default.
if !errors.Is(err, coredata.ErrResourceNotFound) {
r.logger.WarnCtx(ctx, "cannot load connector for default organization", log.Error(err))
}
return
}
cfg, ok := providerOrgConfigs[dbConnector.Provider]
if !ok || !cfg.NeedsPicker || cfg.ListOrgs == nil {
return
}
// Never override an org the user (or an earlier default) already picked.
if cfg.SelectedSlug(dbConnector) != "" {
return
}
orgs, err := cfg.ListOrgs(ctx, httpClient)
if err != nil {
r.logger.WarnCtx(
ctx,
"cannot list provider organizations for default selection",
log.String("provider", dbConnector.Provider.String()),
log.Error(err),
)
return
}
if len(orgs) == 0 {
return
}
if _, err := r.accessReview.ConfigureAccessReviewSource(
ctx,
scope,
accessreview.ConfigureAccessReviewSourceRequest{
AccessReviewSourceID: source.ID,
OrganizationSlug: orgs[0].Slug,
},
); err != nil {
r.logger.WarnCtx(
ctx,
"cannot apply default provider organization",
log.String("provider", dbConnector.Provider.String()),
log.Error(err),
)
}
}
// 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, accessreview.ActionCampaignCreate)
if err != nil {
return nil, err
}
var description string
if input.Description != nil {
description = *input.Description
}
campaign, err := r.accessReview.CreateCampaign(
ctx,
scope,
accessreview.CreateAccessReviewCampaignRequest{
OrganizationID: input.OrganizationID,
Name: input.Name,
Description: description,
AccessReviewSourceIDs: input.AccessReviewSourceIds,
},
)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot create access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
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, accessreview.ActionCampaignUpdate)
if err != nil {
return nil, err
}
campaign, err := r.accessReview.UpdateCampaign(
ctx,
scope,
accessreview.UpdateAccessReviewCampaignRequest{
CampaignID: input.AccessReviewCampaignID,
Name: gqlutils.UnwrapOmittable(input.Name),
Description: gqlutils.UnwrapOmittable(input.Description),
},
)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot update access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
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, accessreview.ActionCampaignDelete)
if err != nil {
return nil, err
}
if err := r.accessReview.DeleteCampaign(ctx, scope, input.AccessReviewCampaignID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot delete access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
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, accessreview.ActionCampaignStart)
if err != nil {
return nil, err
}
campaign, err := r.accessReview.StartCampaign(ctx, scope, input.AccessReviewCampaignID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot start access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
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, accessreview.ActionCampaignClose)
if err != nil {
return nil, err
}
campaign, err := r.accessReview.CloseCampaign(ctx, scope, input.AccessReviewCampaignID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot close access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
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, accessreview.ActionCampaignCancel)
if err != nil {
return nil, err
}
campaign, err := r.accessReview.CancelCampaign(ctx, scope, input.AccessReviewCampaignID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot cancel access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CancelAccessReviewCampaignPayload{
AccessReviewCampaign: types.NewAccessReviewCampaign(campaign),
}, nil
}
// AddAccessReviewCampaignSource is the resolver for the addAccessReviewCampaignSource field.
func (r *mutationResolver) AddAccessReviewCampaignSource(ctx context.Context, input types.AddAccessReviewCampaignSourceInput) (*types.AddAccessReviewCampaignSourcePayload, error) {
scope, err := r.authorize(ctx, input.AccessReviewCampaignID, accessreview.ActionCampaignAddSource)
if err != nil {
return nil, err
}
campaign, err := r.accessReview.AddCampaignSource(
ctx,
scope,
accessreview.AddCampaignSourceRequest{
CampaignID: input.AccessReviewCampaignID,
AccessReviewSourceID: input.AccessReviewSourceID,
},
)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot add scope source to access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.AddAccessReviewCampaignSourcePayload{
AccessReviewCampaign: types.NewAccessReviewCampaign(campaign),
}, nil
}
// RemoveAccessReviewCampaignSource is the resolver for the removeAccessReviewCampaignSource field.
func (r *mutationResolver) RemoveAccessReviewCampaignSource(ctx context.Context, input types.RemoveAccessReviewCampaignSourceInput) (*types.RemoveAccessReviewCampaignSourcePayload, error) {
scope, err := r.authorize(ctx, input.AccessReviewCampaignID, accessreview.ActionCampaignRemoveSource)
if err != nil {
return nil, err
}
campaign, err := r.accessReview.RemoveCampaignSource(
ctx,
scope,
accessreview.RemoveCampaignSourceRequest{
CampaignID: input.AccessReviewCampaignID,
AccessReviewSourceID: input.AccessReviewSourceID,
},
)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot remove scope source from access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.RemoveAccessReviewCampaignSourcePayload{
AccessReviewCampaign: types.NewAccessReviewCampaign(campaign),
}, nil
}
// RecordAccessReviewEntryDecision is the resolver for the recordAccessReviewEntryDecision field.
func (r *mutationResolver) RecordAccessReviewEntryDecision(ctx context.Context, input types.RecordAccessReviewEntryDecisionInput) (*types.RecordAccessReviewEntryDecisionPayload, error) {
scope, err := r.authorize(ctx, input.AccessReviewEntryID, accessreview.ActionEntryDecide)
if err != nil {
return nil, err
}
entry, err := r.accessReview.RecordDecision(
ctx,
scope,
accessreview.RecordAccessReviewEntryDecisionRequest{
EntryID: input.AccessReviewEntryID,
Decision: input.Decision,
DecisionNote: input.DecisionNote,
DecidedByID: &authn.IdentityFromContext(ctx).ID,
},
)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot record access entry decision", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.RecordAccessReviewEntryDecisionPayload{
AccessReviewEntry: types.NewAccessReviewEntry(entry),
}, nil
}
// RecordAccessReviewEntryDecisions is the resolver for the recordAccessReviewEntryDecisions field.
func (r *mutationResolver) RecordAccessReviewEntryDecisions(ctx context.Context, input types.RecordAccessReviewEntryDecisionsInput) (*types.RecordAccessReviewEntryDecisionsPayload, error) {
if len(input.Decisions) == 0 {
return &types.RecordAccessReviewEntryDecisionsPayload{
AccessReviewEntries: []*types.AccessReviewEntry{},
}, 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.AccessReviewEntryID, accessreview.ActionEntryDecide)
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].AccessReviewEntryID.TenantID()
scope := coredata.NewScope(tenantID)
decidedByID := &identity.ID
decisions := make([]accessreview.RecordAccessReviewEntryDecisionRequest, len(input.Decisions))
for i, d := range input.Decisions {
decisions[i] = accessreview.RecordAccessReviewEntryDecisionRequest{
EntryID: d.AccessReviewEntryID,
Decision: d.Decision,
DecisionNote: d.DecisionNote,
DecidedByID: decidedByID,
}
}
entries, err := r.accessReview.RecordDecisions(ctx, scope, decisions)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot record access entry decisions", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
accessEntries := make([]*types.AccessReviewEntry, len(entries))
for i, e := range entries {
accessEntries[i] = types.NewAccessReviewEntry(e)
}
return &types.RecordAccessReviewEntryDecisionsPayload{
AccessReviewEntries: accessEntries,
}, nil
}
// FlagAccessReviewEntry is the resolver for the flagAccessReviewEntry field.
func (r *mutationResolver) FlagAccessReviewEntry(ctx context.Context, input types.FlagAccessReviewEntryInput) (*types.FlagAccessReviewEntryPayload, error) {
scope, err := r.authorize(ctx, input.AccessReviewEntryID, accessreview.ActionEntryFlag)
if err != nil {
return nil, err
}
entry, err := r.accessReview.FlagEntry(ctx, scope, accessreview.FlagAccessReviewEntryRequest{
EntryID: input.AccessReviewEntryID,
Flags: input.Flags,
FlagReasons: input.FlagReasons,
})
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot flag access entry", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.FlagAccessReviewEntryPayload{
AccessReviewEntry: types.NewAccessReviewEntry(entry),
}, nil
}
// 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}
}
// AccessReviewCampaignSource returns schema.AccessReviewCampaignSourceResolver implementation.
func (r *Resolver) AccessReviewCampaignSource() schema.AccessReviewCampaignSourceResolver {
return &accessReviewCampaignSourceResolver{r}
}
// AccessReviewCampaignSourceFetchAttemptConnection returns schema.AccessReviewCampaignSourceFetchAttemptConnectionResolver implementation.
func (r *Resolver) AccessReviewCampaignSourceFetchAttemptConnection() schema.AccessReviewCampaignSourceFetchAttemptConnectionResolver {
return &accessReviewCampaignSourceFetchAttemptConnectionResolver{r}
}
// AccessReviewEntry returns schema.AccessReviewEntryResolver implementation.
func (r *Resolver) AccessReviewEntry() schema.AccessReviewEntryResolver {
return &accessReviewEntryResolver{r}
}
// AccessReviewEntryConnection returns schema.AccessReviewEntryConnectionResolver implementation.
func (r *Resolver) AccessReviewEntryConnection() schema.AccessReviewEntryConnectionResolver {
return &accessReviewEntryConnectionResolver{r}
}
// AccessReviewSource returns schema.AccessReviewSourceResolver implementation.
func (r *Resolver) AccessReviewSource() schema.AccessReviewSourceResolver {
return &accessReviewSourceResolver{r}
}
// AccessReviewSourceConnection returns schema.AccessReviewSourceConnectionResolver implementation.
func (r *Resolver) AccessReviewSourceConnection() schema.AccessReviewSourceConnectionResolver {
return &accessReviewSourceConnectionResolver{r}
}
type (
accessReviewCampaignResolver struct{ *Resolver }
accessReviewCampaignConnectionResolver struct{ *Resolver }
accessReviewCampaignSourceResolver struct{ *Resolver }
accessReviewCampaignSourceFetchAttemptConnectionResolver struct{ *Resolver }
accessReviewEntryResolver struct{ *Resolver }
accessReviewEntryConnectionResolver struct{ *Resolver }
accessReviewSourceResolver struct{ *Resolver }
accessReviewSourceConnectionResolver struct{ *Resolver }
)