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 <bryan@probo.com>
This commit is contained in:
@@ -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
|
||||
<Th>{__("Email")}</Th>
|
||||
<Th>{__("Role")}</Th>
|
||||
<Th>{__("Admin")}</Th>
|
||||
<Th>{__("Status")}</Th>
|
||||
<Th>{__("MFA")}</Th>
|
||||
<Th>{__("Last login")}</Th>
|
||||
<Th>{__("Flag")}</Th>
|
||||
@@ -698,6 +700,15 @@ function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; is
|
||||
<Td>{edge.node.email || <NotAvailable />}</Td>
|
||||
<Td>{edge.node.role || <NotAvailable />}</Td>
|
||||
<Td>{edge.node.isAdmin ? __("Yes") : __("No")}</Td>
|
||||
<Td>
|
||||
{edge.node.active == null
|
||||
? <NotAvailable />
|
||||
: (
|
||||
<Badge variant={edge.node.active ? "success" : "danger"}>
|
||||
{edge.node.active ? __("Active") : __("Disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{edge.node.mfaStatus === "UNKNOWN"
|
||||
? <NotAvailable />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
16
pkg/coredata/migrations/20260611T000000Z.sql
Normal file
16
pkg/coredata/migrations/20260611T000000Z.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- 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.
|
||||
|
||||
ALTER TABLE access_entries
|
||||
ADD COLUMN active BOOLEAN;
|
||||
@@ -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!
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user