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

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2025-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 accessreview
// Access-review service actions.
// Format: access-review:<entity>:<action>
const (
// Campaign actions
ActionCampaignGet = "access-review:campaign:get"
ActionCampaignList = "access-review:campaign:list"
ActionCampaignCreate = "access-review:campaign:create"
ActionCampaignUpdate = "access-review:campaign:update"
ActionCampaignDelete = "access-review:campaign:delete"
ActionCampaignStart = "access-review:campaign:start"
ActionCampaignClose = "access-review:campaign:close"
ActionCampaignCancel = "access-review:campaign:cancel"
ActionCampaignAddSource = "access-review:campaign:add-source"
ActionCampaignRemoveSource = "access-review:campaign:remove-source"
// Entry actions
ActionEntryGet = "access-review:entry:get"
ActionEntryList = "access-review:entry:list"
ActionEntryDecide = "access-review:entry:decide"
ActionEntryFlag = "access-review:entry:flag"
// Source actions
ActionSourceGet = "access-review:source:get"
ActionSourceList = "access-review:source:list"
ActionSourceCreate = "access-review:source:create"
ActionSourceUpdate = "access-review:source:update"
ActionSourceDelete = "access-review:source:delete"
ActionSourceSync = "access-review:source:sync"
)

View File

@@ -25,20 +25,9 @@ import (
"go.probo.inc/probo/pkg/page"
)
type CampaignService struct {
pg *pg.Client
scope coredata.Scoper
}
func NewCampaignService(pgClient *pg.Client, scope coredata.Scoper) *CampaignService {
return &CampaignService{
pg: pgClient,
scope: scope,
}
}
func (s *CampaignService) Create(
func (s *Service) CreateCampaign(
ctx context.Context,
scope coredata.Scoper,
req CreateAccessReviewCampaignRequest,
) (*coredata.AccessReviewCampaign, error) {
if err := req.Validate(); err != nil {
@@ -47,7 +36,7 @@ func (s *CampaignService) Create(
now := time.Now()
campaign := &coredata.AccessReviewCampaign{
ID: gid.New(s.scope.GetTenantID(), coredata.AccessReviewCampaignEntityType),
ID: gid.New(scope.GetTenantID(), coredata.AccessReviewCampaignEntityType),
OrganizationID: req.OrganizationID,
Name: req.Name,
Description: req.Description,
@@ -60,13 +49,13 @@ func (s *CampaignService) Create(
err := s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
if err := campaign.Insert(ctx, conn, s.scope); err != nil {
if err := campaign.Insert(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot insert access review campaign: %w", err)
}
for _, sourceID := range req.AccessSourceIDs {
source := &coredata.AccessSource{}
if err := source.LoadByID(ctx, conn, s.scope, sourceID); err != nil {
for _, sourceID := range req.AccessReviewSourceIDs {
source := &coredata.AccessReviewSource{}
if err := source.LoadByID(ctx, conn, scope, sourceID); err != nil {
return fmt.Errorf("cannot load access source %s: %w", sourceID, err)
}
@@ -74,12 +63,8 @@ func (s *CampaignService) Create(
return fmt.Errorf("cannot create campaign: access source %s does not belong to the same organization", sourceID)
}
scopeSystem := coredata.AccessReviewCampaignScopeSystem{
AccessReviewCampaignID: campaign.ID,
AccessSourceID: sourceID,
}
if err := scopeSystem.Insert(ctx, conn, s.scope); err != nil {
return fmt.Errorf("cannot insert scope system: %w", err)
if err := s.upsertCampaignSource(ctx, conn, scope, campaign.ID, source); err != nil {
return fmt.Errorf("cannot snapshot scope source: %w", err)
}
}
@@ -93,8 +78,9 @@ func (s *CampaignService) Create(
return campaign, nil
}
func (s *CampaignService) Get(
func (s *Service) GetCampaign(
ctx context.Context,
scope coredata.Scoper,
campaignID gid.GID,
) (*coredata.AccessReviewCampaign, error) {
campaign := &coredata.AccessReviewCampaign{}
@@ -102,7 +88,7 @@ func (s *CampaignService) Get(
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := campaign.LoadByID(ctx, conn, s.scope, campaignID); err != nil {
if err := campaign.LoadByID(ctx, conn, scope, campaignID); err != nil {
return fmt.Errorf("cannot load campaign: %w", err)
}
@@ -116,8 +102,33 @@ func (s *CampaignService) Get(
return campaign, nil
}
func (s *CampaignService) Update(
func (s *Service) GetCampaignSource(
ctx context.Context,
scope coredata.Scoper,
campaignSourceID gid.GID,
) (*coredata.AccessReviewCampaignSource, error) {
campaignSource := &coredata.AccessReviewCampaignSource{}
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := campaignSource.LoadByID(ctx, conn, scope, campaignSourceID); err != nil {
return fmt.Errorf("cannot load campaign source: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return campaignSource, nil
}
func (s *Service) UpdateCampaign(
ctx context.Context,
scope coredata.Scoper,
req UpdateAccessReviewCampaignRequest,
) (*coredata.AccessReviewCampaign, error) {
if err := req.Validate(); err != nil {
@@ -129,11 +140,11 @@ func (s *CampaignService) Update(
err := s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
if err := lockCampaignForUpdate(ctx, conn, s.scope, req.CampaignID); err != nil {
if err := lockCampaignForUpdate(ctx, conn, scope, req.CampaignID); err != nil {
return fmt.Errorf("cannot lock campaign: %w", err)
}
if err := campaign.LoadByID(ctx, conn, s.scope, req.CampaignID); err != nil {
if err := campaign.LoadByID(ctx, conn, scope, req.CampaignID); err != nil {
return fmt.Errorf("cannot load campaign: %w", err)
}
@@ -155,7 +166,7 @@ func (s *CampaignService) Update(
campaign.UpdatedAt = time.Now()
if err := campaign.Update(ctx, conn, s.scope); err != nil {
if err := campaign.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update campaign: %w", err)
}
@@ -169,19 +180,20 @@ func (s *CampaignService) Update(
return campaign, nil
}
func (s *CampaignService) Delete(
func (s *Service) DeleteCampaign(
ctx context.Context,
scope coredata.Scoper,
campaignID gid.GID,
) error {
return s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
if err := lockCampaignForUpdate(ctx, conn, s.scope, campaignID); err != nil {
if err := lockCampaignForUpdate(ctx, conn, scope, campaignID); err != nil {
return fmt.Errorf("cannot lock campaign: %w", err)
}
campaign := &coredata.AccessReviewCampaign{}
if err := campaign.LoadByID(ctx, conn, s.scope, campaignID); err != nil {
if err := campaign.LoadByID(ctx, conn, scope, campaignID); err != nil {
return fmt.Errorf("cannot load campaign: %w", err)
}
@@ -190,7 +202,7 @@ func (s *CampaignService) Delete(
return fmt.Errorf("cannot delete campaign: status is %s, expected %s or %s", campaign.Status, coredata.AccessReviewCampaignStatusDraft, coredata.AccessReviewCampaignStatusCancelled)
}
if err := campaign.Delete(ctx, conn, s.scope); err != nil {
if err := campaign.Delete(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot delete campaign: %w", err)
}
@@ -199,20 +211,21 @@ func (s *CampaignService) Delete(
)
}
func (s *CampaignService) AddScopeSource(
func (s *Service) AddCampaignSource(
ctx context.Context,
req AddCampaignScopeSourceRequest,
scope coredata.Scoper,
req AddCampaignSourceRequest,
) (*coredata.AccessReviewCampaign, error) {
campaign := &coredata.AccessReviewCampaign{}
err := s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
if err := lockCampaignForUpdate(ctx, conn, s.scope, req.CampaignID); err != nil {
if err := lockCampaignForUpdate(ctx, conn, scope, req.CampaignID); err != nil {
return fmt.Errorf("cannot lock campaign: %w", err)
}
if err := campaign.LoadByID(ctx, conn, s.scope, req.CampaignID); err != nil {
if err := campaign.LoadByID(ctx, conn, scope, req.CampaignID); err != nil {
return fmt.Errorf("cannot load campaign: %w", err)
}
@@ -220,21 +233,17 @@ func (s *CampaignService) AddScopeSource(
return fmt.Errorf("cannot add scope source: campaign status is %s, expected %s", campaign.Status, coredata.AccessReviewCampaignStatusDraft)
}
source := &coredata.AccessSource{}
if err := source.LoadByID(ctx, conn, s.scope, req.AccessSourceID); err != nil {
return fmt.Errorf("cannot load access source %s: %w", req.AccessSourceID, err)
source := &coredata.AccessReviewSource{}
if err := source.LoadByID(ctx, conn, scope, req.AccessReviewSourceID); err != nil {
return fmt.Errorf("cannot load access source %s: %w", req.AccessReviewSourceID, err)
}
if source.OrganizationID != campaign.OrganizationID {
return fmt.Errorf("cannot add scope source: access source %q does not belong to the same organization", req.AccessSourceID)
return fmt.Errorf("cannot add scope source: access source %q does not belong to the same organization", req.AccessReviewSourceID)
}
scopeSystem := coredata.AccessReviewCampaignScopeSystem{
AccessReviewCampaignID: campaign.ID,
AccessSourceID: req.AccessSourceID,
}
if err := scopeSystem.Upsert(ctx, conn, s.scope); err != nil {
return fmt.Errorf("cannot upsert scope system: %w", err)
if err := s.upsertCampaignSource(ctx, conn, scope, campaign.ID, source); err != nil {
return fmt.Errorf("cannot snapshot scope source: %w", err)
}
return nil
@@ -247,20 +256,21 @@ func (s *CampaignService) AddScopeSource(
return campaign, nil
}
func (s *CampaignService) RemoveScopeSource(
func (s *Service) RemoveCampaignSource(
ctx context.Context,
req RemoveCampaignScopeSourceRequest,
scope coredata.Scoper,
req RemoveCampaignSourceRequest,
) (*coredata.AccessReviewCampaign, error) {
campaign := &coredata.AccessReviewCampaign{}
err := s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
if err := lockCampaignForUpdate(ctx, conn, s.scope, req.CampaignID); err != nil {
if err := lockCampaignForUpdate(ctx, conn, scope, req.CampaignID); err != nil {
return fmt.Errorf("cannot lock campaign: %w", err)
}
if err := campaign.LoadByID(ctx, conn, s.scope, req.CampaignID); err != nil {
if err := campaign.LoadByID(ctx, conn, scope, req.CampaignID); err != nil {
return fmt.Errorf("cannot load campaign: %w", err)
}
@@ -268,12 +278,9 @@ func (s *CampaignService) RemoveScopeSource(
return fmt.Errorf("cannot remove scope source: campaign status is %s, expected DRAFT", campaign.Status)
}
scopeSystem := coredata.AccessReviewCampaignScopeSystem{
AccessReviewCampaignID: campaign.ID,
AccessSourceID: req.AccessSourceID,
}
if err := scopeSystem.Delete(ctx, conn, s.scope); err != nil {
return fmt.Errorf("cannot delete scope system: %w", err)
campaignSource := &coredata.AccessReviewCampaignSource{}
if err := campaignSource.DeleteByCampaignIDAndAccessReviewSourceID(ctx, conn, scope, campaign.ID, req.AccessReviewSourceID); err != nil {
return fmt.Errorf("cannot delete campaign source: %w", err)
}
return nil
@@ -286,8 +293,9 @@ func (s *CampaignService) RemoveScopeSource(
return campaign, nil
}
func (s *CampaignService) Start(
func (s *Service) StartCampaign(
ctx context.Context,
scope coredata.Scoper,
campaignID gid.GID,
) (*coredata.AccessReviewCampaign, error) {
campaign := &coredata.AccessReviewCampaign{}
@@ -295,11 +303,11 @@ func (s *CampaignService) Start(
err := s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
if err := lockCampaignForUpdate(ctx, conn, s.scope, campaignID); err != nil {
if err := lockCampaignForUpdate(ctx, conn, scope, campaignID); err != nil {
return fmt.Errorf("cannot lock campaign: %w", err)
}
if err := campaign.LoadByID(ctx, conn, s.scope, campaignID); err != nil {
if err := campaign.LoadByID(ctx, conn, scope, campaignID); err != nil {
return fmt.Errorf("cannot load campaign: %w", err)
}
@@ -307,12 +315,12 @@ func (s *CampaignService) Start(
return fmt.Errorf("cannot start campaign: status is %s, expected %s", campaign.Status, coredata.AccessReviewCampaignStatusDraft)
}
var sources coredata.AccessSources
if err := sources.LoadScopeSourcesByCampaignID(ctx, conn, s.scope, campaign.ID); err != nil {
return fmt.Errorf("cannot load scope sources: %w", err)
var campaignSources coredata.AccessReviewCampaignSources
if err := campaignSources.LoadByCampaignID(ctx, conn, scope, campaign.ID); err != nil {
return fmt.Errorf("cannot load campaign sources: %w", err)
}
if len(sources) == 0 {
if len(campaignSources) == 0 {
return fmt.Errorf("cannot start campaign: no scope sources configured")
}
@@ -321,11 +329,11 @@ func (s *CampaignService) Start(
campaign.StartedAt = &now
campaign.UpdatedAt = now
if err := campaign.Update(ctx, conn, s.scope); err != nil {
if err := campaign.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update campaign: %w", err)
}
if err := s.enqueueSourceFetches(ctx, conn, campaign.ID, sources); err != nil {
if err := s.enqueueSourceFetches(ctx, conn, scope, campaignSources); err != nil {
return fmt.Errorf("cannot queue source fetches: %w", err)
}
@@ -339,8 +347,9 @@ func (s *CampaignService) Start(
return campaign, nil
}
func (s *CampaignService) Close(
func (s *Service) CloseCampaign(
ctx context.Context,
scope coredata.Scoper,
campaignID gid.GID,
) (*coredata.AccessReviewCampaign, error) {
campaign := &coredata.AccessReviewCampaign{}
@@ -348,11 +357,11 @@ func (s *CampaignService) Close(
err := s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
if err := lockCampaignForUpdate(ctx, conn, s.scope, campaignID); err != nil {
if err := lockCampaignForUpdate(ctx, conn, scope, campaignID); err != nil {
return fmt.Errorf("cannot lock campaign: %w", err)
}
if err := campaign.LoadByID(ctx, conn, s.scope, campaignID); err != nil {
if err := campaign.LoadByID(ctx, conn, scope, campaignID); err != nil {
return fmt.Errorf("cannot load campaign: %w", err)
}
@@ -360,9 +369,9 @@ func (s *CampaignService) Close(
return fmt.Errorf("cannot close campaign: status is %s, expected %s", campaign.Status, coredata.AccessReviewCampaignStatusPendingActions)
}
entries := coredata.AccessEntries{}
entries := coredata.AccessReviewEntries{}
pendingCount, err := entries.CountPendingByCampaignID(ctx, conn, s.scope, campaignID)
pendingCount, err := entries.CountPendingByCampaignID(ctx, conn, scope, campaignID)
if err != nil {
return fmt.Errorf("cannot count pending entries: %w", err)
}
@@ -376,7 +385,7 @@ func (s *CampaignService) Close(
campaign.CompletedAt = &now
campaign.UpdatedAt = now
if err := campaign.Update(ctx, conn, s.scope); err != nil {
if err := campaign.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update campaign: %w", err)
}
@@ -399,29 +408,63 @@ func lockCampaignForUpdate(ctx context.Context, tx pg.Tx, scope coredata.Scoper,
return nil
}
func (s *CampaignService) enqueueSourceFetches(
// upsertCampaignSource snapshots a live access source into the campaign's scope
// so the review keeps the source identity even if the source is later deleted.
func (s *Service) upsertCampaignSource(
ctx context.Context,
tx pg.Tx,
scope coredata.Scoper,
campaignID gid.GID,
sources coredata.AccessSources,
source *coredata.AccessReviewSource,
) error {
now := time.Now()
sourceID := source.ID
campaignSource := &coredata.AccessReviewCampaignSource{
ID: gid.New(scope.GetTenantID(), coredata.AccessReviewCampaignSourceEntityType),
AccessReviewCampaignID: campaignID,
AccessReviewSourceID: &sourceID,
Name: source.Name,
Category: source.Category,
ConnectorID: source.ConnectorID,
CreatedAt: now,
UpdatedAt: now,
}
if err := campaignSource.Upsert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot upsert campaign source %s: %w", source.ID, err)
}
return nil
}
func (s *Service) enqueueSourceFetches(
ctx context.Context,
tx pg.Tx,
scope coredata.Scoper,
campaignSources coredata.AccessReviewCampaignSources,
) error {
now := time.Now()
for _, source := range sources {
fetch := &coredata.AccessReviewCampaignSourceFetch{
AccessReviewCampaignID: campaignID,
AccessSourceID: source.ID,
for _, campaignSource := range campaignSources {
attempt := &coredata.AccessReviewCampaignSourceFetchAttempt{
ID: gid.New(scope.GetTenantID(), coredata.AccessReviewCampaignSourceFetchAttemptEntityType),
AccessReviewCampaignSourceID: campaignSource.ID,
Status: coredata.AccessReviewCampaignSourceFetchStatusQueued,
CreatedAt: now,
UpdatedAt: now,
}
if err := fetch.UpsertQueued(ctx, tx, s.scope, now); err != nil {
return fmt.Errorf("cannot queue source fetch %s: %w", source.ID, err)
if err := attempt.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot queue source fetch %s: %w", campaignSource.ID, err)
}
}
return nil
}
func (s *CampaignService) Cancel(
func (s *Service) CancelCampaign(
ctx context.Context,
scope coredata.Scoper,
campaignID gid.GID,
) (*coredata.AccessReviewCampaign, error) {
campaign := &coredata.AccessReviewCampaign{}
@@ -429,11 +472,11 @@ func (s *CampaignService) Cancel(
err := s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
if err := lockCampaignForUpdate(ctx, conn, s.scope, campaignID); err != nil {
if err := lockCampaignForUpdate(ctx, conn, scope, campaignID); err != nil {
return fmt.Errorf("cannot lock campaign: %w", err)
}
if err := campaign.LoadByID(ctx, conn, s.scope, campaignID); err != nil {
if err := campaign.LoadByID(ctx, conn, scope, campaignID); err != nil {
return fmt.Errorf("cannot load campaign: %w", err)
}
@@ -447,7 +490,7 @@ func (s *CampaignService) Cancel(
campaign.CompletedAt = &now
campaign.UpdatedAt = now
if err := campaign.Update(ctx, conn, s.scope); err != nil {
if err := campaign.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update campaign: %w", err)
}
@@ -461,8 +504,9 @@ func (s *CampaignService) Cancel(
return campaign, nil
}
func (s *CampaignService) ListForOrganizationID(
func (s *Service) ListCampaignsForOrganizationID(
ctx context.Context,
scope coredata.Scoper,
organizationID gid.GID,
cursor *page.Cursor[coredata.AccessReviewCampaignOrderField],
) (*page.Page[*coredata.AccessReviewCampaign, coredata.AccessReviewCampaignOrderField], error) {
@@ -471,7 +515,7 @@ func (s *CampaignService) ListForOrganizationID(
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := campaigns.LoadByOrganizationID(ctx, conn, s.scope, organizationID, cursor); err != nil {
if err := campaigns.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor); err != nil {
return fmt.Errorf("cannot load campaigns by organization: %w", err)
}
@@ -485,17 +529,18 @@ func (s *CampaignService) ListForOrganizationID(
return page.NewPage(campaigns, cursor), nil
}
func (s *CampaignService) ListSourceFetches(
func (s *Service) ListCampaignSources(
ctx context.Context,
scope coredata.Scoper,
campaignID gid.GID,
) (coredata.AccessReviewCampaignSourceFetches, error) {
var fetches coredata.AccessReviewCampaignSourceFetches
) (coredata.AccessReviewCampaignSources, error) {
var sources coredata.AccessReviewCampaignSources
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := fetches.LoadByCampaignID(ctx, conn, s.scope, campaignID); err != nil {
return fmt.Errorf("cannot load source fetches by campaign: %w", err)
if err := sources.LoadByCampaignID(ctx, conn, scope, campaignID); err != nil {
return fmt.Errorf("cannot load campaign sources: %w", err)
}
return nil
@@ -505,11 +550,60 @@ func (s *CampaignService) ListSourceFetches(
return nil, err
}
return fetches, nil
return sources, nil
}
func (s *CampaignService) CountForOrganizationID(
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(
ctx context.Context,
scope coredata.Scoper,
campaignSourceID 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.LoadByCampaignSourceID(ctx, conn, scope, campaignSourceID); err != nil {
return fmt.Errorf("cannot load fetch attempts: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return attempts, nil
}
func (s *Service) CountCampaignsForOrganizationID(
ctx context.Context,
scope coredata.Scoper,
organizationID gid.GID,
) (int, error) {
var count int
@@ -519,7 +613,7 @@ func (s *CampaignService) CountForOrganizationID(
func(ctx context.Context, conn pg.Querier) (err error) {
campaigns := coredata.AccessReviewCampaigns{}
count, err = campaigns.CountByOrganizationID(ctx, conn, s.scope, organizationID)
count, err = campaigns.CountByOrganizationID(ctx, conn, scope, organizationID)
if err != nil {
return fmt.Errorf("cannot count campaigns by organization: %w", err)
}

View File

@@ -24,11 +24,11 @@ const campaignNameMaxLength = 255
type (
CreateAccessReviewCampaignRequest struct {
OrganizationID gid.GID
Name string
Description string
FrameworkControls []string
AccessSourceIDs []gid.GID
OrganizationID gid.GID
Name string
Description string
FrameworkControls []string
AccessReviewSourceIDs []gid.GID
}
UpdateAccessReviewCampaignRequest struct {
@@ -38,14 +38,14 @@ type (
FrameworkControls *[]string
}
AddCampaignScopeSourceRequest struct {
CampaignID gid.GID
AccessSourceID gid.GID
AddCampaignSourceRequest struct {
CampaignID gid.GID
AccessReviewSourceID gid.GID
}
RemoveCampaignScopeSourceRequest struct {
CampaignID gid.GID
AccessSourceID gid.GID
RemoveCampaignSourceRequest struct {
CampaignID gid.GID
AccessReviewSourceID gid.GID
}
)

View File

@@ -75,8 +75,8 @@ func (d *AnthropicDriver) ListAccounts(ctx context.Context) ([]AccountRecord, er
IsAdmin: u.Role == "admin",
ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
// added_at is an RFC 3339 datetime string; ignore parse

View File

@@ -103,8 +103,8 @@ func (d *AsanaDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error)
FullName: u.Name,
ExternalID: u.GID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
},
)
}

View File

@@ -98,8 +98,8 @@ func (d *BetterStackDriver) ListAccounts(ctx context.Context) ([]AccountRecord,
Active: betterStackActive(member.Type),
IsAdmin: betterStackIsAdmin(member.Attributes.Role),
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: member.ID,
}

View File

@@ -100,8 +100,8 @@ func (d *BitbucketDriver) ListAccounts(ctx context.Context) ([]AccountRecord, er
FullName: fullName,
ExternalID: m.User.AccountID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
records = append(records, record)

View File

@@ -72,8 +72,8 @@ func (d *BrexDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error)
IsAdmin: false,
ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
if record.Email != "" {

View File

@@ -94,7 +94,7 @@ func (d *ClerkDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error)
IsAdmin: false,
MFAStatus: clerkMFAStatus(u),
AuthMethod: clerkAuthMethod(u),
AccountType: coredata.AccessEntryAccountTypeUser,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: u.ID,
}
@@ -211,12 +211,12 @@ func clerkMFAStatus(u clerkUser) coredata.MFAStatus {
return coredata.MFAStatusDisabled
}
func clerkAuthMethod(u clerkUser) coredata.AccessEntryAuthMethod {
func clerkAuthMethod(u clerkUser) coredata.AccessReviewEntryAuthMethod {
if u.PasswordEnabled {
return coredata.AccessEntryAuthMethodPassword
return coredata.AccessReviewEntryAuthMethodPassword
}
return coredata.AccessEntryAuthMethodUnknown
return coredata.AccessReviewEntryAuthMethodUnknown
}
func clerkUnixMillisToTime(unixMillis int64) *time.Time {

View File

@@ -40,11 +40,11 @@ func TestClerkDriver(t *testing.T) {
assert.Equal(t, "user_3EfkCEWmtIsoMD3rRxIpDsBOPzv", first.ExternalID)
assert.Equal(t, "c@example.com", first.Email)
assert.Equal(t, "c c", first.FullName)
assert.Equal(t, coredata.AccessEntryAccountTypeUser, first.AccountType)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, first.AccountType)
require.NotNil(t, first.Active)
assert.True(t, *first.Active)
assert.Equal(t, coredata.MFAStatusDisabled, first.MFAStatus)
assert.Equal(t, coredata.AccessEntryAuthMethodPassword, first.AuthMethod)
assert.Equal(t, coredata.AccessReviewEntryAuthMethodPassword, first.AuthMethod)
assert.NotNil(t, first.CreatedAt)
assert.Nil(t, first.LastLogin)
@@ -60,7 +60,7 @@ func TestClerkDriver(t *testing.T) {
assert.Equal(t, "a a", third.FullName)
require.NotNil(t, third.Active)
assert.False(t, *third.Active)
assert.Equal(t, coredata.AccessEntryAuthMethodPassword, third.AuthMethod)
assert.Equal(t, coredata.AccessReviewEntryAuthMethodPassword, third.AuthMethod)
}
func TestClerkPrimaryEmail(t *testing.T) {

View File

@@ -111,8 +111,8 @@ func (d *ClickUpDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
IsAdmin: isAdmin,
ExternalID: m.User.ID.String(),
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
if m.InvitePending != nil {

View File

@@ -197,8 +197,8 @@ func (d *CloudflareDriver) queryAllMembers(ctx context.Context, accountID string
IsAdmin: isAdmin,
ExternalID: m.ID,
MFAStatus: mfaStatus,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
if record.Email != "" {

View File

@@ -70,8 +70,8 @@ func (d *CSVDriver) ListAccounts(_ context.Context) ([]AccountRecord, error) {
record := AccountRecord{
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
if idx, ok := colIndex["email"]; ok && idx < len(row) {
@@ -104,7 +104,7 @@ func (d *CSVDriver) ListAccounts(_ context.Context) ([]AccountRecord, error) {
if idx, ok := colIndex["account_type"]; ok && idx < len(row) {
if strings.TrimSpace(strings.ToUpper(row[idx])) == "SERVICE_ACCOUNT" {
record.AccountType = coredata.AccessEntryAccountTypeServiceAccount
record.AccountType = coredata.AccessReviewEntryAccountTypeServiceAccount
}
}

View File

@@ -123,9 +123,9 @@ func (d *DatadogDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
}
}
accountType := coredata.AccessEntryAccountTypeUser
accountType := coredata.AccessReviewEntryAccountTypeUser
if u.Attributes.ServiceAccount {
accountType = coredata.AccessEntryAccountTypeServiceAccount
accountType = coredata.AccessReviewEntryAccountTypeServiceAccount
}
mfaStatus := coredata.MFAStatusDisabled
@@ -144,7 +144,7 @@ func (d *DatadogDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
// Datadog's /api/v2/users does not expose the login method
// used (no allowed_login_methods in the schema), so the
// auth method is unknown.
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: accountType,
ExternalID: u.ID,
CreatedAt: parseRFC3339Ptr(u.Attributes.CreatedAt),

View File

@@ -44,9 +44,9 @@ func TestDatadogDriver(t *testing.T) {
assert.True(t, r.IsAdmin)
assert.Equal(t, "Datadog Admin Role", r.Role)
assert.Equal(t, "Security Engineer", r.JobTitle)
assert.Equal(t, coredata.AccessEntryAccountTypeUser, r.AccountType)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, r.AccountType)
assert.Equal(t, coredata.MFAStatusEnabled, r.MFAStatus)
assert.Equal(t, coredata.AccessEntryAuthMethodUnknown, r.AuthMethod)
assert.Equal(t, coredata.AccessReviewEntryAuthMethodUnknown, r.AuthMethod)
// Second record exercises the inactive, non-admin, and service-account
// (MFA-disabled) branches.
@@ -57,6 +57,6 @@ func TestDatadogDriver(t *testing.T) {
assert.False(t, *r2.Active)
assert.False(t, r2.IsAdmin)
assert.Equal(t, "Datadog Standard Role", r2.Role)
assert.Equal(t, coredata.AccessEntryAccountTypeServiceAccount, r2.AccountType)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeServiceAccount, r2.AccountType)
assert.Equal(t, coredata.MFAStatusDisabled, r2.MFAStatus)
}

View File

@@ -99,8 +99,8 @@ func (d *DocuSignDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err
IsAdmin: strings.EqualFold(u.IsAdmin, "True"),
ExternalID: u.UserID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
if u.LastLogin != "" {

View File

@@ -42,8 +42,8 @@ type AccountRecord struct {
Active *bool
IsAdmin bool
MFAStatus coredata.MFAStatus
AuthMethod coredata.AccessEntryAuthMethod
AccountType coredata.AccessEntryAccountType
AuthMethod coredata.AccessReviewEntryAuthMethod
AccountType coredata.AccessReviewEntryAccountType
LastLogin *time.Time
CreatedAt *time.Time
ExternalID string // system-specific user ID

View File

@@ -108,9 +108,9 @@ func (d *GitHubDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
fullName = m.Login
}
accountType := coredata.AccessEntryAccountTypeUser
accountType := coredata.AccessReviewEntryAccountTypeUser
if m.Type == "Bot" {
accountType = coredata.AccessEntryAccountTypeServiceAccount
accountType = coredata.AccessReviewEntryAccountTypeServiceAccount
}
mfaStatus := coredata.MFAStatusUnknown
@@ -130,7 +130,7 @@ func (d *GitHubDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
Active: new(membership.State == "active"),
IsAdmin: membership.Role == "admin",
MFAStatus: mfaStatus,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: accountType,
ExternalID: strconv.FormatInt(m.ID, 10),
}

View File

@@ -105,8 +105,8 @@ func (d *GitLabDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
Active: &active,
IsAdmin: m.AccessLevel >= 50, // 50 = Owner
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: strconv.FormatInt(m.ID, 10),
}

View File

@@ -120,8 +120,8 @@ func (d *GoogleWorkspaceDriver) ListAccounts(ctx context.Context) ([]AccountReco
IsAdmin: u.IsAdmin,
ExternalID: u.Id,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodSSO,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodSSO,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
if u.IsEnrolledIn2Sv {

View File

@@ -81,8 +81,8 @@ func (d *GrafanaDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
Role: strings.TrimSpace(u.Role),
IsAdmin: strings.EqualFold(strings.TrimSpace(u.Role), "Admin"),
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: strconv.Itoa(u.UserID),
}

View File

@@ -159,8 +159,8 @@ func (d *HerokuDriver) listTeamMembers(ctx context.Context) ([]AccountRecord, er
Role: m.Role,
IsAdmin: isAdmin,
MFAStatus: mfaStatus,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: externalID,
}
@@ -268,8 +268,8 @@ func herokuPersonalRecord(externalID, email, role string, isAdmin bool) AccountR
Role: role,
IsAdmin: isAdmin,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: externalID,
}
}

View File

@@ -115,8 +115,8 @@ func (d *HubSpotDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
IsAdmin: u.SuperAdmin,
ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
if record.Email != "" || record.ExternalID != "" {

View File

@@ -71,8 +71,8 @@ func (d *IntercomDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err
IsAdmin: false, // Intercom API does not expose admin role information
ExternalID: a.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
if record.Email != "" || record.FullName != "" {

View File

@@ -88,9 +88,9 @@ func (d *LinearDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
}
for _, u := range resp.Data.Users.Nodes {
accountType := coredata.AccessEntryAccountTypeUser
accountType := coredata.AccessReviewEntryAccountTypeUser
if strings.HasSuffix(u.Email, ".linear.app") {
accountType = coredata.AccessEntryAccountTypeServiceAccount
accountType = coredata.AccessReviewEntryAccountTypeServiceAccount
}
record := AccountRecord{
@@ -101,7 +101,7 @@ func (d *LinearDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
IsAdmin: u.Admin,
ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: accountType,
}

View File

@@ -83,8 +83,8 @@ func (d *MetabaseDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err
IsAdmin: u.IsSuperuser,
ExternalID: strconv.Itoa(u.ID),
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
if t, ok := parseMetabaseTimestamp(u.LastLogin); ok {

View File

@@ -178,8 +178,8 @@ func (d *Microsoft365Driver) ListAccounts(ctx context.Context) ([]AccountRecord,
Active: &active,
IsAdmin: isAdmin,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodSSO,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodSSO,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: u.ID,
}

View File

@@ -100,8 +100,8 @@ func (d *MondayDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
Active: &active,
IsAdmin: u.IsAdmin,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: u.ID,
}

View File

@@ -353,7 +353,7 @@ func (r *qoveryNameResolver) ResolveInstanceName(ctx context.Context) (string, e
}
// renderNameResolver resolves the Render workspace (owner) name from
// GET /v1/owners/{ownerId}, used to title the AccessSource "Render <name>".
// GET /v1/owners/{ownerId}, used to title the AccessReviewSource "Render <name>".
type renderNameResolver struct {
httpClient *http.Client
ownerID string
@@ -651,7 +651,7 @@ func (r *anthropicNameResolver) ResolveInstanceName(ctx context.Context) (string
}
// sendGridNameResolver resolves the SendGrid account's company name from
// the user profile endpoint, used as the AccessSource instance label.
// the user profile endpoint, used as the AccessReviewSource instance label.
type sendGridNameResolver struct {
httpClient *http.Client
}
@@ -1062,7 +1062,7 @@ func (r *pagerdutyNameResolver) ResolveInstanceName(_ context.Context) (string,
// datadogNameResolver returns the Datadog site/region label stored in
// connector settings (e.g. "US3"), captured during the OAuth callback. No
// HTTP call is required; the AccessSource title becomes "Datadog <region>".
// HTTP call is required; the AccessReviewSource title becomes "Datadog <region>".
// Org-name resolution is intentionally omitted to keep scopes to
// user_access_read (the org name endpoint needs org_management).
type datadogNameResolver struct {
@@ -1132,7 +1132,7 @@ func (r *oktaNameResolver) ResolveInstanceName(ctx context.Context) (string, err
// zendeskNameResolver returns the Zendesk subdomain stored in connector
// settings (e.g. "acme" for acme.zendesk.com), captured at connect time. No
// HTTP call is required; the AccessSource title becomes "Zendesk <subdomain>".
// HTTP call is required; the AccessReviewSource title becomes "Zendesk <subdomain>".
// Account-name resolution is intentionally omitted to keep the scope to
// users:read (Zendesk exposes no human account name on that scope).
type zendeskNameResolver struct {

View File

@@ -95,8 +95,8 @@ func (d *NeonDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error)
Active: new(m.User.DeactivatedAt == ""),
IsAdmin: neonIsAdmin(m.Member.Role),
MFAStatus: neonMFAStatus(m.User.HasMFA),
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: neonExternalID(m),
CreatedAt: parseRFC3339Ptr(m.Member.JoinedAt),
})

View File

@@ -84,8 +84,8 @@ func (d *NetlifyDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
Role: m.Role,
ExternalID: m.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
records = append(records, record)
}

View File

@@ -67,9 +67,9 @@ func (d *NotionDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
}
for _, u := range resp.Results {
accountType := coredata.AccessEntryAccountTypeUser
accountType := coredata.AccessReviewEntryAccountTypeUser
if u.Type == "bot" {
accountType = coredata.AccessEntryAccountTypeServiceAccount
accountType = coredata.AccessReviewEntryAccountTypeServiceAccount
}
var email string
@@ -84,7 +84,7 @@ func (d *NotionDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
IsAdmin: false,
ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: accountType,
}

View File

@@ -100,8 +100,8 @@ func (d *OktaDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error)
JobTitle: u.Profile.Title,
Active: oktaActive(u.Status),
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: u.ID,
}

View File

@@ -97,8 +97,8 @@ func (d *OnePasswordDriver) ListAccounts(ctx context.Context) ([]AccountRecord,
Active: new(u.Active),
ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
if record.FullName == "" && u.Name.Formatted != "" {

View File

@@ -89,8 +89,8 @@ func (d *OnePasswordUsersAPIDriver) ListAccounts(ctx context.Context) ([]Account
Active: new(u.State == "ACTIVE"),
ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
if u.CreateTime != "" {

View File

@@ -72,8 +72,8 @@ func (d *OpenAIDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
IsAdmin: u.Role == "owner",
ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
if u.AddedAt != 0 {

View File

@@ -83,8 +83,8 @@ func (d *PagerDutyDriver) ListAccounts(ctx context.Context) ([]AccountRecord, er
Role: u.Role,
IsAdmin: isAdmin,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: u.ID,
}

View File

@@ -296,8 +296,8 @@ func posthogAccountRecord(member posthogMember) AccountRecord {
IsAdmin: posthogIsAdmin(member.Level),
ExternalID: member.User.UUID,
MFAStatus: posthogMFAStatus(member.Is2FAEnabled),
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
if record.ExternalID == "" {

View File

@@ -76,8 +76,8 @@ func (d *ProboMembershipsDriver) ListAccounts(ctx context.Context) ([]AccountRec
ExternalID: account.ID.String(),
CreatedAt: &createdAt,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
},
)
}

View File

@@ -104,8 +104,8 @@ func (d *QoveryDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
Role: qoveryRole(member.Role),
IsAdmin: qoveryIsAdmin(member.Role),
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: member.ID,
}

View File

@@ -103,8 +103,8 @@ func (d *RenderDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
Active: renderActive(member.Status),
IsAdmin: renderIsAdmin(member.Role),
MFAStatus: renderMFAStatus(member.MFAEnabled),
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: member.UserID,
})
}

View File

@@ -54,8 +54,8 @@ func TestRenderDriverListAccounts(t *testing.T) {
assert.Equal(t, "Admin", records[0].Role)
assert.True(t, records[0].IsAdmin)
assert.Equal(t, coredata.MFAStatusEnabled, records[0].MFAStatus)
assert.Equal(t, coredata.AccessEntryAccountTypeUser, records[0].AccountType)
assert.Equal(t, coredata.AccessEntryAuthMethodUnknown, records[0].AuthMethod)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, records[0].AccountType)
assert.Equal(t, coredata.AccessReviewEntryAuthMethodUnknown, records[0].AuthMethod)
assert.Equal(t, "usr-000000000000000000a1", records[0].ExternalID)
require.NotNil(t, records[0].Active)
assert.True(t, *records[0].Active)

View File

@@ -61,8 +61,8 @@ func (d *ResendDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
IsAdmin: false,
ExternalID: k.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeServiceAccount,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeServiceAccount,
}
if k.CreatedAt != "" {

View File

@@ -38,5 +38,5 @@ func TestResendDriver(t *testing.T) {
r := records[0]
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID)
assert.Equal(t, coredata.AccessEntryAccountTypeServiceAccount, r.AccountType)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeServiceAccount, r.AccountType)
}

View File

@@ -110,7 +110,7 @@ func (d *SendGridDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err
ExternalID: strings.TrimSpace(teammate.Username),
MFAStatus: mfaStatus,
AuthMethod: sendGridAuthMethod(teammate),
AccountType: coredata.AccessEntryAccountTypeUser,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
})
}
@@ -232,12 +232,12 @@ func sendGridRole(userType string, isAdmin bool) string {
// authenticated through SSO (native or partner) is SSO; otherwise they sign in
// with SendGrid's own credentials. Both flags are always present on the
// teammate payload, so this is a definitive signal.
func sendGridAuthMethod(t sendGridTeammate) coredata.AccessEntryAuthMethod {
func sendGridAuthMethod(t sendGridTeammate) coredata.AccessReviewEntryAuthMethod {
if t.IsSSO || t.IsPartnerSSO {
return coredata.AccessEntryAuthMethodSSO
return coredata.AccessReviewEntryAuthMethodSSO
}
return coredata.AccessEntryAuthMethodPassword
return coredata.AccessReviewEntryAuthMethodPassword
}
// sendGridMFAStatus derives a teammate's MFA status from the auto-set 2fa

View File

@@ -46,9 +46,9 @@ func TestSendGridDriver(t *testing.T) {
assert.Equal(t, "Owner", owner.Role)
assert.True(t, owner.IsAdmin)
assert.Equal(t, "owner@example.com", owner.ExternalID)
assert.Equal(t, coredata.AccessEntryAccountTypeUser, owner.AccountType)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, owner.AccountType)
// is_sso=false on the owner -> authenticates with SendGrid credentials.
assert.Equal(t, coredata.AccessEntryAuthMethodPassword, owner.AuthMethod)
assert.Equal(t, coredata.AccessReviewEntryAuthMethodPassword, owner.AuthMethod)
// The owner is a full-access user whose scope catalog contains BOTH
// 2fa_exempt and 2fa_required, so the MFA signal is ambiguous and the
// driver reports Unknown rather than guessing from scope ordering.
@@ -66,7 +66,7 @@ func TestSendGridDriver(t *testing.T) {
assert.False(t, teammate.IsAdmin)
// Non-unified teammate: username is a handle distinct from the email.
assert.Equal(t, "taylor-teammate", teammate.ExternalID)
assert.Equal(t, coredata.AccessEntryAuthMethodSSO, teammate.AuthMethod)
assert.Equal(t, coredata.AccessReviewEntryAuthMethodSSO, teammate.AuthMethod)
assert.Equal(t, coredata.MFAStatusEnabled, teammate.MFAStatus)
}

View File

@@ -143,7 +143,7 @@ func (d *SentryDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
ExternalID: m.ID,
MFAStatus: mfaStatus,
AuthMethod: authMethod,
AccountType: coredata.AccessEntryAccountTypeUser,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
if m.User != nil && m.User.LastLogin != "" {
@@ -216,14 +216,14 @@ func sentryNextLink(header string) string {
return ""
}
func sentryAuthMethod(flags map[string]bool, user *sentryUser) coredata.AccessEntryAuthMethod {
func sentryAuthMethod(flags map[string]bool, user *sentryUser) coredata.AccessReviewEntryAuthMethod {
if flags["sso:linked"] {
return coredata.AccessEntryAuthMethodSSO
return coredata.AccessReviewEntryAuthMethodSSO
}
if user != nil && user.HasPasswordAuth {
return coredata.AccessEntryAuthMethodPassword
return coredata.AccessReviewEntryAuthMethodPassword
}
return coredata.AccessEntryAuthMethodUnknown
return coredata.AccessReviewEntryAuthMethodUnknown
}

View File

@@ -86,8 +86,8 @@ func (d *SigNozDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
Active: sigNozActiveStatus(u.Status),
IsAdmin: u.IsRoot || strings.EqualFold(role, "Admin"),
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: strings.TrimSpace(u.ID),
}

View File

@@ -91,9 +91,9 @@ func (d *SlackDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error)
continue
}
accountType := coredata.AccessEntryAccountTypeUser
accountType := coredata.AccessReviewEntryAccountTypeUser
if m.IsBot || m.IsAppUser {
accountType = coredata.AccessEntryAccountTypeServiceAccount
accountType = coredata.AccessReviewEntryAccountTypeServiceAccount
}
record := AccountRecord{
@@ -105,7 +105,7 @@ func (d *SlackDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error)
IsAdmin: m.IsAdmin || m.IsOwner || m.IsPrimaryOwner,
ExternalID: m.ID,
MFAStatus: slackMFAStatus(m.Has2FA),
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: accountType,
}

View File

@@ -69,8 +69,8 @@ func (d *SupabaseDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err
IsAdmin: isAdmin,
ExternalID: m.UserID,
MFAStatus: mfaStatus,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
records = append(records, record)

View File

@@ -86,8 +86,8 @@ func (d *TailscaleDriver) ListAccounts(ctx context.Context) ([]AccountRecord, er
// Tailscale has no local credentials; it always delegates
// authentication to an upstream identity provider, so every
// account is SSO regardless of which IdP backs the tailnet.
AuthMethod: coredata.AccessEntryAuthMethodSSO,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodSSO,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
}
if u.Created != "" {

View File

@@ -120,8 +120,8 @@ func (d *TallyDriver) listUsers(ctx context.Context) ([]AccountRecord, error) {
Active: new(!u.IsDeleted),
ExternalID: u.ID,
MFAStatus: mfaStatus,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
CreatedAt: new(u.CreatedAt),
}
@@ -176,8 +176,8 @@ func (d *TallyDriver) listInvites(ctx context.Context) ([]AccountRecord, error)
Active: new(false),
ExternalID: inv.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
Role: "Invited",
}

View File

@@ -100,8 +100,8 @@ func (d *VercelDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
Active: &confirmed,
IsAdmin: m.Role == "OWNER" || m.Role == "owner",
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: m.UID,
}

View File

@@ -138,8 +138,8 @@ func zendeskRecord(u zendeskUser) AccountRecord {
MFAStatus: mfaStatus,
// Zendesk's users API does not expose the sign-in method
// (password / SSO / social), so the auth method is unknown.
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: strconv.FormatInt(u.ID, 10),
LastLogin: parseRFC3339Ptr(lastLogin),
CreatedAt: parseRFC3339Ptr(u.CreatedAt),

View File

@@ -45,9 +45,9 @@ func TestZendeskDriver(t *testing.T) {
assert.True(t, *r.Active)
assert.True(t, r.IsAdmin)
assert.Equal(t, "admin", r.Role)
assert.Equal(t, coredata.AccessEntryAccountTypeUser, r.AccountType)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, r.AccountType)
assert.Equal(t, coredata.MFAStatusEnabled, r.MFAStatus)
assert.Equal(t, coredata.AccessEntryAuthMethodUnknown, r.AuthMethod)
assert.Equal(t, coredata.AccessReviewEntryAuthMethodUnknown, r.AuthMethod)
require.NotNil(t, r.LastLogin)
require.NotNil(t, r.CreatedAt)

View File

@@ -27,35 +27,31 @@ import (
)
type (
AccessEntryService struct {
pg *pg.Client
scope coredata.Scoper
}
RecordAccessEntryDecisionRequest struct {
RecordAccessReviewEntryDecisionRequest struct {
EntryID gid.GID
Decision coredata.AccessEntryDecision
Decision coredata.AccessReviewEntryDecision
DecisionNote *string
DecidedByID *gid.GID
}
FlagAccessEntryRequest struct {
FlagAccessReviewEntryRequest struct {
EntryID gid.GID
Flags []coredata.AccessEntryFlag
Flags []coredata.AccessReviewEntryFlag
FlagReasons []string
}
)
func (s AccessEntryService) Get(
func (s *Service) GetEntry(
ctx context.Context,
scope coredata.Scoper,
entryID gid.GID,
) (*coredata.AccessEntry, error) {
entry := &coredata.AccessEntry{}
) (*coredata.AccessReviewEntry, error) {
entry := &coredata.AccessReviewEntry{}
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return entry.LoadByID(ctx, conn, s.scope, entryID)
return entry.LoadByID(ctx, conn, scope, entryID)
},
)
if err != nil {
@@ -65,31 +61,32 @@ func (s AccessEntryService) Get(
return entry, nil
}
func (s AccessEntryService) RecordDecision(
func (s *Service) RecordDecision(
ctx context.Context,
req RecordAccessEntryDecisionRequest,
) (*coredata.AccessEntry, error) {
if req.Decision == coredata.AccessEntryDecisionPending {
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.AccessEntryDecisionApproved {
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.AccessEntry{}
entry := &coredata.AccessReviewEntry{}
err := s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
if err := entry.LoadByID(ctx, conn, s.scope, req.EntryID); err != nil {
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, s.scope, entry.AccessReviewCampaignID); err != nil {
if err := campaign.LoadByID(ctx, conn, scope, entry.AccessReviewCampaignID); err != nil {
return fmt.Errorf("cannot load campaign: %w", err)
}
@@ -105,34 +102,34 @@ func (s AccessEntryService) RecordDecision(
entry.UpdatedAt = now
if entry.Flags == nil {
entry.Flags = []coredata.AccessEntryFlag{}
entry.Flags = []coredata.AccessReviewEntryFlag{}
}
if entry.FlagReasons == nil {
entry.FlagReasons = []string{}
}
if req.Decision == coredata.AccessEntryDecisionRevoke || req.Decision == coredata.AccessEntryDecisionEscalate {
if req.Decision == coredata.AccessReviewEntryDecisionRevoke || req.Decision == coredata.AccessReviewEntryDecisionEscalate {
if len(entry.Flags) == 0 {
entry.Flags = []coredata.AccessEntryFlag{coredata.AccessEntryFlagExcessive}
entry.Flags = []coredata.AccessReviewEntryFlag{coredata.AccessReviewEntryFlagExcessive}
}
}
if err := entry.Update(ctx, conn, s.scope); err != nil {
if err := entry.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot record access entry decision: %w", err)
}
history := &coredata.AccessEntryDecisionHistory{
ID: gid.New(s.scope.GetTenantID(), coredata.AccessEntryDecisionHistoryEntityType),
OrganizationID: entry.OrganizationID,
AccessEntry: entry.ID,
Decision: entry.Decision,
DecisionNote: entry.DecisionNote,
DecidedBy: entry.DecidedBy,
DecidedAt: *entry.DecidedAt,
CreatedAt: now,
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, s.scope); err != nil {
if err := history.Insert(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot insert decision history: %w", err)
}
@@ -143,7 +140,7 @@ func (s AccessEntryService) RecordDecision(
return nil, fmt.Errorf("cannot record access entry decision: %w", err)
}
updatedEntry, err := s.Get(ctx, req.EntryID)
updatedEntry, err := s.GetEntry(ctx, scope, req.EntryID)
if err != nil {
return nil, fmt.Errorf("cannot reload access entry after decision: %w", err)
}
@@ -151,16 +148,17 @@ func (s AccessEntryService) RecordDecision(
return updatedEntry, nil
}
func (s AccessEntryService) RecordDecisions(
func (s *Service) RecordDecisions(
ctx context.Context,
decisions []RecordAccessEntryDecisionRequest,
) ([]*coredata.AccessEntry, error) {
scope coredata.Scoper,
decisions []RecordAccessReviewEntryDecisionRequest,
) ([]*coredata.AccessReviewEntry, error) {
for _, d := range decisions {
if d.Decision == coredata.AccessEntryDecisionPending {
if d.Decision == coredata.AccessReviewEntryDecisionPending {
return nil, fmt.Errorf("cannot bulk decide access entries: invalid decision %q", d.Decision)
}
if d.Decision != coredata.AccessEntryDecisionApproved {
if d.Decision != coredata.AccessReviewEntryDecisionApproved {
if d.DecisionNote == nil || strings.TrimSpace(*d.DecisionNote) == "" {
return nil, fmt.Errorf(
"cannot bulk decide access entries: note is required for non-approved decisions on entry %s",
@@ -183,14 +181,14 @@ func (s AccessEntryService) RecordDecisions(
verifiedCampaigns := make(map[gid.GID]bool)
for _, d := range decisions {
entry := &coredata.AccessEntry{}
if err := entry.LoadByID(ctx, conn, s.scope, d.EntryID); err != nil {
entry := &coredata.AccessReviewEntry{}
if err := entry.LoadByID(ctx, conn, scope, d.EntryID); err != nil {
return fmt.Errorf("cannot load access entry %s: %w", d.EntryID, err)
}
if !verifiedCampaigns[entry.AccessReviewCampaignID] {
campaign := &coredata.AccessReviewCampaign{}
if err := campaign.LoadByID(ctx, conn, s.scope, entry.AccessReviewCampaignID); err != nil {
if err := campaign.LoadByID(ctx, conn, scope, entry.AccessReviewCampaignID); err != nil {
return fmt.Errorf("cannot load campaign: %w", err)
}
@@ -209,34 +207,34 @@ func (s AccessEntryService) RecordDecisions(
entry.UpdatedAt = now
if entry.Flags == nil {
entry.Flags = []coredata.AccessEntryFlag{}
entry.Flags = []coredata.AccessReviewEntryFlag{}
}
if entry.FlagReasons == nil {
entry.FlagReasons = []string{}
}
if d.Decision == coredata.AccessEntryDecisionRevoke || d.Decision == coredata.AccessEntryDecisionEscalate {
if d.Decision == coredata.AccessReviewEntryDecisionRevoke || d.Decision == coredata.AccessReviewEntryDecisionEscalate {
if len(entry.Flags) == 0 {
entry.Flags = []coredata.AccessEntryFlag{coredata.AccessEntryFlagExcessive}
entry.Flags = []coredata.AccessReviewEntryFlag{coredata.AccessReviewEntryFlagExcessive}
}
}
if err := entry.Update(ctx, conn, s.scope); err != nil {
if err := entry.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot record decision for entry %s: %w", d.EntryID, err)
}
history := &coredata.AccessEntryDecisionHistory{
ID: gid.New(s.scope.GetTenantID(), coredata.AccessEntryDecisionHistoryEntityType),
OrganizationID: entry.OrganizationID,
AccessEntry: entry.ID,
Decision: entry.Decision,
DecisionNote: entry.DecisionNote,
DecidedBy: entry.DecidedBy,
DecidedAt: *entry.DecidedAt,
CreatedAt: now,
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, s.scope); err != nil {
if err := history.Insert(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot insert decision history for entry %s: %w", d.EntryID, err)
}
}
@@ -248,9 +246,9 @@ func (s AccessEntryService) RecordDecisions(
return nil, fmt.Errorf("cannot record access entry decisions: %w", err)
}
entries := make([]*coredata.AccessEntry, len(entryIDs))
entries := make([]*coredata.AccessReviewEntry, len(entryIDs))
for i, id := range entryIDs {
entry, err := s.Get(ctx, id)
entry, err := s.GetEntry(ctx, scope, id)
if err != nil {
return nil, fmt.Errorf("cannot reload access entry %s: %w", id, err)
}
@@ -261,21 +259,22 @@ func (s AccessEntryService) RecordDecisions(
return entries, nil
}
func (s AccessEntryService) FlagEntry(
func (s *Service) FlagEntry(
ctx context.Context,
req FlagAccessEntryRequest,
) (*coredata.AccessEntry, error) {
entry := &coredata.AccessEntry{}
scope coredata.Scoper,
req FlagAccessReviewEntryRequest,
) (*coredata.AccessReviewEntry, error) {
entry := &coredata.AccessReviewEntry{}
err := s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
if err := entry.LoadByID(ctx, conn, s.scope, req.EntryID); err != nil {
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, s.scope, entry.AccessReviewCampaignID); err != nil {
if err := campaign.LoadByID(ctx, conn, scope, entry.AccessReviewCampaignID); err != nil {
return fmt.Errorf("cannot load campaign: %w", err)
}
@@ -287,7 +286,7 @@ func (s AccessEntryService) FlagEntry(
entry.Flags = req.Flags
if entry.Flags == nil {
entry.Flags = []coredata.AccessEntryFlag{}
entry.Flags = []coredata.AccessReviewEntryFlag{}
}
entry.FlagReasons = req.FlagReasons
@@ -297,28 +296,29 @@ func (s AccessEntryService) FlagEntry(
entry.UpdatedAt = now
return entry.UpdateFlags(ctx, conn, s.scope)
return entry.UpdateFlags(ctx, conn, scope)
},
)
if err != nil {
return nil, fmt.Errorf("cannot flag access entry: %w", err)
}
return s.Get(ctx, req.EntryID)
return s.GetEntry(ctx, scope, req.EntryID)
}
func (s AccessEntryService) ListForCampaignID(
func (s *Service) ListEntriesForCampaignID(
ctx context.Context,
scope coredata.Scoper,
campaignID gid.GID,
cursor *page.Cursor[coredata.AccessEntryOrderField],
filter *coredata.AccessEntryFilter,
) (*page.Page[*coredata.AccessEntry, coredata.AccessEntryOrderField], error) {
var entries coredata.AccessEntries
cursor *page.Cursor[coredata.AccessReviewEntryOrderField],
filter *coredata.AccessReviewEntryFilter,
) (*page.Page[*coredata.AccessReviewEntry, coredata.AccessReviewEntryOrderField], error) {
var entries coredata.AccessReviewEntries
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return entries.LoadByCampaignID(ctx, conn, s.scope, campaignID, cursor, filter)
return entries.LoadByCampaignID(ctx, conn, scope, campaignID, cursor, filter)
},
)
if err != nil {
@@ -328,19 +328,20 @@ func (s AccessEntryService) ListForCampaignID(
return page.NewPage(entries, cursor), nil
}
func (s AccessEntryService) ListForCampaignIDAndSourceID(
func (s *Service) ListEntriesForCampaignIDAndSourceID(
ctx context.Context,
scope coredata.Scoper,
campaignID gid.GID,
sourceID gid.GID,
cursor *page.Cursor[coredata.AccessEntryOrderField],
filter *coredata.AccessEntryFilter,
) (*page.Page[*coredata.AccessEntry, coredata.AccessEntryOrderField], error) {
var entries coredata.AccessEntries
cursor *page.Cursor[coredata.AccessReviewEntryOrderField],
filter *coredata.AccessReviewEntryFilter,
) (*page.Page[*coredata.AccessReviewEntry, coredata.AccessReviewEntryOrderField], error) {
var entries coredata.AccessReviewEntries
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return entries.LoadByCampaignIDAndSourceID(ctx, conn, s.scope, campaignID, sourceID, cursor, filter)
return entries.LoadByCampaignIDAndSourceID(ctx, conn, scope, campaignID, sourceID, cursor, filter)
},
)
if err != nil {
@@ -350,19 +351,20 @@ func (s AccessEntryService) ListForCampaignIDAndSourceID(
return page.NewPage(entries, cursor), nil
}
func (s AccessEntryService) CountForCampaignID(
func (s *Service) CountEntriesForCampaignID(
ctx context.Context,
scope coredata.Scoper,
campaignID gid.GID,
filter *coredata.AccessEntryFilter,
filter *coredata.AccessReviewEntryFilter,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
entries := coredata.AccessEntries{}
entries := coredata.AccessReviewEntries{}
count, err = entries.CountByCampaignID(ctx, conn, s.scope, campaignID, filter)
count, err = entries.CountByCampaignID(ctx, conn, scope, campaignID, filter)
if err != nil {
return fmt.Errorf("cannot count access entries by campaign: %w", err)
}
@@ -377,20 +379,21 @@ func (s AccessEntryService) CountForCampaignID(
return count, nil
}
func (s AccessEntryService) CountForCampaignIDAndSourceID(
func (s *Service) CountEntriesForCampaignIDAndSourceID(
ctx context.Context,
scope coredata.Scoper,
campaignID gid.GID,
sourceID gid.GID,
filter *coredata.AccessEntryFilter,
filter *coredata.AccessReviewEntryFilter,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
entries := coredata.AccessEntries{}
entries := coredata.AccessReviewEntries{}
count, err = entries.CountByCampaignIDAndSourceID(ctx, conn, s.scope, campaignID, sourceID, filter)
count, err = entries.CountByCampaignIDAndSourceID(ctx, conn, scope, campaignID, sourceID, filter)
if err != nil {
return fmt.Errorf("cannot count access entries by campaign and source: %w", err)
}
@@ -405,8 +408,9 @@ func (s AccessEntryService) CountForCampaignIDAndSourceID(
return count, nil
}
func (s AccessEntryService) CountPendingForCampaignID(
func (s *Service) CountPendingEntriesForCampaignID(
ctx context.Context,
scope coredata.Scoper,
campaignID gid.GID,
) (int, error) {
var count int
@@ -414,9 +418,9 @@ func (s AccessEntryService) CountPendingForCampaignID(
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
entries := coredata.AccessEntries{}
entries := coredata.AccessReviewEntries{}
count, err = entries.CountPendingByCampaignID(ctx, conn, s.scope, campaignID)
count, err = entries.CountPendingByCampaignID(ctx, conn, scope, campaignID)
if err != nil {
return fmt.Errorf("cannot count pending access entries: %w", err)
}
@@ -431,16 +435,17 @@ func (s AccessEntryService) CountPendingForCampaignID(
return count, nil
}
func (s AccessEntryService) DecisionHistory(
func (s *Service) EntryDecisionHistory(
ctx context.Context,
scope coredata.Scoper,
entryID gid.GID,
) (coredata.AccessEntryDecisionHistories, error) {
var histories coredata.AccessEntryDecisionHistories
) (coredata.AccessReviewEntryDecisionHistories, error) {
var histories coredata.AccessReviewEntryDecisionHistories
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return histories.LoadByEntryID(ctx, conn, s.scope, entryID)
return histories.LoadByEntryID(ctx, conn, scope, entryID)
},
)
if err != nil {
@@ -450,16 +455,17 @@ func (s AccessEntryService) DecisionHistory(
return histories, nil
}
func (s AccessEntryService) Statistics(
func (s *Service) CampaignStatistics(
ctx context.Context,
scope coredata.Scoper,
campaignID gid.GID,
) (*coredata.AccessEntryStatistics, error) {
stats := &coredata.AccessEntryStatistics{}
) (*coredata.AccessReviewStatistics, error) {
stats := &coredata.AccessReviewStatistics{}
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return stats.LoadByCampaignID(ctx, conn, s.scope, campaignID)
return stats.LoadByCampaignID(ctx, conn, scope, campaignID)
},
)
if err != nil {
@@ -469,17 +475,18 @@ func (s AccessEntryService) Statistics(
return stats, nil
}
func (s AccessEntryService) StatisticsForSource(
func (s *Service) CampaignSourceStatistics(
ctx context.Context,
scope coredata.Scoper,
campaignID gid.GID,
sourceID gid.GID,
) (*coredata.AccessEntryStatistics, error) {
stats := &coredata.AccessEntryStatistics{}
) (*coredata.AccessReviewStatistics, error) {
stats := &coredata.AccessReviewStatistics{}
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return stats.LoadByCampaignIDAndSourceID(ctx, conn, s.scope, campaignID, sourceID)
return stats.LoadByCampaignIDAndSourceID(ctx, conn, scope, campaignID, sourceID)
},
)
if err != nil {

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2025-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 accessreview
import (
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/iam/policy"
)
var organizationCondition = policy.Equals("principal.organization_id", "resource.organization_id")
// FullAccessPolicy grants complete access-review access, including campaign,
// entry, and source management, to organization owners and admins.
var FullAccessPolicy = policy.NewPolicy(
"access-review:full-access",
"Access Review Full Access",
policy.Allow(
ActionCampaignGet, ActionCampaignList, ActionCampaignCreate,
ActionCampaignUpdate, ActionCampaignDelete, ActionCampaignStart,
ActionCampaignClose, ActionCampaignCancel, ActionCampaignAddSource,
ActionCampaignRemoveSource,
ActionEntryGet, ActionEntryList, ActionEntryDecide, ActionEntryFlag,
ActionSourceGet, ActionSourceList, ActionSourceCreate,
ActionSourceUpdate, ActionSourceDelete, ActionSourceSync,
).WithSID("access-review-full-access").When(organizationCondition),
).WithDescription("Full access-review access including campaign, entry, and source management")
// ReadAccessPolicy grants read-only access-review access to viewers.
var ReadAccessPolicy = policy.NewPolicy(
"access-review:read-access",
"Access Review Read Access",
policy.Allow(
ActionCampaignGet, ActionCampaignList,
ActionEntryGet, ActionEntryList,
ActionSourceGet, ActionSourceList,
).WithSID("access-review-read-access").When(organizationCondition),
).WithDescription("Read-only access-review access")
// PolicySet returns the PolicySet for the access-review service. It is owned by
// this package and registered into the authorizer at composition time so the
// access-review authorization rules live alongside the access-review domain
// logic instead of in the core probo policy set.
func PolicySet() *iam.PolicySet {
return iam.NewPolicySet().
AddRolePolicy("OWNER", FullAccessPolicy).
AddRolePolicy("ADMIN", FullAccessPolicy).
AddRolePolicy("VIEWER", ReadAccessPolicy)
}

View File

@@ -22,66 +22,42 @@ import (
"strings"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/accessreview/drivers"
"go.probo.inc/probo/pkg/connector"
"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"
)
// ReviewEngine contains the stateless core logic for access review campaigns:
// snapshot and source data collection.
type ReviewEngine struct {
pg *pg.Client
scope coredata.Scoper
encryptionKey cipher.EncryptionKey
connectorRegistry *connector.ConnectorRegistry
providerRegistry *provider.Registry
logger *log.Logger
}
func NewReviewEngine(
pgClient *pg.Client,
scope coredata.Scoper,
encryptionKey cipher.EncryptionKey,
connectorRegistry *connector.ConnectorRegistry,
providerRegistry *provider.Registry,
logger *log.Logger,
) *ReviewEngine {
return &ReviewEngine{
pg: pgClient,
scope: scope,
encryptionKey: encryptionKey,
connectorRegistry: connectorRegistry,
providerRegistry: providerRegistry,
logger: logger,
}
}
// FetchSource pulls accounts from a single source and upserts access entries.
func (e *ReviewEngine) FetchSource(
// FetchSource pulls accounts from a single campaign source snapshot and upserts
// access entries against that snapshot.
func (s *Service) FetchSource(
ctx context.Context,
scope coredata.Scoper,
campaign *coredata.AccessReviewCampaign,
sourceID gid.GID,
campaignSource *coredata.AccessReviewCampaignSource,
) (int, error) {
fetchedCount := 0
if campaignSource.AccessReviewSourceID == nil {
return 0, fmt.Errorf("cannot fetch source %s: the access source no longer exists", campaignSource.ID)
}
sourceID := *campaignSource.AccessReviewSourceID
// Resolve the driver and load baseline data outside the write transaction
// so that external HTTP calls do not hold a database connection.
var (
source *coredata.AccessSource
source *coredata.AccessReviewSource
driver drivers.Driver
baseline []coredata.BaselineAccountEntry
)
err := e.pg.WithTx(
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
source = &coredata.AccessSource{}
if err := source.LoadByID(ctx, tx, e.scope, sourceID); err != nil {
source = &coredata.AccessReviewSource{}
if err := source.LoadByID(ctx, tx, scope, sourceID); err != nil {
return fmt.Errorf("cannot load access source %s: %w", sourceID, err)
}
@@ -91,20 +67,20 @@ func (e *ReviewEngine) FetchSource(
var err error
driver, err = e.resolveDriver(ctx, tx, source)
driver, err = s.resolveDriver(ctx, tx, scope, source)
if err != nil {
return fmt.Errorf("cannot resolve driver for source %s: %w", source.Name, err)
}
lastCompletedCampaign := &coredata.AccessReviewCampaign{}
if err := lastCompletedCampaign.LoadLastCompletedByOrganizationID(ctx, tx, e.scope, campaign.OrganizationID); err != nil {
if err := lastCompletedCampaign.LoadLastCompletedByOrganizationID(ctx, tx, scope, campaign.OrganizationID); err != nil {
if !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load last completed campaign: %w", err)
}
} else {
entries := &coredata.AccessEntries{}
entries := &coredata.AccessReviewEntries{}
baseline, err = entries.LoadBaselineBySourceID(ctx, tx, e.scope, lastCompletedCampaign.ID, sourceID)
baseline, err = entries.LoadBaselineBySourceID(ctx, tx, scope, lastCompletedCampaign.ID, sourceID)
if err != nil {
return fmt.Errorf("cannot load baseline entries by source: %w", err)
}
@@ -133,7 +109,7 @@ func (e *ReviewEngine) FetchSource(
fetchedCount = len(accounts)
err = e.pg.WithTx(
err = s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
now := time.Now()
@@ -143,38 +119,38 @@ func (e *ReviewEngine) FetchSource(
accountKey := normalizeAccountKey(account.Email, account.ExternalID)
seenAccountKeys[accountKey] = struct{}{}
incrementalTag := coredata.AccessEntryIncrementalTagNew
incrementalTag := coredata.AccessReviewEntryIncrementalTagNew
if _, ok := previousByAccountKey[accountKey]; ok {
incrementalTag = coredata.AccessEntryIncrementalTagUnchanged
incrementalTag = coredata.AccessReviewEntryIncrementalTagUnchanged
}
entry := &coredata.AccessEntry{
ID: gid.New(e.scope.GetTenantID(), coredata.AccessEntryEntityType),
OrganizationID: campaign.OrganizationID,
AccessReviewCampaignID: campaign.ID,
AccessSourceID: sourceID,
Email: account.Email,
FullName: account.FullName,
Role: account.Role,
JobTitle: account.JobTitle,
IsAdmin: account.IsAdmin,
MFAStatus: account.MFAStatus,
AuthMethod: account.AuthMethod,
AccountType: account.AccountType,
Active: account.Active,
LastLogin: account.LastLogin,
AccountCreatedAt: account.CreatedAt,
ExternalID: account.ExternalID,
AccountKey: accountKey,
IncrementalTag: incrementalTag,
Flags: []coredata.AccessEntryFlag{},
FlagReasons: []string{},
Decision: coredata.AccessEntryDecisionPending,
CreatedAt: now,
UpdatedAt: now,
entry := &coredata.AccessReviewEntry{
ID: gid.New(scope.GetTenantID(), coredata.AccessReviewEntryEntityType),
OrganizationID: campaign.OrganizationID,
AccessReviewCampaignID: campaign.ID,
AccessReviewCampaignSourceID: campaignSource.ID,
Email: account.Email,
FullName: account.FullName,
Role: account.Role,
JobTitle: account.JobTitle,
IsAdmin: account.IsAdmin,
MFAStatus: account.MFAStatus,
AuthMethod: account.AuthMethod,
AccountType: account.AccountType,
Active: account.Active,
LastLogin: account.LastLogin,
AccountCreatedAt: account.CreatedAt,
ExternalID: account.ExternalID,
AccountKey: accountKey,
IncrementalTag: incrementalTag,
Flags: []coredata.AccessReviewEntryFlag{},
FlagReasons: []string{},
Decision: coredata.AccessReviewEntryDecisionPending,
CreatedAt: now,
UpdatedAt: now,
}
if err := entry.Upsert(ctx, conn, e.scope); err != nil {
if err := entry.Upsert(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot upsert access entry: %w", err)
}
}
@@ -186,26 +162,26 @@ func (e *ReviewEngine) FetchSource(
continue
}
entry := &coredata.AccessEntry{
ID: gid.New(e.scope.GetTenantID(), coredata.AccessEntryEntityType),
OrganizationID: campaign.OrganizationID,
AccessReviewCampaignID: campaign.ID,
AccessSourceID: sourceID,
Email: prev.Email,
FullName: prev.FullName,
AccountKey: accountKey,
IncrementalTag: coredata.AccessEntryIncrementalTagRemoved,
Flags: []coredata.AccessEntryFlag{},
FlagReasons: []string{},
Decision: coredata.AccessEntryDecisionPending,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
CreatedAt: now,
UpdatedAt: now,
entry := &coredata.AccessReviewEntry{
ID: gid.New(scope.GetTenantID(), coredata.AccessReviewEntryEntityType),
OrganizationID: campaign.OrganizationID,
AccessReviewCampaignID: campaign.ID,
AccessReviewCampaignSourceID: campaignSource.ID,
Email: prev.Email,
FullName: prev.FullName,
AccountKey: accountKey,
IncrementalTag: coredata.AccessReviewEntryIncrementalTagRemoved,
Flags: []coredata.AccessReviewEntryFlag{},
FlagReasons: []string{},
Decision: coredata.AccessReviewEntryDecisionPending,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
CreatedAt: now,
UpdatedAt: now,
}
if err := entry.Upsert(ctx, conn, e.scope); err != nil {
if err := entry.Upsert(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot upsert removed access entry: %w", err)
}
}
@@ -233,13 +209,13 @@ func normalizeAccountKey(email, externalID string) string {
// oauthClient returns an HTTP client for an OAuth2 connection, using
// RefreshableClient when a refresh config is available for the provider.
func (e *ReviewEngine) oauthClient(
func (s *Service) oauthClient(
ctx context.Context,
conn *connector.OAuth2Connection,
provider coredata.ConnectorProvider,
) (*http.Client, error) {
if e.connectorRegistry != nil {
refreshCfg := e.connectorRegistry.GetOAuth2RefreshConfig(string(provider))
if s.connectorRegistry != nil {
refreshCfg := s.connectorRegistry.GetOAuth2RefreshConfig(string(provider))
if refreshCfg != nil {
return conn.RefreshableClient(ctx, *refreshCfg)
}
@@ -252,23 +228,24 @@ func (e *ReviewEngine) oauthClient(
// For OAuth2 connections it delegates to oauthClient so that token refresh
// is handled transparently. For other connection types it falls back to
// the standard Client method.
func (e *ReviewEngine) connectorHTTPClient(
func (s *Service) connectorHTTPClient(
ctx context.Context,
dbConnector *coredata.Connector,
) (*http.Client, error) {
if oauth2Conn, ok := dbConnector.Connection.(*connector.OAuth2Connection); ok {
return e.oauthClient(ctx, oauth2Conn, dbConnector.Provider)
return s.oauthClient(ctx, oauth2Conn, dbConnector.Provider)
}
return dbConnector.Connection.Client(ctx)
}
// resolveDriver creates a Driver for the given AccessSource based on
// resolveDriver creates a Driver for the given AccessReviewSource based on
// connector_id (null = built-in, set = connector-backed).
func (e *ReviewEngine) resolveDriver(
func (s *Service) resolveDriver(
ctx context.Context,
tx pg.Tx,
source *coredata.AccessSource,
scope coredata.Scoper,
source *coredata.AccessReviewSource,
) (drivers.Driver, error) {
if source.ConnectorID == nil {
// CSV-backed source: use CSVDriver when csv_data is present
@@ -277,12 +254,12 @@ func (e *ReviewEngine) resolveDriver(
}
// Built-in driver: default to ProboMemberships
return drivers.NewProboMembershipsDriver(e.pg, e.scope, source.OrganizationID), nil
return drivers.NewProboMembershipsDriver(s.pg, scope, source.OrganizationID), nil
}
// Connector-backed: look up the connector and resolve driver by provider
dbConnector := &coredata.Connector{}
if err := dbConnector.LoadByID(ctx, tx, e.scope, *source.ConnectorID, e.encryptionKey); err != nil {
if err := dbConnector.LoadByID(ctx, tx, scope, *source.ConnectorID, s.encryptionKey); err != nil {
return nil, fmt.Errorf("cannot load connector %s: %w", *source.ConnectorID, err)
}
@@ -294,7 +271,7 @@ func (e *ReviewEngine) resolveDriver(
// Build an HTTP client. For OAuth2 connections, use RefreshableClient
// so that short-lived tokens are transparently refreshed.
httpClient, err := e.connectorHTTPClient(ctx, dbConnector)
httpClient, err := s.connectorHTTPClient(ctx, dbConnector)
if err != nil {
return nil, fmt.Errorf("cannot create HTTP client for %s connector: %w", dbConnector.Provider, err)
}
@@ -306,16 +283,16 @@ func (e *ReviewEngine) resolveDriver(
if oauth2Conn, ok := dbConnector.Connection.(*connector.OAuth2Connection); ok {
if oauth2Conn.AccessToken != tokenBefore {
dbConnector.UpdatedAt = time.Now()
if err := dbConnector.Update(ctx, tx, e.scope, e.encryptionKey); err != nil {
if err := dbConnector.Update(ctx, tx, scope, s.encryptionKey); err != nil {
return nil, fmt.Errorf("cannot persist refreshed token for connector %s: %w", *source.ConnectorID, err)
}
}
}
reg, ok := e.providerRegistry.Get(dbConnector.Provider)
reg, ok := s.providerRegistry.Get(dbConnector.Provider)
if !ok || reg.NewDriver == nil {
return nil, fmt.Errorf("cannot resolve driver: unsupported provider %q", dbConnector.Provider)
}
return reg.NewDriver(ctx, httpClient, dbConnector, e.logger)
return reg.NewDriver(ctx, httpClient, dbConnector, s.logger)
}

View File

@@ -38,8 +38,8 @@ type (
providerRegistry *provider.Registry
logger *log.Logger
fetchWorker *worker.Worker[coredata.AccessReviewCampaignSourceFetch]
sourceNameWorker *worker.Worker[coredata.AccessSource]
fetchWorker *worker.Worker[coredata.AccessReviewCampaignSourceFetchAttempt]
sourceNameWorker *worker.Worker[coredata.AccessReviewSource]
}
Option func(*options)
@@ -102,39 +102,6 @@ func NewService(
return s
}
// Sources returns a tenant-scoped AccessSourceService.
func (s *Service) Sources(scope coredata.Scoper) *AccessSourceService {
return &AccessSourceService{
pg: s.pg,
scope: scope,
encryptionKey: s.encryptionKey,
connectorRegistry: s.connectorRegistry,
providerRegistry: s.providerRegistry,
}
}
// Campaigns returns a tenant-scoped CampaignService.
func (s *Service) Campaigns(scope coredata.Scoper) *CampaignService {
return NewCampaignService(s.pg, scope)
}
// Entries returns a tenant-scoped AccessEntryService.
func (s *Service) Entries(scope coredata.Scoper) *AccessEntryService {
return &AccessEntryService{pg: s.pg, scope: scope}
}
// Engine returns a tenant-scoped ReviewEngine.
func (s *Service) Engine(scope coredata.Scoper) *ReviewEngine {
return NewReviewEngine(
s.pg,
scope,
s.encryptionKey,
s.connectorRegistry,
s.providerRegistry,
s.logger.Named("review_engine"),
)
}
// 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.
@@ -146,7 +113,7 @@ func (s *Service) ResolveEntryOrganizationID(ctx context.Context, entryID gid.GI
func(ctx context.Context, conn pg.Querier) error {
var err error
entry := &coredata.AccessEntry{}
entry := &coredata.AccessReviewEntry{}
organizationID, err = entry.LoadOrganizationID(ctx, conn, entryID)
if err != nil {

View File

@@ -48,7 +48,7 @@ func NewSourceNameWorker(
providerRegistry *provider.Registry,
logger *log.Logger,
opts ...worker.Option,
) *worker.Worker[coredata.AccessSource] {
) *worker.Worker[coredata.AccessReviewSource] {
h := &sourceNameHandler{
pg: pgClient,
encryptionKey: encryptionKey,
@@ -70,8 +70,8 @@ func NewSourceNameWorker(
)
}
func (h *sourceNameHandler) Claim(ctx context.Context) (coredata.AccessSource, error) {
var source coredata.AccessSource
func (h *sourceNameHandler) Claim(ctx context.Context) (coredata.AccessReviewSource, error) {
var source coredata.AccessReviewSource
err := h.pg.WithTx(
ctx,
@@ -80,17 +80,17 @@ func (h *sourceNameHandler) Claim(ctx context.Context) (coredata.AccessSource, e
},
)
if err != nil {
if errors.Is(err, coredata.ErrNoAccessSourceNameSyncAvailable) {
return coredata.AccessSource{}, worker.ErrNoTask
if errors.Is(err, coredata.ErrNoAccessReviewSourceNameSyncAvailable) {
return coredata.AccessReviewSource{}, worker.ErrNoTask
}
return coredata.AccessSource{}, err
return coredata.AccessReviewSource{}, err
}
return source, nil
}
func (h *sourceNameHandler) Process(ctx context.Context, source coredata.AccessSource) error {
func (h *sourceNameHandler) Process(ctx context.Context, source coredata.AccessReviewSource) error {
h.logger.InfoCtx(
ctx,
"syncing source name",
@@ -206,7 +206,7 @@ func (h *sourceNameHandler) Process(ctx context.Context, source coredata.AccessS
func (h *sourceNameHandler) markNameSynced(
ctx context.Context,
source *coredata.AccessSource,
source *coredata.AccessReviewSource,
) error {
return h.pg.WithTx(
ctx,

View File

@@ -22,9 +22,7 @@ import (
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/connector"
"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"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
@@ -35,76 +33,69 @@ const (
)
type (
AccessSourceService struct {
pg *pg.Client
scope coredata.Scoper
encryptionKey cipher.EncryptionKey
connectorRegistry *connector.ConnectorRegistry
providerRegistry *provider.Registry
}
CreateAccessSourceRequest struct {
CreateAccessReviewSourceRequest struct {
OrganizationID gid.GID
ConnectorID *gid.GID
Name string
Category coredata.AccessSourceCategory
Category coredata.AccessReviewSourceCategory
CsvData *string
}
UpdateAccessSourceRequest struct {
AccessSourceID gid.GID
Name *string
Category *coredata.AccessSourceCategory
ConnectorID **gid.GID
CsvData **string
UpdateAccessReviewSourceRequest struct {
AccessReviewSourceID gid.GID
Name *string
Category *coredata.AccessReviewSourceCategory
ConnectorID **gid.GID
CsvData **string
}
ConfigureAccessSourceRequest struct {
AccessSourceID gid.GID
OrganizationSlug string
ConfigureAccessReviewSourceRequest struct {
AccessReviewSourceID gid.GID
OrganizationSlug string
}
)
func (r *CreateAccessSourceRequest) Validate() error {
func (r *CreateAccessReviewSourceRequest) Validate() error {
v := validator.New()
v.Check(r.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(r.Name, "name", validator.SafeTextNoNewLine(NameMaxLength))
v.Check(r.Category, "category", validator.OneOfSlice(coredata.AccessSourceCategories()))
v.Check(r.Category, "category", validator.OneOfSlice(coredata.AccessReviewSourceCategories()))
return v.Error()
}
func (r *ConfigureAccessSourceRequest) Validate() error {
func (r *ConfigureAccessReviewSourceRequest) Validate() error {
v := validator.New()
v.Check(r.AccessSourceID, "access_source_id", validator.Required(), validator.GID(coredata.AccessSourceEntityType))
v.Check(r.AccessReviewSourceID, "access_review_source_id", validator.Required(), validator.GID(coredata.AccessReviewSourceEntityType))
v.Check(r.OrganizationSlug, "organization_slug", validator.Required())
return v.Error()
}
func (r *UpdateAccessSourceRequest) Validate() error {
func (r *UpdateAccessReviewSourceRequest) Validate() error {
v := validator.New()
v.Check(r.AccessSourceID, "access_source_id", validator.Required(), validator.GID(coredata.AccessSourceEntityType))
v.Check(r.AccessReviewSourceID, "access_review_source_id", validator.Required(), validator.GID(coredata.AccessReviewSourceEntityType))
v.Check(r.Name, "name", validator.SafeTextNoNewLine(NameMaxLength))
v.Check(r.Category, "category", validator.OneOfSlice(coredata.AccessSourceCategories()))
v.Check(r.Category, "category", validator.OneOfSlice(coredata.AccessReviewSourceCategories()))
return v.Error()
}
func (s AccessSourceService) Create(
func (s *Service) CreateSource(
ctx context.Context,
req CreateAccessSourceRequest,
) (*coredata.AccessSource, error) {
scope coredata.Scoper,
req CreateAccessReviewSourceRequest,
) (*coredata.AccessReviewSource, error) {
if err := req.Validate(); err != nil {
return nil, err
}
now := time.Now()
source := &coredata.AccessSource{
ID: gid.New(s.scope.GetTenantID(), coredata.AccessSourceEntityType),
source := &coredata.AccessReviewSource{
ID: gid.New(scope.GetTenantID(), coredata.AccessReviewSourceEntityType),
OrganizationID: req.OrganizationID,
ConnectorID: req.ConnectorID,
Name: req.Name,
@@ -120,12 +111,12 @@ func (s AccessSourceService) Create(
// Validate connector exists if provided
if req.ConnectorID != nil {
connector := &coredata.Connector{}
if err := connector.LoadMetadataByID(ctx, conn, s.scope, *req.ConnectorID); err != nil {
if err := connector.LoadMetadataByID(ctx, conn, scope, *req.ConnectorID); err != nil {
return fmt.Errorf("cannot load connector: %w", err)
}
}
if err := source.Insert(ctx, conn, s.scope); err != nil {
if err := source.Insert(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot insert access source: %w", err)
}
@@ -139,16 +130,17 @@ func (s AccessSourceService) Create(
return source, nil
}
func (s AccessSourceService) Get(
func (s *Service) GetSource(
ctx context.Context,
scope coredata.Scoper,
accessSourceID gid.GID,
) (*coredata.AccessSource, error) {
source := &coredata.AccessSource{}
) (*coredata.AccessReviewSource, error) {
source := &coredata.AccessReviewSource{}
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return source.LoadByID(ctx, conn, s.scope, accessSourceID)
return source.LoadByID(ctx, conn, scope, accessSourceID)
},
)
if err != nil {
@@ -158,20 +150,21 @@ func (s AccessSourceService) Get(
return source, nil
}
func (s AccessSourceService) Update(
func (s *Service) UpdateSource(
ctx context.Context,
req UpdateAccessSourceRequest,
) (*coredata.AccessSource, error) {
scope coredata.Scoper,
req UpdateAccessReviewSourceRequest,
) (*coredata.AccessReviewSource, error) {
if err := req.Validate(); err != nil {
return nil, err
}
source := &coredata.AccessSource{}
source := &coredata.AccessReviewSource{}
err := s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
if err := source.LoadByID(ctx, conn, s.scope, req.AccessSourceID); err != nil {
if err := source.LoadByID(ctx, conn, scope, req.AccessReviewSourceID); err != nil {
return fmt.Errorf("cannot load access source: %w", err)
}
@@ -186,7 +179,7 @@ func (s AccessSourceService) Update(
if req.ConnectorID != nil {
if *req.ConnectorID != nil {
connector := &coredata.Connector{}
if err := connector.LoadMetadataByID(ctx, conn, s.scope, **req.ConnectorID); err != nil {
if err := connector.LoadMetadataByID(ctx, conn, scope, **req.ConnectorID); err != nil {
return fmt.Errorf("cannot load connector: %w", err)
}
}
@@ -200,7 +193,7 @@ func (s AccessSourceService) Update(
source.UpdatedAt = time.Now()
if err := source.Update(ctx, conn, s.scope); err != nil {
if err := source.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update access source: %w", err)
}
@@ -214,20 +207,21 @@ func (s AccessSourceService) Update(
return source, nil
}
func (s AccessSourceService) Delete(
func (s *Service) DeleteSource(
ctx context.Context,
scope coredata.Scoper,
accessSourceID gid.GID,
) error {
source := &coredata.AccessSource{}
source := &coredata.AccessReviewSource{}
return s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
if err := source.LoadByID(ctx, conn, s.scope, accessSourceID); err != nil {
if err := source.LoadByID(ctx, conn, scope, accessSourceID); err != nil {
return fmt.Errorf("cannot load access source: %w", err)
}
if err := source.Delete(ctx, conn, s.scope); err != nil {
if err := source.Delete(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot delete access source: %w", err)
}
@@ -239,9 +233,9 @@ func (s AccessSourceService) Delete(
return nil
}
accessSources := &coredata.AccessSources{}
accessSources := &coredata.AccessReviewSources{}
sourceCount, err := accessSources.CountByConnectorID(ctx, conn, s.scope, *source.ConnectorID)
sourceCount, err := accessSources.CountByConnectorID(ctx, conn, scope, *source.ConnectorID)
if err != nil {
return fmt.Errorf("cannot count access sources for connector: %w", err)
}
@@ -252,7 +246,7 @@ func (s AccessSourceService) Delete(
bridges := &coredata.SCIMBridges{}
bridgeCount, err := bridges.CountByConnectorID(ctx, conn, s.scope, *source.ConnectorID)
bridgeCount, err := bridges.CountByConnectorID(ctx, conn, scope, *source.ConnectorID)
if err != nil {
return fmt.Errorf("cannot count scim bridges for connector: %w", err)
}
@@ -272,7 +266,7 @@ func (s AccessSourceService) Delete(
ctx,
func(ctx context.Context, conn pg.Tx) error {
cnnctr := &coredata.Connector{ID: *source.ConnectorID}
if err := cnnctr.Delete(ctx, conn, s.scope); err != nil {
if err := cnnctr.Delete(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot delete connector: %w", err)
}
@@ -287,17 +281,18 @@ func (s AccessSourceService) Delete(
)
}
func (s AccessSourceService) ListForOrganizationID(
func (s *Service) ListSourcesForOrganizationID(
ctx context.Context,
scope coredata.Scoper,
organizationID gid.GID,
cursor *page.Cursor[coredata.AccessSourceOrderField],
) (*page.Page[*coredata.AccessSource, coredata.AccessSourceOrderField], error) {
var sources coredata.AccessSources
cursor *page.Cursor[coredata.AccessReviewSourceOrderField],
) (*page.Page[*coredata.AccessReviewSource, coredata.AccessReviewSourceOrderField], error) {
var sources coredata.AccessReviewSources
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return sources.LoadByOrganizationID(ctx, conn, s.scope, organizationID, cursor)
return sources.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor)
},
)
if err != nil {
@@ -307,8 +302,9 @@ func (s AccessSourceService) ListForOrganizationID(
return page.NewPage(sources, cursor), nil
}
func (s AccessSourceService) CountForOrganizationID(
func (s *Service) CountSourcesForOrganizationID(
ctx context.Context,
scope coredata.Scoper,
organizationID gid.GID,
) (int, error) {
var count int
@@ -316,8 +312,8 @@ func (s AccessSourceService) CountForOrganizationID(
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
sources := coredata.AccessSources{}
count, err = sources.CountByOrganizationID(ctx, conn, s.scope, organizationID)
sources := coredata.AccessReviewSources{}
count, err = sources.CountByOrganizationID(ctx, conn, scope, organizationID)
return err
},
@@ -329,30 +325,12 @@ func (s AccessSourceService) CountForOrganizationID(
return count, nil
}
func (s AccessSourceService) ListScopeSourcesForCampaignID(
ctx context.Context,
campaignID gid.GID,
) ([]*coredata.AccessSource, error) {
var sources coredata.AccessSources
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return sources.LoadScopeSourcesByCampaignID(ctx, conn, s.scope, campaignID)
},
)
if err != nil {
return nil, fmt.Errorf("cannot list scope sources: %w", err)
}
return sources, nil
}
// ConnectorHTTPClient loads a connector by ID with decrypted credentials
// and returns an HTTP client with token refresh support. If the token was
// refreshed during client creation, the updated credentials are persisted.
func (s AccessSourceService) ConnectorHTTPClient(
func (s *Service) ConnectorHTTPClient(
ctx context.Context,
scope coredata.Scoper,
connectorID gid.GID,
) (*http.Client, *coredata.Connector, error) {
var dbConnector coredata.Connector
@@ -360,7 +338,7 @@ func (s AccessSourceService) ConnectorHTTPClient(
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := dbConnector.LoadByID(ctx, conn, s.scope, connectorID, s.encryptionKey); err != nil {
if err := dbConnector.LoadByID(ctx, conn, scope, connectorID, s.encryptionKey); err != nil {
return fmt.Errorf("cannot load connector: %w", err)
}
@@ -408,7 +386,7 @@ func (s AccessSourceService) ConnectorHTTPClient(
if err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
return dbConnector.Update(ctx, tx, s.scope, s.encryptionKey)
return dbConnector.Update(ctx, tx, scope, s.encryptionKey)
},
); err != nil {
return nil, nil, fmt.Errorf("cannot persist refreshed token: %w", err)
@@ -418,20 +396,21 @@ func (s AccessSourceService) ConnectorHTTPClient(
return httpClient, &dbConnector, nil
}
func (s AccessSourceService) ConfigureAccessSource(
func (s *Service) ConfigureAccessReviewSource(
ctx context.Context,
req ConfigureAccessSourceRequest,
) (*coredata.AccessSource, error) {
scope coredata.Scoper,
req ConfigureAccessReviewSourceRequest,
) (*coredata.AccessReviewSource, error) {
if err := req.Validate(); err != nil {
return nil, err
}
source := &coredata.AccessSource{}
source := &coredata.AccessReviewSource{}
err := s.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
if err := source.LoadByID(ctx, conn, s.scope, req.AccessSourceID); err != nil {
if err := source.LoadByID(ctx, conn, scope, req.AccessReviewSourceID); err != nil {
return fmt.Errorf("cannot load access source: %w", err)
}
@@ -440,7 +419,7 @@ func (s AccessSourceService) ConfigureAccessSource(
}
dbConnector := &coredata.Connector{}
if err := dbConnector.LoadByID(ctx, conn, s.scope, *source.ConnectorID, s.encryptionKey); err != nil {
if err := dbConnector.LoadByID(ctx, conn, scope, *source.ConnectorID, s.encryptionKey); err != nil {
return fmt.Errorf("cannot load connector: %w", err)
}
@@ -455,7 +434,7 @@ func (s AccessSourceService) ConfigureAccessSource(
dbConnector.UpdatedAt = time.Now()
if err := dbConnector.Update(ctx, conn, s.scope, s.encryptionKey); err != nil {
if err := dbConnector.Update(ctx, conn, scope, s.encryptionKey); err != nil {
return fmt.Errorf("cannot update connector: %w", err)
}

View File

@@ -27,6 +27,11 @@ import (
"go.probo.inc/probo/pkg/gid"
)
// sourceFetchFailureMessage is the generic, user-facing message persisted on a
// failed fetch attempt. The raw error is only ever written to the logs so that
// internal connector details are never surfaced through the API or UI.
const sourceFetchFailureMessage = "We couldn't fetch accounts from this source. Verify the source configuration and try again."
type sourceFetchHandler struct {
svc *Service
pg *pg.Client
@@ -39,7 +44,7 @@ func NewSourceFetchWorker(
pgClient *pg.Client,
logger *log.Logger,
opts ...worker.Option,
) *worker.Worker[coredata.AccessReviewCampaignSourceFetch] {
) *worker.Worker[coredata.AccessReviewCampaignSourceFetchAttempt] {
h := &sourceFetchHandler{
svc: svc,
pg: pgClient,
@@ -55,44 +60,43 @@ func NewSourceFetchWorker(
)
}
func (h *sourceFetchHandler) Claim(ctx context.Context) (coredata.AccessReviewCampaignSourceFetch, error) {
var sourceFetch coredata.AccessReviewCampaignSourceFetch
func (h *sourceFetchHandler) Claim(ctx context.Context) (coredata.AccessReviewCampaignSourceFetchAttempt, error) {
var attempt coredata.AccessReviewCampaignSourceFetchAttempt
if err := h.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := sourceFetch.LoadNextQueuedForUpdateSkipLocked(ctx, tx); err != nil {
if err := attempt.LoadNextQueuedForUpdateSkipLocked(ctx, tx); err != nil {
return err
}
now := time.Now()
sourceFetch.Status = coredata.AccessReviewCampaignSourceFetchStatusFetching
sourceFetch.AttemptCount++
sourceFetch.LastError = nil
sourceFetch.StartedAt = new(now)
sourceFetch.CompletedAt = nil
sourceFetch.UpdatedAt = now
attempt.Status = coredata.AccessReviewCampaignSourceFetchStatusFetching
attempt.Error = nil
attempt.StartedAt = &now
attempt.CompletedAt = nil
attempt.UpdatedAt = now
scope := coredata.NewScope(sourceFetch.TenantID)
if err := sourceFetch.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update source fetch status: %w", err)
scope := coredata.NewScope(attempt.TenantID)
if err := attempt.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update fetch attempt status: %w", err)
}
return nil
},
); err != nil {
if errors.Is(err, coredata.ErrNoAccessReviewCampaignSourceFetchAvailable) {
return coredata.AccessReviewCampaignSourceFetch{}, worker.ErrNoTask
if errors.Is(err, coredata.ErrNoAccessReviewCampaignSourceFetchAttemptAvailable) {
return coredata.AccessReviewCampaignSourceFetchAttempt{}, worker.ErrNoTask
}
return coredata.AccessReviewCampaignSourceFetch{}, fmt.Errorf("cannot claim source fetch: %w", err)
return coredata.AccessReviewCampaignSourceFetchAttempt{}, fmt.Errorf("cannot claim fetch attempt: %w", err)
}
return sourceFetch, nil
return attempt, nil
}
func (h *sourceFetchHandler) Process(ctx context.Context, sourceFetch coredata.AccessReviewCampaignSourceFetch) error {
return h.handle(ctx, &sourceFetch)
func (h *sourceFetchHandler) Process(ctx context.Context, attempt coredata.AccessReviewCampaignSourceFetchAttempt) error {
return h.handle(ctx, &attempt)
}
func (h *sourceFetchHandler) RecoverStale(ctx context.Context) error {
@@ -102,18 +106,18 @@ func (h *sourceFetchHandler) RecoverStale(ctx context.Context) error {
return h.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var fetches coredata.AccessReviewCampaignSourceFetches
var attempts coredata.AccessReviewCampaignSourceFetchAttempts
count, err := fetches.RecoverStale(ctx, tx, staleThreshold, now)
count, err := attempts.RecoverStale(ctx, tx, staleThreshold, now)
if err != nil {
return fmt.Errorf("cannot recover stale source fetches: %w", err)
return fmt.Errorf("cannot recover stale fetch attempts: %w", err)
}
if count > 0 {
h.logger.InfoCtx(
ctx,
"recovered stale source fetches",
log.Int64("count", count),
"recovered stale fetch attempts",
log.Int("count", count),
)
}
@@ -124,101 +128,123 @@ func (h *sourceFetchHandler) RecoverStale(ctx context.Context) error {
func (h *sourceFetchHandler) handle(
ctx context.Context,
sourceFetch *coredata.AccessReviewCampaignSourceFetch,
attempt *coredata.AccessReviewCampaignSourceFetchAttempt,
) error {
scope := coredata.NewScope(sourceFetch.TenantID)
scope := coredata.NewScope(attempt.TenantID)
campaign, err := h.svc.Campaigns(scope).Get(ctx, sourceFetch.AccessReviewCampaignID)
if err != nil {
commitErr := h.commitFailedSourceFetch(
ctx,
sourceFetch,
fmt.Errorf("cannot load campaign: %w", err),
)
campaignSource := &coredata.AccessReviewCampaignSource{}
if err := h.loadCampaignSource(ctx, scope, attempt.AccessReviewCampaignSourceID, campaignSource); err != nil {
commitErr := h.commitFailedSourceFetch(ctx, attempt, fmt.Errorf("cannot load campaign source: %w", err))
if commitErr != nil {
return fmt.Errorf("cannot load campaign: %w, and cannot commit failed source fetch: %w", err, commitErr)
return fmt.Errorf("cannot load campaign source: %w, and cannot commit failed fetch attempt: %w", err, commitErr)
}
return fmt.Errorf("cannot load campaign source: %w", err)
}
campaign, err := h.svc.GetCampaign(ctx, scope, campaignSource.AccessReviewCampaignID)
if err != nil {
commitErr := h.commitFailedSourceFetch(ctx, attempt, fmt.Errorf("cannot load campaign: %w", err))
if commitErr != nil {
return fmt.Errorf("cannot load campaign: %w, and cannot commit failed fetch attempt: %w", err, commitErr)
}
return fmt.Errorf("cannot load campaign: %w", err)
}
count, err := h.svc.Engine(scope).FetchSource(ctx, campaign, sourceFetch.AccessSourceID)
count, err := h.svc.FetchSource(ctx, scope, campaign, campaignSource)
if err != nil {
commitErr := h.commitFailedSourceFetch(ctx, sourceFetch, err)
if commitErr != nil {
return fmt.Errorf("cannot fetch source: %w, and cannot commit failed source fetch: %w", err, commitErr)
if commitErr := h.commitFailedSourceFetch(ctx, attempt, err); commitErr != nil {
return fmt.Errorf("cannot fetch source: %w, and cannot commit failed fetch attempt: %w", err, commitErr)
}
if finalizeErr := h.finalizeCampaignFetchLifecycle(ctx, sourceFetch.TenantID, sourceFetch.AccessReviewCampaignID); finalizeErr != nil {
return fmt.Errorf("cannot finalize campaign after failed source fetch: %w", finalizeErr)
if finalizeErr := h.finalizeCampaignFetchLifecycle(ctx, attempt.TenantID, campaignSource.AccessReviewCampaignID); finalizeErr != nil {
return fmt.Errorf("cannot finalize campaign after failed fetch attempt: %w", finalizeErr)
}
h.logger.WarnCtx(
ctx,
"source fetch failed but campaign can continue",
log.String("campaign_id", sourceFetch.AccessReviewCampaignID.String()),
log.String("access_source_id", sourceFetch.AccessSourceID.String()),
log.Error(err),
)
return nil
}
if err := h.commitSuccessfulSourceFetch(ctx, sourceFetch, count); err != nil {
return fmt.Errorf("cannot commit successful source fetch: %w", err)
if err := h.commitSuccessfulSourceFetch(ctx, attempt, count); err != nil {
return fmt.Errorf("cannot commit successful fetch attempt: %w", err)
}
if err := h.finalizeCampaignFetchLifecycle(ctx, sourceFetch.TenantID, sourceFetch.AccessReviewCampaignID); err != nil {
if err := h.finalizeCampaignFetchLifecycle(ctx, attempt.TenantID, campaignSource.AccessReviewCampaignID); err != nil {
return fmt.Errorf("cannot finalize campaign fetch lifecycle: %w", err)
}
return nil
}
func (h *sourceFetchHandler) loadCampaignSource(
ctx context.Context,
scope coredata.Scoper,
campaignSourceID gid.GID,
campaignSource *coredata.AccessReviewCampaignSource,
) error {
return h.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return campaignSource.LoadByID(ctx, conn, scope, campaignSourceID)
},
)
}
// commitFailedSourceFetch marks the in-flight attempt as failed with a generic,
// user-facing message and logs the raw error so the internal detail stays in the
// logs only.
func (h *sourceFetchHandler) commitFailedSourceFetch(
ctx context.Context,
sourceFetch *coredata.AccessReviewCampaignSourceFetch,
attempt *coredata.AccessReviewCampaignSourceFetchAttempt,
failureErr error,
) error {
var (
now = time.Now()
errMsg = failureErr.Error()
scope = coredata.NewScopeFromObjectID(sourceFetch.AccessReviewCampaignID)
h.logger.WarnCtx(
ctx,
"source fetch failed but campaign can continue",
log.String("access_review_campaign_source_id", attempt.AccessReviewCampaignSourceID.String()),
log.String("fetch_attempt_id", attempt.ID.String()),
log.Error(failureErr),
)
sourceFetch.Status = coredata.AccessReviewCampaignSourceFetchStatusFailed
sourceFetch.LastError = &errMsg
sourceFetch.CompletedAt = new(now)
sourceFetch.UpdatedAt = now
var (
now = time.Now()
errMsg = sourceFetchFailureMessage
scope = coredata.NewScope(attempt.TenantID)
)
attempt.Status = coredata.AccessReviewCampaignSourceFetchStatusFailed
attempt.Error = &errMsg
attempt.CompletedAt = &now
attempt.UpdatedAt = now
return h.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
return sourceFetch.Update(ctx, tx, scope)
return attempt.Update(ctx, tx, scope)
},
)
}
func (h *sourceFetchHandler) commitSuccessfulSourceFetch(
ctx context.Context,
sourceFetch *coredata.AccessReviewCampaignSourceFetch,
attempt *coredata.AccessReviewCampaignSourceFetchAttempt,
fetchedAccountsCount int,
) error {
var (
now = time.Now()
scope = coredata.NewScopeFromObjectID(sourceFetch.AccessReviewCampaignID)
scope = coredata.NewScope(attempt.TenantID)
)
sourceFetch.Status = coredata.AccessReviewCampaignSourceFetchStatusSuccess
sourceFetch.FetchedAccountsCount = fetchedAccountsCount
sourceFetch.LastError = nil
sourceFetch.CompletedAt = new(now)
sourceFetch.UpdatedAt = now
attempt.Status = coredata.AccessReviewCampaignSourceFetchStatusSuccess
attempt.FetchedAccountsCount = fetchedAccountsCount
attempt.Error = nil
attempt.CompletedAt = &now
attempt.UpdatedAt = now
return h.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
return sourceFetch.Update(ctx, tx, scope)
return attempt.Update(ctx, tx, scope)
},
)
}
@@ -246,17 +272,17 @@ func (h *sourceFetchHandler) finalizeCampaignFetchLifecycle(
return nil
}
fetches := coredata.AccessReviewCampaignSourceFetches{}
if err := fetches.LoadByCampaignID(ctx, tx, scope, campaignID); err != nil {
return fmt.Errorf("cannot load source fetches: %w", err)
latest := coredata.AccessReviewCampaignSourceFetchAttempts{}
if err := latest.LoadLatestByCampaignID(ctx, tx, scope, campaignID); err != nil {
return fmt.Errorf("cannot load latest fetch attempts: %w", err)
}
if len(fetches) == 0 {
if len(latest) == 0 {
return nil
}
for _, fetch := range fetches {
if !fetch.Status.IsTerminal() {
for _, attempt := range latest {
if !attempt.Status.IsTerminal() {
return nil
}
}