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}