From fa7a89d792d4581929f7ef4873dc21971ca3858a Mon Sep 17 00:00:00 2001
From: Jeff Evans <jeff303@users.noreply.github.com>
Date: Thu, 28 Oct 2021 21:29:25 -0500
Subject: [PATCH] Prevent secret records from being orphaned (#18714)

Delete Secret instances from the app DB, when the corresponding Database instance is deleted

Updating existing test (and renamed it to `secret-db-details-integration-test`) to confirm this behavior
---
 src/metabase/models/database.clj       | 39 +++++++++--
 test/metabase/models/database_test.clj | 91 +++++++++++++++-----------
 2 files changed, 84 insertions(+), 46 deletions(-)

diff --git a/src/metabase/models/database.clj b/src/metabase/models/database.clj
index 1262ff2a1ff..508335a5fe4 100644
--- a/src/metabase/models/database.clj
+++ b/src/metabase/models/database.clj
@@ -10,7 +10,7 @@
             [metabase.models.interface :as i]
             [metabase.models.permissions :as perms]
             [metabase.models.permissions-group :as perm-group]
-            [metabase.models.secret :as secret]
+            [metabase.models.secret :as secret :refer [Secret]]
             [metabase.plugins.classloader :as classloader]
             [metabase.util :as u]
             [metabase.util.i18n :refer [trs]]
@@ -60,12 +60,41 @@
           (driver/normalize-db-details driver db*)
           db*))))
 
