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