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