From f2981efc5cc9c96c4b5ea6eec037086c6c7436e3 Mon Sep 17 00:00:00 2001
From: Braden Shepherdson <Braden.Shepherdson@gmail.com>
Date: Thu, 15 Sep 2022 11:17:41 -0400
Subject: [PATCH] Serdes v2: Handle missing User refs by synthesizing dummy
 users (#25391)

Many entities have `creator_id` and similar fields. `User`s are not
serialized. Foreign keys to users are serialized as email addresses.

During deserialization in a different instance (eg. a local dev instance
importing a dump from a prod instance) many such users may not exist.
This change creates new `User` entities on the fly with empty names,
generated passwords, and the email set.
---
 .../serialization/v2/load_test.clj            | 58 +++++++++++++++++++
 src/metabase/models/card.clj                  |  4 +-
 src/metabase/models/collection.clj            | 10 ++--
 src/metabase/models/dashboard.clj             |  4 +-
 src/metabase/models/database.clj              |  9 +--
 src/metabase/models/metric.clj                |  4 +-
 src/metabase/models/native_query_snippet.clj  |  4 +-
 src/metabase/models/pulse.clj                 |  4 +-
 src/metabase/models/segment.clj               |  4 +-
 src/metabase/models/serialization/util.clj    | 28 +++++++--
 src/metabase/models/timeline.clj              |  4 +-
 src/metabase/models/timeline_event.clj        |  4 +-
 src/metabase/models/user.clj                  |  6 ++
 13 files changed, 114 insertions(+), 29 deletions(-)

diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj
index bf635ac89b9..d2fbfedeaff 100644
--- a/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj
+++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj
@@ -660,6 +660,64 @@
                        :target       [:dimension [:field (:id @field1d) {:source-field (:id @field2d)}]]}]
                      (:parameter_mappings @dashcard1d))))))))))
 
