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:
45
pkg/accessreview/actions.go
Normal file
45
pkg/accessreview/actions.go
Normal 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"
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
60
pkg/accessreview/policies.go
Normal file
60
pkg/accessreview/policies.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ import (
|
||||
)
|
||||
|
||||
const addSourceMutation = `
|
||||
mutation($input: AddAccessReviewCampaignScopeSourceInput!) {
|
||||
addAccessReviewCampaignScopeSource(input: $input) {
|
||||
mutation($input: AddAccessReviewCampaignSourceInput!) {
|
||||
addAccessReviewCampaignSource(input: $input) {
|
||||
accessReviewCampaign {
|
||||
id
|
||||
name
|
||||
@@ -36,13 +36,13 @@ mutation($input: AddAccessReviewCampaignScopeSourceInput!) {
|
||||
`
|
||||
|
||||
type addSourceResponse struct {
|
||||
AddAccessReviewCampaignScopeSource struct {
|
||||
AddAccessReviewCampaignSource struct {
|
||||
AccessReviewCampaign struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
} `json:"accessReviewCampaign"`
|
||||
} `json:"addAccessReviewCampaignScopeSource"`
|
||||
} `json:"addAccessReviewCampaignSource"`
|
||||
}
|
||||
|
||||
func NewCmdAddSource(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -73,7 +73,7 @@ func NewCmdAddSource(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
input := map[string]any{
|
||||
"accessReviewCampaignId": args[0],
|
||||
"accessSourceId": flagSourceID,
|
||||
"accessReviewSourceId": flagSourceID,
|
||||
}
|
||||
|
||||
data, err := client.Do(
|
||||
@@ -89,7 +89,7 @@ func NewCmdAddSource(f *cmdutil.Factory) *cobra.Command {
|
||||
return fmt.Errorf("cannot parse response: %w", err)
|
||||
}
|
||||
|
||||
c := resp.AddAccessReviewCampaignScopeSource.AccessReviewCampaign
|
||||
c := resp.AddAccessReviewCampaignSource.AccessReviewCampaign
|
||||
out := f.IOStreams.Out
|
||||
_, _ = fmt.Fprintf(out, "Added source %s to campaign %s\n", flagSourceID, c.ID)
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
if len(flagSourceIDs) > 0 {
|
||||
input["accessSourceIds"] = flagSourceIDs
|
||||
input["accessReviewSourceIds"] = flagSourceIDs
|
||||
}
|
||||
|
||||
data, err := client.Do(
|
||||
|
||||
@@ -24,8 +24,8 @@ import (
|
||||
)
|
||||
|
||||
const removeSourceMutation = `
|
||||
mutation($input: RemoveAccessReviewCampaignScopeSourceInput!) {
|
||||
removeAccessReviewCampaignScopeSource(input: $input) {
|
||||
mutation($input: RemoveAccessReviewCampaignSourceInput!) {
|
||||
removeAccessReviewCampaignSource(input: $input) {
|
||||
accessReviewCampaign {
|
||||
id
|
||||
name
|
||||
@@ -36,13 +36,13 @@ mutation($input: RemoveAccessReviewCampaignScopeSourceInput!) {
|
||||
`
|
||||
|
||||
type removeSourceResponse struct {
|
||||
RemoveAccessReviewCampaignScopeSource struct {
|
||||
RemoveAccessReviewCampaignSource struct {
|
||||
AccessReviewCampaign struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
} `json:"accessReviewCampaign"`
|
||||
} `json:"removeAccessReviewCampaignScopeSource"`
|
||||
} `json:"removeAccessReviewCampaignSource"`
|
||||
}
|
||||
|
||||
func NewCmdRemoveSource(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -73,7 +73,7 @@ func NewCmdRemoveSource(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
input := map[string]any{
|
||||
"accessReviewCampaignId": args[0],
|
||||
"accessSourceId": flagSourceID,
|
||||
"accessReviewSourceId": flagSourceID,
|
||||
}
|
||||
|
||||
data, err := client.Do(
|
||||
@@ -89,7 +89,7 @@ func NewCmdRemoveSource(f *cmdutil.Factory) *cobra.Command {
|
||||
return fmt.Errorf("cannot parse response: %w", err)
|
||||
}
|
||||
|
||||
c := resp.RemoveAccessReviewCampaignScopeSource.AccessReviewCampaign
|
||||
c := resp.RemoveAccessReviewCampaignSource.AccessReviewCampaign
|
||||
out := f.IOStreams.Out
|
||||
_, _ = fmt.Fprintf(out, "Removed source %s from campaign %s\n", flagSourceID, c.ID)
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ import (
|
||||
)
|
||||
|
||||
const decideMutation = `
|
||||
mutation($input: RecordAccessEntryDecisionInput!) {
|
||||
recordAccessEntryDecision(input: $input) {
|
||||
mutation($input: RecordAccessReviewEntryDecisionInput!) {
|
||||
recordAccessReviewEntryDecision(input: $input) {
|
||||
accessEntry {
|
||||
id
|
||||
email
|
||||
@@ -39,8 +39,8 @@ mutation($input: RecordAccessEntryDecisionInput!) {
|
||||
`
|
||||
|
||||
type decideResponse struct {
|
||||
RecordAccessEntryDecision struct {
|
||||
AccessEntry struct {
|
||||
RecordAccessReviewEntryDecision struct {
|
||||
AccessReviewEntry struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"fullName"`
|
||||
@@ -48,7 +48,7 @@ type decideResponse struct {
|
||||
DecisionNote *string `json:"decisionNote"`
|
||||
DecidedAt *string `json:"decidedAt"`
|
||||
} `json:"accessEntry"`
|
||||
} `json:"recordAccessEntryDecision"`
|
||||
} `json:"recordAccessReviewEntryDecision"`
|
||||
}
|
||||
|
||||
func NewCmdDecide(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -102,8 +102,8 @@ func NewCmdDecide(f *cmdutil.Factory) *cobra.Command {
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
"accessEntryId": args[0],
|
||||
"decision": flagDecision,
|
||||
"accessReviewEntryId": args[0],
|
||||
"decision": flagDecision,
|
||||
}
|
||||
if flagNote != "" {
|
||||
input["decisionNote"] = flagNote
|
||||
@@ -122,7 +122,7 @@ func NewCmdDecide(f *cmdutil.Factory) *cobra.Command {
|
||||
return fmt.Errorf("cannot parse response: %w", err)
|
||||
}
|
||||
|
||||
e := resp.RecordAccessEntryDecision.AccessEntry
|
||||
e := resp.RecordAccessReviewEntryDecision.AccessReviewEntry
|
||||
|
||||
if *flagOutput == cmdutil.OutputJSON {
|
||||
return cmdutil.PrintJSON(f.IOStreams.Out, e)
|
||||
|
||||
@@ -24,8 +24,8 @@ import (
|
||||
)
|
||||
|
||||
const decideAllMutation = `
|
||||
mutation($input: RecordAccessEntryDecisionsInput!) {
|
||||
recordAccessEntryDecisions(input: $input) {
|
||||
mutation($input: RecordAccessReviewEntryDecisionsInput!) {
|
||||
recordAccessReviewEntryDecisions(input: $input) {
|
||||
accessEntries {
|
||||
id
|
||||
email
|
||||
@@ -36,13 +36,13 @@ mutation($input: RecordAccessEntryDecisionsInput!) {
|
||||
`
|
||||
|
||||
type decideAllResponse struct {
|
||||
RecordAccessEntryDecisions struct {
|
||||
AccessEntries []struct {
|
||||
RecordAccessReviewEntryDecisions struct {
|
||||
AccessReviewEntries []struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Decision string `json:"decision"`
|
||||
} `json:"accessEntries"`
|
||||
} `json:"recordAccessEntryDecisions"`
|
||||
} `json:"recordAccessReviewEntryDecisions"`
|
||||
}
|
||||
|
||||
func NewCmdDecideAll(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -96,8 +96,8 @@ func NewCmdDecideAll(f *cmdutil.Factory) *cobra.Command {
|
||||
decisions := make([]map[string]any, len(flagEntryIDs))
|
||||
for i, id := range flagEntryIDs {
|
||||
d := map[string]any{
|
||||
"accessEntryId": id,
|
||||
"decision": flagDecision,
|
||||
"accessReviewEntryId": id,
|
||||
"decision": flagDecision,
|
||||
}
|
||||
if flagNote != "" {
|
||||
d["decisionNote"] = flagNote
|
||||
@@ -119,7 +119,7 @@ func NewCmdDecideAll(f *cmdutil.Factory) *cobra.Command {
|
||||
return fmt.Errorf("cannot parse response: %w", err)
|
||||
}
|
||||
|
||||
entries := resp.RecordAccessEntryDecisions.AccessEntries
|
||||
entries := resp.RecordAccessReviewEntryDecisions.AccessReviewEntries
|
||||
|
||||
if *flagOutput == cmdutil.OutputJSON {
|
||||
return cmdutil.PrintJSON(f.IOStreams.Out, entries)
|
||||
|
||||
@@ -29,9 +29,9 @@ query(
|
||||
$id: ID!,
|
||||
$first: Int,
|
||||
$after: CursorKey,
|
||||
$orderBy: AccessEntryOrder,
|
||||
$accessSourceId: ID,
|
||||
$filter: AccessEntryFilter
|
||||
$orderBy: AccessReviewEntryOrder,
|
||||
$campaignSourceId: ID,
|
||||
$filter: AccessReviewEntryFilter
|
||||
) {
|
||||
node(id: $id) {
|
||||
__typename
|
||||
@@ -40,7 +40,7 @@ query(
|
||||
first: $first,
|
||||
after: $after,
|
||||
orderBy: $orderBy,
|
||||
accessSourceId: $accessSourceId,
|
||||
campaignSourceId: $campaignSourceId,
|
||||
filter: $filter
|
||||
) {
|
||||
totalCount
|
||||
@@ -81,24 +81,24 @@ query(
|
||||
`
|
||||
|
||||
type entryNode struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"fullName"`
|
||||
Role string `json:"role"`
|
||||
JobTitle string `json:"jobTitle"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Active *bool `json:"active"`
|
||||
MfaStatus string `json:"mfaStatus"`
|
||||
AuthMethod string `json:"authMethod"`
|
||||
AccountType string `json:"accountType"`
|
||||
LastLogin *string `json:"lastLogin"`
|
||||
ExternalID string `json:"externalId"`
|
||||
IncrementalTag string `json:"incrementalTag"`
|
||||
Flags []string `json:"flags"`
|
||||
FlagReasons []string `json:"flagReasons"`
|
||||
Decision string `json:"decision"`
|
||||
DecisionNote *string `json:"decisionNote"`
|
||||
AccessSource struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"fullName"`
|
||||
Role string `json:"role"`
|
||||
JobTitle string `json:"jobTitle"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Active *bool `json:"active"`
|
||||
MfaStatus string `json:"mfaStatus"`
|
||||
AuthMethod string `json:"authMethod"`
|
||||
AccountType string `json:"accountType"`
|
||||
LastLogin *string `json:"lastLogin"`
|
||||
ExternalID string `json:"externalId"`
|
||||
IncrementalTag string `json:"incrementalTag"`
|
||||
Flags []string `json:"flags"`
|
||||
FlagReasons []string `json:"flagReasons"`
|
||||
Decision string `json:"decision"`
|
||||
DecisionNote *string `json:"decisionNote"`
|
||||
AccessReviewSource struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"accessSource"`
|
||||
@@ -107,18 +107,18 @@ type entryNode struct {
|
||||
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
flagLimit int
|
||||
flagOrderBy string
|
||||
flagOrderDir string
|
||||
flagSourceID string
|
||||
flagDecision string
|
||||
flagFlag string
|
||||
flagIncTag string
|
||||
flagIsAdmin *bool
|
||||
flagActive *bool
|
||||
flagAuthMethod string
|
||||
flagAccountType string
|
||||
flagOutput *string
|
||||
flagLimit int
|
||||
flagOrderBy string
|
||||
flagOrderDir string
|
||||
flagCampaignSourceID string
|
||||
flagDecision string
|
||||
flagFlag string
|
||||
flagIncTag string
|
||||
flagIsAdmin *bool
|
||||
flagActive *bool
|
||||
flagAuthMethod string
|
||||
flagAccountType string
|
||||
flagOutput *string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -129,7 +129,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
prb access-review entry list <campaign-id>
|
||||
|
||||
# List entries for a specific source
|
||||
prb access-review entry list <campaign-id> --source-id <source-id>
|
||||
prb access-review entry list <campaign-id> --campaign-source-id <source-id>
|
||||
|
||||
# List only pending entries
|
||||
prb access-review entry list <campaign-id> --decision PENDING
|
||||
@@ -178,8 +178,8 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
if flagSourceID != "" {
|
||||
variables["accessSourceId"] = flagSourceID
|
||||
if flagCampaignSourceID != "" {
|
||||
variables["campaignSourceId"] = flagCampaignSourceID
|
||||
}
|
||||
|
||||
filter := map[string]any{}
|
||||
@@ -326,7 +326,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
e.ID,
|
||||
e.Email,
|
||||
e.FullName,
|
||||
e.AccessSource.Name,
|
||||
e.AccessReviewSource.Name,
|
||||
e.Decision,
|
||||
strings.Join(e.Flags, ","),
|
||||
admin,
|
||||
@@ -354,7 +354,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of entries to list")
|
||||
cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT)")
|
||||
cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)")
|
||||
cmd.Flags().StringVar(&flagSourceID, "source-id", "", "Filter by access source ID")
|
||||
cmd.Flags().StringVar(&flagCampaignSourceID, "source-id", "", "Filter by access source ID")
|
||||
cmd.Flags().StringVar(&flagDecision, "decision", "", "Filter by decision (PENDING, APPROVED, REVOKE, DEFER, ESCALATE)")
|
||||
cmd.Flags().StringVar(&flagFlag, "flag", "", "Filter by flag (NONE, ORPHANED, INACTIVE, EXCESSIVE, ROLE_MISMATCH, NEW)")
|
||||
cmd.Flags().StringVar(&flagIncTag, "incremental-tag", "", "Filter by incremental tag (NEW, REMOVED, UNCHANGED)")
|
||||
|
||||
@@ -25,8 +25,8 @@ import (
|
||||
)
|
||||
|
||||
const flagMutation = `
|
||||
mutation($input: FlagAccessEntryInput!) {
|
||||
flagAccessEntry(input: $input) {
|
||||
mutation($input: FlagAccessReviewEntryInput!) {
|
||||
flagAccessReviewEntry(input: $input) {
|
||||
accessEntry {
|
||||
id
|
||||
email
|
||||
@@ -40,8 +40,8 @@ mutation($input: FlagAccessEntryInput!) {
|
||||
`
|
||||
|
||||
type flagResponse struct {
|
||||
FlagAccessEntry struct {
|
||||
AccessEntry struct {
|
||||
FlagAccessReviewEntry struct {
|
||||
AccessReviewEntry struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"fullName"`
|
||||
@@ -49,7 +49,7 @@ type flagResponse struct {
|
||||
FlagReasons []string `json:"flagReasons"`
|
||||
Decision string `json:"decision"`
|
||||
} `json:"accessEntry"`
|
||||
} `json:"flagAccessEntry"`
|
||||
} `json:"flagAccessReviewEntry"`
|
||||
}
|
||||
|
||||
func NewCmdFlag(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -107,8 +107,8 @@ func NewCmdFlag(f *cmdutil.Factory) *cobra.Command {
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
"accessEntryId": args[0],
|
||||
"flags": flagFlags,
|
||||
"accessReviewEntryId": args[0],
|
||||
"flags": flagFlags,
|
||||
}
|
||||
if flagReason != "" {
|
||||
input["flagReasons"] = []string{flagReason}
|
||||
@@ -127,7 +127,7 @@ func NewCmdFlag(f *cmdutil.Factory) *cobra.Command {
|
||||
return fmt.Errorf("cannot parse response: %w", err)
|
||||
}
|
||||
|
||||
e := resp.FlagAccessEntry.AccessEntry
|
||||
e := resp.FlagAccessReviewEntry.AccessReviewEntry
|
||||
|
||||
if *flagOutput == cmdutil.OutputJSON {
|
||||
return cmdutil.PrintJSON(f.IOStreams.Out, e)
|
||||
|
||||
@@ -25,8 +25,8 @@ import (
|
||||
)
|
||||
|
||||
const createMutation = `
|
||||
mutation($input: CreateAccessSourceInput!) {
|
||||
createAccessSource(input: $input) {
|
||||
mutation($input: CreateAccessReviewSourceInput!) {
|
||||
createAccessReviewSource(input: $input) {
|
||||
accessSourceEdge {
|
||||
node {
|
||||
id
|
||||
@@ -38,14 +38,14 @@ mutation($input: CreateAccessSourceInput!) {
|
||||
`
|
||||
|
||||
type createResponse struct {
|
||||
CreateAccessSource struct {
|
||||
AccessSourceEdge struct {
|
||||
CreateAccessReviewSource struct {
|
||||
AccessReviewSourceEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"node"`
|
||||
} `json:"accessSourceEdge"`
|
||||
} `json:"createAccessSource"`
|
||||
} `json:"createAccessReviewSource"`
|
||||
}
|
||||
|
||||
func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -127,7 +127,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
return fmt.Errorf("cannot parse response: %w", err)
|
||||
}
|
||||
|
||||
s := resp.CreateAccessSource.AccessSourceEdge.Node
|
||||
s := resp.CreateAccessReviewSource.AccessReviewSourceEdge.Node
|
||||
out := f.IOStreams.Out
|
||||
_, _ = fmt.Fprintf(out, "Created access source %s\n", s.ID)
|
||||
_, _ = fmt.Fprintf(out, "Name: %s\n", s.Name)
|
||||
|
||||
@@ -24,9 +24,9 @@ import (
|
||||
)
|
||||
|
||||
const deleteMutation = `
|
||||
mutation($input: DeleteAccessSourceInput!) {
|
||||
deleteAccessSource(input: $input) {
|
||||
deletedAccessSourceId
|
||||
mutation($input: DeleteAccessReviewSourceInput!) {
|
||||
deleteAccessReviewSource(input: $input) {
|
||||
deletedAccessReviewSourceId
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -81,7 +81,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
deleteMutation,
|
||||
map[string]any{
|
||||
"input": map[string]any{
|
||||
"accessSourceId": args[0],
|
||||
"accessReviewSourceId": args[0],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -24,11 +24,11 @@ import (
|
||||
)
|
||||
|
||||
const listQuery = `
|
||||
query($id: ID!, $first: Int, $after: CursorKey, $orderBy: AccessSourceOrder) {
|
||||
query($id: ID!, $first: Int, $after: CursorKey, $orderBy: AccessReviewSourceOrder) {
|
||||
node(id: $id) {
|
||||
__typename
|
||||
... on Organization {
|
||||
accessSources(first: $first, after: $after, orderBy: $orderBy) {
|
||||
accessReviewSources(first: $first, after: $after, orderBy: $orderBy) {
|
||||
totalCount
|
||||
edges {
|
||||
node {
|
||||
@@ -125,8 +125,8 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
func(data json.RawMessage) (*api.Connection[sourceNode], error) {
|
||||
var resp struct {
|
||||
Node *struct {
|
||||
Typename string `json:"__typename"`
|
||||
AccessSources api.Connection[sourceNode] `json:"accessSources"`
|
||||
Typename string `json:"__typename"`
|
||||
AccessReviewSources api.Connection[sourceNode] `json:"accessReviewSources"`
|
||||
} `json:"node"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
@@ -141,7 +141,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
return nil, fmt.Errorf("expected Organization node, got %s", resp.Node.Typename)
|
||||
}
|
||||
|
||||
return &resp.Node.AccessSources, nil
|
||||
return &resp.Node.AccessReviewSources, nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -25,8 +25,8 @@ import (
|
||||
)
|
||||
|
||||
const updateMutation = `
|
||||
mutation($input: UpdateAccessSourceInput!) {
|
||||
updateAccessSource(input: $input) {
|
||||
mutation($input: UpdateAccessReviewSourceInput!) {
|
||||
updateAccessReviewSource(input: $input) {
|
||||
accessSource {
|
||||
id
|
||||
name
|
||||
@@ -36,12 +36,12 @@ mutation($input: UpdateAccessSourceInput!) {
|
||||
`
|
||||
|
||||
type updateResponse struct {
|
||||
UpdateAccessSource struct {
|
||||
AccessSource struct {
|
||||
UpdateAccessReviewSource struct {
|
||||
AccessReviewSource struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"accessSource"`
|
||||
} `json:"updateAccessSource"`
|
||||
} `json:"updateAccessReviewSource"`
|
||||
}
|
||||
|
||||
func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -80,7 +80,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
)
|
||||
|
||||
input := map[string]any{
|
||||
"accessSourceId": args[0],
|
||||
"accessReviewSourceId": args[0],
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("name") {
|
||||
@@ -113,7 +113,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
return fmt.Errorf("cannot parse response: %w", err)
|
||||
}
|
||||
|
||||
s := resp.UpdateAccessSource.AccessSource
|
||||
s := resp.UpdateAccessReviewSource.AccessReviewSource
|
||||
|
||||
if *flagOutput == cmdutil.OutputJSON {
|
||||
return cmdutil.PrintJSON(f.IOStreams.Out, s)
|
||||
|
||||
@@ -28,7 +28,7 @@ const viewQuery = `
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
__typename
|
||||
... on AccessSource {
|
||||
... on AccessReviewSource {
|
||||
id
|
||||
name
|
||||
connectorId
|
||||
@@ -97,8 +97,8 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
return fmt.Errorf("access source %s not found", args[0])
|
||||
}
|
||||
|
||||
if resp.Node.Typename != "AccessSource" {
|
||||
return fmt.Errorf("expected AccessSource node, got %s", resp.Node.Typename)
|
||||
if resp.Node.Typename != "AccessReviewSource" {
|
||||
return fmt.Errorf("expected AccessReviewSource node, got %s", resp.Node.Typename)
|
||||
}
|
||||
|
||||
if *flagOutput == cmdutil.OutputJSON {
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type AccessEntryAccountType string
|
||||
|
||||
const (
|
||||
AccessEntryAccountTypeUser AccessEntryAccountType = "USER"
|
||||
AccessEntryAccountTypeServiceAccount AccessEntryAccountType = "SERVICE_ACCOUNT"
|
||||
)
|
||||
|
||||
var (
|
||||
_ fmt.Stringer = AccessEntryAccountType("")
|
||||
_ encoding.TextMarshaler = AccessEntryAccountType("")
|
||||
_ encoding.TextUnmarshaler = (*AccessEntryAccountType)(nil)
|
||||
)
|
||||
|
||||
func AccessEntryAccountTypes() []AccessEntryAccountType {
|
||||
return []AccessEntryAccountType{
|
||||
AccessEntryAccountTypeUser,
|
||||
AccessEntryAccountTypeServiceAccount,
|
||||
}
|
||||
}
|
||||
|
||||
func (v AccessEntryAccountType) IsValid() bool {
|
||||
switch v {
|
||||
case
|
||||
AccessEntryAccountTypeUser,
|
||||
AccessEntryAccountTypeServiceAccount:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (v AccessEntryAccountType) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
func (v AccessEntryAccountType) MarshalText() ([]byte, error) {
|
||||
return []byte(v.String()), nil
|
||||
}
|
||||
|
||||
func (v *AccessEntryAccountType) UnmarshalText(text []byte) error {
|
||||
val := AccessEntryAccountType(text)
|
||||
if !val.IsValid() {
|
||||
return fmt.Errorf("invalid AccessEntryAccountType value: %q", string(text))
|
||||
}
|
||||
|
||||
*v = val
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
// 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 coredata
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type AccessEntryDecision string
|
||||
|
||||
const (
|
||||
AccessEntryDecisionPending AccessEntryDecision = "PENDING"
|
||||
AccessEntryDecisionApproved AccessEntryDecision = "APPROVED"
|
||||
AccessEntryDecisionRevoke AccessEntryDecision = "REVOKE"
|
||||
AccessEntryDecisionDefer AccessEntryDecision = "DEFER"
|
||||
AccessEntryDecisionEscalate AccessEntryDecision = "ESCALATE"
|
||||
)
|
||||
|
||||
var (
|
||||
_ fmt.Stringer = AccessEntryDecision("")
|
||||
_ encoding.TextMarshaler = AccessEntryDecision("")
|
||||
_ encoding.TextUnmarshaler = (*AccessEntryDecision)(nil)
|
||||
)
|
||||
|
||||
func AccessEntryDecisions() []AccessEntryDecision {
|
||||
return []AccessEntryDecision{
|
||||
AccessEntryDecisionPending,
|
||||
AccessEntryDecisionApproved,
|
||||
AccessEntryDecisionRevoke,
|
||||
AccessEntryDecisionDefer,
|
||||
AccessEntryDecisionEscalate,
|
||||
}
|
||||
}
|
||||
|
||||
func (v AccessEntryDecision) IsValid() bool {
|
||||
switch v {
|
||||
case
|
||||
AccessEntryDecisionPending,
|
||||
AccessEntryDecisionApproved,
|
||||
AccessEntryDecisionRevoke,
|
||||
AccessEntryDecisionDefer,
|
||||
AccessEntryDecisionEscalate:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (v AccessEntryDecision) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
func (v AccessEntryDecision) MarshalText() ([]byte, error) {
|
||||
return []byte(v.String()), nil
|
||||
}
|
||||
|
||||
func (v *AccessEntryDecision) UnmarshalText(text []byte) error {
|
||||
val := AccessEntryDecision(text)
|
||||
if !val.IsValid() {
|
||||
return fmt.Errorf("invalid AccessEntryDecision value: %q", string(text))
|
||||
}
|
||||
|
||||
*v = val
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type AccessEntryFlag string
|
||||
|
||||
const (
|
||||
AccessEntryFlagNone AccessEntryFlag = "NONE"
|
||||
AccessEntryFlagOrphaned AccessEntryFlag = "ORPHANED"
|
||||
AccessEntryFlagInactive AccessEntryFlag = "INACTIVE"
|
||||
AccessEntryFlagExcessive AccessEntryFlag = "EXCESSIVE"
|
||||
AccessEntryFlagRoleMismatch AccessEntryFlag = "ROLE_MISMATCH"
|
||||
AccessEntryFlagNew AccessEntryFlag = "NEW"
|
||||
AccessEntryFlagDormant AccessEntryFlag = "DORMANT"
|
||||
AccessEntryFlagTerminatedUser AccessEntryFlag = "TERMINATED_USER"
|
||||
AccessEntryFlagContractorExpired AccessEntryFlag = "CONTRACTOR_EXPIRED"
|
||||
AccessEntryFlagSoDConflict AccessEntryFlag = "SOD_CONFLICT"
|
||||
AccessEntryFlagPrivilegedAccess AccessEntryFlag = "PRIVILEGED_ACCESS"
|
||||
AccessEntryFlagRoleCreep AccessEntryFlag = "ROLE_CREEP"
|
||||
AccessEntryFlagNoBusinessJustification AccessEntryFlag = "NO_BUSINESS_JUSTIFICATION"
|
||||
AccessEntryFlagOutOfDepartment AccessEntryFlag = "OUT_OF_DEPARTMENT"
|
||||
AccessEntryFlagSharedAccount AccessEntryFlag = "SHARED_ACCOUNT"
|
||||
)
|
||||
|
||||
var (
|
||||
_ fmt.Stringer = AccessEntryFlag("")
|
||||
_ encoding.TextMarshaler = AccessEntryFlag("")
|
||||
_ encoding.TextUnmarshaler = (*AccessEntryFlag)(nil)
|
||||
)
|
||||
|
||||
func AccessEntryFlags() []AccessEntryFlag {
|
||||
return []AccessEntryFlag{
|
||||
AccessEntryFlagNone,
|
||||
AccessEntryFlagOrphaned,
|
||||
AccessEntryFlagInactive,
|
||||
AccessEntryFlagExcessive,
|
||||
AccessEntryFlagRoleMismatch,
|
||||
AccessEntryFlagNew,
|
||||
AccessEntryFlagDormant,
|
||||
AccessEntryFlagTerminatedUser,
|
||||
AccessEntryFlagContractorExpired,
|
||||
AccessEntryFlagSoDConflict,
|
||||
AccessEntryFlagPrivilegedAccess,
|
||||
AccessEntryFlagRoleCreep,
|
||||
AccessEntryFlagNoBusinessJustification,
|
||||
AccessEntryFlagOutOfDepartment,
|
||||
AccessEntryFlagSharedAccount,
|
||||
}
|
||||
}
|
||||
|
||||
func (v AccessEntryFlag) IsValid() bool {
|
||||
switch v {
|
||||
case
|
||||
AccessEntryFlagNone,
|
||||
AccessEntryFlagOrphaned,
|
||||
AccessEntryFlagInactive,
|
||||
AccessEntryFlagExcessive,
|
||||
AccessEntryFlagRoleMismatch,
|
||||
AccessEntryFlagNew,
|
||||
AccessEntryFlagDormant,
|
||||
AccessEntryFlagTerminatedUser,
|
||||
AccessEntryFlagContractorExpired,
|
||||
AccessEntryFlagSoDConflict,
|
||||
AccessEntryFlagPrivilegedAccess,
|
||||
AccessEntryFlagRoleCreep,
|
||||
AccessEntryFlagNoBusinessJustification,
|
||||
AccessEntryFlagOutOfDepartment,
|
||||
AccessEntryFlagSharedAccount:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (v AccessEntryFlag) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
func (v AccessEntryFlag) MarshalText() ([]byte, error) {
|
||||
return []byte(v.String()), nil
|
||||
}
|
||||
|
||||
func (v *AccessEntryFlag) UnmarshalText(text []byte) error {
|
||||
val := AccessEntryFlag(text)
|
||||
if !val.IsValid() {
|
||||
return fmt.Errorf("invalid AccessEntryFlag value: %q", string(text))
|
||||
}
|
||||
|
||||
*v = val
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type AccessEntryIncrementalTag string
|
||||
|
||||
const (
|
||||
AccessEntryIncrementalTagNew AccessEntryIncrementalTag = "NEW"
|
||||
AccessEntryIncrementalTagRemoved AccessEntryIncrementalTag = "REMOVED"
|
||||
AccessEntryIncrementalTagUnchanged AccessEntryIncrementalTag = "UNCHANGED"
|
||||
)
|
||||
|
||||
var (
|
||||
_ fmt.Stringer = AccessEntryIncrementalTag("")
|
||||
_ encoding.TextMarshaler = AccessEntryIncrementalTag("")
|
||||
_ encoding.TextUnmarshaler = (*AccessEntryIncrementalTag)(nil)
|
||||
)
|
||||
|
||||
func AccessEntryIncrementalTags() []AccessEntryIncrementalTag {
|
||||
return []AccessEntryIncrementalTag{
|
||||
AccessEntryIncrementalTagNew,
|
||||
AccessEntryIncrementalTagRemoved,
|
||||
AccessEntryIncrementalTagUnchanged,
|
||||
}
|
||||
}
|
||||
|
||||
func (v AccessEntryIncrementalTag) IsValid() bool {
|
||||
switch v {
|
||||
case
|
||||
AccessEntryIncrementalTagNew,
|
||||
AccessEntryIncrementalTagRemoved,
|
||||
AccessEntryIncrementalTagUnchanged:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (v AccessEntryIncrementalTag) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
func (v AccessEntryIncrementalTag) MarshalText() ([]byte, error) {
|
||||
return []byte(v.String()), nil
|
||||
}
|
||||
|
||||
func (v *AccessEntryIncrementalTag) UnmarshalText(text []byte) error {
|
||||
val := AccessEntryIncrementalTag(text)
|
||||
if !val.IsValid() {
|
||||
return fmt.Errorf("invalid AccessEntryIncrementalTag value: %q", string(text))
|
||||
}
|
||||
|
||||
*v = val
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,486 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/internal/test"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
// accessEntryFixture bootstraps the parent rows (organization, campaign,
|
||||
// source) that the access_entries FKs require.
|
||||
type accessEntryFixture struct {
|
||||
scope *coredata.Scope
|
||||
organizationID gid.GID
|
||||
campaignID gid.GID
|
||||
sourceID gid.GID
|
||||
accountKey string
|
||||
}
|
||||
|
||||
func seedAccessEntryFixture(t *testing.T, ctx context.Context, client *pg.Client) accessEntryFixture {
|
||||
t.Helper()
|
||||
|
||||
tenantID := gid.NewTenantID()
|
||||
scope := coredata.NewScope(tenantID)
|
||||
organizationID := gid.New(tenantID, coredata.OrganizationEntityType)
|
||||
campaignID := gid.New(tenantID, coredata.AccessReviewCampaignEntityType)
|
||||
sourceID := gid.New(tenantID, coredata.AccessSourceEntityType)
|
||||
accountKey := "upsert-freeze-test@example.com"
|
||||
now := time.Now().UTC()
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
org := &coredata.Organization{
|
||||
ID: organizationID,
|
||||
TenantID: tenantID,
|
||||
Name: "Upsert Freeze Test Org",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := org.Insert(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
source := &coredata.AccessSource{
|
||||
ID: sourceID,
|
||||
OrganizationID: organizationID,
|
||||
Name: "Upsert Freeze Test Source",
|
||||
Category: coredata.AccessSourceCategorySaaS,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := source.Insert(ctx, tx, scope); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
campaign := &coredata.AccessReviewCampaign{
|
||||
ID: campaignID,
|
||||
OrganizationID: organizationID,
|
||||
Name: "Upsert Freeze Test Campaign",
|
||||
Status: coredata.AccessReviewCampaignStatusDraft,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := campaign.Insert(ctx, tx, scope); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}))
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error {
|
||||
// Delete access_entries first (no ON DELETE CASCADE for the org side),
|
||||
// then parents.
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM access_entries WHERE access_review_campaign_id = $1`, campaignID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM access_review_campaigns WHERE id = $1`, campaignID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM access_sources WHERE id = $1`, sourceID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM organizations WHERE id = $1`, organizationID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
return accessEntryFixture{
|
||||
scope: scope,
|
||||
organizationID: organizationID,
|
||||
campaignID: campaignID,
|
||||
sourceID: sourceID,
|
||||
accountKey: accountKey,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessEntry_Upsert_FreezesDecidedFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
fx := seedAccessEntryFixture(t, ctx, client)
|
||||
|
||||
tenantID := fx.scope.GetTenantID()
|
||||
originalFlagReasons := []string{"original-flag-reason"}
|
||||
originalFlags := []coredata.AccessEntryFlag{coredata.AccessEntryFlagNew}
|
||||
originalEmail := "old@example.com"
|
||||
originalFullName := "Old Name"
|
||||
originalRole := "viewer"
|
||||
|
||||
t0 := time.Now().UTC().Truncate(time.Microsecond)
|
||||
|
||||
// Step 1: Initial Upsert with PENDING decision.
|
||||
entryID := gid.New(tenantID, coredata.AccessEntryEntityType)
|
||||
initial := &coredata.AccessEntry{
|
||||
ID: entryID,
|
||||
OrganizationID: fx.organizationID,
|
||||
AccessReviewCampaignID: fx.campaignID,
|
||||
AccessSourceID: fx.sourceID,
|
||||
Email: originalEmail,
|
||||
FullName: originalFullName,
|
||||
Role: originalRole,
|
||||
JobTitle: "",
|
||||
IsAdmin: false,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
ExternalID: "ext-1",
|
||||
AccountKey: fx.accountKey,
|
||||
IncrementalTag: coredata.AccessEntryIncrementalTagNew,
|
||||
Flags: originalFlags,
|
||||
FlagReasons: originalFlagReasons,
|
||||
Decision: coredata.AccessEntryDecisionPending,
|
||||
DecisionNote: nil,
|
||||
DecidedBy: nil,
|
||||
DecidedAt: nil,
|
||||
CreatedAt: t0,
|
||||
UpdatedAt: t0,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
return initial.Upsert(ctx, tx, fx.scope)
|
||||
}))
|
||||
|
||||
// Step 2: Record a decision via Update — APPROVED with decided_by / decided_at.
|
||||
decisionTime := t0.Add(1 * time.Hour)
|
||||
decidedBy := gid.New(tenantID, coredata.OrganizationEntityType) // opaque ID suffices: decided_by has no FK.
|
||||
decisionNote := "looks good"
|
||||
|
||||
decided := &coredata.AccessEntry{
|
||||
ID: entryID,
|
||||
Flags: originalFlags,
|
||||
FlagReasons: originalFlagReasons,
|
||||
Decision: coredata.AccessEntryDecisionApproved,
|
||||
DecisionNote: &decisionNote,
|
||||
DecidedBy: &decidedBy,
|
||||
DecidedAt: &decisionTime,
|
||||
UpdatedAt: decisionTime,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
return decided.Update(ctx, tx, fx.scope)
|
||||
}))
|
||||
|
||||
// Step 3: Second Upsert with the same unique key but new flags, new
|
||||
// flag reasons, PENDING decision, nil note/decidedBy/decidedAt, and
|
||||
// refreshed top-level fields (email, full_name, role).
|
||||
t2 := decisionTime.Add(1 * time.Hour)
|
||||
secondEmail := "new@example.com"
|
||||
secondFullName := "New Name"
|
||||
secondRole := "admin"
|
||||
refresh := &coredata.AccessEntry{
|
||||
ID: gid.New(tenantID, coredata.AccessEntryEntityType), // ignored by ON CONFLICT
|
||||
OrganizationID: fx.organizationID,
|
||||
AccessReviewCampaignID: fx.campaignID,
|
||||
AccessSourceID: fx.sourceID,
|
||||
Email: secondEmail,
|
||||
FullName: secondFullName,
|
||||
Role: secondRole,
|
||||
JobTitle: "",
|
||||
IsAdmin: true,
|
||||
MFAStatus: coredata.MFAStatusEnabled,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodSSO,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
ExternalID: "ext-1",
|
||||
AccountKey: fx.accountKey,
|
||||
IncrementalTag: coredata.AccessEntryIncrementalTagUnchanged,
|
||||
Flags: []coredata.AccessEntryFlag{coredata.AccessEntryFlagInactive},
|
||||
FlagReasons: []string{"refreshed-flag-reason"},
|
||||
Decision: coredata.AccessEntryDecisionPending,
|
||||
DecisionNote: nil,
|
||||
DecidedBy: nil,
|
||||
DecidedAt: nil,
|
||||
CreatedAt: t2,
|
||||
UpdatedAt: t2,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
return refresh.Upsert(ctx, tx, fx.scope)
|
||||
}))
|
||||
|
||||
// Step 4: Load and assert the freeze semantics.
|
||||
loaded := &coredata.AccessEntry{}
|
||||
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
return loaded.LoadByID(ctx, conn, fx.scope, entryID)
|
||||
}))
|
||||
|
||||
// Decision fields are FROZEN at APPROVED / decided_by / decided_at /
|
||||
// decision_note from the Update call.
|
||||
assert.Equal(t, coredata.AccessEntryDecisionApproved, loaded.Decision, "decision must be frozen once locked")
|
||||
require.NotNil(t, loaded.DecidedBy, "decided_by must be preserved")
|
||||
assert.Equal(t, decidedBy, *loaded.DecidedBy)
|
||||
require.NotNil(t, loaded.DecidedAt, "decided_at must be preserved")
|
||||
assert.WithinDuration(t, decisionTime, *loaded.DecidedAt, time.Second)
|
||||
require.NotNil(t, loaded.DecisionNote, "decision_note must be preserved")
|
||||
assert.Equal(t, decisionNote, *loaded.DecisionNote)
|
||||
|
||||
// Flags / flag_reasons are FROZEN (the new guard from Task 1): once a
|
||||
// reviewer locks a decision, the evidence that drove that decision must
|
||||
// not be silently replaced by a subsequent poll.
|
||||
assert.Equal(t, originalFlags, loaded.Flags, "flags must be frozen once decision is locked")
|
||||
assert.Equal(t, originalFlagReasons, loaded.FlagReasons, "flag_reasons must be frozen once decision is locked")
|
||||
|
||||
// Columns that ARE refreshed on every poll.
|
||||
assert.Equal(t, secondEmail, loaded.Email)
|
||||
assert.Equal(t, secondFullName, loaded.FullName)
|
||||
assert.Equal(t, secondRole, loaded.Role)
|
||||
assert.True(t, loaded.IsAdmin)
|
||||
assert.Equal(t, coredata.MFAStatusEnabled, loaded.MFAStatus)
|
||||
assert.Equal(t, coredata.AccessEntryAuthMethodSSO, loaded.AuthMethod)
|
||||
assert.WithinDuration(t, t2, loaded.UpdatedAt, time.Second)
|
||||
}
|
||||
|
||||
// TestAccessEntry_Upsert_RefreshesSourceTrackingFields pins the contract of
|
||||
// the ON CONFLICT DO UPDATE SET clause: across repeated polls of the same
|
||||
// (campaign, source, account_key), the columns that track live source state
|
||||
// (email, full_name, role, is_admin, MFA, auth_method, last_login, etc.)
|
||||
// move forward to the latest values, while the verdict-related columns
|
||||
// (flags, flag_reasons, decision, decision_note, decided_by, decided_at) are
|
||||
// never written by a re-poll -- those can only change through Update.
|
||||
func TestAccessEntry_Upsert_RefreshesSourceTrackingFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
fx := seedAccessEntryFixture(t, ctx, client)
|
||||
|
||||
tenantID := fx.scope.GetTenantID()
|
||||
t0 := time.Now().UTC().Truncate(time.Microsecond)
|
||||
|
||||
entryID := gid.New(tenantID, coredata.AccessEntryEntityType)
|
||||
first := &coredata.AccessEntry{
|
||||
ID: entryID,
|
||||
OrganizationID: fx.organizationID,
|
||||
AccessReviewCampaignID: fx.campaignID,
|
||||
AccessSourceID: fx.sourceID,
|
||||
Email: "old@example.com",
|
||||
FullName: "Old Name",
|
||||
Role: "viewer",
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
ExternalID: "ext-2",
|
||||
AccountKey: fx.accountKey,
|
||||
IncrementalTag: coredata.AccessEntryIncrementalTagNew,
|
||||
Flags: []coredata.AccessEntryFlag{},
|
||||
FlagReasons: []string{},
|
||||
Decision: coredata.AccessEntryDecisionPending,
|
||||
CreatedAt: t0,
|
||||
UpdatedAt: t0,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
return first.Upsert(ctx, tx, fx.scope)
|
||||
}))
|
||||
|
||||
t1 := t0.Add(1 * time.Hour)
|
||||
second := &coredata.AccessEntry{
|
||||
ID: gid.New(tenantID, coredata.AccessEntryEntityType),
|
||||
OrganizationID: fx.organizationID,
|
||||
AccessReviewCampaignID: fx.campaignID,
|
||||
AccessSourceID: fx.sourceID,
|
||||
Email: "new@example.com",
|
||||
FullName: "New Name",
|
||||
Role: "admin",
|
||||
MFAStatus: coredata.MFAStatusEnabled,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodSSO,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
ExternalID: "ext-2",
|
||||
AccountKey: fx.accountKey,
|
||||
IncrementalTag: coredata.AccessEntryIncrementalTagUnchanged,
|
||||
Flags: []coredata.AccessEntryFlag{},
|
||||
FlagReasons: []string{},
|
||||
Decision: coredata.AccessEntryDecisionPending,
|
||||
CreatedAt: t1,
|
||||
UpdatedAt: t1,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
return second.Upsert(ctx, tx, fx.scope)
|
||||
}))
|
||||
|
||||
loaded := &coredata.AccessEntry{}
|
||||
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
return loaded.LoadByID(ctx, conn, fx.scope, entryID)
|
||||
}))
|
||||
|
||||
// Source-tracking columns advanced to the second poll's values.
|
||||
assert.Equal(t, "new@example.com", loaded.Email)
|
||||
assert.Equal(t, "New Name", loaded.FullName)
|
||||
assert.Equal(t, "admin", loaded.Role)
|
||||
assert.Equal(t, coredata.MFAStatusEnabled, loaded.MFAStatus)
|
||||
assert.Equal(t, coredata.AccessEntryAuthMethodSSO, loaded.AuthMethod)
|
||||
|
||||
// Verdict-related columns stayed at whatever the first Upsert set (empty /
|
||||
// PENDING); the second Upsert did not touch them.
|
||||
assert.Equal(t, coredata.AccessEntryDecisionPending, loaded.Decision)
|
||||
assert.Equal(t, []coredata.AccessEntryFlag{}, loaded.Flags)
|
||||
assert.Equal(t, []string{}, loaded.FlagReasons)
|
||||
assert.Nil(t, loaded.DecisionNote)
|
||||
assert.Nil(t, loaded.DecidedBy)
|
||||
assert.Nil(t, loaded.DecidedAt)
|
||||
}
|
||||
|
||||
func TestAccessEntry_Upsert_RefreshesActiveStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
fx := seedAccessEntryFixture(t, ctx, client)
|
||||
|
||||
tenantID := fx.scope.GetTenantID()
|
||||
t0 := time.Now().UTC().Truncate(time.Microsecond)
|
||||
activeTrue := true
|
||||
activeFalse := false
|
||||
|
||||
entryID := gid.New(tenantID, coredata.AccessEntryEntityType)
|
||||
first := &coredata.AccessEntry{
|
||||
ID: entryID,
|
||||
OrganizationID: fx.organizationID,
|
||||
AccessReviewCampaignID: fx.campaignID,
|
||||
AccessSourceID: fx.sourceID,
|
||||
Email: "user@example.com",
|
||||
FullName: "User",
|
||||
Role: "member",
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
Active: &activeTrue,
|
||||
ExternalID: "ext-active",
|
||||
AccountKey: fx.accountKey,
|
||||
IncrementalTag: coredata.AccessEntryIncrementalTagNew,
|
||||
Flags: []coredata.AccessEntryFlag{},
|
||||
FlagReasons: []string{},
|
||||
Decision: coredata.AccessEntryDecisionPending,
|
||||
CreatedAt: t0,
|
||||
UpdatedAt: t0,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
return first.Upsert(ctx, tx, fx.scope)
|
||||
}))
|
||||
|
||||
t1 := t0.Add(1 * time.Hour)
|
||||
second := &coredata.AccessEntry{
|
||||
ID: gid.New(tenantID, coredata.AccessEntryEntityType),
|
||||
OrganizationID: fx.organizationID,
|
||||
AccessReviewCampaignID: fx.campaignID,
|
||||
AccessSourceID: fx.sourceID,
|
||||
Email: "user@example.com",
|
||||
FullName: "User",
|
||||
Role: "member",
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
Active: &activeFalse,
|
||||
ExternalID: "ext-active",
|
||||
AccountKey: fx.accountKey,
|
||||
IncrementalTag: coredata.AccessEntryIncrementalTagUnchanged,
|
||||
Flags: []coredata.AccessEntryFlag{},
|
||||
FlagReasons: []string{},
|
||||
Decision: coredata.AccessEntryDecisionPending,
|
||||
CreatedAt: t1,
|
||||
UpdatedAt: t1,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
return second.Upsert(ctx, tx, fx.scope)
|
||||
}))
|
||||
|
||||
loaded := &coredata.AccessEntry{}
|
||||
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
return loaded.LoadByID(ctx, conn, fx.scope, entryID)
|
||||
}))
|
||||
|
||||
require.NotNil(t, loaded.Active)
|
||||
assert.False(t, *loaded.Active)
|
||||
}
|
||||
|
||||
// TestAccessEntry_Upsert_InsertsActiveAccount covers the shape FetchSource
|
||||
// builds for an active account: a PENDING decision and explicit empty
|
||||
// flags / flag_reasons slices. The access_entries.flags and flag_reasons
|
||||
// columns are declared NOT NULL, so the caller (FetchSource) is responsible
|
||||
// for passing non-nil slices.
|
||||
func TestAccessEntry_Upsert_InsertsActiveAccount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
fx := seedAccessEntryFixture(t, ctx, client)
|
||||
|
||||
tenantID := fx.scope.GetTenantID()
|
||||
t0 := time.Now().UTC().Truncate(time.Microsecond)
|
||||
|
||||
activeTrue := true
|
||||
entryID := gid.New(tenantID, coredata.AccessEntryEntityType)
|
||||
entry := &coredata.AccessEntry{
|
||||
ID: entryID,
|
||||
OrganizationID: fx.organizationID,
|
||||
AccessReviewCampaignID: fx.campaignID,
|
||||
AccessSourceID: fx.sourceID,
|
||||
Email: "active@example.com",
|
||||
FullName: "Active User",
|
||||
Role: "member",
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
Active: &activeTrue,
|
||||
ExternalID: "ext-active",
|
||||
AccountKey: fx.accountKey,
|
||||
IncrementalTag: coredata.AccessEntryIncrementalTagNew,
|
||||
Flags: []coredata.AccessEntryFlag{},
|
||||
FlagReasons: []string{},
|
||||
Decision: coredata.AccessEntryDecisionPending,
|
||||
CreatedAt: t0,
|
||||
UpdatedAt: t0,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
return entry.Upsert(ctx, tx, fx.scope)
|
||||
}))
|
||||
|
||||
loaded := &coredata.AccessEntry{}
|
||||
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
return loaded.LoadByID(ctx, conn, fx.scope, entryID)
|
||||
}))
|
||||
|
||||
require.NotNil(t, loaded.Active)
|
||||
assert.True(t, *loaded.Active)
|
||||
assert.Equal(t, coredata.AccessEntryDecisionPending, loaded.Decision)
|
||||
assert.Equal(t, []coredata.AccessEntryFlag{}, loaded.Flags)
|
||||
assert.Equal(t, []string{}, loaded.FlagReasons)
|
||||
assert.Nil(t, loaded.DecisionNote)
|
||||
assert.Nil(t, loaded.DecidedBy)
|
||||
assert.Nil(t, loaded.DecidedAt)
|
||||
}
|
||||
@@ -54,6 +54,34 @@ func (c AccessReviewCampaign) CursorKey(orderBy AccessReviewCampaignOrderField)
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
func (c *AccessReviewCampaign) LockForUpdate(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
SELECT id
|
||||
FROM access_review_campaigns
|
||||
WHERE %s
|
||||
AND id = @id
|
||||
FOR UPDATE
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
args := pgx.StrictNamedArgs{"id": c.ID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
var id gid.GID
|
||||
if err := conn.QueryRow(ctx, q, args).Scan(&id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot lock campaign: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *AccessReviewCampaign) AuthorizationAttributes(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type AccessReviewCampaignScopeSystem struct {
|
||||
AccessReviewCampaignID gid.GID `db:"access_review_campaign_id"`
|
||||
AccessSourceID gid.GID `db:"access_source_id"`
|
||||
}
|
||||
|
||||
func (ss AccessReviewCampaignScopeSystem) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO access_review_campaign_scope_systems (access_review_campaign_id, access_source_id, tenant_id)
|
||||
VALUES (@access_review_campaign_id, @access_source_id, @tenant_id)
|
||||
`
|
||||
args := pgx.StrictNamedArgs{
|
||||
"access_review_campaign_id": ss.AccessReviewCampaignID,
|
||||
"access_source_id": ss.AccessSourceID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert campaign scope system: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ss AccessReviewCampaignScopeSystem) Upsert(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO access_review_campaign_scope_systems (access_review_campaign_id, access_source_id, tenant_id)
|
||||
VALUES (@access_review_campaign_id, @access_source_id, @tenant_id)
|
||||
ON CONFLICT (access_review_campaign_id, access_source_id) DO NOTHING
|
||||
`
|
||||
args := pgx.StrictNamedArgs{
|
||||
"access_review_campaign_id": ss.AccessReviewCampaignID,
|
||||
"access_source_id": ss.AccessSourceID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot upsert campaign scope system: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ss AccessReviewCampaignScopeSystem) Delete(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
DELETE FROM access_review_campaign_scope_systems
|
||||
WHERE
|
||||
%s
|
||||
AND access_review_campaign_id = @access_review_campaign_id
|
||||
AND access_source_id = @access_source_id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"access_review_campaign_id": ss.AccessReviewCampaignID,
|
||||
"access_source_id": ss.AccessSourceID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete campaign scope system: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *AccessReviewCampaign) LockForUpdate(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
SELECT id
|
||||
FROM access_review_campaigns
|
||||
WHERE %s
|
||||
AND id = @id
|
||||
FOR UPDATE
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
args := pgx.StrictNamedArgs{"id": c.ID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
var id gid.GID
|
||||
if err := conn.QueryRow(ctx, q, args).Scan(&id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot lock campaign: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *AccessReviewCampaignSourceFetch) UpsertQueued(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
now time.Time,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO access_review_campaign_source_fetches (
|
||||
tenant_id,
|
||||
access_review_campaign_id,
|
||||
access_source_id,
|
||||
status,
|
||||
fetched_accounts_count,
|
||||
attempt_count,
|
||||
last_error,
|
||||
started_at,
|
||||
completed_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@tenant_id, @access_review_campaign_id, @access_source_id,
|
||||
'QUEUED', 0, 0, NULL, NULL, NULL, @now, @now
|
||||
)
|
||||
ON CONFLICT (access_review_campaign_id, access_source_id) DO UPDATE SET
|
||||
status = 'QUEUED',
|
||||
fetched_accounts_count = 0,
|
||||
attempt_count = 0,
|
||||
last_error = NULL,
|
||||
started_at = NULL,
|
||||
completed_at = NULL,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`
|
||||
args := pgx.StrictNamedArgs{
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"access_review_campaign_id": f.AccessReviewCampaignID,
|
||||
"access_source_id": f.AccessSourceID,
|
||||
"now": now,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot upsert queued source fetch: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecoverStale is intentionally cross-tenant: the background worker recovers
|
||||
// all stale fetches regardless of tenant.
|
||||
func (fs *AccessReviewCampaignSourceFetches) RecoverStale(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
staleThreshold time.Time,
|
||||
now time.Time,
|
||||
) (int64, error) {
|
||||
q := `
|
||||
UPDATE access_review_campaign_source_fetches
|
||||
SET
|
||||
status = 'QUEUED',
|
||||
last_error = 'recovered from stale FETCHING state',
|
||||
started_at = NULL,
|
||||
completed_at = NULL,
|
||||
updated_at = @now
|
||||
WHERE
|
||||
status = 'FETCHING'
|
||||
AND updated_at < @stale_threshold
|
||||
`
|
||||
args := pgx.StrictNamedArgs{
|
||||
"now": now,
|
||||
"stale_threshold": staleThreshold,
|
||||
}
|
||||
|
||||
result, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot recover stale source fetches: %w", err)
|
||||
}
|
||||
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
224
pkg/coredata/access_review_campaign_source.go
Normal file
224
pkg/coredata/access_review_campaign_source.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
// AccessReviewCampaignSource is the per-campaign snapshot of an access
|
||||
// source. It captures the source identity (name, category, connector) at
|
||||
// the time the source was scoped into the campaign so that the review's
|
||||
// data survives even if the live access source is later deleted. Access
|
||||
// entries and fetch attempts reference this snapshot, not the live source.
|
||||
AccessReviewCampaignSource struct {
|
||||
ID gid.GID `db:"id"`
|
||||
TenantID gid.TenantID `db:"tenant_id"`
|
||||
AccessReviewCampaignID gid.GID `db:"access_review_campaign_id"`
|
||||
AccessReviewSourceID *gid.GID `db:"access_review_source_id"`
|
||||
Name string `db:"name"`
|
||||
Category AccessReviewSourceCategory `db:"category"`
|
||||
ConnectorID *gid.GID `db:"connector_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
AccessReviewCampaignSources []*AccessReviewCampaignSource
|
||||
)
|
||||
|
||||
// Upsert inserts the snapshot or refreshes its denormalized identity from the
|
||||
// live source. The generated ID is preserved across upserts because it is not
|
||||
// part of the conflict target, so entries that already reference the snapshot
|
||||
// keep pointing at the same row.
|
||||
func (s *AccessReviewCampaignSource) Upsert(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO access_review_campaign_sources (
|
||||
id,
|
||||
tenant_id,
|
||||
access_review_campaign_id,
|
||||
access_review_source_id,
|
||||
name,
|
||||
category,
|
||||
connector_id,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@access_review_campaign_id,
|
||||
@access_review_source_id,
|
||||
@name,
|
||||
@category,
|
||||
@connector_id,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
ON CONFLICT (access_review_campaign_id, access_review_source_id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
category = EXCLUDED.category,
|
||||
connector_id = EXCLUDED.connector_id,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING id
|
||||
`
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": s.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"access_review_campaign_id": s.AccessReviewCampaignID,
|
||||
"access_review_source_id": s.AccessReviewSourceID,
|
||||
"name": s.Name,
|
||||
"category": s.Category,
|
||||
"connector_id": s.ConnectorID,
|
||||
"created_at": s.CreatedAt,
|
||||
"updated_at": s.UpdatedAt,
|
||||
}
|
||||
|
||||
if err := conn.QueryRow(ctx, q, args).Scan(&s.ID); err != nil {
|
||||
return fmt.Errorf("cannot upsert campaign source: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AccessReviewCampaignSource) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
id gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
tenant_id,
|
||||
access_review_campaign_id,
|
||||
access_review_source_id,
|
||||
name,
|
||||
category,
|
||||
connector_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM access_review_campaign_sources
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
LIMIT 1
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": id}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query campaign source: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessReviewCampaignSource])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect campaign source: %w", err)
|
||||
}
|
||||
|
||||
*s = result
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AccessReviewCampaignSource) DeleteByCampaignIDAndAccessReviewSourceID(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
campaignID gid.GID,
|
||||
accessSourceID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
DELETE FROM access_review_campaign_sources
|
||||
WHERE
|
||||
%s
|
||||
AND access_review_campaign_id = @access_review_campaign_id
|
||||
AND access_review_source_id = @access_review_source_id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"access_review_campaign_id": campaignID,
|
||||
"access_review_source_id": accessSourceID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
if _, err := conn.Exec(ctx, q, args); err != nil {
|
||||
return fmt.Errorf("cannot delete campaign source: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sources *AccessReviewCampaignSources) LoadByCampaignID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
campaignID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
tenant_id,
|
||||
access_review_campaign_id,
|
||||
access_review_source_id,
|
||||
name,
|
||||
category,
|
||||
connector_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM access_review_campaign_sources
|
||||
WHERE
|
||||
%s
|
||||
AND access_review_campaign_id = @access_review_campaign_id
|
||||
ORDER BY name ASC
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"access_review_campaign_id": campaignID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query campaign sources: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewCampaignSource])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect campaign sources: %w", err)
|
||||
}
|
||||
|
||||
*sources = result
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
// AccessReviewCampaignSourceFetch tracks per-source fetch lifecycle.
|
||||
// TenantID is retained on the struct because the background worker claims
|
||||
// rows cross-tenant via LoadNextQueuedForUpdateSkipLocked and needs the
|
||||
// tenant to construct a Scope for subsequent operations.
|
||||
AccessReviewCampaignSourceFetch struct {
|
||||
TenantID gid.TenantID `db:"tenant_id"`
|
||||
AccessReviewCampaignID gid.GID `db:"access_review_campaign_id"`
|
||||
AccessSourceID gid.GID `db:"access_source_id"`
|
||||
Status AccessReviewCampaignSourceFetchStatus `db:"status"`
|
||||
FetchedAccountsCount int `db:"fetched_accounts_count"`
|
||||
AttemptCount int `db:"attempt_count"`
|
||||
LastError *string `db:"last_error"`
|
||||
StartedAt *time.Time `db:"started_at"`
|
||||
CompletedAt *time.Time `db:"completed_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
AccessReviewCampaignSourceFetches []*AccessReviewCampaignSourceFetch
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoAccessReviewCampaignSourceFetchAvailable = errors.New("no access review campaign source fetch available")
|
||||
)
|
||||
|
||||
func (f *AccessReviewCampaignSourceFetch) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO access_review_campaign_source_fetches (
|
||||
tenant_id,
|
||||
access_review_campaign_id,
|
||||
access_source_id,
|
||||
status,
|
||||
fetched_accounts_count,
|
||||
attempt_count,
|
||||
last_error,
|
||||
started_at,
|
||||
completed_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@tenant_id,
|
||||
@access_review_campaign_id,
|
||||
@access_source_id,
|
||||
@status,
|
||||
@fetched_accounts_count,
|
||||
@attempt_count,
|
||||
@last_error,
|
||||
@started_at,
|
||||
@completed_at,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
`
|
||||
args := pgx.StrictNamedArgs{
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"access_review_campaign_id": f.AccessReviewCampaignID,
|
||||
"access_source_id": f.AccessSourceID,
|
||||
"status": f.Status,
|
||||
"fetched_accounts_count": f.FetchedAccountsCount,
|
||||
"attempt_count": f.AttemptCount,
|
||||
"last_error": f.LastError,
|
||||
"started_at": f.StartedAt,
|
||||
"completed_at": f.CompletedAt,
|
||||
"created_at": f.CreatedAt,
|
||||
"updated_at": f.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert campaign source fetch: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *AccessReviewCampaignSourceFetch) Update(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE access_review_campaign_source_fetches
|
||||
SET
|
||||
status = @status,
|
||||
fetched_accounts_count = @fetched_accounts_count,
|
||||
attempt_count = @attempt_count,
|
||||
last_error = @last_error,
|
||||
started_at = @started_at,
|
||||
completed_at = @completed_at,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
%s
|
||||
AND access_review_campaign_id = @access_review_campaign_id
|
||||
AND access_source_id = @access_source_id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"status": f.Status,
|
||||
"fetched_accounts_count": f.FetchedAccountsCount,
|
||||
"attempt_count": f.AttemptCount,
|
||||
"last_error": f.LastError,
|
||||
"started_at": f.StartedAt,
|
||||
"completed_at": f.CompletedAt,
|
||||
"updated_at": f.UpdatedAt,
|
||||
"access_review_campaign_id": f.AccessReviewCampaignID,
|
||||
"access_source_id": f.AccessSourceID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
result, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update campaign source fetch: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *AccessReviewCampaignSourceFetch) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
campaignID gid.GID,
|
||||
sourceID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
tenant_id,
|
||||
access_review_campaign_id,
|
||||
access_source_id,
|
||||
status,
|
||||
fetched_accounts_count,
|
||||
attempt_count,
|
||||
last_error,
|
||||
started_at,
|
||||
completed_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM access_review_campaign_source_fetches
|
||||
WHERE
|
||||
%s
|
||||
AND access_review_campaign_id = @access_review_campaign_id
|
||||
AND access_source_id = @access_source_id
|
||||
LIMIT 1
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"access_review_campaign_id": campaignID,
|
||||
"access_source_id": sourceID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query campaign source fetch: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessReviewCampaignSourceFetch])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect campaign source fetch: %w", err)
|
||||
}
|
||||
|
||||
*f = result
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *AccessReviewCampaignSourceFetches) LoadByCampaignID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
campaignID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
tenant_id,
|
||||
access_review_campaign_id,
|
||||
access_source_id,
|
||||
status,
|
||||
fetched_accounts_count,
|
||||
attempt_count,
|
||||
last_error,
|
||||
started_at,
|
||||
completed_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM access_review_campaign_source_fetches
|
||||
WHERE
|
||||
%s
|
||||
AND access_review_campaign_id = @access_review_campaign_id
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"access_review_campaign_id": campaignID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query campaign source fetches: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewCampaignSourceFetch])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect campaign source fetches: %w", err)
|
||||
}
|
||||
|
||||
*fs = result
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadNextQueuedForUpdateSkipLocked is intentionally cross-tenant: the
|
||||
// background worker claims the next available fetch regardless of tenant.
|
||||
// The caller extracts TenantID from the returned struct to construct a
|
||||
// Scope for subsequent operations.
|
||||
func (f *AccessReviewCampaignSourceFetch) LoadNextQueuedForUpdateSkipLocked(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
tenant_id,
|
||||
access_review_campaign_id,
|
||||
access_source_id,
|
||||
status,
|
||||
fetched_accounts_count,
|
||||
attempt_count,
|
||||
last_error,
|
||||
started_at,
|
||||
completed_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM access_review_campaign_source_fetches
|
||||
WHERE status = @status
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`
|
||||
args := pgx.StrictNamedArgs{
|
||||
"status": AccessReviewCampaignSourceFetchStatusQueued,
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query next queued campaign source fetch: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessReviewCampaignSourceFetch])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNoAccessReviewCampaignSourceFetchAvailable
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect campaign source fetch: %w", err)
|
||||
}
|
||||
|
||||
*f = result
|
||||
|
||||
return nil
|
||||
}
|
||||
378
pkg/coredata/access_review_campaign_source_fetch_attempt.go
Normal file
378
pkg/coredata/access_review_campaign_source_fetch_attempt.go
Normal file
@@ -0,0 +1,378 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
// AccessReviewCampaignSourceFetchAttempt is a single, append-only fetch run for a
|
||||
// campaign source snapshot. Each retry produces a new row, so the error of
|
||||
// every attempt is retained. The current state of a snapshot is the latest
|
||||
// attempt (highest attempt_number). Terminal rows (SUCCESS / FAILED) are
|
||||
// immutable; only the in-flight attempt is updated.
|
||||
//
|
||||
// TenantID is retained on the struct because the background worker claims
|
||||
// rows cross-tenant via LoadNextQueuedForUpdateSkipLocked and needs the
|
||||
// tenant to construct a Scope for subsequent operations.
|
||||
AccessReviewCampaignSourceFetchAttempt struct {
|
||||
ID gid.GID `db:"id"`
|
||||
TenantID gid.TenantID `db:"tenant_id"`
|
||||
AccessReviewCampaignSourceID gid.GID `db:"access_review_campaign_source_id"`
|
||||
AttemptNumber int `db:"attempt_number"`
|
||||
Status AccessReviewCampaignSourceFetchStatus `db:"status"`
|
||||
FetchedAccountsCount int `db:"fetched_accounts_count"`
|
||||
Error *string `db:"error"`
|
||||
StartedAt *time.Time `db:"started_at"`
|
||||
CompletedAt *time.Time `db:"completed_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
AccessReviewCampaignSourceFetchAttempts []*AccessReviewCampaignSourceFetchAttempt
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoAccessReviewCampaignSourceFetchAttemptAvailable = errors.New("no access review source fetch attempt available")
|
||||
)
|
||||
|
||||
// Insert appends a new attempt for the snapshot, assigning the next
|
||||
// attempt_number atomically. The receiver's AttemptNumber is synced from the
|
||||
// database.
|
||||
func (a *AccessReviewCampaignSourceFetchAttempt) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO access_review_campaign_source_fetch_attempts (
|
||||
id,
|
||||
tenant_id,
|
||||
access_review_campaign_source_id,
|
||||
attempt_number,
|
||||
status,
|
||||
fetched_accounts_count,
|
||||
error,
|
||||
started_at,
|
||||
completed_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@access_review_campaign_source_id,
|
||||
COALESCE((
|
||||
SELECT MAX(attempt_number)
|
||||
FROM access_review_campaign_source_fetch_attempts
|
||||
WHERE access_review_campaign_source_id = @access_review_campaign_source_id
|
||||
), 0) + 1,
|
||||
@status,
|
||||
@fetched_accounts_count,
|
||||
@error,
|
||||
@started_at,
|
||||
@completed_at,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
RETURNING attempt_number
|
||||
`
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": a.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"access_review_campaign_source_id": a.AccessReviewCampaignSourceID,
|
||||
"status": a.Status,
|
||||
"fetched_accounts_count": a.FetchedAccountsCount,
|
||||
"error": a.Error,
|
||||
"started_at": a.StartedAt,
|
||||
"completed_at": a.CompletedAt,
|
||||
"created_at": a.CreatedAt,
|
||||
"updated_at": a.UpdatedAt,
|
||||
}
|
||||
|
||||
if err := conn.QueryRow(ctx, q, args).Scan(&a.AttemptNumber); err != nil {
|
||||
return fmt.Errorf("cannot insert source fetch attempt: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update writes the in-flight attempt's lifecycle fields. It must only be
|
||||
// called on the attempt that the worker currently owns.
|
||||
func (a *AccessReviewCampaignSourceFetchAttempt) Update(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE access_review_campaign_source_fetch_attempts
|
||||
SET
|
||||
status = @status,
|
||||
fetched_accounts_count = @fetched_accounts_count,
|
||||
error = @error,
|
||||
started_at = @started_at,
|
||||
completed_at = @completed_at,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": a.ID,
|
||||
"status": a.Status,
|
||||
"fetched_accounts_count": a.FetchedAccountsCount,
|
||||
"error": a.Error,
|
||||
"started_at": a.StartedAt,
|
||||
"completed_at": a.CompletedAt,
|
||||
"updated_at": a.UpdatedAt,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
result, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update source fetch attempt: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadNextQueuedForUpdateSkipLocked is intentionally cross-tenant: the
|
||||
// background worker claims the next available attempt regardless of tenant.
|
||||
// The caller extracts TenantID from the returned struct to construct a Scope
|
||||
// for subsequent operations.
|
||||
func (a *AccessReviewCampaignSourceFetchAttempt) LoadNextQueuedForUpdateSkipLocked(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
tenant_id,
|
||||
access_review_campaign_source_id,
|
||||
attempt_number,
|
||||
status,
|
||||
fetched_accounts_count,
|
||||
error,
|
||||
started_at,
|
||||
completed_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM access_review_campaign_source_fetch_attempts
|
||||
WHERE status = @status
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`
|
||||
args := pgx.StrictNamedArgs{
|
||||
"status": AccessReviewCampaignSourceFetchStatusQueued,
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query next queued fetch attempt: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessReviewCampaignSourceFetchAttempt])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNoAccessReviewCampaignSourceFetchAttemptAvailable
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect fetch attempt: %w", err)
|
||||
}
|
||||
|
||||
*a = result
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadLatestByCampaignID returns the most recent attempt for every snapshot in
|
||||
// the campaign, keyed by snapshot ID. Snapshots without any attempt are absent.
|
||||
func (attempts *AccessReviewCampaignSourceFetchAttempts) LoadLatestByCampaignID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
campaignID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT DISTINCT ON (access_review_campaign_source_id)
|
||||
id,
|
||||
tenant_id,
|
||||
access_review_campaign_source_id,
|
||||
attempt_number,
|
||||
status,
|
||||
fetched_accounts_count,
|
||||
error,
|
||||
started_at,
|
||||
completed_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM access_review_campaign_source_fetch_attempts
|
||||
WHERE
|
||||
%s
|
||||
AND access_review_campaign_source_id IN (
|
||||
SELECT id
|
||||
FROM access_review_campaign_sources
|
||||
WHERE access_review_campaign_id = @campaign_id
|
||||
)
|
||||
ORDER BY access_review_campaign_source_id, attempt_number DESC
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"campaign_id": campaignID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query latest fetch attempts: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewCampaignSourceFetchAttempt])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect latest fetch attempts: %w", err)
|
||||
}
|
||||
|
||||
*attempts = result
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadByCampaignSourceID returns the full attempt history for a snapshot,
|
||||
// newest first.
|
||||
func (attempts *AccessReviewCampaignSourceFetchAttempts) LoadByCampaignSourceID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
campaignSourceID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
tenant_id,
|
||||
access_review_campaign_source_id,
|
||||
attempt_number,
|
||||
status,
|
||||
fetched_accounts_count,
|
||||
error,
|
||||
started_at,
|
||||
completed_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM access_review_campaign_source_fetch_attempts
|
||||
WHERE
|
||||
%s
|
||||
AND access_review_campaign_source_id = @access_review_campaign_source_id
|
||||
ORDER BY attempt_number DESC
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"access_review_campaign_source_id": campaignSourceID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query fetch attempts: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewCampaignSourceFetchAttempt])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect fetch attempts: %w", err)
|
||||
}
|
||||
|
||||
*attempts = result
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecoverStale fails attempts stuck in FETCHING past the threshold and queues a
|
||||
// fresh retry attempt for each, preserving the stale attempt's history. It is
|
||||
// intentionally cross-tenant. Returns the number of recovered attempts.
|
||||
func (attempts *AccessReviewCampaignSourceFetchAttempts) RecoverStale(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
staleThreshold time.Time,
|
||||
now time.Time,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
tenant_id,
|
||||
access_review_campaign_source_id,
|
||||
attempt_number,
|
||||
status,
|
||||
fetched_accounts_count,
|
||||
error,
|
||||
started_at,
|
||||
completed_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM access_review_campaign_source_fetch_attempts
|
||||
WHERE status = 'FETCHING'
|
||||
AND updated_at < @stale_threshold
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`
|
||||
rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"stale_threshold": staleThreshold})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot query stale fetch attempts: %w", err)
|
||||
}
|
||||
|
||||
stale, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewCampaignSourceFetchAttempt])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot collect stale fetch attempts: %w", err)
|
||||
}
|
||||
|
||||
staleMessage := "fetch timed out"
|
||||
|
||||
for _, attempt := range stale {
|
||||
scope := NewScope(attempt.TenantID)
|
||||
|
||||
attempt.Status = AccessReviewCampaignSourceFetchStatusFailed
|
||||
attempt.Error = &staleMessage
|
||||
attempt.CompletedAt = &now
|
||||
attempt.UpdatedAt = now
|
||||
|
||||
if err := attempt.Update(ctx, conn, scope); err != nil {
|
||||
return 0, fmt.Errorf("cannot fail stale fetch attempt: %w", err)
|
||||
}
|
||||
|
||||
retry := &AccessReviewCampaignSourceFetchAttempt{
|
||||
ID: gid.New(attempt.TenantID, AccessReviewCampaignSourceFetchAttemptEntityType),
|
||||
AccessReviewCampaignSourceID: attempt.AccessReviewCampaignSourceID,
|
||||
Status: AccessReviewCampaignSourceFetchStatusQueued,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := retry.Insert(ctx, conn, scope); err != nil {
|
||||
return 0, fmt.Errorf("cannot queue retry fetch attempt: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return len(stale), nil
|
||||
}
|
||||
158
pkg/coredata/access_review_campaign_source_test.go
Normal file
158
pkg/coredata/access_review_campaign_source_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/internal/test"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
func insertAccessReviewEntry(t *testing.T, ctx context.Context, client *pg.Client, fx accessEntryFixture, accountKey string) gid.GID {
|
||||
t.Helper()
|
||||
|
||||
tenantID := fx.scope.GetTenantID()
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
entryID := gid.New(tenantID, coredata.AccessReviewEntryEntityType)
|
||||
|
||||
entry := &coredata.AccessReviewEntry{
|
||||
ID: entryID,
|
||||
OrganizationID: fx.organizationID,
|
||||
AccessReviewCampaignID: fx.campaignID,
|
||||
AccessReviewCampaignSourceID: fx.campaignSourceID,
|
||||
Email: accountKey,
|
||||
FullName: "Snapshot User",
|
||||
Role: "member",
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: "ext-snapshot",
|
||||
AccountKey: accountKey,
|
||||
IncrementalTag: coredata.AccessReviewEntryIncrementalTagNew,
|
||||
Flags: []coredata.AccessReviewEntryFlag{},
|
||||
FlagReasons: []string{},
|
||||
Decision: coredata.AccessReviewEntryDecisionPending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
return entry.Upsert(ctx, tx, fx.scope)
|
||||
}))
|
||||
|
||||
return entryID
|
||||
}
|
||||
|
||||
// TestAccessReviewSourceDeletion_PreservesSnapshotAndEntries verifies the core
|
||||
// archival guarantee: deleting the live access source nulls the snapshot link
|
||||
// (ON DELETE SET NULL) but keeps the per-campaign snapshot and its entries.
|
||||
func TestAccessReviewSourceDeletion_PreservesSnapshotAndEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
fx := seedAccessReviewEntryFixture(t, ctx, client)
|
||||
|
||||
entryID := insertAccessReviewEntry(t, ctx, client, fx, "preserve-me@example.com")
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
_, err := tx.Exec(ctx, `DELETE FROM access_review_sources WHERE id = $1`, fx.sourceID)
|
||||
return err
|
||||
}))
|
||||
|
||||
loadedEntry := &coredata.AccessReviewEntry{}
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
return loadedEntry.LoadByID(ctx, conn, fx.scope, entryID)
|
||||
}))
|
||||
assert.Equal(t, "preserve-me@example.com", loadedEntry.Email, "entry must survive source deletion")
|
||||
|
||||
loadedSource := &coredata.AccessReviewCampaignSource{}
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
return loadedSource.LoadByID(ctx, conn, fx.scope, fx.campaignSourceID)
|
||||
}))
|
||||
assert.Nil(t, loadedSource.AccessReviewSourceID, "snapshot link must be nulled, not cascaded")
|
||||
assert.Equal(t, "Upsert Freeze Test Source", loadedSource.Name, "snapshot name must be preserved")
|
||||
}
|
||||
|
||||
// TestSourceFetchAttempts_AppendOnly verifies attempts accumulate as an
|
||||
// append-only log and that the latest attempt reflects the most recent run.
|
||||
func TestSourceFetchAttempts_AppendOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
fx := seedAccessReviewEntryFixture(t, ctx, client)
|
||||
|
||||
tenantID := fx.scope.GetTenantID()
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
failureMsg := "We couldn't fetch accounts from this source."
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
first := &coredata.AccessReviewCampaignSourceFetchAttempt{
|
||||
ID: gid.New(tenantID, coredata.AccessReviewCampaignSourceFetchAttemptEntityType),
|
||||
AccessReviewCampaignSourceID: fx.campaignSourceID,
|
||||
Status: coredata.AccessReviewCampaignSourceFetchStatusFailed,
|
||||
Error: &failureMsg,
|
||||
CompletedAt: &now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := first.Insert(ctx, tx, fx.scope); err != nil {
|
||||
return err
|
||||
}
|
||||
require.Equal(t, 1, first.AttemptNumber)
|
||||
|
||||
second := &coredata.AccessReviewCampaignSourceFetchAttempt{
|
||||
ID: gid.New(tenantID, coredata.AccessReviewCampaignSourceFetchAttemptEntityType),
|
||||
AccessReviewCampaignSourceID: fx.campaignSourceID,
|
||||
Status: coredata.AccessReviewCampaignSourceFetchStatusSuccess,
|
||||
FetchedAccountsCount: 7,
|
||||
CompletedAt: &now,
|
||||
CreatedAt: now.Add(time.Minute),
|
||||
UpdatedAt: now.Add(time.Minute),
|
||||
}
|
||||
if err := second.Insert(ctx, tx, fx.scope); err != nil {
|
||||
return err
|
||||
}
|
||||
require.Equal(t, 2, second.AttemptNumber)
|
||||
|
||||
return nil
|
||||
}))
|
||||
|
||||
var history coredata.AccessReviewCampaignSourceFetchAttempts
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
return history.LoadByCampaignSourceID(ctx, conn, fx.scope, fx.campaignSourceID)
|
||||
}))
|
||||
require.Len(t, history, 2, "both attempts must be retained")
|
||||
assert.Equal(t, 2, history[0].AttemptNumber, "history is newest first")
|
||||
assert.Equal(t, coredata.AccessReviewCampaignSourceFetchStatusFailed, history[1].Status)
|
||||
require.NotNil(t, history[1].Error)
|
||||
assert.Equal(t, failureMsg, *history[1].Error, "the failed attempt's error is retained")
|
||||
|
||||
var latest coredata.AccessReviewCampaignSourceFetchAttempts
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
return latest.LoadLatestByCampaignID(ctx, conn, fx.scope, fx.campaignID)
|
||||
}))
|
||||
require.Len(t, latest, 1, "one latest attempt per snapshot")
|
||||
assert.Equal(t, coredata.AccessReviewCampaignSourceFetchStatusSuccess, latest[0].Status)
|
||||
assert.Equal(t, 7, latest[0].FetchedAccountsCount)
|
||||
}
|
||||
@@ -29,54 +29,54 @@ import (
|
||||
)
|
||||
|
||||
type (
|
||||
AccessEntry struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
AccessReviewCampaignID gid.GID `db:"access_review_campaign_id"`
|
||||
AccessSourceID gid.GID `db:"access_source_id"`
|
||||
IdentityID *gid.GID `db:"identity_id"`
|
||||
Email string `db:"email"`
|
||||
FullName string `db:"full_name"`
|
||||
Role string `db:"role"`
|
||||
JobTitle string `db:"job_title"`
|
||||
IsAdmin bool `db:"is_admin"`
|
||||
MFAStatus MFAStatus `db:"mfa_status"`
|
||||
AuthMethod AccessEntryAuthMethod `db:"auth_method"`
|
||||
AccountType AccessEntryAccountType `db:"account_type"`
|
||||
Active *bool `db:"active"`
|
||||
LastLogin *time.Time `db:"last_login"`
|
||||
AccountCreatedAt *time.Time `db:"account_created_at"`
|
||||
ExternalID string `db:"external_id"`
|
||||
AccountKey string `db:"account_key"`
|
||||
IncrementalTag AccessEntryIncrementalTag `db:"incremental_tag"`
|
||||
Flags []AccessEntryFlag `db:"flags"`
|
||||
FlagReasons []string `db:"flag_reasons"`
|
||||
Decision AccessEntryDecision `db:"decision"`
|
||||
DecisionNote *string `db:"decision_note"`
|
||||
DecidedBy *gid.GID `db:"decided_by"`
|
||||
DecidedAt *time.Time `db:"decided_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
AccessReviewEntry struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
AccessReviewCampaignID gid.GID `db:"access_review_campaign_id"`
|
||||
AccessReviewCampaignSourceID gid.GID `db:"access_review_campaign_source_id"`
|
||||
IdentityID *gid.GID `db:"identity_id"`
|
||||
Email string `db:"email"`
|
||||
FullName string `db:"full_name"`
|
||||
Role string `db:"role"`
|
||||
JobTitle string `db:"job_title"`
|
||||
IsAdmin bool `db:"is_admin"`
|
||||
MFAStatus MFAStatus `db:"mfa_status"`
|
||||
AuthMethod AccessReviewEntryAuthMethod `db:"auth_method"`
|
||||
AccountType AccessReviewEntryAccountType `db:"account_type"`
|
||||
Active *bool `db:"active"`
|
||||
LastLogin *time.Time `db:"last_login"`
|
||||
AccountCreatedAt *time.Time `db:"account_created_at"`
|
||||
ExternalID string `db:"external_id"`
|
||||
AccountKey string `db:"account_key"`
|
||||
IncrementalTag AccessReviewEntryIncrementalTag `db:"incremental_tag"`
|
||||
Flags []AccessReviewEntryFlag `db:"flags"`
|
||||
FlagReasons []string `db:"flag_reasons"`
|
||||
Decision AccessReviewEntryDecision `db:"decision"`
|
||||
DecisionNote *string `db:"decision_note"`
|
||||
DecidedBy *gid.GID `db:"decided_by"`
|
||||
DecidedAt *time.Time `db:"decided_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
AccessEntries []*AccessEntry
|
||||
AccessReviewEntries []*AccessReviewEntry
|
||||
)
|
||||
|
||||
func (e AccessEntry) CursorKey(orderBy AccessEntryOrderField) page.CursorKey {
|
||||
func (e AccessReviewEntry) CursorKey(orderBy AccessReviewEntryOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case AccessEntryOrderFieldCreatedAt:
|
||||
case AccessReviewEntryOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(e.ID, e.CreatedAt)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
func (e *AccessEntry) AuthorizationAttributes(
|
||||
func (e *AccessReviewEntry) AuthorizationAttributes(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
resourceIDs []gid.GID,
|
||||
) (policy.AttributesByID, error) {
|
||||
q := `SELECT id, organization_id FROM access_entries WHERE id = ANY(@resource_ids::text[])`
|
||||
q := `SELECT id, organization_id FROM access_review_entries WHERE id = ANY(@resource_ids::text[])`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"resource_ids": resourceIDs,
|
||||
@@ -110,7 +110,7 @@ func (e *AccessEntry) AuthorizationAttributes(
|
||||
return attrsByID, nil
|
||||
}
|
||||
|
||||
func (e *AccessEntry) LoadByID(
|
||||
func (e *AccessReviewEntry) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
@@ -121,7 +121,7 @@ SELECT
|
||||
id,
|
||||
organization_id,
|
||||
access_review_campaign_id,
|
||||
access_source_id,
|
||||
access_review_campaign_source_id,
|
||||
identity_id,
|
||||
email,
|
||||
full_name,
|
||||
@@ -146,7 +146,7 @@ SELECT
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
access_entries
|
||||
access_review_entries
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
@@ -159,10 +159,10 @@ LIMIT 1;
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query access_entries: %w", err)
|
||||
return fmt.Errorf("cannot query access_review_entries: %w", err)
|
||||
}
|
||||
|
||||
entry, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessEntry])
|
||||
entry, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessReviewEntry])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
@@ -176,19 +176,19 @@ LIMIT 1;
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *AccessEntry) Insert(
|
||||
func (e *AccessReviewEntry) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO
|
||||
access_entries (
|
||||
access_review_entries (
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
access_review_campaign_id,
|
||||
access_source_id,
|
||||
access_review_campaign_source_id,
|
||||
identity_id,
|
||||
email,
|
||||
full_name,
|
||||
@@ -218,7 +218,7 @@ VALUES (
|
||||
@tenant_id,
|
||||
@organization_id,
|
||||
@access_review_campaign_id,
|
||||
@access_source_id,
|
||||
@access_review_campaign_source_id,
|
||||
@identity_id,
|
||||
@email,
|
||||
@full_name,
|
||||
@@ -246,34 +246,34 @@ VALUES (
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": e.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": e.OrganizationID,
|
||||
"access_review_campaign_id": e.AccessReviewCampaignID,
|
||||
"access_source_id": e.AccessSourceID,
|
||||
"identity_id": e.IdentityID,
|
||||
"email": e.Email,
|
||||
"full_name": e.FullName,
|
||||
"role": e.Role,
|
||||
"job_title": e.JobTitle,
|
||||
"is_admin": e.IsAdmin,
|
||||
"mfa_status": e.MFAStatus,
|
||||
"auth_method": e.AuthMethod,
|
||||
"account_type": e.AccountType,
|
||||
"active": e.Active,
|
||||
"last_login": e.LastLogin,
|
||||
"account_created_at": e.AccountCreatedAt,
|
||||
"external_id": e.ExternalID,
|
||||
"account_key": e.AccountKey,
|
||||
"incremental_tag": e.IncrementalTag,
|
||||
"flags": e.Flags,
|
||||
"flag_reasons": e.FlagReasons,
|
||||
"decision": e.Decision,
|
||||
"decision_note": e.DecisionNote,
|
||||
"decided_by": e.DecidedBy,
|
||||
"decided_at": e.DecidedAt,
|
||||
"created_at": e.CreatedAt,
|
||||
"updated_at": e.UpdatedAt,
|
||||
"id": e.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": e.OrganizationID,
|
||||
"access_review_campaign_id": e.AccessReviewCampaignID,
|
||||
"access_review_campaign_source_id": e.AccessReviewCampaignSourceID,
|
||||
"identity_id": e.IdentityID,
|
||||
"email": e.Email,
|
||||
"full_name": e.FullName,
|
||||
"role": e.Role,
|
||||
"job_title": e.JobTitle,
|
||||
"is_admin": e.IsAdmin,
|
||||
"mfa_status": e.MFAStatus,
|
||||
"auth_method": e.AuthMethod,
|
||||
"account_type": e.AccountType,
|
||||
"active": e.Active,
|
||||
"last_login": e.LastLogin,
|
||||
"account_created_at": e.AccountCreatedAt,
|
||||
"external_id": e.ExternalID,
|
||||
"account_key": e.AccountKey,
|
||||
"incremental_tag": e.IncrementalTag,
|
||||
"flags": e.Flags,
|
||||
"flag_reasons": e.FlagReasons,
|
||||
"decision": e.Decision,
|
||||
"decision_note": e.DecisionNote,
|
||||
"decided_by": e.DecidedBy,
|
||||
"decided_at": e.DecidedAt,
|
||||
"created_at": e.CreatedAt,
|
||||
"updated_at": e.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
@@ -284,13 +284,13 @@ VALUES (
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *AccessEntry) Update(
|
||||
func (e *AccessReviewEntry) Update(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE access_entries
|
||||
UPDATE access_review_entries
|
||||
SET
|
||||
flags = @flags,
|
||||
flag_reasons = @flag_reasons,
|
||||
@@ -329,20 +329,20 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (entries *AccessEntries) LoadByCampaignID(
|
||||
func (entries *AccessReviewEntries) LoadByCampaignID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
campaignID gid.GID,
|
||||
cursor *page.Cursor[AccessEntryOrderField],
|
||||
filter *AccessEntryFilter,
|
||||
cursor *page.Cursor[AccessReviewEntryOrderField],
|
||||
filter *AccessReviewEntryFilter,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
access_review_campaign_id,
|
||||
access_source_id,
|
||||
access_review_campaign_source_id,
|
||||
identity_id,
|
||||
email,
|
||||
full_name,
|
||||
@@ -367,7 +367,7 @@ SELECT
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
access_entries
|
||||
access_review_entries
|
||||
WHERE
|
||||
%s
|
||||
AND access_review_campaign_id = @campaign_id
|
||||
@@ -383,12 +383,12 @@ WHERE
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query access_entries: %w", err)
|
||||
return fmt.Errorf("cannot query access_review_entries: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessEntry])
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewEntry])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect access_entries: %w", err)
|
||||
return fmt.Errorf("cannot collect access_review_entries: %w", err)
|
||||
}
|
||||
|
||||
*entries = result
|
||||
@@ -396,21 +396,21 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (entries *AccessEntries) LoadByCampaignIDAndSourceID(
|
||||
func (entries *AccessReviewEntries) LoadByCampaignIDAndSourceID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
campaignID gid.GID,
|
||||
sourceID gid.GID,
|
||||
cursor *page.Cursor[AccessEntryOrderField],
|
||||
filter *AccessEntryFilter,
|
||||
cursor *page.Cursor[AccessReviewEntryOrderField],
|
||||
filter *AccessReviewEntryFilter,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
access_review_campaign_id,
|
||||
access_source_id,
|
||||
access_review_campaign_source_id,
|
||||
identity_id,
|
||||
email,
|
||||
full_name,
|
||||
@@ -435,11 +435,11 @@ SELECT
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
access_entries
|
||||
access_review_entries
|
||||
WHERE
|
||||
%s
|
||||
AND access_review_campaign_id = @campaign_id
|
||||
AND access_source_id = @source_id
|
||||
AND access_review_campaign_source_id = @source_id
|
||||
AND %s
|
||||
AND %s
|
||||
`
|
||||
@@ -452,12 +452,12 @@ WHERE
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query access_entries: %w", err)
|
||||
return fmt.Errorf("cannot query access_review_entries: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessEntry])
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewEntry])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect access_entries: %w", err)
|
||||
return fmt.Errorf("cannot collect access_review_entries: %w", err)
|
||||
}
|
||||
|
||||
*entries = result
|
||||
@@ -465,16 +465,16 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (entries *AccessEntries) CountByCampaignID(
|
||||
func (entries *AccessReviewEntries) CountByCampaignID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
campaignID gid.GID,
|
||||
filter *AccessEntryFilter,
|
||||
filter *AccessReviewEntryFilter,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT COUNT(id)
|
||||
FROM access_entries
|
||||
FROM access_review_entries
|
||||
WHERE
|
||||
%s
|
||||
AND access_review_campaign_id = @campaign_id
|
||||
@@ -488,27 +488,27 @@ WHERE
|
||||
|
||||
var count int
|
||||
if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot count access_entries: %w", err)
|
||||
return 0, fmt.Errorf("cannot count access_review_entries: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (entries *AccessEntries) CountByCampaignIDAndSourceID(
|
||||
func (entries *AccessReviewEntries) CountByCampaignIDAndSourceID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
campaignID gid.GID,
|
||||
sourceID gid.GID,
|
||||
filter *AccessEntryFilter,
|
||||
filter *AccessReviewEntryFilter,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT COUNT(id)
|
||||
FROM access_entries
|
||||
FROM access_review_entries
|
||||
WHERE
|
||||
%s
|
||||
AND access_review_campaign_id = @campaign_id
|
||||
AND access_source_id = @source_id
|
||||
AND access_review_campaign_source_id = @source_id
|
||||
AND %s;
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
|
||||
@@ -519,13 +519,13 @@ WHERE
|
||||
|
||||
var count int
|
||||
if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot count access_entries: %w", err)
|
||||
return 0, fmt.Errorf("cannot count access_review_entries: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (entries *AccessEntries) CountPendingByCampaignID(
|
||||
func (entries *AccessReviewEntries) CountPendingByCampaignID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
@@ -533,7 +533,7 @@ func (entries *AccessEntries) CountPendingByCampaignID(
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT COUNT(id)
|
||||
FROM access_entries
|
||||
FROM access_review_entries
|
||||
WHERE
|
||||
%s
|
||||
AND access_review_campaign_id = @campaign_id
|
||||
@@ -546,18 +546,18 @@ WHERE
|
||||
|
||||
var count int
|
||||
if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot count pending access_entries: %w", err)
|
||||
return 0, fmt.Errorf("cannot count pending access_review_entries: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (e *AccessEntry) LoadOrganizationID(
|
||||
func (e *AccessReviewEntry) LoadOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
entryID gid.GID,
|
||||
) (gid.GID, error) {
|
||||
q := `SELECT organization_id FROM access_entries WHERE id = $1 LIMIT 1;`
|
||||
q := `SELECT organization_id FROM access_review_entries WHERE id = $1 LIMIT 1;`
|
||||
|
||||
var organizationID gid.GID
|
||||
if err := conn.QueryRow(ctx, q, entryID).Scan(&organizationID); err != nil {
|
||||
@@ -571,13 +571,13 @@ func (e *AccessEntry) LoadOrganizationID(
|
||||
return organizationID, nil
|
||||
}
|
||||
|
||||
func (e *AccessEntry) UpdateFlags(
|
||||
func (e *AccessReviewEntry) UpdateFlags(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE access_entries
|
||||
UPDATE access_review_entries
|
||||
SET
|
||||
flags = @flags,
|
||||
flag_reasons = @flag_reasons,
|
||||
@@ -614,19 +614,19 @@ WHERE
|
||||
// intentionally absent from the ON CONFLICT DO UPDATE SET clause, so an
|
||||
// existing row's verdict survives every subsequent source poll untouched.
|
||||
// Those columns are written on the initial INSERT (new row) and can only be
|
||||
// changed afterwards through AccessEntry.Update.
|
||||
func (e *AccessEntry) Upsert(
|
||||
// changed afterwards through AccessReviewEntry.Update.
|
||||
func (e *AccessReviewEntry) Upsert(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO access_entries (
|
||||
INSERT INTO access_review_entries (
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
access_review_campaign_id,
|
||||
access_source_id,
|
||||
access_review_campaign_source_id,
|
||||
identity_id,
|
||||
email,
|
||||
full_name,
|
||||
@@ -655,7 +655,7 @@ INSERT INTO access_entries (
|
||||
@tenant_id,
|
||||
@organization_id,
|
||||
@access_review_campaign_id,
|
||||
@access_source_id,
|
||||
@access_review_campaign_source_id,
|
||||
@identity_id,
|
||||
@email,
|
||||
@full_name,
|
||||
@@ -680,7 +680,7 @@ INSERT INTO access_entries (
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
ON CONFLICT (access_review_campaign_id, access_source_id, account_key) DO UPDATE SET
|
||||
ON CONFLICT (access_review_campaign_source_id, account_key) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
full_name = EXCLUDED.full_name,
|
||||
role = EXCLUDED.role,
|
||||
@@ -698,34 +698,34 @@ ON CONFLICT (access_review_campaign_id, access_source_id, account_key) DO UPDATE
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": e.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": e.OrganizationID,
|
||||
"access_review_campaign_id": e.AccessReviewCampaignID,
|
||||
"access_source_id": e.AccessSourceID,
|
||||
"identity_id": e.IdentityID,
|
||||
"email": e.Email,
|
||||
"full_name": e.FullName,
|
||||
"role": e.Role,
|
||||
"job_title": e.JobTitle,
|
||||
"is_admin": e.IsAdmin,
|
||||
"mfa_status": e.MFAStatus,
|
||||
"auth_method": e.AuthMethod,
|
||||
"account_type": e.AccountType,
|
||||
"active": e.Active,
|
||||
"last_login": e.LastLogin,
|
||||
"account_created_at": e.AccountCreatedAt,
|
||||
"external_id": e.ExternalID,
|
||||
"account_key": e.AccountKey,
|
||||
"incremental_tag": e.IncrementalTag,
|
||||
"flags": e.Flags,
|
||||
"flag_reasons": e.FlagReasons,
|
||||
"decision": e.Decision,
|
||||
"decision_note": e.DecisionNote,
|
||||
"decided_by": e.DecidedBy,
|
||||
"decided_at": e.DecidedAt,
|
||||
"created_at": e.CreatedAt,
|
||||
"updated_at": e.UpdatedAt,
|
||||
"id": e.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": e.OrganizationID,
|
||||
"access_review_campaign_id": e.AccessReviewCampaignID,
|
||||
"access_review_campaign_source_id": e.AccessReviewCampaignSourceID,
|
||||
"identity_id": e.IdentityID,
|
||||
"email": e.Email,
|
||||
"full_name": e.FullName,
|
||||
"role": e.Role,
|
||||
"job_title": e.JobTitle,
|
||||
"is_admin": e.IsAdmin,
|
||||
"mfa_status": e.MFAStatus,
|
||||
"auth_method": e.AuthMethod,
|
||||
"account_type": e.AccountType,
|
||||
"active": e.Active,
|
||||
"last_login": e.LastLogin,
|
||||
"account_created_at": e.AccountCreatedAt,
|
||||
"external_id": e.ExternalID,
|
||||
"account_key": e.AccountKey,
|
||||
"incremental_tag": e.IncrementalTag,
|
||||
"flags": e.Flags,
|
||||
"flag_reasons": e.FlagReasons,
|
||||
"decision": e.Decision,
|
||||
"decision_note": e.DecisionNote,
|
||||
"decided_by": e.DecidedBy,
|
||||
"decided_at": e.DecidedAt,
|
||||
"created_at": e.CreatedAt,
|
||||
"updated_at": e.UpdatedAt,
|
||||
}
|
||||
|
||||
if _, err := conn.Exec(ctx, q, args); err != nil {
|
||||
@@ -743,7 +743,7 @@ type BaselineAccountEntry struct {
|
||||
FullName string
|
||||
}
|
||||
|
||||
func (entries *AccessEntries) LoadBaselineBySourceID(
|
||||
func (entries *AccessReviewEntries) LoadBaselineBySourceID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
@@ -752,10 +752,14 @@ func (entries *AccessEntries) LoadBaselineBySourceID(
|
||||
) ([]BaselineAccountEntry, error) {
|
||||
q := fmt.Sprintf(`
|
||||
SELECT account_key, email, full_name
|
||||
FROM access_entries
|
||||
FROM access_review_entries
|
||||
WHERE %s
|
||||
AND access_review_campaign_id = @campaign_id
|
||||
AND access_source_id = @source_id
|
||||
AND access_review_campaign_source_id IN (
|
||||
SELECT id
|
||||
FROM access_review_campaign_sources
|
||||
WHERE access_review_campaign_id = @campaign_id
|
||||
AND access_review_source_id = @source_id
|
||||
)
|
||||
`, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
70
pkg/coredata/access_review_entry_account_type.go
Normal file
70
pkg/coredata/access_review_entry_account_type.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type AccessReviewEntryAccountType string
|
||||
|
||||
const (
|
||||
AccessReviewEntryAccountTypeUser AccessReviewEntryAccountType = "USER"
|
||||
AccessReviewEntryAccountTypeServiceAccount AccessReviewEntryAccountType = "SERVICE_ACCOUNT"
|
||||
)
|
||||
|
||||
var (
|
||||
_ fmt.Stringer = AccessReviewEntryAccountType("")
|
||||
_ encoding.TextMarshaler = AccessReviewEntryAccountType("")
|
||||
_ encoding.TextUnmarshaler = (*AccessReviewEntryAccountType)(nil)
|
||||
)
|
||||
|
||||
func AccessReviewEntryAccountTypes() []AccessReviewEntryAccountType {
|
||||
return []AccessReviewEntryAccountType{
|
||||
AccessReviewEntryAccountTypeUser,
|
||||
AccessReviewEntryAccountTypeServiceAccount,
|
||||
}
|
||||
}
|
||||
|
||||
func (v AccessReviewEntryAccountType) IsValid() bool {
|
||||
switch v {
|
||||
case
|
||||
AccessReviewEntryAccountTypeUser,
|
||||
AccessReviewEntryAccountTypeServiceAccount:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (v AccessReviewEntryAccountType) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
func (v AccessReviewEntryAccountType) MarshalText() ([]byte, error) {
|
||||
return []byte(v.String()), nil
|
||||
}
|
||||
|
||||
func (v *AccessReviewEntryAccountType) UnmarshalText(text []byte) error {
|
||||
val := AccessReviewEntryAccountType(text)
|
||||
if !val.IsValid() {
|
||||
return fmt.Errorf("invalid AccessReviewEntryAccountType value: %q", string(text))
|
||||
}
|
||||
|
||||
*v = val
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -16,28 +16,28 @@ package coredata
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAccessEntryIncrementalTagIsValid(t *testing.T) {
|
||||
func TestAccessReviewEntryAccountTypeIsValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range AccessEntryIncrementalTags() {
|
||||
for _, value := range AccessReviewEntryAccountTypes() {
|
||||
if !value.IsValid() {
|
||||
t.Fatalf("IsValid() = false for %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
if AccessEntryIncrementalTag("BOGUS").IsValid() {
|
||||
if AccessReviewEntryAccountType("BOGUS").IsValid() {
|
||||
t.Fatal("IsValid() = true for invalid value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessEntryIncrementalTagUnmarshalText(t *testing.T) {
|
||||
func TestAccessReviewEntryAccountTypeUnmarshalText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range AccessEntryIncrementalTags() {
|
||||
for _, value := range AccessReviewEntryAccountTypes() {
|
||||
t.Run(string(value), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var got AccessEntryIncrementalTag
|
||||
var got AccessReviewEntryAccountType
|
||||
if err := got.UnmarshalText([]byte(value)); err != nil {
|
||||
t.Fatalf("UnmarshalText(%q) returned error: %v", value, err)
|
||||
}
|
||||
@@ -51,17 +51,17 @@ func TestAccessEntryIncrementalTagUnmarshalText(t *testing.T) {
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var got AccessEntryIncrementalTag
|
||||
var got AccessReviewEntryAccountType
|
||||
if err := got.UnmarshalText([]byte("BOGUS")); err == nil {
|
||||
t.Fatal("UnmarshalText(BOGUS) expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccessEntryIncrementalTagMarshalText(t *testing.T) {
|
||||
func TestAccessReviewEntryAccountTypeMarshalText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range AccessEntryIncrementalTags() {
|
||||
for _, value := range AccessReviewEntryAccountTypes() {
|
||||
t.Run(string(value), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
79
pkg/coredata/access_review_entry_decision.go
Normal file
79
pkg/coredata/access_review_entry_decision.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// 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 coredata
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type AccessReviewEntryDecision string
|
||||
|
||||
const (
|
||||
AccessReviewEntryDecisionPending AccessReviewEntryDecision = "PENDING"
|
||||
AccessReviewEntryDecisionApproved AccessReviewEntryDecision = "APPROVED"
|
||||
AccessReviewEntryDecisionRevoke AccessReviewEntryDecision = "REVOKE"
|
||||
AccessReviewEntryDecisionDefer AccessReviewEntryDecision = "DEFER"
|
||||
AccessReviewEntryDecisionEscalate AccessReviewEntryDecision = "ESCALATE"
|
||||
)
|
||||
|
||||
var (
|
||||
_ fmt.Stringer = AccessReviewEntryDecision("")
|
||||
_ encoding.TextMarshaler = AccessReviewEntryDecision("")
|
||||
_ encoding.TextUnmarshaler = (*AccessReviewEntryDecision)(nil)
|
||||
)
|
||||
|
||||
func AccessReviewEntryDecisions() []AccessReviewEntryDecision {
|
||||
return []AccessReviewEntryDecision{
|
||||
AccessReviewEntryDecisionPending,
|
||||
AccessReviewEntryDecisionApproved,
|
||||
AccessReviewEntryDecisionRevoke,
|
||||
AccessReviewEntryDecisionDefer,
|
||||
AccessReviewEntryDecisionEscalate,
|
||||
}
|
||||
}
|
||||
|
||||
func (v AccessReviewEntryDecision) IsValid() bool {
|
||||
switch v {
|
||||
case
|
||||
AccessReviewEntryDecisionPending,
|
||||
AccessReviewEntryDecisionApproved,
|
||||
AccessReviewEntryDecisionRevoke,
|
||||
AccessReviewEntryDecisionDefer,
|
||||
AccessReviewEntryDecisionEscalate:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (v AccessReviewEntryDecision) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
func (v AccessReviewEntryDecision) MarshalText() ([]byte, error) {
|
||||
return []byte(v.String()), nil
|
||||
}
|
||||
|
||||
func (v *AccessReviewEntryDecision) UnmarshalText(text []byte) error {
|
||||
val := AccessReviewEntryDecision(text)
|
||||
if !val.IsValid() {
|
||||
return fmt.Errorf("invalid AccessReviewEntryDecision value: %q", string(text))
|
||||
}
|
||||
|
||||
*v = val
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -27,31 +27,31 @@ import (
|
||||
)
|
||||
|
||||
type (
|
||||
AccessEntryDecisionHistory struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
AccessEntry gid.GID `db:"access_entry_id"`
|
||||
Decision AccessEntryDecision `db:"decision"`
|
||||
DecisionNote *string `db:"decision_note"`
|
||||
DecidedBy *gid.GID `db:"decided_by"`
|
||||
DecidedAt time.Time `db:"decided_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
AccessReviewEntryDecisionHistory struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
AccessReviewEntry gid.GID `db:"access_review_entry_id"`
|
||||
Decision AccessReviewEntryDecision `db:"decision"`
|
||||
DecisionNote *string `db:"decision_note"`
|
||||
DecidedBy *gid.GID `db:"decided_by"`
|
||||
DecidedAt time.Time `db:"decided_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
AccessEntryDecisionHistories []*AccessEntryDecisionHistory
|
||||
AccessReviewEntryDecisionHistories []*AccessReviewEntryDecisionHistory
|
||||
)
|
||||
|
||||
func (h *AccessEntryDecisionHistory) Insert(
|
||||
func (h *AccessReviewEntryDecisionHistory) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO access_entry_decision_history (
|
||||
INSERT INTO access_review_entry_decision_history (
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
access_entry_id,
|
||||
access_review_entry_id,
|
||||
decision,
|
||||
decision_note,
|
||||
decided_by,
|
||||
@@ -61,7 +61,7 @@ INSERT INTO access_entry_decision_history (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@organization_id,
|
||||
@access_entry_id,
|
||||
@access_review_entry_id,
|
||||
@decision,
|
||||
@decision_note,
|
||||
@decided_by,
|
||||
@@ -70,15 +70,15 @@ INSERT INTO access_entry_decision_history (
|
||||
);
|
||||
`
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": h.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": h.OrganizationID,
|
||||
"access_entry_id": h.AccessEntry,
|
||||
"decision": h.Decision,
|
||||
"decision_note": h.DecisionNote,
|
||||
"decided_by": h.DecidedBy,
|
||||
"decided_at": h.DecidedAt,
|
||||
"created_at": h.CreatedAt,
|
||||
"id": h.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": h.OrganizationID,
|
||||
"access_review_entry_id": h.AccessReviewEntry,
|
||||
"decision": h.Decision,
|
||||
"decision_note": h.DecisionNote,
|
||||
"decided_by": h.DecidedBy,
|
||||
"decided_at": h.DecidedAt,
|
||||
"created_at": h.CreatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
@@ -89,12 +89,12 @@ INSERT INTO access_entry_decision_history (
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AccessEntryDecisionHistory) AuthorizationAttributes(
|
||||
func (h *AccessReviewEntryDecisionHistory) AuthorizationAttributes(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
resourceIDs []gid.GID,
|
||||
) (policy.AttributesByID, error) {
|
||||
q := `SELECT id, organization_id FROM access_entry_decision_history WHERE id = ANY(@resource_ids::text[])`
|
||||
q := `SELECT id, organization_id FROM access_review_entry_decision_history WHERE id = ANY(@resource_ids::text[])`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"resource_ids": resourceIDs,
|
||||
@@ -128,7 +128,7 @@ func (h *AccessEntryDecisionHistory) AuthorizationAttributes(
|
||||
return attrsByID, nil
|
||||
}
|
||||
|
||||
func (hs *AccessEntryDecisionHistories) LoadByEntryID(
|
||||
func (hs *AccessReviewEntryDecisionHistories) LoadByEntryID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
@@ -138,22 +138,22 @@ func (hs *AccessEntryDecisionHistories) LoadByEntryID(
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
access_entry_id,
|
||||
access_review_entry_id,
|
||||
decision,
|
||||
decision_note,
|
||||
decided_by,
|
||||
decided_at,
|
||||
created_at
|
||||
FROM
|
||||
access_entry_decision_history
|
||||
access_review_entry_decision_history
|
||||
WHERE
|
||||
%s
|
||||
AND access_entry_id = @access_entry_id
|
||||
AND access_review_entry_id = @access_review_entry_id
|
||||
ORDER BY decided_at ASC;
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"access_entry_id": entryID}
|
||||
args := pgx.StrictNamedArgs{"access_review_entry_id": entryID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
@@ -161,7 +161,7 @@ ORDER BY decided_at ASC;
|
||||
return fmt.Errorf("cannot query access entry decision history: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessEntryDecisionHistory])
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewEntryDecisionHistory])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect access entry decision history: %w", err)
|
||||
}
|
||||
@@ -16,28 +16,28 @@ package coredata
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAccessEntryAccountTypeIsValid(t *testing.T) {
|
||||
func TestAccessReviewEntryDecisionIsValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range AccessEntryAccountTypes() {
|
||||
for _, value := range AccessReviewEntryDecisions() {
|
||||
if !value.IsValid() {
|
||||
t.Fatalf("IsValid() = false for %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
if AccessEntryAccountType("BOGUS").IsValid() {
|
||||
if AccessReviewEntryDecision("BOGUS").IsValid() {
|
||||
t.Fatal("IsValid() = true for invalid value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessEntryAccountTypeUnmarshalText(t *testing.T) {
|
||||
func TestAccessReviewEntryDecisionUnmarshalText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range AccessEntryAccountTypes() {
|
||||
for _, value := range AccessReviewEntryDecisions() {
|
||||
t.Run(string(value), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var got AccessEntryAccountType
|
||||
var got AccessReviewEntryDecision
|
||||
if err := got.UnmarshalText([]byte(value)); err != nil {
|
||||
t.Fatalf("UnmarshalText(%q) returned error: %v", value, err)
|
||||
}
|
||||
@@ -51,17 +51,17 @@ func TestAccessEntryAccountTypeUnmarshalText(t *testing.T) {
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var got AccessEntryAccountType
|
||||
var got AccessReviewEntryDecision
|
||||
if err := got.UnmarshalText([]byte("BOGUS")); err == nil {
|
||||
t.Fatal("UnmarshalText(BOGUS) expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccessEntryAccountTypeMarshalText(t *testing.T) {
|
||||
func TestAccessReviewEntryDecisionMarshalText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range AccessEntryAccountTypes() {
|
||||
for _, value := range AccessReviewEntryDecisions() {
|
||||
t.Run(string(value), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -18,17 +18,17 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type AccessEntryFilter struct {
|
||||
Decision *AccessEntryDecision
|
||||
Flag *AccessEntryFlag
|
||||
IncrementalTag *AccessEntryIncrementalTag
|
||||
type AccessReviewEntryFilter struct {
|
||||
Decision *AccessReviewEntryDecision
|
||||
Flag *AccessReviewEntryFlag
|
||||
IncrementalTag *AccessReviewEntryIncrementalTag
|
||||
IsAdmin *bool
|
||||
Active *bool
|
||||
AuthMethod *AccessEntryAuthMethod
|
||||
AccountType *AccessEntryAccountType
|
||||
AuthMethod *AccessReviewEntryAuthMethod
|
||||
AccountType *AccessReviewEntryAccountType
|
||||
}
|
||||
|
||||
func (f *AccessEntryFilter) SQLFragment() string {
|
||||
func (f *AccessReviewEntryFilter) SQLFragment() string {
|
||||
if f == nil {
|
||||
return "TRUE"
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func (f *AccessEntryFilter) SQLFragment() string {
|
||||
)`
|
||||
}
|
||||
|
||||
func (f *AccessEntryFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||
func (f *AccessReviewEntryFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||
if f == nil {
|
||||
return pgx.StrictNamedArgs{}
|
||||
}
|
||||
109
pkg/coredata/access_review_entry_flag.go
Normal file
109
pkg/coredata/access_review_entry_flag.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type AccessReviewEntryFlag string
|
||||
|
||||
const (
|
||||
AccessReviewEntryFlagNone AccessReviewEntryFlag = "NONE"
|
||||
AccessReviewEntryFlagOrphaned AccessReviewEntryFlag = "ORPHANED"
|
||||
AccessReviewEntryFlagInactive AccessReviewEntryFlag = "INACTIVE"
|
||||
AccessReviewEntryFlagExcessive AccessReviewEntryFlag = "EXCESSIVE"
|
||||
AccessReviewEntryFlagRoleMismatch AccessReviewEntryFlag = "ROLE_MISMATCH"
|
||||
AccessReviewEntryFlagNew AccessReviewEntryFlag = "NEW"
|
||||
AccessReviewEntryFlagDormant AccessReviewEntryFlag = "DORMANT"
|
||||
AccessReviewEntryFlagTerminatedUser AccessReviewEntryFlag = "TERMINATED_USER"
|
||||
AccessReviewEntryFlagContractorExpired AccessReviewEntryFlag = "CONTRACTOR_EXPIRED"
|
||||
AccessReviewEntryFlagSoDConflict AccessReviewEntryFlag = "SOD_CONFLICT"
|
||||
AccessReviewEntryFlagPrivilegedAccess AccessReviewEntryFlag = "PRIVILEGED_ACCESS"
|
||||
AccessReviewEntryFlagRoleCreep AccessReviewEntryFlag = "ROLE_CREEP"
|
||||
AccessReviewEntryFlagNoBusinessJustification AccessReviewEntryFlag = "NO_BUSINESS_JUSTIFICATION"
|
||||
AccessReviewEntryFlagOutOfDepartment AccessReviewEntryFlag = "OUT_OF_DEPARTMENT"
|
||||
AccessReviewEntryFlagSharedAccount AccessReviewEntryFlag = "SHARED_ACCOUNT"
|
||||
)
|
||||
|
||||
var (
|
||||
_ fmt.Stringer = AccessReviewEntryFlag("")
|
||||
_ encoding.TextMarshaler = AccessReviewEntryFlag("")
|
||||
_ encoding.TextUnmarshaler = (*AccessReviewEntryFlag)(nil)
|
||||
)
|
||||
|
||||
func AccessReviewEntryFlags() []AccessReviewEntryFlag {
|
||||
return []AccessReviewEntryFlag{
|
||||
AccessReviewEntryFlagNone,
|
||||
AccessReviewEntryFlagOrphaned,
|
||||
AccessReviewEntryFlagInactive,
|
||||
AccessReviewEntryFlagExcessive,
|
||||
AccessReviewEntryFlagRoleMismatch,
|
||||
AccessReviewEntryFlagNew,
|
||||
AccessReviewEntryFlagDormant,
|
||||
AccessReviewEntryFlagTerminatedUser,
|
||||
AccessReviewEntryFlagContractorExpired,
|
||||
AccessReviewEntryFlagSoDConflict,
|
||||
AccessReviewEntryFlagPrivilegedAccess,
|
||||
AccessReviewEntryFlagRoleCreep,
|
||||
AccessReviewEntryFlagNoBusinessJustification,
|
||||
AccessReviewEntryFlagOutOfDepartment,
|
||||
AccessReviewEntryFlagSharedAccount,
|
||||
}
|
||||
}
|
||||
|
||||
func (v AccessReviewEntryFlag) IsValid() bool {
|
||||
switch v {
|
||||
case
|
||||
AccessReviewEntryFlagNone,
|
||||
AccessReviewEntryFlagOrphaned,
|
||||
AccessReviewEntryFlagInactive,
|
||||
AccessReviewEntryFlagExcessive,
|
||||
AccessReviewEntryFlagRoleMismatch,
|
||||
AccessReviewEntryFlagNew,
|
||||
AccessReviewEntryFlagDormant,
|
||||
AccessReviewEntryFlagTerminatedUser,
|
||||
AccessReviewEntryFlagContractorExpired,
|
||||
AccessReviewEntryFlagSoDConflict,
|
||||
AccessReviewEntryFlagPrivilegedAccess,
|
||||
AccessReviewEntryFlagRoleCreep,
|
||||
AccessReviewEntryFlagNoBusinessJustification,
|
||||
AccessReviewEntryFlagOutOfDepartment,
|
||||
AccessReviewEntryFlagSharedAccount:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (v AccessReviewEntryFlag) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
func (v AccessReviewEntryFlag) MarshalText() ([]byte, error) {
|
||||
return []byte(v.String()), nil
|
||||
}
|
||||
|
||||
func (v *AccessReviewEntryFlag) UnmarshalText(text []byte) error {
|
||||
val := AccessReviewEntryFlag(text)
|
||||
if !val.IsValid() {
|
||||
return fmt.Errorf("invalid AccessReviewEntryFlag value: %q", string(text))
|
||||
}
|
||||
|
||||
*v = val
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -16,28 +16,28 @@ package coredata
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAccessEntryFlagIsValid(t *testing.T) {
|
||||
func TestAccessReviewEntryFlagIsValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range AccessEntryFlags() {
|
||||
for _, value := range AccessReviewEntryFlags() {
|
||||
if !value.IsValid() {
|
||||
t.Fatalf("IsValid() = false for %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
if AccessEntryFlag("BOGUS").IsValid() {
|
||||
if AccessReviewEntryFlag("BOGUS").IsValid() {
|
||||
t.Fatal("IsValid() = true for invalid value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessEntryFlagUnmarshalText(t *testing.T) {
|
||||
func TestAccessReviewEntryFlagUnmarshalText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range AccessEntryFlags() {
|
||||
for _, value := range AccessReviewEntryFlags() {
|
||||
t.Run(string(value), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var got AccessEntryFlag
|
||||
var got AccessReviewEntryFlag
|
||||
if err := got.UnmarshalText([]byte(value)); err != nil {
|
||||
t.Fatalf("UnmarshalText(%q) returned error: %v", value, err)
|
||||
}
|
||||
@@ -51,17 +51,17 @@ func TestAccessEntryFlagUnmarshalText(t *testing.T) {
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var got AccessEntryFlag
|
||||
var got AccessReviewEntryFlag
|
||||
if err := got.UnmarshalText([]byte("BOGUS")); err == nil {
|
||||
t.Fatal("UnmarshalText(BOGUS) expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccessEntryFlagMarshalText(t *testing.T) {
|
||||
func TestAccessReviewEntryFlagMarshalText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range AccessEntryFlags() {
|
||||
for _, value := range AccessReviewEntryFlags() {
|
||||
t.Run(string(value), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
73
pkg/coredata/access_review_entry_incremental_tag.go
Normal file
73
pkg/coredata/access_review_entry_incremental_tag.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type AccessReviewEntryIncrementalTag string
|
||||
|
||||
const (
|
||||
AccessReviewEntryIncrementalTagNew AccessReviewEntryIncrementalTag = "NEW"
|
||||
AccessReviewEntryIncrementalTagRemoved AccessReviewEntryIncrementalTag = "REMOVED"
|
||||
AccessReviewEntryIncrementalTagUnchanged AccessReviewEntryIncrementalTag = "UNCHANGED"
|
||||
)
|
||||
|
||||
var (
|
||||
_ fmt.Stringer = AccessReviewEntryIncrementalTag("")
|
||||
_ encoding.TextMarshaler = AccessReviewEntryIncrementalTag("")
|
||||
_ encoding.TextUnmarshaler = (*AccessReviewEntryIncrementalTag)(nil)
|
||||
)
|
||||
|
||||
func AccessReviewEntryIncrementalTags() []AccessReviewEntryIncrementalTag {
|
||||
return []AccessReviewEntryIncrementalTag{
|
||||
AccessReviewEntryIncrementalTagNew,
|
||||
AccessReviewEntryIncrementalTagRemoved,
|
||||
AccessReviewEntryIncrementalTagUnchanged,
|
||||
}
|
||||
}
|
||||
|
||||
func (v AccessReviewEntryIncrementalTag) IsValid() bool {
|
||||
switch v {
|
||||
case
|
||||
AccessReviewEntryIncrementalTagNew,
|
||||
AccessReviewEntryIncrementalTagRemoved,
|
||||
AccessReviewEntryIncrementalTagUnchanged:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (v AccessReviewEntryIncrementalTag) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
func (v AccessReviewEntryIncrementalTag) MarshalText() ([]byte, error) {
|
||||
return []byte(v.String()), nil
|
||||
}
|
||||
|
||||
func (v *AccessReviewEntryIncrementalTag) UnmarshalText(text []byte) error {
|
||||
val := AccessReviewEntryIncrementalTag(text)
|
||||
if !val.IsValid() {
|
||||
return fmt.Errorf("invalid AccessReviewEntryIncrementalTag value: %q", string(text))
|
||||
}
|
||||
|
||||
*v = val
|
||||
|
||||
return nil
|
||||
}
|
||||
78
pkg/coredata/access_review_entry_incremental_tag_test.go
Normal file
78
pkg/coredata/access_review_entry_incremental_tag_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAccessReviewEntryIncrementalTagIsValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range AccessReviewEntryIncrementalTags() {
|
||||
if !value.IsValid() {
|
||||
t.Fatalf("IsValid() = false for %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
if AccessReviewEntryIncrementalTag("BOGUS").IsValid() {
|
||||
t.Fatal("IsValid() = true for invalid value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessReviewEntryIncrementalTagUnmarshalText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range AccessReviewEntryIncrementalTags() {
|
||||
t.Run(string(value), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var got AccessReviewEntryIncrementalTag
|
||||
if err := got.UnmarshalText([]byte(value)); err != nil {
|
||||
t.Fatalf("UnmarshalText(%q) returned error: %v", value, err)
|
||||
}
|
||||
|
||||
if got != value {
|
||||
t.Fatalf("UnmarshalText(%q) = %q, want %q", value, got, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var got AccessReviewEntryIncrementalTag
|
||||
if err := got.UnmarshalText([]byte("BOGUS")); err == nil {
|
||||
t.Fatal("UnmarshalText(BOGUS) expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccessReviewEntryIncrementalTagMarshalText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range AccessReviewEntryIncrementalTags() {
|
||||
t.Run(string(value), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := value.MarshalText()
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalText() returned error: %v", err)
|
||||
}
|
||||
|
||||
if string(got) != value.String() {
|
||||
t.Fatalf("MarshalText() = %q, want %q", string(got), value.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -22,48 +22,48 @@ import (
|
||||
)
|
||||
|
||||
type (
|
||||
AccessEntryOrderField string
|
||||
AccessReviewEntryOrderField string
|
||||
)
|
||||
|
||||
const (
|
||||
AccessEntryOrderFieldCreatedAt AccessEntryOrderField = "CREATED_AT"
|
||||
AccessReviewEntryOrderFieldCreatedAt AccessReviewEntryOrderField = "CREATED_AT"
|
||||
)
|
||||
|
||||
var (
|
||||
_ page.OrderField = AccessEntryOrderField("")
|
||||
_ fmt.Stringer = AccessEntryOrderField("")
|
||||
_ encoding.TextMarshaler = AccessEntryOrderField("")
|
||||
_ encoding.TextUnmarshaler = (*AccessEntryOrderField)(nil)
|
||||
_ page.OrderField = AccessReviewEntryOrderField("")
|
||||
_ fmt.Stringer = AccessReviewEntryOrderField("")
|
||||
_ encoding.TextMarshaler = AccessReviewEntryOrderField("")
|
||||
_ encoding.TextUnmarshaler = (*AccessReviewEntryOrderField)(nil)
|
||||
)
|
||||
|
||||
func AccessEntryOrderFields() []AccessEntryOrderField {
|
||||
return []AccessEntryOrderField{
|
||||
AccessEntryOrderFieldCreatedAt,
|
||||
func AccessReviewEntryOrderFields() []AccessReviewEntryOrderField {
|
||||
return []AccessReviewEntryOrderField{
|
||||
AccessReviewEntryOrderFieldCreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (v AccessEntryOrderField) IsValid() bool {
|
||||
func (v AccessReviewEntryOrderField) IsValid() bool {
|
||||
switch v {
|
||||
case
|
||||
AccessEntryOrderFieldCreatedAt:
|
||||
AccessReviewEntryOrderFieldCreatedAt:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (v AccessEntryOrderField) String() string {
|
||||
func (v AccessReviewEntryOrderField) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
func (v AccessEntryOrderField) MarshalText() ([]byte, error) {
|
||||
func (v AccessReviewEntryOrderField) MarshalText() ([]byte, error) {
|
||||
return []byte(v.String()), nil
|
||||
}
|
||||
|
||||
func (v *AccessEntryOrderField) UnmarshalText(text []byte) error {
|
||||
val := AccessEntryOrderField(text)
|
||||
func (v *AccessReviewEntryOrderField) UnmarshalText(text []byte) error {
|
||||
val := AccessReviewEntryOrderField(text)
|
||||
if !val.IsValid() {
|
||||
return fmt.Errorf("invalid AccessEntryOrderField value: %q", string(text))
|
||||
return fmt.Errorf("invalid AccessReviewEntryOrderField value: %q", string(text))
|
||||
}
|
||||
|
||||
*v = val
|
||||
@@ -71,9 +71,9 @@ func (v *AccessEntryOrderField) UnmarshalText(text []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p AccessEntryOrderField) Column() string {
|
||||
func (p AccessReviewEntryOrderField) Column() string {
|
||||
switch p {
|
||||
case AccessEntryOrderFieldCreatedAt:
|
||||
case AccessReviewEntryOrderFieldCreatedAt:
|
||||
return "created_at"
|
||||
}
|
||||
|
||||
427
pkg/coredata/access_review_entry_upsert_test.go
Normal file
427
pkg/coredata/access_review_entry_upsert_test.go
Normal file
@@ -0,0 +1,427 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/internal/test"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
// accessEntryFixture bootstraps the parent rows (organization, campaign,
|
||||
// source) that the access_review_entries FKs require.
|
||||
type accessEntryFixture struct {
|
||||
scope *coredata.Scope
|
||||
organizationID gid.GID
|
||||
campaignID gid.GID
|
||||
sourceID gid.GID
|
||||
campaignSourceID gid.GID
|
||||
accountKey string
|
||||
}
|
||||
|
||||
func seedAccessReviewEntryFixture(t *testing.T, ctx context.Context, client *pg.Client) accessEntryFixture {
|
||||
t.Helper()
|
||||
|
||||
tenantID := gid.NewTenantID()
|
||||
scope := coredata.NewScope(tenantID)
|
||||
organizationID := gid.New(tenantID, coredata.OrganizationEntityType)
|
||||
campaignID := gid.New(tenantID, coredata.AccessReviewCampaignEntityType)
|
||||
sourceID := gid.New(tenantID, coredata.AccessReviewSourceEntityType)
|
||||
campaignSourceID := gid.New(tenantID, coredata.AccessReviewCampaignSourceEntityType)
|
||||
accountKey := "upsert-freeze-test@example.com"
|
||||
now := time.Now().UTC()
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
org := &coredata.Organization{
|
||||
ID: organizationID,
|
||||
TenantID: tenantID,
|
||||
Name: "Upsert Freeze Test Org",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := org.Insert(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
source := &coredata.AccessReviewSource{
|
||||
ID: sourceID,
|
||||
OrganizationID: organizationID,
|
||||
Name: "Upsert Freeze Test Source",
|
||||
Category: coredata.AccessReviewSourceCategorySaaS,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := source.Insert(ctx, tx, scope); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
campaign := &coredata.AccessReviewCampaign{
|
||||
ID: campaignID,
|
||||
OrganizationID: organizationID,
|
||||
Name: "Upsert Freeze Test Campaign",
|
||||
Status: coredata.AccessReviewCampaignStatusDraft,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := campaign.Insert(ctx, tx, scope); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
campaignSource := &coredata.AccessReviewCampaignSource{
|
||||
ID: campaignSourceID,
|
||||
TenantID: tenantID,
|
||||
AccessReviewCampaignID: campaignID,
|
||||
AccessReviewSourceID: &sourceID,
|
||||
Name: "Upsert Freeze Test Source",
|
||||
Category: coredata.AccessReviewSourceCategorySaaS,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := campaignSource.Upsert(ctx, tx, scope); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}))
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error {
|
||||
// Delete access_review_entries first (no ON DELETE CASCADE for the org side),
|
||||
// then parents.
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM access_review_entries WHERE access_review_campaign_id = $1`, campaignID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM access_review_campaign_sources WHERE access_review_campaign_id = $1`, campaignID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM access_review_campaigns WHERE id = $1`, campaignID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM access_review_sources WHERE id = $1`, sourceID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM organizations WHERE id = $1`, organizationID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
return accessEntryFixture{
|
||||
scope: scope,
|
||||
organizationID: organizationID,
|
||||
campaignID: campaignID,
|
||||
sourceID: sourceID,
|
||||
campaignSourceID: campaignSourceID,
|
||||
accountKey: accountKey,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessReviewEntry_Upsert_FreezesDecidedFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
fx := seedAccessReviewEntryFixture(t, ctx, client)
|
||||
|
||||
tenantID := fx.scope.GetTenantID()
|
||||
originalFlagReasons := []string{"original-flag-reason"}
|
||||
originalFlags := []coredata.AccessReviewEntryFlag{coredata.AccessReviewEntryFlagNew}
|
||||
originalEmail := "old@example.com"
|
||||
originalFullName := "Old Name"
|
||||
originalRole := "viewer"
|
||||
|
||||
t0 := time.Now().UTC().Truncate(time.Microsecond)
|
||||
|
||||
// Step 1: Initial Upsert with PENDING decision.
|
||||
entryID := gid.New(tenantID, coredata.AccessReviewEntryEntityType)
|
||||
initial := &coredata.AccessReviewEntry{
|
||||
ID: entryID,
|
||||
OrganizationID: fx.organizationID,
|
||||
AccessReviewCampaignID: fx.campaignID,
|
||||
AccessReviewCampaignSourceID: fx.campaignSourceID,
|
||||
Email: originalEmail,
|
||||
FullName: originalFullName,
|
||||
Role: originalRole,
|
||||
JobTitle: "",
|
||||
IsAdmin: false,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: "ext-1",
|
||||
AccountKey: fx.accountKey,
|
||||
IncrementalTag: coredata.AccessReviewEntryIncrementalTagNew,
|
||||
Flags: originalFlags,
|
||||
FlagReasons: originalFlagReasons,
|
||||
Decision: coredata.AccessReviewEntryDecisionPending,
|
||||
DecisionNote: nil,
|
||||
DecidedBy: nil,
|
||||
DecidedAt: nil,
|
||||
CreatedAt: t0,
|
||||
UpdatedAt: t0,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
return initial.Upsert(ctx, tx, fx.scope)
|
||||
}))
|
||||
|
||||
// Step 2: Record a decision via Update — APPROVED with decided_by / decided_at.
|
||||
decisionTime := t0.Add(1 * time.Hour)
|
||||
decidedBy := gid.New(tenantID, coredata.OrganizationEntityType) // opaque ID suffices: decided_by has no FK.
|
||||
decisionNote := "looks good"
|
||||
|
||||
decided := &coredata.AccessReviewEntry{
|
||||
ID: entryID,
|
||||
Flags: originalFlags,
|
||||
FlagReasons: originalFlagReasons,
|
||||
Decision: coredata.AccessReviewEntryDecisionApproved,
|
||||
DecisionNote: &decisionNote,
|
||||
DecidedBy: &decidedBy,
|
||||
DecidedAt: &decisionTime,
|
||||
UpdatedAt: decisionTime,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
return decided.Update(ctx, tx, fx.scope)
|
||||
}))
|
||||
|
||||
// Step 3: Second Upsert with the same unique key but new flags, new
|
||||
// flag reasons, PENDING decision, nil note/decidedBy/decidedAt, and
|
||||
// refreshed top-level fields (email, full_name, role).
|
||||
t2 := decisionTime.Add(1 * time.Hour)
|
||||
secondEmail := "new@example.com"
|
||||
secondFullName := "New Name"
|
||||
secondRole := "admin"
|
||||
refresh := &coredata.AccessReviewEntry{
|
||||
ID: gid.New(tenantID, coredata.AccessReviewEntryEntityType), // ignored by ON CONFLICT
|
||||
OrganizationID: fx.organizationID,
|
||||
AccessReviewCampaignID: fx.campaignID,
|
||||
AccessReviewCampaignSourceID: fx.campaignSourceID,
|
||||
Email: secondEmail,
|
||||
FullName: secondFullName,
|
||||
Role: secondRole,
|
||||
JobTitle: "",
|
||||
IsAdmin: true,
|
||||
MFAStatus: coredata.MFAStatusEnabled,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodSSO,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: "ext-1",
|
||||
AccountKey: fx.accountKey,
|
||||
IncrementalTag: coredata.AccessReviewEntryIncrementalTagUnchanged,
|
||||
Flags: []coredata.AccessReviewEntryFlag{coredata.AccessReviewEntryFlagInactive},
|
||||
FlagReasons: []string{"refreshed-flag-reason"},
|
||||
Decision: coredata.AccessReviewEntryDecisionPending,
|
||||
DecisionNote: nil,
|
||||
DecidedBy: nil,
|
||||
DecidedAt: nil,
|
||||
CreatedAt: t2,
|
||||
UpdatedAt: t2,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
return refresh.Upsert(ctx, tx, fx.scope)
|
||||
}))
|
||||
|
||||
// Step 4: Load and assert the freeze semantics.
|
||||
loaded := &coredata.AccessReviewEntry{}
|
||||
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
return loaded.LoadByID(ctx, conn, fx.scope, entryID)
|
||||
}))
|
||||
|
||||
// Decision fields are FROZEN at APPROVED / decided_by / decided_at /
|
||||
// decision_note from the Update call.
|
||||
assert.Equal(t, coredata.AccessReviewEntryDecisionApproved, loaded.Decision, "decision must be frozen once locked")
|
||||
require.NotNil(t, loaded.DecidedBy, "decided_by must be preserved")
|
||||
assert.Equal(t, decidedBy, *loaded.DecidedBy)
|
||||
require.NotNil(t, loaded.DecidedAt, "decided_at must be preserved")
|
||||
assert.WithinDuration(t, decisionTime, *loaded.DecidedAt, time.Second)
|
||||
require.NotNil(t, loaded.DecisionNote, "decision_note must be preserved")
|
||||
assert.Equal(t, decisionNote, *loaded.DecisionNote)
|
||||
|
||||
// Flags / flag_reasons are FROZEN (the new guard from Task 1): once a
|
||||
// reviewer locks a decision, the evidence that drove that decision must
|
||||
// not be silently replaced by a subsequent poll.
|
||||
assert.Equal(t, originalFlags, loaded.Flags, "flags must be frozen once decision is locked")
|
||||
assert.Equal(t, originalFlagReasons, loaded.FlagReasons, "flag_reasons must be frozen once decision is locked")
|
||||
|
||||
// Columns that ARE refreshed on every poll.
|
||||
assert.Equal(t, secondEmail, loaded.Email)
|
||||
assert.Equal(t, secondFullName, loaded.FullName)
|
||||
assert.Equal(t, secondRole, loaded.Role)
|
||||
assert.True(t, loaded.IsAdmin)
|
||||
assert.Equal(t, coredata.MFAStatusEnabled, loaded.MFAStatus)
|
||||
assert.Equal(t, coredata.AccessReviewEntryAuthMethodSSO, loaded.AuthMethod)
|
||||
assert.WithinDuration(t, t2, loaded.UpdatedAt, time.Second)
|
||||
}
|
||||
|
||||
// TestAccessReviewEntry_Upsert_RefreshesSourceTrackingFields pins the contract of
|
||||
// the ON CONFLICT DO UPDATE SET clause: across repeated polls of the same
|
||||
// (campaign, source, account_key), the columns that track live source state
|
||||
// (email, full_name, role, is_admin, MFA, auth_method, last_login, etc.)
|
||||
// move forward to the latest values, while the verdict-related columns
|
||||
// (flags, flag_reasons, decision, decision_note, decided_by, decided_at) are
|
||||
// never written by a re-poll -- those can only change through Update.
|
||||
func TestAccessReviewEntry_Upsert_RefreshesSourceTrackingFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
fx := seedAccessReviewEntryFixture(t, ctx, client)
|
||||
|
||||
tenantID := fx.scope.GetTenantID()
|
||||
t0 := time.Now().UTC().Truncate(time.Microsecond)
|
||||
|
||||
entryID := gid.New(tenantID, coredata.AccessReviewEntryEntityType)
|
||||
first := &coredata.AccessReviewEntry{
|
||||
ID: entryID,
|
||||
OrganizationID: fx.organizationID,
|
||||
AccessReviewCampaignID: fx.campaignID,
|
||||
AccessReviewCampaignSourceID: fx.campaignSourceID,
|
||||
Email: "old@example.com",
|
||||
FullName: "Old Name",
|
||||
Role: "viewer",
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: "ext-2",
|
||||
AccountKey: fx.accountKey,
|
||||
IncrementalTag: coredata.AccessReviewEntryIncrementalTagNew,
|
||||
Flags: []coredata.AccessReviewEntryFlag{},
|
||||
FlagReasons: []string{},
|
||||
Decision: coredata.AccessReviewEntryDecisionPending,
|
||||
CreatedAt: t0,
|
||||
UpdatedAt: t0,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
return first.Upsert(ctx, tx, fx.scope)
|
||||
}))
|
||||
|
||||
t1 := t0.Add(1 * time.Hour)
|
||||
second := &coredata.AccessReviewEntry{
|
||||
ID: gid.New(tenantID, coredata.AccessReviewEntryEntityType),
|
||||
OrganizationID: fx.organizationID,
|
||||
AccessReviewCampaignID: fx.campaignID,
|
||||
AccessReviewCampaignSourceID: fx.campaignSourceID,
|
||||
Email: "new@example.com",
|
||||
FullName: "New Name",
|
||||
Role: "admin",
|
||||
MFAStatus: coredata.MFAStatusEnabled,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodSSO,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: "ext-2",
|
||||
AccountKey: fx.accountKey,
|
||||
IncrementalTag: coredata.AccessReviewEntryIncrementalTagUnchanged,
|
||||
Flags: []coredata.AccessReviewEntryFlag{},
|
||||
FlagReasons: []string{},
|
||||
Decision: coredata.AccessReviewEntryDecisionPending,
|
||||
CreatedAt: t1,
|
||||
UpdatedAt: t1,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
return second.Upsert(ctx, tx, fx.scope)
|
||||
}))
|
||||
|
||||
loaded := &coredata.AccessReviewEntry{}
|
||||
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
return loaded.LoadByID(ctx, conn, fx.scope, entryID)
|
||||
}))
|
||||
|
||||
// Source-tracking columns advanced to the second poll's values.
|
||||
assert.Equal(t, "new@example.com", loaded.Email)
|
||||
assert.Equal(t, "New Name", loaded.FullName)
|
||||
assert.Equal(t, "admin", loaded.Role)
|
||||
assert.Equal(t, coredata.MFAStatusEnabled, loaded.MFAStatus)
|
||||
assert.Equal(t, coredata.AccessReviewEntryAuthMethodSSO, loaded.AuthMethod)
|
||||
|
||||
// Verdict-related columns stayed at whatever the first Upsert set (empty /
|
||||
// PENDING); the second Upsert did not touch them.
|
||||
assert.Equal(t, coredata.AccessReviewEntryDecisionPending, loaded.Decision)
|
||||
assert.Equal(t, []coredata.AccessReviewEntryFlag{}, loaded.Flags)
|
||||
assert.Equal(t, []string{}, loaded.FlagReasons)
|
||||
assert.Nil(t, loaded.DecisionNote)
|
||||
assert.Nil(t, loaded.DecidedBy)
|
||||
assert.Nil(t, loaded.DecidedAt)
|
||||
}
|
||||
|
||||
// TestAccessReviewEntry_Upsert_InsertsActiveAccount covers the shape FetchSource
|
||||
// builds for an active account: a PENDING decision and explicit empty
|
||||
// flags / flag_reasons slices. The access_review_entries.flags and flag_reasons
|
||||
// columns are declared NOT NULL, so the caller (FetchSource) is responsible
|
||||
// for passing non-nil slices.
|
||||
func TestAccessReviewEntry_Upsert_InsertsActiveAccount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
fx := seedAccessReviewEntryFixture(t, ctx, client)
|
||||
|
||||
tenantID := fx.scope.GetTenantID()
|
||||
t0 := time.Now().UTC().Truncate(time.Microsecond)
|
||||
|
||||
entryID := gid.New(tenantID, coredata.AccessReviewEntryEntityType)
|
||||
entry := &coredata.AccessReviewEntry{
|
||||
ID: entryID,
|
||||
OrganizationID: fx.organizationID,
|
||||
AccessReviewCampaignID: fx.campaignID,
|
||||
AccessReviewCampaignSourceID: fx.campaignSourceID,
|
||||
Email: "active@example.com",
|
||||
FullName: "Active User",
|
||||
Role: "member",
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: "ext-active",
|
||||
AccountKey: fx.accountKey,
|
||||
IncrementalTag: coredata.AccessReviewEntryIncrementalTagNew,
|
||||
Flags: []coredata.AccessReviewEntryFlag{},
|
||||
FlagReasons: []string{},
|
||||
Decision: coredata.AccessReviewEntryDecisionPending,
|
||||
CreatedAt: t0,
|
||||
UpdatedAt: t0,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
return entry.Upsert(ctx, tx, fx.scope)
|
||||
}))
|
||||
|
||||
loaded := &coredata.AccessReviewEntry{}
|
||||
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
return loaded.LoadByID(ctx, conn, fx.scope, entryID)
|
||||
}))
|
||||
|
||||
assert.Equal(t, coredata.AccessReviewEntryDecisionPending, loaded.Decision)
|
||||
assert.Equal(t, []coredata.AccessReviewEntryFlag{}, loaded.Flags)
|
||||
assert.Equal(t, []string{}, loaded.FlagReasons)
|
||||
assert.Nil(t, loaded.DecisionNote)
|
||||
assert.Nil(t, loaded.DecidedBy)
|
||||
assert.Nil(t, loaded.DecidedAt)
|
||||
}
|
||||
@@ -29,36 +29,36 @@ import (
|
||||
)
|
||||
|
||||
type (
|
||||
AccessSource struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
ConnectorID *gid.GID `db:"connector_id"`
|
||||
Name string `db:"name"`
|
||||
Category AccessSourceCategory `db:"category"`
|
||||
CsvData *string `db:"csv_data"`
|
||||
NameSyncedAt *time.Time `db:"name_synced_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
AccessReviewSource struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
ConnectorID *gid.GID `db:"connector_id"`
|
||||
Name string `db:"name"`
|
||||
Category AccessReviewSourceCategory `db:"category"`
|
||||
CsvData *string `db:"csv_data"`
|
||||
NameSyncedAt *time.Time `db:"name_synced_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
AccessSources []*AccessSource
|
||||
AccessReviewSources []*AccessReviewSource
|
||||
)
|
||||
|
||||
func (as AccessSource) CursorKey(orderBy AccessSourceOrderField) page.CursorKey {
|
||||
func (as AccessReviewSource) CursorKey(orderBy AccessReviewSourceOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case AccessSourceOrderFieldCreatedAt:
|
||||
case AccessReviewSourceOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(as.ID, as.CreatedAt)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
func (as *AccessSource) AuthorizationAttributes(
|
||||
func (as *AccessReviewSource) AuthorizationAttributes(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
resourceIDs []gid.GID,
|
||||
) (policy.AttributesByID, error) {
|
||||
q := `SELECT id, organization_id FROM access_sources WHERE id = ANY(@resource_ids::text[])`
|
||||
q := `SELECT id, organization_id FROM access_review_sources WHERE id = ANY(@resource_ids::text[])`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"resource_ids": resourceIDs,
|
||||
@@ -92,7 +92,7 @@ func (as *AccessSource) AuthorizationAttributes(
|
||||
return attrsByID, nil
|
||||
}
|
||||
|
||||
func (as *AccessSource) LoadByID(
|
||||
func (as *AccessReviewSource) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
@@ -110,7 +110,7 @@ SELECT
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
access_sources
|
||||
access_review_sources
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
@@ -123,10 +123,10 @@ LIMIT 1;
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query access_sources: %w", err)
|
||||
return fmt.Errorf("cannot query access_review_sources: %w", err)
|
||||
}
|
||||
|
||||
source, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessSource])
|
||||
source, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessReviewSource])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
@@ -140,14 +140,14 @@ LIMIT 1;
|
||||
return nil
|
||||
}
|
||||
|
||||
func (as *AccessSource) Insert(
|
||||
func (as *AccessReviewSource) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO
|
||||
access_sources (
|
||||
access_review_sources (
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
@@ -194,13 +194,13 @@ VALUES (
|
||||
return nil
|
||||
}
|
||||
|
||||
func (as *AccessSource) Update(
|
||||
func (as *AccessReviewSource) Update(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE access_sources
|
||||
UPDATE access_review_sources
|
||||
SET
|
||||
name = @name,
|
||||
category = @category,
|
||||
@@ -237,13 +237,13 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (as *AccessSource) Delete(
|
||||
func (as *AccessReviewSource) Delete(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
DELETE FROM access_sources
|
||||
DELETE FROM access_review_sources
|
||||
WHERE %s AND id = @id
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
@@ -263,12 +263,12 @@ WHERE %s AND id = @id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sources *AccessSources) LoadByOrganizationID(
|
||||
func (sources *AccessReviewSources) LoadByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
cursor *page.Cursor[AccessSourceOrderField],
|
||||
cursor *page.Cursor[AccessReviewSourceOrderField],
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
@@ -282,7 +282,7 @@ SELECT
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
access_sources
|
||||
access_review_sources
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
@@ -296,12 +296,12 @@ WHERE
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query access_sources: %w", err)
|
||||
return fmt.Errorf("cannot query access_review_sources: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessSource])
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewSource])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect access_sources: %w", err)
|
||||
return fmt.Errorf("cannot collect access_review_sources: %w", err)
|
||||
}
|
||||
|
||||
*sources = result
|
||||
@@ -309,7 +309,7 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sources *AccessSources) CountByOrganizationID(
|
||||
func (sources *AccessReviewSources) CountByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
@@ -317,7 +317,7 @@ func (sources *AccessSources) CountByOrganizationID(
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT COUNT(id)
|
||||
FROM access_sources
|
||||
FROM access_review_sources
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id;
|
||||
@@ -329,13 +329,13 @@ WHERE
|
||||
|
||||
var count int
|
||||
if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot count access_sources: %w", err)
|
||||
return 0, fmt.Errorf("cannot count access_review_sources: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (sources *AccessSources) CountByConnectorID(
|
||||
func (sources *AccessReviewSources) CountByConnectorID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
@@ -343,7 +343,7 @@ func (sources *AccessSources) CountByConnectorID(
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT COUNT(id)
|
||||
FROM access_sources
|
||||
FROM access_review_sources
|
||||
WHERE
|
||||
%s
|
||||
AND connector_id = @connector_id;
|
||||
@@ -355,70 +355,20 @@ WHERE
|
||||
|
||||
var count int
|
||||
if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot count access_sources by connector ID: %w", err)
|
||||
return 0, fmt.Errorf("cannot count access_review_sources by connector ID: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// LoadScopeSourcesByCampaignID loads the campaign scope sources in deterministic
|
||||
// name order. Only explicitly scoped sources are returned.
|
||||
func (sources *AccessSources) LoadScopeSourcesByCampaignID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
campaignID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
connector_id,
|
||||
name,
|
||||
category,
|
||||
csv_data,
|
||||
name_synced_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
access_sources
|
||||
WHERE
|
||||
%s
|
||||
AND id IN (
|
||||
SELECT arcss.access_source_id
|
||||
FROM access_review_campaign_scope_systems arcss
|
||||
WHERE arcss.access_review_campaign_id = @campaign_id
|
||||
)
|
||||
ORDER BY name ASC
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"campaign_id": campaignID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query scope access_sources: %w", err)
|
||||
}
|
||||
|
||||
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessSource])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect scope access_sources: %w", err)
|
||||
}
|
||||
|
||||
*sources = result
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrNoAccessSourceNameSyncAvailable is returned when no access source
|
||||
// ErrNoAccessReviewSourceNameSyncAvailable is returned when no access source
|
||||
// needs its name synced from its connector.
|
||||
var ErrNoAccessSourceNameSyncAvailable = fmt.Errorf("no access source name sync available")
|
||||
var ErrNoAccessReviewSourceNameSyncAvailable = fmt.Errorf("no access source name sync available")
|
||||
|
||||
// LoadNextUnsyncedNameForUpdateSkipLocked claims the next access source that
|
||||
// has a connector but has not yet had its name synced. The row is locked with
|
||||
// FOR UPDATE SKIP LOCKED so concurrent workers do not pick the same row.
|
||||
func (as *AccessSource) LoadNextUnsyncedNameForUpdateSkipLocked(
|
||||
func (as *AccessReviewSource) LoadNextUnsyncedNameForUpdateSkipLocked(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
) error {
|
||||
@@ -434,7 +384,7 @@ SELECT
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
access_sources
|
||||
access_review_sources
|
||||
WHERE
|
||||
connector_id IS NOT NULL
|
||||
AND name_synced_at IS NULL
|
||||
@@ -446,13 +396,13 @@ FOR UPDATE SKIP LOCKED;
|
||||
|
||||
rows, err := conn.Query(ctx, q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query unsynced access_sources: %w", err)
|
||||
return fmt.Errorf("cannot query unsynced access_review_sources: %w", err)
|
||||
}
|
||||
|
||||
row, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessSource])
|
||||
row, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessReviewSource])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNoAccessSourceNameSyncAvailable
|
||||
return ErrNoAccessReviewSourceNameSyncAvailable
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect unsynced access source: %w", err)
|
||||
76
pkg/coredata/access_review_source_category.go
Normal file
76
pkg/coredata/access_review_source_category.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type AccessReviewSourceCategory string
|
||||
|
||||
const (
|
||||
AccessReviewSourceCategorySaaS AccessReviewSourceCategory = "SAAS"
|
||||
AccessReviewSourceCategoryCloudInfra AccessReviewSourceCategory = "CLOUD_INFRA"
|
||||
AccessReviewSourceCategorySourceCode AccessReviewSourceCategory = "SOURCE_CODE"
|
||||
AccessReviewSourceCategoryOther AccessReviewSourceCategory = "OTHER"
|
||||
)
|
||||
|
||||
var (
|
||||
_ fmt.Stringer = AccessReviewSourceCategory("")
|
||||
_ encoding.TextMarshaler = AccessReviewSourceCategory("")
|
||||
_ encoding.TextUnmarshaler = (*AccessReviewSourceCategory)(nil)
|
||||
)
|
||||
|
||||
func AccessReviewSourceCategories() []AccessReviewSourceCategory {
|
||||
return []AccessReviewSourceCategory{
|
||||
AccessReviewSourceCategorySaaS,
|
||||
AccessReviewSourceCategoryCloudInfra,
|
||||
AccessReviewSourceCategorySourceCode,
|
||||
AccessReviewSourceCategoryOther,
|
||||
}
|
||||
}
|
||||
|
||||
func (v AccessReviewSourceCategory) IsValid() bool {
|
||||
switch v {
|
||||
case
|
||||
AccessReviewSourceCategorySaaS,
|
||||
AccessReviewSourceCategoryCloudInfra,
|
||||
AccessReviewSourceCategorySourceCode,
|
||||
AccessReviewSourceCategoryOther:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (v AccessReviewSourceCategory) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
func (v AccessReviewSourceCategory) MarshalText() ([]byte, error) {
|
||||
return []byte(v.String()), nil
|
||||
}
|
||||
|
||||
func (v *AccessReviewSourceCategory) UnmarshalText(text []byte) error {
|
||||
val := AccessReviewSourceCategory(text)
|
||||
if !val.IsValid() {
|
||||
return fmt.Errorf("invalid AccessReviewSourceCategory value: %q", string(text))
|
||||
}
|
||||
|
||||
*v = val
|
||||
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user