package storage import ( "context" "database/sql" "errors" "fmt" "tug/internal/platform/sshkey" ) var ErrUserbaseNotEmpty = errors.New("storage: the store is not empty, so a user base cannot be imported into it") func (s *Store) UserbaseImportable(ctx context.Context) error { const operation = "check userbase importable" if err := s.checkOpen(operation); err != nil { return err } tx, err := s.sql.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) if err != nil { return classify("begin "+operation, err) } defer tx.Rollback() if err := checkEmptyForImport(ctx, tx, operation); err != nil { return err } return classify("end "+operation, tx.Commit()) } func (s *Store) ImportUserbase(ctx context.Context, snapshot UserbaseSnapshot, restored, checkpoint string) error { const operation = "import userbase" if err := s.checkOpen(operation); err != nil { return err } if err := requireValue("restored commit", restored); err != nil { return invalid(operation, err) } if err := requireValue("checkpoint", checkpoint); err != nil { return invalid(operation, err) } if err := checkUserbaseCounters(snapshot); err != nil { return invalid(operation, err) } tx, err := s.sql.BeginTx(ctx, nil) if err != nil { return classify("begin "+operation, err) } defer tx.Rollback() if err := checkEmptyForImport(ctx, tx, operation); err != nil { return err } if _, err := tx.ExecContext(ctx, `DELETE FROM groups`); err != nil { return classify(operation+" replace started groups", err) } if err := insertUserbase(ctx, tx, operation, snapshot); err != nil { return err } if _, err := tx.ExecContext(ctx, ` UPDATE settings SET next_uid_number = ?, next_gid_number = ?, bootstrap_token_hash = NULL, bootstrap_expires_at = NULL, userbase_commit = ? WHERE id = 1`, snapshot.NextUIDNumber, snapshot.NextGIDNumber, checkpoint); err != nil { return classify(operation+" settings", err) } if err := writeAudit(ctx, tx, operation, "system", "userbase.restore", "userbase", canonicalTime(s.now()), map[string]any{ "restored": restored, "checkpoint": checkpoint, "users": len(snapshot.Users), "groups": len(snapshot.Groups), "memberships": len(snapshot.Memberships), "google_links": len(snapshot.GoogleLinks), "invitations": len(snapshot.Invitations), "invitation_groups": len(snapshot.InvitationGroups), "pending_confirmations": len(snapshot.PendingConfirmations), "ssh_keys": len(snapshot.SSHKeys), }); err != nil { return err } return classify("commit "+operation, tx.Commit()) } func checkUserbaseCounters(snapshot UserbaseSnapshot) error { for _, u := range snapshot.Users { if u.UIDNumber >= snapshot.NextUIDNumber { return fmt.Errorf("uid %d of %s is not below next_uid_number %d", u.UIDNumber, u.Username, snapshot.NextUIDNumber) } } for _, g := range snapshot.Groups { if g.GIDNumber >= snapshot.NextGIDNumber { return fmt.Errorf("gid %d of %s is not below next_gid_number %d", g.GIDNumber, g.Name, snapshot.NextGIDNumber) } } return nil } func checkEmptyForImport(ctx context.Context, tx *sql.Tx, operation string) error { var users, memberships, links, invitations, nextUID int64 var checkpoint sql.NullString if err := tx.QueryRowContext(ctx, ` SELECT (SELECT count(*) FROM users), (SELECT count(*) FROM memberships), (SELECT count(*) FROM google_links), (SELECT count(*) FROM invitations), next_uid_number, userbase_commit FROM settings WHERE id = 1`).Scan(&users, &memberships, &links, &invitations, &nextUID, &checkpoint); err != nil { return classify(operation+" emptiness", err) } switch { case users != 0: return fmt.Errorf("%s: %w: it holds %d users", operation, ErrUserbaseNotEmpty, users) case nextUID != firstUID: return fmt.Errorf("%s: %w: it allocated uids up to %d, so it held a user", operation, ErrUserbaseNotEmpty, nextUID-1) case checkpoint.Valid: return fmt.Errorf("%s: %w: it already holds a userbase checkpoint", operation, ErrUserbaseNotEmpty) case memberships != 0 || links != 0 || invitations != 0: return fmt.Errorf("%s: %w: it holds memberships, Google links or invitations", operation, ErrUserbaseNotEmpty) } return nil } func insertUserbase(ctx context.Context, tx *sql.Tx, operation string, snapshot UserbaseSnapshot) error { for _, u := range snapshot.Users { if _, err := tx.ExecContext(ctx, ` INSERT INTO users (id, username, display_name, password_hash, disabled, uid_number, created_at, last_login_at, credentials_epoch) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 0)`, u.ID, u.Username, u.DisplayName, u.PasswordHash, boolToInt(u.Disabled), u.UIDNumber, u.CreatedAt); err != nil { return classify(operation+" user "+u.Username, err) } } for _, k := range snapshot.SSHKeys { if err := checkUserbaseSSHKey(k); err != nil { return invalid(operation+" ssh key "+k.ID, err) } 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 { return classify(operation+" ssh key", err) } } for _, p := range snapshot.PendingConfirmations { 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 { return classify(operation+" pending confirmation", err) } } for _, g := range snapshot.Groups { 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 { return classify(operation+" group "+g.Name, err) } } for _, m := range snapshot.Memberships { if _, err := tx.ExecContext(ctx, `INSERT INTO memberships (user_id, group_id) VALUES (?, ?)`, m.UserID, m.GroupID); err != nil { return classify(operation+" membership", err) } } for _, l := range snapshot.GoogleLinks { if _, err := tx.ExecContext(ctx, `INSERT INTO google_links (user_id, google_sub, linked_at) VALUES (?, ?, ?)`, l.UserID, l.GoogleSub, l.LinkedAt); err != nil { return classify(operation+" google link", err) } } for _, i := range snapshot.Invitations { 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, i.ID, i.TokenHash, i.CreatedBy, i.CreatedAt, i.ExpiresAt, i.UsedBy, i.UsedAt, i.RevokedAt, i.Label, i.BatchID); err != nil { return classify(operation+" invitation", err) } } for _, g := range snapshot.InvitationGroups { if _, err := tx.ExecContext(ctx, `INSERT INTO invitation_groups (invitation_id, group_id, group_name) VALUES (?, ?, ?)`, g.InvitationID, g.GroupID, g.GroupName); err != nil { return classify(operation+" invitation group", err) } } return nil } func checkUserbaseSSHKey(k UserbaseSSHKey) error { if err := sshkey.Check(k.KeyType, k.KeyData); err != nil { return err } if sshkey.Fingerprint(k.KeyData) != k.Fingerprint { return fmt.Errorf("fingerprint %s is not that of the key's data", k.Fingerprint) } return CheckSSHKeyLabel(k.Label) }