+(deftest users-test
+  ;; Users are serialized as their email address. If a corresponding user is found during deserialization, its ID is
+  ;; used. However, if no such user exists, a new one is created with mostly blank fields.
+  (testing "existing users are found and used; missing users are created on the fly"
+    (let [serialized (atom nil)
+          metric1s   (atom nil)
+          metric2s   (atom nil)
+          user1s     (atom nil)
+          user2s     (atom nil)
+          user1d     (atom nil)
+          metric1d   (atom nil)
+          metric2d   (atom nil)]
+
+      (ts/with-source-and-dest-dbs
+        (testing "serializing the original entities"
+          (ts/with-source-db
+            (reset! user1s    (ts/create! User :first_name "Tom" :last_name "Scholz" :email "tom@bost.on"))
+            (reset! user2s    (ts/create! User :first_name "Neil"  :last_name "Peart"   :email "neil@rush.yyz"))
+            (reset! metric1s  (ts/create! Metric :name "Large Users"       :creator_id (:id @user1s)))
+            (reset! metric2s  (ts/create! Metric :name "Support Headaches" :creator_id (:id @user2s)))
+            (reset! serialized (into [] (serdes.extract/extract-metabase {})))))
+
+        (testing "exported form is properly converted"
+          (is (= "tom@bost.on"
+                 (-> @serialized
+                     (by-model "Metric")
+                     first
+                     :creator_id))))
+
+        (testing "deserializing finds the matching user and synthesizes the missing one"
+          (ts/with-dest-db
+            ;; Create another random user to change the user IDs.
+            (ts/create! User   :first_name "Gideon" :last_name "Nav" :email "griddle@ninth.tomb")
+            ;; Likewise, create some other metrics.
+            (ts/create! Metric :name "Other metric A")
+            (ts/create! Metric :name "Other metric B")
+            (ts/create! Metric :name "Other metric C")
+            (reset! user1d  (ts/create! User  :first_name "Tom" :last_name "Scholz" :email "tom@bost.on"))
+
+            ;; Load the serialized content.
+            (serdes.load/load-metabase (ingestion-in-memory @serialized))
+
+            ;; Fetch the relevant bits
+            (reset! metric1d (db/select-one Metric :name "Large Users"))
+            (reset! metric2d (db/select-one Metric :name "Support Headaches"))
+
+            (testing "the Metrics and Users have different IDs now"
+              (is (not= (:id @metric1s) (:id @metric1d)))
+              (is (not= (:id @metric2s) (:id @metric2d)))
+              (is (not= (:id @user1s)   (:id @user1d))))
+
+            (testing "both existing User and the new one are set up properly"
+              (is (= (:id @user1d) (:creator_id @metric1d)))
+              (let [user2d-id (:creator_id @metric2d)
+                    user2d    (db/select-one User :id user2d-id)]
+                (is (any? user2d))
+                (is (= (:email @user2s) (:email user2d)))))))))))
+
 (deftest field-values-test
   ;; FieldValues are a bit special - they map 1-1 with Fields but are a separate table serialized separately.
   ;; The main special thing to test here is that the custom load-find-local correctly finds an existing FieldValues.
diff --git a/src/metabase/models/card.clj b/src/metabase/models/card.clj
index 689082fe826..c9c316054f2 100644
--- a/src/metabase/models/card.clj
+++ b/src/metabase/models/card.clj
@@ -355,7 +355,7 @@
       (update :database_id            serdes.util/export-fk-keyed 'Database :name)
       (update :table_id               serdes.util/export-table-fk)
       (update :collection_id          serdes.util/export-fk 'Collection)
-      (update :creator_id             serdes.util/export-fk-keyed 'User :email)
+      (update :creator_id             serdes.util/export-user)
       (update :dataset_query          serdes.util/export-json-mbql)
       (update :parameter_mappings     serdes.util/export-parameter-mappings)
       (update :visualization_settings serdes.util/export-visualization-settings)
@@ -367,7 +367,7 @@
       serdes.base/load-xform-basics
       (update :database_id            serdes.util/import-fk-keyed 'Database :name)
       (update :table_id               serdes.util/import-table-fk)
-      (update :creator_id             serdes.util/import-fk-keyed 'User :email)
+      (update :creator_id             serdes.util/import-user)
       (update :collection_id          serdes.util/import-fk 'Collection)
       (update :dataset_query          serdes.util/import-json-mbql)
       (update :parameter_mappings     serdes.util/import-parameter-mappings)
diff --git a/src/metabase/models/collection.clj b/src/metabase/models/collection.clj
index 52cc250ad6a..81e40e0ba68 100644
--- a/src/metabase/models/collection.clj
+++ b/src/metabase/models/collection.clj
@@ -16,6 +16,7 @@
             [metabase.models.permissions :as perms :refer [Permissions]]
             [metabase.models.serialization.base :as serdes.base]
             [metabase.models.serialization.hash :as serdes.hash]
+            [metabase.models.serialization.util :as serdes.util]
             [metabase.public-settings.premium-features :as premium-features]
             [metabase.util :as u]
             [metabase.util.honeysql-extensions :as hx]
@@ -942,17 +943,16 @@
         (assoc :parent_id parent-id :personal_owner_id owner-email)
         (assoc-in [:serdes/meta 0 :label] (:slug coll)))))
 
-(defmethod serdes.base/load-xform "Collection" [{:keys [parent_id personal_owner_id] :as contents}]
+(defmethod serdes.base/load-xform "Collection" [{:keys [parent_id] :as contents}]
   (let [loc        (if parent_id
                      (let [{:keys [id location]} (serdes.base/lookup-by-id Collection parent_id)]
                        (str location id "/"))
-                     "/")
-        user-id    (when personal_owner_id
-                     (db/select-one-field :id 'User :email personal_owner_id))]
+                     "/")]
     (-> contents
         serdes.base/load-xform-basics
         (dissoc :parent_id)
-        (assoc :location loc :personal_owner_id user-id))))
+        (assoc :location loc)
+        (update :personal_owner_id serdes.util/import-user))))
 
 (defmethod serdes.base/serdes-dependencies "Collection"
   [{:keys [parent_id]}]
diff --git a/src/metabase/models/dashboard.clj b/src/metabase/models/dashboard.clj
index 6aed0b6a852..304741ed5b2 100644
--- a/src/metabase/models/dashboard.clj
+++ b/src/metabase/models/dashboard.clj
@@ -434,14 +434,14 @@
   [_model-name _opts dash]
   (-> (serdes.base/extract-one-basics "Dashboard" dash)
       (update :collection_id serdes.util/export-fk 'Collection)
-      (update :creator_id    serdes.util/export-fk-keyed 'User :email)))
+      (update :creator_id    serdes.util/export-user)))
 
 (defmethod serdes.base/load-xform "Dashboard"
   [dash]
   (-> dash
       serdes.base/load-xform-basics
       (update :collection_id serdes.util/import-fk 'Collection)
-      (update :creator_id    serdes.util/import-fk-keyed 'User :email)))
+      (update :creator_id    serdes.util/import-user)))
 
 (defmethod serdes.base/serdes-dependencies "Dashboard"
   [{:keys [collection_id]}]
diff --git a/src/metabase/models/database.clj b/src/metabase/models/database.clj
index 50ded97170b..cc107c26ff5 100644
--- a/src/metabase/models/database.clj
+++ b/src/metabase/models/database.clj
@@ -12,6 +12,7 @@
             [metabase.models.secret :as secret :refer [Secret]]
             [metabase.models.serialization.base :as serdes.base]
             [metabase.models.serialization.hash :as serdes.hash]
+            [metabase.models.serialization.util :as serdes.util]
             [metabase.plugins.classloader :as classloader]
             [metabase.util :as u]
             [metabase.util.i18n :refer [trs]]
@@ -287,7 +288,7 @@
   ;; TODO Support alternative encryption of secret database details.
   ;; There's one optional foreign key: creator_id. Resolve it as an email.
   (cond-> (serdes.base/extract-one-basics "Database" entity)
-    (:creator_id entity) (assoc :creator_id (db/select-one-field :email 'User :id (:creator_id entity)))
+    true                 (update :creator_id serdes.util/export-user)
     (= :exclude secrets) (dissoc :details)))
 
 (defmethod serdes.base/serdes-entity-id "Database"
@@ -302,6 +303,6 @@
   [[{:keys [id]}]]
   (db/select-one-field :id Database :name id))
 
-(defmethod serdes.base/load-xform "Database" [{:keys [creator_id] :as entity}]
-  (cond-> (serdes.base/load-xform-basics entity)
-    creator_id (assoc :creator_id (db/select-one-field :id 'User :email creator_id))))
+(defmethod serdes.base/load-xform "Database" [entity]
+  (-> (serdes.base/load-xform-basics entity)
+    (update :creator_id serdes.util/import-user)))
diff --git a/src/metabase/models/metric.clj b/src/metabase/models/metric.clj
index e36b8307b40..82f8585594f 100644
--- a/src/metabase/models/metric.clj
+++ b/src/metabase/models/metric.clj
@@ -88,14 +88,14 @@
   [_model-name _opts metric]
   (-> (serdes.base/extract-one-basics "Metric" metric)
       (update :table_id   serdes.util/export-table-fk)
-      (update :creator_id serdes.util/export-fk-keyed 'User :email)
+      (update :creator_id serdes.util/export-user)
       (update :definition serdes.util/export-json-mbql)))
 
 (defmethod serdes.base/load-xform "Metric" [metric]
   (-> metric
       serdes.base/load-xform-basics
       (update :table_id   serdes.util/import-table-fk)
-      (update :creator_id serdes.util/import-fk-keyed 'User :email)
+      (update :creator_id serdes.util/import-user)
       (update :definition serdes.util/import-json-mbql)))
 
 (defmethod serdes.base/serdes-dependencies "Metric" [{:keys [definition table_id]}]
diff --git a/src/metabase/models/native_query_snippet.clj b/src/metabase/models/native_query_snippet.clj
index 88cb839a70d..55b9db23908 100644
--- a/src/metabase/models/native_query_snippet.clj
+++ b/src/metabase/models/native_query_snippet.clj
@@ -94,13 +94,13 @@
 (defmethod serdes.base/extract-one "NativeQuerySnippet"
   [_model-name _opts snippet]
   (-> (serdes.base/extract-one-basics "NativeQuerySnippet" snippet)
-      (update :creator_id serdes.util/export-fk-keyed 'User :email)
+      (update :creator_id serdes.util/export-user)
       (update :collection_id #(when % (serdes.util/export-fk % 'Collection)))))
 
 (defmethod serdes.base/load-xform "NativeQuerySnippet" [snippet]
   (-> snippet
       serdes.base/load-xform-basics
-      (update :creator_id serdes.util/import-fk-keyed 'User :email)
+      (update :creator_id serdes.util/import-user)
       (update :collection_id #(when % (serdes.util/import-fk % 'Collection)))))
 
 (defmethod serdes.base/serdes-dependencies "NativeQuerySnippet"
diff --git a/src/metabase/models/pulse.clj b/src/metabase/models/pulse.clj
index 35c1ab93c4a..5d2428fdb53 100644
--- a/src/metabase/models/pulse.clj
+++ b/src/metabase/models/pulse.clj
@@ -567,11 +567,11 @@
   (cond-> (serdes.base/extract-one-basics "Pulse" pulse)
     (:collection_id pulse) (update :collection_id serdes.util/export-fk 'Collection)
     (:dashboard_id  pulse) (update :dashboard_id  serdes.util/export-fk 'Dashboard)
-    true                   (update :creator_id serdes.util/export-fk-keyed 'User :email)))
+    true                   (update :creator_id    serdes.util/export-user)))
 
 (defmethod serdes.base/load-xform "Pulse" [pulse]
   (cond-> (serdes.base/load-xform-basics pulse)
-      true                   (update :creator_id    serdes.util/import-fk-keyed 'User :email)
+      true                   (update :creator_id    serdes.util/import-user)
       (:collection_id pulse) (update :collection_id serdes.util/import-fk 'Collection)
       (:dashboard_id  pulse) (update :dashboard_id  serdes.util/import-fk 'Dashboard)))
 
diff --git a/src/metabase/models/segment.clj b/src/metabase/models/segment.clj
index 85c1fc28c59..894fe929845 100644
--- a/src/metabase/models/segment.clj
+++ b/src/metabase/models/segment.clj
@@ -89,14 +89,14 @@
   [_model-name _opts segment]
   (-> (serdes.base/extract-one-basics "Segment" segment)
       (update :table_id   serdes.util/export-table-fk)
-      (update :creator_id serdes.util/export-fk-keyed 'User :email)
+      (update :creator_id serdes.util/export-user)
       (update :definition serdes.util/export-json-mbql)))
 
 (defmethod serdes.base/load-xform "Segment" [segment]
   (-> segment
       serdes.base/load-xform-basics
       (update :table_id   serdes.util/import-table-fk)
-      (update :creator_id serdes.util/import-fk-keyed 'User :email)
+      (update :creator_id serdes.util/import-user)
       (update :definition serdes.util/import-json-mbql)))
 
 (defmethod serdes.base/serdes-dependencies "Segment" [{:keys [definition table_id]}]
diff --git a/src/metabase/models/serialization/util.clj b/src/metabase/models/serialization/util.clj
index 6e4cd0affb4..2f9c31e709f 100644
--- a/src/metabase/models/serialization/util.clj
+++ b/src/metabase/models/serialization/util.clj
@@ -47,9 +47,9 @@
 
 (defn export-fk-keyed
   "Given a numeric ID, look up a different identifying field for that entity, and return it as a portable ID.
-  Eg. `User.email`, `Database.name`.
+  Eg. `Database.name`.
   [[import-fk-keyed]] is the inverse.
-  Unusual parameter order lets this be called as, for example, `(update x :creator_id export-fk-keyed 'User :email).
+  Unusual parameter order lets this be called as, for example, `(update x :creator_id export-fk-keyed 'Database :name)`.
 
   Note: This assumes the primary key is called `:id`."
   [id model field]
@@ -58,12 +58,32 @@
 (defn import-fk-keyed
   "Given a single, portable, identifying field and the model it refers to, this resolves the entity and returns its
   numeric `:id`.
-  Eg. `User.email` or `Database.name`.
+  Eg. `Database.name`.
 
-  Unusual parameter order lets this be called as, for example, `(update x :creator_id import-fk-keyed 'User :email)`."
+  Unusual parameter order lets this be called as, for example,
+  `(update x :creator_id import-fk-keyed 'Database :name)`."
   [portable model field]
   (db/select-one-id model field portable))
 
+;; -------------------------------------------------- Users ----------------------------------------------------------
+(defn export-user
+  "Exports a user as the email address.
+  This just calls [[export-fk-keyed]], but the counterpart [[import-user]] is more involved. This is a unique function
+  so they form a pair."
+  [id]
+  (when id (export-fk-keyed id 'User :email)))
+
+(defn import-user
+  "Imports a user by their email address.
+  If a user with that email address exists, returns its primary key.
+  If no such user exists, creates a dummy one with the default settings, blank name, and randomized password.
+  Does not send any invite emails."
+  [email]
+  (when email
+    (or (import-fk-keyed email 'User :email)
+        ;; Need to break a circular dependency here.
+        (:id ((resolve 'metabase.models.user/serdes-synthesize-user!) {:email email})))))
+
 ;; -------------------------------------------------- Tables ---------------------------------------------------------
 (defn export-table-fk
   "Given a numeric `table_id`, return a portable table reference.
diff --git a/src/metabase/models/timeline.clj b/src/metabase/models/timeline.clj
index c79b5a6e567..280f2d7c811 100644
--- a/src/metabase/models/timeline.clj
+++ b/src/metabase/models/timeline.clj
@@ -66,13 +66,13 @@
   [_model-name _opts timeline]
   (-> (serdes.base/extract-one-basics "Timeline" timeline)
       (update :collection_id serdes.util/export-fk 'Collection)
-      (update :creator_id    serdes.util/export-fk-keyed 'User :email)))
+      (update :creator_id    serdes.util/export-user)))
 
 (defmethod serdes.base/load-xform "Timeline" [timeline]
   (-> timeline
       serdes.base/load-xform-basics
       (update :collection_id serdes.util/import-fk 'Collection)
-      (update :creator_id    serdes.util/import-fk-keyed 'User :email)))
+      (update :creator_id    serdes.util/import-user)))
 
 (defmethod serdes.base/serdes-dependencies "Timeline" [{:keys [collection_id]}]
   [[{:model "Collection" :id collection_id}]])
diff --git a/src/metabase/models/timeline_event.clj b/src/metabase/models/timeline_event.clj
index fa0aa69597d..7abf5d7aee8 100644
--- a/src/metabase/models/timeline_event.clj
+++ b/src/metabase/models/timeline_event.clj
@@ -110,14 +110,14 @@
   [_model-name _opts event]
   (-> (serdes.base/extract-one-basics "TimelineEvent" event)
       (update :timeline_id serdes.util/export-fk 'Timeline)
-      (update :creator_id  serdes.util/export-fk-keyed 'User :email)
+      (update :creator_id  serdes.util/export-user)
       (update :timestamp   #(u.date/format (t/offset-date-time %)))))
 
 (defmethod serdes.base/load-xform "TimelineEvent" [event]
   (-> event
       serdes.base/load-xform-basics
       (update :timeline_id serdes.util/import-fk 'Timeline)
-      (update :creator_id  serdes.util/import-fk-keyed 'User :email)
+      (update :creator_id  serdes.util/import-user)
       (update :timestamp   u.date/parse)))
 
 (defmethod serdes.base/serdes-dependencies "TimelineEvent" [{:keys [timeline_id]}]
diff --git a/src/metabase/models/user.clj b/src/metabase/models/user.clj
index f2f82f73a15..0e03c59e35b 100644
--- a/src/metabase/models/user.clj
+++ b/src/metabase/models/user.clj
@@ -280,6 +280,12 @@
   [new-user :- NewUser]
   (db/insert! User (update new-user :password #(or % (str (UUID/randomUUID))))))
 
+(defn serdes-synthesize-user!
+  "Creates a new user with a default password, when deserializing eg. a `:creator_id` field whose email address doesn't
+  match any existing user."
+  [new-user]
+  (insert-new-user! new-user))
+
 (s/defn create-and-invite-user!
   "Convenience function for inviting a new `User` and sending out the welcome email."
   [new-user :- NewUser, invitor :- Invitor, setup? :- s/Bool]
-- 
GitLab