package admin import ( "context" "errors" "fmt" "log/slog" "slices" "strconv" "strings" "sync" "time" "tug/internal/service/invite" "tug/internal/storage" ) const ( UsersPerPage = 20 MembersPerPage = 25 GroupsPerPage = 25 InvitationsPerPage = 25 RecentEntries = 10 DisabledShown = 10 CandidatesShown = 10 ) var ErrNotFound = errors.New("admin: no such user, group or invitation") type Problem int const ( ProblemUsernameGrammar Problem = iota + 1 ProblemUsernameTaken ProblemDisplayNameInvalid ProblemGroupNameGrammar ProblemGroupNameTaken ProblemDescriptionInvalid ProblemUnknownUser ProblemUnknownGroup ProblemAlreadyMember ProblemNotMember ProblemPrimaryGroup ProblemSelf ProblemLastAdmin ProblemReferencedGroup ProblemInvitationClosed ProblemGoogleNotLinked ProblemResetLinkDisabled ProblemResetLinkPending ProblemLabelInvalid ProblemExpiryInvalid ProblemAdminGroupGrant ProblemBatchSize ProblemUserPending ProblemUnknownBatch ProblemSSHKeyDisabled ProblemSSHKeyPending ) var problemNames = map[Problem]string{ ProblemUsernameGrammar: "the username does not follow the username grammar", ProblemUsernameTaken: "the username is taken", ProblemDisplayNameInvalid: "the display name is not valid", ProblemGroupNameGrammar: "the group name does not follow the group name grammar", ProblemGroupNameTaken: "the group name is taken", ProblemDescriptionInvalid: "the description is not valid", ProblemUnknownUser: "no user has that username", ProblemUnknownGroup: "no group has that name", ProblemAlreadyMember: "the user is already a member of the group", ProblemNotMember: "the user is not a member of the group", ProblemPrimaryGroup: "the primary group's membership cannot be changed", ProblemSelf: "an administrator cannot do this to their own account", ProblemLastAdmin: "the admin group would have no enabled member", ProblemReferencedGroup: "the group is named in the configuration", ProblemInvitationClosed: "the invitation is no longer pending", ProblemGoogleNotLinked: "the user has no linked Google account", ProblemResetLinkDisabled: "a reset link cannot be issued to a disabled account", ProblemResetLinkPending: "a reset link cannot be issued to an account waiting for its confirmation", ProblemLabelInvalid: "a label is empty or not valid", ProblemExpiryInvalid: "the expiry is not one of the offered choices", ProblemAdminGroupGrant: "an invitation cannot grant the admin group", ProblemBatchSize: "the batch is empty or larger than the cap", ProblemUserPending: "the account is waiting for its confirmation", ProblemUnknownBatch: "no invitation batch has that id", ProblemSSHKeyDisabled: "a disabled account takes no new SSH key", ProblemSSHKeyPending: "an account waiting for its confirmation takes no SSH key", } func (p Problem) Error() string { if s, ok := problemNames[p]; ok { return "admin: " + s } return "admin: unknown problem " + strconv.Itoa(int(p)) } type AuditEntry struct { At time.Time Actor string Action string Subject string NewSession bool } type Dashboard struct { Users int DisabledUsers int Groups int PendingInvitations int LiveSessions int Recent []AuditEntry } type UserRow struct { ID string Username string DisplayName string Disabled bool Pending bool Groups []string GroupCount int } type UserList struct { Users []UserRow Total int } type GroupRef struct { ID string Name string Primary bool } type GoogleLink struct { LinkedAt time.Time } type User struct { ID string Username string DisplayName string UIDNumber int Disabled bool Pending bool Admin bool CreatedAt time.Time LastLoginAt *time.Time Groups []GroupRef GroupCount int Google *GoogleLink Sessions int Activity []AuditEntry SSHKeys []SSHKey } type Memberships struct { Username string Groups []GroupRef Total int Candidates []string } type GroupRow struct { ID string Name string Description string Members int } type GroupList struct { Groups []GroupRow Total int } type Member struct { ID string Username string DisplayName string Pending bool } type Group struct { ID string Name string Description string GIDNumber int Primary bool Referenced bool Members []Member Total int Disabled []Member DisabledTotal int } type InvitationState int const ( InvitationPending InvitationState = iota + 1 InvitationUsed InvitationRevoked InvitationExpired ) type Invitation struct { ID string CreatedBy string CreatedAt time.Time ExpiresAt time.Time State InvitationState Label string UsedBy string UsedByID string UsedByPending bool UsedAt *time.Time Batch string Groups []InvitationGroup } type InvitationGroup struct { Name string Deleted bool } type InvitationList struct { Invitations []Invitation Total int } type NewUser struct { Username string DisplayName string } type Profile struct { DisplayName string } type NewGroup struct { Name string Description string } type Config struct { Store *storage.Store Invites *invite.Service Logger *slog.Logger AdminGroup string PrimaryGroup string DefaultGroups []string ReferencedGroups []string Now func() time.Time AuditRetention time.Duration } type Service struct { store *storage.Store invites *invite.Service logger *slog.Logger adminGroup string primaryGroup string groupsMu sync.RWMutex defaultGroups []string referenced []string now func() time.Time retention time.Duration } func New(cfg Config) (*Service, error) { switch { case cfg.Store == nil: return nil, errors.New("admin: store is nil") case cfg.Invites == nil: return nil, errors.New("admin: invitation service is nil") case cfg.Logger == nil: return nil, errors.New("admin: logger is nil") case strings.TrimSpace(cfg.AdminGroup) == "": return nil, errors.New("admin: admin group is empty") case strings.TrimSpace(cfg.PrimaryGroup) == "": return nil, errors.New("admin: primary group is empty") case cfg.AdminGroup == cfg.PrimaryGroup: return nil, errors.New("admin: the admin group is the primary group") } if err := checkDefaultGroups(cfg.AdminGroup, cfg.PrimaryGroup, cfg.DefaultGroups, cfg.ReferencedGroups); err != nil { return nil, err } now := cfg.Now if now == nil { now = time.Now } return &Service{ store: cfg.Store, invites: cfg.Invites, logger: cfg.Logger, adminGroup: cfg.AdminGroup, primaryGroup: cfg.PrimaryGroup, defaultGroups: slices.Clone(cfg.DefaultGroups), referenced: slices.Clone(cfg.ReferencedGroups), now: now, retention: cfg.AuditRetention, }, nil } func checkDefaultGroups(adminGroup, primaryGroup string, defaults, referenced []string) error { if slices.Contains(defaults, adminGroup) { return errors.New("admin: the admin group is a default group") } for _, name := range append([]string{adminGroup, primaryGroup}, defaults...) { if !slices.Contains(referenced, name) { return fmt.Errorf("admin: group %q is not among the referenced groups", name) } } return nil } func (s *Service) SetDefaultGroups(defaults, referenced []string) error { if err := checkDefaultGroups(s.adminGroup, s.primaryGroup, defaults, referenced); err != nil { return err } s.groupsMu.Lock() defer s.groupsMu.Unlock() s.defaultGroups, s.referenced = slices.Clone(defaults), slices.Clone(referenced) return nil } func (s *Service) configuredGroups() (defaults, referenced []string) { s.groupsMu.RLock() defer s.groupsMu.RUnlock() return s.defaultGroups, s.referenced } func window[T any](items []T, page, size int) []T { if page < 1 { page = 1 } if page-1 > len(items)/size { return nil } start := (page - 1) * size if start >= len(items) { return nil } return items[start:min(start+size, len(items))] } func (s *Service) primary(ctx context.Context) (*storage.Group, error) { group, err := s.store.GroupByName(ctx, s.primaryGroup) if errors.Is(err, storage.ErrNotFound) { return nil, nil } if err != nil { return nil, failed("read the primary group", err) } return &group, nil } func refs(groups []storage.Group, primary *storage.Group) []GroupRef { out := make([]GroupRef, 0, len(groups)+1) if primary != nil { out = append(out, GroupRef{ID: primary.ID, Name: primary.Name, Primary: true}) } for _, g := range groups { if primary != nil && g.ID == primary.ID { continue } out = append(out, GroupRef{ID: g.ID, Name: g.Name}) } return out } func failed(operation string, err error) error { return fmt.Errorf("admin: %s: %w", operation, err) } func target(operation string, err error) error { if errors.Is(err, storage.ErrNotFound) { return ErrNotFound } return failed(operation, err) }