1package storage
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "time"
10)
11
12const DexConnectorID = "unified"
13
14var (
15 ErrLastAdmin = errors.New("storage: the admin group would have no enabled member")
16 ErrReferencedGroup = errors.New("storage: group is referenced by the configuration")
17 ErrGroupTaken = errors.New("storage: group name is taken")
18)
19
20type Revoked struct {
21 Sessions int64
22 RefreshTokens int64
23 Codes int64
24 PendingSignIns int64
25}
26
27type DashboardCounts struct {
28 Users int
29 DisabledUsers int
30 Groups int
31 PendingInvitations int
32 LiveSessions int
33}
34
35const (
36 enabledMembersQuery = `
37SELECT count(*) FROM memberships m
38JOIN groups g ON g.id = m.group_id
39JOIN users u ON u.id = m.user_id
40WHERE g.name = ? AND u.disabled = 0`
41 isEnabledMemberQuery = enabledMembersQuery + ` AND u.id = ?`
42 dashboardQuery = `
43SELECT
44 (SELECT count(*) FROM users),
45 (SELECT coalesce(sum(disabled), 0) FROM users),
46 (SELECT count(*) FROM groups),
47 (SELECT count(*) FROM invitations WHERE used_at IS NULL AND revoked_at IS NULL AND expires_at > ?1),
48 (SELECT count(*) FROM forward_sessions WHERE revoked = 0 AND expires_at > ?1)`
49 sessionEpochQuery = `
50SELECT u.credentials_epoch FROM forward_sessions f
51JOIN users u ON u.id = f.user_id
52WHERE f.id = ? AND f.app_domain = ? AND f.revoked = 0 AND f.expires_at > ? AND u.disabled = 0`
53 liveSessionEpochQuery = `
54SELECT u.credentials_epoch FROM users u
55WHERE u.id = ?1 AND u.disabled = 0
56 AND (SELECT count(*) FROM forward_sessions f
57 WHERE f.user_id = u.id AND f.app_domain = ?2 AND f.created_at = ?3) = 1
58 AND EXISTS (SELECT 1 FROM forward_sessions f
59 WHERE f.user_id = u.id AND f.app_domain = ?2 AND f.created_at = ?3
60 AND f.revoked = 0 AND f.expires_at > ?4)`
61)
62
63var dexCredentialDeletes = [4]struct{ name, query string }{
64 {"dex refresh tokens", `DELETE FROM refresh_tokens WHERE claims_user_id = ? AND connector_id = ?`},
65 {"dex offline sessions", `DELETE FROM offline_sessions WHERE user_id = ? AND conn_id = ?`},
66 {"dex auth codes", `DELETE FROM auth_codes WHERE claims_user_id = ? AND connector_id = ?`},
67 {"dex auth requests", `DELETE FROM auth_requests WHERE connector_id = ?2 AND (claims_user_id = ?1 OR substr(CAST(connector_data AS TEXT), 1, length(?1) + 1) = ?1 || '.' OR (CASE WHEN json_valid(CAST(connector_data AS TEXT)) THEN json_extract(CAST(connector_data AS TEXT), '$.` + PendingUserKey + `') END) = ?1)`},
68}
69
70func (s *Store) CreateUserAudited(ctx context.Context, input CreateUserInput, groups []string, actor string) (User, error) {
71 const operation = "create user"
72 if err := s.checkOpen(operation); err != nil {
73 return User{}, err
74 }
75 prepared, err := s.prepareUser(operation, input)
76 if err != nil {
77 return User{}, err
78 }
79 var created User
80 err = s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error {
81 if err := requireFree(ctx, tx, operation, `SELECT 1 FROM users WHERE username = ?`, prepared.Username, ErrUsernameTaken); err != nil {
82 return err
83 }
84 prepared.CreatedAt = at
85 user, err := insertUser(ctx, tx, operation, prepared)
86 if err != nil {
87 return err
88 }
89 granted := []string{}
90 for _, name := range groups {
91 group, _, err := s.ensureGroup(ctx, tx, operation, name, "system")
92 if err != nil {
93 return err
94 }
95 if contains(granted, group.Name) {
96 continue
97 }
98 if err := addMembership(ctx, tx, operation, user.ID, group.ID); err != nil {
99 return err
100 }
101 granted = append(granted, group.Name)
102 }
103 created = user
104 return writeAudit(ctx, tx, operation, actor, "user.create", user.ID, at, map[string]any{"username": user.Username, "groups": granted})
105 })
106 if err != nil {
107 return User{}, err
108 }
109 return created, nil
110}
111
112func (s *Store) UpdateProfileAudited(ctx context.Context, userID, displayName, actor string) (User, error) {
113 const operation = "update profile"
114 if err := validateDisplayName(displayName); err != nil {
115 return User{}, invalid(operation, err)
116 }
117 var updated User
118 err := s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error {
119 current, err := userInTx(ctx, tx, operation, userID)
120 if err != nil {
121 return err
122 }
123 if _, err := tx.ExecContext(ctx, `UPDATE users SET display_name = ? WHERE id = ?`, displayName, userID); err != nil {
124 return classify(operation, err)
125 }
126 if err := writeAudit(ctx, tx, operation, actor, "user.update", userID, at, map[string]any{"username": current.Username, "display_name_changed": current.DisplayName != displayName}); err != nil {
127 return err
128 }
129 updated, err = userInTx(ctx, tx, operation, userID)
130 return err
131 })
132 if err != nil {
133 return User{}, err
134 }
135 return updated, nil
136}
137
138func (s *Store) SetUserDisabledAudited(ctx context.Context, userID string, disabled bool, actor, adminGroup string) (Revoked, error) {
139 const operation = "set user disabled"
140 var revoked Revoked
141 err := s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error {
142 username, _, err := userNameAndUID(ctx, tx, operation, userID)
143 if err != nil {
144 return err
145 }
146 wasAdmin := false
147 if disabled {
148 if wasAdmin, err = isEnabledMember(ctx, tx, operation, userID, adminGroup); err != nil {
149 return err
150 }
151 }
152 changed, err := execCount(ctx, tx, operation, `UPDATE users SET disabled = ? WHERE id = ? AND disabled <> ?`, boolToInt(disabled), userID, boolToInt(disabled))
153 if err != nil && !disabled && errors.Is(err, ErrConflict) {
154 if pending, perr := isPending(ctx, tx, operation, userID); perr == nil && pending {
155 return fmt.Errorf("%s: %w: %w", operation, ErrUserPending, ErrConflict)
156 }
157 }
158 if err != nil || changed == 0 {
159 return err
160 }
161 if wasAdmin {
162 if err := requireEnabledMember(ctx, tx, operation, adminGroup); err != nil {
163 return err
164 }
165 }
166 if revoked, err = revokeCredentials(ctx, tx, operation, userID, "", disabled); err != nil {
167 return err
168 }
169 if disabled {
170 if _, err := endResetLink(ctx, tx, operation, userID); err != nil {
171 return err
172 }
173 }
174 if !disabled {
175 return writeAudit(ctx, tx, operation, actor, "user.enable", userID, at, revoked.into(map[string]any{"username": username}))
176 }
177 if err := writeAudit(ctx, tx, operation, actor, "user.disable", userID, at, map[string]any{"username": username}); err != nil {
178 return err
179 }
180 return writeAudit(ctx, tx, operation+" revoke", actor, "session.revoke_all", userID, at, revoked.into(map[string]any{"username": username}))
181 })
182 if err != nil {
183 return Revoked{}, err
184 }
185 return revoked, nil
186}
187
188func (s *Store) DeleteUserAudited(ctx context.Context, userID, actor, adminGroup string) (Revoked, error) {
189 const operation = "delete user"
190 var revoked Revoked
191 err := s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error {
192 username, uid, err := userNameAndUID(ctx, tx, operation, userID)
193 if err != nil {
194 return err
195 }
196 wasAdmin, err := isEnabledMember(ctx, tx, operation, userID, adminGroup)
197 if err != nil {
198 return err
199 }
200 if revoked, err = revokeCredentials(ctx, tx, operation, userID, "", true); err != nil {
201 return err
202 }
203 if err := blankRedeemedLabels(ctx, tx, operation, userID); err != nil {
204 return err
205 }
206 deleted, err := execCount(ctx, tx, operation, `DELETE FROM users WHERE id = ?`, userID)
207 if err != nil {
208 return err
209 }
210 if deleted == 0 {
211 return fmt.Errorf("%s: %w", operation, ErrNotFound)
212 }
213 if wasAdmin {
214 if err := requireEnabledMember(ctx, tx, operation, adminGroup); err != nil {
215 return err
216 }
217 }
218 return writeAudit(ctx, tx, operation, actor, "user.delete", userID, at, revoked.into(map[string]any{"username": username, "uid_number": uid}))
219 })
220 if err != nil {
221 return Revoked{}, err
222 }
223 return revoked, nil
224}
225
226func (s *Store) AddMembershipAudited(ctx context.Context, userID, groupID, actor string) error {
227 const operation = "add membership"
228 return s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error {
229 username, _, err := userNameAndUID(ctx, tx, operation, userID)
230 if err != nil {
231 return err
232 }
233 group, err := groupInTx(ctx, tx, operation, groupID)
234 if err != nil {
235 return err
236 }
237 if err := addMembership(ctx, tx, operation, userID, groupID); err != nil {
238 return err
239 }
240 return writeAudit(ctx, tx, operation, actor, "membership.add", userID, at, map[string]any{"username": username, "group": group.Name, "group_id": groupID})
241 })
242}
243
244func (s *Store) RemoveMembershipAudited(ctx context.Context, userID, groupID, actor, adminGroup string) error {
245 const operation = "remove membership"
246 return s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error {
247 username, _, err := userNameAndUID(ctx, tx, operation, userID)
248 if err != nil {
249 return err
250 }
251 group, err := groupInTx(ctx, tx, operation, groupID)
252 if err != nil {
253 return err
254 }
255 wasAdmin := false
256 if group.Name == adminGroup {
257 if wasAdmin, err = isEnabledMember(ctx, tx, operation, userID, adminGroup); err != nil {
258 return err
259 }
260 }
261 removed, err := execCount(ctx, tx, operation, `DELETE FROM memberships WHERE user_id = ? AND group_id = ?`, userID, groupID)
262 if err != nil {
263 return err
264 }
265 if removed == 0 {
266 return fmt.Errorf("%s: %w", operation, ErrNotFound)
267 }
268 if wasAdmin {
269 if err := requireEnabledMember(ctx, tx, operation, adminGroup); err != nil {
270 return err
271 }
272 }
273 return writeAudit(ctx, tx, operation, actor, "membership.remove", userID, at, map[string]any{"username": username, "group": group.Name, "group_id": groupID})
274 })
275}
276
277func (s *Store) CreateGroupAudited(ctx context.Context, input CreateGroupInput, actor string) (Group, error) {
278 const operation = "create group"
279 if err := s.checkOpen(operation); err != nil {
280 return Group{}, err
281 }
282 prepared, err := s.prepareGroup(operation, input)
283 if err != nil {
284 return Group{}, err
285 }
286 var created Group
287 err = s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error {
288 if err := requireFree(ctx, tx, operation, `SELECT 1 FROM groups WHERE name = ?`, prepared.Name, ErrGroupTaken); err != nil {
289 return err
290 }
291 prepared.CreatedAt = at
292 group, err := insertGroup(ctx, tx, operation, prepared)
293 if err != nil {
294 return err
295 }
296 created = group
297 return writeAudit(ctx, tx, operation, actor, "group.create", group.ID, at, map[string]any{"group": group.Name, "gid_number": group.GIDNumber})
298 })
299 if err != nil {
300 return Group{}, err
301 }
302 return created, nil
303}
304
305func (s *Store) UpdateGroupDescriptionAudited(ctx context.Context, groupID, description, actor string) (Group, error) {
306 const operation = "update group"
307 if err := validateGroupDescription(description); err != nil {
308 return Group{}, invalid(operation, err)
309 }
310 var updated Group
311 err := s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error {
312 group, err := groupInTx(ctx, tx, operation, groupID)
313 if err != nil {
314 return err
315 }
316 if _, err := tx.ExecContext(ctx, `UPDATE groups SET description = ? WHERE id = ?`, description, groupID); err != nil {
317 return classify(operation, err)
318 }
319 if err := writeAudit(ctx, tx, operation, actor, "group.update", groupID, at, map[string]any{"group": group.Name}); err != nil {
320 return err
321 }
322 updated, err = groupInTx(ctx, tx, operation, groupID)
323 return err
324 })
325 if err != nil {
326 return Group{}, err
327 }
328 return updated, nil
329}
330
331func (s *Store) DeleteGroupAudited(ctx context.Context, groupID, actor string, referenced []string) error {
332 const operation = "delete group"
333 return s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error {
334 group, err := groupInTx(ctx, tx, operation, groupID)
335 if err != nil {
336 return err
337 }
338 if contains(referenced, group.Name) {
339 return fmt.Errorf("%s %q: %w", operation, group.Name, ErrReferencedGroup)
340 }
341 if _, err := tx.ExecContext(ctx, `DELETE FROM groups WHERE id = ?`, groupID); err != nil {
342 return classify(operation, err)
343 }
344 return writeAudit(ctx, tx, operation, actor, "group.delete", groupID, at, map[string]any{"group": group.Name, "gid_number": group.GIDNumber})
345 })
346}
347
348func (s *Store) RevokeInvitationAudited(ctx context.Context, invitationID, actor string) error {
349 const operation = "revoke invitation"
350 return s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error {
351 result, err := tx.ExecContext(ctx, `
352UPDATE invitations
353SET revoked_at = ?
354WHERE id = ? AND used_at IS NULL AND revoked_at IS NULL`, timeToInt(at), invitationID)
355 if err != nil {
356 return classify(operation, err)
357 }
358 if err := requireInvitationTransition(ctx, tx, operation, invitationID, result); err != nil {
359 return err
360 }
361 return writeAudit(ctx, tx, operation, actor, "invitation.revoke", invitationID, at, map[string]any{})
362 })
363}
364
365func (s *Store) RevokeUserCredentialsAudited(ctx context.Context, userID, keepSessionID string, audit AppendAuditInput) (Revoked, error) {
366 return s.RevokeUserCredentialsAuditedAt(ctx, userID, keepSessionID, audit, s.now())
367}
368
369func (s *Store) RevokeUserCredentialsAuditedAt(ctx context.Context, userID, keepSessionID string, audit AppendAuditInput, now time.Time) (Revoked, error) {
370 const operation = "revoke user credentials"
371 fields := map[string]any{}
372 if audit.Detail != "" {
373 if err := json.Unmarshal([]byte(audit.Detail), &fields); err != nil || fields == nil {
374 return Revoked{}, invalid(operation, fmt.Errorf("audit detail is not a JSON object"))
375 }
376 }
377 var revoked Revoked
378 err := s.auditedAt(ctx, operation, audit.Actor, now, func(tx *sql.Tx, at time.Time) error {
379 if keepSessionID != "" {
380 var owner string
381 err := tx.QueryRowContext(ctx, `SELECT user_id FROM forward_sessions WHERE id = ?`, keepSessionID).Scan(&owner)
382 switch {
383 case errors.Is(err, sql.ErrNoRows), err == nil && owner != userID:
384 return invalid(operation, fmt.Errorf("kept session does not belong to the user"))
385 case err != nil:
386 return classify(operation, err)
387 }
388 }
389 username, _, err := userNameAndUID(ctx, tx, operation, userID)
390 if err != nil {
391 return err
392 }
393 if revoked, err = revokeCredentials(ctx, tx, operation, userID, keepSessionID, true); err != nil {
394 return err
395 }
396 fields["username"] = username
397 return writeAudit(ctx, tx, operation, audit.Actor, audit.Action, audit.Subject, at, revoked.into(fields))
398 })
399 if err != nil {
400 return Revoked{}, err
401 }
402 return revoked, nil
403}
404
405func (s *Store) SessionEpoch(ctx context.Context, sessionID, host string, now time.Time) (int64, error) {
406 return s.epochBy(ctx, "session credentials epoch", sessionEpochQuery, sessionID, host, timeToInt(now))
407}
408
409func (s *Store) LiveSessionEpoch(ctx context.Context, userID, host string, since, now time.Time) (int64, error) {
410 return s.epochBy(ctx, "live session credentials epoch", liveSessionEpochQuery, userID, host, timeToInt(since), timeToInt(now))
411}
412
413func (s *Store) epochBy(ctx context.Context, operation, query string, args ...any) (int64, error) {
414 if err := s.checkOpen(operation); err != nil {
415 return 0, err
416 }
417 var epoch int64
418 err := s.sql.QueryRowContext(ctx, query, args...).Scan(&epoch)
419 if errors.Is(err, sql.ErrNoRows) {
420 return 0, fmt.Errorf("%s: %w", operation, ErrNotFound)
421 }
422 if err != nil {
423 return 0, classify(operation, err)
424 }
425 return epoch, nil
426}
427
428func (s *Store) Dashboard(ctx context.Context, now time.Time) (DashboardCounts, error) {
429 const operation = "dashboard counts"
430 if err := s.checkOpen(operation); err != nil {
431 return DashboardCounts{}, err
432 }
433 var c DashboardCounts
434 if err := s.sql.QueryRowContext(ctx, dashboardQuery, timeToInt(now)).Scan(&c.Users, &c.DisabledUsers, &c.Groups, &c.PendingInvitations, &c.LiveSessions); err != nil {
435 return DashboardCounts{}, classify(operation, err)
436 }
437 return c, nil
438}
439
440func (s *Store) GroupsForUsers(ctx context.Context, userIDs []string) (map[string][]Group, error) {
441 const operation = "list groups for users"
442 if err := s.checkOpen(operation); err != nil {
443 return nil, err
444 }
445 groups := map[string][]Group{}
446 if len(userIDs) == 0 {
447 return groups, nil
448 }
449 ids, err := json.Marshal(userIDs)
450 if err != nil {
451 return nil, fmt.Errorf("%s: %w", operation, err)
452 }
453 rows, err := s.sql.QueryContext(ctx, `
454SELECT m.user_id, g.id, g.name, g.description, g.gid_number, g.created_at
455FROM memberships m
456JOIN groups g ON g.id = m.group_id
457WHERE m.user_id IN (SELECT value FROM json_each(?))
458ORDER BY m.user_id, g.name, g.id`, string(ids))
459 if err != nil {
460 return nil, classify(operation, err)
461 }
462 defer rows.Close()
463 for rows.Next() {
464 var userID string
465 var group Group
466 var createdAt int64
467 if err := rows.Scan(&userID, &group.ID, &group.Name, &group.Description, &group.GIDNumber, &createdAt); err != nil {
468 return nil, classify(operation, err)
469 }
470 group.CreatedAt = intToTime(createdAt)
471 groups[userID] = append(groups[userID], group)
472 }
473 if err := rows.Err(); err != nil {
474 return nil, classify(operation, err)
475 }
476 return groups, nil
477}
478
479func (s *Store) audited(ctx context.Context, operation, actor string, write func(tx *sql.Tx, at time.Time) error) error {
480 return s.auditedAt(ctx, operation, actor, s.now(), write)
481}
482
483func (s *Store) auditedAt(ctx context.Context, operation, actor string, now time.Time, write func(tx *sql.Tx, at time.Time) error) error {
484 if err := s.checkOpen(operation); err != nil {
485 return err
486 }
487 if err := requireValue("actor", actor); err != nil {
488 return invalid(operation, err)
489 }
490 tx, err := s.sql.BeginTx(ctx, nil)
491 if err != nil {
492 return classify("begin "+operation, err)
493 }
494 defer tx.Rollback()
495 if err := write(tx, canonicalTime(now)); err != nil {
496 return err
497 }
498 return classify("commit "+operation, tx.Commit())
499}
500
501func revokeCredentials(ctx context.Context, q execer, operation, userID, keepSessionID string, advance bool) (Revoked, error) {
502 if advance {
503 advanced, err := execCount(ctx, q, operation+" credentials epoch", `UPDATE users SET credentials_epoch = credentials_epoch + 1 WHERE id = ?`, userID)
504 if err != nil {
505 return Revoked{}, err
506 }
507 if advanced == 0 {
508 return Revoked{}, fmt.Errorf("%s: %w", operation, ErrNotFound)
509 }
510 }
511 sessions, err := revokeForwardSessionsByUser(ctx, q, operation, userID, keepSessionID)
512 if err != nil {
513 return Revoked{}, err
514 }
515 var deleted [len(dexCredentialDeletes)]int64
516 for i, d := range dexCredentialDeletes {
517 if deleted[i], err = execCount(ctx, q, operation+" "+d.name, d.query, userID, DexConnectorID); err != nil {
518 return Revoked{}, err
519 }
520 }
521 return Revoked{Sessions: sessions, RefreshTokens: deleted[0], Codes: deleted[2], PendingSignIns: deleted[3]}, nil
522}
523
524func (r Revoked) into(fields map[string]any) map[string]any {
525 fields["sessions"] = r.Sessions
526 fields["refresh_tokens"] = r.RefreshTokens
527 fields["codes"] = r.Codes
528 fields["pending_sign_ins"] = r.PendingSignIns
529 return fields
530}
531
532func writeAudit(ctx context.Context, q execer, operation, actor, action, subject string, at time.Time, fields map[string]any) error {
533 detail, err := json.Marshal(fields)
534 if err != nil {
535 return fmt.Errorf("%s: encode audit detail: %w", operation, err)
536 }
537 _, err = appendAudit(ctx, q, operation+" audit", AppendAuditInput{Actor: actor, Action: action, Subject: subject, Detail: string(detail)}, at)
538 return err
539}
540
541func execCount(ctx context.Context, q execer, operation, query string, args ...any) (int64, error) {
542 result, err := q.ExecContext(ctx, query, args...)
543 if err != nil {
544 return 0, classify(operation, err)
545 }
546 count, err := result.RowsAffected()
547 if err != nil {
548 return 0, classify(operation+" rows affected", err)
549 }
550 return count, nil
551}
552
553func userInTx(ctx context.Context, q execer, operation, userID string) (User, error) {
554 user, err := scanUser(q.QueryRowContext(ctx, `SELECT `+userColumns+` FROM users WHERE id = ?`, userID))
555 if errors.Is(err, sql.ErrNoRows) {
556 return User{}, fmt.Errorf("%s: %w", operation, ErrNotFound)
557 }
558 if err != nil {
559 return User{}, classify(operation, err)
560 }
561 return user, nil
562}
563
564func groupInTx(ctx context.Context, q execer, operation, groupID string) (Group, error) {
565 group, err := scanGroup(q.QueryRowContext(ctx, `SELECT id, name, description, gid_number, created_at FROM groups WHERE id = ?`, groupID))
566 if errors.Is(err, sql.ErrNoRows) {
567 return Group{}, fmt.Errorf("%s: %w", operation, ErrNotFound)
568 }
569 if err != nil {
570 return Group{}, classify(operation, err)
571 }
572 return group, nil
573}
574
575func userNameAndUID(ctx context.Context, q execer, operation, userID string) (string, int, error) {
576 var username string
577 var uid int
578 err := q.QueryRowContext(ctx, `SELECT username, uid_number FROM users WHERE id = ?`, userID).Scan(&username, &uid)
579 if errors.Is(err, sql.ErrNoRows) {
580 return "", 0, fmt.Errorf("%s: %w", operation, ErrNotFound)
581 }
582 if err != nil {
583 return "", 0, classify(operation, err)
584 }
585 return username, uid, nil
586}
587
588func isEnabledMember(ctx context.Context, q execer, operation, userID, group string) (bool, error) {
589 if group == "" {
590 return false, nil
591 }
592 var count int
593 if err := q.QueryRowContext(ctx, isEnabledMemberQuery, group, userID).Scan(&count); err != nil {
594 return false, classify(operation+" admin membership", err)
595 }
596 return count > 0, nil
597}
598
599func requireEnabledMember(ctx context.Context, q execer, operation, group string) error {
600 var count int
601 if err := q.QueryRowContext(ctx, enabledMembersQuery, group).Scan(&count); err != nil {
602 return classify(operation+" count admins", err)
603 }
604 if count == 0 {
605 return fmt.Errorf("%s: %w", operation, ErrLastAdmin)
606 }
607 return nil
608}
609
610func contains(names []string, name string) bool {
611 for _, existing := range names {
612 if existing == name {
613 return true
614 }
615 }
616 return false
617}