From f3c3667b38b568683cbc2d49735974514e68575e Mon Sep 17 00:00:00 2001
From: Allen Gilliland <agilliland@gmail.com>
Date: Fri, 13 Nov 2015 22:57:45 -0800
Subject: [PATCH] a few things happening here:

* remove the /api/database/validate endpoint
* introduce connection checking as part of the database api create/update endpoints directly
* modify the return of the Database model in the api so that if a password was provided we swap it out for a known value and prevent leaking of the real password via the api.
---
 src/metabase/api/database.clj    | 100 +++++++++++++++++++++----------
 src/metabase/models/database.clj |   4 ++
 2 files changed, 74 insertions(+), 30 deletions(-)

diff --git a/src/metabase/api/database.clj b/src/metabase/api/database.clj
index 1eaf29f7457..82b0256a81d 100644
--- a/src/metabase/api/database.clj
+++ b/src/metabase/api/database.clj
@@ -8,7 +8,7 @@
             [metabase.events :as events]
             (metabase.models common
                              [hydrate :refer [hydrate]]
-                             [database :refer [Database]]
+                             [database :refer [Database protected-password]]
                              [field :refer [Field]]
                              [table :refer [Table]])
             [metabase.sample-data :as sample-data]
@@ -19,6 +19,34 @@
   [symb value :nillable]
   (checkp-contains? (set (map name (keys @driver/available-drivers))) symb value))
 
+(defn test-database-connection
+  "Try out the connection details for a database and useful error message if connection fails, returns `nil` if connection succeeds."
+  [engine {:keys [host port] :as details}]
+  (let [engine           (keyword engine)
+        details          (assoc details :engine engine)
+        response-invalid (fn [field m] {:valid false
+                                        field m        ; work with the new {:field error-message} format
+                                        :message m})]  ; but be backwards-compatible with the UI as it exists right now
+    (try
+      (cond
+        (driver/can-connect-with-details? engine details :rethrow-exceptions) nil
+        (and host port (u/host-port-up? host port))                           (response-invalid :dbname (format "Connection to '%s:%d' successful, but could not connect to DB." host port))
+        (and host (u/host-up? host))                                          (response-invalid :port   (format "Connection to '%s' successful, but port %d is invalid." port))
+        host                                                                  (response-invalid :host   (format "'%s' is not reachable" host))
+        :else                                                                 (response-invalid :db     "Unable to connect to database."))
+      (catch Throwable e
+        (response-invalid :dbname (.getMessage e))))))
+
+(defn supports-ssl?
+  "Predicate function which determines if a given `engine` supports the `:ssl` setting."
+  [engine]
+  {:pre [(driver/is-engine? engine)]}
+  (let [driver-props (->> (driver/engine->driver engine)
+                          :details-fields
+                          (map :name)
+                          set)]
+    (contains? driver-props "ssl")))
+
 (defendpoint GET "/"
   "Fetch all `Databases`."
   []
@@ -30,10 +58,26 @@
   {name    [Required NonEmptyString]
    engine  [Required DBEngine]
    details [Required Dict]}
-  ;; TODO - we should validate the contents of `details` here based on the engine
   (check-superuser)
-  (let-500 [new-db (ins Database :name name :engine engine :details details)]
-    (events/publish-event :database-create new-db)))
+  ;; this function tries connecting over ssl and non-ssl to establish a connection
+  ;; if it succeeds it returns the `details` that worked, otherwise it returns an error
+  (let [try-connection   (fn [engine details]
+                           (let [error (test-database-connection engine details)]
+                             (if (and error
+                                      (true? (:ssl details)))
+                               (recur engine (assoc details :ssl false))
+                               (or error details))))
+        details          (if (supports-ssl? engine)
+                           (assoc details :ssl true)
+                           details)
+        details-or-error (try-connection engine details)]
+    (if-not (false? (:valid details-or-error))
+      ;; no error, proceed with creation
+      (let-500 [new-db (ins Database :name name :engine engine :details details-or-error)]
+        (events/publish-event :database-create new-db))
+      ;; failed to connect, return error
+      {:status 400
+       :body   details-or-error})))
 
 (defendpoint POST "/sample_dataset"
   "Add the sample dataset as a new `Database`."
@@ -42,25 +86,6 @@
   (sample-data/add-sample-dataset!)
   (sel :one Database :is_sample true))
 
