diff --git a/src/metabase/driver/sql_jdbc/actions.clj b/src/metabase/driver/sql_jdbc/actions.clj index 63b9354b6a3d417268774557f93f89d702d648e5..d5dcc91a65a9af2d706b9e92a74c11dae29d78ae 100644 --- a/src/metabase/driver/sql_jdbc/actions.clj +++ b/src/metabase/driver/sql_jdbc/actions.clj @@ -14,6 +14,7 @@ [metabase.driver.util :as driver.u] [metabase.models.field :refer [Field]] [metabase.query-processor :as qp] + [metabase.query-processor.error-type :as qp.error-type] [metabase.query-processor.store :as qp.store] [metabase.util :as u] [metabase.util.honeysql-extensions :as hx] @@ -22,7 +23,7 @@ [schema.core :as s] [toucan.db :as db]) (:import - (java.sql Connection))) + (java.sql Connection PreparedStatement))) (set! *warn-on-reflection* true) @@ -111,7 +112,9 @@ ;;; 2. [[jdbc/with-db-transaction]] does a lot of magic that we don't necessarily want. Writing raw JDBC code is barely ;;; any more code and lets us have complete control over what happens and lets us see at a glance exactly what's ;;; happening without having to keep [[clojure.java.jdbc]] magic in mind or work around it. -(defn- do-with-jdbc-transaction [database-id f] +(defn do-with-jdbc-transaction + "Impl function for [[with-jdbc-transaction]]." + [database-id f] (if *connection* (f *connection*) (let [jdbc-spec (sql-jdbc.conn/db->pooled-connection-spec database-id)] @@ -134,7 +137,7 @@ (.rollback conn) (throw e))))))) -(defmacro ^:private with-jdbc-transaction +(defmacro with-jdbc-transaction "Execute `f` with a JDBC Connection for the Database with `database-id`. Uses [[*connection*]] if already bound, otherwise fetches a new Connection from the Database's Connection pool and executes `f` inside of a transaction." {:style/indent 1} @@ -343,6 +346,21 @@ successes])))) rows))) +(defmethod driver/execute-write-query! :sql-jdbc + [driver {{sql :query, :keys [params]} :native}] + {:pre [(string? sql)]} + (try + (let [{db-id :id} (qp.store/database)] + (with-jdbc-transaction [conn db-id] + (with-open [stmt (sql-jdbc.execute/statement-or-prepared-statement driver conn sql params nil)] + {:rows-affected (if (instance? PreparedStatement stmt) + (.executeUpdate ^PreparedStatement stmt) + (.executeUpdate stmt sql))}))) + (catch Throwable e + (throw (ex-info (tru "Error executing write query: {0}" (ex-message e)) + {:sql sql, :params params, :type qp.error-type/invalid-query} + e))))) + ;;;; `:bulk/create` (defmethod actions/perform-action!* [:sql-jdbc :bulk/create] diff --git a/src/metabase/driver/sql_jdbc/execute.clj b/src/metabase/driver/sql_jdbc/execute.clj index 6fdabbe406cadd7a9679b46c832f5d73042de948..1148ad83425b5f59a9bf7a60ece0f0c558abcbda 100644 --- a/src/metabase/driver/sql_jdbc/execute.clj +++ b/src/metabase/driver/sql_jdbc/execute.clj @@ -6,7 +6,6 @@ `metabase.driver.sql-jdbc.execute.legacy-impl`. " (:require [clojure.core.async :as a] - [clojure.java.jdbc :as jdbc] [clojure.string :as str] [java-time :as t] [metabase.db.query :as mdb.query] @@ -369,7 +368,9 @@ (doto (statement driver conn) (wire-up-canceled-chan-to-cancel-Statement! canceled-chan))) -(defn- statement-or-prepared-statement ^Statement [driver conn sql params canceled-chan] +(defn statement-or-prepared-statement + "Create a statement or a prepared statement. Should be called from [[with-open]]." + ^Statement [driver conn sql params canceled-chan] (if (use-statement? driver params) (statement* driver conn canceled-chan) (prepared-statement* driver conn sql params canceled-chan))) @@ -512,27 +513,6 @@ (respond results-metadata (reducible-rows driver rs rsmeta (qp.context/canceled-chan context))))))) -;;; +----------------------------------------------------------------------------------------------------------------+ -;;; | Actions Stuff | -;;; +----------------------------------------------------------------------------------------------------------------+ - -(defmethod driver/execute-write-query! :sql-jdbc - [driver {{sql :query, :keys [params]} :native}] - {:pre [(string? sql)]} - (try - (let [{:keys [details]} (qp.store/database) - jdbc-spec (sql-jdbc.conn/connection-details->spec driver details)] - ;; TODO -- should this be done in a transaction? Should we set the isolation level? - (with-open [conn (jdbc/get-connection jdbc-spec) - stmt (statement-or-prepared-statement driver conn sql params nil)] - {:rows-affected (if (instance? PreparedStatement stmt) - (.executeUpdate ^PreparedStatement stmt) - (.executeUpdate stmt sql))})) - (catch Throwable e - (throw (ex-info (tru "Error executing write query: {0}" (ex-message e)) - {:sql sql, :params params, :type qp.error-type/invalid-query} - e))))) - ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | Convenience Imports from Old Impl | ;;; +----------------------------------------------------------------------------------------------------------------+ diff --git a/test/metabase/actions_test.clj b/test/metabase/actions_test.clj index 8150d009c27240443a3763a04ecc24743a25e940..7b84d0e94d83f37311af3807d8d00532995e0fb1 100644 --- a/test/metabase/actions_test.clj +++ b/test/metabase/actions_test.clj @@ -2,14 +2,22 @@ (:require [clojure.test :refer :all] [metabase.actions :as actions] + [metabase.actions.execution :as actions.execution] [metabase.api.common :refer [*current-user-permissions-set*]] [metabase.driver :as driver] [metabase.models :refer [Database Table]] + [metabase.models.action :as action] [metabase.query-processor :as qp] [metabase.test :as mt] [metabase.util :as u] [metabase.util.schema :as su] - [schema.core :as s])) + [schema.core :as s] + [toucan2.core :as t2]) + (:import + (org.apache.sshd.server SshServer) + (org.apache.sshd.server.forward AcceptAllForwardingFilter))) + +(set! *warn-on-reflection* true) (defmacro with-actions-test-data-and-actions-permissively-enabled "Combines [[mt/with-actions-test-data-and-actions-enabled]] with full permissions." @@ -477,3 +485,96 @@ [2 "American"] [3 "Artisan"]] (first-three-categories))))))))) + +(defn basic-auth-ssh-server ^java.io.Closeable [username password] + (try + (let [password-auth (reify org.apache.sshd.server.auth.password.PasswordAuthenticator + (authenticate [_ auth-username auth-password _session] + (and + (= auth-username username) + (= auth-password password)))) + keypair-provider (org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider.) + sshd (doto (SshServer/setUpDefaultServer) + (.setPort 0) + (.setKeyPairProvider keypair-provider) + (.setPasswordAuthenticator password-auth) + (.setForwardingFilter AcceptAllForwardingFilter/INSTANCE) + .start)] + sshd) + (catch Throwable e + (throw (ex-info (format "Error starting SSH mock server with password") + {:username username :password password} + e))))) + +(deftest actions-on-ssh-tunneled-db + ;; testing actions against dbs with ssh tunnels. Use an in-memory ssh server that just forwards to the correct port + ;; through localhost. Since it is local, it's possible for the application to ignore the ssh tunnel and just talk to + ;; the db on the correct port. So we do the tests twice, once expecting the correct answer, and then again with the + ;; wrong password for the ssh tunnel and we expect failures. This ensures that the correct result is actually going + ;; through the ssh tunnel + (mt/test-drivers (disj (mt/normal-drivers-with-feature :actions) :h2) + (let [username "username", password "password"] + (with-open [ssh-server (basic-auth-ssh-server username password)] + (doseq [[correct-password? ssh-password] [[true password] [false "wrong-password"]]] + (with-actions-test-data-and-actions-permissively-enabled + (let [ssh-port (.getPort ^SshServer ssh-server)] + (let [details (t2/select-one-fn :details 'Database :id (mt/id))] + (t2/update! 'Database (mt/id) + ;; enable ssh tunnel + {:details (assoc details + :tunnel-enabled true + :tunnel-host "localhost" + :tunnel-auth-option "password" + :tunnel-port ssh-port + :tunnel-user username + :tunnel-pass ssh-password)})) + (testing "Can perform implicit actions on ssh-enabled database" + (let [response (try (actions/perform-action! :bulk/update + {:database (mt/id) + :table-id (mt/id :categories) + :arg + (let [id (format-field-name :id) + name (format-field-name :name)] + [{id 1, name "Seed Bowl"} + {id 2, name "Millet Treat"}])}) + (catch Exception e e))] + (if correct-password? + (is (= {:rows-updated 2} response)) + (do + (is (instance? Exception response) "Did not get an error with wrong password") + (is (some (partial instance? org.apache.sshd.common.SshException) + (u/full-exception-chain response)) + "None of the errors are from ssh"))))) + (testing "Can perform custom actions on ssh-enabled database" + (let [query (update (mt/native-query + {:query "update categories set name = 'foo' where id = {{id}}" + :template-tags {:id {:id "id" + :name "id" + :type "number" + :display-name "Id"}}}) + :type name)] + (mt/with-actions [{card-id :id} {:dataset true + :dataset_query (mt/mbql-query categories)} + {action-id :action-id} {:name "Query example" + :type :query + :model_id card-id + :dataset_query query + :database_id (mt/id) + :parameters [{:id "id" + :type :number/= + :target [:variable + [:template-tag "id"]] + :name "Id" + :slug "id" + :hasVariableTemplateTagTarget true}] + :visualization_settings {:position "top"}}] + (let [action (action/select-action :id action-id)] + (if correct-password? + (is (= {:rows-affected 1} + (actions.execution/execute-action! action {"id" 1}))) + (let [response (try (actions.execution/execute-action! action {"id" 1}) + (catch Exception e e))] + (is (instance? Exception response) "Did not get an error with wrong password") + (is (some (partial instance? org.apache.sshd.common.SshException) + (u/full-exception-chain response)) + "None of the errors are from ssh"))))))))))))))