diff --git a/.dir-locals.el b/.dir-locals.el index 59d9bd66e2f1e4f7920cbad3e43e48f904216205..fec35b27192d192a582801b582f95da56f6736ef 100644 --- a/.dir-locals.el +++ b/.dir-locals.el @@ -22,6 +22,7 @@ (merge-with 1) (l/matche '(1 (:defn))) (l/matcha '(1 (:defn))) + (p/defprotocol+ '(1 (:defn))) (p.types/defprotocol+ '(1 (:defn))) (p.types/def-abstract-type '(1 (:defn))) (p.types/deftype+ '(2 nil nil (:defn))) diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/query_processor/middleware/row_level_restrictions_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/query_processor/middleware/row_level_restrictions_test.clj index 457b3477ec7b7b1c47b07435e6eb5219f57d551a..cc21b06afe89f3412372674b0cf170478379ea10 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/query_processor/middleware/row_level_restrictions_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/query_processor/middleware/row_level_restrictions_test.clj @@ -2,6 +2,7 @@ (:require [clojure [string :as str] [test :refer :all]] + [clojure.core.async :as a] [honeysql.core :as hsql] [metabase [driver :as driver] @@ -11,6 +12,7 @@ [util :as u]] [metabase-enterprise.sandbox.query-processor.middleware.row-level-restrictions :as row-level-restrictions] [metabase-enterprise.sandbox.test-util :as mt.tu] + [metabase.api.common :as api] [metabase.driver.sql.query-processor :as sql.qp] [metabase.mbql [normalize :as normalize] @@ -18,6 +20,7 @@ [metabase.models [permissions :as perms] [permissions-group :as perms-group]] + [metabase.query-processor.middleware.cache-test :as cache-test] [metabase.query-processor.util :as qputil] [metabase.test.data.env :as tx.env] [metabase.test.util :as tu] @@ -551,3 +554,39 @@ :alias "Venue"}] :order-by [[:asc $id]] :limit 3}))))))) + +(deftest dont-cache-sandboxes-test + (cache-test/with-mock-cache [save-chan] + (mt/with-gtaps {:gtaps {:venues (venues-category-mbql-gtap-def)} + :attributes {"cat" 50}} + (letfn [(run-query [] + (qp/process-query (assoc (mt/mbql-query venues {:aggregation [[:count]]}) + :cache-ttl 100)))] + (testing "Run the query, should not be cached" + (let [result (run-query)] + (is (= nil + (:cached result))) + (is (= [[10]] + (mt/rows result))))) + (testing "Cache entry should be saved within 5 seconds" + (let [[_ chan] (a/alts!! [save-chan (a/timeout 5000)])] + (is (= save-chan + chan)))) + + (testing "Run it again, should be cached" + (let [result (run-query)] + (is (= true + (:cached result))) + (is (= [[10]] + (mt/rows result))))) + (testing "Run the query with different User attributes, should not get the cached result" + (mt.tu/with-user-attributes :rasta {"cat" 40} + ;; re-bind current user so updated attributes come in to effect + (mt/with-test-user :rasta + (is (= {"cat" 40} + (:login_attributes @api/*current-user*))) + (let [result (run-query)] + (is (= nil + (:cached result))) + (is (= [[9]] + (mt/rows result))))))))))) diff --git a/src/metabase/async/util.clj b/src/metabase/async/util.clj index 9e69c023983ad54ad941df0b3e751c7c24c98897..fa02c01dae97a526b22b7951830ca253a697f580 100644 --- a/src/metabase/async/util.clj +++ b/src/metabase/async/util.clj @@ -46,7 +46,9 @@ ;; * `result-chan` will get the result of `(f)`, *after* `done-chan` is closed (let [done-chan (a/promise-chan) result-chan (a/promise-chan) - f* (bound-fn [] + binds (clojure.lang.Var/getThreadBindingFrame) + f* (fn [] + (clojure.lang.Var/resetThreadBindingFrame binds) (let [result (try (f) (catch Throwable e @@ -56,7 +58,7 @@ (when (some? result) (a/>!! result-chan result))) (a/close! result-chan)) - futur (.submit ^ThreadPoolExecutor (var-get (resolve 'clojure.core.async/thread-macro-executor)) ^Runnable f*)] + futur (.submit ^ThreadPoolExecutor @#'a/thread-macro-executor ^Runnable f*)] ;; if `result-chan` gets a result/closed *before* `done-chan`, it means it was closed by the caller, so we should ;; cancel the thread running `f*` (a/go diff --git a/src/metabase/models/card.clj b/src/metabase/models/card.clj index e2dabaa7d6db48b9503269d80bf4460720a3ea4c..e0f4d22ba5a22f2b61cf0ba75e6bcb9521e1e643 100644 --- a/src/metabase/models/card.clj +++ b/src/metabase/models/card.clj @@ -115,8 +115,20 @@ ;; Cards with queries they wouldn't be allowed to run! (when *current-user-id* (when-not (query-perms/can-run-query? query) - (throw (Exception. (tru "You do not have permissions to run ad-hoc native queries against Database {0}." - (:database query)))))) + (let [required-perms (try + (query-perms/perms-set query :throw-exceptions? true) + (catch Throwable e + e))] + (throw (ex-info (tru "You do not have permissions to run ad-hoc native queries against Database {0}." + (:database query)) + {:status-code 403 + :query query + :required-perms (if (instance? Throwable required-perms) + :error + required-perms) + :actual-perms @api/*current-user-permissions-set*} + (when (instance? Throwable required-perms) + required-perms)))))) ;; make sure this Card doesn't have circular source query references (check-for-circular-source-query-references card) (collection/check-collection-namespace Card (:collection_id card)))) diff --git a/src/metabase/models/query/permissions.clj b/src/metabase/models/query/permissions.clj index 3b95a344ad267a6733a054765f3f885c73601d4d..f632b18263c3fe69ea5be6b67d04afa92176c039 100644 --- a/src/metabase/models/query/permissions.clj +++ b/src/metabase/models/query/permissions.clj @@ -126,11 +126,10 @@ ;; if for some reason we can't expand the Card (i.e. it's an invalid legacy card) just return a set of permissions ;; that means no one will ever get to see it (except for superusers who get to see everything) (catch Throwable e - (when throw-exceptions? - (throw e)) - (log/error (tru "Error calculating permissions for query: {0}" (.getMessage e)) - "\n" - (u/pprint-to-str (u/filtered-stacktrace e))) + (let [e (ex-info "Error calculating permissions for query" {:query query} e)] + (when throw-exceptions? + (throw e)) + (log/error e)) #{"/db/0/"}))) ; DB 0 will never exist (s/defn ^:private perms-set* :- #{perms/ObjectPath} diff --git a/src/metabase/query_processor.clj b/src/metabase/query_processor.clj index 52105c675cf586fbdf5d5198f92684dcf2ef1fae..95da6d6225a3c0bb91ddb001799066666fd8300b 100644 --- a/src/metabase/query_processor.clj +++ b/src/metabase/query_processor.clj @@ -76,6 +76,8 @@ "The default set of middleware applied to queries ran via `process-query`." [#'mbql-to-native/mbql->native #'check-features/check-features + #'limit/limit + #'cache/maybe-return-cached-results #'optimize-datetime-filters/optimize-datetime-filters #'auto-parse-filter-values/auto-parse-filter-values #'wrap-value-literals/wrap-value-literals @@ -89,7 +91,6 @@ #'resolve-joins/resolve-joins #'add-implicit-joins/add-implicit-joins #'large-int-id/convert-id-to-string - #'limit/limit #'format-rows/format-rows #'desugar/desugar #'binning/update-binning-strategy @@ -110,7 +111,6 @@ #'resolve-database-and-driver/resolve-database-and-driver #'fetch-source-query/resolve-card-id-source-tables #'store/initialize-store - #'cache/maybe-return-cached-results #'validate/validate-query #'normalize/normalize #'add-rows-truncated/add-rows-truncated diff --git a/src/metabase/query_processor/context/default.clj b/src/metabase/query_processor/context/default.clj index 1149f916b7945f40e3cc7660c05b8313a057ce19..084a7170d913c255d5b02e225036e8cf089549bf 100644 --- a/src/metabase/query_processor/context/default.clj +++ b/src/metabase/query_processor/context/default.clj @@ -32,8 +32,9 @@ {:data metadata}) ([result] - {:pre [(map? result)]} - (-> result + {:pre [(map? (unreduced result))]} + ;; if the result is a clojure.lang.Reduced, unwrap it so we always get back the standard-format map + (-> (unreduced result) (assoc :row_count @row-count :status :completed) (assoc-in [:data :rows] @rows))) diff --git a/src/metabase/query_processor/middleware/cache.clj b/src/metabase/query_processor/middleware/cache.clj index 246ab476ac0a1182df442d425d6470017027d03d..d09cf17c3b59bc4dbc65518afcb4d52bac336375 100644 --- a/src/metabase/query_processor/middleware/cache.clj +++ b/src/metabase/query_processor/middleware/cache.clj @@ -130,15 +130,15 @@ ([] (rf)) - ([acc] - ;; if results are in the 'normal format' then use the final metadata from the cache rather than - ;; whatever `acc` is right now since we don't run the entire post-processing pipeline for cached results - (let [normal-format? (and (map? acc) (seq (get-in acc [:data :cols]))) - acc* (-> (if normal-format? - @final-metadata - acc) + ([result] + (let [normal-format? (and (map? (unreduced result)) + (seq (get-in (unreduced result) [:data :cols]))) + result* (-> (if normal-format? + (merge-with merge @final-metadata (unreduced result)) + (unreduced result)) (assoc :cached true, :updated_at last-ran))] - (rf acc*))) + (rf (cond-> result* + (reduced? result) reduced)))) ([acc row] (if (map? row) diff --git a/src/metabase/query_processor/middleware/cache/impl.clj b/src/metabase/query_processor/middleware/cache/impl.clj index 1144a7807993a2043914b5deae605119aab8d58d..8ce37c9c1adc3aa5dc10d2a076ac552443543147 100644 --- a/src/metabase/query_processor/middleware/cache/impl.clj +++ b/src/metabase/query_processor/middleware/cache/impl.clj @@ -134,7 +134,7 @@ (reduce [_ rf init] (loop [acc init] (if (reduced? acc) - (reduced acc) + acc (let [row (thaw! is)] (if (= ::eof row) acc diff --git a/src/metabase/query_processor/middleware/limit.clj b/src/metabase/query_processor/middleware/limit.clj index 69b5a046b0eaf0706ede00a8036b7f0ac22a1ee0..2102368e68c68293268dea2088526752cd981fd1 100644 --- a/src/metabase/query_processor/middleware/limit.clj +++ b/src/metabase/query_processor/middleware/limit.clj @@ -25,7 +25,7 @@ (let [result' (rf result row) new-row-count (vswap! row-count inc)] (if (>= new-row-count max-rows) - (reduced result') + (ensure-reduced result') result')))))) (defn limit diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index dab25f0d413e4cc4ea00eae5a1e88b5b81f75c8f..22c51076058647b4407a121bbdbea04942a296b4 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -461,12 +461,17 @@ (mt/with-temp Collection [collection] (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) (mt/with-model-cleanup [Card] - (mt/user-http-request :rasta :post 202 "card" - (assoc (card-with-name-and-query card-name) - :collection_id (u/get-id collection), :collection_position 1)) - (is (= #metabase.models.card.CardInstance{:collection_id true, :collection_position 1} - (some-> (db/select-one [Card :collection_id :collection_position] :name card-name) - (update :collection_id (partial = (u/get-id collection)))))))))))) + (is (schema= {:collection_id (s/eq (u/get-id collection)) + :collection_position (s/eq 1) + :name (s/eq card-name) + s/Keyword s/Any} + (mt/user-http-request :rasta :post 202 "card" + (assoc (card-with-name-and-query card-name) + :collection_id (u/get-id collection), :collection_position 1)))) + (is (schema= {:collection_id (s/eq (u/get-id collection)) + :collection_position (s/eq 1) + s/Keyword s/Any} + (db/select-one Card :name card-name))))))))) (deftest need-permission-for-collection (testing "You need to have Collection permissions to create a Card in a Collection" diff --git a/test/metabase/query_processor/middleware/cache/impl_test.clj b/test/metabase/query_processor/middleware/cache/impl_test.clj index 562b054a2842e6d34d52f8b8b757052b0d2e4f56..988291e671ab716c972b9886a0607b97e77f7908 100644 --- a/test/metabase/query_processor/middleware/cache/impl_test.clj +++ b/test/metabase/query_processor/middleware/cache/impl_test.clj @@ -10,7 +10,7 @@ (def ^:private objects [{:metadata? true} -200.0 3 "HELLO!" {:x 100, :y #t "2020-02-02", :z #{:a :b :c}} (Z. 100)]) -(defn- deserialize +(defn deserialize "Deserialize objects serialized with `serialize-async` using reducing function `rf`." ([^bytes bytea] (deserialize bytea (fn [metadata] diff --git a/test/metabase/query_processor/middleware/cache_test.clj b/test/metabase/query_processor/middleware/cache_test.clj index f04f391e381b617c4c20713ac61d3415d80e5894..6f5f87aa155fafdcd1e7a79d526932c74e4f71ca 100644 --- a/test/metabase/query_processor/middleware/cache_test.clj +++ b/test/metabase/query_processor/middleware/cache_test.clj @@ -13,16 +13,23 @@ [test :as mt] [util :as u]] [metabase.driver.sql-jdbc.execute :as sql-jdbc.execute] + [metabase.middleware.session :as session] + [metabase.models + [permissions :as perms] + [permissions-group :as group]] [metabase.query-processor [streaming :as qp.streaming] [util :as qputil]] [metabase.query-processor.middleware.cache :as cache] + [metabase.query-processor.middleware.cache + [impl :as impl] + [impl-test :as impl-test]] [metabase.query-processor.middleware.cache-backend.interface :as i] - [metabase.query-processor.middleware.cache.impl :as impl] [metabase.test [fixtures :as fixtures] [util :as tu]] - [pretty.core :as pretty])) + [pretty.core :as pretty] + [schema.core :as s])) (use-fixtures :once (fixtures/initialize :db)) @@ -35,6 +42,9 @@ "Gets a message whenever old entries are purged from the test backend." nil) +(defprotocol ^:private CacheContents + (^:private contents [cache-backend])) + (defn- test-backend "In in-memory cache backend implementation." [save-chan purge-chan] @@ -45,7 +55,12 @@ (str "\n" (metabase.util/pprint-to-str 'blue (for [[hash {:keys [created]}] @store] - [hash (metabase.util/format-nanoseconds (.getNano (t/duration created (t/instant))))])))) + [hash (u/format-nanoseconds (.getNano (t/duration created (t/instant))))])))) + + CacheContents + (contents [_] + (into {} (for [[k v] store] + [k (impl-test/deserialize v)]))) i/CacheBackend (cached-results [this query-hash max-age-seconds respond] @@ -62,7 +77,7 @@ (respond nil)))) (save-results! [this query-hash results] - (let [hex-hash (buddy.core.codecs/bytes->hex query-hash)] + (let [hex-hash (codecs/bytes->hex query-hash)] (swap! store assoc hex-hash {:results results :created (t/instant)}) (log/tracef "Save results for %s --> store: %s" hex-hash this)) @@ -76,7 +91,7 @@ (log/tracef "Purge old entries --> store: %s" this) (a/>!! purge-chan ::purge))))) -(defn- do-with-mock-cache [f] +(defn do-with-mock-cache [f] (mt/with-open-channels [save-chan (a/chan 1) purge-chan (a/chan 1)] (mt/with-temporary-setting-values [enable-query-caching true @@ -401,3 +416,35 @@ (is (= max-bytes (count (transduce identity (#'cache/save-results-xform 0 {} (byte 0) conj) (repeat max-bytes [1])))))))) + +(deftest perms-checks-should-still-apply-test + (testing "Double-check that perms checks still happen even for cached results" + (mt/with-temp-copy-of-db + (perms/revoke-permissions! (group/all-users) (mt/db)) + (mt/with-test-user :rasta + (with-mock-cache [save-chan] + (letfn [(run-forbidden-query [] + (qp/process-query (assoc (mt/mbql-query checkins {:aggregation [[:count]]}) + :cache-ttl 100)))] + (testing "Shouldn't be allowed to run a query if we don't have perms for it" + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"You do not have permissions to run this query" + (run-forbidden-query)))) + (testing "Run forbidden query as superuser to populate the cache" + (session/with-current-user (mt/user->id :crowberto) + (is (= [[1000]] + (mt/rows (run-forbidden-query)))))) + (testing "Cache entry should be saved within 5 seconds" + (let [[_ chan] (a/alts!! [save-chan (a/timeout 5000)])] + (is (= save-chan + chan)))) + (testing "Run forbidden query again as superuser again, should be cached" + (session/with-current-user (mt/user->id :crowberto) + (is (schema= {:cached (s/eq true), s/Keyword s/Any} + (run-forbidden-query))))) + (testing "Run query as regular user, should get perms Exception even though result is cached" + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"You do not have permissions to run this query" + (run-forbidden-query)))))))))) diff --git a/test/metabase/test/data/impl.clj b/test/metabase/test/data/impl.clj index 320e4d2f99f26ae253a150bd645ad6a7ea2cc071..08d432c3330dbbc981bec68f7d7ccf599a16e619 100644 --- a/test/metabase/test/data/impl.clj +++ b/test/metabase/test/data/impl.clj @@ -112,12 +112,14 @@ ;; make sure we're returing an up-to-date copy of the DB (Database (u/get-id db)) (catch Throwable e - (db/delete! Database :id (u/get-id db)) - (throw (ex-info "Failed to create test database" - {:driver driver - :database-name database-name - :connection-details connection-details} - e))))) + (let [e (ex-info "Failed to create test database" + {:driver driver + :database-name database-name + :connection-details connection-details} + e)] + (println (u/pprint-to-str 'red (Throwable->map e))) + (db/delete! Database :id (u/get-id db)) + (throw e))))) (catch Throwable e (let [message (format "Failed to create %s '%s' test database" driver database-name)] (println message "\n" e) @@ -261,7 +263,8 @@ (try (copy-db-tables-and-fields! old-db-id new-db-id) (do-with-db new-db f) - (finally (db/delete! Database :id new-db-id)))))) + (finally + (db/delete! Database :id new-db-id)))))) ;;; +----------------------------------------------------------------------------------------------------------------+