-;; Stub function that will eventually validate a connection string
-(defendpoint POST "/validate"
-  "Validate that we can connect to a `Database`."
-  [:as {{:keys [host port engine] :as details} :body}]
-  (let [engine           (keyword engine)
-        details          (assoc details :engine engine)
-        response-invalid (fn [field m] {:status 400 :body {:valid false
-                                                          field m        ; work with the new {:field error-message} format
-                                                          :message m}})] ; but be backwards-compatible with the UI as it exists right now
-    (try
-      (cond
-        (driver/can-connect-with-details? engine details :rethrow-exceptions) {:valid true}
-        (and host port (u/host-port-up? host port))                           (response-invalid :dbname (format "Connection to '%s:%d' successful, but could not connect to DB." host port))
-        (and host (u/host-up? host))                                          (response-invalid :port   (format "Connection to '%s' successful, but port %d is invalid." port))
-        host                                                                  (response-invalid :host   (format "'%s' is not reachable" host))
-        :else                                                                 (response-invalid :db     "Unable to connect to database."))
-      (catch Throwable e
-        (response-invalid :dbname (.getMessage e))))))
-
 (defendpoint GET "/:id"
   "Get `Database` with ID."
   [id]
@@ -69,13 +94,28 @@
 (defendpoint PUT "/:id"
   "Update a `Database`."
   [id :as {{:keys [name engine details]} :body}]
-  {name NonEmptyString, details Dict} ; TODO - check that engine is a valid choice
-  (write-check Database id)
-  (check-500 (upd-non-nil-keys Database id
-                               :name name
-                               :engine engine
-                               :details details))
-  (Database id))
+  {name    [Required NonEmptyString]
+   engine  [Required DBEngine]
+   details [Required Dict]}
+  (check-superuser)
+  (let-404 [database (Database id)]
+    (let [details    (if-not (= protected-password (:password details))
+                       details
+                       (assoc details :password (get-in database [:details :password])))
+          conn-error (test-database-connection engine details)]
+      (if-not conn-error
+        ;; no error, proceed with update
+        (do
+          ;; TODO: is there really a reason to let someone change the engine on an existing database?
+          ;;       that seems like the kind of thing that will almost never work in any practical way
+          (check-500 (upd-non-nil-keys Database id
+                                       :name name
+                                       :engine engine
+                                       :details details))
+          (Database id))
+        ;; failed to connect, return error
+        {:status 400
+         :body   conn-error}))))
 
 (defendpoint DELETE "/:id"
   "Delete a `Database`."
diff --git a/src/metabase/models/database.clj b/src/metabase/models/database.clj
index 22d203a3463..87f30a2a843 100644
--- a/src/metabase/models/database.clj
+++ b/src/metabase/models/database.clj
@@ -5,6 +5,9 @@
             [metabase.models.interface :refer :all]
             [metabase.util :as u]))
 
+(def ^:const protected-password
+  "**MetabasePass**")
+
 (defrecord DatabaseInstance []
   ;; preserve normal IFn behavior so things like ((sel :one Database) :id) work correctly
   clojure.lang.IFn
@@ -15,6 +18,7 @@
   (api-serialize [this]
     ;; If current user isn't an admin strip out DB details which may include things like password
     (cond-> this
+      (get-in this [:details :password])    (assoc-in [:details :password] protected-password)
       (not (:is_superuser @*current-user*)) (dissoc :details))))
 
 (extend-ICanReadWrite DatabaseInstance :read :always, :write :superuser)
-- 
GitLab