Refactor access review campaign source API

Expose campaign sources as first-class nodes, paginate fetch attempts
instead of denormalized status fields, and bind entries to their
campaign snapshot. Update GraphQL, MCP, CLI, console, and e2e coverage
to match.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-06-15 14:42:58 +02:00
parent 82c9800677
commit eed6bf579d
25 changed files with 1078 additions and 692 deletions

View File

@@ -154,9 +154,15 @@ export const campaignDetailPageQuery = graphql`
id id
} }
name name
fetchStatus fetchAttempts(first: 1) {
fetchedAccountsCount edges {
lastError node {
status
fetchedAccountsCount
error
}
}
}
entries(first: 500) { entries(first: 500) {
edges { edges {
node { node {
@@ -504,6 +510,10 @@ function CampaignSourceCard({ source, isPendingActions }: { source: CampaignSour
const entries = source.entries?.edges ?? []; const entries = source.entries?.edges ?? [];
const entryIds = entries.map(edge => edge.node.id); const entryIds = entries.map(edge => edge.node.id);
const latestAttempt = source.fetchAttempts.edges[0]?.node;
const fetchStatus = latestAttempt?.status ?? "QUEUED";
const fetchedAccountsCount = latestAttempt?.fetchedAccountsCount ?? 0;
const lastError = latestAttempt?.error;
const handleBulkDecision = (value: string) => { const handleBulkDecision = (value: string) => {
const decision = value as AccessReviewEntryDecision; const decision = value as AccessReviewEntryDecision;
@@ -639,22 +649,22 @@ function CampaignSourceCard({ source, isPendingActions }: { source: CampaignSour
: <IconChevronRight className="size-4 text-txt-tertiary" />} : <IconChevronRight className="size-4 text-txt-tertiary" />}
<span className="font-medium">{source.name}</span> <span className="font-medium">{source.name}</span>
<Badge variant="neutral"> <Badge variant="neutral">
{source.fetchedAccountsCount} {fetchedAccountsCount}
{" "} {" "}
{__("accounts")} {__("accounts")}
</Badge> </Badge>
<Badge variant={fetchStatusBadgeVariant(source.fetchStatus)}> <Badge variant={fetchStatusBadgeVariant(fetchStatus)}>
{formatStatus(source.fetchStatus)} {formatStatus(fetchStatus)}
</Badge> </Badge>
</div> </div>
</button> </button>
{source.fetchStatus === "FAILED" && source.lastError && ( {fetchStatus === "FAILED" && lastError && (
<div className="flex items-start gap-2 border-t bg-danger px-4 py-3 text-sm text-txt-danger"> <div className="flex items-start gap-2 border-t bg-danger px-4 py-3 text-sm text-txt-danger">
<IconWarning className="mt-0.5 size-4 shrink-0" /> <IconWarning className="mt-0.5 size-4 shrink-0" />
<div> <div>
<p className="font-medium">{__("Fetch failed")}</p> <p className="font-medium">{__("Fetch failed")}</p>
<p>{source.lastError}</p> <p>{lastError}</p>
</div> </div>
</div> </div>
)} )}

View File

@@ -41,8 +41,15 @@ const addScopeMutation = graphql`
sources { sources {
id id
name name
fetchStatus fetchAttempts(first: 1) {
fetchedAccountsCount edges {
node {
status
fetchedAccountsCount
error
}
}
}
entries(first: 50) { entries(first: 50) {
edges { edges {
node { node {

View File

@@ -824,6 +824,182 @@ func TestAccessReviewCampaign_AddAndRemoveCampaignSource(t *testing.T) {
}) })
} }
func TestAccessReviewCampaignSource_Node(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
orgID := owner.GetOrganizationID().String()
sourceID := factory.NewAccessReviewSource(owner, orgID).
WithName("Node Query Source").
WithCsvData(testCsvData).
Create()
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
WithName("Node Query Campaign").
WithAccessReviewSourceIDs([]string{sourceID}).
Create()
const campaignQuery = `
query($id: ID!) {
node(id: $id) {
... on AccessReviewCampaign {
sources {
id
name
}
}
}
}
`
var campaignResult struct {
Node struct {
Sources []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"sources"`
} `json:"node"`
}
err := owner.Execute(campaignQuery, map[string]any{"id": campaignID}, &campaignResult)
require.NoError(t, err)
require.Len(t, campaignResult.Node.Sources, 1)
campaignSourceID := campaignResult.Node.Sources[0].ID
const nodeQuery = `
query($id: ID!) {
node(id: $id) {
__typename
... on AccessReviewCampaignSource {
id
campaign {
id
}
name
entries(first: 10) {
totalCount
}
}
}
}
`
var nodeResult struct {
Node struct {
Typename string `json:"__typename"`
ID string `json:"id"`
Campaign struct {
ID string `json:"id"`
} `json:"campaign"`
Name string `json:"name"`
Entries struct {
TotalCount int `json:"totalCount"`
} `json:"entries"`
} `json:"node"`
}
err = owner.Execute(nodeQuery, map[string]any{"id": campaignSourceID}, &nodeResult)
require.NoError(t, err)
assert.Equal(t, "AccessReviewCampaignSource", nodeResult.Node.Typename)
assert.Equal(t, campaignSourceID, nodeResult.Node.ID)
assert.Equal(t, campaignID, nodeResult.Node.Campaign.ID)
assert.Equal(t, "Node Query Source", nodeResult.Node.Name)
assert.Greater(t, nodeResult.Node.Entries.TotalCount, 0)
}
func TestAccessReviewCampaignSource_NameSurvivesSourceDeletion(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
orgID := owner.GetOrganizationID().String()
const snapshotName = "Archived Snapshot Source"
sourceID := factory.NewAccessReviewSource(owner, orgID).
WithName(snapshotName).
WithCsvData(testCsvData).
Create()
campaignID := factory.NewAccessReviewCampaign(owner, orgID).
WithName("Archival Campaign").
WithAccessReviewSourceIDs([]string{sourceID}).
Create()
const sourcesQuery = `
query($id: ID!) {
node(id: $id) {
... on AccessReviewCampaign {
sources {
id
name
sourceId
source {
id
}
}
}
}
}
`
var before struct {
Node struct {
Sources []struct {
ID string `json:"id"`
Name string `json:"name"`
SourceID *string `json:"sourceId"`
Source *struct {
ID string `json:"id"`
} `json:"source"`
} `json:"sources"`
} `json:"node"`
}
err := owner.Execute(sourcesQuery, map[string]any{"id": campaignID}, &before)
require.NoError(t, err)
require.Len(t, before.Node.Sources, 1)
assert.Equal(t, snapshotName, before.Node.Sources[0].Name)
require.NotNil(t, before.Node.Sources[0].SourceID)
assert.Equal(t, sourceID, *before.Node.Sources[0].SourceID)
require.NotNil(t, before.Node.Sources[0].Source)
assert.Equal(t, sourceID, before.Node.Sources[0].Source.ID)
const deleteQuery = `
mutation($input: DeleteAccessReviewSourceInput!) {
deleteAccessReviewSource(input: $input) {
deletedAccessReviewSourceId
}
}
`
err = owner.Execute(deleteQuery, map[string]any{
"input": map[string]any{
"accessReviewSourceId": sourceID,
},
}, nil)
require.NoError(t, err)
var after struct {
Node struct {
Sources []struct {
ID string `json:"id"`
Name string `json:"name"`
SourceID *string `json:"sourceId"`
Source *struct {
ID string `json:"id"`
} `json:"source"`
} `json:"sources"`
} `json:"node"`
}
err = owner.Execute(sourcesQuery, map[string]any{"id": campaignID}, &after)
require.NoError(t, err)
require.Len(t, after.Node.Sources, 1)
assert.Equal(t, before.Node.Sources[0].ID, after.Node.Sources[0].ID)
assert.Equal(t, snapshotName, after.Node.Sources[0].Name)
assert.Nil(t, after.Node.Sources[0].SourceID)
assert.Nil(t, after.Node.Sources[0].Source)
}
func TestAccessReviewCampaign_Cancel(t *testing.T) { func TestAccessReviewCampaign_Cancel(t *testing.T) {
t.Parallel() t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner) owner := testutil.NewClient(t, testutil.RoleOwner)

View File

@@ -152,12 +152,12 @@ func (s *Service) UpdateCampaign(
return fmt.Errorf("cannot update campaign: status is %s, expected DRAFT", campaign.Status) return fmt.Errorf("cannot update campaign: status is %s, expected DRAFT", campaign.Status)
} }
if req.Name != nil { if req.Name != nil && *req.Name != nil {
campaign.Name = *req.Name campaign.Name = **req.Name
} }
if req.Description != nil { if req.Description != nil && *req.Description != nil {
campaign.Description = *req.Description campaign.Description = **req.Description
} }
if req.FrameworkControls != nil { if req.FrameworkControls != nil {
@@ -425,7 +425,6 @@ func (s *Service) upsertCampaignSource(
AccessReviewCampaignID: campaignID, AccessReviewCampaignID: campaignID,
AccessReviewSourceID: &sourceID, AccessReviewSourceID: &sourceID,
Name: source.Name, Name: source.Name,
Category: source.Category,
ConnectorID: source.ConnectorID, ConnectorID: source.ConnectorID,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
@@ -553,41 +552,18 @@ func (s *Service) ListCampaignSources(
return sources, nil return sources, nil
} }
func (s *Service) ListLatestFetchAttempts( func (s *Service) ListFetchAttemptsForCampaignSourceID(
ctx context.Context,
scope coredata.Scoper,
campaignID gid.GID,
) (coredata.AccessReviewCampaignSourceFetchAttempts, error) {
var attempts coredata.AccessReviewCampaignSourceFetchAttempts
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := attempts.LoadLatestByCampaignID(ctx, conn, scope, campaignID); err != nil {
return fmt.Errorf("cannot load latest fetch attempts by campaign: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return attempts, nil
}
func (s *Service) ListFetchAttempts(
ctx context.Context, ctx context.Context,
scope coredata.Scoper, scope coredata.Scoper,
campaignSourceID gid.GID, campaignSourceID gid.GID,
) (coredata.AccessReviewCampaignSourceFetchAttempts, error) { cursor *page.Cursor[coredata.AccessReviewCampaignSourceFetchAttemptOrderField],
) (*page.Page[*coredata.AccessReviewCampaignSourceFetchAttempt, coredata.AccessReviewCampaignSourceFetchAttemptOrderField], error) {
var attempts coredata.AccessReviewCampaignSourceFetchAttempts var attempts coredata.AccessReviewCampaignSourceFetchAttempts
err := s.pg.WithConn( err := s.pg.WithConn(
ctx, ctx,
func(ctx context.Context, conn pg.Querier) error { func(ctx context.Context, conn pg.Querier) error {
if err := attempts.LoadByCampaignSourceID(ctx, conn, scope, campaignSourceID); err != nil { if err := attempts.LoadByCampaignSourceID(ctx, conn, scope, campaignSourceID, cursor); err != nil {
return fmt.Errorf("cannot load fetch attempts: %w", err) return fmt.Errorf("cannot load fetch attempts: %w", err)
} }
@@ -598,7 +574,35 @@ func (s *Service) ListFetchAttempts(
return nil, err return nil, err
} }
return attempts, nil return page.NewPage(attempts, cursor), nil
}
func (s *Service) CountFetchAttemptsForCampaignSourceID(
ctx context.Context,
scope coredata.Scoper,
campaignSourceID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var attempts coredata.AccessReviewCampaignSourceFetchAttempts
var err error
count, err = attempts.CountByCampaignSourceID(ctx, conn, scope, campaignSourceID)
if err != nil {
return fmt.Errorf("cannot count fetch attempts: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
} }
func (s *Service) CountCampaignsForOrganizationID( func (s *Service) CountCampaignsForOrganizationID(

View File

@@ -33,8 +33,8 @@ type (
UpdateAccessReviewCampaignRequest struct { UpdateAccessReviewCampaignRequest struct {
CampaignID gid.GID CampaignID gid.GID
Name *string Name **string
Description *string Description **string
FrameworkControls *[]string FrameworkControls *[]string
} }

View File

@@ -51,11 +51,15 @@ func (s *Service) GetEntry(
err := s.pg.WithConn( err := s.pg.WithConn(
ctx, ctx,
func(ctx context.Context, conn pg.Querier) error { func(ctx context.Context, conn pg.Querier) error {
return entry.LoadByID(ctx, conn, scope, entryID) if err := entry.LoadByID(ctx, conn, scope, entryID); err != nil {
return fmt.Errorf("cannot load access entry: %w", err)
}
return nil
}, },
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot get access entry: %w", err) return nil, err
} }
return entry, nil return entry, nil
@@ -66,86 +70,12 @@ func (s *Service) RecordDecision(
scope coredata.Scoper, scope coredata.Scoper,
req RecordAccessReviewEntryDecisionRequest, req RecordAccessReviewEntryDecisionRequest,
) (*coredata.AccessReviewEntry, error) { ) (*coredata.AccessReviewEntry, error) {
if req.Decision == coredata.AccessReviewEntryDecisionPending { entries, err := s.RecordDecisions(ctx, scope, []RecordAccessReviewEntryDecisionRequest{req})
return nil, fmt.Errorf("cannot decide access entry: invalid decision %q", req.Decision)
}
if req.Decision != coredata.AccessReviewEntryDecisionApproved {
if req.DecisionNote == nil || strings.TrimSpace(*req.DecisionNote) == "" {
return nil, fmt.Errorf("cannot decide access entry: note is required for non-approved decisions")
}
}
entry := &coredata.AccessReviewEntry{}
err := s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
if err := entry.LoadByID(ctx, conn, scope, req.EntryID); err != nil {
return fmt.Errorf("cannot load access entry: %w", err)
}
campaign := &coredata.AccessReviewCampaign{}
if err := campaign.LoadByID(ctx, conn, scope, entry.AccessReviewCampaignID); err != nil {
return fmt.Errorf("cannot load campaign: %w", err)
}
if campaign.Status != coredata.AccessReviewCampaignStatusPendingActions {
return fmt.Errorf("cannot decide access entry: campaign status is %s, expected PENDING_ACTIONS", campaign.Status)
}
now := time.Now()
entry.Decision = req.Decision
entry.DecisionNote = req.DecisionNote
entry.DecidedBy = req.DecidedByID
entry.DecidedAt = &now
entry.UpdatedAt = now
if entry.Flags == nil {
entry.Flags = []coredata.AccessReviewEntryFlag{}
}
if entry.FlagReasons == nil {
entry.FlagReasons = []string{}
}
if req.Decision == coredata.AccessReviewEntryDecisionRevoke || req.Decision == coredata.AccessReviewEntryDecisionEscalate {
if len(entry.Flags) == 0 {
entry.Flags = []coredata.AccessReviewEntryFlag{coredata.AccessReviewEntryFlagExcessive}
}
}
if err := entry.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot record access entry decision: %w", err)
}
history := &coredata.AccessReviewEntryDecisionHistory{
ID: gid.New(scope.GetTenantID(), coredata.AccessReviewEntryDecisionHistoryEntityType),
OrganizationID: entry.OrganizationID,
AccessReviewEntry: entry.ID,
Decision: entry.Decision,
DecisionNote: entry.DecisionNote,
DecidedBy: entry.DecidedBy,
DecidedAt: *entry.DecidedAt,
CreatedAt: now,
}
if err := history.Insert(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot insert decision history: %w", err)
}
return nil
},
)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot record access entry decision: %w", err) return nil, err
} }
updatedEntry, err := s.GetEntry(ctx, scope, req.EntryID) return entries[0], nil
if err != nil {
return nil, fmt.Errorf("cannot reload access entry after decision: %w", err)
}
return updatedEntry, nil
} }
func (s *Service) RecordDecisions( func (s *Service) RecordDecisions(
@@ -179,6 +109,7 @@ func (s *Service) RecordDecisions(
// Track verified campaigns to avoid repeated loads within the // Track verified campaigns to avoid repeated loads within the
// same transaction. // same transaction.
verifiedCampaigns := make(map[gid.GID]bool) verifiedCampaigns := make(map[gid.GID]bool)
decidedByCache := make(map[gid.GID]*gid.GID)
for _, d := range decisions { for _, d := range decisions {
entry := &coredata.AccessReviewEntry{} entry := &coredata.AccessReviewEntry{}
@@ -199,10 +130,23 @@ func (s *Service) RecordDecisions(
verifiedCampaigns[entry.AccessReviewCampaignID] = true verifiedCampaigns[entry.AccessReviewCampaignID] = true
} }
decidedByID, ok := decidedByCache[entry.OrganizationID]
if !ok {
decidedByID = nil
if d.DecidedByID != nil {
profile := &coredata.MembershipProfile{}
if err := profile.LoadByIdentityIDAndOrganizationID(ctx, conn, scope, *d.DecidedByID, entry.OrganizationID); err == nil {
decidedByID = &profile.ID
}
}
decidedByCache[entry.OrganizationID] = decidedByID
}
now := time.Now() now := time.Now()
entry.Decision = d.Decision entry.Decision = d.Decision
entry.DecisionNote = d.DecisionNote entry.DecisionNote = d.DecisionNote
entry.DecidedBy = d.DecidedByID entry.DecidedBy = decidedByID
entry.DecidedAt = &now entry.DecidedAt = &now
entry.UpdatedAt = now entry.UpdatedAt = now
@@ -279,11 +223,9 @@ func (s *Service) FlagEntry(
} }
if campaign.Status != coredata.AccessReviewCampaignStatusPendingActions { if campaign.Status != coredata.AccessReviewCampaignStatusPendingActions {
return fmt.Errorf("cannot flag access entry: campaign status is %s, expected PENDING_ACTIONS", campaign.Status) return fmt.Errorf("cannot flag access entry: campaign status is %q, expected PENDING_ACTIONS", campaign.Status)
} }
now := time.Now()
entry.Flags = req.Flags entry.Flags = req.Flags
if entry.Flags == nil { if entry.Flags == nil {
entry.Flags = []coredata.AccessReviewEntryFlag{} entry.Flags = []coredata.AccessReviewEntryFlag{}
@@ -294,16 +236,20 @@ func (s *Service) FlagEntry(
entry.FlagReasons = []string{} entry.FlagReasons = []string{}
} }
entry.UpdatedAt = now entry.UpdatedAt = time.Now()
return entry.UpdateFlags(ctx, conn, scope) if err := entry.UpdateFlags(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update access entry flags: %w", err)
}
return nil
}, },
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot flag access entry: %w", err) return nil, err
} }
return s.GetEntry(ctx, scope, req.EntryID) return entry, nil
} }
func (s *Service) ListEntriesForCampaignID( func (s *Service) ListEntriesForCampaignID(
@@ -318,11 +264,22 @@ func (s *Service) ListEntriesForCampaignID(
err := s.pg.WithConn( err := s.pg.WithConn(
ctx, ctx,
func(ctx context.Context, conn pg.Querier) error { func(ctx context.Context, conn pg.Querier) error {
return entries.LoadByCampaignID(ctx, conn, scope, campaignID, cursor, filter) if err := entries.LoadByCampaignID(
ctx,
conn,
scope,
campaignID,
cursor,
filter,
); err != nil {
return fmt.Errorf("cannot load access entries: %w", err)
}
return nil
}, },
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot list access entries: %w", err) return nil, err
} }
return page.NewPage(entries, cursor), nil return page.NewPage(entries, cursor), nil
@@ -341,11 +298,23 @@ func (s *Service) ListEntriesForCampaignIDAndSourceID(
err := s.pg.WithConn( err := s.pg.WithConn(
ctx, ctx,
func(ctx context.Context, conn pg.Querier) error { func(ctx context.Context, conn pg.Querier) error {
return entries.LoadByCampaignIDAndSourceID(ctx, conn, scope, campaignID, sourceID, cursor, filter) if err := entries.LoadByCampaignIDAndSourceID(
ctx,
conn,
scope,
campaignID,
sourceID,
cursor,
filter,
); err != nil {
return fmt.Errorf("cannot load access entries: %w", err)
}
return nil
}, },
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot list access entries: %w", err) return nil, err
} }
return page.NewPage(entries, cursor), nil return page.NewPage(entries, cursor), nil
@@ -373,7 +342,7 @@ func (s *Service) CountEntriesForCampaignID(
}, },
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("cannot count access entries: %w", err) return 0, err
} }
return count, nil return count, nil
@@ -402,7 +371,7 @@ func (s *Service) CountEntriesForCampaignIDAndSourceID(
}, },
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("cannot count access entries: %w", err) return 0, err
} }
return count, nil return count, nil
@@ -429,7 +398,7 @@ func (s *Service) CountPendingEntriesForCampaignID(
}, },
) )
if err != nil { if err != nil {
return 0, fmt.Errorf("cannot count pending access entries: %w", err) return 0, err
} }
return count, nil return count, nil
@@ -465,7 +434,11 @@ func (s *Service) CampaignStatistics(
err := s.pg.WithConn( err := s.pg.WithConn(
ctx, ctx,
func(ctx context.Context, conn pg.Querier) error { func(ctx context.Context, conn pg.Querier) error {
return stats.LoadByCampaignID(ctx, conn, scope, campaignID) if err := stats.LoadByCampaignID(ctx, conn, scope, campaignID); err != nil {
return fmt.Errorf("cannot load campaign statistics: %w", err)
}
return nil
}, },
) )
if err != nil { if err != nil {
@@ -486,11 +459,15 @@ func (s *Service) CampaignSourceStatistics(
err := s.pg.WithConn( err := s.pg.WithConn(
ctx, ctx,
func(ctx context.Context, conn pg.Querier) error { func(ctx context.Context, conn pg.Querier) error {
return stats.LoadByCampaignIDAndSourceID(ctx, conn, scope, campaignID, sourceID) if err := stats.LoadByCampaignIDAndSourceID(ctx, conn, scope, campaignID, sourceID); err != nil {
return fmt.Errorf("cannot load source statistics: %w", err)
}
return nil
}, },
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot load source statistics: %w", err) return nil, err
} }
return stats, nil return stats, nil

View File

@@ -16,7 +16,6 @@ package accessreview
import ( import (
"context" "context"
"fmt"
"time" "time"
"go.gearno.de/kit/log" "go.gearno.de/kit/log"
@@ -26,7 +25,6 @@ import (
"go.probo.inc/probo/pkg/connector/provider" "go.probo.inc/probo/pkg/connector/provider"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/crypto/cipher" "go.probo.inc/probo/pkg/crypto/cipher"
"go.probo.inc/probo/pkg/gid"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
) )
@@ -102,34 +100,6 @@ func NewService(
return s return s
} }
// ResolveEntryOrganizationID resolves the organization ID for an access entry.
// This is unscoped because it is used by resolvers before authorization to
// find the organization from an entry ID.
func (s *Service) ResolveEntryOrganizationID(ctx context.Context, entryID gid.GID) (gid.GID, error) {
var organizationID gid.GID
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var err error
entry := &coredata.AccessReviewEntry{}
organizationID, err = entry.LoadOrganizationID(ctx, conn, entryID)
if err != nil {
return fmt.Errorf("cannot load organization id: %w", err)
}
return nil
},
)
if err != nil {
return gid.GID{}, fmt.Errorf("cannot resolve organization id: %w", err)
}
return organizationID, nil
}
func (s *Service) Run(ctx context.Context) error { func (s *Service) Run(ctx context.Context) error {
g, gCtx := errgroup.WithContext(ctx) g, gCtx := errgroup.WithContext(ctx)

View File

@@ -26,7 +26,7 @@ import (
const decideMutation = ` const decideMutation = `
mutation($input: RecordAccessReviewEntryDecisionInput!) { mutation($input: RecordAccessReviewEntryDecisionInput!) {
recordAccessReviewEntryDecision(input: $input) { recordAccessReviewEntryDecision(input: $input) {
accessEntry { accessReviewEntry {
id id
email email
fullName fullName
@@ -47,7 +47,7 @@ type decideResponse struct {
Decision string `json:"decision"` Decision string `json:"decision"`
DecisionNote *string `json:"decisionNote"` DecisionNote *string `json:"decisionNote"`
DecidedAt *string `json:"decidedAt"` DecidedAt *string `json:"decidedAt"`
} `json:"accessEntry"` } `json:"accessReviewEntry"`
} `json:"recordAccessReviewEntryDecision"` } `json:"recordAccessReviewEntryDecision"`
} }

View File

@@ -26,7 +26,7 @@ import (
const decideAllMutation = ` const decideAllMutation = `
mutation($input: RecordAccessReviewEntryDecisionsInput!) { mutation($input: RecordAccessReviewEntryDecisionsInput!) {
recordAccessReviewEntryDecisions(input: $input) { recordAccessReviewEntryDecisions(input: $input) {
accessEntries { accessReviewEntries {
id id
email email
decision decision
@@ -41,7 +41,7 @@ type decideAllResponse struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Decision string `json:"decision"` Decision string `json:"decision"`
} `json:"accessEntries"` } `json:"accessReviewEntries"`
} `json:"recordAccessReviewEntryDecisions"` } `json:"recordAccessReviewEntryDecisions"`
} }

View File

@@ -30,7 +30,6 @@ query(
$first: Int, $first: Int,
$after: CursorKey, $after: CursorKey,
$orderBy: AccessReviewEntryOrder, $orderBy: AccessReviewEntryOrder,
$campaignSourceId: ID,
$filter: AccessReviewEntryFilter $filter: AccessReviewEntryFilter
) { ) {
node(id: $id) { node(id: $id) {
@@ -40,7 +39,6 @@ query(
first: $first, first: $first,
after: $after, after: $after,
orderBy: $orderBy, orderBy: $orderBy,
campaignSourceId: $campaignSourceId,
filter: $filter filter: $filter
) { ) {
totalCount totalCount
@@ -63,8 +61,60 @@ query(
flagReasons flagReasons
decision decision
decisionNote decisionNote
accessSource { campaignSource {
id name
}
createdAt
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`
const listBySourceQuery = `
query(
$id: ID!,
$first: Int,
$after: CursorKey,
$orderBy: AccessReviewEntryOrder,
$filter: AccessReviewEntryFilter
) {
node(id: $id) {
__typename
... on AccessReviewCampaignSource {
entries(
first: $first,
after: $after,
orderBy: $orderBy,
filter: $filter
) {
totalCount
edges {
node {
id
email
fullName
role
jobTitle
isAdmin
active
mfaStatus
authMethod
accountType
lastLogin
externalId
incrementalTag
flags
flagReasons
decision
decisionNote
campaignSource {
name name
} }
createdAt createdAt
@@ -81,27 +131,26 @@ query(
` `
type entryNode struct { type entryNode struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
FullName string `json:"fullName"` FullName string `json:"fullName"`
Role string `json:"role"` Role string `json:"role"`
JobTitle string `json:"jobTitle"` JobTitle string `json:"jobTitle"`
IsAdmin bool `json:"isAdmin"` IsAdmin bool `json:"isAdmin"`
Active *bool `json:"active"` Active *bool `json:"active"`
MfaStatus string `json:"mfaStatus"` MfaStatus string `json:"mfaStatus"`
AuthMethod string `json:"authMethod"` AuthMethod string `json:"authMethod"`
AccountType string `json:"accountType"` AccountType string `json:"accountType"`
LastLogin *string `json:"lastLogin"` LastLogin *string `json:"lastLogin"`
ExternalID string `json:"externalId"` ExternalID string `json:"externalId"`
IncrementalTag string `json:"incrementalTag"` IncrementalTag string `json:"incrementalTag"`
Flags []string `json:"flags"` Flags []string `json:"flags"`
FlagReasons []string `json:"flagReasons"` FlagReasons []string `json:"flagReasons"`
Decision string `json:"decision"` Decision string `json:"decision"`
DecisionNote *string `json:"decisionNote"` DecisionNote *string `json:"decisionNote"`
AccessReviewSource struct { CampaignSource struct {
ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
} `json:"accessSource"` } `json:"campaignSource"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
} }
@@ -122,14 +171,14 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
) )
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "list <campaign-id>", Use: "list [<campaign-id>]",
Short: "List access entries for a campaign", Short: "List access entries for a campaign or campaign source",
Args: cobra.ExactArgs(1), Args: cobra.MaximumNArgs(1),
Example: ` # List all entries for a campaign Example: ` # List all entries for a campaign
prb access-review entry list <campaign-id> prb access-review entry list <campaign-id>
# List entries for a specific source # List entries for a specific campaign source
prb access-review entry list <campaign-id> --campaign-source-id <source-id> prb access-review entry list --source-id <campaign-source-id>
# List only pending entries # List only pending entries
prb access-review entry list <campaign-id> --decision PENDING prb access-review entry list <campaign-id> --decision PENDING
@@ -141,6 +190,10 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
return err return err
} }
if flagCampaignSourceID == "" && len(args) == 0 {
return fmt.Errorf("campaign ID is required when --source-id is not set")
}
cfg, err := f.Config() cfg, err := f.Config()
if err != nil { if err != nil {
return err return err
@@ -159,8 +212,14 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
cmdutil.TokenRefreshOption(cfg, host, hc), cmdutil.TokenRefreshOption(cfg, host, hc),
) )
variables := map[string]any{ variables := map[string]any{}
"id": args[0],
if len(args) > 0 {
variables["id"] = args[0]
}
if flagCampaignSourceID != "" {
variables["id"] = flagCampaignSourceID
} }
if err := cmdutil.ValidateEnum("order-direction", flagOrderDir, []string{"ASC", "DESC"}); err != nil { if err := cmdutil.ValidateEnum("order-direction", flagOrderDir, []string{"ASC", "DESC"}); err != nil {
@@ -178,10 +237,6 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
} }
} }
if flagCampaignSourceID != "" {
variables["campaignSourceId"] = flagCampaignSourceID
}
filter := map[string]any{} filter := map[string]any{}
if flagDecision != "" { if flagDecision != "" {
@@ -261,9 +316,19 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
variables["filter"] = filter variables["filter"] = filter
} }
query := listQuery
expectedTypename := "AccessReviewCampaign"
notFoundLabel := "campaign"
if flagCampaignSourceID != "" {
query = listBySourceQuery
expectedTypename = "AccessReviewCampaignSource"
notFoundLabel = "campaign source"
}
entries, totalCount, err := api.Paginate( entries, totalCount, err := api.Paginate(
client, client,
listQuery, query,
variables, variables,
flagLimit, flagLimit,
func(data json.RawMessage) (*api.Connection[entryNode], error) { func(data json.RawMessage) (*api.Connection[entryNode], error) {
@@ -278,11 +343,11 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
} }
if resp.Node == nil { if resp.Node == nil {
return nil, fmt.Errorf("campaign %s not found", args[0]) return nil, fmt.Errorf("%s %s not found", notFoundLabel, variables["id"])
} }
if resp.Node.Typename != "AccessReviewCampaign" { if resp.Node.Typename != expectedTypename {
return nil, fmt.Errorf("expected AccessReviewCampaign node, got %s", resp.Node.Typename) return nil, fmt.Errorf("expected %s node, got %s", expectedTypename, resp.Node.Typename)
} }
return &resp.Node.Entries, nil return &resp.Node.Entries, nil
@@ -326,7 +391,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
e.ID, e.ID,
e.Email, e.Email,
e.FullName, e.FullName,
e.AccessReviewSource.Name, e.CampaignSource.Name,
e.Decision, e.Decision,
strings.Join(e.Flags, ","), strings.Join(e.Flags, ","),
admin, admin,
@@ -354,7 +419,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of entries to list") cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of entries to list")
cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT)") cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT)")
cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)") cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)")
cmd.Flags().StringVar(&flagCampaignSourceID, "source-id", "", "Filter by access source ID") cmd.Flags().StringVar(&flagCampaignSourceID, "source-id", "", "Campaign source ID to list entries for")
cmd.Flags().StringVar(&flagDecision, "decision", "", "Filter by decision (PENDING, APPROVED, REVOKE, DEFER, ESCALATE)") cmd.Flags().StringVar(&flagDecision, "decision", "", "Filter by decision (PENDING, APPROVED, REVOKE, DEFER, ESCALATE)")
cmd.Flags().StringVar(&flagFlag, "flag", "", "Filter by flag (NONE, ORPHANED, INACTIVE, EXCESSIVE, ROLE_MISMATCH, NEW)") cmd.Flags().StringVar(&flagFlag, "flag", "", "Filter by flag (NONE, ORPHANED, INACTIVE, EXCESSIVE, ROLE_MISMATCH, NEW)")
cmd.Flags().StringVar(&flagIncTag, "incremental-tag", "", "Filter by incremental tag (NEW, REMOVED, UNCHANGED)") cmd.Flags().StringVar(&flagIncTag, "incremental-tag", "", "Filter by incremental tag (NEW, REMOVED, UNCHANGED)")

