Raw
  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}