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,