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