-(defn- pre-delete [{id :id, driver :engine, :as database}]
+(defn- conn-props->secret-props-by-name
+  "For the given `conn-props` (output of `driver/connection-properties`), return a map of all secret type properties,
+  keyed by property name."
+  [conn-props]
+  (->> (filter #(= "secret" (:type %)) conn-props)
+    (reduce (fn [acc prop] (assoc acc (:name prop) prop)) {})))
+
+(defn- delete-orphaned-secrets!
+  "Delete Secret instances from the app DB, that will become orphaned when `database` is deleted. For now, this will
+  simply delete any Secret whose ID appears in the details blob, since every Secret instance that is currently created
+  is exclusively associated with a single Database.
+
+  In the future, if/when we allow arbitrary association of secret instances to database instances, this will need to
+  change and become more complicated (likely by consulting a many-to-many join table)."
+  [{:keys [id details] :as database}]
+  (when-let [conn-props-fn (get-method driver/connection-properties (driver.u/database->driver database))]
+    (let [conn-props                 (conn-props-fn (driver.u/database->driver database))
+          possible-secret-prop-names (keys (conn-props->secret-props-by-name conn-props))]
+      (doseq [secret-id (reduce (fn [acc prop-name]
+                                  (when-let [secret-id (get details (keyword (str prop-name "-id")))]
+                                    (conj acc secret-id)))
+                                []
+                                possible-secret-prop-names)]
+        (log/info (trs "Deleting secret ID {0} from app DB because the owning database ({1}) is being deleted"
+                       secret-id
+                       id))
+        (db/delete! Secret :id secret-id)))))
+
+(defn- pre-delete [{id :id, driver :engine, details :details :as database}]
   (unschedule-tasks! database)
   (db/execute! {:delete-from (db/resolve-model 'Permissions)
                 :where       [:or
                               [:like :object (str (perms/data-perms-path id) "%")]
                               [:= :object (perms/database-block-perms-path id)]]})
+  (delete-orphaned-secrets! database)
   (try
     (driver/notify-database-updated driver database)
     (catch Throwable e
@@ -115,10 +144,8 @@
 
           details ; we have details populated in this Database instance, so handle them
           (let [conn-props            (conn-props-fn (driver.u/database->driver database))
-                conn-secrets-by-name  (->> (filter #(= "secret" (:type %)) conn-props)
-                                           (reduce (fn [acc prop] (assoc acc (:name prop) prop)) {}))
-                ;; saved-details         (db/select-field :details Database id)
-                 updated-details      (reduce-kv (partial handle-db-details-secret-prop! database)
+                conn-secrets-by-name  (conn-props->secret-props-by-name conn-props)
+                updated-details       (reduce-kv (partial handle-db-details-secret-prop! database)
                                                  details
                                                  conn-secrets-by-name)]
            (assoc database :details updated-details))
diff --git a/test/metabase/models/database_test.clj b/test/metabase/models/database_test.clj
index ed55108e33b..d85f2736808 100644
--- a/test/metabase/models/database_test.clj
+++ b/test/metabase/models/database_test.clj
@@ -8,7 +8,7 @@
             [metabase.models :refer [Database]]
             [metabase.models.database :as mdb]
             [metabase.models.permissions :as perms]
-            [metabase.models.secret :as secret]
+            [metabase.models.secret :as secret :refer [Secret]]
             [metabase.models.user :as user]
             [metabase.server.middleware.session :as mw.session]
             [metabase.task :as task]
@@ -161,43 +161,54 @@
     (is (= driver.u/default-sensitive-fields
            (mdb/sensitive-fields-for-db {})))))
 
-(deftest secret-resolution-test
-  (mt/with-driver :secret-test-driver
-    (binding [api/*current-user-id* (mt/user->id :crowberto)]
-      (letfn [(check-db-fn [{:keys [details] :as database} exp-secret]
-                (is (not (contains? details :password-value)) "password-value was removed from details")
-                (is (some? (:password-created-at details)) "password-created-at was populated in details")
-                (is (= (mt/user->id :crowberto) (:password-creator-id details))
-                    "password-creator-id was populated in details")
-                (is (= (if-let [src (:source exp-secret)]
-                         (name src)
-                         nil) (:password-source details)) "password-source matches the value from the secret")
-                (is (contains? details :password-id) "password-id was added to details")
-                (let [{:keys [created_at updated_at] :as secret} (secret/latest-for-id (:password-id details))]
-                  (is (some? secret) "Loaded Secret instance by ID")
-                  (is (some? created_at) "created_at populated for the secret instance")
-                  (is (some? updated_at) "updated_at populated for the secret instance")
-                  (doseq [[exp-key exp-val] exp-secret]
-                    (testing (format "%s=%s in secret" exp-key exp-val)
-                      (is (= exp-val (cond-> (exp-key secret)
-                                       (string? exp-val)
-                                       (String.)
+(deftest secret-db-details-integration-test
+  (testing "manipulating secret values in db-details works correctly"
+    (mt/with-driver :secret-test-driver
+      (binding [api/*current-user-id* (mt/user->id :crowberto)]
+        (let [secret-ids  (atom #{}) ; keep track of all secret IDs created with the temp database
+              check-db-fn (fn [{:keys [details] :as database} exp-secret]
+                            (is (not (contains? details :password-value)) "password-value was removed from details")
+                            (is (some? (:password-created-at details)) "password-created-at was populated in details")
+                            (is (= (mt/user->id :crowberto) (:password-creator-id details))
+                                "password-creator-id was populated in details")
+                            (is (= (if-let [src (:source exp-secret)]
+                                     (name src)
+                                     nil) (:password-source details))
+                                "password-source matches the value from the secret")
+                            (is (contains? details :password-id) "password-id was added to details")
+                            (let [secret-id                                  (:password-id details)
+                                  {:keys [created_at updated_at] :as secret} (secret/latest-for-id secret-id)]
+                              (swap! secret-ids conj secret-id)
+                              (is (some? secret) "Loaded Secret instance by ID")
+                              (is (some? created_at) "created_at populated for the secret instance")
+                              (is (some? updated_at) "updated_at populated for the secret instance")
+                              (doseq [[exp-key exp-val] exp-secret]
+                                (testing (format "%s=%s in secret" exp-key exp-val)
+                                  (is (= exp-val (cond-> (exp-key secret)
+                                                   (string? exp-val)
+                                                   (String.)
 
-                                       :else
-                                       identity)))))))]
-        (testing "values for referenced secret IDs are resolved in a new DB"
-          (mt/with-temp Database [{:keys [id details] :as database} {:engine  :secret-test-driver
-                                                                     :name    "Test DB with secrets"
-                                                                     :details {:host           "localhost"
-                                                                               :password-value "new-password"}}]
-            (testing " and saved db-details looks correct"
-              (check-db-fn database {:kind    :password
-                                     :source  nil
-                                     :version 1
-                                     :value   "new-password"})
-              (testing " updating the value works as expected"
-                (db/update! Database id :details (assoc details :password-path  "/path/to/my/password-file"))
-                (check-db-fn (Database id) {:kind    :password
-                                            :source  :file-path
-                                            :version 2
-                                            :value   "/path/to/my/password-file"})))))))))
+                                                   :else
+                                                   identity)))))))]
+          (testing "values for referenced secret IDs are resolved in a new DB"
+            (mt/with-temp Database [{:keys [id details] :as database} {:engine  :secret-test-driver
+                                                                       :name    "Test DB with secrets"
+                                                                       :details {:host           "localhost"
+                                                                                 :password-value "new-password"}}]
+              (testing " and saved db-details looks correct"
+                (check-db-fn database {:kind    :password
+                                       :source  nil
+                                       :version 1
+                                       :value   "new-password"})
+                (testing " updating the value works as expected"
+                  (db/update! Database id :details (assoc details :password-path  "/path/to/my/password-file"))
+                  (check-db-fn (Database id) {:kind    :password
+                                              :source  :file-path
+                                              :version 2
+                                              :value   "/path/to/my/password-file"}))))
+            (testing "Secret instances are deleted from the app DB when the DatabaseInstance is deleted"
+              (is (seq @secret-ids) "At least one Secret instance should have been created")
+              (doseq [secret-id @secret-ids]
+                (testing (format "Secret ID %d should have been deleted after the Database was" secret-id)
+                  (is (nil? (db/select-one Secret :id secret-id))
+                      (format "Secret ID %d was not removed from the app DB" secret-id)))))))))))
-- 
GitLab