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}