package storage import ( "context" "database/sql" "errors" "fmt" "slices" "strings" "time" ) var ErrAdminGroupGrant = errors.New("storage: an invitation cannot grant the admin group") type InvitationGrant struct { GroupID *string GroupName string } type NewInvitation struct { TokenHash []byte Label string } type CreateInvitationsInput struct { Invitations []NewInvitation GroupIDs []string CreatedBy string ValidFor time.Duration AdminGroup string } type InvitationRecord struct { Invitation Label string BatchID *string Groups []InvitationGrant CreatedByName *string UsedByName *string } type InvitationPage struct { Records []InvitationRecord Total int } func CheckInvitationLabel(value string) error { if value == "" { return fmt.Errorf("label is empty") } if err := validateDisplayName(value); err != nil { return fmt.Errorf("label: %w", err) } return nil } func (s *Store) CreateInvitationsAudited(ctx context.Context, input CreateInvitationsInput) ([]InvitationRecord, error) { const operation = "create invitations" if err := s.checkOpen(operation); err != nil { return nil, err } if len(input.Invitations) == 0 { return nil, invalid(operation, fmt.Errorf("no invitation to create")) } for _, inv := range input.Invitations { if err := CheckInvitationLabel(inv.Label); err != nil { return nil, invalid(operation, err) } } var batch *string if len(input.Invitations) > 1 { id, err := s.newID() if err != nil { return nil, fmt.Errorf("%s: %w", operation, err) } batch = &id } var created []InvitationRecord err := s.audited(ctx, operation, input.CreatedBy, func(tx *sql.Tx, at time.Time) error { groups, err := invitationGroupsInTx(ctx, tx, operation, input.GroupIDs, input.AdminGroup) if err != nil { return err } names := make([]string, 0, len(groups)) grants := make([]InvitationGrant, 0, len(groups)) for _, g := range groups { id := g.ID names = append(names, g.Name) grants = append(grants, InvitationGrant{GroupID: &id, GroupName: g.Name}) } created = make([]InvitationRecord, 0, len(input.Invitations)) for _, inv := range input.Invitations { invitation, err := s.prepareInvitation(operation, CreateInvitationInput{TokenHash: inv.TokenHash, CreatedBy: &input.CreatedBy, ExpiresAt: at.Add(input.ValidFor)}, at) if err != nil { return err } if _, err := tx.ExecContext(ctx, ` INSERT INTO invitations ( id, token_hash, created_by, created_at, expires_at, used_by, used_at, revoked_at, label, batch_id ) VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?)`, invitation.ID, invitation.TokenHash, input.CreatedBy, timeToInt(invitation.CreatedAt), timeToInt(invitation.ExpiresAt), inv.Label, batch); err != nil { return classify(operation, err) } for _, g := range groups { if _, err := tx.ExecContext(ctx, `INSERT INTO invitation_groups (invitation_id, group_id, group_name) VALUES (?, ?, ?)`, invitation.ID, g.ID, g.Name); err != nil { return classify(operation+" groups", err) } } fields := map[string]any{"groups": names, "expires_at": invitation.ExpiresAt.UTC().Format(time.RFC3339)} if batch != nil { fields["batch"] = *batch } if err := writeAudit(ctx, tx, operation, input.CreatedBy, "invitation.create", invitation.ID, at, fields); err != nil { return err } created = append(created, InvitationRecord{Invitation: invitation, Label: inv.Label, BatchID: batch, Groups: grants}) } return nil }) if err != nil { return nil, err } return created, nil } func invitationGroupsInTx(ctx context.Context, tx *sql.Tx, operation string, ids []string, adminGroup string) ([]Group, error) { var groups []Group for _, id := range ids { group, err := groupInTx(ctx, tx, operation+" group", id) if err != nil { return nil, err } if adminGroup != "" && group.Name == adminGroup { return nil, fmt.Errorf("%s: %w", operation, ErrAdminGroupGrant) } if !slices.ContainsFunc(groups, func(g Group) bool { return g.ID == group.ID }) { groups = append(groups, group) } } slices.SortFunc(groups, func(a, b Group) int { return strings.Compare(a.Name, b.Name) }) return groups, nil } func grantInvitationGroups(ctx context.Context, tx *sql.Tx, operation, invitationID, userID, adminGroup string, granted []string) ([]string, []string, error) { rows, err := tx.QueryContext(ctx, ` SELECT ig.group_name, g.id, g.name FROM invitation_groups ig LEFT JOIN groups g ON g.id = ig.group_id WHERE ig.invitation_id = ? ORDER BY ig.group_name`, invitationID) if err != nil { return nil, nil, classify(operation+" invitation groups", err) } type stored struct { name string id, live sql.NullString } var list []stored for rows.Next() { var r stored if err := rows.Scan(&r.name, &r.id, &r.live); err != nil { rows.Close() return nil, nil, classify(operation+" invitation groups", err) } list = append(list, r) } if err := rows.Err(); err != nil { rows.Close() return nil, nil, classify(operation+" invitation groups", err) } rows.Close() var dropped []string for _, r := range list { switch { case !r.id.Valid || r.live.String == adminGroup: dropped = append(dropped, r.name) case slices.Contains(granted, r.live.String): default: if err := addMembership(ctx, tx, operation, userID, r.id.String); err != nil { return nil, nil, err } granted = append(granted, r.live.String) } } return granted, dropped, nil } type extraScanner struct { rows *sql.Rows extra []any } func (e extraScanner) Scan(dest ...any) error { return e.rows.Scan(append(dest, e.extra...)...) } func (s *Store) ListInvitationPage(ctx context.Context, offset, limit int) (InvitationPage, error) { const operation = "list invitation page" if err := s.checkOpen(operation); err != nil { return InvitationPage{}, err } if offset < 0 || limit <= 0 { return InvitationPage{}, invalid(operation, fmt.Errorf("offset %d, limit %d", offset, limit)) } tx, err := s.sql.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) if err != nil { return InvitationPage{}, classify("begin "+operation, err) } defer tx.Rollback() var page InvitationPage if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM invitations`).Scan(&page.Total); err != nil { return InvitationPage{}, classify(operation, err) } rows, err := tx.QueryContext(ctx, ` SELECT i.id, i.token_hash, i.created_by, i.created_at, i.expires_at, i.used_by, i.used_at, i.revoked_at, i.label, i.batch_id, c.username, u.username FROM invitations i LEFT JOIN users c ON c.id = i.created_by LEFT JOIN users u ON u.id = i.used_by ORDER BY i.created_at DESC, i.id LIMIT ? OFFSET ?`, limit, offset) if err != nil { return InvitationPage{}, classify(operation, err) } index := map[string]int{} for rows.Next() { var r InvitationRecord var batch, creator, user sql.NullString inv, err := scanInvitation(extraScanner{rows: rows, extra: []any{&r.Label, &batch, &creator, &user}}) if err != nil { rows.Close() return InvitationPage{}, classify(operation, err) } r.Invitation = inv r.BatchID, r.CreatedByName, r.UsedByName = nullStringPtr(batch), nullStringPtr(creator), nullStringPtr(user) index[r.ID] = len(page.Records) page.Records = append(page.Records, r) } if err := rows.Err(); err != nil { rows.Close() return InvitationPage{}, classify(operation, err) } rows.Close() grants, err := tx.QueryContext(ctx, ` SELECT invitation_id, group_id, group_name FROM invitation_groups WHERE invitation_id IN (SELECT id FROM invitations ORDER BY created_at DESC, id LIMIT ? OFFSET ?) ORDER BY invitation_id, group_name`, limit, offset) if err != nil { return InvitationPage{}, classify(operation+" groups", err) } defer grants.Close() for grants.Next() { var invitationID, name string var groupID sql.NullString if err := grants.Scan(&invitationID, &groupID, &name); err != nil { return InvitationPage{}, classify(operation+" groups", err) } if i, ok := index[invitationID]; ok { page.Records[i].Groups = append(page.Records[i].Groups, InvitationGrant{GroupID: nullStringPtr(groupID), GroupName: name}) } } if err := grants.Err(); err != nil { return InvitationPage{}, classify(operation+" groups", err) } return page, nil }