Introduce access-review source snapshot and normalize naming

Decouple each campaign from the live access-review sources it was started
with by introducing a per-campaign source snapshot table
(access_review_campaign_sources). The snapshot captures the source name,
category, and connector at start time, so a review remains coherent even
after the underlying source is edited or deleted. Fetch tracking becomes
an append-only log (access_review_campaign_source_fetch_attempts) that
preserves every attempt with its own status and error rather than
overwriting a single row.

Rename the shared access-review tables and enums to use a consistent
access_review_ prefix throughout:

  access_entries          → access_review_entries
  access_sources          → access_review_sources
  access_source_category  → access_review_source_category
  access_entry_*          → access_review_entry_*

The same rename propagates to every coredata type, service, GraphQL
schema, MCP specification, CLI command, frontend component, and e2e test.
The accessreview package gains dedicated actions.go and policies.go files
for its own IAM policy set, mirroring the agentrun package pattern.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-06-11 15:58:30 +02:00
parent 8a53e0ec1c
commit 4b64e59da4
146 changed files with 4795 additions and 4069 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -40,7 +40,7 @@ type providerOrgConfig struct {
}
// providerOrgConfigs is the single source of truth that the three
// AccessSource picker resolvers (ProviderOrganizations,
// AccessReviewSource picker resolvers (ProviderOrganizations,
// SelectedOrganization, NeedsConfiguration) dispatch through. Adding a
// provider takes one entry here, not three switch arms.
var providerOrgConfigs = map[coredata.ConnectorProvider]providerOrgConfig{

View File

@@ -12,6 +12,7 @@ import (
"strings"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/accessreview"
"go.probo.inc/probo/pkg/agentrun"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
@@ -384,34 +385,34 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
return types.NewAgentRun(run), nil
}
case coredata.AccessReviewCampaignEntityType:
action = probo.ActionAccessReviewCampaignGet
action = accessreview.ActionCampaignGet
loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) {
campaign, err := r.accessReview.Campaigns(scope).Get(ctx, id)
campaign, err := r.accessReview.GetCampaign(ctx, scope, id)
if err != nil {
return nil, err
}
return types.NewAccessReviewCampaign(campaign), nil
}
case coredata.AccessSourceEntityType:
action = probo.ActionAccessSourceGet
case coredata.AccessReviewSourceEntityType:
action = accessreview.ActionSourceGet
loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) {
source, err := r.accessReview.Sources(scope).Get(ctx, id)
source, err := r.accessReview.GetSource(ctx, scope, id)
if err != nil {
return nil, err
}
return types.NewAccessSource(source), nil
return types.NewAccessReviewSource(source), nil
}
case coredata.AccessEntryEntityType:
action = probo.ActionAccessEntryGet
case coredata.AccessReviewEntryEntityType:
action = accessreview.ActionEntryGet
loadNode = func(ctx context.Context, scope *coredata.Scope, id gid.GID) (types.Node, error) {
entry, err := r.accessReview.Entries(scope).Get(ctx, id)
entry, err := r.accessReview.GetEntry(ctx, scope, id)
if err != nil {
return nil, err
}
return types.NewAccessEntry(entry), nil
return types.NewAccessReviewEntry(entry), nil
}
case coredata.CookieBannerEntityType:
action = probo.ActionCookieBannerGet

View File

