1package storage
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8
9 "tug/internal/platform/sshkey"
10)
11
12var ErrUserbaseNotEmpty = errors.New("storage: the store is not empty, so a user base cannot be imported into it")
13
14func (s *Store) UserbaseImportable(ctx context.Context) error {
15 const operation = "check userbase importable"
16 if err := s.checkOpen(operation); err != nil {
17 return err
18 }
19 tx, err := s.sql.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
20 if err != nil {
21 return classify("begin "+operation, err)
22 }
23 defer tx.Rollback()
24 if err := checkEmptyForImport(ctx, tx, operation); err != nil {
25 return err
26 }
27 return classify("end "+operation, tx.Commit())
28}
29
30func (s *Store) ImportUserbase(ctx context.Context, snapshot UserbaseSnapshot, restored, checkpoint string) error {
31 const operation = "import userbase"
32 if err := s.checkOpen(operation); err != nil {
33 return err
34 }
35 if err := requireValue("restored commit", restored); err != nil {
36 return invalid(operation, err)
37 }
38 if err := requireValue("checkpoint", checkpoint); err != nil {
39 return invalid(operation, err)
40 }
41 if err := checkUserbaseCounters(snapshot); err != nil {
42 return invalid(operation, err)
43 }
44 tx, err := s.sql.BeginTx(ctx, nil)
45 if err != nil {
46 return classify("begin "+operation, err)
47 }
48 defer tx.Rollback()
49 if err := checkEmptyForImport(ctx, tx, operation); err != nil {
50 return err
51 }
52 if _, err := tx.ExecContext(ctx, `DELETE FROM groups`); err != nil {
53 return classify(operation+" replace started groups", err)
54 }
55 if err := insertUserbase(ctx, tx, operation, snapshot); err != nil {
56 return err
57 }
58 if _, err := tx.ExecContext(ctx, `
59UPDATE settings
60SET next_uid_number = ?, next_gid_number = ?, bootstrap_token_hash = NULL,
61 bootstrap_expires_at = NULL, userbase_commit = ?
62WHERE id = 1`, snapshot.NextUIDNumber, snapshot.NextGIDNumber, checkpoint); err != nil {
63 return classify(operation+" settings", err)
64 }
65 if err := writeAudit(ctx, tx, operation, "system", "userbase.restore", "userbase", canonicalTime(s.now()), map[string]any{
66 "restored": restored,
67 "checkpoint": checkpoint,
68 "users": len(snapshot.Users),
69 "groups": len(snapshot.Groups),
70 "memberships": len(snapshot.Memberships),
71 "google_links": len(snapshot.GoogleLinks),
72 "invitations": len(snapshot.Invitations),
73 "invitation_groups": len(snapshot.InvitationGroups),
74 "pending_confirmations": len(snapshot.PendingConfirmations),
75 "ssh_keys": len(snapshot.SSHKeys),
76 }); err != nil {
77 return err
78 }
79 return classify("commit "+operation, tx.Commit())
80}
81
82func checkUserbaseCounters(snapshot UserbaseSnapshot) error {
83 for _, u := range snapshot.Users {
84 if u.UIDNumber >= snapshot.NextUIDNumber {
85 return fmt.Errorf("uid %d of %s is not below next_uid_number %d", u.UIDNumber, u.Username, snapshot.NextUIDNumber)
86 }
87 }
88 for _, g := range snapshot.Groups {
89 if g.GIDNumber >= snapshot.NextGIDNumber {
90 return fmt.Errorf("gid %d of %s is not below next_gid_number %d", g.GIDNumber, g.Name, snapshot.NextGIDNumber)
91 }
92 }
93 return nil
94}
95
96func checkEmptyForImport(ctx context.Context, tx *sql.Tx, operation string) error {
97 var users, memberships, links, invitations, nextUID int64
98 var checkpoint sql.NullString
99 if err := tx.QueryRowContext(ctx, `
100SELECT (SELECT count(*) FROM users), (SELECT count(*) FROM memberships),
101 (SELECT count(*) FROM google_links), (SELECT count(*) FROM invitations),
102 next_uid_number, userbase_commit
103FROM settings WHERE id = 1`).Scan(&users, &memberships, &links, &invitations, &nextUID, &checkpoint); err != nil {
104 return classify(operation+" emptiness", err)
105 }
106 switch {
107 case users != 0:
108 return fmt.Errorf("%s: %w: it holds %d users", operation, ErrUserbaseNotEmpty, users)
109 case nextUID != firstUID:
110 return fmt.Errorf("%s: %w: it allocated uids up to %d, so it held a user", operation, ErrUserbaseNotEmpty, nextUID-1)
111 case checkpoint.Valid:
112 return fmt.Errorf("%s: %w: it already holds a userbase checkpoint", operation, ErrUserbaseNotEmpty)
113 case memberships != 0 || links != 0 || invitations != 0:
114 return fmt.Errorf("%s: %w: it holds memberships, Google links or invitations", operation, ErrUserbaseNotEmpty)
115 }
116 return nil
117}
118
119func insertUserbase(ctx context.Context, tx *sql.Tx, operation string, snapshot UserbaseSnapshot) error {
120 for _, u := range snapshot.Users {
121 if _, err := tx.ExecContext(ctx, `
122INSERT INTO users (id, username, display_name, password_hash, disabled, uid_number, created_at, last_login_at, credentials_epoch)
123VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 0)`, u.ID, u.Username, u.DisplayName, u.PasswordHash, boolToInt(u.Disabled), u.UIDNumber, u.CreatedAt); err != nil {
124 return classify(operation+" user "+u.Username, err)
125 }
126 }
127 for _, k := range snapshot.SSHKeys {
128 if err := checkUserbaseSSHKey(k); err != nil {
129 return invalid(operation+" ssh key "+k.ID, err)
130 }
131 if _, err := tx.ExecContext(ctx, `INSERT INTO ssh_keys (`+sshKeyColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?)`, k.ID, k.UserID, k.Fingerprint, k.KeyType, k.KeyData, k.Label, k.AddedAt); err != nil {
132 return classify(operation+" ssh key", err)
133 }
134 }
135 for _, p := range snapshot.PendingConfirmations {
136 if _, err := tx.ExecContext(ctx, `INSERT INTO pending_confirmations (user_id, code_hash, issued_at, expires_at, wrong_codes) VALUES (?, ?, ?, ?, ?)`, p.UserID, p.CodeHash, p.IssuedAt, p.ExpiresAt, p.WrongCodes); err != nil {
137 return classify(operation+" pending confirmation", err)
138 }
139 }
140 for _, g := range snapshot.Groups {
141 if _, err := tx.ExecContext(ctx, `INSERT INTO groups (id, name, description, gid_number, created_at) VALUES (?, ?, ?, ?, ?)`, g.ID, g.Name, g.Description, g.GIDNumber, g.CreatedAt); err != nil {
142 return classify(operation+" group "+g.Name, err)
143 }
144 }
145 for _, m := range snapshot.Memberships {
146 if _, err := tx.ExecContext(ctx, `INSERT INTO memberships (user_id, group_id) VALUES (?, ?)`, m.UserID, m.GroupID); err != nil {
147 return classify(operation+" membership", err)
148 }
149 }
150 for _, l := range snapshot.GoogleLinks {
151 if _, err := tx.ExecContext(ctx, `INSERT INTO google_links (user_id, google_sub, linked_at) VALUES (?, ?, ?)`, l.UserID, l.GoogleSub, l.LinkedAt); err != nil {
152 return classify(operation+" google link", err)
153 }
154 }
155 for _, i := range snapshot.Invitations {
156 if _, err := tx.ExecContext(ctx, `
157INSERT INTO invitations (id, token_hash, created_by, created_at, expires_at, used_by, used_at, revoked_at, label, batch_id)
158VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, i.ID, i.TokenHash, i.CreatedBy, i.CreatedAt, i.ExpiresAt, i.UsedBy, i.UsedAt, i.RevokedAt, i.Label, i.BatchID); err != nil {
159 return classify(operation+" invitation", err)
160 }
161 }
162 for _, g := range snapshot.InvitationGroups {
163 if _, err := tx.ExecContext(ctx, `INSERT INTO invitation_groups (invitation_id, group_id, group_name) VALUES (?, ?, ?)`, g.InvitationID, g.GroupID, g.GroupName); err != nil {
164 return classify(operation+" invitation group", err)
165 }
166 }
167 return nil
168}
169
170func checkUserbaseSSHKey(k UserbaseSSHKey) error {
171 if err := sshkey.Check(k.KeyType, k.KeyData); err != nil {
172 return err
173 }
174 if sshkey.Fingerprint(k.KeyData) != k.Fingerprint {
175 return fmt.Errorf("fingerprint %s is not that of the key's data", k.Fingerprint)
176 }
177 return CheckSSHKeyLabel(k.Label)
178}