Skip to content
This repository was archived by the owner on Feb 3, 2026. It is now read-only.

Commit 733535c

Browse files
committed
feat: sanity check for scim user deactivation
1 parent c5d5a68 commit 733535c

File tree

2 files changed

+67
-16
lines changed

2 files changed

+67
-16
lines changed

pkg/github/enterpriseuserwriter.go

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,18 @@ func WithMaxUsersToProvision(num int64) EnterpriseRWOpt {
4141
}
4242
}
4343

44+
// WithUserDeactivationSanityCheck sets the sanity check function for SCIM user deactivation.
45+
func WithUserDeactivationSanityCheck(f func(context.Context, *groupsync.User) (bool, error)) EnterpriseRWOpt {
46+
return func(rw *EnterpriseUserWriter) {
47+
rw.userDeactivationSanityCheck = f
48+
}
49+
}
50+
4451
// EnterpriseUserWriter manages enterprise users via a direct GHES SCIM API client.
4552
type EnterpriseUserWriter struct {
46-
scimClient *SCIMClient
47-
maxUsersToProvision int64
53+
scimClient *SCIMClient
54+
maxUsersToProvision int64
55+
userDeactivationSanityCheck func(context.Context, *groupsync.User) (bool, error)
4856
}
4957

5058
// NewEnterpriseUserWriter creates a new EnterpriseUserWriter with default 1000
@@ -57,6 +65,9 @@ func NewEnterpriseUserWriter(httpClient *http.Client, enterpriseBaseURL string,
5765
w := &EnterpriseUserWriter{
5866
maxUsersToProvision: defaultMaxUsersToProvision,
5967
scimClient: scimClient,
68+
userDeactivationSanityCheck: func(context.Context, *groupsync.User) (bool, error) {
69+
return true, nil
70+
},
6071
}
6172
for _, opt := range opts {
6273
opt(w)
@@ -73,8 +84,9 @@ func (w *EnterpriseUserWriter) SetMembers(ctx context.Context, _ string, members
7384
return fmt.Errorf("failed to list users: %w", err)
7485
}
7586
desiredUsersMap := make(map[string]*SCIMUser)
87+
userMemberMap := make(map[string]*groupsync.User)
7688
// Use a list to maintain the ordering of the desired users to avoid unit test flakiness.
77-
desiredUsersName := []string{}
89+
var desiredUsersName []string
7890
for _, m := range members {
7991
if !m.IsUser() {
8092
logger.DebugContext(ctx, "skipping non-user member", "member", m.ID())
@@ -86,6 +98,7 @@ func (w *EnterpriseUserWriter) SetMembers(ctx context.Context, _ string, members
8698
logger.DebugContext(ctx, "skipping non-SCIM user member", "member", m.ID())
8799
continue
88100
}
101+
userMemberMap[scimUser.UserName] = u
89102
desiredUsersMap[scimUser.UserName] = scimUser
90103
desiredUsersName = append(desiredUsersName, scimUser.UserName)
91104
}
@@ -99,11 +112,19 @@ func (w *EnterpriseUserWriter) SetMembers(ctx context.Context, _ string, members
99112
}
100113
// Deactivate user who is not in desiredUsersMap and remove any role grants.
101114
if _, ok := desiredUsersMap[username]; !ok {
102-
logger.InfoContext(ctx, "deactivating user", "user", username)
115+
deactivate, err := w.userDeactivationSanityCheck(ctx, userMemberMap[username])
116+
if err != nil {
117+
merr = errors.Join(merr, fmt.Errorf("failed to check user ACL for deactivation user %q host %q: %w", username, err, w.scimClient.baseURL.Host))
118+
}
119+
if !deactivate {
120+
logger.InfoContext(ctx, "skipping user deactivation due to sanity check failed", "user", username, "host", w.scimClient.baseURL.Host)
121+
continue
122+
}
123+
logger.InfoContext(ctx, "deactivating user", "user", username, "host", w.scimClient.baseURL.Host)
103124
scimUser.Active = github.Bool(false)
104125
scimUser.Roles = nil
105126
if _, _, err := w.scimClient.UpdateUser(ctx, *scimUser.ID, scimUser); err != nil {
106-
merr = errors.Join(merr, fmt.Errorf("failed to deactivate %q: %w", username, err))
127+
merr = errors.Join(merr, fmt.Errorf("failed to deactivate %q, host %q: %w", w.scimClient.baseURL.Host, username, err))
107128
}
108129
}
109130
}
@@ -113,7 +134,7 @@ func (w *EnterpriseUserWriter) SetMembers(ctx context.Context, _ string, members
113134
for _, username := range desiredUsersName {
114135
count++
115136
if count > w.maxUsersToProvision {
116-
merr = errors.Join(merr, fmt.Errorf("exceeded max users to provision: %d", w.maxUsersToProvision))
137+
merr = errors.Join(merr, fmt.Errorf("exceeded max users to provision, host %q: %d", w.scimClient.baseURL.Host, w.maxUsersToProvision))
117138
break
118139
}
119140

@@ -122,9 +143,9 @@ func (w *EnterpriseUserWriter) SetMembers(ctx context.Context, _ string, members
122143
// Create the user if not found in currentUsersMap.
123144
currentUser, ok := currentUsersMap[username]
124145
if !ok {
125-
logger.InfoContext(ctx, "creating user", "user", username)
146+
logger.InfoContext(ctx, "creating user", "user", username, "host", w.scimClient.baseURL.Host)
126147
if _, _, err := w.scimClient.CreateUser(ctx, desiredUser); err != nil {
127-
merr = errors.Join(merr, fmt.Errorf("failed to create %q: %w", username, err))
148+
merr = errors.Join(merr, fmt.Errorf("failed to create %q host %q: %w", username, w.scimClient.baseURL.Host, err))
128149
}
129150
continue
130151
}

pkg/github/enterpriseuserwriter_test.go

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,15 @@ func TestEnterpriseUserWriter_SetMembers(t *testing.T) {
2929
t.Parallel()
3030

3131
cases := []struct {
32-
name string
33-
initialUsers map[string]*SCIMUser
34-
desiredMembers []groupsync.Member
35-
maxUsersToProvision int64
36-
failCreateUserCalls bool
37-
failListUserCalls bool
38-
wantUsersOnServer map[string]*SCIMUser
39-
wantErrStr string
32+
name string
33+
initialUsers map[string]*SCIMUser
34+
desiredMembers []groupsync.Member
35+
maxUsersToProvision int64
36+
failCreateUserCalls bool
37+
failListUserCalls bool
38+
failUserDeactivationSanityCheck bool
39+
wantUsersOnServer map[string]*SCIMUser
40+
wantErrStr string
4041
}{
4142
{
4243
name: "success_create_and_deactivate",
@@ -156,6 +157,29 @@ func TestEnterpriseUserWriter_SetMembers(t *testing.T) {
156157
},
157158
},
158159
},
160+
{
161+
name: "deactivate_sanity_check_fails",
162+
initialUsers: map[string]*SCIMUser{
163+
"scim-id-user.one": {
164+
SCIMUserAttributes: github.SCIMUserAttributes{
165+
ID: github.String("scim-id-user.one"),
166+
UserName: "user.one",
167+
Active: github.Bool(true),
168+
},
169+
},
170+
},
171+
desiredMembers: []groupsync.Member{},
172+
failUserDeactivationSanityCheck: true,
173+
wantUsersOnServer: map[string]*SCIMUser{
174+
"scim-id-user.one": {
175+
SCIMUserAttributes: github.SCIMUserAttributes{
176+
ID: github.String("scim-id-user.one"),
177+
UserName: "user.one",
178+
Active: github.Bool(true),
179+
},
180+
},
181+
},
182+
},
159183
{
160184
name: "success_reactivate_only",
161185
initialUsers: map[string]*SCIMUser{
@@ -455,6 +479,12 @@ func TestEnterpriseUserWriter_SetMembers(t *testing.T) {
455479
opts = append(opts, WithMaxUsersToProvision(tc.maxUsersToProvision))
456480
}
457481

482+
if tc.failUserDeactivationSanityCheck {
483+
opts = append(opts, WithUserDeactivationSanityCheck(func(context.Context, *groupsync.User) (bool, error) {
484+
return false, nil
485+
}))
486+
}
487+
458488
writer, err := NewEnterpriseUserWriter(srv.Client(), srv.URL, opts...)
459489
if err != nil {
460490
t.Fatalf("NewEnterpriseUserWriter failed: %v", err)

0 commit comments

Comments
 (0)