Raw
  1package storage
  2
  3import (
  4	"context"
  5	"database/sql"
  6	"fmt"
  7	"strings"
  8)
  9
 10type UserbaseSnapshot struct {
 11	NextUIDNumber        int64
 12	NextGIDNumber        int64
 13	Users                []UserbaseUser
 14	Groups               []UserbaseGroup
 15	Memberships          []UserbaseMembership
 16	GoogleLinks          []UserbaseGoogleLink
 17	Invitations          []UserbaseInvitation
 18	InvitationGroups     []UserbaseInvitationGroup
 19	PendingConfirmations []UserbasePendingConfirmation
 20	SSHKeys              []UserbaseSSHKey
 21}
 22
 23type UserbaseUser struct {
 24	ID           string
 25	Username     string
 26	DisplayName  string
 27	PasswordHash []byte
 28	Disabled     bool
 29	UIDNumber    int64
 30	CreatedAt    int64
 31}
 32
 33type UserbaseGroup struct {
 34	ID          string
 35	Name        string
 36	Description string
 37	GIDNumber   int64
 38	CreatedAt   int64
 39}
 40
 41type UserbaseMembership struct {
 42	UserID  string
 43	GroupID string
 44}
 45
 46type UserbaseGoogleLink struct {
 47	UserID    string
 48	GoogleSub string
 49	LinkedAt  int64
 50}
 51
 52type UserbaseInvitation struct {
 53	ID        string
 54	TokenHash []byte
 55	CreatedBy *string
 56	CreatedAt int64
 57	ExpiresAt int64
 58	UsedBy    *string
 59	UsedAt    *int64
 60	RevokedAt *int64
 61	Label     string
 62	BatchID   *string
 63}
 64
 65type UserbaseInvitationGroup struct {
 66	InvitationID string
 67	GroupID      *string
 68	GroupName    string
 69}
 70
 71type UserbasePendingConfirmation struct {
 72	UserID     string
 73	CodeHash   []byte
 74	IssuedAt   int64
 75	ExpiresAt  int64
 76	WrongCodes int64
 77}
 78
 79type UserbaseSSHKey struct {
 80	ID          string
 81	UserID      string
 82	Fingerprint string
 83	KeyType     string
 84	KeyData     []byte
 85	Label       string
 86	AddedAt     int64
 87}
 88
 89type userbaseTable struct {
 90	name    string
 91	columns []string
 92	order   string
 93}
 94
 95var userbaseTables = []userbaseTable{
 96	{"users", []string{"id", "username", "display_name", "password_hash", "disabled", "uid_number", "created_at"}, "id"},
 97	{"groups", []string{"id", "name", "description", "gid_number", "created_at"}, "id"},
 98	{"memberships", []string{"user_id", "group_id"}, "user_id, group_id"},
 99	{"google_links", []string{"user_id", "google_sub", "linked_at"}, "user_id"},
100	{"invitations", []string{"id", "token_hash", "created_by", "created_at", "expires_at", "used_by", "used_at", "revoked_at", "label", "batch_id"}, "id"},
101	{"invitation_groups", []string{"invitation_id", "group_id", "group_name"}, "invitation_id, group_name"},
102	{"pending_confirmations", []string{"user_id", "code_hash", "issued_at", "expires_at", "wrong_codes"}, "user_id"},
103	{"ssh_keys", []string{"id", "user_id", "fingerprint", "key_type", "key_data", "label", "added_at"}, "id"},
104	{"settings", []string{"next_uid_number", "next_gid_number"}, "id"},
105}
106
107var afterUserbaseTableRead = func(string) {}
108
109func UserbaseExportColumns() map[string][]string {
110	columns := map[string][]string{}
111	for _, table := range userbaseTables {
112		columns[table.name] = append([]string(nil), table.columns...)
113	}
114	return columns
115}
116
117func (t userbaseTable) query() string {
118	return `SELECT ` + strings.Join(t.columns, ", ") + ` FROM ` + t.name + ` ORDER BY ` + t.order
119}
120
121func (s *Store) ExportUserbase(ctx context.Context) (UserbaseSnapshot, error) {
122	const operation = "export userbase"
123	if err := s.checkOpen(operation); err != nil {
124		return UserbaseSnapshot{}, err
125	}
126	tx, err := s.sql.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
127	if err != nil {
128		return UserbaseSnapshot{}, classify("begin "+operation, err)
129	}
130	defer tx.Rollback()
131	var snap UserbaseSnapshot
132	for _, table := range userbaseTables {
133		if err := readUserbaseTable(ctx, tx, table, &snap); err != nil {
134			return UserbaseSnapshot{}, classify(operation+" "+table.name, err)
135		}
136		afterUserbaseTableRead(table.name)
137	}
138	if err := tx.Commit(); err != nil {
139		return UserbaseSnapshot{}, classify("end "+operation, err)
140	}
141	return snap, nil
142}
143
144type userbaseQuerier interface {
145	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
146}
147
148func readUserbaseTable(ctx context.Context, q userbaseQuerier, table userbaseTable, snap *UserbaseSnapshot) error {
149	rows, err := q.QueryContext(ctx, table.query())
150	if err != nil {
151		return err
152	}
153	defer rows.Close()
154	settings := 0
155	for rows.Next() {
156		switch table.name {
157		case "users":
158			var u UserbaseUser
159			var disabled int
160			if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, &disabled, &u.UIDNumber, &u.CreatedAt); err != nil {
161				return err
162			}
163			u.Disabled = disabled != 0
164			snap.Users = append(snap.Users, u)
165		case "groups":
166			var g UserbaseGroup
167			if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.GIDNumber, &g.CreatedAt); err != nil {
168				return err
169			}
170			snap.Groups = append(snap.Groups, g)
171		case "memberships":
172			var m UserbaseMembership
173			if err := rows.Scan(&m.UserID, &m.GroupID); err != nil {
174				return err
175			}
176			snap.Memberships = append(snap.Memberships, m)
177		case "google_links":
178			var l UserbaseGoogleLink
179			if err := rows.Scan(&l.UserID, &l.GoogleSub, &l.LinkedAt); err != nil {
180				return err
181			}
182			snap.GoogleLinks = append(snap.GoogleLinks, l)
183		case "invitations":
184			var i UserbaseInvitation
185			var createdBy, usedBy, batch sql.NullString
186			var usedAt, revokedAt sql.NullInt64
187			if err := rows.Scan(&i.ID, &i.TokenHash, &createdBy, &i.CreatedAt, &i.ExpiresAt, &usedBy, &usedAt, &revokedAt, &i.Label, &batch); err != nil {
188				return err
189			}
190			i.CreatedBy, i.UsedBy = nullStringPtr(createdBy), nullStringPtr(usedBy)
191			i.UsedAt, i.RevokedAt = nullInt64Ptr(usedAt), nullInt64Ptr(revokedAt)
192			i.BatchID = nullStringPtr(batch)
193			snap.Invitations = append(snap.Invitations, i)
194		case "invitation_groups":
195			var g UserbaseInvitationGroup
196			var groupID sql.NullString
197			if err := rows.Scan(&g.InvitationID, &groupID, &g.GroupName); err != nil {
198				return err
199			}
200			g.GroupID = nullStringPtr(groupID)
201			snap.InvitationGroups = append(snap.InvitationGroups, g)
202		case "pending_confirmations":
203			var p UserbasePendingConfirmation
204			if err := rows.Scan(&p.UserID, &p.CodeHash, &p.IssuedAt, &p.ExpiresAt, &p.WrongCodes); err != nil {
205				return err
206			}
207			snap.PendingConfirmations = append(snap.PendingConfirmations, p)
208		case "ssh_keys":
209			var k UserbaseSSHKey
210			if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.KeyType, &k.KeyData, &k.Label, &k.AddedAt); err != nil {
211				return err
212			}
213			snap.SSHKeys = append(snap.SSHKeys, k)
214		case "settings":
215			if err := rows.Scan(&snap.NextUIDNumber, &snap.NextGIDNumber); err != nil {
216				return err
217			}
218			settings++
219		default:
220			return fmt.Errorf("no reader for table %s", table.name)
221		}
222	}
223	if err := rows.Err(); err != nil {
224		return err
225	}
226	if table.name == "settings" && settings != 1 {
227		return fmt.Errorf("settings holds %d rows, want 1: %w", settings, ErrSchemaInvalid)
228	}
229	return nil
230}
231
232func nullStringPtr(value sql.NullString) *string {
233	if !value.Valid {
234		return nil
235	}
236	v := value.String
237	return &v
238}
239
240func nullInt64Ptr(value sql.NullInt64) *int64 {
241	if !value.Valid {
242		return nil
243	}
244	v := value.Int64
245	return &v
246}