diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index 6e45e8600d51c1aa2237d52725d4db2661275f53..181ab8214394d21ce490a12785c82fc48369cae6 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -265,11 +265,11 @@ metadata_checksum (s/maybe su/NonBlankString)} ;; check that we have permissions to run the query that we're trying to save (api/check-403 (perms/set-has-full-permissions-for-set? @api/*current-user-permissions-set* - (card/query-perms-set dataset_query :write))) + (card/query-perms-set dataset_query :write))) ;; check that we have permissions for the collection we're trying to save this card to, if applicable (when collection_id (api/check-403 (perms/set-has-full-permissions? @api/*current-user-permissions-set* - (perms/collection-readwrite-path collection_id)))) + (perms/collection-readwrite-path collection_id)))) ;; everything is g2g, now save the card (let [card (db/insert! Card :creator_id api/*current-user-id* diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj index 6392480c6a3b4a5004400a9702924368f01c99f0..be2c68d17e734f5ed42d2cbe6935e1cd6ff20a5d 100644 --- a/src/metabase/api/dataset.clj +++ b/src/metabase/api/dataset.clj @@ -40,15 +40,15 @@ ;;; -------------------------------------------- Running a Query Normally -------------------------------------------- (defn- query->source-card-id - "Return the ID of the Card used as the \"source\" query of this query, if applicable; otherwise return `nil`. - Used so `:card-id` context can be passed along with the query so Collections perms checking is done if appropriate." + "Return the ID of the Card used as the \"source\" query of this query, if applicable; otherwise return `nil`. Used so + `:card-id` context can be passed along with the query so Collections perms checking is done if appropriate. This fn + is a wrapper for the function of the same name in the QP util namespace; it adds additional permissions checking as + well." [outer-query] - (let [source-table (qputil/get-in-normalized outer-query [:query :source-table])] - (when (string? source-table) - (when-let [[_ card-id-str] (re-matches #"^card__(\d+$)" source-table)] - (log/info (str "Source query for this query is Card " card-id-str)) - (u/prog1 (Integer/parseInt card-id-str) - (api/read-check Card <>)))))) + (when-let [source-card-id (qputil/query->source-card-id outer-query)] + (log/info (str "Source query for this query is Card " source-card-id)) + (api/read-check Card source-card-id) + source-card-id)) (api/defendpoint POST "/" "Execute a query and retrieve the results in the usual format." diff --git a/src/metabase/models/card.clj b/src/metabase/models/card.clj index dbe47eec12ab2da9d1c567282e568e824c1ce8c9..e840cb7cfc09e68831cfba31fb35ef009f0a83c0 100644 --- a/src/metabase/models/card.clj +++ b/src/metabase/models/card.clj @@ -101,18 +101,33 @@ (:schema table) (or (:id table) (:table-id table))))))) +(declare perms-objects-set) + +(defn- source-card-perms + "If `outer-query` is based on a source Card (if `:source-table` uses a psuedo-source-table like `card__<id>`) then + return the permissions needed to *read* that Card. Running or saving a Card that uses another Card as a source query + simply requires read permissions for that Card; e.g. if you are allowed to view a query you can save a new query + that uses it as a source. Thus the distinction between read and write permissions in not important here. + + See issue #6845 for further discussion." + [outer-query] + (when-let [source-card-id (qputil/query->source-card-id outer-query)] + (perms-objects-set (Card source-card-id) :read))) + (defn- mbql-permissions-path-set "Return the set of required permissions needed to run QUERY." [read-or-write query] {:pre [(map? query) (map? (:query query))]} - (try (let [{:keys [query database]} (qp/expand query)] - (tables->permissions-path-set read-or-write database (query->source-and-join-tables query))) - ;; 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 - (catch Throwable e - (log/warn "Error getting permissions for card:" (.getMessage e) "\n" - (u/pprint-to-str (u/filtered-stacktrace e))) - #{"/db/0/"}))) ; DB 0 will never exist + (try + (or (source-card-perms query) + (let [{:keys [query database]} (qp/expand query)] + (tables->permissions-path-set read-or-write database (query->source-and-join-tables query)))) + ;; 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 + (catch Throwable e + (log/warn "Error getting permissions for card:" (.getMessage e) "\n" + (u/pprint-to-str (u/filtered-stacktrace e))) + #{"/db/0/"}))) ; DB 0 will never exist ;; it takes a lot of DB calls and function calls to expand/resolve a query, and since they're pure functions we can ;; save ourselves some a lot of DB calls by caching the results. Cache the permissions reqquired to run a given query @@ -154,12 +169,14 @@ ;;; -------------------------------------------------- Dependencies -------------------------------------------------- (defn card-dependencies - "Calculate any dependent objects for a given `Card`." - [this id {:keys [dataset_query]}] - (when (and dataset_query - (= :query (keyword (:type dataset_query)))) - {:Metric (q/extract-metric-ids (:query dataset_query)) - :Segment (q/extract-segment-ids (:query dataset_query))})) + "Calculate any dependent objects for a given `card`." + ([_ _ card] + (card-dependencies card)) + ([{:keys [dataset_query]}] + (when (and dataset_query + (= :query (keyword (:type dataset_query)))) + {:Metric (q/extract-metric-ids (:query dataset_query)) + :Segment (q/extract-segment-ids (:query dataset_query))}))) ;;; -------------------------------------------------- Revisions -------------------------------------------------- diff --git a/src/metabase/models/permissions.clj b/src/metabase/models/permissions.clj index af5bfc5ef55c1c2de27752eaf6260a6b3588cd87..1bb3ac67e9f999875dea08cc0a6a8a2765b23201 100644 --- a/src/metabase/models/permissions.clj +++ b/src/metabase/models/permissions.clj @@ -145,17 +145,20 @@ (defn set-has-full-permissions? "Does PERMISSIONS-SET grant *full* access to object with PATH?" + {:style/indent 1} ^Boolean [permissions-set path] (boolean (some (u/rpartial is-permissions-for-object? path) permissions-set))) (defn set-has-partial-permissions? "Does PERMISSIONS-SET grant access full access to object with PATH *or* to a descendant of it?" + {:style/indent 1} ^Boolean [permissions-set path] (boolean (some (u/rpartial is-partial-permissions-for-object? path) permissions-set))) (defn ^Boolean set-has-full-permissions-for-set? "Do the permissions paths in PERMISSIONS-SET grant *full* access to all the object paths in OBJECT-PATHS-SET?" + {:style/indent 1} [permissions-set object-paths-set] {:pre [(is-permissions-set? permissions-set) (is-permissions-set? object-paths-set)]} (every? (partial set-has-full-permissions? permissions-set) @@ -164,6 +167,7 @@ (defn ^Boolean set-has-partial-permissions-for-set? "Do the permissions paths in PERMISSIONS-SET grant *partial* access to all the object paths in OBJECT-PATHS-SET? (PERMISSIONS-SET must grant partial access to *every* object in OBJECT-PATH-SETS set)." + {:style/indent 1} [permissions-set object-paths-set] {:pre [(is-permissions-set? permissions-set) (is-permissions-set? object-paths-set)]} (every? (partial set-has-partial-permissions? permissions-set) diff --git a/src/metabase/query_processor/util.clj b/src/metabase/query_processor/util.clj index 45aeed87ef43ab42611faef191d490c090b21d88..5441fa30c01096ff1fed8fcd3795fafcc819d0a0 100644 --- a/src/metabase/query_processor/util.clj +++ b/src/metabase/query_processor/util.clj @@ -127,3 +127,14 @@ "Return a 256-bit SHA3 hash of QUERY as a key for the cache. (This is returned as a byte array.)" [query] (hash/sha3-256 (json/generate-string (select-keys-for-hashing query)))) + + +;;; --------------------------------------------- Query Source Card IDs ---------------------------------------------- + +(defn query->source-card-id + "Return the ID of the Card used as the \"source\" query of this query, if applicable; otherwise return `nil`." + ^Integer [outer-query] + (let [source-table (get-in-normalized outer-query [:query :source-table])] + (when (string? source-table) + (when-let [[_ card-id-str] (re-matches #"^card__(\d+$)" source-table)] + (Integer/parseInt card-id-str))))) diff --git a/test/metabase/models/card_test.clj b/test/metabase/models/card_test.clj index 37769bdceaf889bbc075a02fefcab77748e5e1bd..26fd2374dfb40afcba5e36f1ad1584234bd7e15f 100644 --- a/test/metabase/models/card_test.clj +++ b/test/metabase/models/card_test.clj @@ -38,26 +38,29 @@ (expect {:Segment #{2 3} :Metric nil} - (card-dependencies Card 12 {:dataset_query {:type :query - :query {:aggregation ["rows"] - :filter ["AND" [">" 4 "2014-10-19"] ["=" 5 "yes"] ["SEGMENT" 2] ["SEGMENT" 3]]}}})) + (card-dependencies + {:dataset_query {:type :query + :query {:aggregation ["rows"] + :filter ["AND" [">" 4 "2014-10-19"] ["=" 5 "yes"] ["SEGMENT" 2] ["SEGMENT" 3]]}}})) (expect {:Segment #{1} :Metric #{7}} - (card-dependencies Card 12 {:dataset_query {:type :query - :query {:aggregation ["METRIC" 7] - :filter ["AND" [">" 4 "2014-10-19"] ["=" 5 "yes"] ["OR" ["SEGMENT" 1] ["!=" 5 "5"]]]}}})) + (card-dependencies + {:dataset_query {:type :query + :query {:aggregation ["METRIC" 7] + :filter ["AND" [">" 4 "2014-10-19"] ["=" 5 "yes"] ["OR" ["SEGMENT" 1] ["!=" 5 "5"]]]}}})) (expect {:Segment nil :Metric nil} - (card-dependencies Card 12 {:dataset_query {:type :query - :query {:aggregation nil - :filter nil}}})) + (card-dependencies + {:dataset_query {:type :query + :query {:aggregation nil + :filter nil}}})) -;;; ------------------------------------------------------------ Permissions Checking ------------------------------------------------------------ +;;; ---------------------------------------------- Permissions Checking ---------------------------------------------- (expect false @@ -150,13 +153,24 @@ :native {:query "SELECT * FROM CHECKINS"}}}] (query-perms-set (query-with-source-card card) :read))) +;; You should still only need native READ permissions if you want to save a Card based on another Card you can already +;; READ. (expect - #{(perms/native-readwrite-path (data/id))} + #{(perms/native-read-path (data/id))} (tt/with-temp Card [card {:dataset_query {:database (data/id) :type :native :native {:query "SELECT * FROM CHECKINS"}}}] (query-perms-set (query-with-source-card card) :write))) +;; However if you just pass in the same query directly as a `:source-query` you will still require READWRITE +;; permissions to save the query since we can't verify that it belongs to a Card that you can view. +(expect + #{(perms/native-readwrite-path (data/id))} + (query-perms-set {:database (data/id) + :type :query + :query {:source-query {:native "SELECT * FROM CHECKINS"}}} + :write)) + ;; invalid/legacy card should return perms for something that doesn't exist so no one gets to see it (expect #{"/db/0/"} diff --git a/test/metabase/query_processor_test/nested_queries_test.clj b/test/metabase/query_processor_test/nested_queries_test.clj index adfd312d32a3ef354a2bb5a73c82d538c7528f53..fcd817f818b4bc5d112c02b814fcd63d19606f42 100644 --- a/test/metabase/query_processor_test/nested_queries_test.clj +++ b/test/metabase/query_processor_test/nested_queries_test.clj @@ -9,13 +9,19 @@ [util :as u]] [metabase.driver.generic-sql :as generic-sql] [metabase.models - [card :refer [Card]] - [database :as database] + [card :as card :refer [Card]] + [database :as database :refer [Database]] [field :refer [Field]] + [permissions :as perms] + [permissions-group :as perms-group] [segment :refer [Segment]] [table :refer [Table]]] - [metabase.test.data :as data] - [metabase.test.data.datasets :as datasets] + [metabase.test + [data :as data] + [util :as tu]] + [metabase.test.data + [datasets :as datasets] + [users :refer [user->client]]] [toucan.db :as db] [toucan.util.test :as tt])) @@ -458,3 +464,62 @@ :aggregation [:count]) qp/process-query rows))) + + +;; Make suer you're allowed to save a query that uses a SQL-based source query even if you don't have SQL *write* +;; permissions (#6845) + +;; Check that write perms for a Card with a source query require that you are able to *read* (i.e., view) the source +;; query rather than be able to write (i.e., save) it. For example you should be able to save a query that uses a +;; native query as its source query if you have permissions to view that query, even if you aren't allowed to create +;; new ad-hoc SQL queries yourself. +(expect + #{(perms/native-read-path (data/id))} + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT * FROM VENUES"}}}] + (card/query-perms-set (query-with-source-card card :aggregation [:count]) :write))) + +;; try this in an end-to-end fashion using the API and make sure we can save a Card if we have appropriate read +;; permissions for the source query +(defn- do-with-temp-copy-of-test-db + "Run `f` with a temporary Database that copies the details from the standard test database. `f` is invoked as `(f + db)`." + [f] + (tt/with-temp Database [db {:details (:details (data/db)), :engine "h2"}] + (f db))) + +(defn- save-card-via-API-with-native-source-query! + "Attempt to save a Card that uses a native source query for Database with `db-id` via the API using Rasta. Use this to + test how the API endpoint behaves based on certain permissions grants for the `All Users` group." + [expected-status-code db-id] + (tt/with-temp Card [card {:dataset_query {:database db-id + :type :native + :native {:query "SELECT * FROM VENUES"}}}] + ((user->client :rasta) :post "card" + {:name (tu/random-name) + :display "scalar" + :visualization_settings {} + :dataset_query (query-with-source-card card + :aggregation [:count])}))) + +;; ok... grant native *read* permissions which means we should be able to view our source query generated with the +;; function above. API should allow use to save here because write permissions for a query require read permissions +;; for any source queries +(expect + :ok + (do-with-temp-copy-of-test-db + (fn [db] + (perms/revoke-permissions! (perms-group/all-users) (u/get-id db)) + (perms/grant-permissions! (perms-group/all-users) (perms/native-read-path (u/get-id db))) + (save-card-via-API-with-native-source-query! 200 (u/get-id db)) + :ok))) + +;; however, if we do *not* have read permissions for the source query, we shouldn't be allowed to save the query. This +;; API call should fail +(expect + "You don't have permissions to do that." + (do-with-temp-copy-of-test-db + (fn [db] + (perms/revoke-permissions! (perms-group/all-users) (u/get-id db)) + (save-card-via-API-with-native-source-query! 403 (u/get-id db)))))