View File

@@ -27,7 +27,7 @@ import (
const flagMutation = ` const flagMutation = `
mutation($input: FlagAccessReviewEntryInput!) { mutation($input: FlagAccessReviewEntryInput!) {
flagAccessReviewEntry(input: $input) { flagAccessReviewEntry(input: $input) {
accessEntry { accessReviewEntry {
id id
email email
fullName fullName
@@ -48,7 +48,7 @@ type flagResponse struct {
Flags []string `json:"flags"` Flags []string `json:"flags"`
FlagReasons []string `json:"flagReasons"` FlagReasons []string `json:"flagReasons"`
Decision string `json:"decision"` Decision string `json:"decision"`
} `json:"accessEntry"` } `json:"accessReviewEntry"`
} `json:"flagAccessReviewEntry"` } `json:"flagAccessReviewEntry"`
} }

View File

@@ -27,7 +27,7 @@ import (
const createMutation = ` const createMutation = `
mutation($input: CreateAccessReviewSourceInput!) { mutation($input: CreateAccessReviewSourceInput!) {
createAccessReviewSource(input: $input) { createAccessReviewSource(input: $input) {
accessSourceEdge { accessReviewSourceEdge {
node { node {
id id
name name
@@ -44,7 +44,7 @@ type createResponse struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
} `json:"node"` } `json:"node"`
} `json:"accessSourceEdge"` } `json:"accessReviewSourceEdge"`
} `json:"createAccessReviewSource"` } `json:"createAccessReviewSource"`
} }

View File

@@ -27,7 +27,7 @@ import (
const updateMutation = ` const updateMutation = `
mutation($input: UpdateAccessReviewSourceInput!) { mutation($input: UpdateAccessReviewSourceInput!) {
updateAccessReviewSource(input: $input) { updateAccessReviewSource(input: $input) {
accessSource { accessReviewSource {
id id
name name
} }
@@ -40,7 +40,7 @@ type updateResponse struct {
AccessReviewSource struct { AccessReviewSource struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
} `json:"accessSource"` } `json:"accessReviewSource"`
} `json:"updateAccessReviewSource"` } `json:"updateAccessReviewSource"`
} }

View File

@@ -24,13 +24,14 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg" "go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
) )
type ( type (
// AccessReviewCampaignSourceFetchAttempt is a single, append-only fetch run for a // AccessReviewCampaignSourceFetchAttempt is a single, append-only fetch run for a
// campaign source snapshot. Each retry produces a new row, so the error of // campaign source snapshot. Each retry produces a new row, so the error of
// every attempt is retained. The current state of a snapshot is the latest // every attempt is retained. The current state of a snapshot is the latest
// attempt (highest attempt_number). Terminal rows (SUCCESS / FAILED) are // attempt (most recently created). Terminal rows (SUCCESS / FAILED) are
// immutable; only the in-flight attempt is updated. // immutable; only the in-flight attempt is updated.
// //
// TenantID is retained on the struct because the background worker claims // TenantID is retained on the struct because the background worker claims
@@ -40,7 +41,6 @@ type (
ID gid.GID `db:"id"` ID gid.GID `db:"id"`
TenantID gid.TenantID `db:"tenant_id"` TenantID gid.TenantID `db:"tenant_id"`
AccessReviewCampaignSourceID gid.GID `db:"access_review_campaign_source_id"` AccessReviewCampaignSourceID gid.GID `db:"access_review_campaign_source_id"`
AttemptNumber int `db:"attempt_number"`
Status AccessReviewCampaignSourceFetchStatus `db:"status"` Status AccessReviewCampaignSourceFetchStatus `db:"status"`
FetchedAccountsCount int `db:"fetched_accounts_count"` FetchedAccountsCount int `db:"fetched_accounts_count"`
Error *string `db:"error"` Error *string `db:"error"`
@@ -48,11 +48,23 @@ type (
CompletedAt *time.Time `db:"completed_at"` CompletedAt *time.Time `db:"completed_at"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
AttemptNumber int `db:"attempt_number"`
} }
AccessReviewCampaignSourceFetchAttempts []*AccessReviewCampaignSourceFetchAttempt AccessReviewCampaignSourceFetchAttempts []*AccessReviewCampaignSourceFetchAttempt
) )
func (a AccessReviewCampaignSourceFetchAttempt) CursorKey(
orderBy AccessReviewCampaignSourceFetchAttemptOrderField,
) page.CursorKey {
switch orderBy {
case AccessReviewCampaignSourceFetchAttemptOrderFieldCreatedAt:
return page.NewCursorKey(a.ID, a.CreatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
var ( var (
ErrNoAccessReviewCampaignSourceFetchAttemptAvailable = errors.New("no access review source fetch attempt available") ErrNoAccessReviewCampaignSourceFetchAttemptAvailable = errors.New("no access review source fetch attempt available")
) )
@@ -185,7 +197,7 @@ SELECT
updated_at updated_at
FROM access_review_campaign_source_fetch_attempts FROM access_review_campaign_source_fetch_attempts
WHERE status = @status WHERE status = @status
ORDER BY created_at ASC ORDER BY created_at ASC, id ASC
LIMIT 1 LIMIT 1
FOR UPDATE SKIP LOCKED FOR UPDATE SKIP LOCKED
` `
@@ -263,13 +275,76 @@ ORDER BY access_review_campaign_source_id, attempt_number DESC
return nil return nil
} }
// LoadByCampaignSourceID returns the full attempt history for a snapshot, // LoadByCampaignSourceID returns a page of fetch attempts for a snapshot.
// newest first.
func (attempts *AccessReviewCampaignSourceFetchAttempts) LoadByCampaignSourceID( func (attempts *AccessReviewCampaignSourceFetchAttempts) LoadByCampaignSourceID(
ctx context.Context, ctx context.Context,
conn pg.Querier, conn pg.Querier,
scope Scoper, scope Scoper,
campaignSourceID gid.GID, campaignSourceID gid.GID,
cursor *page.Cursor[AccessReviewCampaignSourceFetchAttemptOrderField],
) error {
q := `
SELECT
id,
tenant_id,
access_review_campaign_source_id,
status,
fetched_accounts_count,
error,
started_at,
completed_at,
created_at,
updated_at,
attempt_number
FROM (
SELECT
id,
tenant_id,
access_review_campaign_source_id,
status,
fetched_accounts_count,
error,
started_at,
completed_at,
created_at,
updated_at,
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC)::int AS attempt_number
FROM access_review_campaign_source_fetch_attempts
WHERE
%s
AND access_review_campaign_source_id = @access_review_campaign_source_id
) fetch_attempts
WHERE
%s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"access_review_campaign_source_id": campaignSourceID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query fetch attempts: %w", err)
}
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewCampaignSourceFetchAttempt])
if err != nil {
return fmt.Errorf("cannot collect fetch attempts: %w", err)
}
*attempts = result
return nil
}
// LoadAllByCampaignSourceID returns the full attempt history for a snapshot,
// newest first.
func (attempts *AccessReviewCampaignSourceFetchAttempts) LoadAllByCampaignSourceID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
campaignSourceID gid.GID,
) error { ) error {
q := ` q := `
SELECT SELECT
@@ -310,6 +385,33 @@ ORDER BY attempt_number DESC
return nil return nil
} }
func (attempts *AccessReviewCampaignSourceFetchAttempts) CountByCampaignSourceID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
campaignSourceID gid.GID,
) (int, error) {
q := `
SELECT COUNT(*)
FROM access_review_campaign_source_fetch_attempts
WHERE
%s
AND access_review_campaign_source_id = @access_review_campaign_source_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"access_review_campaign_source_id": campaignSourceID}
maps.Copy(args, scope.SQLArguments())
var count int
if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil {
return 0, fmt.Errorf("cannot count fetch attempts: %w", err)
}
return count, nil
}
// RecoverStale fails attempts stuck in FETCHING past the threshold and queues a // RecoverStale fails attempts stuck in FETCHING past the threshold and queues a
// fresh retry attempt for each, preserving the stale attempt's history. It is // fresh retry attempt for each, preserving the stale attempt's history. It is
// intentionally cross-tenant. Returns the number of recovered attempts. // intentionally cross-tenant. Returns the number of recovered attempts.

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"encoding"
"fmt"
"go.probo.inc/probo/pkg/page"
)
type (
AccessReviewCampaignSourceFetchAttemptOrderField string
)
const (
AccessReviewCampaignSourceFetchAttemptOrderFieldCreatedAt AccessReviewCampaignSourceFetchAttemptOrderField = "CREATED_AT"
)
var (
_ page.OrderField = AccessReviewCampaignSourceFetchAttemptOrderField("")
_ fmt.Stringer = AccessReviewCampaignSourceFetchAttemptOrderField("")
_ encoding.TextMarshaler = AccessReviewCampaignSourceFetchAttemptOrderField("")
_ encoding.TextUnmarshaler = (*AccessReviewCampaignSourceFetchAttemptOrderField)(nil)
)
func AccessReviewCampaignSourceFetchAttemptOrderFields() []AccessReviewCampaignSourceFetchAttemptOrderField {
return []AccessReviewCampaignSourceFetchAttemptOrderField{
AccessReviewCampaignSourceFetchAttemptOrderFieldCreatedAt,
}
}
func (v AccessReviewCampaignSourceFetchAttemptOrderField) IsValid() bool {
switch v {
case AccessReviewCampaignSourceFetchAttemptOrderFieldCreatedAt:
return true
}
return false
}
func (v AccessReviewCampaignSourceFetchAttemptOrderField) String() string {
return string(v)
}
func (v AccessReviewCampaignSourceFetchAttemptOrderField) MarshalText() ([]byte, error) {
return []byte(v.String()), nil
}
func (v *AccessReviewCampaignSourceFetchAttemptOrderField) UnmarshalText(text []byte) error {
val := AccessReviewCampaignSourceFetchAttemptOrderField(text)
if !val.IsValid() {
return fmt.Errorf("invalid AccessReviewCampaignSourceFetchAttemptOrderField value: %q", string(text))
}
*v = val
return nil
}
func (p AccessReviewCampaignSourceFetchAttemptOrderField) Column() string {
switch p {
case AccessReviewCampaignSourceFetchAttemptOrderFieldCreatedAt:
return "created_at"
}
panic(fmt.Sprintf("unsupported order by: %s", p))
}

View File

@@ -119,7 +119,6 @@ func TestSourceFetchAttempts_AppendOnly(t *testing.T) {
if err := first.Insert(ctx, tx, fx.scope); err != nil { if err := first.Insert(ctx, tx, fx.scope); err != nil {
return err return err
} }
require.Equal(t, 1, first.AttemptNumber)
second := &coredata.AccessReviewCampaignSourceFetchAttempt{ second := &coredata.AccessReviewCampaignSourceFetchAttempt{
ID: gid.New(tenantID, coredata.AccessReviewCampaignSourceFetchAttemptEntityType), ID: gid.New(tenantID, coredata.AccessReviewCampaignSourceFetchAttemptEntityType),
@@ -133,17 +132,16 @@ func TestSourceFetchAttempts_AppendOnly(t *testing.T) {
if err := second.Insert(ctx, tx, fx.scope); err != nil { if err := second.Insert(ctx, tx, fx.scope); err != nil {
return err return err
} }
require.Equal(t, 2, second.AttemptNumber)
return nil return nil
})) }))
var history coredata.AccessReviewCampaignSourceFetchAttempts var history coredata.AccessReviewCampaignSourceFetchAttempts
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
return history.LoadByCampaignSourceID(ctx, conn, fx.scope, fx.campaignSourceID) return history.LoadAllByCampaignSourceID(ctx, conn, fx.scope, fx.campaignSourceID)
})) }))
require.Len(t, history, 2, "both attempts must be retained") require.Len(t, history, 2, "both attempts must be retained")
assert.Equal(t, 2, history[0].AttemptNumber, "history is newest first") assert.Equal(t, coredata.AccessReviewCampaignSourceFetchStatusSuccess, history[0].Status, "history is newest first")
assert.Equal(t, coredata.AccessReviewCampaignSourceFetchStatusFailed, history[1].Status) assert.Equal(t, coredata.AccessReviewCampaignSourceFetchStatusFailed, history[1].Status)
require.NotNil(t, history[1].Error) require.NotNil(t, history[1].Error)
assert.Equal(t, failureMsg, *history[1].Error, "the failed attempt's error is retained") assert.Equal(t, failureMsg, *history[1].Error, "the failed attempt's error is retained")

View File

@@ -18,6 +18,7 @@ import (
"context" "context"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
) )
type ( type (
@@ -45,6 +46,15 @@ func IdentityFromContext(ctx context.Context) *coredata.Identity {
return identity return identity
} }
func IdentityIDFromContext(ctx context.Context) *gid.GID {
identity := IdentityFromContext(ctx)
if identity == nil {
return nil
}
return &identity.ID
}
func ContextWithIdentity(ctx context.Context, identity *coredata.Identity) context.Context { func ContextWithIdentity(ctx context.Context, identity *coredata.Identity) context.Context {
return context.WithValue(ctx, identityContextKey, identity) return context.WithValue(ctx, identityContextKey, identity)
} }

View File

@@ -14,7 +14,6 @@ import (
"go.gearno.de/kit/log" "go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/accessreview" "go.probo.inc/probo/pkg/accessreview"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/authn"
@@ -55,29 +54,21 @@ func (r *accessReviewCampaignResolver) Sources(ctx context.Context, obj *types.A
campaignSources, err := r.accessReview.ListCampaignSources(ctx, scope, obj.ID) campaignSources, err := r.accessReview.ListCampaignSources(ctx, scope, obj.ID)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot list campaign sources: %w", err)) r.logger.ErrorCtx(ctx, "cannot list campaign sources", log.Error(err))
}
attempts, err := r.accessReview.ListLatestFetchAttempts(ctx, scope, obj.ID) return nil, gqlutils.Internal(ctx)
if err != nil {
panic(fmt.Errorf("cannot list latest fetch attempts: %w", err))
}
attemptByCampaignSourceID := make(map[gid.GID]*coredata.AccessReviewCampaignSourceFetchAttempt, len(attempts))
for _, attempt := range attempts {
attemptByCampaignSourceID[attempt.AccessReviewCampaignSourceID] = attempt
} }
result := make([]*types.AccessReviewCampaignSource, len(campaignSources)) result := make([]*types.AccessReviewCampaignSource, len(campaignSources))
for i, campaignSource := range campaignSources { for i, campaignSource := range campaignSources {
result[i] = types.NewAccessReviewCampaignSource(campaignSource, attemptByCampaignSourceID[campaignSource.ID]) result[i] = types.NewAccessReviewCampaignSource(campaignSource)
} }
return result, nil return result, nil
} }
// Entries is the resolver for the entries field. // 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, campaignSourceID *gid.GID, filter *coredata.AccessReviewEntryFilter) (*types.AccessReviewEntryConnection, error) { 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) scope, err := r.authorize(ctx, obj.ID, accessreview.ActionEntryList)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -97,21 +88,14 @@ func (r *accessReviewCampaignResolver) Entries(ctx context.Context, obj *types.A
cursor := types.NewCursor(first, after, last, before, pageOrderBy) cursor := types.NewCursor(first, after, last, before, pageOrderBy)
var ( p, err := r.accessReview.ListEntriesForCampaignID(ctx, scope, obj.ID, cursor, filter)
p *page.Page[*coredata.AccessReviewEntry, coredata.AccessReviewEntryOrderField]
)
if campaignSourceID != nil {
p, err = r.accessReview.ListEntriesForCampaignIDAndSourceID(ctx, scope, obj.ID, *campaignSourceID, cursor, filter)
} else {
p, err = r.accessReview.ListEntriesForCampaignID(ctx, scope, obj.ID, cursor, filter)
}
if err != nil { if err != nil {
panic(fmt.Errorf("cannot list access entries: %w", err)) r.logger.ErrorCtx(ctx, "cannot list access entries", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return types.NewAccessReviewEntryConnection(p, r, obj.ID, campaignSourceID, filter), nil return types.NewAccessReviewEntryConnection(p, r, obj.ID, nil, filter), nil
} }
// PendingEntryCount is the resolver for the pendingEntryCount field. // PendingEntryCount is the resolver for the pendingEntryCount field.
@@ -123,7 +107,9 @@ func (r *accessReviewCampaignResolver) PendingEntryCount(ctx context.Context, ob
count, err := r.accessReview.CountPendingEntriesForCampaignID(ctx, scope, obj.ID) count, err := r.accessReview.CountPendingEntriesForCampaignID(ctx, scope, obj.ID)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot count pending access entries: %w", err)) r.logger.ErrorCtx(ctx, "cannot count pending access entries", log.Error(err))
return 0, gqlutils.Internal(ctx)
} }
return count, nil return count, nil
@@ -138,7 +124,9 @@ func (r *accessReviewCampaignResolver) Statistics(ctx context.Context, obj *type
stats, err := r.accessReview.CampaignStatistics(ctx, scope, obj.ID) stats, err := r.accessReview.CampaignStatistics(ctx, scope, obj.ID)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot get campaign statistics: %w", err)) r.logger.ErrorCtx(ctx, "cannot get campaign statistics", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return types.NewAccessReviewStatistics(stats), nil return types.NewAccessReviewStatistics(stats), nil
@@ -160,13 +148,38 @@ func (r *accessReviewCampaignConnectionResolver) TotalCount(ctx context.Context,
case *organizationResolver: case *organizationResolver:
count, err := r.accessReview.CountCampaignsForOrganizationID(ctx, scope, obj.ParentID) count, err := r.accessReview.CountCampaignsForOrganizationID(ctx, scope, obj.ParentID)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot count access review campaigns: %w", err)) r.logger.ErrorCtx(ctx, "cannot count access review campaigns", log.Error(err))
return 0, gqlutils.Internal(ctx)
} }
return count, nil return count, nil
} }
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver)) 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. // Source is the resolver for the source field.
@@ -175,7 +188,7 @@ func (r *accessReviewCampaignSourceResolver) Source(ctx context.Context, obj *ty
return nil, nil return nil, nil
} }
scope, err := r.authorize(ctx, obj.CampaignID, accessreview.ActionCampaignGet) scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -186,35 +199,48 @@ func (r *accessReviewCampaignSourceResolver) Source(ctx context.Context, obj *ty
return nil, nil return nil, nil
} }
panic(fmt.Errorf("cannot get access review source: %w", err)) r.logger.ErrorCtx(ctx, "cannot get access review source", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return types.NewAccessReviewSource(source), nil return types.NewAccessReviewSource(source), nil
} }
// FetchAttempts is the resolver for the fetchAttempts field. // FetchAttempts is the resolver for the fetchAttempts field.
func (r *accessReviewCampaignSourceResolver) FetchAttempts(ctx context.Context, obj *types.AccessReviewCampaignSource) ([]*types.AccessReviewCampaignSourceFetchAttempt, error) { 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.CampaignID, accessreview.ActionCampaignGet) scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet)
if err != nil { if err != nil {
return nil, err return nil, err
} }
attempts, err := r.accessReview.ListFetchAttempts(ctx, scope, obj.ID) 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 { if err != nil {
panic(fmt.Errorf("cannot list fetch attempts: %w", err)) r.logger.ErrorCtx(ctx, "cannot list fetch attempts", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
result := make([]*types.AccessReviewCampaignSourceFetchAttempt, len(attempts)) return types.NewAccessReviewCampaignSourceFetchAttemptConnection(p, r, obj.ID), nil
for i, attempt := range attempts {
result[i] = types.NewAccessReviewCampaignSourceFetchAttempt(attempt)
}
return result, nil
} }
// Entries is the resolver for the entries field. // 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) { 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.CampaignID, accessreview.ActionEntryList) scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionEntryList)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -233,31 +259,52 @@ func (r *accessReviewCampaignSourceResolver) Entries(ctx context.Context, obj *t
cursor := types.NewCursor(first, after, last, before, pageOrderBy) cursor := types.NewCursor(first, after, last, before, pageOrderBy)
p, err := r.accessReview.ListEntriesForCampaignIDAndSourceID(ctx, scope, obj.CampaignID, obj.ID, cursor, filter) p, err := r.accessReview.ListEntriesForCampaignIDAndSourceID(ctx, scope, obj.Campaign.ID, obj.ID, cursor, filter)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot list access entries: %w", err)) r.logger.ErrorCtx(ctx, "cannot list access entries", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
sourceID := obj.ID sourceID := obj.ID
return types.NewAccessReviewEntryConnection(p, r, obj.CampaignID, &sourceID, filter), nil return types.NewAccessReviewEntryConnection(p, r, obj.Campaign.ID, &sourceID, filter), nil
} }
// Statistics is the resolver for the statistics field. // Statistics is the resolver for the statistics field.
func (r *accessReviewCampaignSourceResolver) Statistics(ctx context.Context, obj *types.AccessReviewCampaignSource) (*types.AccessReviewStatistics, error) { func (r *accessReviewCampaignSourceResolver) Statistics(ctx context.Context, obj *types.AccessReviewCampaignSource) (*types.AccessReviewStatistics, error) {
scope, err := r.authorize(ctx, obj.CampaignID, accessreview.ActionEntryList) scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionEntryList)
if err != nil { if err != nil {
return nil, err return nil, err
} }
stats, err := r.accessReview.CampaignSourceStatistics(ctx, scope, obj.CampaignID, obj.ID) stats, err := r.accessReview.CampaignSourceStatistics(ctx, scope, obj.Campaign.ID, obj.ID)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot get source statistics: %w", err)) r.logger.ErrorCtx(ctx, "cannot get source statistics", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return types.NewAccessReviewStatistics(stats), nil 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. // Campaign is the resolver for the campaign field.
func (r *accessReviewEntryResolver) Campaign(ctx context.Context, obj *types.AccessReviewEntry) (*types.AccessReviewCampaign, error) { func (r *accessReviewEntryResolver) Campaign(ctx context.Context, obj *types.AccessReviewEntry) (*types.AccessReviewCampaign, error) {
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet) scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet)
@@ -271,51 +318,33 @@ func (r *accessReviewEntryResolver) Campaign(ctx context.Context, obj *types.Acc
return nil, gqlutils.NotFound(ctx, err) return nil, gqlutils.NotFound(ctx, err)
} }
panic(fmt.Errorf("cannot get access review campaign: %w", err)) r.logger.ErrorCtx(ctx, "cannot get access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return types.NewAccessReviewCampaign(campaign), nil return types.NewAccessReviewCampaign(campaign), nil
} }
// AccessReviewSource is the resolver for the accessReviewSource field. // CampaignSource is the resolver for the campaignSource field.
func (r *accessReviewEntryResolver) AccessReviewSource(ctx context.Context, obj *types.AccessReviewEntry) (*types.AccessReviewSource, error) { func (r *accessReviewEntryResolver) CampaignSource(ctx context.Context, obj *types.AccessReviewEntry) (*types.AccessReviewCampaignSource, error) {
scope, err := r.authorize(ctx, obj.ID, accessreview.ActionEntryGet) scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet)
if err != nil { if err != nil {
return nil, err return nil, err
} }
entry, err := r.accessReview.GetEntry(ctx, scope, obj.ID) campaignSource, err := r.accessReview.GetCampaignSource(ctx, scope, obj.CampaignSource.ID)
if err != nil { if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) { if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err) return nil, gqlutils.NotFound(ctx, err)
} }
panic(fmt.Errorf("cannot get access review entry: %w", err)) r.logger.ErrorCtx(ctx, "cannot get campaign source", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
campaignSource, err := r.accessReview.GetCampaignSource(ctx, scope, entry.AccessReviewCampaignSourceID) return types.NewAccessReviewCampaignSource(campaignSource), nil
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, nil
}
panic(fmt.Errorf("cannot get campaign source: %w", err))
}
if campaignSource.AccessReviewSourceID == nil {
return nil, nil
}
source, err := r.accessReview.GetSource(ctx, scope, *campaignSource.AccessReviewSourceID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, nil
}
panic(fmt.Errorf("cannot get access review source: %w", err))
}
return types.NewAccessReviewSource(source), nil
} }
// DecisionHistory is the resolver for the decisionHistory field. // DecisionHistory is the resolver for the decisionHistory field.
@@ -327,7 +356,9 @@ func (r *accessReviewEntryResolver) DecisionHistory(ctx context.Context, obj *ty
histories, err := r.accessReview.EntryDecisionHistory(ctx, scope, obj.ID) histories, err := r.accessReview.EntryDecisionHistory(ctx, scope, obj.ID)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot get decision history: %w", err)) r.logger.ErrorCtx(ctx, "cannot get decision history", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
result := make([]*types.AccessReviewEntryDecisionHistoryEntry, len(histories)) result := make([]*types.AccessReviewEntryDecisionHistoryEntry, len(histories))
@@ -352,24 +383,28 @@ func (r *accessReviewEntryConnectionResolver) TotalCount(ctx context.Context, ob
switch obj.Resolver.(type) { switch obj.Resolver.(type) {
case *accessReviewCampaignResolver: case *accessReviewCampaignResolver:
if obj.SourceID != nil {
count, err := r.accessReview.CountEntriesForCampaignIDAndSourceID(ctx, scope, 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.CountEntriesForCampaignID(ctx, scope, obj.ParentID, obj.Filter) count, err := r.accessReview.CountEntriesForCampaignID(ctx, scope, obj.ParentID, obj.Filter)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot count access entries: %w", err)) 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 return count, nil
} }
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver)) 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. // Organization is the resolver for the organization field.
@@ -411,7 +446,9 @@ func (r *accessReviewSourceResolver) Connector(ctx context.Context, obj *types.A
return nil, nil return nil, nil
} }
panic(fmt.Errorf("cannot get connector: %w", err)) r.logger.ErrorCtx(ctx, "cannot get connector", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return types.NewConnector(connector), nil return types.NewConnector(connector), nil
@@ -477,7 +514,9 @@ func (r *accessReviewSourceResolver) NeedsConfiguration(ctx context.Context, obj
return false, nil return false, nil
} }
panic(fmt.Errorf("cannot get connector: %w", err)) r.logger.ErrorCtx(ctx, "cannot get connector", log.Error(err))
return false, gqlutils.Internal(ctx)
} }
cfg, ok := providerOrgConfigs[dbConnector.Provider] cfg, ok := providerOrgConfigs[dbConnector.Provider]
@@ -536,7 +575,9 @@ func (r *accessReviewSourceResolver) SelectedOrganization(ctx context.Context, o
return nil, nil return nil, nil
} }
panic(fmt.Errorf("cannot get connector: %w", err)) r.logger.ErrorCtx(ctx, "cannot get connector", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
cfg, ok := providerOrgConfigs[dbConnector.Provider] cfg, ok := providerOrgConfigs[dbConnector.Provider]
@@ -568,13 +609,17 @@ func (r *accessReviewSourceConnectionResolver) TotalCount(ctx context.Context, o
case *organizationResolver: case *organizationResolver:
count, err := r.accessReview.CountSourcesForOrganizationID(ctx, scope, obj.ParentID) count, err := r.accessReview.CountSourcesForOrganizationID(ctx, scope, obj.ParentID)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot count access sources: %w", err)) r.logger.ErrorCtx(ctx, "cannot count access sources", log.Error(err))
return 0, gqlutils.Internal(ctx)
} }
return count, nil return count, nil
} }
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver)) 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. // CreateAccessReviewSource is the resolver for the createAccessReviewSource field.
@@ -584,15 +629,20 @@ func (r *mutationResolver) CreateAccessReviewSource(ctx context.Context, input t
return nil, err return nil, err
} }
source, err := r.accessReview.CreateSource(ctx, scope, accessreview.CreateAccessReviewSourceRequest{ source, err := r.accessReview.CreateSource(
OrganizationID: input.OrganizationID, ctx,
ConnectorID: input.ConnectorID, scope,
Name: input.Name, accessreview.CreateAccessReviewSourceRequest{
Category: coredata.AccessReviewSourceCategorySaaS, OrganizationID: input.OrganizationID,
CsvData: input.CSVData, ConnectorID: input.ConnectorID,
}) Name: input.Name,
CsvData: input.CSVData,
},
)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot create access source: %w", err)) r.logger.ErrorCtx(ctx, "cannot create access source", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return &types.CreateAccessReviewSourcePayload{ return &types.CreateAccessReviewSourcePayload{
@@ -607,29 +657,24 @@ func (r *mutationResolver) UpdateAccessReviewSource(ctx context.Context, input t
return nil, err return nil, err
} }
req := accessreview.UpdateAccessReviewSourceRequest{ source, err := r.accessReview.UpdateSource(
AccessReviewSourceID: input.AccessReviewSourceID, ctx,
} scope,
accessreview.UpdateAccessReviewSourceRequest{
if input.Name.IsSet() { AccessReviewSourceID: input.AccessReviewSourceID,
req.Name = input.Name.Value() Name: gqlutils.UnwrapOmittable(input.Name),
} ConnectorID: gqlutils.UnwrapOmittable(input.ConnectorID),
CsvData: gqlutils.UnwrapOmittable(input.CSVData),
if input.ConnectorID.IsSet() { },
req.ConnectorID = gqlutils.UnwrapOmittable(input.ConnectorID) )
}
if input.CSVData.IsSet() {
req.CsvData = gqlutils.UnwrapOmittable(input.CSVData)
}
source, err := r.accessReview.UpdateSource(ctx, scope, req)
if err != nil { if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) { if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err) return nil, gqlutils.NotFound(ctx, err)
} }
panic(fmt.Errorf("cannot update access source: %w", err)) r.logger.ErrorCtx(ctx, "cannot update access source", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return &types.UpdateAccessReviewSourcePayload{ return &types.UpdateAccessReviewSourcePayload{
@@ -649,7 +694,9 @@ func (r *mutationResolver) DeleteAccessReviewSource(ctx context.Context, input t
return nil, gqlutils.NotFound(ctx, err) return nil, gqlutils.NotFound(ctx, err)
} }
panic(fmt.Errorf("cannot delete access source: %w", err)) r.logger.ErrorCtx(ctx, "cannot delete access source", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return &types.DeleteAccessReviewSourcePayload{ return &types.DeleteAccessReviewSourcePayload{
@@ -677,7 +724,9 @@ func (r *mutationResolver) ConfigureAccessReviewSource(ctx context.Context, inpu
return nil, gqlutils.NotFound(ctx, err) return nil, gqlutils.NotFound(ctx, err)
} }
panic(fmt.Errorf("cannot configure access source: %w", err)) r.logger.ErrorCtx(ctx, "cannot configure access source", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return &types.ConfigureAccessReviewSourcePayload{ return &types.ConfigureAccessReviewSourcePayload{
@@ -697,15 +746,21 @@ func (r *mutationResolver) CreateAccessReviewCampaign(ctx context.Context, input
description = *input.Description description = *input.Description
} }
campaign, err := r.accessReview.CreateCampaign(ctx, scope, accessreview.CreateAccessReviewCampaignRequest{ campaign, err := r.accessReview.CreateCampaign(
OrganizationID: input.OrganizationID, ctx,
Name: input.Name, scope,
Description: description, accessreview.CreateAccessReviewCampaignRequest{
FrameworkControls: input.FrameworkControls, OrganizationID: input.OrganizationID,
AccessReviewSourceIDs: input.AccessReviewSourceIds, Name: input.Name,
}) Description: description,
FrameworkControls: input.FrameworkControls,
AccessReviewSourceIDs: input.AccessReviewSourceIds,
},
)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot create access review campaign: %w", err)) r.logger.ErrorCtx(ctx, "cannot create access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return &types.CreateAccessReviewCampaignPayload{ return &types.CreateAccessReviewCampaignPayload{
@@ -720,30 +775,24 @@ func (r *mutationResolver) UpdateAccessReviewCampaign(ctx context.Context, input
return nil, err return nil, err
} }
req := accessreview.UpdateAccessReviewCampaignRequest{ campaign, err := r.accessReview.UpdateCampaign(
CampaignID: input.AccessReviewCampaignID, ctx,
} scope,
accessreview.UpdateAccessReviewCampaignRequest{
if input.Name.IsSet() { CampaignID: input.AccessReviewCampaignID,
req.Name = input.Name.Value() Name: gqlutils.UnwrapOmittable(input.Name),
} Description: gqlutils.UnwrapOmittable(input.Description),
FrameworkControls: gqlutils.UnwrapOmittable(input.FrameworkControls),
if input.Description.IsSet() { },
req.Description = input.Description.Value() )
}
if input.FrameworkControls.IsSet() {
controls := input.FrameworkControls.Value()
req.FrameworkControls = &controls
}
campaign, err := r.accessReview.UpdateCampaign(ctx, scope, req)
if err != nil { if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) { if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err) return nil, gqlutils.NotFound(ctx, err)
} }
panic(fmt.Errorf("cannot update access review campaign: %w", err)) r.logger.ErrorCtx(ctx, "cannot update access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return &types.UpdateAccessReviewCampaignPayload{ return &types.UpdateAccessReviewCampaignPayload{
@@ -763,7 +812,9 @@ func (r *mutationResolver) DeleteAccessReviewCampaign(ctx context.Context, input
return nil, gqlutils.NotFound(ctx, err) return nil, gqlutils.NotFound(ctx, err)
} }
panic(fmt.Errorf("cannot delete access review campaign: %w", err)) r.logger.ErrorCtx(ctx, "cannot delete access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return &types.DeleteAccessReviewCampaignPayload{ return &types.DeleteAccessReviewCampaignPayload{
@@ -780,7 +831,9 @@ func (r *mutationResolver) StartAccessReviewCampaign(ctx context.Context, input
campaign, err := r.accessReview.StartCampaign(ctx, scope, input.AccessReviewCampaignID) campaign, err := r.accessReview.StartCampaign(ctx, scope, input.AccessReviewCampaignID)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot start access review campaign: %w", err)) r.logger.ErrorCtx(ctx, "cannot start access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return &types.StartAccessReviewCampaignPayload{ return &types.StartAccessReviewCampaignPayload{
@@ -797,7 +850,9 @@ func (r *mutationResolver) CloseAccessReviewCampaign(ctx context.Context, input
campaign, err := r.accessReview.CloseCampaign(ctx, scope, input.AccessReviewCampaignID) campaign, err := r.accessReview.CloseCampaign(ctx, scope, input.AccessReviewCampaignID)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot close access review campaign: %w", err)) r.logger.ErrorCtx(ctx, "cannot close access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return &types.CloseAccessReviewCampaignPayload{ return &types.CloseAccessReviewCampaignPayload{
@@ -814,7 +869,9 @@ func (r *mutationResolver) CancelAccessReviewCampaign(ctx context.Context, input
campaign, err := r.accessReview.CancelCampaign(ctx, scope, input.AccessReviewCampaignID) campaign, err := r.accessReview.CancelCampaign(ctx, scope, input.AccessReviewCampaignID)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot cancel access review campaign: %w", err)) r.logger.ErrorCtx(ctx, "cannot cancel access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return &types.CancelAccessReviewCampaignPayload{ return &types.CancelAccessReviewCampaignPayload{
@@ -829,12 +886,18 @@ func (r *mutationResolver) AddAccessReviewCampaignSource(ctx context.Context, in
return nil, err return nil, err
} }
campaign, err := r.accessReview.AddCampaignSource(ctx, scope, accessreview.AddCampaignSourceRequest{ campaign, err := r.accessReview.AddCampaignSource(
CampaignID: input.AccessReviewCampaignID, ctx,
AccessReviewSourceID: input.AccessReviewSourceID, scope,
}) accessreview.AddCampaignSourceRequest{
CampaignID: input.AccessReviewCampaignID,
AccessReviewSourceID: input.AccessReviewSourceID,
},
)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot add scope source to access review campaign: %w", err)) r.logger.ErrorCtx(ctx, "cannot add scope source to access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return &types.AddAccessReviewCampaignSourcePayload{ return &types.AddAccessReviewCampaignSourcePayload{
@@ -849,12 +912,18 @@ func (r *mutationResolver) RemoveAccessReviewCampaignSource(ctx context.Context,
return nil, err return nil, err
} }
campaign, err := r.accessReview.RemoveCampaignSource(ctx, scope, accessreview.RemoveCampaignSourceRequest{ campaign, err := r.accessReview.RemoveCampaignSource(
CampaignID: input.AccessReviewCampaignID, ctx,
AccessReviewSourceID: input.AccessReviewSourceID, scope,
}) accessreview.RemoveCampaignSourceRequest{
CampaignID: input.AccessReviewCampaignID,
AccessReviewSourceID: input.AccessReviewSourceID,
},
)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot remove scope source from access review campaign: %w", err)) r.logger.ErrorCtx(ctx, "cannot remove scope source from access review campaign", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return &types.RemoveAccessReviewCampaignSourcePayload{ return &types.RemoveAccessReviewCampaignSourcePayload{
@@ -869,35 +938,24 @@ func (r *mutationResolver) RecordAccessReviewEntryDecision(ctx context.Context,
return nil, err return nil, err
} }
// Resolve the profile ID from the session's identity. entry, err := r.accessReview.RecordDecision(
// The profile may not exist for every identity, in which ctx,
// case decided_by will be left nil. scope,
identity := authn.IdentityFromContext(ctx) accessreview.RecordAccessReviewEntryDecisionRequest{
if identity == nil { EntryID: input.AccessReviewEntryID,
return nil, fmt.Errorf("no identity in context") Decision: input.Decision,
} DecisionNote: input.DecisionNote,
DecidedByID: authn.IdentityIDFromContext(ctx),
req := accessreview.RecordAccessReviewEntryDecisionRequest{ },
EntryID: input.AccessReviewEntryID, )
Decision: input.Decision,
DecisionNote: input.DecisionNote,
}
organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, input.AccessReviewEntryID)
if err == nil {
profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, organizationID)
if err == nil {
req.DecidedByID = &profile.ID
}
}
entry, err := r.accessReview.RecordDecision(ctx, scope, req)
if err != nil { if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) { if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err) return nil, gqlutils.NotFound(ctx, err)
} }
panic(fmt.Errorf("cannot record access entry decision: %w", err)) r.logger.ErrorCtx(ctx, "cannot record access entry decision", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return &types.RecordAccessReviewEntryDecisionPayload{ return &types.RecordAccessReviewEntryDecisionPayload{
@@ -933,29 +991,10 @@ func (r *mutationResolver) RecordAccessReviewEntryDecisions(ctx context.Context,
tenantID := input.Decisions[0].AccessReviewEntryID.TenantID() tenantID := input.Decisions[0].AccessReviewEntryID.TenantID()
scope := coredata.NewScope(tenantID) scope := coredata.NewScope(tenantID)
decidedByID := &identity.ID
// 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.RecordAccessReviewEntryDecisionRequest, len(input.Decisions)) decisions := make([]accessreview.RecordAccessReviewEntryDecisionRequest, len(input.Decisions))
for i, d := range input.Decisions { for i, d := range input.Decisions {
var decidedByID *gid.GID
organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, d.AccessReviewEntryID)
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.RecordAccessReviewEntryDecisionRequest{ decisions[i] = accessreview.RecordAccessReviewEntryDecisionRequest{
EntryID: d.AccessReviewEntryID, EntryID: d.AccessReviewEntryID,
Decision: d.Decision, Decision: d.Decision,
@@ -970,7 +1009,9 @@ func (r *mutationResolver) RecordAccessReviewEntryDecisions(ctx context.Context,
return nil, gqlutils.NotFound(ctx, err) return nil, gqlutils.NotFound(ctx, err)
} }
panic(fmt.Errorf("cannot record access entry decisions: %w", err)) r.logger.ErrorCtx(ctx, "cannot record access entry decisions", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
accessEntries := make([]*types.AccessReviewEntry, len(entries)) accessEntries := make([]*types.AccessReviewEntry, len(entries))
@@ -1000,7 +1041,9 @@ func (r *mutationResolver) FlagAccessReviewEntry(ctx context.Context, input type
return nil, gqlutils.NotFound(ctx, err) return nil, gqlutils.NotFound(ctx, err)
} }
panic(fmt.Errorf("cannot flag access entry: %w", err)) r.logger.ErrorCtx(ctx, "cannot flag access entry", log.Error(err))
return nil, gqlutils.Internal(ctx)
} }
return &types.FlagAccessReviewEntryPayload{ return &types.FlagAccessReviewEntryPayload{
@@ -1023,6 +1066,11 @@ func (r *Resolver) AccessReviewCampaignSource() schema.AccessReviewCampaignSourc
return &accessReviewCampaignSourceResolver{r} return &accessReviewCampaignSourceResolver{r}
} }
// AccessReviewCampaignSourceFetchAttemptConnection returns schema.AccessReviewCampaignSourceFetchAttemptConnectionResolver implementation.
func (r *Resolver) AccessReviewCampaignSourceFetchAttemptConnection() schema.AccessReviewCampaignSourceFetchAttemptConnectionResolver {
return &accessReviewCampaignSourceFetchAttemptConnectionResolver{r}
}
// AccessReviewEntry returns schema.AccessReviewEntryResolver implementation. // AccessReviewEntry returns schema.AccessReviewEntryResolver implementation.
func (r *Resolver) AccessReviewEntry() schema.AccessReviewEntryResolver { func (r *Resolver) AccessReviewEntry() schema.AccessReviewEntryResolver {
return &accessReviewEntryResolver{r} return &accessReviewEntryResolver{r}
@@ -1046,6 +1094,7 @@ func (r *Resolver) AccessReviewSourceConnection() schema.AccessReviewSourceConne
type accessReviewCampaignResolver struct{ *Resolver } type accessReviewCampaignResolver struct{ *Resolver }
type accessReviewCampaignConnectionResolver struct{ *Resolver } type accessReviewCampaignConnectionResolver struct{ *Resolver }
type accessReviewCampaignSourceResolver struct{ *Resolver } type accessReviewCampaignSourceResolver struct{ *Resolver }
type accessReviewCampaignSourceFetchAttemptConnectionResolver struct{ *Resolver }
type accessReviewEntryResolver struct{ *Resolver } type accessReviewEntryResolver struct{ *Resolver }
type accessReviewEntryConnectionResolver struct{ *Resolver } type accessReviewEntryConnectionResolver struct{ *Resolver }
type accessReviewSourceResolver struct{ *Resolver } type accessReviewSourceResolver struct{ *Resolver }

View File

@@ -394,6 +394,16 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
return types.NewAccessReviewCampaign(campaign), nil return types.NewAccessReviewCampaign(campaign), nil
} }
case coredata.AccessReviewCampaignSourceEntityType:
action = accessreview.ActionCampaignGet
loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) {
campaignSource, err := r.accessReview.GetCampaignSource(ctx, scope, id)
if err != nil {
return nil, err
}
return types.NewAccessReviewCampaignSource(campaignSource), nil
}
case coredata.AccessReviewSourceEntityType: case coredata.AccessReviewSourceEntityType:
action = accessreview.ActionSourceGet action = accessreview.ActionSourceGet
loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) { loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) {

View File

@@ -24,28 +24,6 @@ enum AccessReviewCampaignStatus
) )
} }
enum AccessReviewSourceCategory
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategory"
) {
SAAS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategorySaaS"
)
CLOUD_INFRA
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategoryCloudInfra"
)
SOURCE_CODE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategorySourceCode"
)
OTHER
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategoryOther"
)
}
enum AccessReviewCampaignSourceFetchStatus enum AccessReviewCampaignSourceFetchStatus
@goModel( @goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignSourceFetchStatus" model: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignSourceFetchStatus"
@@ -251,6 +229,21 @@ enum AccessReviewEntryOrderField
CREATED_AT CREATED_AT
} }
enum AccessReviewCampaignSourceFetchAttemptOrderField
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignSourceFetchAttemptOrderField"
) {
CREATED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignSourceFetchAttemptOrderFieldCreatedAt"
)
}
input AccessReviewCampaignSourceFetchAttemptOrder {
direction: OrderDirection!
field: AccessReviewCampaignSourceFetchAttemptOrderField!
}
enum AccessReviewSourceConnectionStatus { enum AccessReviewSourceConnectionStatus {
CONNECTED CONNECTED
DISCONNECTED DISCONNECTED
@@ -307,10 +300,7 @@ type AccessReviewSource implements Node {
permission(action: String!): Boolean! @goField(forceResolver: true) permission(action: String!): Boolean! @goField(forceResolver: true)
} }
type AccessReviewCampaignSourceFetchAttempt type AccessReviewCampaignSourceFetchAttempt {
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewCampaignSourceFetchAttempt"
) {
id: ID! id: ID!
attemptNumber: Int! attemptNumber: Int!
status: AccessReviewCampaignSourceFetchStatus! status: AccessReviewCampaignSourceFetchStatus!
@@ -322,25 +312,22 @@ type AccessReviewCampaignSourceFetchAttempt
updatedAt: Datetime! updatedAt: Datetime!
} }
type AccessReviewCampaignSource type AccessReviewCampaignSource implements Node {
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewCampaignSource"
) {
id: ID! id: ID!
campaign: AccessReviewCampaign! @goField(forceResolver: true)
sourceId: ID
"The live access source this snapshot was taken from. Null once the source is deleted." "The live access source this snapshot was taken from. Null once the source is deleted."
source: AccessReviewSource @goField(forceResolver: true) source: AccessReviewSource @goField(forceResolver: true)
name: String! name: String!
"Current fetch status, derived from the latest fetch attempt."
fetchStatus: AccessReviewCampaignSourceFetchStatus!
fetchedAccountsCount: Int!
attemptCount: Int!
"Generic, user-facing error from the latest attempt. Raw errors stay in logs."
lastError: String
fetchStartedAt: Datetime
fetchCompletedAt: Datetime
"Full append-only history of fetch attempts, most recent first." fetchAttempts(
fetchAttempts: [AccessReviewCampaignSourceFetchAttempt!]! @goField(forceResolver: true) first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: AccessReviewCampaignSourceFetchAttemptOrder
): AccessReviewCampaignSourceFetchAttemptConnection!
@goField(forceResolver: true)
entries( entries(
first: Int first: Int
@@ -374,7 +361,6 @@ type AccessReviewCampaign implements Node {
last: Int last: Int
before: CursorKey before: CursorKey
orderBy: AccessReviewEntryOrder orderBy: AccessReviewEntryOrder
campaignSourceId: ID
filter: AccessReviewEntryFilter filter: AccessReviewEntryFilter
): AccessReviewEntryConnection! @goField(forceResolver: true) ): AccessReviewEntryConnection! @goField(forceResolver: true)
@@ -388,8 +374,7 @@ type AccessReviewCampaign implements Node {
type AccessReviewEntry implements Node { type AccessReviewEntry implements Node {
id: ID! id: ID!
campaign: AccessReviewCampaign! @goField(forceResolver: true) campaign: AccessReviewCampaign! @goField(forceResolver: true)
"The live access source this entry came from. Null once the source is deleted." campaignSource: AccessReviewCampaignSource! @goField(forceResolver: true)
accessReviewSource: AccessReviewSource @goField(forceResolver: true)
email: String! email: String!
fullName: String! fullName: String!
role: String! role: String!
@@ -468,6 +453,20 @@ type AccessReviewEntryEdge {
node: AccessReviewEntry! node: AccessReviewEntry!
} }
type AccessReviewCampaignSourceFetchAttemptConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewCampaignSourceFetchAttemptConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [AccessReviewCampaignSourceFetchAttemptEdge!]!
pageInfo: PageInfo!
}
type AccessReviewCampaignSourceFetchAttemptEdge {
cursor: CursorKey!
node: AccessReviewCampaignSourceFetchAttempt!
}
type AccessReviewStatistics { type AccessReviewStatistics {
totalCount: Int! totalCount: Int!
decisionCounts: [AccessReviewEntryDecisionCount!]! decisionCounts: [AccessReviewEntryDecisionCount!]!

View File

@@ -15,17 +15,16 @@
package types package types
import ( import (
"time"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/page"
) )
type ( type (
AccessReviewSourceOrderBy OrderBy[coredata.AccessReviewSourceOrderField] AccessReviewSourceOrderBy OrderBy[coredata.AccessReviewSourceOrderField]
AccessReviewCampaignOrderBy OrderBy[coredata.AccessReviewCampaignOrderField] AccessReviewCampaignOrderBy OrderBy[coredata.AccessReviewCampaignOrderField]
AccessReviewEntryOrderBy OrderBy[coredata.AccessReviewEntryOrderField] AccessReviewEntryOrderBy OrderBy[coredata.AccessReviewEntryOrderField]
AccessReviewCampaignSourceFetchAttemptOrderBy OrderBy[coredata.AccessReviewCampaignSourceFetchAttemptOrderField]
AccessReviewSourceConnection struct { AccessReviewSourceConnection struct {
TotalCount int TotalCount int
@@ -55,6 +54,15 @@ type (
SourceID *gid.GID SourceID *gid.GID
Filter *coredata.AccessReviewEntryFilter Filter *coredata.AccessReviewEntryFilter
} }
AccessReviewCampaignSourceFetchAttemptConnection struct {
TotalCount int
Edges []*AccessReviewCampaignSourceFetchAttemptEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
}
) )
// AccessReviewSource helpers // AccessReviewSource helpers
@@ -100,53 +108,37 @@ func NewAccessReviewSource(s *coredata.AccessReviewSource) *AccessReviewSource {
} }
} }
// NewAccessReviewCampaignSource builds the GraphQL scope source from a // NewAccessReviewCampaignSource builds the GraphQL campaign source from a
// campaign source snapshot. The current fetch state is derived from the latest // snapshot row. Fetch state is resolved lazily via field resolvers on
// fetch attempt (nil when the source has never been fetched). The live access // AccessReviewCampaignSource. The live access source is resolved via the
// source is resolved lazily via the source field resolver from SourceID. // source field resolver from SourceID.
func NewAccessReviewCampaignSource( func NewAccessReviewCampaignSource(
campaignSource *coredata.AccessReviewCampaignSource, campaignSource *coredata.AccessReviewCampaignSource,
latestAttempt *coredata.AccessReviewCampaignSourceFetchAttempt,
) *AccessReviewCampaignSource { ) *AccessReviewCampaignSource {
status := coredata.AccessReviewCampaignSourceFetchStatusQueued
fetchedAccountsCount := 0
attemptCount := 0
var (
lastError *string
fetchStartedAt *time.Time
fetchCompletedAt *time.Time
)
if latestAttempt != nil {
status = latestAttempt.Status
fetchedAccountsCount = latestAttempt.FetchedAccountsCount
attemptCount = latestAttempt.AttemptNumber
lastError = latestAttempt.Error
fetchStartedAt = latestAttempt.StartedAt
fetchCompletedAt = latestAttempt.CompletedAt
}
return &AccessReviewCampaignSource{ return &AccessReviewCampaignSource{
ID: campaignSource.ID, ID: campaignSource.ID,
CampaignID: campaignSource.AccessReviewCampaignID, Campaign: &AccessReviewCampaign{
SourceID: campaignSource.AccessReviewSourceID, ID: campaignSource.AccessReviewCampaignID,
Name: campaignSource.Name, },
FetchStatus: status, SourceID: campaignSource.AccessReviewSourceID,
FetchedAccountsCount: fetchedAccountsCount, Name: campaignSource.Name,
AttemptCount: attemptCount,
LastError: lastError,
FetchStartedAt: fetchStartedAt,
FetchCompletedAt: fetchCompletedAt,
} }
} }
// NewAccessReviewCampaignSourceFetchAttempt builds the GraphQL representation of a // NewAccessReviewCampaignSourceFetchAttempt builds the GraphQL representation of a
// single append-only fetch attempt. // single append-only fetch attempt. attemptNumber is the 1-based position in the
func NewAccessReviewCampaignSourceFetchAttempt(a *coredata.AccessReviewCampaignSourceFetchAttempt) *AccessReviewCampaignSourceFetchAttempt { // snapshot's history, counting up from the oldest attempt.
func NewAccessReviewCampaignSourceFetchAttempt(
a *coredata.AccessReviewCampaignSourceFetchAttempt,
attemptNumber int,
) *AccessReviewCampaignSourceFetchAttempt {
if a.AttemptNumber > 0 {
attemptNumber = a.AttemptNumber
}
return &AccessReviewCampaignSourceFetchAttempt{ return &AccessReviewCampaignSourceFetchAttempt{
ID: a.ID, ID: a.ID,
AttemptNumber: a.AttemptNumber, AttemptNumber: attemptNumber,
Status: a.Status, Status: a.Status,
FetchedAccountsCount: a.FetchedAccountsCount, FetchedAccountsCount: a.FetchedAccountsCount,
Error: a.Error, Error: a.Error,
@@ -157,6 +149,37 @@ func NewAccessReviewCampaignSourceFetchAttempt(a *coredata.AccessReviewCampaignS
} }
} }
func NewAccessReviewCampaignSourceFetchAttemptConnection(
p *page.Page[*coredata.AccessReviewCampaignSourceFetchAttempt, coredata.AccessReviewCampaignSourceFetchAttemptOrderField],
parentType any,
parentID gid.GID,
) *AccessReviewCampaignSourceFetchAttemptConnection {
edges := make([]*AccessReviewCampaignSourceFetchAttemptEdge, len(p.Data))
for i := range edges {
edges[i] = NewAccessReviewCampaignSourceFetchAttemptEdge(p.Data[i], p.Cursor.OrderBy.Field, 0)
}
return &AccessReviewCampaignSourceFetchAttemptConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: parentType,
ParentID: parentID,
}
}
func NewAccessReviewCampaignSourceFetchAttemptEdge(
a *coredata.AccessReviewCampaignSourceFetchAttempt,
orderBy coredata.AccessReviewCampaignSourceFetchAttemptOrderField,
attemptNumber int,
) *AccessReviewCampaignSourceFetchAttemptEdge {
return &AccessReviewCampaignSourceFetchAttemptEdge{
Cursor: a.CursorKey(orderBy),
Node: NewAccessReviewCampaignSourceFetchAttempt(a, attemptNumber),
}
}
// AccessReviewCampaign helpers // AccessReviewCampaign helpers
func NewAccessReviewCampaignConnection( func NewAccessReviewCampaignConnection(
@@ -260,6 +283,9 @@ func NewAccessReviewEntry(e *coredata.AccessReviewEntry) *AccessReviewEntry {
Campaign: &AccessReviewCampaign{ Campaign: &AccessReviewCampaign{
ID: e.AccessReviewCampaignID, ID: e.AccessReviewCampaignID,
}, },
CampaignSource: &AccessReviewCampaignSource{
ID: e.AccessReviewCampaignSourceID,
},
Email: e.Email, Email: e.Email,
FullName: e.FullName, FullName: e.FullName,
Role: e.Role, Role: e.Role,

View File

@@ -1,49 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
type AccessReviewCampaignSource struct {
ID gid.GID `json:"id"`
CampaignID gid.GID `json:"-"`
// SourceID is the live access source this snapshot points at, or nil once
// that source has been deleted. The source field is resolved lazily from it.
SourceID *gid.GID `json:"-"`
Name string `json:"name"`
FetchStatus coredata.AccessReviewCampaignSourceFetchStatus `json:"fetchStatus"`
FetchedAccountsCount int `json:"fetchedAccountsCount"`
AttemptCount int `json:"attemptCount"`
LastError *string `json:"lastError,omitempty"`
FetchStartedAt *time.Time `json:"fetchStartedAt,omitempty"`
FetchCompletedAt *time.Time `json:"fetchCompletedAt,omitempty"`
}
type AccessReviewCampaignSourceFetchAttempt struct {
ID gid.GID `json:"id"`
AttemptNumber int `json:"attemptNumber"`
Status coredata.AccessReviewCampaignSourceFetchStatus `json:"status"`
FetchedAccountsCount int `json:"fetchedAccountsCount"`
Error *string `json:"error,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

View File

@@ -16,7 +16,6 @@ package types
import ( import (
"testing" "testing"
"time"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/gid"
@@ -29,28 +28,23 @@ func newTestCampaignSource(tenantID gid.TenantID, sourceID *gid.GID, name string
AccessReviewCampaignID: gid.New(tenantID, coredata.AccessReviewCampaignEntityType), AccessReviewCampaignID: gid.New(tenantID, coredata.AccessReviewCampaignEntityType),
AccessReviewSourceID: sourceID, AccessReviewSourceID: sourceID,
Name: name, Name: name,
Category: coredata.AccessReviewSourceCategorySaaS,
} }
} }
func TestNewAccessReviewCampaignSource_DefaultFetchState(t *testing.T) { func TestNewAccessReviewCampaignSource(t *testing.T) {
t.Parallel() t.Parallel()
tenantID := gid.NewTenantID() tenantID := gid.NewTenantID()
sourceID := gid.New(tenantID, coredata.AccessReviewSourceEntityType) sourceID := gid.New(tenantID, coredata.AccessReviewSourceEntityType)
campaignSource := newTestCampaignSource(tenantID, &sourceID, "Google Workspace") campaignSource := newTestCampaignSource(tenantID, &sourceID, "Google Workspace")
got := NewAccessReviewCampaignSource(campaignSource, nil) got := NewAccessReviewCampaignSource(campaignSource)
if got.FetchStatus != coredata.AccessReviewCampaignSourceFetchStatusQueued { if got.ID != campaignSource.ID {
t.Fatalf("fetch status = %q, want QUEUED", got.FetchStatus) t.Fatalf("id = %v, want %v", got.ID, campaignSource.ID)
} }
if got.FetchedAccountsCount != 0 { if got.Campaign == nil || got.Campaign.ID != campaignSource.AccessReviewCampaignID {
t.Fatalf("fetched accounts count = %d, want 0", got.FetchedAccountsCount) t.Fatalf("campaign id = %v, want %v", got.Campaign, campaignSource.AccessReviewCampaignID)
}
if got.AttemptCount != 0 {
t.Fatalf("attempt count = %d, want 0", got.AttemptCount)
} }
if got.SourceID == nil || *got.SourceID != sourceID { if got.SourceID == nil || *got.SourceID != sourceID {
@@ -62,41 +56,6 @@ func TestNewAccessReviewCampaignSource_DefaultFetchState(t *testing.T) {
} }
} }
func TestNewAccessReviewCampaignSource_UsesLatestAttempt(t *testing.T) {
t.Parallel()
now := time.Now()
errMsg := "We couldn't fetch accounts from this source."
tenantID := gid.NewTenantID()
sourceID := gid.New(tenantID, coredata.AccessReviewSourceEntityType)
campaignSource := newTestCampaignSource(tenantID, &sourceID, "Linear")
attempt := &coredata.AccessReviewCampaignSourceFetchAttempt{
Status: coredata.AccessReviewCampaignSourceFetchStatusFailed,
FetchedAccountsCount: 42,
AttemptNumber: 3,
Error: &errMsg,
StartedAt: &now,
CompletedAt: &now,
}
got := NewAccessReviewCampaignSource(campaignSource, attempt)
if got.FetchStatus != coredata.AccessReviewCampaignSourceFetchStatusFailed {
t.Fatalf("fetch status = %q, want FAILED", got.FetchStatus)
}
if got.FetchedAccountsCount != 42 {
t.Fatalf("fetched accounts count = %d, want 42", got.FetchedAccountsCount)
}
if got.AttemptCount != 3 {
t.Fatalf("attempt count = %d, want 3", got.AttemptCount)
}
if got.LastError == nil || *got.LastError != errMsg {
t.Fatalf("last error = %v, want %q", got.LastError, errMsg)
}
}
// TestNewAccessReviewCampaignSource_DeletedSource verifies a snapshot whose // TestNewAccessReviewCampaignSource_DeletedSource verifies a snapshot whose
// live source has been deleted still renders (with a nil source link) so the // live source has been deleted still renders (with a nil source link) so the
// historical review data remains visible. // historical review data remains visible.
@@ -106,7 +65,7 @@ func TestNewAccessReviewCampaignSource_DeletedSource(t *testing.T) {
tenantID := gid.NewTenantID() tenantID := gid.NewTenantID()
campaignSource := newTestCampaignSource(tenantID, nil, "Deleted Source") campaignSource := newTestCampaignSource(tenantID, nil, "Deleted Source")
got := NewAccessReviewCampaignSource(campaignSource, nil) got := NewAccessReviewCampaignSource(campaignSource)
if got.SourceID != nil { if got.SourceID != nil {
t.Fatalf("source id = %v, want nil for deleted source", got.SourceID) t.Fatalf("source id = %v, want nil for deleted source", got.SourceID)
} }

View File

@@ -3410,11 +3410,39 @@ func (r *Resolver) ListAccessReviewCampaignsTool(ctx context.Context, req *mcp.C
} }
// ListAccessEntriesTool handles the listAccessEntries tool // ListAccessEntriesTool handles the listAccessEntries tool
// List access entries for a campaign with optional filters // List access entries for a campaign or campaign source with optional filters
func (r *Resolver) ListAccessEntriesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListAccessEntriesInput) (*mcp.CallToolResult, types.ListAccessEntriesOutput, error) { func (r *Resolver) ListAccessEntriesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListAccessEntriesInput) (*mcp.CallToolResult, types.ListAccessEntriesOutput, error) {
scope, err := r.Authorize(ctx, input.CampaignID, accessreview.ActionEntryList) if input.AccessReviewCampaignSourceID == nil && input.CampaignID == nil {
if err != nil { return nil, types.ListAccessEntriesOutput{}, fmt.Errorf("campaign_id or access_review_campaign_source_id is required")
return nil, types.ListAccessEntriesOutput{}, err }
var (
scope *coredata.Scope
campaignID gid.GID
sourceID *gid.GID
err error
)
if input.AccessReviewCampaignSourceID != nil {
scope, err = r.Authorize(ctx, *input.AccessReviewCampaignSourceID, accessreview.ActionEntryList)
if err != nil {
return nil, types.ListAccessEntriesOutput{}, err
}
campaignSource, err := r.accessReview.GetCampaignSource(ctx, scope, *input.AccessReviewCampaignSourceID)
if err != nil {
panic(fmt.Errorf("cannot get campaign source: %w", err))
}
campaignID = campaignSource.AccessReviewCampaignID
sourceID = input.AccessReviewCampaignSourceID
} else {
scope, err = r.Authorize(ctx, *input.CampaignID, accessreview.ActionEntryList)
if err != nil {
return nil, types.ListAccessEntriesOutput{}, err
}
campaignID = *input.CampaignID
} }
pageOrderBy := page.OrderBy[coredata.AccessReviewEntryOrderField]{ pageOrderBy := page.OrderBy[coredata.AccessReviewEntryOrderField]{
@@ -3446,14 +3474,12 @@ func (r *Resolver) ListAccessEntriesTool(ctx context.Context, req *mcp.CallToolR
var p *page.Page[*coredata.AccessReviewEntry, coredata.AccessReviewEntryOrderField] var p *page.Page[*coredata.AccessReviewEntry, coredata.AccessReviewEntryOrderField]
if input.AccessReviewCampaignSourceID != nil { if sourceID != nil {
var err error
p, err = r.accessReview.ListEntriesForCampaignIDAndSourceID( p, err = r.accessReview.ListEntriesForCampaignIDAndSourceID(
ctx, ctx,
scope, scope,
input.CampaignID, campaignID,
*input.AccessReviewCampaignSourceID, *sourceID,
cursor, cursor,
filter, filter,
) )
@@ -3461,9 +3487,7 @@ func (r *Resolver) ListAccessEntriesTool(ctx context.Context, req *mcp.CallToolR
panic(fmt.Errorf("cannot list access entries: %w", err)) panic(fmt.Errorf("cannot list access entries: %w", err))
} }
} else { } else {
var err error p, err = r.accessReview.ListEntriesForCampaignID(ctx, scope, campaignID, cursor, filter)
p, err = r.accessReview.ListEntriesForCampaignID(ctx, scope, input.CampaignID, cursor, filter)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot list access entries: %w", err)) panic(fmt.Errorf("cannot list access entries: %w", err))
} }
@@ -3498,26 +3522,16 @@ func (r *Resolver) RecordAccessReviewEntryDecisionTool(ctx context.Context, req
return nil, types.RecordAccessReviewEntryDecisionOutput{}, err return nil, types.RecordAccessReviewEntryDecisionOutput{}, err
} }
identity := authn.IdentityFromContext(ctx) entry, err := r.accessReview.RecordDecision(
if identity == nil { ctx,
return nil, types.RecordAccessReviewEntryDecisionOutput{}, fmt.Errorf("no identity in context") scope,
} accessreview.RecordAccessReviewEntryDecisionRequest{
EntryID: input.AccessReviewEntryID,
decisionReq := accessreview.RecordAccessReviewEntryDecisionRequest{ Decision: input.Decision,
EntryID: input.AccessReviewEntryID, DecisionNote: input.DecisionNote,
Decision: input.Decision, DecidedByID: authn.IdentityIDFromContext(ctx),
DecisionNote: input.DecisionNote, },
} )
organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, input.AccessReviewEntryID)
if err == nil {
profile, err := r.iamSvc.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, organizationID)
if err == nil {
decisionReq.DecidedByID = &profile.ID
}
}
entry, err := r.accessReview.RecordDecision(ctx, scope, decisionReq)
if err != nil { if err != nil {
return nil, types.RecordAccessReviewEntryDecisionOutput{}, fmt.Errorf("cannot record decision: %w", err) return nil, types.RecordAccessReviewEntryDecisionOutput{}, fmt.Errorf("cannot record decision: %w", err)
} }
@@ -3555,28 +3569,10 @@ func (r *Resolver) RecordAccessReviewEntryDecisionsTool(ctx context.Context, req
return nil, types.RecordAccessReviewEntryDecisionsOutput{}, fmt.Errorf("no identity in context") return nil, types.RecordAccessReviewEntryDecisionsOutput{}, fmt.Errorf("no identity in context")
} }
// Cache profile lookups per organization so we resolve the correct decidedByID := &identity.ID
// decidedByID for each entry even when a batch spans multiple orgs.
profileCache := make(map[gid.GID]*gid.GID)
decisions := make([]accessreview.RecordAccessReviewEntryDecisionRequest, len(input.Decisions)) decisions := make([]accessreview.RecordAccessReviewEntryDecisionRequest, len(input.Decisions))
for i, d := range input.Decisions { for i, d := range input.Decisions {
var decidedByID *gid.GID
organizationID, err := r.accessReview.ResolveEntryOrganizationID(ctx, d.AccessReviewEntryID)
if err == nil {
if cached, ok := profileCache[organizationID]; ok {
decidedByID = cached
} else {
profile, err := r.iamSvc.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, organizationID)
if err == nil {
decidedByID = &profile.ID
}
profileCache[organizationID] = decidedByID
}
}
decisions[i] = accessreview.RecordAccessReviewEntryDecisionRequest{ decisions[i] = accessreview.RecordAccessReviewEntryDecisionRequest{
EntryID: d.AccessReviewEntryID, EntryID: d.AccessReviewEntryID,
Decision: d.Decision, Decision: d.Decision,
@@ -3660,7 +3656,6 @@ func (r *Resolver) CreateAccessReviewSourceTool(ctx context.Context, req *mcp.Ca
OrganizationID: input.OrganizationID, OrganizationID: input.OrganizationID,
ConnectorID: input.ConnectorID, ConnectorID: input.ConnectorID,
Name: input.Name, Name: input.Name,
Category: coredata.AccessReviewSourceCategorySaaS,
CsvData: input.CsvData, CsvData: input.CsvData,
}) })
if err != nil { if err != nil {
@@ -3682,7 +3677,10 @@ func (r *Resolver) UpdateAccessReviewSourceTool(ctx context.Context, req *mcp.Ca
updateReq := accessreview.UpdateAccessReviewSourceRequest{ updateReq := accessreview.UpdateAccessReviewSourceRequest{
AccessReviewSourceID: input.AccessReviewSourceID, AccessReviewSourceID: input.AccessReviewSourceID,
Name: input.Name, }
if input.Name != nil {
updateReq.Name = &input.Name
} }
if rawConnectorID := UnwrapOmittable(input.ConnectorID); rawConnectorID != nil { if rawConnectorID := UnwrapOmittable(input.ConnectorID); rawConnectorID != nil {
@@ -3770,9 +3768,15 @@ func (r *Resolver) UpdateAccessReviewCampaignTool(ctx context.Context, req *mcp.
} }
updateReq := accessreview.UpdateAccessReviewCampaignRequest{ updateReq := accessreview.UpdateAccessReviewCampaignRequest{
CampaignID: input.CampaignID, CampaignID: input.CampaignID,
Name: input.Name, }
Description: input.Description,
if input.Name != nil {
updateReq.Name = &input.Name
}
if input.Description != nil {
updateReq.Description = &input.Description
} }
if rawControls := UnwrapOmittable(input.FrameworkControls); rawControls != nil { if rawControls := UnwrapOmittable(input.FrameworkControls); rawControls != nil {

View File

@@ -7851,15 +7851,13 @@ components:
ListAccessEntriesInput: ListAccessEntriesInput:
type: object type: object
required:
- campaign_id
properties: properties:
campaign_id: campaign_id:
$ref: "#/components/schemas/GID" $ref: "#/components/schemas/GID"
description: Campaign ID description: Campaign ID. Required when access_review_campaign_source_id is omitted.
access_review_campaign_source_id: access_review_campaign_source_id:
$ref: "#/components/schemas/GID" $ref: "#/components/schemas/GID"
description: Filter by per-campaign source snapshot ID description: Campaign source snapshot ID. When set, lists entries for that source only and campaign_id is ignored.
order_by: order_by:
$ref: "#/components/schemas/AccessReviewEntryOrderBy" $ref: "#/components/schemas/AccessReviewEntryOrderBy"
description: Order by description: Order by
@@ -8026,15 +8024,6 @@ components:
campaign: campaign:
$ref: "#/components/schemas/AccessReviewCampaign" $ref: "#/components/schemas/AccessReviewCampaign"
AccessReviewSourceCategory:
type: string
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategory
enum:
- SAAS
- CLOUD_INFRA
- SOURCE_CODE
- OTHER
AccessReviewSourceOrderField: AccessReviewSourceOrderField:
type: string type: string
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessReviewSourceOrderField go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessReviewSourceOrderField