Introduce access-review source snapshot and normalize naming

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

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

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

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

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

View File

@@ -0,0 +1,860 @@
// 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"
"go.probo.inc/probo/pkg/iam/policy"
"go.probo.inc/probo/pkg/page"
)
type (
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"`
}
AccessReviewEntries []*AccessReviewEntry
)
func (e AccessReviewEntry) CursorKey(orderBy AccessReviewEntryOrderField) page.CursorKey {
switch orderBy {
case AccessReviewEntryOrderFieldCreatedAt:
return page.NewCursorKey(e.ID, e.CreatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (e *AccessReviewEntry) AuthorizationAttributes(
ctx context.Context,
conn pg.Querier,
resourceIDs []gid.GID,
) (policy.AttributesByID, error) {
q := `SELECT id, organization_id FROM access_review_entries WHERE id = ANY(@resource_ids::text[])`
args := pgx.StrictNamedArgs{
"resource_ids": resourceIDs,
}
rows, err := conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("cannot query authorization attributes: %w", err)
}
defer rows.Close()
attrsByID := make(policy.AttributesByID)
for rows.Next() {
var id, organizationID gid.GID
if err := rows.Scan(&id, &organizationID); err != nil {
return nil, fmt.Errorf("cannot scan authorization attributes: %w", err)
}
attrsByID[id] = policy.Attributes{
"organization_id": organizationID.String(),
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err)
}
return attrsByID, nil
}
func (e *AccessReviewEntry) LoadByID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
id gid.GID,
) error {
q := `
SELECT
id,
organization_id,
access_review_campaign_id,
access_review_campaign_source_id,
identity_id,
email,
full_name,
role,
job_title,
is_admin,
mfa_status,
auth_method,
account_type,
active,
last_login,
account_created_at,
external_id,
account_key,
incremental_tag,
flags,
flag_reasons,
decision,
decision_note,
decided_by,
decided_at,
created_at,
updated_at
FROM
access_review_entries
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 access_review_entries: %w", err)
}
entry, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[AccessReviewEntry])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect access entry: %w", err)
}
*e = entry
return nil
}
func (e *AccessReviewEntry) Insert(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
INSERT INTO
access_review_entries (
id,
tenant_id,
organization_id,
access_review_campaign_id,
access_review_campaign_source_id,
identity_id,
email,
full_name,
role,
job_title,
is_admin,
mfa_status,
auth_method,
account_type,
active,
last_login,
account_created_at,
external_id,
account_key,
incremental_tag,
flags,
flag_reasons,
decision,
decision_note,
decided_by,
decided_at,
created_at,
updated_at
)
VALUES (
@id,
@tenant_id,
@organization_id,
@access_review_campaign_id,
@access_review_campaign_source_id,
@identity_id,
@email,
@full_name,
@role,
@job_title,
@is_admin,
@mfa_status,
@auth_method,
@account_type,
COALESCE(@active, TRUE),
@last_login,
@account_created_at,
@external_id,
@account_key,
@incremental_tag,
@flags,
@flag_reasons,
@decision,
@decision_note,
@decided_by,
@decided_at,
@created_at,
@updated_at
);
`
args := pgx.StrictNamedArgs{
"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)
if err != nil {
return fmt.Errorf("cannot insert access_entry: %w", err)
}
return nil
}
func (e *AccessReviewEntry) Update(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
UPDATE access_review_entries
SET
flags = @flags,
flag_reasons = @flag_reasons,
decision = @decision,
decision_note = @decision_note,
decided_by = @decided_by,
decided_at = @decided_at,
updated_at = @updated_at
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": e.ID,
"flags": e.Flags,
"flag_reasons": e.FlagReasons,
"decision": e.Decision,
"decision_note": e.DecisionNote,
"decided_by": e.DecidedBy,
"decided_at": e.DecidedAt,
"updated_at": e.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
result, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update access_entry: %w", err)
}
if result.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil
}
func (entries *AccessReviewEntries) LoadByCampaignID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
campaignID gid.GID,
cursor *page.Cursor[AccessReviewEntryOrderField],
filter *AccessReviewEntryFilter,
) error {
q := `
SELECT
id,
organization_id,
access_review_campaign_id,
access_review_campaign_source_id,
identity_id,
email,
full_name,
role,
job_title,
is_admin,
mfa_status,
auth_method,
account_type,
active,
last_login,
account_created_at,
external_id,
account_key,
incremental_tag,
flags,
flag_reasons,
decision,
decision_note,
decided_by,
decided_at,
created_at,
updated_at
FROM
access_review_entries
WHERE
%s
AND access_review_campaign_id = @campaign_id
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"campaign_id": campaignID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query access_review_entries: %w", err)
}
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewEntry])
if err != nil {
return fmt.Errorf("cannot collect access_review_entries: %w", err)
}
*entries = result
return nil
}
func (entries *AccessReviewEntries) LoadByCampaignIDAndSourceID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
campaignID gid.GID,
sourceID gid.GID,
cursor *page.Cursor[AccessReviewEntryOrderField],
filter *AccessReviewEntryFilter,
) error {
q := `
SELECT
id,
organization_id,
access_review_campaign_id,
access_review_campaign_source_id,
identity_id,
email,
full_name,
role,
job_title,
is_admin,
mfa_status,
auth_method,
account_type,
active,
last_login,
account_created_at,
external_id,
account_key,
incremental_tag,
flags,
flag_reasons,
decision,
decision_note,
decided_by,
decided_at,
created_at,
updated_at
FROM
access_review_entries
WHERE
%s
AND access_review_campaign_id = @campaign_id
AND access_review_campaign_source_id = @source_id
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"campaign_id": campaignID, "source_id": sourceID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query access_review_entries: %w", err)
}
result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[AccessReviewEntry])
if err != nil {
return fmt.Errorf("cannot collect access_review_entries: %w", err)
}
*entries = result
return nil
}
func (entries *AccessReviewEntries) CountByCampaignID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
campaignID gid.GID,
filter *AccessReviewEntryFilter,
) (int, error) {
q := `
SELECT COUNT(id)
FROM access_review_entries
WHERE
%s
AND access_review_campaign_id = @campaign_id
AND %s;
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{"campaign_id": campaignID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
var count int
if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil {
return 0, fmt.Errorf("cannot count access_review_entries: %w", err)
}
return count, nil
}
func (entries *AccessReviewEntries) CountByCampaignIDAndSourceID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
campaignID gid.GID,
sourceID gid.GID,
filter *AccessReviewEntryFilter,
) (int, error) {
q := `
SELECT COUNT(id)
FROM access_review_entries
WHERE
%s
AND access_review_campaign_id = @campaign_id
AND access_review_campaign_source_id = @source_id
AND %s;
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{"campaign_id": campaignID, "source_id": sourceID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
var count int
if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil {
return 0, fmt.Errorf("cannot count access_review_entries: %w", err)
}
return count, nil
}
func (entries *AccessReviewEntries) CountPendingByCampaignID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
campaignID gid.GID,
) (int, error) {
q := `
SELECT COUNT(id)
FROM access_review_entries
WHERE
%s
AND access_review_campaign_id = @campaign_id
AND decision = 'PENDING';
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"campaign_id": campaignID}
maps.Copy(args, scope.SQLArguments())
var count int
if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil {
return 0, fmt.Errorf("cannot count pending access_review_entries: %w", err)
}
return count, nil
}
func (e *AccessReviewEntry) LoadOrganizationID(
ctx context.Context,
conn pg.Querier,
entryID gid.GID,
) (gid.GID, error) {
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 {
if errors.Is(err, pgx.ErrNoRows) {
return gid.GID{}, ErrResourceNotFound
}
return gid.GID{}, fmt.Errorf("cannot load organization id for access entry: %w", err)
}
return organizationID, nil
}
func (e *AccessReviewEntry) UpdateFlags(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
UPDATE access_review_entries
SET
flags = @flags,
flag_reasons = @flag_reasons,
updated_at = @updated_at
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": e.ID,
"flags": e.Flags,
"flag_reasons": e.FlagReasons,
"updated_at": e.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
result, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update access entry flags: %w", err)
}
if result.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil
}
// Upsert inserts the entry or refreshes the source-tracking fields from the
// caller. Columns that capture a reviewer's or an agent's verdict -- flags,
// flag_reasons, decision, decision_note, decided_by, decided_at -- are
// 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 AccessReviewEntry.Update.
func (e *AccessReviewEntry) Upsert(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
INSERT INTO access_review_entries (
id,
tenant_id,
organization_id,
access_review_campaign_id,
access_review_campaign_source_id,
identity_id,
email,
full_name,
role,
job_title,
is_admin,
mfa_status,
auth_method,
account_type,
active,
last_login,
account_created_at,
external_id,
account_key,
incremental_tag,
flags,
flag_reasons,
decision,
decision_note,
decided_by,
decided_at,
created_at,
updated_at
) VALUES (
@id,
@tenant_id,
@organization_id,
@access_review_campaign_id,
@access_review_campaign_source_id,
@identity_id,
@email,
@full_name,
@role,
@job_title,
@is_admin,
@mfa_status,
@auth_method,
@account_type,
COALESCE(@active, TRUE),
@last_login,
@account_created_at,
@external_id,
@account_key,
@incremental_tag,
@flags,
@flag_reasons,
@decision,
@decision_note,
@decided_by,
@decided_at,
@created_at,
@updated_at
)
ON CONFLICT (access_review_campaign_source_id, account_key) DO UPDATE SET
email = EXCLUDED.email,
full_name = EXCLUDED.full_name,
role = EXCLUDED.role,
job_title = EXCLUDED.job_title,
is_admin = EXCLUDED.is_admin,
mfa_status = EXCLUDED.mfa_status,
auth_method = EXCLUDED.auth_method,
account_type = EXCLUDED.account_type,
active = EXCLUDED.active,
last_login = EXCLUDED.last_login,
account_created_at = EXCLUDED.account_created_at,
external_id = EXCLUDED.external_id,
incremental_tag = EXCLUDED.incremental_tag,
updated_at = EXCLUDED.updated_at
`
args := pgx.StrictNamedArgs{
"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 {
return fmt.Errorf("cannot upsert access entry: %w", err)
}
return nil
}
// BaselineAccountEntry holds minimal data from a previous campaign's entries
// for incremental diffing.
type BaselineAccountEntry struct {
AccountKey string
Email string
FullName string
}
func (entries *AccessReviewEntries) LoadBaselineBySourceID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
campaignID gid.GID,
sourceID gid.GID,
) ([]BaselineAccountEntry, error) {
q := fmt.Sprintf(`
SELECT account_key, email, full_name
FROM access_review_entries
WHERE %s
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{
"campaign_id": campaignID,
"source_id": sourceID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("cannot load baseline entries: %w", err)
}
defer rows.Close()
var result []BaselineAccountEntry
for rows.Next() {
var entry BaselineAccountEntry
if err := rows.Scan(&entry.AccountKey, &entry.Email, &entry.FullName); err != nil {
return nil, fmt.Errorf("cannot scan baseline entry: %w", err)
}
result = append(result, entry)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("cannot iterate baseline entries: %w", err)
}
return result, nil
}
// LoadMembershipAccountsByOrganizationID loads IAM membership accounts for the
// given organization.
type MembershipAccount struct {
ID gid.GID
Email string
FullName string
State string
Role string
CreatedAt time.Time
}
func LoadMembershipAccountsByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
) ([]MembershipAccount, error) {
q := `
SELECT
m.id,
i.email_address,
i.full_name,
m.state,
m.role,
m.created_at
FROM
iam_memberships m
JOIN
identities i ON i.id = m.identity_id
WHERE
m.%s
AND m.organization_id = @organization_id
AND m.state = 'ACTIVE'
ORDER BY
i.email_address ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("cannot query membership accounts: %w", err)
}
defer rows.Close()
var result []MembershipAccount
for rows.Next() {
var account MembershipAccount
if err := rows.Scan(&account.ID, &account.Email, &account.FullName, &account.State, &account.Role, &account.CreatedAt); err != nil {
return nil, fmt.Errorf("cannot scan membership account: %w", err)
}
result = append(result, account)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("cannot iterate membership accounts: %w", err)
}
return result, nil
}