@@ -24,25 +24,25 @@ enum AccessReviewCampaignStatus
)
}
enum AccessSourceCategory
enum AccessReviewSourceCategory
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessSourceCategory"
model: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategory"
) {
SAAS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessSourceCategorySaaS"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategorySaaS"
)
CLOUD_INFRA
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessSourceCategoryCloudInfra"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategoryCloudInfra"
)
SOURCE_CODE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessSourceCategorySourceCode"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategorySourceCode"
)
OTHER
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessSourceCategoryOther"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceCategoryOther"
)
}
@@ -68,109 +68,109 @@ enum AccessReviewCampaignSourceFetchStatus
)
}
enum AccessEntryFlag
@goModel(model: "go.probo.inc/probo/pkg/coredata.AccessEntryFlag") {
enum AccessReviewEntryFlag
@goModel(model: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlag") {
NONE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagNone"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagNone"
)
ORPHANED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagOrphaned"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagOrphaned"
)
INACTIVE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagInactive"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagInactive"
)
EXCESSIVE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagExcessive"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagExcessive"
)
ROLE_MISMATCH
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagRoleMismatch"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagRoleMismatch"
)
NEW
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagNew"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagNew"
)
DORMANT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagDormant"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagDormant"
)
TERMINATED_USER
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagTerminatedUser"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagTerminatedUser"
)
CONTRACTOR_EXPIRED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagContractorExpired"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagContractorExpired"
)
SOD_CONFLICT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagSoDConflict"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagSoDConflict"
)
PRIVILEGED_ACCESS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagPrivilegedAccess"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagPrivilegedAccess"
)
ROLE_CREEP
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagRoleCreep"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagRoleCreep"
)
NO_BUSINESS_JUSTIFICATION
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagNoBusinessJustification"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagNoBusinessJustification"
)
OUT_OF_DEPARTMENT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagOutOfDepartment"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagOutOfDepartment"
)
SHARED_ACCOUNT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryFlagSharedAccount"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFlagSharedAccount"
)
}
enum AccessEntryDecision
enum AccessReviewEntryDecision
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessEntryDecision"
model: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryDecision"
) {
PENDING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryDecisionPending"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryDecisionPending"
)
APPROVED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryDecisionApproved"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryDecisionApproved"
)
REVOKE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryDecisionRevoke"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryDecisionRevoke"
)
DEFER
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryDecisionDefer"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryDecisionDefer"
)
ESCALATE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryDecisionEscalate"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryDecisionEscalate"
)
}
enum AccessEntryIncrementalTag
@goModel(model: "go.probo.inc/probo/pkg/coredata.AccessEntryIncrementalTag") {
enum AccessReviewEntryIncrementalTag
@goModel(model: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryIncrementalTag") {
NEW
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryIncrementalTagNew"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryIncrementalTagNew"
)
REMOVED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryIncrementalTagRemoved"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryIncrementalTagRemoved"
)
UNCHANGED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryIncrementalTagUnchanged"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryIncrementalTagUnchanged"
)
}
@@ -190,49 +190,49 @@ enum MfaStatus
)
}
enum AccessEntryAuthMethod
enum AccessReviewEntryAuthMethod
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethod"
model: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAuthMethod"
) {
SSO
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethodSSO"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAuthMethodSSO"
)
PASSWORD
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethodPassword"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAuthMethodPassword"
)
API_KEY
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethodAPIKey"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAuthMethodAPIKey"
)
SERVICE_ACCOUNT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethodServiceAccount"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAuthMethodServiceAccount"
)
UNKNOWN
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryAuthMethodUnknown"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAuthMethodUnknown"
)
}
enum AccessEntryAccountType
enum AccessReviewEntryAccountType
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessEntryAccountType"
model: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAccountType"
) {
USER
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryAccountTypeUser"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAccountTypeUser"
)
SERVICE_ACCOUNT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AccessEntryAccountTypeServiceAccount"
value: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryAccountTypeServiceAccount"
)
}
enum AccessSourceOrderField
enum AccessReviewSourceOrderField
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessSourceOrderField"
model: "go.probo.inc/probo/pkg/coredata.AccessReviewSourceOrderField"
) {
CREATED_AT
}
@@ -244,22 +244,22 @@ enum AccessReviewCampaignOrderField
CREATED_AT
}
enum AccessEntryOrderField
enum AccessReviewEntryOrderField
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessEntryOrderField"
model: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryOrderField"
) {
CREATED_AT
}
enum AccessSourceConnectionStatus {
enum AccessReviewSourceConnectionStatus {
CONNECTED
DISCONNECTED
NOT_APPLICABLE
}
input AccessSourceOrder {
input AccessReviewSourceOrder {
direction: OrderDirection!
field: AccessSourceOrderField!
field: AccessReviewSourceOrderField!
}
input AccessReviewCampaignOrder {
@@ -267,22 +267,22 @@ input AccessReviewCampaignOrder {
field: AccessReviewCampaignOrderField!
}
input AccessEntryOrder {
input AccessReviewEntryOrder {
direction: OrderDirection!
field: AccessEntryOrderField!
field: AccessReviewEntryOrderField!
}
input AccessEntryFilter
input AccessReviewEntryFilter
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AccessEntryFilter"
model: "go.probo.inc/probo/pkg/coredata.AccessReviewEntryFilter"
) {
decision: AccessEntryDecision
flag: AccessEntryFlag
incrementalTag: AccessEntryIncrementalTag
decision: AccessReviewEntryDecision
flag: AccessReviewEntryFlag
incrementalTag: AccessReviewEntryIncrementalTag
isAdmin: Boolean
active: Boolean
authMethod: AccessEntryAuthMethod
accountType: AccessEntryAccountType
authMethod: AccessReviewEntryAuthMethod
accountType: AccessReviewEntryAccountType
}
type ProviderOrganization {
@@ -290,7 +290,7 @@ type ProviderOrganization {
displayName: String!
}
type AccessSource implements Node {
type AccessReviewSource implements Node {
id: ID!
organization: Organization! @goField(forceResolver: true)
connectorId: ID
@@ -299,7 +299,7 @@ type AccessSource implements Node {
csvData: String
providerOrganizations: [ProviderOrganization!]! @goField(forceResolver: true)
needsConfiguration: Boolean! @goField(forceResolver: true)
connectionStatus: AccessSourceConnectionStatus! @goField(forceResolver: true)
connectionStatus: AccessReviewSourceConnectionStatus! @goField(forceResolver: true)
selectedOrganization: String @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
@@ -307,30 +307,51 @@ type AccessSource implements Node {
permission(action: String!): Boolean! @goField(forceResolver: true)
}
type AccessReviewCampaignScopeSource
type AccessReviewCampaignSourceFetchAttempt
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewCampaignScopeSource"
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewCampaignSourceFetchAttempt"
) {
id: ID!
source: AccessSource!
attemptNumber: Int!
status: AccessReviewCampaignSourceFetchStatus!
fetchedAccountsCount: Int!
error: String
startedAt: Datetime
completedAt: Datetime
createdAt: Datetime!
updatedAt: Datetime!
}
type AccessReviewCampaignSource
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewCampaignSource"
) {
id: 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)
entries(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: AccessEntryOrder
filter: AccessEntryFilter
): AccessEntryConnection! @goField(forceResolver: true)
orderBy: AccessReviewEntryOrder
filter: AccessReviewEntryFilter
): AccessReviewEntryConnection! @goField(forceResolver: true)
statistics: AccessReviewCampaignStatistics! @goField(forceResolver: true)
statistics: AccessReviewStatistics! @goField(forceResolver: true)
}
type AccessReviewCampaign implements Node {
@@ -345,29 +366,30 @@ type AccessReviewCampaign implements Node {
createdAt: Datetime!
updatedAt: Datetime!
scopeSources: [AccessReviewCampaignScopeSource!]! @goField(forceResolver: true)
sources: [AccessReviewCampaignSource!]! @goField(forceResolver: true)
entries(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: AccessEntryOrder
accessSourceId: ID
filter: AccessEntryFilter
): AccessEntryConnection! @goField(forceResolver: true)
orderBy: AccessReviewEntryOrder
campaignSourceId: ID
filter: AccessReviewEntryFilter
): AccessReviewEntryConnection! @goField(forceResolver: true)
pendingEntryCount: Int! @goField(forceResolver: true)
statistics: AccessReviewCampaignStatistics! @goField(forceResolver: true)
statistics: AccessReviewStatistics! @goField(forceResolver: true)
permission(action: String!): Boolean! @goField(forceResolver: true)
}
type AccessEntry implements Node {
type AccessReviewEntry implements Node {
id: ID!
campaign: AccessReviewCampaign! @goField(forceResolver: true)
accessSource: AccessSource! @goField(forceResolver: true)
"The live access source this entry came from. Null once the source is deleted."
accessReviewSource: AccessReviewSource @goField(forceResolver: true)
email: String!
fullName: String!
role: String!
@@ -375,19 +397,19 @@ type AccessEntry implements Node {
isAdmin: Boolean!
active: Boolean
mfaStatus: MfaStatus!
authMethod: AccessEntryAuthMethod!
accountType: AccessEntryAccountType!
authMethod: AccessReviewEntryAuthMethod!
accountType: AccessReviewEntryAccountType!
lastLogin: Datetime
accountCreatedAt: Datetime
externalId: String!
incrementalTag: AccessEntryIncrementalTag!
flags: [AccessEntryFlag!]!
incrementalTag: AccessReviewEntryIncrementalTag!
flags: [AccessReviewEntryFlag!]!
flagReasons: [String!]!
decision: AccessEntryDecision!
decision: AccessReviewEntryDecision!
decisionNote: String
decidedBy: ID
decidedAt: Datetime
decisionHistory: [AccessEntryDecisionHistoryEntry!]!
decisionHistory: [AccessReviewEntryDecisionHistoryEntry!]!
@goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
@@ -395,27 +417,27 @@ type AccessEntry implements Node {
permission(action: String!): Boolean! @goField(forceResolver: true)
}
type AccessEntryDecisionHistoryEntry {
type AccessReviewEntryDecisionHistoryEntry {
id: ID!
decision: AccessEntryDecision!
decision: AccessReviewEntryDecision!
decisionNote: String
decidedBy: ID
decidedAt: Datetime!
createdAt: Datetime!
}
type AccessSourceConnection
type AccessReviewSourceConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessSourceConnection"
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewSourceConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [AccessSourceEdge!]!
edges: [AccessReviewSourceEdge!]!
pageInfo: PageInfo!
}
type AccessSourceEdge {
type AccessReviewSourceEdge {
cursor: CursorKey!
node: AccessSource!
node: AccessReviewSource!
}
type AccessReviewCampaignConnection
@@ -432,55 +454,55 @@ type AccessReviewCampaignEdge {
node: AccessReviewCampaign!
}
type AccessEntryConnection
type AccessReviewEntryConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessEntryConnection"
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.AccessReviewEntryConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [AccessEntryEdge!]!
edges: [AccessReviewEntryEdge!]!
pageInfo: PageInfo!
}
type AccessEntryEdge {
type AccessReviewEntryEdge {
cursor: CursorKey!
node: AccessEntry!
node: AccessReviewEntry!
}
type AccessReviewCampaignStatistics {
type AccessReviewStatistics {
totalCount: Int!
decisionCounts: [AccessEntryDecisionCount!]!
flagCounts: [AccessEntryFlagCount!]!
incrementalTagCounts: [AccessEntryIncrementalTagCount!]!
decisionCounts: [AccessReviewEntryDecisionCount!]!
flagCounts: [AccessReviewEntryFlagCount!]!
incrementalTagCounts: [AccessReviewEntryIncrementalTagCount!]!
}
type AccessEntryDecisionCount {
decision: AccessEntryDecision!
type AccessReviewEntryDecisionCount {
decision: AccessReviewEntryDecision!
count: Int!
}
type AccessEntryFlagCount {
flag: AccessEntryFlag!
type AccessReviewEntryFlagCount {
flag: AccessReviewEntryFlag!
count: Int!
}
type AccessEntryIncrementalTagCount {
incrementalTag: AccessEntryIncrementalTag!
type AccessReviewEntryIncrementalTagCount {
incrementalTag: AccessReviewEntryIncrementalTag!
count: Int!
}
extend type Mutation {
createAccessSource(
input: CreateAccessSourceInput!
): CreateAccessSourcePayload!
updateAccessSource(
input: UpdateAccessSourceInput!
): UpdateAccessSourcePayload!
deleteAccessSource(
input: DeleteAccessSourceInput!
): DeleteAccessSourcePayload!
configureAccessSource(
input: ConfigureAccessSourceInput!
): ConfigureAccessSourcePayload!
createAccessReviewSource(
input: CreateAccessReviewSourceInput!
): CreateAccessReviewSourcePayload!
updateAccessReviewSource(
input: UpdateAccessReviewSourceInput!
): UpdateAccessReviewSourcePayload!
deleteAccessReviewSource(
input: DeleteAccessReviewSourceInput!
): DeleteAccessReviewSourcePayload!
configureAccessReviewSource(
input: ConfigureAccessReviewSourceInput!
): ConfigureAccessReviewSourcePayload!
createAccessReviewCampaign(
input: CreateAccessReviewCampaignInput!
): CreateAccessReviewCampaignPayload!
@@ -499,60 +521,60 @@ extend type Mutation {
cancelAccessReviewCampaign(
input: CancelAccessReviewCampaignInput!
): CancelAccessReviewCampaignPayload!
addAccessReviewCampaignScopeSource(
input: AddAccessReviewCampaignScopeSourceInput!
): AddAccessReviewCampaignScopeSourcePayload!
removeAccessReviewCampaignScopeSource(
input: RemoveAccessReviewCampaignScopeSourceInput!
): RemoveAccessReviewCampaignScopeSourcePayload!
recordAccessEntryDecision(
input: RecordAccessEntryDecisionInput!
): RecordAccessEntryDecisionPayload!
recordAccessEntryDecisions(
input: RecordAccessEntryDecisionsInput!
): RecordAccessEntryDecisionsPayload!
flagAccessEntry(
input: FlagAccessEntryInput!
): FlagAccessEntryPayload!
addAccessReviewCampaignSource(
input: AddAccessReviewCampaignSourceInput!
): AddAccessReviewCampaignSourcePayload!
removeAccessReviewCampaignSource(
input: RemoveAccessReviewCampaignSourceInput!
): RemoveAccessReviewCampaignSourcePayload!
recordAccessReviewEntryDecision(
input: RecordAccessReviewEntryDecisionInput!
): RecordAccessReviewEntryDecisionPayload!
recordAccessReviewEntryDecisions(
input: RecordAccessReviewEntryDecisionsInput!
): RecordAccessReviewEntryDecisionsPayload!
flagAccessReviewEntry(
input: FlagAccessReviewEntryInput!
): FlagAccessReviewEntryPayload!
}
input CreateAccessSourceInput {
input CreateAccessReviewSourceInput {
organizationId: ID!
connectorId: ID
name: String!
csvData: String
}
type CreateAccessSourcePayload {
accessSourceEdge: AccessSourceEdge!
type CreateAccessReviewSourcePayload {
accessReviewSourceEdge: AccessReviewSourceEdge!
}
input UpdateAccessSourceInput {
accessSourceId: ID!
input UpdateAccessReviewSourceInput {
accessReviewSourceId: ID!
name: String @goField(omittable: true)
connectorId: ID @goField(omittable: true)
csvData: String @goField(omittable: true)
}
type UpdateAccessSourcePayload {
accessSource: AccessSource!
type UpdateAccessReviewSourcePayload {
accessReviewSource: AccessReviewSource!
}
input DeleteAccessSourceInput {
accessSourceId: ID!
input DeleteAccessReviewSourceInput {
accessReviewSourceId: ID!
}
type DeleteAccessSourcePayload {
deletedAccessSourceId: ID!
type DeleteAccessReviewSourcePayload {
deletedAccessReviewSourceId: ID!
}
input ConfigureAccessSourceInput {
accessSourceId: ID!
input ConfigureAccessReviewSourceInput {
accessReviewSourceId: ID!
organizationSlug: String!
}
type ConfigureAccessSourcePayload {
accessSource: AccessSource!
type ConfigureAccessReviewSourcePayload {
accessReviewSource: AccessReviewSource!
}
input CreateAccessReviewCampaignInput {
@@ -560,7 +582,7 @@ input CreateAccessReviewCampaignInput {
name: String!
description: String
frameworkControls: [String!]
accessSourceIds: [ID!]
accessReviewSourceIds: [ID!]
}
type CreateAccessReviewCampaignPayload {
@@ -610,54 +632,54 @@ type CancelAccessReviewCampaignPayload {
accessReviewCampaign: AccessReviewCampaign!
}
input AddAccessReviewCampaignScopeSourceInput {
input AddAccessReviewCampaignSourceInput {
accessReviewCampaignId: ID!
accessSourceId: ID!
accessReviewSourceId: ID!
}
type AddAccessReviewCampaignScopeSourcePayload {
type AddAccessReviewCampaignSourcePayload {
accessReviewCampaign: AccessReviewCampaign!
}
input RemoveAccessReviewCampaignScopeSourceInput {
input RemoveAccessReviewCampaignSourceInput {
accessReviewCampaignId: ID!
accessSourceId: ID!
accessReviewSourceId: ID!
}
type RemoveAccessReviewCampaignScopeSourcePayload {
type RemoveAccessReviewCampaignSourcePayload {
accessReviewCampaign: AccessReviewCampaign!
}
input RecordAccessEntryDecisionInput {
accessEntryId: ID!
decision: AccessEntryDecision!
input RecordAccessReviewEntryDecisionInput {
accessReviewEntryId: ID!
decision: AccessReviewEntryDecision!
decisionNote: String
}
type RecordAccessEntryDecisionPayload {
accessEntry: AccessEntry!
type RecordAccessReviewEntryDecisionPayload {
accessReviewEntry: AccessReviewEntry!
}
input RecordAccessEntryDecisionsInput {
decisions: [AccessEntryDecisionInput!]!
input RecordAccessReviewEntryDecisionsInput {
decisions: [AccessReviewEntryDecisionInput!]!
}
input AccessEntryDecisionInput {
accessEntryId: ID!
decision: AccessEntryDecision!
input AccessReviewEntryDecisionInput {
accessReviewEntryId: ID!
decision: AccessReviewEntryDecision!
decisionNote: String
}
type RecordAccessEntryDecisionsPayload {
accessEntries: [AccessEntry!]!
type RecordAccessReviewEntryDecisionsPayload {
accessReviewEntries: [AccessReviewEntry!]!
}
input FlagAccessEntryInput {
accessEntryId: ID!
flags: [AccessEntryFlag!]!
input FlagAccessReviewEntryInput {
accessReviewEntryId: ID!
flags: [AccessReviewEntryFlag!]!
flagReasons: [String!]
}
type FlagAccessEntryPayload {
accessEntry: AccessEntry!
type FlagAccessReviewEntryPayload {
accessReviewEntry: AccessReviewEntry!
}

View File

@@ -110,13 +110,13 @@ type Organization implements Node {
measureCategories: [String!]! @goField(forceResolver: true)
accessSources(
accessReviewSources(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: AccessSourceOrder
): AccessSourceConnection! @goField(forceResolver: true)
orderBy: AccessReviewSourceOrder
): AccessReviewSourceConnection! @goField(forceResolver: true)
accessReviewCampaigns(
first: Int

View File

@@ -11,6 +11,7 @@ import (
"fmt"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/accessreview"
"go.probo.inc/probo/pkg/agentrun"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
@@ -157,20 +158,20 @@ func (r *organizationResolver) MeasureCategories(ctx context.Context, obj *types
return categories, nil
}
// AccessSources is the resolver for the accessSources field.
func (r *organizationResolver) AccessSources(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessSourceOrder) (*types.AccessSourceConnection, error) {
scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessSourceList)
// AccessReviewSources is the resolver for the accessSources field.
func (r *organizationResolver) AccessReviewSources(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessReviewSourceOrder) (*types.AccessReviewSourceConnection, error) {
scope, err := r.authorize(ctx, obj.ID, accessreview.ActionSourceList)
if err != nil {
return nil, err
}
pageOrderBy := page.OrderBy[coredata.AccessSourceOrderField]{
Field: coredata.AccessSourceOrderFieldCreatedAt,
pageOrderBy := page.OrderBy[coredata.AccessReviewSourceOrderField]{
Field: coredata.AccessReviewSourceOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.AccessSourceOrderField]{
pageOrderBy = page.OrderBy[coredata.AccessReviewSourceOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
@@ -178,17 +179,17 @@ func (r *organizationResolver) AccessSources(ctx context.Context, obj *types.Org
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
p, err := r.accessReview.Sources(scope).ListForOrganizationID(ctx, obj.ID, cursor)
p, err := r.accessReview.ListSourcesForOrganizationID(ctx, scope, obj.ID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list access sources: %w", err))
}
return types.NewAccessSourceConnection(p, r, obj.ID), nil
return types.NewAccessReviewSourceConnection(p, r, obj.ID), nil
}
// AccessReviewCampaigns is the resolver for the accessReviewCampaigns field.
func (r *organizationResolver) AccessReviewCampaigns(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AccessReviewCampaignOrder) (*types.AccessReviewCampaignConnection, error) {
scope, err := r.authorize(ctx, obj.ID, probo.ActionAccessReviewCampaignList)
scope, err := r.authorize(ctx, obj.ID, accessreview.ActionCampaignList)
if err != nil {
return nil, err
}
@@ -207,7 +208,7 @@ func (r *organizationResolver) AccessReviewCampaigns(ctx context.Context, obj *t
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
p, err := r.accessReview.Campaigns(scope).ListForOrganizationID(ctx, obj.ID, cursor)
p, err := r.accessReview.ListCampaignsForOrganizationID(ctx, scope, obj.ID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list access review campaigns: %w", err))
}

View File

@@ -23,13 +23,13 @@ import (
)
type (
AccessSourceOrderBy OrderBy[coredata.AccessSourceOrderField]
AccessReviewSourceOrderBy OrderBy[coredata.AccessReviewSourceOrderField]
AccessReviewCampaignOrderBy OrderBy[coredata.AccessReviewCampaignOrderField]
AccessEntryOrderBy OrderBy[coredata.AccessEntryOrderField]
AccessReviewEntryOrderBy OrderBy[coredata.AccessReviewEntryOrderField]
AccessSourceConnection struct {
AccessReviewSourceConnection struct {
TotalCount int
Edges []*AccessSourceEdge
Edges []*AccessReviewSourceEdge
PageInfo PageInfo
Resolver any
@@ -45,32 +45,32 @@ type (
ParentID gid.GID
}
AccessEntryConnection struct {
AccessReviewEntryConnection struct {
TotalCount int
Edges []*AccessEntryEdge
Edges []*AccessReviewEntryEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
SourceID *gid.GID
Filter *coredata.AccessEntryFilter
Filter *coredata.AccessReviewEntryFilter
}
)
// AccessSource helpers
// AccessReviewSource helpers
func NewAccessSourceConnection(
p *page.Page[*coredata.AccessSource, coredata.AccessSourceOrderField],
func NewAccessReviewSourceConnection(
p *page.Page[*coredata.AccessReviewSource, coredata.AccessReviewSourceOrderField],
parentType any,
parentID gid.GID,
) *AccessSourceConnection {
edges := make([]*AccessSourceEdge, len(p.Data))
) *AccessReviewSourceConnection {
edges := make([]*AccessReviewSourceEdge, len(p.Data))
for i := range edges {
edges[i] = NewAccessSourceEdge(p.Data[i], p.Cursor.OrderBy.Field)
edges[i] = NewAccessReviewSourceEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &AccessSourceConnection{
return &AccessReviewSourceConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
@@ -79,15 +79,15 @@ func NewAccessSourceConnection(
}
}
func NewAccessSourceEdge(s *coredata.AccessSource, orderBy coredata.AccessSourceOrderField) *AccessSourceEdge {
return &AccessSourceEdge{
func NewAccessReviewSourceEdge(s *coredata.AccessReviewSource, orderBy coredata.AccessReviewSourceOrderField) *AccessReviewSourceEdge {
return &AccessReviewSourceEdge{
Cursor: s.CursorKey(orderBy),
Node: NewAccessSource(s),
Node: NewAccessReviewSource(s),
}
}
func NewAccessSource(s *coredata.AccessSource) *AccessSource {
return &AccessSource{
func NewAccessReviewSource(s *coredata.AccessReviewSource) *AccessReviewSource {
return &AccessReviewSource{
ID: s.ID,
Organization: &Organization{
ID: s.OrganizationID,
@@ -100,11 +100,14 @@ func NewAccessSource(s *coredata.AccessSource) *AccessSource {
}
}
func NewAccessReviewCampaignScopeSource(
campaignID gid.GID,
source *coredata.AccessSource,
fetch *coredata.AccessReviewCampaignSourceFetch,
) *AccessReviewCampaignScopeSource {
// 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.
func NewAccessReviewCampaignSource(
campaignSource *coredata.AccessReviewCampaignSource,
latestAttempt *coredata.AccessReviewCampaignSourceFetchAttempt,
) *AccessReviewCampaignSource {
status := coredata.AccessReviewCampaignSourceFetchStatusQueued
fetchedAccountsCount := 0
attemptCount := 0
@@ -115,20 +118,20 @@ func NewAccessReviewCampaignScopeSource(
fetchCompletedAt *time.Time
)
if fetch != nil {
status = fetch.Status
fetchedAccountsCount = fetch.FetchedAccountsCount
attemptCount = fetch.AttemptCount
lastError = fetch.LastError
fetchStartedAt = fetch.StartedAt
fetchCompletedAt = fetch.CompletedAt
if latestAttempt != nil {
status = latestAttempt.Status
fetchedAccountsCount = latestAttempt.FetchedAccountsCount
attemptCount = latestAttempt.AttemptNumber
lastError = latestAttempt.Error
fetchStartedAt = latestAttempt.StartedAt
fetchCompletedAt = latestAttempt.CompletedAt
}
return &AccessReviewCampaignScopeSource{
ID: source.ID,
CampaignID: campaignID,
Source: NewAccessSource(source),
Name: source.Name,
return &AccessReviewCampaignSource{
ID: campaignSource.ID,
CampaignID: campaignSource.AccessReviewCampaignID,
SourceID: campaignSource.AccessReviewSourceID,
Name: campaignSource.Name,
FetchStatus: status,
FetchedAccountsCount: fetchedAccountsCount,
AttemptCount: attemptCount,
@@ -138,6 +141,22 @@ func NewAccessReviewCampaignScopeSource(
}
}
// NewAccessReviewCampaignSourceFetchAttempt builds the GraphQL representation of a
// single append-only fetch attempt.
func NewAccessReviewCampaignSourceFetchAttempt(a *coredata.AccessReviewCampaignSourceFetchAttempt) *AccessReviewCampaignSourceFetchAttempt {
return &AccessReviewCampaignSourceFetchAttempt{
ID: a.ID,
AttemptNumber: a.AttemptNumber,
Status: a.Status,
FetchedAccountsCount: a.FetchedAccountsCount,
Error: a.Error,
StartedAt: a.StartedAt,
CompletedAt: a.CompletedAt,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
}
}
// AccessReviewCampaign helpers
func NewAccessReviewCampaignConnection(
@@ -186,8 +205,8 @@ func NewAccessReviewCampaign(c *coredata.AccessReviewCampaign) *AccessReviewCamp
return campaign
}
func NewAccessEntryDecisionHistoryEntry(h *coredata.AccessEntryDecisionHistory) *AccessEntryDecisionHistoryEntry {
entry := &AccessEntryDecisionHistoryEntry{
func NewAccessReviewEntryDecisionHistoryEntry(h *coredata.AccessReviewEntryDecisionHistory) *AccessReviewEntryDecisionHistoryEntry {
entry := &AccessReviewEntryDecisionHistoryEntry{
ID: h.ID,
Decision: h.Decision,
DecisionNote: h.DecisionNote,
@@ -202,22 +221,22 @@ func NewAccessEntryDecisionHistoryEntry(h *coredata.AccessEntryDecisionHistory)
return entry
}
// AccessEntry helpers
// AccessReviewEntry helpers
func NewAccessEntryConnection(
p *page.Page[*coredata.AccessEntry, coredata.AccessEntryOrderField],
func NewAccessReviewEntryConnection(
p *page.Page[*coredata.AccessReviewEntry, coredata.AccessReviewEntryOrderField],
parentType any,
parentID gid.GID,
sourceID *gid.GID,
filter *coredata.AccessEntryFilter,
) *AccessEntryConnection {
edges := make([]*AccessEntryEdge, len(p.Data))
filter *coredata.AccessReviewEntryFilter,
) *AccessReviewEntryConnection {
edges := make([]*AccessReviewEntryEdge, len(p.Data))
for i := range edges {
edges[i] = NewAccessEntryEdge(p.Data[i], p.Cursor.OrderBy.Field)
edges[i] = NewAccessReviewEntryEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &AccessEntryConnection{
return &AccessReviewEntryConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
@@ -228,22 +247,19 @@ func NewAccessEntryConnection(
}
}
func NewAccessEntryEdge(e *coredata.AccessEntry, orderBy coredata.AccessEntryOrderField) *AccessEntryEdge {
return &AccessEntryEdge{
func NewAccessReviewEntryEdge(e *coredata.AccessReviewEntry, orderBy coredata.AccessReviewEntryOrderField) *AccessReviewEntryEdge {
return &AccessReviewEntryEdge{
Cursor: e.CursorKey(orderBy),
Node: NewAccessEntry(e),
Node: NewAccessReviewEntry(e),
}
}
func NewAccessEntry(e *coredata.AccessEntry) *AccessEntry {
entry := &AccessEntry{
func NewAccessReviewEntry(e *coredata.AccessReviewEntry) *AccessReviewEntry {
entry := &AccessReviewEntry{
ID: e.ID,
Campaign: &AccessReviewCampaign{
ID: e.AccessReviewCampaignID,
},
AccessSource: &AccessSource{
ID: e.AccessSourceID,
},
Email: e.Email,
FullName: e.FullName,
Role: e.Role,
@@ -273,32 +289,32 @@ func NewAccessEntry(e *coredata.AccessEntry) *AccessEntry {
return entry
}
func NewAccessReviewCampaignStatistics(stats *coredata.AccessEntryStatistics) *AccessReviewCampaignStatistics {
decisionCounts := make([]*AccessEntryDecisionCount, 0, len(stats.DecisionCounts))
func NewAccessReviewStatistics(stats *coredata.AccessReviewStatistics) *AccessReviewStatistics {
decisionCounts := make([]*AccessReviewEntryDecisionCount, 0, len(stats.DecisionCounts))
for decision, count := range stats.DecisionCounts {
decisionCounts = append(
decisionCounts,
&AccessEntryDecisionCount{Decision: decision, Count: count},
&AccessReviewEntryDecisionCount{Decision: decision, Count: count},
)
}
flagCounts := make([]*AccessEntryFlagCount, 0, len(stats.FlagCounts))
flagCounts := make([]*AccessReviewEntryFlagCount, 0, len(stats.FlagCounts))
for flag, count := range stats.FlagCounts {
flagCounts = append(
flagCounts,
&AccessEntryFlagCount{Flag: flag, Count: count},
&AccessReviewEntryFlagCount{Flag: flag, Count: count},
)
}
incrementalTagCounts := make([]*AccessEntryIncrementalTagCount, 0, len(stats.IncrementalTagCounts))
incrementalTagCounts := make([]*AccessReviewEntryIncrementalTagCount, 0, len(stats.IncrementalTagCounts))
for tag, count := range stats.IncrementalTagCounts {
incrementalTagCounts = append(
incrementalTagCounts,
&AccessEntryIncrementalTagCount{IncrementalTag: tag, Count: count},
&AccessReviewEntryIncrementalTagCount{IncrementalTag: tag, Count: count},
)
}
return &AccessReviewCampaignStatistics{
return &AccessReviewStatistics{
TotalCount: stats.TotalCount,
DecisionCounts: decisionCounts,
FlagCounts: flagCounts,

View File

@@ -1,89 +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 (
"testing"
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
func TestNewAccessReviewCampaignScopeSource_DefaultFetchState(t *testing.T) {
t.Parallel()
tenantID := gid.NewTenantID()
source := &coredata.AccessSource{
ID: gid.New(tenantID, coredata.AccessSourceEntityType),
OrganizationID: gid.New(tenantID, coredata.OrganizationEntityType),
Name: "Google Workspace",
}
campaignID := gid.New(tenantID, coredata.AccessReviewCampaignEntityType)
got := NewAccessReviewCampaignScopeSource(campaignID, source, nil)
if got.FetchStatus != coredata.AccessReviewCampaignSourceFetchStatusQueued {
t.Fatalf("fetch status = %q, want QUEUED", got.FetchStatus)
}
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)
}
}
func TestNewAccessReviewCampaignScopeSource_UsesFetchState(t *testing.T) {
t.Parallel()
now := time.Now()
errMsg := "connector timeout"
tenantID := gid.NewTenantID()
source := &coredata.AccessSource{
ID: gid.New(tenantID, coredata.AccessSourceEntityType),
OrganizationID: gid.New(tenantID, coredata.OrganizationEntityType),
Name: "Linear",
}
fetch := &coredata.AccessReviewCampaignSourceFetch{
Status: coredata.AccessReviewCampaignSourceFetchStatusFailed,
FetchedAccountsCount: 42,
AttemptCount: 3,
LastError: &errMsg,
StartedAt: &now,
CompletedAt: &now,
}
campaignID := gid.New(tenantID, coredata.AccessReviewCampaignEntityType)
got := NewAccessReviewCampaignScopeSource(campaignID, source, fetch)
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)
}
}

View File

@@ -21,10 +21,12 @@ import (
"go.probo.inc/probo/pkg/gid"
)
type AccessReviewCampaignScopeSource struct {
ID gid.GID `json:"id"`
CampaignID gid.GID `json:"-"`
Source *AccessSource `json:"source"`
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"`
@@ -33,3 +35,15 @@ type AccessReviewCampaignScopeSource struct {
FetchStartedAt *time.Time `json:"fetchStartedAt,omitempty"`
FetchCompletedAt *time.Time `json:"fetchCompletedAt,omitempty"`
}
type AccessReviewCampaignSourceFetchAttempt struct {
ID gid.GID `json:"id"`
AttemptNumber int `json:"attemptNumber"`
Status coredata.AccessReviewCampaignSourceFetchStatus `json:"status"`
FetchedAccountsCount int `json:"fetchedAccountsCount"`
Error *string `json:"error,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

View File

@@ -0,0 +1,117 @@
// 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 (
"testing"
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
func newTestCampaignSource(tenantID gid.TenantID, sourceID *gid.GID, name string) *coredata.AccessReviewCampaignSource {
return &coredata.AccessReviewCampaignSource{
ID: gid.New(tenantID, coredata.AccessReviewCampaignSourceEntityType),
TenantID: tenantID,
AccessReviewCampaignID: gid.New(tenantID, coredata.AccessReviewCampaignEntityType),
AccessReviewSourceID: sourceID,
Name: name,
Category: coredata.AccessReviewSourceCategorySaaS,
}
}
func TestNewAccessReviewCampaignSource_DefaultFetchState(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)
}
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.SourceID == nil || *got.SourceID != sourceID {
t.Fatalf("source id = %v, want %v", got.SourceID, sourceID)
}
if got.Name != "Google Workspace" {
t.Fatalf("name = %q, want snapshot name", got.Name)
}
}
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.
func TestNewAccessReviewCampaignSource_DeletedSource(t *testing.T) {
t.Parallel()
tenantID := gid.NewTenantID()
campaignSource := newTestCampaignSource(tenantID, nil, "Deleted Source")
got := NewAccessReviewCampaignSource(campaignSource, nil)
if got.SourceID != nil {
t.Fatalf("source id = %v, want nil for deleted source", got.SourceID)
}
if got.Name != "Deleted Source" {
t.Fatalf("name = %q, want snapshot name preserved", got.Name)
}
}