Introduce access-review source snapshot and normalize naming

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View 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
}

View File

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

View 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
}

View 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)
}

View File

@@ -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{

View 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
}

View File

@@ -16,28 +16,28 @@ package coredata
import "testing"
func TestAccessEntryFlagIsValid(t *testing.T) {
func TestAccessReviewEntryAccountTypeIsValid(t *testing.T) {
t.Parallel()
for _, value := range AccessEntryFlags() {
for _, value := range AccessReviewEntryAccountTypes() {
if !value.IsValid() {
t.Fatalf("IsValid() = false for %q", value)
}
}
if AccessEntryFlag("BOGUS").IsValid() {
if AccessReviewEntryAccountType("BOGUS").IsValid() {
t.Fatal("IsValid() = true for invalid value")
}
}
func TestAccessEntryFlagUnmarshalText(t *testing.T) {
func TestAccessReviewEntryAccountTypeUnmarshalText(t *testing.T) {
t.Parallel()
for _, value := range AccessEntryFlags() {
for _, value := range AccessReviewEntryAccountTypes() {
t.Run(string(value), func(t *testing.T) {
t.Parallel()
var got AccessEntryFlag
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 TestAccessEntryFlagUnmarshalText(t *testing.T) {
t.Run("invalid", func(t *testing.T) {
t.Parallel()
var got AccessEntryFlag
var got AccessReviewEntryAccountType
if err := got.UnmarshalText([]byte("BOGUS")); err == nil {
t.Fatal("UnmarshalText(BOGUS) expected error")
}
})
}
func TestAccessEntryFlagMarshalText(t *testing.T) {
func TestAccessReviewEntryAccountTypeMarshalText(t *testing.T) {
t.Parallel()
for _, value := range AccessEntryFlags() {
for _, value := range AccessReviewEntryAccountTypes() {
t.Run(string(value), func(t *testing.T) {
t.Parallel()

View 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
}

View File

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

View File

@@ -16,28 +16,28 @@ package coredata
import "testing"
func TestAccessEntryIncrementalTagIsValid(t *testing.T) {
func TestAccessReviewEntryDecisionIsValid(t *testing.T) {
t.Parallel()
for _, value := range AccessEntryIncrementalTags() {
for _, value := range AccessReviewEntryDecisions() {
if !value.IsValid() {
t.Fatalf("IsValid() = false for %q", value)
}
}
if AccessEntryIncrementalTag("BOGUS").IsValid() {
if AccessReviewEntryDecision("BOGUS").IsValid() {
t.Fatal("IsValid() = true for invalid value")
}
}
func TestAccessEntryIncrementalTagUnmarshalText(t *testing.T) {
func TestAccessReviewEntryDecisionUnmarshalText(t *testing.T) {
t.Parallel()
for _, value := range AccessEntryIncrementalTags() {
for _, value := range AccessReviewEntryDecisions() {
t.Run(string(value), func(t *testing.T) {
t.Parallel()
var got AccessEntryIncrementalTag
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 TestAccessEntryIncrementalTagUnmarshalText(t *testing.T) {
t.Run("invalid", func(t *testing.T) {
t.Parallel()
var got AccessEntryIncrementalTag
var got AccessReviewEntryDecision
if err := got.UnmarshalText([]byte("BOGUS")); err == nil {
t.Fatal("UnmarshalText(BOGUS) expected error")
}
})
}
func TestAccessEntryIncrementalTagMarshalText(t *testing.T) {
func TestAccessReviewEntryDecisionMarshalText(t *testing.T) {
t.Parallel()
for _, value := range AccessEntryIncrementalTags() {
for _, value := range AccessReviewEntryDecisions() {
t.Run(string(value), func(t *testing.T) {
t.Parallel()

View File

@@ -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{}
}

View 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
}

View File

@@ -16,28 +16,28 @@ package coredata
import "testing"
func TestAccessEntryDecisionIsValid(t *testing.T) {
func TestAccessReviewEntryFlagIsValid(t *testing.T) {
t.Parallel()
for _, value := range AccessEntryDecisions() {
for _, value := range AccessReviewEntryFlags() {
if !value.IsValid() {
t.Fatalf("IsValid() = false for %q", value)
}
}
if AccessEntryDecision("BOGUS").IsValid() {
if AccessReviewEntryFlag("BOGUS").IsValid() {
t.Fatal("IsValid() = true for invalid value")
}
}
func TestAccessEntryDecisionUnmarshalText(t *testing.T) {
func TestAccessReviewEntryFlagUnmarshalText(t *testing.T) {
t.Parallel()
for _, value := range AccessEntryDecisions() {
for _, value := range AccessReviewEntryFlags() {
t.Run(string(value), func(t *testing.T) {
t.Parallel()
var got AccessEntryDecision
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 TestAccessEntryDecisionUnmarshalText(t *testing.T) {
t.Run("invalid", func(t *testing.T) {
t.Parallel()
var got AccessEntryDecision
var got AccessReviewEntryFlag
if err := got.UnmarshalText([]byte("BOGUS")); err == nil {
t.Fatal("UnmarshalText(BOGUS) expected error")
}
})
}
func TestAccessEntryDecisionMarshalText(t *testing.T) {
func TestAccessReviewEntryFlagMarshalText(t *testing.T) {
t.Parallel()
for _, value := range AccessEntryDecisions() {
for _, value := range AccessReviewEntryFlags() {
t.Run(string(value), func(t *testing.T) {
t.Parallel()

View 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
}

View 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())
}
})
}
}

View File

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

View 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)
}

View File

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

View 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
}

View File

@@ -16,28 +16,28 @@ package coredata
import "testing"
func TestAccessEntryAccountTypeIsValid(t *testing.T) {
func TestAccessReviewSourceCategoryIsValid(t *testing.T) {
t.Parallel()
for _, value := range AccessEntryAccountTypes() {
for _, value := range AccessReviewSourceCategories() {
if !value.IsValid() {
t.Fatalf("IsValid() = false for %q", value)
}
}
if AccessEntryAccountType("BOGUS").IsValid() {
if AccessReviewSourceCategory("BOGUS").IsValid() {
t.Fatal("IsValid() = true for invalid value")
}
}
func TestAccessEntryAccountTypeUnmarshalText(t *testing.T) {
func TestAccessReviewSourceCategoryUnmarshalText(t *testing.T) {
t.Parallel()
for _, value := range AccessEntryAccountTypes() {
for _, value := range AccessReviewSourceCategories() {
t.Run(string(value), func(t *testing.T) {
t.Parallel()
var got AccessEntryAccountType
var got AccessReviewSourceCategory
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 AccessReviewSourceCategory
if err := got.UnmarshalText([]byte("BOGUS")); err == nil {
t.Fatal("UnmarshalText(BOGUS) expected error")
}
})
}
func TestAccessEntryAccountTypeMarshalText(t *testing.T) {
func TestAccessReviewSourceCategoryMarshalText(t *testing.T) {
t.Parallel()
for _, value := range AccessEntryAccountTypes() {
for _, value := range AccessReviewSourceCategories() {
t.Run(string(value), func(t *testing.T) {
t.Parallel()

View File

@@ -22,48 +22,48 @@ import (
)
type (
AccessSourceOrderField string
AccessReviewSourceOrderField string
)
const (
AccessSourceOrderFieldCreatedAt AccessSourceOrderField = "CREATED_AT"
AccessReviewSourceOrderFieldCreatedAt AccessReviewSourceOrderField = "CREATED_AT"
)
var (
_ page.OrderField = AccessSourceOrderField("")
_ fmt.Stringer = AccessSourceOrderField("")
_ encoding.TextMarshaler = AccessSourceOrderField("")
_ encoding.TextUnmarshaler = (*AccessSourceOrderField)(nil)
_ page.OrderField = AccessReviewSourceOrderField("")
_ fmt.Stringer = AccessReviewSourceOrderField("")
_ encoding.TextMarshaler = AccessReviewSourceOrderField("")
_ encoding.TextUnmarshaler = (*AccessReviewSourceOrderField)(nil)
)
func AccessSourceOrderFields() []AccessSourceOrderField {
return []AccessSourceOrderField{
AccessSourceOrderFieldCreatedAt,
func AccessReviewSourceOrderFields() []AccessReviewSourceOrderField {
return []AccessReviewSourceOrderField{
AccessReviewSourceOrderFieldCreatedAt,
}
}
func (v AccessSourceOrderField) IsValid() bool {
func (v AccessReviewSourceOrderField) IsValid() bool {
switch v {
case
AccessSourceOrderFieldCreatedAt:
AccessReviewSourceOrderFieldCreatedAt:
return true
}
return false
}
func (v AccessSourceOrderField) String() string {
func (v AccessReviewSourceOrderField) String() string {
return string(v)
}
func (v AccessSourceOrderField) MarshalText() ([]byte, error) {
func (v AccessReviewSourceOrderField) MarshalText() ([]byte, error) {
return []byte(v.String()), nil
}
func (v *AccessSourceOrderField) UnmarshalText(text []byte) error {
val := AccessSourceOrderField(text)
func (v *AccessReviewSourceOrderField) UnmarshalText(text []byte) error {
val := AccessReviewSourceOrderField(text)
if !val.IsValid() {
return fmt.Errorf("invalid AccessSourceOrderField value: %q", string(text))
return fmt.Errorf("invalid AccessReviewSourceOrderField value: %q", string(text))
}
*v = val
@@ -71,9 +71,9 @@ func (v *AccessSourceOrderField) UnmarshalText(text []byte) error {
return nil
}
func (p AccessSourceOrderField) Column() string {
func (p AccessReviewSourceOrderField) Column() string {
switch p {
case AccessSourceOrderFieldCreatedAt:
case AccessReviewSourceOrderFieldCreatedAt:
return "created_at"
}

View File

@@ -24,14 +24,14 @@ import (
"go.probo.inc/probo/pkg/gid"
)
type AccessEntryStatistics struct {
type AccessReviewStatistics struct {
TotalCount int
DecisionCounts map[AccessEntryDecision]int
FlagCounts map[AccessEntryFlag]int
IncrementalTagCounts map[AccessEntryIncrementalTag]int
DecisionCounts map[AccessReviewEntryDecision]int
FlagCounts map[AccessReviewEntryFlag]int
IncrementalTagCounts map[AccessReviewEntryIncrementalTag]int
}
func (s *AccessEntryStatistics) LoadByCampaignID(
func (s *AccessReviewStatistics) LoadByCampaignID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
@@ -40,14 +40,14 @@ func (s *AccessEntryStatistics) LoadByCampaignID(
args := pgx.StrictNamedArgs{"campaign_id": campaignID}
maps.Copy(args, scope.SQLArguments())
s.DecisionCounts = make(map[AccessEntryDecision]int)
s.FlagCounts = make(map[AccessEntryFlag]int)
s.IncrementalTagCounts = make(map[AccessEntryIncrementalTag]int)
s.DecisionCounts = make(map[AccessReviewEntryDecision]int)
s.FlagCounts = make(map[AccessReviewEntryFlag]int)
s.IncrementalTagCounts = make(map[AccessReviewEntryIncrementalTag]int)
s.TotalCount = 0
q := `
SELECT decision, COUNT(*) as count
FROM access_entries
FROM access_review_entries
WHERE
%s
AND access_review_campaign_id = @campaign_id
@@ -63,7 +63,7 @@ GROUP BY decision;
for rows.Next() {
var (
decision AccessEntryDecision
decision AccessReviewEntryDecision
count int
)
@@ -81,7 +81,7 @@ GROUP BY decision;
q = `
SELECT f, COUNT(*) as count
FROM access_entries, unnest(flags) AS f
FROM access_review_entries, unnest(flags) AS f
WHERE
%s
AND access_review_campaign_id = @campaign_id
@@ -97,7 +97,7 @@ GROUP BY f;
for rows.Next() {
var (
flag AccessEntryFlag
flag AccessReviewEntryFlag
count int
)
@@ -114,7 +114,7 @@ GROUP BY f;
q = `
SELECT incremental_tag, COUNT(*) as count
FROM access_entries
FROM access_review_entries
WHERE
%s
AND access_review_campaign_id = @campaign_id
@@ -130,7 +130,7 @@ GROUP BY incremental_tag;
for rows.Next() {
var (
tag AccessEntryIncrementalTag
tag AccessReviewEntryIncrementalTag
count int
)
@@ -148,7 +148,7 @@ GROUP BY incremental_tag;
return nil
}
func (s *AccessEntryStatistics) LoadByCampaignIDAndSourceID(
func (s *AccessReviewStatistics) LoadByCampaignIDAndSourceID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
@@ -161,18 +161,18 @@ func (s *AccessEntryStatistics) LoadByCampaignIDAndSourceID(
}
maps.Copy(args, scope.SQLArguments())
s.DecisionCounts = make(map[AccessEntryDecision]int)
s.FlagCounts = make(map[AccessEntryFlag]int)
s.IncrementalTagCounts = make(map[AccessEntryIncrementalTag]int)
s.DecisionCounts = make(map[AccessReviewEntryDecision]int)
s.FlagCounts = make(map[AccessReviewEntryFlag]int)
s.IncrementalTagCounts = make(map[AccessReviewEntryIncrementalTag]int)
s.TotalCount = 0
q := `
SELECT decision, COUNT(*) as count
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
GROUP BY decision;
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -185,7 +185,7 @@ GROUP BY decision;
for rows.Next() {
var (
decision AccessEntryDecision
decision AccessReviewEntryDecision
count int
)
@@ -203,11 +203,11 @@ GROUP BY decision;
q = `
SELECT f, COUNT(*) as count
FROM access_entries, unnest(flags) AS f
FROM access_review_entries, unnest(flags) AS f
WHERE
%s
AND access_review_campaign_id = @campaign_id
AND access_source_id = @source_id
AND access_review_campaign_source_id = @source_id
GROUP BY f;
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -220,7 +220,7 @@ GROUP BY f;
for rows.Next() {
var (
flag AccessEntryFlag
flag AccessReviewEntryFlag
count int
)
@@ -237,11 +237,11 @@ GROUP BY f;
q = `
SELECT incremental_tag, COUNT(*) as count
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
GROUP BY incremental_tag;
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -254,7 +254,7 @@ GROUP BY incremental_tag;
for rows.Next() {
var (
tag AccessEntryIncrementalTag
tag AccessReviewEntryIncrementalTag
count int
)

View File

@@ -1,76 +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 AccessSourceCategory string
const (
AccessSourceCategorySaaS AccessSourceCategory = "SAAS"
AccessSourceCategoryCloudInfra AccessSourceCategory = "CLOUD_INFRA"
AccessSourceCategorySourceCode AccessSourceCategory = "SOURCE_CODE"
AccessSourceCategoryOther AccessSourceCategory = "OTHER"
)
var (
_ fmt.Stringer = AccessSourceCategory("")
_ encoding.TextMarshaler = AccessSourceCategory("")
_ encoding.TextUnmarshaler = (*AccessSourceCategory)(nil)
)
func AccessSourceCategories() []AccessSourceCategory {
return []AccessSourceCategory{
AccessSourceCategorySaaS,
AccessSourceCategoryCloudInfra,
AccessSourceCategorySourceCode,
AccessSourceCategoryOther,
}
}
func (v AccessSourceCategory) IsValid() bool {
switch v {
case
AccessSourceCategorySaaS,
AccessSourceCategoryCloudInfra,
AccessSourceCategorySourceCode,
AccessSourceCategoryOther:
return true
}
return false
}
func (v AccessSourceCategory) String() string {
return string(v)
}
func (v AccessSourceCategory) MarshalText() ([]byte, error) {
return []byte(v.String()), nil
}
func (v *AccessSourceCategory) UnmarshalText(text []byte) error {
val := AccessSourceCategory(text)
if !val.IsValid() {
return fmt.Errorf("invalid AccessSourceCategory value: %q", string(text))
}
*v = val
return nil
}

View File

@@ -1,78 +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 "testing"
func TestAccessSourceCategoryIsValid(t *testing.T) {
t.Parallel()
for _, value := range AccessSourceCategories() {
if !value.IsValid() {
t.Fatalf("IsValid() = false for %q", value)
}
}
if AccessSourceCategory("BOGUS").IsValid() {
t.Fatal("IsValid() = true for invalid value")
}
}
func TestAccessSourceCategoryUnmarshalText(t *testing.T) {
t.Parallel()
for _, value := range AccessSourceCategories() {
t.Run(string(value), func(t *testing.T) {
t.Parallel()
var got AccessSourceCategory
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 AccessSourceCategory
if err := got.UnmarshalText([]byte("BOGUS")); err == nil {
t.Fatal("UnmarshalText(BOGUS) expected error")
}
})
}
func TestAccessSourceCategoryMarshalText(t *testing.T) {
t.Parallel()
for _, value := range AccessSourceCategories() {
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())
}
})
}
}

View File

@@ -19,58 +19,58 @@ import (
"fmt"
)
type AccessEntryAuthMethod string
type AccessReviewEntryAuthMethod string
const (
AccessEntryAuthMethodSSO AccessEntryAuthMethod = "SSO"
AccessEntryAuthMethodPassword AccessEntryAuthMethod = "PASSWORD"
AccessEntryAuthMethodAPIKey AccessEntryAuthMethod = "API_KEY"
AccessEntryAuthMethodServiceAccount AccessEntryAuthMethod = "SERVICE_ACCOUNT"
AccessEntryAuthMethodUnknown AccessEntryAuthMethod = "UNKNOWN"
AccessReviewEntryAuthMethodSSO AccessReviewEntryAuthMethod = "SSO"
AccessReviewEntryAuthMethodPassword AccessReviewEntryAuthMethod = "PASSWORD"
AccessReviewEntryAuthMethodAPIKey AccessReviewEntryAuthMethod = "API_KEY"
AccessReviewEntryAuthMethodServiceAccount AccessReviewEntryAuthMethod = "SERVICE_ACCOUNT"
AccessReviewEntryAuthMethodUnknown AccessReviewEntryAuthMethod = "UNKNOWN"
)
var (
_ fmt.Stringer = AccessEntryAuthMethod("")
_ encoding.TextMarshaler = AccessEntryAuthMethod("")
_ encoding.TextUnmarshaler = (*AccessEntryAuthMethod)(nil)
_ fmt.Stringer = AccessReviewEntryAuthMethod("")
_ encoding.TextMarshaler = AccessReviewEntryAuthMethod("")
_ encoding.TextUnmarshaler = (*AccessReviewEntryAuthMethod)(nil)
)
func AccessEntryAuthMethods() []AccessEntryAuthMethod {
return []AccessEntryAuthMethod{
AccessEntryAuthMethodSSO,
AccessEntryAuthMethodPassword,
AccessEntryAuthMethodAPIKey,
AccessEntryAuthMethodServiceAccount,
AccessEntryAuthMethodUnknown,
func AccessReviewEntryAuthMethods() []AccessReviewEntryAuthMethod {
return []AccessReviewEntryAuthMethod{
AccessReviewEntryAuthMethodSSO,
AccessReviewEntryAuthMethodPassword,
AccessReviewEntryAuthMethodAPIKey,
AccessReviewEntryAuthMethodServiceAccount,
AccessReviewEntryAuthMethodUnknown,
}
}
func (v AccessEntryAuthMethod) IsValid() bool {
func (v AccessReviewEntryAuthMethod) IsValid() bool {
switch v {
case
AccessEntryAuthMethodSSO,
AccessEntryAuthMethodPassword,
AccessEntryAuthMethodAPIKey,
AccessEntryAuthMethodServiceAccount,
AccessEntryAuthMethodUnknown:
AccessReviewEntryAuthMethodSSO,
AccessReviewEntryAuthMethodPassword,
AccessReviewEntryAuthMethodAPIKey,
AccessReviewEntryAuthMethodServiceAccount,
AccessReviewEntryAuthMethodUnknown:
return true
}
return false
}
func (v AccessEntryAuthMethod) String() string {
func (v AccessReviewEntryAuthMethod) String() string {
return string(v)
}
func (v AccessEntryAuthMethod) MarshalText() ([]byte, error) {
func (v AccessReviewEntryAuthMethod) MarshalText() ([]byte, error) {
return []byte(v.String()), nil
}
func (v *AccessEntryAuthMethod) UnmarshalText(text []byte) error {
val := AccessEntryAuthMethod(text)
func (v *AccessReviewEntryAuthMethod) UnmarshalText(text []byte) error {
val := AccessReviewEntryAuthMethod(text)
if !val.IsValid() {
return fmt.Errorf("invalid AccessEntryAuthMethod value: %q", string(text))
return fmt.Errorf("invalid AccessReviewEntryAuthMethod value: %q", string(text))
}
*v = val

View File

@@ -109,7 +109,7 @@ type (
// DatadogConnectorSettings holds the per-customer Datadog site captured
// during the OAuth callback. Region is the site key (e.g. "US3") used for
// the AccessSource title; Domain is the API domain (e.g.
// the AccessReviewSource title; Domain is the API domain (e.g.
// "us3.datadoghq.com") the driver and name resolver use to build hosts.
DatadogConnectorSettings struct {
Region string `json:"region"`
@@ -132,7 +132,7 @@ type (
// redirect, and it rides the signed state token to the callback —
// Zendesk does not echo it back). Subdomain is the <subdomain> part of
// <subdomain>.zendesk.com, used by the driver to build the API host and
// by the name resolver for the AccessSource title.
// by the name resolver for the AccessReviewSource title.
ZendeskConnectorSettings struct {
Subdomain string `json:"subdomain"`
}

View File

@@ -23,108 +23,110 @@ var (
)
const (
OrganizationEntityType uint16 = 0
FrameworkEntityType uint16 = 1
MeasureEntityType uint16 = 2
TaskEntityType uint16 = 3
EvidenceEntityType uint16 = 4
ConnectorEntityType uint16 = 5
ThirdPartyRiskAssessmentEntityType uint16 = 6
ThirdPartyEntityType uint16 = 7
_ uint16 = 8 // PeopleEntityType - removed
ThirdPartyComplianceReportEntityType uint16 = 9
DocumentEntityType uint16 = 10
IdentityEntityType uint16 = 11
SessionEntityType uint16 = 12
EmailEntityType uint16 = 13
ControlEntityType uint16 = 14
RiskEntityType uint16 = 15
DocumentVersionEntityType uint16 = 16
DocumentVersionSignatureEntityType uint16 = 17
AssetEntityType uint16 = 18
DatumEntityType uint16 = 19
AuditEntityType uint16 = 20
_ uint16 = 21 // ReportEntityType - removed
TrustCenterEntityType uint16 = 22
TrustCenterAccessEntityType uint16 = 23
ThirdPartyBusinessAssociateAgreementEntityType uint16 = 24
FileEntityType uint16 = 25
ThirdPartyContactEntityType uint16 = 26
ThirdPartyDataPrivacyAgreementEntityType uint16 = 27
_ uint16 = 28 // NonconformityEntityType - removed
ObligationEntityType uint16 = 29
ThirdPartyServiceEntityType uint16 = 30
_ uint16 = 31 // SnapshotEntityType - removed
_ uint16 = 32 // ContinualImprovementEntityType - removed
ProcessingActivityEntityType uint16 = 33
ExportJobEntityType uint16 = 34
TrustCenterReferenceEntityType uint16 = 35
TrustCenterDocumentAccessEntityType uint16 = 36
CustomDomainEntityType uint16 = 37
InvitationEntityType uint16 = 38
MembershipEntityType uint16 = 39
SlackMessageEntityType uint16 = 40
TrustCenterFileEntityType uint16 = 41
SAMLConfigurationEntityType uint16 = 42
PersonalAPIKeyEntityType uint16 = 43
_ uint16 = 44 // PersonalAPIKeyMembershipEntityType - removed
_ uint16 = 45 // MeetingEntityType - removed
DataProtectionImpactAssessmentEntityType uint16 = 46
TransferImpactAssessmentEntityType uint16 = 47
RightsRequestEntityType uint16 = 48
StatementOfApplicabilityEntityType uint16 = 49
ApplicabilityStatementEntityType uint16 = 50
MembershipProfileEntityType uint16 = 51
SCIMConfigurationEntityType uint16 = 52
SCIMEventEntityType uint16 = 53
TokenEntityType uint16 = 54
SCIMBridgeEntityType uint16 = 55
WebhookSubscriptionEntityType uint16 = 56
WebhookDataEntityType uint16 = 57
WebhookEventEntityType uint16 = 58
ElectronicSignatureEntityType uint16 = 59
ElectronicSignatureEventEntityType uint16 = 60
EmailAttachmentEntityType uint16 = 61
ComplianceFrameworkEntityType uint16 = 62
ComplianceExternalURLEntityType uint16 = 63
MailingListEntityType uint16 = 64
MailingListSubscriberEntityType uint16 = 65
MailingListUpdateEntityType uint16 = 66
FindingEntityType uint16 = 67
AuditLogEntryEntityType uint16 = 68
DocumentVersionApprovalQuorumEntityType uint16 = 69
DocumentVersionApprovalDecisionEntityType uint16 = 70
AccessSourceEntityType uint16 = 71
AccessReviewCampaignEntityType uint16 = 72
AccessEntryEntityType uint16 = 73
AccessEntryDecisionHistoryEntityType uint16 = 74
CookieBannerEntityType uint16 = 75
CookieCategoryEntityType uint16 = 76
CookieConsentRecordEntityType uint16 = 77
CookieBannerVersionEntityType uint16 = 78
OAuth2ClientEntityType uint16 = 79
OAuth2ConsentEntityType uint16 = 80
OAuth2AccessTokenEntityType uint16 = 81
OAuth2RefreshTokenEntityType uint16 = 82
OAuth2AuthorizationCodeEntityType uint16 = 83
OAuth2DeviceCodeEntityType uint16 = 84
_ uint16 = 85 // CookieEntityType - removed
CookieBannerTranslationEntityType uint16 = 86
AgentRunEntityType uint16 = 87
_ uint16 = 88 // CookiePatternEntityType - removed
TrackerPatternEntityType uint16 = 89
DetectedTrackerEntityType uint16 = 90
TrackerResourceEntityType uint16 = 91
CommonThirdPartyEntityType uint16 = 92
CommonThirdPartyDomainEntityType uint16 = 93
CommonTrackerPatternEntityType uint16 = 94
RiskAssessmentEntityType uint16 = 95
RiskAssessmentNodeEntityType uint16 = 96
RiskAssessmentProcessEntityType uint16 = 97
RiskAssessmentThreatEntityType uint16 = 98
RiskAssessmentScopeEntityType uint16 = 99
RiskAssessmentScenarioEntityType uint16 = 100
RiskAssessmentBoundaryEntityType uint16 = 101
OrganizationEntityType uint16 = 0
FrameworkEntityType uint16 = 1
MeasureEntityType uint16 = 2
TaskEntityType uint16 = 3
EvidenceEntityType uint16 = 4
ConnectorEntityType uint16 = 5
ThirdPartyRiskAssessmentEntityType uint16 = 6
ThirdPartyEntityType uint16 = 7
_ uint16 = 8 // PeopleEntityType - removed
ThirdPartyComplianceReportEntityType uint16 = 9
DocumentEntityType uint16 = 10
IdentityEntityType uint16 = 11
SessionEntityType uint16 = 12
EmailEntityType uint16 = 13
ControlEntityType uint16 = 14
RiskEntityType uint16 = 15
DocumentVersionEntityType uint16 = 16
DocumentVersionSignatureEntityType uint16 = 17
AssetEntityType uint16 = 18
DatumEntityType uint16 = 19
AuditEntityType uint16 = 20
_ uint16 = 21 // ReportEntityType - removed
TrustCenterEntityType uint16 = 22
TrustCenterAccessEntityType uint16 = 23
ThirdPartyBusinessAssociateAgreementEntityType uint16 = 24
FileEntityType uint16 = 25
ThirdPartyContactEntityType uint16 = 26
ThirdPartyDataPrivacyAgreementEntityType uint16 = 27
_ uint16 = 28 // NonconformityEntityType - removed
ObligationEntityType uint16 = 29
ThirdPartyServiceEntityType uint16 = 30
_ uint16 = 31 // SnapshotEntityType - removed
_ uint16 = 32 // ContinualImprovementEntityType - removed
ProcessingActivityEntityType uint16 = 33
ExportJobEntityType uint16 = 34
TrustCenterReferenceEntityType uint16 = 35
TrustCenterDocumentAccessEntityType uint16 = 36
CustomDomainEntityType uint16 = 37
InvitationEntityType uint16 = 38
MembershipEntityType uint16 = 39
SlackMessageEntityType uint16 = 40
TrustCenterFileEntityType uint16 = 41
SAMLConfigurationEntityType uint16 = 42
PersonalAPIKeyEntityType uint16 = 43
_ uint16 = 44 // PersonalAPIKeyMembershipEntityType - removed
_ uint16 = 45 // MeetingEntityType - removed
DataProtectionImpactAssessmentEntityType uint16 = 46
TransferImpactAssessmentEntityType uint16 = 47
RightsRequestEntityType uint16 = 48
StatementOfApplicabilityEntityType uint16 = 49
ApplicabilityStatementEntityType uint16 = 50
MembershipProfileEntityType uint16 = 51
SCIMConfigurationEntityType uint16 = 52
SCIMEventEntityType uint16 = 53
TokenEntityType uint16 = 54
SCIMBridgeEntityType uint16 = 55
WebhookSubscriptionEntityType uint16 = 56
WebhookDataEntityType uint16 = 57
WebhookEventEntityType uint16 = 58
ElectronicSignatureEntityType uint16 = 59
ElectronicSignatureEventEntityType uint16 = 60
EmailAttachmentEntityType uint16 = 61
ComplianceFrameworkEntityType uint16 = 62
ComplianceExternalURLEntityType uint16 = 63
MailingListEntityType uint16 = 64
MailingListSubscriberEntityType uint16 = 65
MailingListUpdateEntityType uint16 = 66
FindingEntityType uint16 = 67
AuditLogEntryEntityType uint16 = 68
DocumentVersionApprovalQuorumEntityType uint16 = 69
DocumentVersionApprovalDecisionEntityType uint16 = 70
AccessReviewSourceEntityType uint16 = 71
AccessReviewCampaignEntityType uint16 = 72
AccessReviewEntryEntityType uint16 = 73
AccessReviewEntryDecisionHistoryEntityType uint16 = 74
CookieBannerEntityType uint16 = 75
CookieCategoryEntityType uint16 = 76
CookieConsentRecordEntityType uint16 = 77
CookieBannerVersionEntityType uint16 = 78
OAuth2ClientEntityType uint16 = 79
OAuth2ConsentEntityType uint16 = 80
OAuth2AccessTokenEntityType uint16 = 81
OAuth2RefreshTokenEntityType uint16 = 82
OAuth2AuthorizationCodeEntityType uint16 = 83
OAuth2DeviceCodeEntityType uint16 = 84
_ uint16 = 85 // CookieEntityType - removed
CookieBannerTranslationEntityType uint16 = 86
AgentRunEntityType uint16 = 87
_ uint16 = 88 // CookiePatternEntityType - removed
TrackerPatternEntityType uint16 = 89
DetectedTrackerEntityType uint16 = 90
TrackerResourceEntityType uint16 = 91
CommonThirdPartyEntityType uint16 = 92
CommonThirdPartyDomainEntityType uint16 = 93
CommonTrackerPatternEntityType uint16 = 94
RiskAssessmentEntityType uint16 = 95
RiskAssessmentNodeEntityType uint16 = 96
RiskAssessmentProcessEntityType uint16 = 97
RiskAssessmentThreatEntityType uint16 = 98
RiskAssessmentScopeEntityType uint16 = 99
RiskAssessmentScenarioEntityType uint16 = 100
RiskAssessmentBoundaryEntityType uint16 = 101
AccessReviewCampaignSourceEntityType uint16 = 102
AccessReviewCampaignSourceFetchAttemptEntityType uint16 = 103
)
func NewEntityFromID(id gid.GID) (any, bool) {
@@ -257,14 +259,14 @@ func NewEntityFromID(id gid.GID) (any, bool) {
return &DocumentVersionApprovalDecision{ID: id}, true
case DocumentVersionApprovalQuorumEntityType:
return &DocumentVersionApprovalQuorum{ID: id}, true
case AccessSourceEntityType:
return &AccessSource{ID: id}, true
case AccessReviewSourceEntityType:
return &AccessReviewSource{ID: id}, true
case AccessReviewCampaignEntityType:
return &AccessReviewCampaign{ID: id}, true
case AccessEntryEntityType:
return &AccessEntry{ID: id}, true
case AccessEntryDecisionHistoryEntityType:
return &AccessEntryDecisionHistory{ID: id}, true
case AccessReviewEntryEntityType:
return &AccessReviewEntry{ID: id}, true
case AccessReviewEntryDecisionHistoryEntityType:
return &AccessReviewEntryDecisionHistory{ID: id}, true
case CookieBannerEntityType:
return &CookieBanner{ID: id}, true
case CookieCategoryEntityType:
@@ -315,6 +317,10 @@ func NewEntityFromID(id gid.GID) (any, bool) {
return &RiskAssessmentScenario{ID: id}, true
case RiskAssessmentBoundaryEntityType:
return &RiskAssessmentBoundary{ID: id}, true
case AccessReviewCampaignSourceEntityType:
return &AccessReviewCampaignSource{ID: id}, true
case AccessReviewCampaignSourceFetchAttemptEntityType:
return &AccessReviewCampaignSourceFetchAttempt{ID: id}, true
default:
return nil, false
}

View File

@@ -0,0 +1,151 @@
-- 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.
-- Access review: decouple campaign data from live sources.
--
-- Introduces a per-campaign source snapshot (access_review_campaign_sources)
-- that owns the source identity (name/category/connector) so a review survives
-- the deletion of the live access source. Fetch tracking becomes an append-only
-- log (access_review_campaign_source_fetch_attempts) so each fetch run keeps its own
-- error. Access entries are repointed from the live source to the snapshot.
-- Helper to mint a valid GID (base64url of: tenant 8B | entity type 2B |
-- timestamp 8B | random 6B) for back-filled rows.
CREATE FUNCTION pg_temp.gen_gid(tenant_text text, entity_type int) RETURNS text AS $$
SELECT translate(
encode(
decode(rpad(translate(tenant_text, '-_', '+/'), 12, '='), 'base64')
|| set_byte(set_byte('\x0000'::bytea, 0, (entity_type >> 8) & 255), 1, entity_type & 255)
|| int8send((extract(epoch FROM clock_timestamp()) * 1000)::bigint)
|| substring(uuid_send(gen_random_uuid()) FROM 1 FOR 6),
'base64'),
'+/', '-_')
$$ LANGUAGE sql VOLATILE;
-- 1. Per-campaign source snapshot. The live access source link is nullable and
-- ON DELETE SET NULL so deleting a source preserves the review's snapshot.
CREATE TABLE access_review_campaign_sources (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
access_review_campaign_id TEXT NOT NULL REFERENCES access_review_campaigns(id) ON DELETE CASCADE,
access_source_id TEXT REFERENCES access_sources(id) ON DELETE SET NULL,
name TEXT NOT NULL,
category access_source_category NOT NULL,
connector_id TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
UNIQUE (access_review_campaign_id, access_source_id)
);
-- 2. Append-only fetch attempts. Each run is a new row; the snapshot's current
-- state is the latest attempt.
CREATE TABLE access_review_campaign_source_fetch_attempts (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
access_review_campaign_source_id TEXT NOT NULL REFERENCES access_review_campaign_sources(id) ON DELETE CASCADE,
attempt_number INTEGER NOT NULL,
status access_review_campaign_source_fetch_status NOT NULL,
fetched_accounts_count INTEGER NOT NULL,
error TEXT,
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
UNIQUE (access_review_campaign_source_id, attempt_number)
);
CREATE INDEX idx_fetch_attempts_queued
ON access_review_campaign_source_fetch_attempts (created_at)
WHERE status = 'QUEUED';
-- 3. Back-fill snapshots from every (campaign, source) pair already present in
-- the scope, entries, or fetches.
INSERT INTO access_review_campaign_sources (
id, tenant_id, access_review_campaign_id, access_source_id,
name, category, connector_id, created_at, updated_at
)
SELECT
pg_temp.gen_gid(s.tenant_id, 102),
s.tenant_id,
p.access_review_campaign_id,
p.access_source_id,
s.name,
s.category,
s.connector_id,
now(),
now()
FROM (
SELECT access_review_campaign_id, access_source_id
FROM access_review_campaign_scope_systems
UNION
SELECT access_review_campaign_id, access_source_id
FROM access_entries
UNION
SELECT access_review_campaign_id, access_source_id
FROM access_review_campaign_source_fetches
) p
JOIN access_sources s ON s.id = p.access_source_id;
-- 4. Repoint access entries at the snapshot.
ALTER TABLE access_entries
ADD COLUMN access_review_campaign_source_id TEXT;
UPDATE access_entries e
SET access_review_campaign_source_id = cs.id
FROM access_review_campaign_sources cs
WHERE cs.access_review_campaign_id = e.access_review_campaign_id
AND cs.access_source_id = e.access_source_id;
ALTER TABLE access_entries
ALTER COLUMN access_review_campaign_source_id SET NOT NULL;
ALTER TABLE access_entries
ADD CONSTRAINT access_entries_campaign_source_id_fkey
FOREIGN KEY (access_review_campaign_source_id)
REFERENCES access_review_campaign_sources(id) ON DELETE CASCADE;
DROP INDEX idx_access_entries_campaign_source_account_key;
CREATE UNIQUE INDEX idx_access_entries_campaign_source_account_key
ON access_entries (access_review_campaign_source_id, account_key);
ALTER TABLE access_entries
DROP COLUMN access_source_id;
-- 5. Migrate fetch rows into the append-only attempt log (one attempt each).
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
)
SELECT
pg_temp.gen_gid(f.tenant_id, 103),
f.tenant_id,
cs.id,
GREATEST(f.attempt_count, 1),
f.status,
f.fetched_accounts_count,
f.last_error,
f.started_at,
f.completed_at,
f.created_at,
f.updated_at
FROM access_review_campaign_source_fetches f
JOIN access_review_campaign_sources cs
ON cs.access_review_campaign_id = f.access_review_campaign_id
AND cs.access_source_id = f.access_source_id;
-- 6. Drop the superseded tables.
DROP TABLE access_review_campaign_source_fetches;
DROP TABLE access_review_campaign_scope_systems;

View File

@@ -0,0 +1,84 @@
-- 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.
-- Normalize access-review table, column, and enum names.
-- 1. Enum types
ALTER TYPE access_source_category RENAME TO access_review_source_category;
ALTER TYPE access_entry_decision RENAME TO access_review_entry_decision;
ALTER TYPE access_entry_incremental_tag RENAME TO access_review_entry_incremental_tag;
ALTER TYPE access_entry_flag RENAME TO access_review_entry_flag;
-- 2. Live sources
ALTER TABLE access_sources RENAME TO access_review_sources;
ALTER TABLE access_review_sources
RENAME CONSTRAINT access_sources_pkey TO access_review_sources_pkey;
ALTER TABLE access_review_sources
RENAME CONSTRAINT access_sources_organization_id_fkey TO access_review_sources_organization_id_fkey;
ALTER TABLE access_review_sources
RENAME CONSTRAINT access_sources_connector_id_fkey TO access_review_sources_connector_id_fkey;
-- 3. Campaign source snapshots: live-source FK column
ALTER TABLE access_review_campaign_sources
RENAME COLUMN access_source_id TO access_review_source_id;
ALTER TABLE access_review_campaign_sources
RENAME CONSTRAINT access_review_campaign_sources_access_source_id_fkey
TO access_review_campaign_sources_access_review_source_id_fkey;
ALTER TABLE access_review_campaign_sources
DROP CONSTRAINT IF EXISTS access_review_campaign_sources_access_review_campaign_id_access_sour_key;
ALTER TABLE access_review_campaign_sources
DROP CONSTRAINT IF EXISTS access_review_campaign_sources_access_review_campaign_id_access_source_id_key;
ALTER TABLE access_review_campaign_sources
ADD CONSTRAINT access_review_campaign_sources_campaign_source_unique
UNIQUE (access_review_campaign_id, access_review_source_id);
-- 4. Entries
ALTER TABLE access_entries RENAME TO access_review_entries;
ALTER TABLE access_review_entries
RENAME CONSTRAINT access_entries_pkey TO access_review_entries_pkey;
ALTER TABLE access_review_entries
RENAME CONSTRAINT access_entries_access_review_campaign_id_fkey
TO access_review_entries_access_review_campaign_id_fkey;
ALTER TABLE access_review_entries
RENAME CONSTRAINT access_entries_identity_id_fkey
TO access_review_entries_identity_id_fkey;
ALTER TABLE access_review_entries
RENAME CONSTRAINT access_entries_organization_id_fkey
TO access_review_entries_organization_id_fkey;
ALTER TABLE access_review_entries
RENAME CONSTRAINT access_entries_campaign_source_id_fkey
TO access_review_entries_campaign_source_id_fkey;
ALTER INDEX idx_access_entries_campaign_source_account_key
RENAME TO idx_access_review_entries_campaign_source_account_key;
-- 5. Decision history
ALTER TABLE access_entry_decision_history RENAME TO access_review_entry_decision_history;
ALTER TABLE access_review_entry_decision_history
RENAME CONSTRAINT access_entry_decision_history_pkey
TO access_review_entry_decision_history_pkey;
ALTER TABLE access_review_entry_decision_history
RENAME CONSTRAINT access_entry_decision_history_access_entry_id_fkey
TO access_review_entry_decision_history_access_review_entry_id_fkey;
ALTER TABLE access_review_entry_decision_history
RENAME CONSTRAINT access_entry_decision_history_organization_id_fkey
TO access_review_entry_decision_history_organization_id_fkey;
ALTER TABLE access_review_entry_decision_history
RENAME COLUMN access_entry_id TO access_review_entry_id;