Refactor access review campaign source API

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

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

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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)")

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}