1package storage
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "slices"
9 "strings"
10 "time"
11)
12
13var ErrAdminGroupGrant = errors.New("storage: an invitation cannot grant the admin group")
14
15type InvitationGrant struct {
16 GroupID *string
17 GroupName string
18}
19
20type NewInvitation struct {
21 TokenHash []byte
22 Label string
23}
24
25type CreateInvitationsInput struct {
26 Invitations []NewInvitation
27 GroupIDs []string
28 CreatedBy string
29 ValidFor time.Duration
30 AdminGroup string
31}
32
33type InvitationRecord struct {
34 Invitation
35 Label string
36 BatchID *string
37 Groups []InvitationGrant
38 CreatedByName *string
39 UsedByName *string
40}
41
42type InvitationPage struct {
43 Records []InvitationRecord
44 Total int
45}
46
47func CheckInvitationLabel(value string) error {
48 if value == "" {
49 return fmt.Errorf("label is empty")
50 }
51 if err := validateDisplayName(value); err != nil {
52 return fmt.Errorf("label: %w", err)
53 }
54 return nil
55}
56
57func (s *Store) CreateInvitationsAudited(ctx context.Context, input CreateInvitationsInput) ([]InvitationRecord, error) {
58 const operation = "create invitations"
59 if err := s.checkOpen(operation); err != nil {
60 return nil, err
61 }
62 if len(input.Invitations) == 0 {
63 return nil, invalid(operation, fmt.Errorf("no invitation to create"))
64 }
65 for _, inv := range input.Invitations {
66 if err := CheckInvitationLabel(inv.Label); err != nil {
67 return nil, invalid(operation, err)
68 }
69 }
70 var batch *string
71 if len(input.Invitations) > 1 {
72 id, err := s.newID()
73 if err != nil {
74 return nil, fmt.Errorf("%s: %w", operation, err)
75 }
76 batch = &id
77 }
78 var created []InvitationRecord
79 err := s.audited(ctx, operation, input.CreatedBy, func(tx *sql.Tx, at time.Time) error {
80 groups, err := invitationGroupsInTx(ctx, tx, operation, input.GroupIDs, input.AdminGroup)
81 if err != nil {
82 return err
83 }
84 names := make([]string, 0, len(groups))
85 grants := make([]InvitationGrant, 0, len(groups))
86 for _, g := range groups {
87 id := g.ID
88 names = append(names, g.Name)
89 grants = append(grants, InvitationGrant{GroupID: &id, GroupName: g.Name})
90 }
91 created = make([]InvitationRecord, 0, len(input.Invitations))
92 for _, inv := range input.Invitations {
93 invitation, err := s.prepareInvitation(operation, CreateInvitationInput{TokenHash: inv.TokenHash, CreatedBy: &input.CreatedBy, ExpiresAt: at.Add(input.ValidFor)}, at)
94 if err != nil {
95 return err
96 }
97 if _, err := tx.ExecContext(ctx, `
98INSERT INTO invitations (
99 id, token_hash, created_by, created_at, expires_at,
100 used_by, used_at, revoked_at, label, batch_id
101) VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?)`, invitation.ID, invitation.TokenHash, input.CreatedBy,
102 timeToInt(invitation.CreatedAt), timeToInt(invitation.ExpiresAt), inv.Label, batch); err != nil {
103 return classify(operation, err)
104 }
105 for _, g := range groups {
106 if _, err := tx.ExecContext(ctx, `INSERT INTO invitation_groups (invitation_id, group_id, group_name) VALUES (?, ?, ?)`, invitation.ID, g.ID, g.Name); err != nil {
107 return classify(operation+" groups", err)
108 }
109 }
110 fields := map[string]any{"groups": names, "expires_at": invitation.ExpiresAt.UTC().Format(time.RFC3339)}
111 if batch != nil {
112 fields["batch"] = *batch
113 }
114 if err := writeAudit(ctx, tx, operation, input.CreatedBy, "invitation.create", invitation.ID, at, fields); err != nil {
115 return err
116 }
117 created = append(created, InvitationRecord{Invitation: invitation, Label: inv.Label, BatchID: batch, Groups: grants})
118 }
119 return nil
120 })
121 if err != nil {
122 return nil, err
123 }
124 return created, nil
125}
126
127func invitationGroupsInTx(ctx context.Context, tx *sql.Tx, operation string, ids []string, adminGroup string) ([]Group, error) {
128 var groups []Group
129 for _, id := range ids {
130 group, err := groupInTx(ctx, tx, operation+" group", id)
131 if err != nil {
132 return nil, err
133 }
134 if adminGroup != "" && group.Name == adminGroup {
135 return nil, fmt.Errorf("%s: %w", operation, ErrAdminGroupGrant)
136 }
137 if !slices.ContainsFunc(groups, func(g Group) bool { return g.ID == group.ID }) {
138 groups = append(groups, group)
139 }
140 }
141 slices.SortFunc(groups, func(a, b Group) int { return strings.Compare(a.Name, b.Name) })
142 return groups, nil
143}
144
145func grantInvitationGroups(ctx context.Context, tx *sql.Tx, operation, invitationID, userID, adminGroup string, granted []string) ([]string, []string, error) {
146 rows, err := tx.QueryContext(ctx, `
147SELECT ig.group_name, g.id, g.name FROM invitation_groups ig
148LEFT JOIN groups g ON g.id = ig.group_id
149WHERE ig.invitation_id = ? ORDER BY ig.group_name`, invitationID)
150 if err != nil {
151 return nil, nil, classify(operation+" invitation groups", err)
152 }
153 type stored struct {
154 name string
155 id, live sql.NullString
156 }
157 var list []stored
158 for rows.Next() {
159 var r stored
160 if err := rows.Scan(&r.name, &r.id, &r.live); err != nil {
161 rows.Close()
162 return nil, nil, classify(operation+" invitation groups", err)
163 }
164 list = append(list, r)
165 }
166 if err := rows.Err(); err != nil {
167 rows.Close()
168 return nil, nil, classify(operation+" invitation groups", err)
169 }
170 rows.Close()
171 var dropped []string
172 for _, r := range list {
173 switch {
174 case !r.id.Valid || r.live.String == adminGroup:
175 dropped = append(dropped, r.name)
176 case slices.Contains(granted, r.live.String):
177 default:
178 if err := addMembership(ctx, tx, operation, userID, r.id.String); err != nil {
179 return nil, nil, err
180 }
181 granted = append(granted, r.live.String)
182 }
183 }
184 return granted, dropped, nil
185}
186
187type extraScanner struct {
188 rows *sql.Rows
189 extra []any
190}
191
192func (e extraScanner) Scan(dest ...any) error {
193 return e.rows.Scan(append(dest, e.extra...)...)
194}
195
196func (s *Store) ListInvitationPage(ctx context.Context, offset, limit int) (InvitationPage, error) {
197 const operation = "list invitation page"
198 if err := s.checkOpen(operation); err != nil {
199 return InvitationPage{}, err
200 }
201 if offset < 0 || limit <= 0 {
202 return InvitationPage{}, invalid(operation, fmt.Errorf("offset %d, limit %d", offset, limit))
203 }
204 tx, err := s.sql.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
205 if err != nil {
206 return InvitationPage{}, classify("begin "+operation, err)
207 }
208 defer tx.Rollback()
209 var page InvitationPage
210 if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM invitations`).Scan(&page.Total); err != nil {
211 return InvitationPage{}, classify(operation, err)
212 }
213 rows, err := tx.QueryContext(ctx, `
214SELECT i.id, i.token_hash, i.created_by, i.created_at, i.expires_at,
215 i.used_by, i.used_at, i.revoked_at, i.label, i.batch_id, c.username, u.username
216FROM invitations i
217LEFT JOIN users c ON c.id = i.created_by
218LEFT JOIN users u ON u.id = i.used_by
219ORDER BY i.created_at DESC, i.id LIMIT ? OFFSET ?`, limit, offset)
220 if err != nil {
221 return InvitationPage{}, classify(operation, err)
222 }
223 index := map[string]int{}
224 for rows.Next() {
225 var r InvitationRecord
226 var batch, creator, user sql.NullString
227 inv, err := scanInvitation(extraScanner{rows: rows, extra: []any{&r.Label, &batch, &creator, &user}})
228 if err != nil {
229 rows.Close()
230 return InvitationPage{}, classify(operation, err)
231 }
232 r.Invitation = inv
233 r.BatchID, r.CreatedByName, r.UsedByName = nullStringPtr(batch), nullStringPtr(creator), nullStringPtr(user)
234 index[r.ID] = len(page.Records)
235 page.Records = append(page.Records, r)
236 }
237 if err := rows.Err(); err != nil {
238 rows.Close()
239 return InvitationPage{}, classify(operation, err)
240 }
241 rows.Close()
242 grants, err := tx.QueryContext(ctx, `
243SELECT invitation_id, group_id, group_name FROM invitation_groups
244WHERE invitation_id IN (SELECT id FROM invitations ORDER BY created_at DESC, id LIMIT ? OFFSET ?)
245ORDER BY invitation_id, group_name`, limit, offset)
246 if err != nil {
247 return InvitationPage{}, classify(operation+" groups", err)
248 }
249 defer grants.Close()
250 for grants.Next() {
251 var invitationID, name string
252 var groupID sql.NullString
253 if err := grants.Scan(&invitationID, &groupID, &name); err != nil {
254 return InvitationPage{}, classify(operation+" groups", err)
255 }
256 if i, ok := index[invitationID]; ok {
257 page.Records[i].Groups = append(page.Records[i].Groups, InvitationGrant{GroupID: nullStringPtr(groupID), GroupName: name})
258 }
259 }
260 if err := grants.Err(); err != nil {
261 return InvitationPage{}, classify(operation+" groups", err)
262 }
263 return page, nil
264}