Raw
  1package admin
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"log/slog"
  8	"slices"
  9	"strconv"
 10	"strings"
 11	"sync"
 12	"time"
 13
 14	"tug/internal/service/invite"
 15	"tug/internal/storage"
 16)
 17
 18const (
 19	UsersPerPage       = 20
 20	MembersPerPage     = 25
 21	GroupsPerPage      = 25
 22	InvitationsPerPage = 25
 23	RecentEntries      = 10
 24	DisabledShown      = 10
 25	CandidatesShown    = 10
 26)
 27
 28var ErrNotFound = errors.New("admin: no such user, group or invitation")
 29
 30type Problem int
 31
 32const (
 33	ProblemUsernameGrammar Problem = iota + 1
 34	ProblemUsernameTaken
 35	ProblemDisplayNameInvalid
 36	ProblemGroupNameGrammar
 37	ProblemGroupNameTaken
 38	ProblemDescriptionInvalid
 39	ProblemUnknownUser
 40	ProblemUnknownGroup
 41	ProblemAlreadyMember
 42	ProblemNotMember
 43	ProblemPrimaryGroup
 44	ProblemSelf
 45	ProblemLastAdmin
 46	ProblemReferencedGroup
 47	ProblemInvitationClosed
 48	ProblemGoogleNotLinked
 49	ProblemResetLinkDisabled
 50	ProblemResetLinkPending
 51	ProblemLabelInvalid
 52	ProblemExpiryInvalid
 53	ProblemAdminGroupGrant
 54	ProblemBatchSize
 55	ProblemUserPending
 56	ProblemUnknownBatch
 57	ProblemSSHKeyDisabled
 58	ProblemSSHKeyPending
 59)
 60
 61var problemNames = map[Problem]string{
 62	ProblemUsernameGrammar:    "the username does not follow the username grammar",
 63	ProblemUsernameTaken:      "the username is taken",
 64	ProblemDisplayNameInvalid: "the display name is not valid",
 65	ProblemGroupNameGrammar:   "the group name does not follow the group name grammar",
 66	ProblemGroupNameTaken:     "the group name is taken",
 67	ProblemDescriptionInvalid: "the description is not valid",
 68	ProblemUnknownUser:        "no user has that username",
 69	ProblemUnknownGroup:       "no group has that name",
 70	ProblemAlreadyMember:      "the user is already a member of the group",
 71	ProblemNotMember:          "the user is not a member of the group",
 72	ProblemPrimaryGroup:       "the primary group's membership cannot be changed",
 73	ProblemSelf:               "an administrator cannot do this to their own account",
 74	ProblemLastAdmin:          "the admin group would have no enabled member",
 75	ProblemReferencedGroup:    "the group is named in the configuration",
 76	ProblemInvitationClosed:   "the invitation is no longer pending",
 77	ProblemGoogleNotLinked:    "the user has no linked Google account",
 78	ProblemResetLinkDisabled:  "a reset link cannot be issued to a disabled account",
 79	ProblemResetLinkPending:   "a reset link cannot be issued to an account waiting for its confirmation",
 80	ProblemLabelInvalid:       "a label is empty or not valid",
 81	ProblemExpiryInvalid:      "the expiry is not one of the offered choices",
 82	ProblemAdminGroupGrant:    "an invitation cannot grant the admin group",
 83	ProblemBatchSize:          "the batch is empty or larger than the cap",
 84	ProblemUserPending:        "the account is waiting for its confirmation",
 85	ProblemUnknownBatch:       "no invitation batch has that id",
 86	ProblemSSHKeyDisabled:     "a disabled account takes no new SSH key",
 87	ProblemSSHKeyPending:      "an account waiting for its confirmation takes no SSH key",
 88}
 89
 90func (p Problem) Error() string {
 91	if s, ok := problemNames[p]; ok {
 92		return "admin: " + s
 93	}
 94	return "admin: unknown problem " + strconv.Itoa(int(p))
 95}
 96
 97type AuditEntry struct {
 98	At         time.Time
 99	Actor      string
100	Action     string
101	Subject    string
102	NewSession bool
103}
104
105type Dashboard struct {
106	Users              int
107	DisabledUsers      int
108	Groups             int
109	PendingInvitations int
110	LiveSessions       int
111	Recent             []AuditEntry
112}
113
114type UserRow struct {
115	ID          string
116	Username    string
117	DisplayName string
118	Disabled    bool
119	Pending     bool
120	Groups      []string
121	GroupCount  int
122}
123
124type UserList struct {
125	Users []UserRow
126	Total int
127}
128
129type GroupRef struct {
130	ID      string
131	Name    string
132	Primary bool
133}
134
135type GoogleLink struct {
136	LinkedAt time.Time
137}
138
139type User struct {
140	ID          string
141	Username    string
142	DisplayName string
143	UIDNumber   int
144	Disabled    bool
145	Pending     bool
146	Admin       bool
147	CreatedAt   time.Time
148	LastLoginAt *time.Time
149	Groups      []GroupRef
150	GroupCount  int
151	Google      *GoogleLink
152	Sessions    int
153	Activity    []AuditEntry
154	SSHKeys     []SSHKey
155}
156
157type Memberships struct {
158	Username   string
159	Groups     []GroupRef
160	Total      int
161	Candidates []string
162}
163
164type GroupRow struct {
165	ID          string
166	Name        string
167	Description string
168	Members     int
169}
170
171type GroupList struct {
172	Groups []GroupRow
173	Total  int
174}
175
176type Member struct {
177	ID          string
178	Username    string
179	DisplayName string
180	Pending     bool
181}
182
183type Group struct {
184	ID            string
185	Name          string
186	Description   string
187	GIDNumber     int
188	Primary       bool
189	Referenced    bool
190	Members       []Member
191	Total         int
192	Disabled      []Member
193	DisabledTotal int
194}
195
196type InvitationState int
197
198const (
199	InvitationPending InvitationState = iota + 1
200	InvitationUsed
201	InvitationRevoked
202	InvitationExpired
203)
204
205type Invitation struct {
206	ID            string
207	CreatedBy     string
208	CreatedAt     time.Time
209	ExpiresAt     time.Time
210	State         InvitationState
211	Label         string
212	UsedBy        string
213	UsedByID      string
214	UsedByPending bool
215	UsedAt        *time.Time
216	Batch         string
217	Groups        []InvitationGroup
218}
219
220type InvitationGroup struct {
221	Name    string
222	Deleted bool
223}
224
225type InvitationList struct {
226	Invitations []Invitation
227	Total       int
228}
229
230type NewUser struct {
231	Username    string
232	DisplayName string
233}
234
235type Profile struct {
236	DisplayName string
237}
238
239type NewGroup struct {
240	Name        string
241	Description string
242}
243
244type Config struct {
245	Store            *storage.Store
246	Invites          *invite.Service
247	Logger           *slog.Logger
248	AdminGroup       string
249	PrimaryGroup     string
250	DefaultGroups    []string
251	ReferencedGroups []string
252	Now              func() time.Time
253	AuditRetention   time.Duration
254}
255
256type Service struct {
257	store         *storage.Store
258	invites       *invite.Service
259	logger        *slog.Logger
260	adminGroup    string
261	primaryGroup  string
262	groupsMu      sync.RWMutex
263	defaultGroups []string
264	referenced    []string
265	now           func() time.Time
266	retention     time.Duration
267}
268
269func New(cfg Config) (*Service, error) {
270	switch {
271	case cfg.Store == nil:
272		return nil, errors.New("admin: store is nil")
273	case cfg.Invites == nil:
274		return nil, errors.New("admin: invitation service is nil")
275	case cfg.Logger == nil:
276		return nil, errors.New("admin: logger is nil")
277	case strings.TrimSpace(cfg.AdminGroup) == "":
278		return nil, errors.New("admin: admin group is empty")
279	case strings.TrimSpace(cfg.PrimaryGroup) == "":
280		return nil, errors.New("admin: primary group is empty")
281	case cfg.AdminGroup == cfg.PrimaryGroup:
282		return nil, errors.New("admin: the admin group is the primary group")
283	}
284	if err := checkDefaultGroups(cfg.AdminGroup, cfg.PrimaryGroup, cfg.DefaultGroups, cfg.ReferencedGroups); err != nil {
285		return nil, err
286	}
287	now := cfg.Now
288	if now == nil {
289		now = time.Now
290	}
291	return &Service{
292		store:         cfg.Store,
293		invites:       cfg.Invites,
294		logger:        cfg.Logger,
295		adminGroup:    cfg.AdminGroup,
296		primaryGroup:  cfg.PrimaryGroup,
297		defaultGroups: slices.Clone(cfg.DefaultGroups),
298		referenced:    slices.Clone(cfg.ReferencedGroups),
299		now:           now,
300		retention:     cfg.AuditRetention,
301	}, nil
302}
303
304func checkDefaultGroups(adminGroup, primaryGroup string, defaults, referenced []string) error {
305	if slices.Contains(defaults, adminGroup) {
306		return errors.New("admin: the admin group is a default group")
307	}
308	for _, name := range append([]string{adminGroup, primaryGroup}, defaults...) {
309		if !slices.Contains(referenced, name) {
310			return fmt.Errorf("admin: group %q is not among the referenced groups", name)
311		}
312	}
313	return nil
314}
315
316func (s *Service) SetDefaultGroups(defaults, referenced []string) error {
317	if err := checkDefaultGroups(s.adminGroup, s.primaryGroup, defaults, referenced); err != nil {
318		return err
319	}
320	s.groupsMu.Lock()
321	defer s.groupsMu.Unlock()
322	s.defaultGroups, s.referenced = slices.Clone(defaults), slices.Clone(referenced)
323	return nil
324}
325
326func (s *Service) configuredGroups() (defaults, referenced []string) {
327	s.groupsMu.RLock()
328	defer s.groupsMu.RUnlock()
329	return s.defaultGroups, s.referenced
330}
331
332func window[T any](items []T, page, size int) []T {
333	if page < 1 {
334		page = 1
335	}
336	if page-1 > len(items)/size {
337		return nil
338	}
339	start := (page - 1) * size
340	if start >= len(items) {
341		return nil
342	}
343	return items[start:min(start+size, len(items))]
344}
345
346func (s *Service) primary(ctx context.Context) (*storage.Group, error) {
347	group, err := s.store.GroupByName(ctx, s.primaryGroup)
348	if errors.Is(err, storage.ErrNotFound) {
349		return nil, nil
350	}
351	if err != nil {
352		return nil, failed("read the primary group", err)
353	}
354	return &group, nil
355}
356
357func refs(groups []storage.Group, primary *storage.Group) []GroupRef {
358	out := make([]GroupRef, 0, len(groups)+1)
359	if primary != nil {
360		out = append(out, GroupRef{ID: primary.ID, Name: primary.Name, Primary: true})
361	}
362	for _, g := range groups {
363		if primary != nil && g.ID == primary.ID {
364			continue
365		}
366		out = append(out, GroupRef{ID: g.ID, Name: g.Name})
367	}
368	return out
369}
370
371func failed(operation string, err error) error {
372	return fmt.Errorf("admin: %s: %w", operation, err)
373}
374
375func target(operation string, err error) error {
376	if errors.Is(err, storage.ErrNotFound) {
377		return ErrNotFound
378	}
379	return failed(operation, err)
380}