From c913e97c3550aa344470c40010f7b7fda3775205 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Thu, 11 Jun 2026 08:40:49 +0200 Subject: [PATCH] Add active status field to access entries Track whether an account is active (enabled) or disabled at the source system. The field is nullable so existing entries without this data remain valid. - DB migration adds active BOOLEAN column to access_entries - Coredata read/write/upsert/filter wiring for the new column - Review engine propagates Active from source accounts - GraphQL schema exposes active on AccessEntry and AccessEntryFilter - MCP spec, types, and resolvers expose active and fix missing account_type filter that was wired in GraphQL but not MCP - CLI list command adds --active filter flag and ACTIVE output column - Console campaign detail table shows Active/Disabled status badge - E2e and unit tests updated to cover the new field Signed-off-by: Bryan Frimin --- .../campaigns/CampaignDetailPage.tsx | 11 +++ e2e/console/access_review_test.go | 8 +- pkg/accessreview/review_engine.go | 1 + pkg/cmd/access-review/entry/list/list.go | 20 ++++- pkg/coredata/access_entry.go | 11 +++ pkg/coredata/access_entry_filter.go | 12 +++ pkg/coredata/access_entry_upsert_test.go | 79 +++++++++++++++++++ pkg/coredata/migrations/20260611T000000Z.sql | 16 ++++ .../v1/graphql/access_review_campaign.graphql | 2 + .../api/console/v1/types/access_review.go | 1 + pkg/server/api/mcp/v1/schema.resolvers.go | 2 + pkg/server/api/mcp/v1/specification.yaml | 10 ++- pkg/server/api/mcp/v1/types/access_review.go | 1 + 13 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 pkg/coredata/migrations/20260611T000000Z.sql diff --git a/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPage.tsx b/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPage.tsx index 205e2f6f5..1c5e485a2 100644 --- a/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPage.tsx +++ b/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPage.tsx @@ -162,6 +162,7 @@ export const campaignDetailPageQuery = graphql` fullName role isAdmin + active mfaStatus accountType lastLogin @@ -670,6 +671,7 @@ function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; is {__("Email")} {__("Role")} {__("Admin")} + {__("Status")} {__("MFA")} {__("Last login")} {__("Flag")} @@ -698,6 +700,15 @@ function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; is {edge.node.email || } {edge.node.role || } {edge.node.isAdmin ? __("Yes") : __("No")} + + {edge.node.active == null + ? + : ( + + {edge.node.active ? __("Active") : __("Disabled")} + + )} + {edge.node.mfaStatus === "UNKNOWN" ? diff --git a/e2e/console/access_review_test.go b/e2e/console/access_review_test.go index 078cd61a7..2e2f3c708 100644 --- a/e2e/console/access_review_test.go +++ b/e2e/console/access_review_test.go @@ -24,7 +24,7 @@ import ( "go.probo.inc/probo/e2e/internal/testutil" ) -const testCsvData = "email,full_name,role,job_title,is_admin,mfa_status,auth_method,last_login,account_created_at,external_id\njane@example.com,Jane Smith,admin,CTO,true,ENABLED,SSO,2026-01-15T00:00:00Z,2024-06-01T00:00:00Z,ext-jane" +const testCsvData = "email,full_name,role,job_title,is_admin,active,mfa_status,auth_method,last_login,account_created_at,external_id\njane@example.com,Jane Smith,admin,CTO,true,true,ENABLED,SSO,2026-01-15T00:00:00Z,2024-06-01T00:00:00Z,ext-jane" func TestAccessSource_Create(t *testing.T) { t.Parallel() @@ -1073,6 +1073,7 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) { id email fullName + active decision } } @@ -1100,6 +1101,7 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) { ID string `json:"id"` Email string `json:"email"` FullName string `json:"fullName"` + Active *bool `json:"active"` Decision string `json:"decision"` } `json:"node"` } `json:"edges"` @@ -1133,6 +1135,10 @@ func TestAccessReviewCampaign_FullLifecycle(t *testing.T) { // All entries should be PENDING for _, edge := range campaignResult.Node.Entries.Edges { assert.Equal(t, "PENDING", edge.Node.Decision) + if edge.Node.Email == "jane@example.com" { + require.NotNil(t, edge.Node.Active) + assert.True(t, *edge.Node.Active) + } } // Step 5: Record decisions on all entries diff --git a/pkg/accessreview/review_engine.go b/pkg/accessreview/review_engine.go index 51c4a81ea..8b1826cac 100644 --- a/pkg/accessreview/review_engine.go +++ b/pkg/accessreview/review_engine.go @@ -161,6 +161,7 @@ func (e *ReviewEngine) FetchSource( MFAStatus: account.MFAStatus, AuthMethod: account.AuthMethod, AccountType: account.AccountType, + Active: account.Active, LastLogin: account.LastLogin, AccountCreatedAt: account.CreatedAt, ExternalID: account.ExternalID, diff --git a/pkg/cmd/access-review/entry/list/list.go b/pkg/cmd/access-review/entry/list/list.go index 1868aa006..06a4afb8d 100644 --- a/pkg/cmd/access-review/entry/list/list.go +++ b/pkg/cmd/access-review/entry/list/list.go @@ -52,6 +52,7 @@ query( role jobTitle isAdmin + active mfaStatus authMethod accountType @@ -86,6 +87,7 @@ type entryNode struct { Role string `json:"role"` JobTitle string `json:"jobTitle"` IsAdmin bool `json:"isAdmin"` + Active *bool `json:"active"` MfaStatus string `json:"mfaStatus"` AuthMethod string `json:"authMethod"` AccountType string `json:"accountType"` @@ -113,6 +115,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { flagFlag string flagIncTag string flagIsAdmin *bool + flagActive *bool flagAuthMethod string flagAccountType string flagOutput *string @@ -226,6 +229,10 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { filter["isAdmin"] = *flagIsAdmin } + if cmd.Flags().Changed("active") { + filter["active"] = *flagActive + } + if flagAuthMethod != "" { if err := cmdutil.ValidateEnum( "auth-method", @@ -305,6 +312,15 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { admin = "yes" } + active := "unknown" + if e.Active != nil { + if *e.Active { + active = "active" + } else { + active = "disabled" + } + } + rows = append(rows, []string{ e.ID, e.Email, @@ -313,10 +329,11 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { e.Decision, strings.Join(e.Flags, ","), admin, + active, }) } - t := cmdutil.NewTable("ID", "EMAIL", "NAME", "SOURCE", "DECISION", "FLAGS", "ADMIN").Rows(rows...) + t := cmdutil.NewTable("ID", "EMAIL", "NAME", "SOURCE", "DECISION", "FLAGS", "ADMIN", "ACTIVE").Rows(rows...) _, _ = fmt.Fprintln(f.IOStreams.Out, t) @@ -341,6 +358,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVar(&flagFlag, "flag", "", "Filter by flag (NONE, ORPHANED, INACTIVE, EXCESSIVE, ROLE_MISMATCH, NEW)") cmd.Flags().StringVar(&flagIncTag, "incremental-tag", "", "Filter by incremental tag (NEW, REMOVED, UNCHANGED)") flagIsAdmin = cmd.Flags().Bool("is-admin", false, "Filter by admin status") + flagActive = cmd.Flags().Bool("active", false, "Filter by active status at the source") cmd.Flags().StringVar(&flagAuthMethod, "auth-method", "", "Filter by auth method (SSO, PASSWORD, API_KEY, SERVICE_ACCOUNT, UNKNOWN)") cmd.Flags().StringVar(&flagAccountType, "account-type", "", "Filter by account type (USER, SERVICE_ACCOUNT)") flagOutput = cmdutil.AddOutputFlag(cmd) diff --git a/pkg/coredata/access_entry.go b/pkg/coredata/access_entry.go index ea4242e41..df888911f 100644 --- a/pkg/coredata/access_entry.go +++ b/pkg/coredata/access_entry.go @@ -43,6 +43,7 @@ type ( 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"` @@ -130,6 +131,7 @@ SELECT mfa_status, auth_method, account_type, + active, last_login, account_created_at, external_id, @@ -196,6 +198,7 @@ INSERT INTO mfa_status, auth_method, account_type, + active, last_login, account_created_at, external_id, @@ -225,6 +228,7 @@ VALUES ( @mfa_status, @auth_method, @account_type, + @active, @last_login, @account_created_at, @external_id, @@ -256,6 +260,7 @@ VALUES ( "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, @@ -347,6 +352,7 @@ SELECT mfa_status, auth_method, account_type, + active, last_login, account_created_at, external_id, @@ -414,6 +420,7 @@ SELECT mfa_status, auth_method, account_type, + active, last_login, account_created_at, external_id, @@ -629,6 +636,7 @@ INSERT INTO access_entries ( mfa_status, auth_method, account_type, + active, last_login, account_created_at, external_id, @@ -657,6 +665,7 @@ INSERT INTO access_entries ( @mfa_status, @auth_method, @account_type, + @active, @last_login, @account_created_at, @external_id, @@ -680,6 +689,7 @@ ON CONFLICT (access_review_campaign_id, access_source_id, account_key) DO UPDATE 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, @@ -702,6 +712,7 @@ ON CONFLICT (access_review_campaign_id, access_source_id, account_key) DO UPDATE "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, diff --git a/pkg/coredata/access_entry_filter.go b/pkg/coredata/access_entry_filter.go index a8d2f4e91..71e6e406a 100644 --- a/pkg/coredata/access_entry_filter.go +++ b/pkg/coredata/access_entry_filter.go @@ -23,6 +23,7 @@ type AccessEntryFilter struct { Flag *AccessEntryFlag IncrementalTag *AccessEntryIncrementalTag IsAdmin *bool + Active *bool AuthMethod *AccessEntryAuthMethod AccountType *AccessEntryAccountType } @@ -58,6 +59,12 @@ func (f *AccessEntryFilter) SQLFragment() string { ELSE TRUE END AND + CASE + WHEN @filter_active::boolean IS NOT NULL THEN + active = @filter_active::boolean + ELSE TRUE + END + AND CASE WHEN @filter_auth_method::text IS NOT NULL THEN auth_method = @filter_auth_method::text @@ -82,6 +89,7 @@ func (f *AccessEntryFilter) SQLArguments() pgx.StrictNamedArgs { "filter_flag": nil, "filter_incremental_tag": nil, "filter_is_admin": nil, + "filter_active": nil, "filter_auth_method": nil, "filter_account_type": nil, } @@ -102,6 +110,10 @@ func (f *AccessEntryFilter) SQLArguments() pgx.StrictNamedArgs { args["filter_is_admin"] = *f.IsAdmin } + if f.Active != nil { + args["filter_active"] = *f.Active + } + if f.AuthMethod != nil { args["filter_auth_method"] = string(*f.AuthMethod) } diff --git a/pkg/coredata/access_entry_upsert_test.go b/pkg/coredata/access_entry_upsert_test.go index a4f280b2b..788e4b5fe 100644 --- a/pkg/coredata/access_entry_upsert_test.go +++ b/pkg/coredata/access_entry_upsert_test.go @@ -350,6 +350,82 @@ func TestAccessEntry_Upsert_RefreshesSourceTrackingFields(t *testing.T) { 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 @@ -377,6 +453,7 @@ func TestAccessEntry_Upsert_InsertsActiveAccount(t *testing.T) { MFAStatus: coredata.MFAStatusUnknown, AuthMethod: coredata.AccessEntryAuthMethodUnknown, AccountType: coredata.AccessEntryAccountTypeUser, + Active: new(true), ExternalID: "ext-active", AccountKey: fx.accountKey, IncrementalTag: coredata.AccessEntryIncrementalTagNew, @@ -397,6 +474,8 @@ func TestAccessEntry_Upsert_InsertsActiveAccount(t *testing.T) { 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) diff --git a/pkg/coredata/migrations/20260611T000000Z.sql b/pkg/coredata/migrations/20260611T000000Z.sql new file mode 100644 index 000000000..9352941c6 --- /dev/null +++ b/pkg/coredata/migrations/20260611T000000Z.sql @@ -0,0 +1,16 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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. + +ALTER TABLE access_entries + ADD COLUMN active BOOLEAN; diff --git a/pkg/server/api/console/v1/graphql/access_review_campaign.graphql b/pkg/server/api/console/v1/graphql/access_review_campaign.graphql index 9ddab09be..12b3b19fc 100644 --- a/pkg/server/api/console/v1/graphql/access_review_campaign.graphql +++ b/pkg/server/api/console/v1/graphql/access_review_campaign.graphql @@ -280,6 +280,7 @@ input AccessEntryFilter flag: AccessEntryFlag incrementalTag: AccessEntryIncrementalTag isAdmin: Boolean + active: Boolean authMethod: AccessEntryAuthMethod accountType: AccessEntryAccountType } @@ -372,6 +373,7 @@ type AccessEntry implements Node { role: String! jobTitle: String! isAdmin: Boolean! + active: Boolean mfaStatus: MfaStatus! authMethod: AccessEntryAuthMethod! accountType: AccessEntryAccountType! diff --git a/pkg/server/api/console/v1/types/access_review.go b/pkg/server/api/console/v1/types/access_review.go index 108274f53..9b4b8ab0f 100644 --- a/pkg/server/api/console/v1/types/access_review.go +++ b/pkg/server/api/console/v1/types/access_review.go @@ -249,6 +249,7 @@ func NewAccessEntry(e *coredata.AccessEntry) *AccessEntry { Role: e.Role, JobTitle: e.JobTitle, IsAdmin: e.IsAdmin, + Active: e.Active, MfaStatus: e.MFAStatus, AuthMethod: e.AuthMethod, AccountType: e.AccountType, diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 1d554b0e3..69578b552 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -3438,7 +3438,9 @@ func (r *Resolver) ListAccessEntriesTool(ctx context.Context, req *mcp.CallToolR Flag: input.Filter.Flag, IncrementalTag: input.Filter.IncrementalTag, IsAdmin: input.Filter.IsAdmin, + Active: input.Filter.Active, AuthMethod: input.Filter.AuthMethod, + AccountType: input.Filter.AccountType, } } diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 1cfd20f5a..d4193e56e 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -7721,6 +7721,11 @@ components: is_admin: type: boolean description: Whether the user has admin privileges + active: + type: + - boolean + - "null" + description: Whether the account is active at the source (null when unknown) mfa_status: $ref: "#/components/schemas/MFAStatus" description: MFA status @@ -7879,6 +7884,9 @@ components: is_admin: type: boolean description: Filter by admin status + active: + type: boolean + description: Filter by active status at the source auth_method: $ref: "#/components/schemas/AccessEntryAuthMethod" description: Filter by auth method @@ -13277,7 +13285,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListAccessReviewCampaignsOutput" - name: listAccessEntries - description: List access entries for a campaign with optional filters (decision, flag, incremental_tag, is_admin, auth_method, account_type) + description: List access entries for a campaign with optional filters (decision, flag, incremental_tag, is_admin, active, auth_method, account_type) hints: readonly: true idempotent: true diff --git a/pkg/server/api/mcp/v1/types/access_review.go b/pkg/server/api/mcp/v1/types/access_review.go index 7151ecc3b..3b87c0598 100644 --- a/pkg/server/api/mcp/v1/types/access_review.go +++ b/pkg/server/api/mcp/v1/types/access_review.go @@ -77,6 +77,7 @@ func NewAccessEntry(e *coredata.AccessEntry) *AccessEntry { Role: e.Role, JobTitle: e.JobTitle, IsAdmin: e.IsAdmin, + Active: e.Active, MfaStatus: e.MFAStatus, AuthMethod: e.AuthMethod, AccountType: e.AccountType,