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:
@@ -154,9 +154,15 @@ export const campaignDetailPageQuery = graphql`
|
||||
id
|
||||
}
|
||||
name
|
||||
fetchStatus
|
||||
fetchedAccountsCount
|
||||
lastError
|
||||
fetchAttempts(first: 1) {
|
||||
edges {
|
||||
node {
|
||||
status
|
||||
fetchedAccountsCount
|
||||
error
|
||||
}
|
||||
}
|
||||
}
|
||||
entries(first: 500) {
|
||||
edges {
|
||||
node {
|
||||
@@ -504,6 +510,10 @@ function CampaignSourceCard({ source, isPendingActions }: { source: CampaignSour
|
||||
|
||||
const entries = source.entries?.edges ?? [];
|
||||
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 decision = value as AccessReviewEntryDecision;
|
||||
@@ -639,22 +649,22 @@ function CampaignSourceCard({ source, isPendingActions }: { source: CampaignSour
|
||||
: <IconChevronRight className="size-4 text-txt-tertiary" />}
|
||||
<span className="font-medium">{source.name}</span>
|
||||
<Badge variant="neutral">
|
||||
{source.fetchedAccountsCount}
|
||||
{fetchedAccountsCount}
|
||||
{" "}
|
||||
{__("accounts")}
|
||||
</Badge>
|
||||
<Badge variant={fetchStatusBadgeVariant(source.fetchStatus)}>
|
||||
{formatStatus(source.fetchStatus)}
|
||||
<Badge variant={fetchStatusBadgeVariant(fetchStatus)}>
|
||||
{formatStatus(fetchStatus)}
|
||||
</Badge>
|
||||
</div>
|
||||
</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">
|
||||
<IconWarning className="mt-0.5 size-4 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{__("Fetch failed")}</p>
|
||||
<p>{source.lastError}</p>
|
||||
<p>{lastError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -41,8 +41,15 @@ const addScopeMutation = graphql`
|
||||
sources {
|
||||
id
|
||||
name
|
||||
fetchStatus
|
||||
fetchedAccountsCount
|
||||
fetchAttempts(first: 1) {
|
||||
edges {
|
||||
node {
|
||||
status
|
||||
fetchedAccountsCount
|
||||
error
|
||||
}
|
||||
}
|
||||
}
|
||||
entries(first: 50) {
|
||||
edges {
|
||||
node {
|
||||
|
||||
@@ -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) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
@@ -152,12 +152,12 @@ func (s *Service) UpdateCampaign(
|
||||
return fmt.Errorf("cannot update campaign: status is %s, expected DRAFT", campaign.Status)
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
campaign.Name = *req.Name
|
||||
if req.Name != nil && *req.Name != nil {
|
||||
campaign.Name = **req.Name
|
||||
}
|
||||
|
||||
if req.Description != nil {
|
||||
campaign.Description = *req.Description
|
||||
if req.Description != nil && *req.Description != nil {
|
||||
campaign.Description = **req.Description
|
||||
}
|
||||
|
||||
if req.FrameworkControls != nil {
|
||||
@@ -425,7 +425,6 @@ func (s *Service) upsertCampaignSource(
|
||||
AccessReviewCampaignID: campaignID,
|
||||
AccessReviewSourceID: &sourceID,
|
||||
Name: source.Name,
|
||||
Category: source.Category,
|
||||
ConnectorID: source.ConnectorID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -553,41 +552,18 @@ func (s *Service) ListCampaignSources(
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListLatestFetchAttempts(
|
||||
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(
|
||||
func (s *Service) ListFetchAttemptsForCampaignSourceID(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
campaignSourceID gid.GID,
|
||||
) (coredata.AccessReviewCampaignSourceFetchAttempts, error) {
|
||||
cursor *page.Cursor[coredata.AccessReviewCampaignSourceFetchAttemptOrderField],
|
||||
) (*page.Page[*coredata.AccessReviewCampaignSourceFetchAttempt, coredata.AccessReviewCampaignSourceFetchAttemptOrderField], error) {
|
||||
var attempts coredata.AccessReviewCampaignSourceFetchAttempts
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -598,7 +574,35 @@ func (s *Service) ListFetchAttempts(
|
||||
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(
|
||||
|
||||
@@ -33,8 +33,8 @@ type (
|
||||
|
||||
UpdateAccessReviewCampaignRequest struct {
|
||||
CampaignID gid.GID
|
||||
Name *string
|
||||
Description *string
|
||||
Name **string
|
||||
Description **string
|
||||
FrameworkControls *[]string
|
||||
}
|
||||
|
||||
|
||||
@@ -51,11 +51,15 @@ func (s *Service) GetEntry(
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
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 {
|
||||
return nil, fmt.Errorf("cannot get access entry: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
@@ -66,86 +70,12 @@ func (s *Service) RecordDecision(
|
||||
scope coredata.Scoper,
|
||||
req RecordAccessReviewEntryDecisionRequest,
|
||||
) (*coredata.AccessReviewEntry, error) {
|
||||
if req.Decision == coredata.AccessReviewEntryDecisionPending {
|
||||
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
|
||||
},
|
||||
)
|
||||
entries, err := s.RecordDecisions(ctx, scope, []RecordAccessReviewEntryDecisionRequest{req})
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot reload access entry after decision: %w", err)
|
||||
}
|
||||
|
||||
return updatedEntry, nil
|
||||
return entries[0], nil
|
||||
}
|
||||
|
||||
func (s *Service) RecordDecisions(
|
||||
@@ -179,6 +109,7 @@ func (s *Service) RecordDecisions(
|
||||
// Track verified campaigns to avoid repeated loads within the
|
||||
// same transaction.
|
||||
verifiedCampaigns := make(map[gid.GID]bool)
|
||||
decidedByCache := make(map[gid.GID]*gid.GID)
|
||||
|
||||
for _, d := range decisions {
|
||||
entry := &coredata.AccessReviewEntry{}
|
||||
@@ -199,10 +130,23 @@ func (s *Service) RecordDecisions(
|
||||
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()
|
||||
entry.Decision = d.Decision
|
||||
entry.DecisionNote = d.DecisionNote
|
||||
entry.DecidedBy = d.DecidedByID
|
||||
entry.DecidedBy = decidedByID
|
||||
entry.DecidedAt = &now
|
||||
|
||||
entry.UpdatedAt = now
|
||||
@@ -279,11 +223,9 @@ func (s *Service) FlagEntry(
|
||||
}
|
||||
|
||||
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
|
||||
if entry.Flags == nil {
|
||||
entry.Flags = []coredata.AccessReviewEntryFlag{}
|
||||
@@ -294,16 +236,20 @@ func (s *Service) FlagEntry(
|
||||
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 {
|
||||
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(
|
||||
@@ -318,11 +264,22 @@ func (s *Service) ListEntriesForCampaignID(
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
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 {
|
||||
return nil, fmt.Errorf("cannot list access entries: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return page.NewPage(entries, cursor), nil
|
||||
@@ -341,11 +298,23 @@ func (s *Service) ListEntriesForCampaignIDAndSourceID(
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
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 {
|
||||
return nil, fmt.Errorf("cannot list access entries: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return page.NewPage(entries, cursor), nil
|
||||
@@ -373,7 +342,7 @@ func (s *Service) CountEntriesForCampaignID(
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count access entries: %w", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
@@ -402,7 +371,7 @@ func (s *Service) CountEntriesForCampaignIDAndSourceID(
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count access entries: %w", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
@@ -429,7 +398,7 @@ func (s *Service) CountPendingEntriesForCampaignID(
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count pending access entries: %w", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
@@ -465,7 +434,11 @@ func (s *Service) CampaignStatistics(
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
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 {
|
||||
@@ -486,11 +459,15 @@ func (s *Service) CampaignSourceStatistics(
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
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 {
|
||||
return nil, fmt.Errorf("cannot load source statistics: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
|
||||
@@ -16,7 +16,6 @@ package accessreview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
@@ -26,7 +25,6 @@ import (
|
||||
"go.probo.inc/probo/pkg/connector/provider"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/crypto/cipher"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
@@ -102,34 +100,6 @@ func NewService(
|
||||
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 {
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
const decideMutation = `
|
||||
mutation($input: RecordAccessReviewEntryDecisionInput!) {
|
||||
recordAccessReviewEntryDecision(input: $input) {
|
||||
accessEntry {
|
||||
accessReviewEntry {
|
||||
id
|
||||
email
|
||||
fullName
|
||||
@@ -47,7 +47,7 @@ type decideResponse struct {
|
||||
Decision string `json:"decision"`
|
||||
DecisionNote *string `json:"decisionNote"`
|
||||
DecidedAt *string `json:"decidedAt"`
|
||||
} `json:"accessEntry"`
|
||||
} `json:"accessReviewEntry"`
|
||||
} `json:"recordAccessReviewEntryDecision"`
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
const decideAllMutation = `
|
||||
mutation($input: RecordAccessReviewEntryDecisionsInput!) {
|
||||
recordAccessReviewEntryDecisions(input: $input) {
|
||||
accessEntries {
|
||||
accessReviewEntries {
|
||||
id
|
||||
email
|
||||
decision
|
||||
@@ -41,7 +41,7 @@ type decideAllResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Decision string `json:"decision"`
|
||||
} `json:"accessEntries"`
|
||||
} `json:"accessReviewEntries"`
|
||||
} `json:"recordAccessReviewEntryDecisions"`
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ query(
|
||||
$first: Int,
|
||||
$after: CursorKey,
|
||||
$orderBy: AccessReviewEntryOrder,
|
||||
$campaignSourceId: ID,
|
||||
$filter: AccessReviewEntryFilter
|
||||
) {
|
||||
node(id: $id) {
|
||||
@@ -40,7 +39,6 @@ query(
|
||||
first: $first,
|
||||
after: $after,
|
||||
orderBy: $orderBy,
|
||||
campaignSourceId: $campaignSourceId,
|
||||
filter: $filter
|
||||
) {
|
||||
totalCount
|
||||
@@ -63,8 +61,60 @@ query(
|
||||
flagReasons
|
||||
decision
|
||||
decisionNote
|
||||
accessSource {
|
||||
id
|
||||
campaignSource {
|
||||
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
|
||||
}
|
||||
createdAt
|
||||
@@ -81,27 +131,26 @@ query(
|
||||
`
|
||||
|
||||
type entryNode struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"fullName"`
|
||||
Role string `json:"role"`
|
||||
JobTitle string `json:"jobTitle"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Active *bool `json:"active"`
|
||||
MfaStatus string `json:"mfaStatus"`
|
||||
AuthMethod string `json:"authMethod"`
|
||||
AccountType string `json:"accountType"`
|
||||
LastLogin *string `json:"lastLogin"`
|
||||
ExternalID string `json:"externalId"`
|
||||
IncrementalTag string `json:"incrementalTag"`
|
||||
Flags []string `json:"flags"`
|
||||
FlagReasons []string `json:"flagReasons"`
|
||||
Decision string `json:"decision"`
|
||||
DecisionNote *string `json:"decisionNote"`
|
||||
AccessReviewSource struct {
|
||||
ID string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"fullName"`
|
||||
Role string `json:"role"`
|
||||
JobTitle string `json:"jobTitle"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Active *bool `json:"active"`
|
||||
MfaStatus string `json:"mfaStatus"`
|
||||
AuthMethod string `json:"authMethod"`
|
||||
AccountType string `json:"accountType"`
|
||||
LastLogin *string `json:"lastLogin"`
|
||||
ExternalID string `json:"externalId"`
|
||||
IncrementalTag string `json:"incrementalTag"`
|
||||
Flags []string `json:"flags"`
|
||||
FlagReasons []string `json:"flagReasons"`
|
||||
Decision string `json:"decision"`
|
||||
DecisionNote *string `json:"decisionNote"`
|
||||
CampaignSource struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"accessSource"`
|
||||
} `json:"campaignSource"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -122,14 +171,14 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list <campaign-id>",
|
||||
Short: "List access entries for a campaign",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "list [<campaign-id>]",
|
||||
Short: "List access entries for a campaign or campaign source",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Example: ` # List all entries for a campaign
|
||||
prb access-review entry list <campaign-id>
|
||||
|
||||
# List entries for a specific source
|
||||
prb access-review entry list <campaign-id> --campaign-source-id <source-id>
|
||||
# List entries for a specific campaign source
|
||||
prb access-review entry list --source-id <campaign-source-id>
|
||||
|
||||
# List only pending entries
|
||||
prb access-review entry list <campaign-id> --decision PENDING
|
||||
@@ -141,6 +190,10 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
if flagCampaignSourceID == "" && len(args) == 0 {
|
||||
return fmt.Errorf("campaign ID is required when --source-id is not set")
|
||||
}
|
||||
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -159,8 +212,14 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
cmdutil.TokenRefreshOption(cfg, host, hc),
|
||||
)
|
||||
|
||||
variables := map[string]any{
|
||||
"id": args[0],
|
||||
variables := map[string]any{}
|
||||
|
||||
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 {
|
||||
@@ -178,10 +237,6 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
if flagCampaignSourceID != "" {
|
||||
variables["campaignSourceId"] = flagCampaignSourceID
|
||||
}
|
||||
|
||||
filter := map[string]any{}
|
||||
|
||||
if flagDecision != "" {
|
||||
@@ -261,9 +316,19 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
variables["filter"] = filter
|
||||
}
|
||||
|
||||
query := listQuery
|
||||
expectedTypename := "AccessReviewCampaign"
|
||||
notFoundLabel := "campaign"
|
||||
|
||||
if flagCampaignSourceID != "" {
|
||||
query = listBySourceQuery
|
||||
expectedTypename = "AccessReviewCampaignSource"
|
||||
notFoundLabel = "campaign source"
|
||||
}
|
||||
|
||||
entries, totalCount, err := api.Paginate(
|
||||
client,
|
||||
listQuery,
|
||||
query,
|
||||
variables,
|
||||
flagLimit,
|
||||
func(data json.RawMessage) (*api.Connection[entryNode], error) {
|
||||
@@ -278,11 +343,11 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
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" {
|
||||
return nil, fmt.Errorf("expected AccessReviewCampaign node, got %s", resp.Node.Typename)
|
||||
if resp.Node.Typename != expectedTypename {
|
||||
return nil, fmt.Errorf("expected %s node, got %s", expectedTypename, resp.Node.Typename)
|
||||
}
|
||||
|
||||
return &resp.Node.Entries, nil
|
||||
@@ -326,7 +391,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
e.ID,
|
||||
e.Email,
|
||||
e.FullName,
|
||||
e.AccessReviewSource.Name,
|
||||
e.CampaignSource.Name,
|
||||
e.Decision,
|
||||
strings.Join(e.Flags, ","),
|
||||
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().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT)")
|
||||
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(&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)")
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
const flagMutation = `
|
||||
mutation($input: FlagAccessReviewEntryInput!) {
|
||||
flagAccessReviewEntry(input: $input) {
|
||||
accessEntry {
|
||||
accessReviewEntry {
|
||||
id
|
||||
email
|
||||
fullName
|
||||
@@ -48,7 +48,7 @@ type flagResponse struct {
|
||||
Flags []string `json:"flags"`
|
||||
FlagReasons []string `json:"flagReasons"`
|
||||
Decision string `json:"decision"`
|
||||
} `json:"accessEntry"`
|
||||
} `json:"accessReviewEntry"`
|
||||
} `json:"flagAccessReviewEntry"`
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
const createMutation = `
|
||||
mutation($input: CreateAccessReviewSourceInput!) {
|
||||
createAccessReviewSource(input: $input) {
|
||||
accessSourceEdge {
|
||||
accessReviewSourceEdge {
|
||||
node {
|
||||
id
|
||||
name
|
||||
@@ -44,7 +44,7 @@ type createResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"node"`
|
||||
} `json:"accessSourceEdge"`
|
||||
} `json:"accessReviewSourceEdge"`
|
||||
} `json:"createAccessReviewSource"`
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
const updateMutation = `
|
||||
mutation($input: UpdateAccessReviewSourceInput!) {
|
||||
updateAccessReviewSource(input: $input) {
|
||||
accessSource {
|
||||
accessReviewSource {
|
||||
id
|
||||
name
|
||||
}
|
||||
@@ -40,7 +40,7 @@ type updateResponse struct {
|
||||
AccessReviewSource struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"accessSource"`
|
||||
} `json:"accessReviewSource"`
|
||||
} `json:"updateAccessReviewSource"`
|
||||
}
|
||||
|
||||
|
||||
@@ -24,13 +24,14 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
// AccessReviewCampaignSourceFetchAttempt is a single, append-only fetch run for a
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// TenantID is retained on the struct because the background worker claims
|
||||
@@ -40,7 +41,6 @@ type (
|
||||
ID gid.GID `db:"id"`
|
||||
TenantID gid.TenantID `db:"tenant_id"`
|
||||
AccessReviewCampaignSourceID gid.GID `db:"access_review_campaign_source_id"`
|
||||
AttemptNumber int `db:"attempt_number"`
|
||||
Status AccessReviewCampaignSourceFetchStatus `db:"status"`
|
||||
FetchedAccountsCount int `db:"fetched_accounts_count"`
|
||||
Error *string `db:"error"`
|
||||
@@ -48,11 +48,23 @@ type (
|
||||
CompletedAt *time.Time `db:"completed_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
AttemptNumber int `db:"attempt_number"`
|
||||
}
|
||||
|
||||
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 (
|
||||
ErrNoAccessReviewCampaignSourceFetchAttemptAvailable = errors.New("no access review source fetch attempt available")
|
||||
)
|
||||
@@ -185,7 +197,7 @@ SELECT
|
||||
updated_at
|
||||
FROM access_review_campaign_source_fetch_attempts
|
||||
WHERE status = @status
|
||||
ORDER BY created_at ASC
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`
|
||||
@@ -263,13 +275,76 @@ ORDER BY access_review_campaign_source_id, attempt_number DESC
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadByCampaignSourceID returns the full attempt history for a snapshot,
|
||||
// newest first.
|
||||
// LoadByCampaignSourceID returns a page of fetch attempts for a snapshot.
|
||||
func (attempts *AccessReviewCampaignSourceFetchAttempts) LoadByCampaignSourceID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
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 {
|
||||
q := `
|
||||
SELECT
|
||||
@@ -310,6 +385,33 @@ ORDER BY attempt_number DESC
|
||||
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
|
||||
// fresh retry attempt for each, preserving the stale attempt's history. It is
|
||||
// intentionally cross-tenant. Returns the number of recovered attempts.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -119,7 +119,6 @@ func TestSourceFetchAttempts_AppendOnly(t *testing.T) {
|
||||
if err := first.Insert(ctx, tx, fx.scope); err != nil {
|
||||
return err
|
||||
}
|
||||
require.Equal(t, 1, first.AttemptNumber)
|
||||
|
||||
second := &coredata.AccessReviewCampaignSourceFetchAttempt{
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
require.Equal(t, 2, second.AttemptNumber)
|
||||
|
||||
return nil
|
||||
}))
|
||||
|
||||
var history coredata.AccessReviewCampaignSourceFetchAttempts
|
||||
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")
|
||||
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)
|
||||
require.NotNil(t, history[1].Error)
|
||||
assert.Equal(t, failureMsg, *history[1].Error, "the failed attempt's error is retained")
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"context"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -45,6 +46,15 @@ func IdentityFromContext(ctx context.Context) *coredata.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 {
|
||||
return context.WithValue(ctx, identityContextKey, identity)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/accessreview"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
"go.probo.inc/probo/pkg/probo"
|
||||
"go.probo.inc/probo/pkg/server/api/authn"
|
||||
@@ -55,29 +54,21 @@ func (r *accessReviewCampaignResolver) Sources(ctx context.Context, obj *types.A
|
||||
|
||||
campaignSources, err := r.accessReview.ListCampaignSources(ctx, scope, obj.ID)
|
||||
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)
|
||||
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
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
result := make([]*types.AccessReviewCampaignSource, len(campaignSources))
|
||||
for i, campaignSource := range campaignSources {
|
||||
result[i] = types.NewAccessReviewCampaignSource(campaignSource, attemptByCampaignSourceID[campaignSource.ID])
|
||||
result[i] = types.NewAccessReviewCampaignSource(campaignSource)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Entries is the resolver for the entries field.
|
||||
func (r *accessReviewCampaignResolver) Entries(ctx context.Context, obj *types.AccessReviewCampaign, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessReviewEntryOrder, 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)
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
var (
|
||||
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)
|
||||
}
|
||||
|
||||
p, err := r.accessReview.ListEntriesForCampaignID(ctx, scope, obj.ID, cursor, filter)
|
||||
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.
|
||||
@@ -123,7 +107,9 @@ func (r *accessReviewCampaignResolver) PendingEntryCount(ctx context.Context, ob
|
||||
|
||||
count, err := r.accessReview.CountPendingEntriesForCampaignID(ctx, scope, obj.ID)
|
||||
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
|
||||
@@ -138,7 +124,9 @@ func (r *accessReviewCampaignResolver) Statistics(ctx context.Context, obj *type
|
||||
|
||||
stats, err := r.accessReview.CampaignStatistics(ctx, scope, obj.ID)
|
||||
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
|
||||
@@ -160,13 +148,38 @@ func (r *accessReviewCampaignConnectionResolver) TotalCount(ctx context.Context,
|
||||
case *organizationResolver:
|
||||
count, err := r.accessReview.CountCampaignsForOrganizationID(ctx, scope, obj.ParentID)
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -175,7 +188,7 @@ func (r *accessReviewCampaignSourceResolver) Source(ctx context.Context, obj *ty
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -186,35 +199,48 @@ func (r *accessReviewCampaignSourceResolver) Source(ctx context.Context, obj *ty
|
||||
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
|
||||
}
|
||||
|
||||
// FetchAttempts is the resolver for the fetchAttempts field.
|
||||
func (r *accessReviewCampaignSourceResolver) FetchAttempts(ctx context.Context, obj *types.AccessReviewCampaignSource) ([]*types.AccessReviewCampaignSourceFetchAttempt, error) {
|
||||
scope, err := r.authorize(ctx, obj.CampaignID, accessreview.ActionCampaignGet)
|
||||
func (r *accessReviewCampaignSourceResolver) FetchAttempts(ctx context.Context, obj *types.AccessReviewCampaignSource, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessReviewCampaignSourceFetchAttemptOrder) (*types.AccessReviewCampaignSourceFetchAttemptConnection, error) {
|
||||
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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 {
|
||||
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))
|
||||
for i, attempt := range attempts {
|
||||
result[i] = types.NewAccessReviewCampaignSourceFetchAttempt(attempt)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return types.NewAccessReviewCampaignSourceFetchAttemptConnection(p, r, obj.ID), nil
|
||||
}
|
||||
|
||||
// Entries is the resolver for the entries field.
|
||||
func (r *accessReviewCampaignSourceResolver) Entries(ctx context.Context, obj *types.AccessReviewCampaignSource, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessReviewEntryOrder, filter *coredata.AccessReviewEntryFilter) (*types.AccessReviewEntryConnection, error) {
|
||||
scope, err := r.authorize(ctx, obj.CampaignID, accessreview.ActionEntryList)
|
||||
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionEntryList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -233,31 +259,52 @@ func (r *accessReviewCampaignSourceResolver) Entries(ctx context.Context, obj *t
|
||||
|
||||
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 {
|
||||
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
|
||||
|
||||
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.
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// TotalCount is the resolver for the totalCount field.
|
||||
func (r *accessReviewCampaignSourceFetchAttemptConnectionResolver) TotalCount(ctx context.Context, obj *types.AccessReviewCampaignSourceFetchAttemptConnection) (int, error) {
|
||||
scope, err := r.authorize(ctx, obj.ParentID, accessreview.ActionCampaignGet)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
count, err := r.accessReview.CountFetchAttemptsForCampaignSourceID(ctx, scope, obj.ParentID)
|
||||
if err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot count fetch attempts", log.Error(err))
|
||||
|
||||
return 0, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Campaign is the resolver for the campaign field.
|
||||
func (r *accessReviewEntryResolver) Campaign(ctx context.Context, obj *types.AccessReviewEntry) (*types.AccessReviewCampaign, error) {
|
||||
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet)
|
||||
@@ -271,51 +318,33 @@ func (r *accessReviewEntryResolver) Campaign(ctx context.Context, obj *types.Acc
|
||||
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
|
||||
}
|
||||
|
||||
// AccessReviewSource is the resolver for the accessReviewSource field.
|
||||
func (r *accessReviewEntryResolver) AccessReviewSource(ctx context.Context, obj *types.AccessReviewEntry) (*types.AccessReviewSource, error) {
|
||||
scope, err := r.authorize(ctx, obj.ID, accessreview.ActionEntryGet)
|
||||
// CampaignSource is the resolver for the campaignSource field.
|
||||
func (r *accessReviewEntryResolver) CampaignSource(ctx context.Context, obj *types.AccessReviewEntry) (*types.AccessReviewCampaignSource, error) {
|
||||
scope, err := r.authorize(ctx, obj.Campaign.ID, accessreview.ActionCampaignGet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entry, err := r.accessReview.GetEntry(ctx, scope, obj.ID)
|
||||
campaignSource, err := r.accessReview.GetCampaignSource(ctx, scope, obj.CampaignSource.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, gqlutils.NotFound(ctx, err)
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
return types.NewAccessReviewCampaignSource(campaignSource), nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
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))
|
||||
@@ -352,24 +383,28 @@ func (r *accessReviewEntryConnectionResolver) TotalCount(ctx context.Context, ob
|
||||
|
||||
switch obj.Resolver.(type) {
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -411,7 +446,9 @@ func (r *accessReviewSourceResolver) Connector(ctx context.Context, obj *types.A
|
||||
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
|
||||
@@ -477,7 +514,9 @@ func (r *accessReviewSourceResolver) NeedsConfiguration(ctx context.Context, obj
|
||||
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]
|
||||
@@ -536,7 +575,9 @@ func (r *accessReviewSourceResolver) SelectedOrganization(ctx context.Context, o
|
||||
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]
|
||||
@@ -568,13 +609,17 @@ func (r *accessReviewSourceConnectionResolver) TotalCount(ctx context.Context, o
|
||||
case *organizationResolver:
|
||||
count, err := r.accessReview.CountSourcesForOrganizationID(ctx, scope, obj.ParentID)
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -584,15 +629,20 @@ func (r *mutationResolver) CreateAccessReviewSource(ctx context.Context, input t
|
||||
return nil, err
|
||||
}
|
||||
|
||||
source, err := r.accessReview.CreateSource(ctx, scope, accessreview.CreateAccessReviewSourceRequest{
|
||||
OrganizationID: input.OrganizationID,
|
||||
ConnectorID: input.ConnectorID,
|
||||
Name: input.Name,
|
||||
Category: coredata.AccessReviewSourceCategorySaaS,
|
||||
CsvData: input.CSVData,
|
||||
})
|
||||
source, err := r.accessReview.CreateSource(
|
||||
ctx,
|
||||
scope,
|
||||
accessreview.CreateAccessReviewSourceRequest{
|
||||
OrganizationID: input.OrganizationID,
|
||||
ConnectorID: input.ConnectorID,
|
||||
Name: input.Name,
|
||||
CsvData: input.CSVData,
|
||||
},
|
||||
)
|
||||
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{
|
||||
@@ -607,29 +657,24 @@ func (r *mutationResolver) UpdateAccessReviewSource(ctx context.Context, input t
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req := accessreview.UpdateAccessReviewSourceRequest{
|
||||
AccessReviewSourceID: input.AccessReviewSourceID,
|
||||
}
|
||||
|
||||
if input.Name.IsSet() {
|
||||
req.Name = input.Name.Value()
|
||||
}
|
||||
|
||||
if input.ConnectorID.IsSet() {
|
||||
req.ConnectorID = gqlutils.UnwrapOmittable(input.ConnectorID)
|
||||
}
|
||||
|
||||
if input.CSVData.IsSet() {
|
||||
req.CsvData = gqlutils.UnwrapOmittable(input.CSVData)
|
||||
}
|
||||
|
||||
source, err := r.accessReview.UpdateSource(ctx, scope, req)
|
||||
source, err := r.accessReview.UpdateSource(
|
||||
ctx,
|
||||
scope,
|
||||
accessreview.UpdateAccessReviewSourceRequest{
|
||||
AccessReviewSourceID: input.AccessReviewSourceID,
|
||||
Name: gqlutils.UnwrapOmittable(input.Name),
|
||||
ConnectorID: gqlutils.UnwrapOmittable(input.ConnectorID),
|
||||
CsvData: gqlutils.UnwrapOmittable(input.CSVData),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, gqlutils.NotFound(ctx, err)
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -649,7 +694,9 @@ func (r *mutationResolver) DeleteAccessReviewSource(ctx context.Context, input t
|
||||
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{
|
||||
@@ -677,7 +724,9 @@ func (r *mutationResolver) ConfigureAccessReviewSource(ctx context.Context, inpu
|
||||
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{
|
||||
@@ -697,15 +746,21 @@ func (r *mutationResolver) CreateAccessReviewCampaign(ctx context.Context, input
|
||||
description = *input.Description
|
||||
}
|
||||
|
||||
campaign, err := r.accessReview.CreateCampaign(ctx, scope, accessreview.CreateAccessReviewCampaignRequest{
|
||||
OrganizationID: input.OrganizationID,
|
||||
Name: input.Name,
|
||||
Description: description,
|
||||
FrameworkControls: input.FrameworkControls,
|
||||
AccessReviewSourceIDs: input.AccessReviewSourceIds,
|
||||
})
|
||||
campaign, err := r.accessReview.CreateCampaign(
|
||||
ctx,
|
||||
scope,
|
||||
accessreview.CreateAccessReviewCampaignRequest{
|
||||
OrganizationID: input.OrganizationID,
|
||||
Name: input.Name,
|
||||
Description: description,
|
||||
FrameworkControls: input.FrameworkControls,
|
||||
AccessReviewSourceIDs: input.AccessReviewSourceIds,
|
||||
},
|
||||
)
|
||||
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{
|
||||
@@ -720,30 +775,24 @@ func (r *mutationResolver) UpdateAccessReviewCampaign(ctx context.Context, input
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req := accessreview.UpdateAccessReviewCampaignRequest{
|
||||
CampaignID: input.AccessReviewCampaignID,
|
||||
}
|
||||
|
||||
if input.Name.IsSet() {
|
||||
req.Name = input.Name.Value()
|
||||
}
|
||||
|
||||
if input.Description.IsSet() {
|
||||
req.Description = input.Description.Value()
|
||||
}
|
||||
|
||||
if input.FrameworkControls.IsSet() {
|
||||
controls := input.FrameworkControls.Value()
|
||||
req.FrameworkControls = &controls
|
||||
}
|
||||
|
||||
campaign, err := r.accessReview.UpdateCampaign(ctx, scope, req)
|
||||
campaign, err := r.accessReview.UpdateCampaign(
|
||||
ctx,
|
||||
scope,
|
||||
accessreview.UpdateAccessReviewCampaignRequest{
|
||||
CampaignID: input.AccessReviewCampaignID,
|
||||
Name: gqlutils.UnwrapOmittable(input.Name),
|
||||
Description: gqlutils.UnwrapOmittable(input.Description),
|
||||
FrameworkControls: gqlutils.UnwrapOmittable(input.FrameworkControls),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, gqlutils.NotFound(ctx, err)
|
||||
}
|
||||
|
||||
panic(fmt.Errorf("cannot update access review campaign: %w", err))
|
||||
r.logger.ErrorCtx(ctx, "cannot update access review campaign", log.Error(err))
|
||||
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.UpdateAccessReviewCampaignPayload{
|
||||
@@ -763,7 +812,9 @@ func (r *mutationResolver) DeleteAccessReviewCampaign(ctx context.Context, input
|
||||
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{
|
||||
@@ -780,7 +831,9 @@ func (r *mutationResolver) StartAccessReviewCampaign(ctx context.Context, input
|
||||
|
||||
campaign, err := r.accessReview.StartCampaign(ctx, scope, input.AccessReviewCampaignID)
|
||||
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{
|
||||
@@ -797,7 +850,9 @@ func (r *mutationResolver) CloseAccessReviewCampaign(ctx context.Context, input
|
||||
|
||||
campaign, err := r.accessReview.CloseCampaign(ctx, scope, input.AccessReviewCampaignID)
|
||||
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{
|
||||
@@ -814,7 +869,9 @@ func (r *mutationResolver) CancelAccessReviewCampaign(ctx context.Context, input
|
||||
|
||||
campaign, err := r.accessReview.CancelCampaign(ctx, scope, input.AccessReviewCampaignID)
|
||||
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{
|
||||
@@ -829,12 +886,18 @@ func (r *mutationResolver) AddAccessReviewCampaignSource(ctx context.Context, in
|
||||
return nil, err
|
||||
}
|
||||
|
||||
campaign, err := r.accessReview.AddCampaignSource(ctx, scope, accessreview.AddCampaignSourceRequest{
|
||||
CampaignID: input.AccessReviewCampaignID,
|
||||
AccessReviewSourceID: input.AccessReviewSourceID,
|
||||
})
|
||||
campaign, err := r.accessReview.AddCampaignSource(
|
||||
ctx,
|
||||
scope,
|
||||
accessreview.AddCampaignSourceRequest{
|
||||
CampaignID: input.AccessReviewCampaignID,
|
||||
AccessReviewSourceID: input.AccessReviewSourceID,
|
||||
},
|
||||
)
|
||||
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{
|
||||
@@ -849,12 +912,18 @@ func (r *mutationResolver) RemoveAccessReviewCampaignSource(ctx context.Context,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
campaign, err := r.accessReview.RemoveCampaignSource(ctx, scope, accessreview.RemoveCampaignSourceRequest{
|
||||
CampaignID: input.AccessReviewCampaignID,
|
||||
AccessReviewSourceID: input.AccessReviewSourceID,
|
||||
})
|
||||
campaign, err := r.accessReview.RemoveCampaignSource(
|
||||
ctx,
|
||||
scope,
|
||||
accessreview.RemoveCampaignSourceRequest{
|
||||
CampaignID: input.AccessReviewCampaignID,
|
||||
AccessReviewSourceID: input.AccessReviewSourceID,
|
||||
},
|
||||
)
|
||||
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{
|
||||
@@ -869,35 +938,24 @@ func (r *mutationResolver) RecordAccessReviewEntryDecision(ctx context.Context,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resolve the profile ID from the session's identity.
|
||||
// The profile may not exist for every identity, in which
|
||||
// case decided_by will be left nil.
|
||||
identity := authn.IdentityFromContext(ctx)
|
||||
if identity == nil {
|
||||
return nil, fmt.Errorf("no identity in context")
|
||||
}
|
||||
|
||||
req := accessreview.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)
|
||||
entry, err := r.accessReview.RecordDecision(
|
||||
ctx,
|
||||
scope,
|
||||
accessreview.RecordAccessReviewEntryDecisionRequest{
|
||||
EntryID: input.AccessReviewEntryID,
|
||||
Decision: input.Decision,
|
||||
DecisionNote: input.DecisionNote,
|
||||
DecidedByID: authn.IdentityIDFromContext(ctx),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, gqlutils.NotFound(ctx, err)
|
||||
}
|
||||
|
||||
panic(fmt.Errorf("cannot record access entry decision: %w", err))
|
||||
r.logger.ErrorCtx(ctx, "cannot record access entry decision", log.Error(err))
|
||||
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.RecordAccessReviewEntryDecisionPayload{
|
||||
@@ -933,29 +991,10 @@ func (r *mutationResolver) RecordAccessReviewEntryDecisions(ctx context.Context,
|
||||
|
||||
tenantID := input.Decisions[0].AccessReviewEntryID.TenantID()
|
||||
scope := coredata.NewScope(tenantID)
|
||||
|
||||
// Cache profile lookups per organization so we resolve the correct
|
||||
// decidedByID for each entry even when a batch spans multiple orgs.
|
||||
profileCache := make(map[gid.GID]*gid.GID)
|
||||
decidedByID := &identity.ID
|
||||
|
||||
decisions := make([]accessreview.RecordAccessReviewEntryDecisionRequest, len(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{
|
||||
EntryID: d.AccessReviewEntryID,
|
||||
Decision: d.Decision,
|
||||
@@ -970,7 +1009,9 @@ func (r *mutationResolver) RecordAccessReviewEntryDecisions(ctx context.Context,
|
||||
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))
|
||||
@@ -1000,7 +1041,9 @@ func (r *mutationResolver) FlagAccessReviewEntry(ctx context.Context, input type
|
||||
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{
|
||||
@@ -1023,6 +1066,11 @@ func (r *Resolver) AccessReviewCampaignSource() schema.AccessReviewCampaignSourc
|
||||
return &accessReviewCampaignSourceResolver{r}
|
||||
}
|
||||
|
||||
// AccessReviewCampaignSourceFetchAttemptConnection returns schema.AccessReviewCampaignSourceFetchAttemptConnectionResolver implementation.
|
||||
func (r *Resolver) AccessReviewCampaignSourceFetchAttemptConnection() schema.AccessReviewCampaignSourceFetchAttemptConnectionResolver {
|
||||
return &accessReviewCampaignSourceFetchAttemptConnectionResolver{r}
|
||||
}
|
||||
|
||||
// AccessReviewEntry returns schema.AccessReviewEntryResolver implementation.
|
||||
func (r *Resolver) AccessReviewEntry() schema.AccessReviewEntryResolver {
|
||||
return &accessReviewEntryResolver{r}
|
||||
@@ -1046,6 +1094,7 @@ func (r *Resolver) AccessReviewSourceConnection() schema.AccessReviewSourceConne
|
||||
type accessReviewCampaignResolver struct{ *Resolver }
|
||||
type accessReviewCampaignConnectionResolver struct{ *Resolver }
|
||||
type accessReviewCampaignSourceResolver struct{ *Resolver }
|
||||
type accessReviewCampaignSourceFetchAttemptConnectionResolver struct{ *Resolver }
|
||||
type accessReviewEntryResolver struct{ *Resolver }
|
||||
type accessReviewEntryConnectionResolver struct{ *Resolver }
|
||||
type accessReviewSourceResolver struct{ *Resolver }
|
||||
|
||||
@@ -394,6 +394,16 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
|
||||
|
||||
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:
|
||||
action = accessreview.ActionSourceGet
|
||||
loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) {
|
||||
|
||||
@@ -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
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.AccessReviewCampaignSourceFetchStatus"
|
||||
@@ -251,6 +229,21 @@ enum AccessReviewEntryOrderField
|
||||
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 {
|
||||
CONNECTED
|
||||
DISCONNECTED
|
||||
@@ -307,10 +300,7 @@ type AccessReviewSource implements Node {
|
||||
permission(action: String!): Boolean! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
type AccessReviewCampaignSourceFetchAttempt
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewCampaignSourceFetchAttempt"
|
||||
) {
|
||||
type AccessReviewCampaignSourceFetchAttempt {
|
||||
id: ID!
|
||||
attemptNumber: Int!
|
||||
status: AccessReviewCampaignSourceFetchStatus!
|
||||
@@ -322,25 +312,22 @@ type AccessReviewCampaignSourceFetchAttempt
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
|
||||
type AccessReviewCampaignSource
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewCampaignSource"
|
||||
) {
|
||||
type AccessReviewCampaignSource implements Node {
|
||||
id: ID!
|
||||
campaign: AccessReviewCampaign! @goField(forceResolver: true)
|
||||
sourceId: ID
|
||||
"The live access source this snapshot was taken from. Null once the source is deleted."
|
||||
source: AccessReviewSource @goField(forceResolver: true)
|
||||
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: [AccessReviewCampaignSourceFetchAttempt!]! @goField(forceResolver: true)
|
||||
fetchAttempts(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: AccessReviewCampaignSourceFetchAttemptOrder
|
||||
): AccessReviewCampaignSourceFetchAttemptConnection!
|
||||
@goField(forceResolver: true)
|
||||
|
||||
entries(
|
||||
first: Int
|
||||
@@ -374,7 +361,6 @@ type AccessReviewCampaign implements Node {
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: AccessReviewEntryOrder
|
||||
campaignSourceId: ID
|
||||
filter: AccessReviewEntryFilter
|
||||
): AccessReviewEntryConnection! @goField(forceResolver: true)
|
||||
|
||||
@@ -388,8 +374,7 @@ type AccessReviewCampaign implements Node {
|
||||
type AccessReviewEntry implements Node {
|
||||
id: ID!
|
||||
campaign: AccessReviewCampaign! @goField(forceResolver: true)
|
||||
"The live access source this entry came from. Null once the source is deleted."
|
||||
accessReviewSource: AccessReviewSource @goField(forceResolver: true)
|
||||
campaignSource: AccessReviewCampaignSource! @goField(forceResolver: true)
|
||||
email: String!
|
||||
fullName: String!
|
||||
role: String!
|
||||
@@ -468,6 +453,20 @@ type AccessReviewEntryEdge {
|
||||
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 {
|
||||
totalCount: Int!
|
||||
decisionCounts: [AccessReviewEntryDecisionCount!]!
|
||||
|
||||
@@ -15,17 +15,16 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
AccessReviewSourceOrderBy OrderBy[coredata.AccessReviewSourceOrderField]
|
||||
AccessReviewCampaignOrderBy OrderBy[coredata.AccessReviewCampaignOrderField]
|
||||
AccessReviewEntryOrderBy OrderBy[coredata.AccessReviewEntryOrderField]
|
||||
AccessReviewSourceOrderBy OrderBy[coredata.AccessReviewSourceOrderField]
|
||||
AccessReviewCampaignOrderBy OrderBy[coredata.AccessReviewCampaignOrderField]
|
||||
AccessReviewEntryOrderBy OrderBy[coredata.AccessReviewEntryOrderField]
|
||||
AccessReviewCampaignSourceFetchAttemptOrderBy OrderBy[coredata.AccessReviewCampaignSourceFetchAttemptOrderField]
|
||||
|
||||
AccessReviewSourceConnection struct {
|
||||
TotalCount int
|
||||
@@ -55,6 +54,15 @@ type (
|
||||
SourceID *gid.GID
|
||||
Filter *coredata.AccessReviewEntryFilter
|
||||
}
|
||||
|
||||
AccessReviewCampaignSourceFetchAttemptConnection struct {
|
||||
TotalCount int
|
||||
Edges []*AccessReviewCampaignSourceFetchAttemptEdge
|
||||
PageInfo PageInfo
|
||||
|
||||
Resolver any
|
||||
ParentID gid.GID
|
||||
}
|
||||
)
|
||||
|
||||
// AccessReviewSource helpers
|
||||
@@ -100,53 +108,37 @@ func NewAccessReviewSource(s *coredata.AccessReviewSource) *AccessReviewSource {
|
||||
}
|
||||
}
|
||||
|
||||
// NewAccessReviewCampaignSource builds the GraphQL scope source from a
|
||||
// campaign source snapshot. The current fetch state is derived from the latest
|
||||
// fetch attempt (nil when the source has never been fetched). The live access
|
||||
// source is resolved lazily via the source field resolver from SourceID.
|
||||
// NewAccessReviewCampaignSource builds the GraphQL campaign source from a
|
||||
// snapshot row. Fetch state is resolved lazily via field resolvers on
|
||||
// AccessReviewCampaignSource. The live access source is resolved via the
|
||||
// source field resolver from SourceID.
|
||||
func NewAccessReviewCampaignSource(
|
||||
campaignSource *coredata.AccessReviewCampaignSource,
|
||||
latestAttempt *coredata.AccessReviewCampaignSourceFetchAttempt,
|
||||
) *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{
|
||||
ID: campaignSource.ID,
|
||||
CampaignID: campaignSource.AccessReviewCampaignID,
|
||||
SourceID: campaignSource.AccessReviewSourceID,
|
||||
Name: campaignSource.Name,
|
||||
FetchStatus: status,
|
||||
FetchedAccountsCount: fetchedAccountsCount,
|
||||
AttemptCount: attemptCount,
|
||||
LastError: lastError,
|
||||
FetchStartedAt: fetchStartedAt,
|
||||
FetchCompletedAt: fetchCompletedAt,
|
||||
ID: campaignSource.ID,
|
||||
Campaign: &AccessReviewCampaign{
|
||||
ID: campaignSource.AccessReviewCampaignID,
|
||||
},
|
||||
SourceID: campaignSource.AccessReviewSourceID,
|
||||
Name: campaignSource.Name,
|
||||
}
|
||||
}
|
||||
|
||||
// NewAccessReviewCampaignSourceFetchAttempt builds the GraphQL representation of a
|
||||
// single append-only fetch attempt.
|
||||
func NewAccessReviewCampaignSourceFetchAttempt(a *coredata.AccessReviewCampaignSourceFetchAttempt) *AccessReviewCampaignSourceFetchAttempt {
|
||||
// single append-only fetch attempt. attemptNumber is the 1-based position in the
|
||||
// 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{
|
||||
ID: a.ID,
|
||||
AttemptNumber: a.AttemptNumber,
|
||||
AttemptNumber: attemptNumber,
|
||||
Status: a.Status,
|
||||
FetchedAccountsCount: a.FetchedAccountsCount,
|
||||
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
|
||||
|
||||
func NewAccessReviewCampaignConnection(
|
||||
@@ -260,6 +283,9 @@ func NewAccessReviewEntry(e *coredata.AccessReviewEntry) *AccessReviewEntry {
|
||||
Campaign: &AccessReviewCampaign{
|
||||
ID: e.AccessReviewCampaignID,
|
||||
},
|
||||
CampaignSource: &AccessReviewCampaignSource{
|
||||
ID: e.AccessReviewCampaignSourceID,
|
||||
},
|
||||
Email: e.Email,
|
||||
FullName: e.FullName,
|
||||
Role: e.Role,
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -16,7 +16,6 @@ package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"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),
|
||||
AccessReviewSourceID: sourceID,
|
||||
Name: name,
|
||||
Category: coredata.AccessReviewSourceCategorySaaS,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAccessReviewCampaignSource_DefaultFetchState(t *testing.T) {
|
||||
func TestNewAccessReviewCampaignSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tenantID := gid.NewTenantID()
|
||||
sourceID := gid.New(tenantID, coredata.AccessReviewSourceEntityType)
|
||||
campaignSource := newTestCampaignSource(tenantID, &sourceID, "Google Workspace")
|
||||
|
||||
got := NewAccessReviewCampaignSource(campaignSource, nil)
|
||||
if got.FetchStatus != coredata.AccessReviewCampaignSourceFetchStatusQueued {
|
||||
t.Fatalf("fetch status = %q, want QUEUED", got.FetchStatus)
|
||||
got := NewAccessReviewCampaignSource(campaignSource)
|
||||
if got.ID != campaignSource.ID {
|
||||
t.Fatalf("id = %v, want %v", got.ID, campaignSource.ID)
|
||||
}
|
||||
|
||||
if got.FetchedAccountsCount != 0 {
|
||||
t.Fatalf("fetched accounts count = %d, want 0", got.FetchedAccountsCount)
|
||||
}
|
||||
|
||||
if got.AttemptCount != 0 {
|
||||
t.Fatalf("attempt count = %d, want 0", got.AttemptCount)
|
||||
if got.Campaign == nil || got.Campaign.ID != campaignSource.AccessReviewCampaignID {
|
||||
t.Fatalf("campaign id = %v, want %v", got.Campaign, campaignSource.AccessReviewCampaignID)
|
||||
}
|
||||
|
||||
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
|
||||
// live source has been deleted still renders (with a nil source link) so the
|
||||
// historical review data remains visible.
|
||||
@@ -106,7 +65,7 @@ func TestNewAccessReviewCampaignSource_DeletedSource(t *testing.T) {
|
||||
tenantID := gid.NewTenantID()
|
||||
campaignSource := newTestCampaignSource(tenantID, nil, "Deleted Source")
|
||||
|
||||
got := NewAccessReviewCampaignSource(campaignSource, nil)
|
||||
got := NewAccessReviewCampaignSource(campaignSource)
|
||||
if got.SourceID != nil {
|
||||
t.Fatalf("source id = %v, want nil for deleted source", got.SourceID)
|
||||
}
|
||||
|
||||
@@ -3410,11 +3410,39 @@ func (r *Resolver) ListAccessReviewCampaignsTool(ctx context.Context, req *mcp.C
|
||||
}
|
||||
|
||||
// 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) {
|
||||
scope, err := r.Authorize(ctx, input.CampaignID, accessreview.ActionEntryList)
|
||||
if err != nil {
|
||||
return nil, types.ListAccessEntriesOutput{}, err
|
||||
if input.AccessReviewCampaignSourceID == nil && input.CampaignID == nil {
|
||||
return nil, types.ListAccessEntriesOutput{}, fmt.Errorf("campaign_id or access_review_campaign_source_id is required")
|
||||
}
|
||||
|
||||
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]{
|
||||
@@ -3446,14 +3474,12 @@ func (r *Resolver) ListAccessEntriesTool(ctx context.Context, req *mcp.CallToolR
|
||||
|
||||
var p *page.Page[*coredata.AccessReviewEntry, coredata.AccessReviewEntryOrderField]
|
||||
|
||||
if input.AccessReviewCampaignSourceID != nil {
|
||||
var err error
|
||||
|
||||
if sourceID != nil {
|
||||
p, err = r.accessReview.ListEntriesForCampaignIDAndSourceID(
|
||||
ctx,
|
||||
scope,
|
||||
input.CampaignID,
|
||||
*input.AccessReviewCampaignSourceID,
|
||||
campaignID,
|
||||
*sourceID,
|
||||
cursor,
|
||||
filter,
|
||||
)
|
||||
@@ -3461,9 +3487,7 @@ func (r *Resolver) ListAccessEntriesTool(ctx context.Context, req *mcp.CallToolR
|
||||
panic(fmt.Errorf("cannot list access entries: %w", err))
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
|
||||
p, err = r.accessReview.ListEntriesForCampaignID(ctx, scope, input.CampaignID, cursor, filter)
|
||||
p, err = r.accessReview.ListEntriesForCampaignID(ctx, scope, campaignID, cursor, filter)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
identity := authn.IdentityFromContext(ctx)
|
||||
if identity == nil {
|
||||
return nil, types.RecordAccessReviewEntryDecisionOutput{}, fmt.Errorf("no identity in context")
|
||||
}
|
||||
|
||||
decisionReq := 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.iamSvc.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, organizationID)
|
||||
if err == nil {
|
||||
decisionReq.DecidedByID = &profile.ID
|
||||
}
|
||||
}
|
||||
|
||||
entry, err := r.accessReview.RecordDecision(ctx, scope, decisionReq)
|
||||
entry, err := r.accessReview.RecordDecision(
|
||||
ctx,
|
||||
scope,
|
||||
accessreview.RecordAccessReviewEntryDecisionRequest{
|
||||
EntryID: input.AccessReviewEntryID,
|
||||
Decision: input.Decision,
|
||||
DecisionNote: input.DecisionNote,
|
||||
DecidedByID: authn.IdentityIDFromContext(ctx),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
// 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)
|
||||
decidedByID := &identity.ID
|
||||
|
||||
decisions := make([]accessreview.RecordAccessReviewEntryDecisionRequest, len(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{
|
||||
EntryID: d.AccessReviewEntryID,
|
||||
Decision: d.Decision,
|
||||
@@ -3660,7 +3656,6 @@ func (r *Resolver) CreateAccessReviewSourceTool(ctx context.Context, req *mcp.Ca
|
||||
OrganizationID: input.OrganizationID,
|
||||
ConnectorID: input.ConnectorID,
|
||||
Name: input.Name,
|
||||
Category: coredata.AccessReviewSourceCategorySaaS,
|
||||
CsvData: input.CsvData,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -3682,7 +3677,10 @@ func (r *Resolver) UpdateAccessReviewSourceTool(ctx context.Context, req *mcp.Ca
|
||||
|
||||
updateReq := accessreview.UpdateAccessReviewSourceRequest{
|
||||
AccessReviewSourceID: input.AccessReviewSourceID,
|
||||
Name: input.Name,
|
||||
}
|
||||
|
||||
if input.Name != nil {
|
||||
updateReq.Name = &input.Name
|
||||
}
|
||||
|
||||
if rawConnectorID := UnwrapOmittable(input.ConnectorID); rawConnectorID != nil {
|
||||
@@ -3770,9 +3768,15 @@ func (r *Resolver) UpdateAccessReviewCampaignTool(ctx context.Context, req *mcp.
|
||||
}
|
||||
|
||||
updateReq := accessreview.UpdateAccessReviewCampaignRequest{
|
||||
CampaignID: input.CampaignID,
|
||||
Name: input.Name,
|
||||
Description: input.Description,
|
||||
CampaignID: input.CampaignID,
|
||||
}
|
||||
|
||||
if input.Name != nil {
|
||||
updateReq.Name = &input.Name
|
||||
}
|
||||
|
||||
if input.Description != nil {
|
||||
updateReq.Description = &input.Description
|
||||
}
|
||||
|
||||
if rawControls := UnwrapOmittable(input.FrameworkControls); rawControls != nil {
|
||||
|
||||
@@ -7851,15 +7851,13 @@ components:
|
||||
|
||||
ListAccessEntriesInput:
|
||||
type: object
|
||||
required:
|
||||
- campaign_id
|
||||
properties:
|
||||
campaign_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Campaign ID
|
||||
description: Campaign ID. Required when access_review_campaign_source_id is omitted.
|
||||
access_review_campaign_source_id:
|
||||
$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:
|
||||
$ref: "#/components/schemas/AccessReviewEntryOrderBy"
|
||||
description: Order by
|
||||
@@ -8026,15 +8024,6 @@ components:
|
||||
campaign:
|
||||
$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:
|
||||
type: string
|
||||
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.AccessReviewSourceOrderField
|
||||
|
||||
Reference in New Issue
Block a user