diff --git a/resources/migrations/000_migrations.yaml b/resources/migrations/000_migrations.yaml index 1c99056f602d6848532f578595d588a8abf39440..d38a1e834a491613645d4d19de6e6adda727c879 100644 --- a/resources/migrations/000_migrations.yaml +++ b/resources/migrations/000_migrations.yaml @@ -4020,3 +4020,14 @@ databaseChangeLog: remarks: 'True if a XLS of the data should be included for this pulse card' constraints: nullable: false + - changeSet: + id: 75 + author: camsaul + comment: 'Added 0.28.2' + changes: + - addColumn: + tableName: report_card + columns: + name: read_permissions + type: text + remarks: 'Permissions required to view this Card and run its query.' diff --git a/src/metabase/api/database.clj b/src/metabase/api/database.clj index 97d03cd54e5d3f6ece2cfd277bc0fa542851ba26..0c5ac65cc61e3ef0e1570896897b75d17bd6b022 100644 --- a/src/metabase/api/database.clj +++ b/src/metabase/api/database.clj @@ -55,7 +55,9 @@ native (e.g. SQL) queries. Will be one of `:write`, `:read`, or `:none`." [dbs] (for [db dbs] - (let [user-has-perms? (fn [path-fn] (perms/set-has-full-permissions? @api/*current-user-permissions-set* (path-fn (u/get-id db))))] + (let [user-has-perms? (fn [path-fn] + (perms/set-has-full-permissions? @api/*current-user-permissions-set* + (path-fn (u/get-id db))))] (assoc db :native_permissions (cond (user-has-perms? perms/native-readwrite-path) :write (user-has-perms? perms/native-read-path) :read @@ -103,7 +105,8 @@ (defn- source-query-cards "Fetch the Cards that can be used as source queries (e.g. presented as virtual tables)." [] - (as-> (db/select [Card :name :description :database_id :dataset_query :id :collection_id :result_metadata] + (as-> (db/select [Card :name :description :database_id :dataset_query :id :collection_id :result_metadata + :read_permissions] :result_metadata [:not= nil] :archived false {:order-by [[:%lower.name :asc]]}) <> (filter card-database-supports-nested-queries? <>) @@ -142,7 +145,9 @@ include-cards? add-virtual-tables-for-saved-cards))) (api/defendpoint GET "/" - "Fetch all `Databases`." + "Fetch all `Databases`. `include_tables` means we should hydrate the Tables belonging to each DB. `include_cards` here + means we should also include virtual Table entries for saved Questions, e.g. so we can easily use them as source + Tables in queries. Default for both is `false`." [include_tables include_cards] {include_tables (s/maybe su/BooleanString) include_cards (s/maybe su/BooleanString)} diff --git a/src/metabase/db/migrations.clj b/src/metabase/db/migrations.clj index 1dc0844abf562b697f2e6629db3c648fe1089528..e6077ffb556f32d0af8d23675b8dff26df0f29ca 100644 --- a/src/metabase/db/migrations.clj +++ b/src/metabase/db/migrations.clj @@ -361,3 +361,16 @@ ;; either way, delete the old value from the DB since we'll never be using it again. ;; use `simple-delete!` because `Setting` doesn't have an `:id` column :( (db/simple-delete! Setting {:key "enable-advanced-humanization"}))) + +;; for every Card in the DB, pre-calculate the read permissions required to read the Card/run its query and save them +;; under the new `read_permissions` column. Calculating read permissions is too expensive to do on the fly for Cards, +;; since it requires parsing their queries and expanding things like FKs or Segment/Metric macros. Simply calling +;; `update!` on each Card will cause it to be saved with updated `read_permissions` as a side effect of Card's +;; `pre-update` implementation. +;; +;; Caching these permissions will prevent 1000+ DB call API calls. See https://github.com/metabase/metabase/issues/6889 +(defmigration ^{:author "camsaul", :added "0.28.2"} populate-card-read-permissions + (run! + (fn [card] + (db/update! Card (u/get-id card) {})) + (db/select-reducible Card :archived false, :read_permissions nil))) diff --git a/src/metabase/models/card.clj b/src/metabase/models/card.clj index dbe47eec12ab2da9d1c567282e568e824c1ce8c9..389969c91d256a8dc398daf40cec71a1e051b8d4 100644 --- a/src/metabase/models/card.clj +++ b/src/metabase/models/card.clj @@ -114,28 +114,48 @@ (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 -;; dictionary for up to 6 hours +;; Calculating Card read permissions is rather expensive, since we must parse and expand the Card's query in order to +;; find the Tables it references. Since we read Cards relatively often, these permissions are cached in the +;; `:read_permissions` column of Card. There should not be situtations where these permissions are not cached; but if +;; for some strange reason they are we will go ahead and recalculate them. + ;; TODO - what if the query uses a source query, and that query changes? Not sure if that will cause an issue or not. -;; May need to revisit this -(defn- query-perms-set* [{query-type :type, database :database, :as query} read-or-write] +(defn query-perms-set + "Calculate the set of permissions required to `:read`/run or `:write` (update) a Card with `query`. In normal cases + for read permissions you should look at a Card's `:read_permissions`, which is precalculated. If you specifically + need to calculate permissions for a query directly, and ignore anything precomputed, use this function. Otherwise + you should rely on one of the optimized ones below." + [{query-type :type, database :database, :as query} read-or-write] (cond - (= query {}) #{} + (empty? query) #{} (= (keyword query-type) :native) #{(native-permissions-path read-or-write database)} (= (keyword query-type) :query) (mbql-permissions-path-set read-or-write query) :else (throw (Exception. (str "Invalid query type: " query-type))))) -(def ^{:arglists '([query read-or-write])} query-perms-set - "Return a set of required permissions for *running* QUERY (if READ-OR-WRITE is `:read`) or *saving* it (if - READ-OR-WRITE is `:write`)." - (memoize/ttl query-perms-set* :ttl/threshold (* 6 60 60 1000))) ; memoize for 6 hours -(defn- perms-objects-set - "Return a set of required permissions object paths for CARD. - Optionally specify whether you want `:read` or `:write` permissions; default is `:read`. - (`:write` permissions only affects native queries)." - [{query :dataset_query, collection-id :collection_id, public-uuid :public_uuid, in-public-dash? :in_public_dashboard} +(defn- card-perms-set-for-query + "Return the permissions required to `read-or-write` `card` based on its query, disregarding the collection the Card is + in, whether it is publicly shared, etc. This will return precalculated `:read_permissions` if they are present." + [{read-perms :read_permissions, id :id, query :dataset_query} read-or-write] + (cond + ;; for WRITE permissions always recalculate since these will be determined relatively infrequently (hopefully) + ;; e.g. when updating a Card + (= :write read-or-write) (query-perms-set query :write) + ;; if the Card has populated `:read_permissions` and we're looking up read pems return those rather than calculating + ;; on-the-fly. Cast to `set` to be extra-double-sure it's a set like we'd expect when it gets deserialized from JSON + read-perms (set read-perms) + ;; otherwise if :read_permissions was NOT populated. This should not normally happen since the data migration + ;; should have pre-populated values for all the Cards. If it does it might mean something like we fetched the Card + ;; without its `read_permissions` column. Since that would be "doing something wrong" warn about it. + :else (do (log/warn "Card" id "is missing its read_permissions. Calculating them now...") + (query-perms-set query :read)))) + +(defn- card-perms-set-for-current-user + "Calculate the permissions required to `read-or-write` `card` *for the current user*. This takes into account whether + the Card is publicly available, or in a collection the current user can view; it also attempts to use precalcuated + `read_permissions` when possible. This is the function that should be used for general permissions checking for a + Card." + [{collection-id :collection_id, public-uuid :public_uuid, in-public-dash? :in_public_dashboard, :as card} read-or-write] (cond ;; you don't need any permissions to READ a public card, which is PUBLIC by definition :D @@ -148,7 +168,7 @@ (collection/perms-objects-set collection-id read-or-write) :else - (query-perms-set query read-or-write))) + (card-perms-set-for-query card read-or-write))) ;;; -------------------------------------------------- Dependencies -------------------------------------------------- @@ -201,15 +221,16 @@ :query_type (keyword query-type)}) card)) -(defn- pre-insert [{:keys [dataset_query], :as card}] +(defn- pre-insert [{query :dataset_query, :as card}] ;; TODO - make sure if `collection_id` is specified that we have write permissions for that collection - (u/prog1 card + ;; Save the new Card with read permissions since calculating them dynamically is so expensive. + (u/prog1 (assoc card :read_permissions (query-perms-set query :read)) ;; for native queries we need to make sure the user saving the card has native query permissions for the DB ;; because users can always see native Cards and we don't want someone getting around their lack of permissions ;; that way (when (and *current-user-id* - (= (keyword (:type dataset_query)) :native)) - (let [database (db/select-one ['Database :id :name], :id (:database dataset_query))] + (= (keyword (:type query)) :native)) + (let [database (db/select-one ['Database :id :name], :id (:database query))] (qp-perms/throw-if-cannot-run-new-native-query-referencing-db database))))) (defn- post-insert [card] @@ -220,8 +241,9 @@ (log/info "Card references Fields in params:" field-ids) (field-values/update-field-values-for-on-demand-dbs! field-ids)))) -(defn- pre-update [{archived? :archived, :as card}] - (u/prog1 card +(defn- pre-update [{archived? :archived, query :dataset_query, :as card}] + ;; save the updated Card with updated read permissions. + (u/prog1 (assoc card :read_permissions (query-perms-set query :read)) ;; if the Card is archived, then remove it from any Dashboards (when archived? (db/delete! 'DashboardCard :card_id (u/get-id card))) @@ -260,7 +282,8 @@ :embedding_params :json :query_type :keyword :result_metadata :json - :visualization_settings :json}) + :visualization_settings :json + :read_permissions :json-set}) :properties (constantly {:timestamped? true}) :pre-update (comp populate-query-fields pre-update) :pre-insert (comp populate-query-fields pre-insert) @@ -272,7 +295,7 @@ (merge i/IObjectPermissionsDefaults {:can-read? (partial i/current-user-has-full-permissions? :read) :can-write? (partial i/current-user-has-full-permissions? :write) - :perms-objects-set perms-objects-set}) + :perms-objects-set card-perms-set-for-current-user}) revision/IRevisioned (assoc revision/IRevisionedDefaults diff --git a/src/metabase/models/common.clj b/src/metabase/models/common.clj index b40d81aa5b05fc6dbcc9b579a8e99993545bf185..cc35601ae04f0108acd0e13df3b66e956a90bfb5 100644 --- a/src/metabase/models/common.clj +++ b/src/metabase/models/common.clj @@ -1,6 +1,6 @@ (ns metabase.models.common) -(def ^:const timezones +(def timezones "The different timezones supported by Metabase. Presented as options for the `report-timezone` Setting in the admin panel." ["Africa/Algiers" diff --git a/src/metabase/models/interface.clj b/src/metabase/models/interface.clj index 796d154c6600c81bfb7592eee23d3acd2e77f7eb..b938bed4f8dd4bf53234119f41ee8e665f1fc510 100644 --- a/src/metabase/models/interface.clj +++ b/src/metabase/models/interface.clj @@ -32,6 +32,12 @@ :in json-in :out json-out) +;; json-set is just like json but calls `set` on it when coming out of the DB. Intended for storing things like a +;; permissions set +(models/add-type! :json-set + :in json-in + :out #(when % (set (json-out %)))) + (models/add-type! :clob :in identity :out u/jdbc-clob->str) diff --git a/src/metabase/task/sync_databases.clj b/src/metabase/task/sync_databases.clj index 12450ed14b47a1a4c4e946bfa575726b02f939bb..3c1394e53f02721e5bb1f310c21442b22a948815 100644 --- a/src/metabase/task/sync_databases.clj +++ b/src/metabase/task/sync_databases.clj @@ -20,9 +20,9 @@ (:import metabase.models.database.DatabaseInstance [org.quartz CronTrigger DisallowConcurrentExecution JobDetail JobKey TriggerKey])) -;;; +------------------------------------------------------------------------------------------------------------------------+ -;;; | JOB LOGIC | -;;; +------------------------------------------------------------------------------------------------------------------------+ +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | JOB LOGIC | +;;; +----------------------------------------------------------------------------------------------------------------+ (s/defn ^:private job-context->database :- DatabaseInstance "Get the Database referred to in JOB-CONTEXT. Guaranteed to return a valid Database." @@ -43,9 +43,9 @@ (when (:is_full_sync database) (field-values/update-field-values! (job-context->database job-context))))) -;;; +------------------------------------------------------------------------------------------------------------------------+ -;;; | TASK INFO AND GETTER FUNCTIONS | -;;; +------------------------------------------------------------------------------------------------------------------------+ +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | TASK INFO AND GETTER FUNCTIONS | +;;; +----------------------------------------------------------------------------------------------------------------+ (def ^:private TaskInfo "One-off schema for information about the various sync tasks we run for a DB." @@ -64,7 +64,8 @@ :job-class UpdateFieldValues}) -;; These getter functions are not strictly neccesary but are provided primarily so we can get some extra validation by using them +;; These getter functions are not strictly neccesary but are provided primarily so we can get some extra validation by +;; using them (s/defn ^:private job-key :- JobKey "Return an appropriate string key for the job described by TASK-INFO for DATABASE-OR-ID." @@ -97,15 +98,16 @@ (format "%s for all databases" (name (:key task-info)))) -;;; +------------------------------------------------------------------------------------------------------------------------+ -;;; | DELETING TASKS FOR A DB | -;;; +------------------------------------------------------------------------------------------------------------------------+ +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | DELETING TASKS FOR A DB | +;;; +----------------------------------------------------------------------------------------------------------------+ (s/defn ^:private delete-task! "Cancel a single sync task for DATABASE-OR-ID and TASK-INFO." [database :- DatabaseInstance, task-info :- TaskInfo] (let [trigger-key (trigger-key database task-info)] - (log/debug (u/format-color 'red "Unscheduling task for Database %d: trigger: %s" (u/get-id database) (.getName trigger-key))) + (log/debug (u/format-color 'red "Unscheduling task for Database %d: trigger: %s" + (u/get-id database) (.getName trigger-key))) (task/delete-trigger! trigger-key))) (s/defn unschedule-tasks-for-db! @@ -114,9 +116,10 @@ (delete-task! database sync-analyze-task-info) (delete-task! database field-values-task-info)) -;;; +------------------------------------------------------------------------------------------------------------------------+ -;;; | (RE)SCHEDULING TASKS FOR A DB | -;;; +------------------------------------------------------------------------------------------------------------------------+ + +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | (RE)SCHEDULING TASKS FOR A DB | +;;; +----------------------------------------------------------------------------------------------------------------+ (s/defn ^:private job :- JobDetail "Build a durable Quartz Job for TASK-INFO. Durable in Quartz allows the job to exist even if there are no triggers @@ -158,17 +161,19 @@ ;; unschedule any tasks that might already be scheduled (unschedule-tasks-for-db! database) - (log/debug (u/format-color 'green "Scheduling sync/analyze and field-values task for database %d: trigger: %s and trigger: %s" - (u/get-id database) (.getName (.getKey sync-trigger)) - (u/get-id database) (.getName (.getKey fv-trigger)))) + (log/debug + (u/format-color 'green "Scheduling sync/analyze and field-values task for database %d: trigger: %s and trigger: %s" + (u/get-id database) (.getName (.getKey sync-trigger)) + (u/get-id database) (.getName (.getKey fv-trigger)))) ;; now (re)schedule all the tasks (task/add-trigger! sync-trigger) (task/add-trigger! fv-trigger))) -;;; +------------------------------------------------------------------------------------------------------------------------+ -;;; | TASK INITIALIZATION | -;;; +------------------------------------------------------------------------------------------------------------------------+ + +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | TASK INITIALIZATION | +;;; +----------------------------------------------------------------------------------------------------------------+ (defn- job-init "Separated from `task-init` primarily as it's useful in testing. Adds the sync and field-values job that all of the @@ -179,7 +184,7 @@ (defn task-init "Automatically called during startup; start the jobs for syncing/analyzing and updating FieldValues for all - Databases." + Databases." [] (job-init) (doseq [database (db/select Database)] diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index 39eda790784de774a2da37c068947d2230e66c46..055f44e5ea9a6a5c1cb31fe42c8c12eab14f1b6b 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -213,9 +213,10 @@ :database_id database-id ; these should be inferred automatically :table_id table-id :labels [] - :can_write true, - :dashboard_count 0, + :can_write true + :dashboard_count 0 :collection nil + :read_permissions [(format "/db/%d/schema//table/%d/" database-id table-id)] :creator (match-$ (fetch-user :rasta) {:common_name "Rasta Toucan" :is_superuser false @@ -295,6 +296,7 @@ :id $}) :updated_at $ :dataset_query $ + :read_permissions [(format "/db/%d/schema//table/%d/" database-id table-id)] :id $ :display "table" :visualization_settings {} diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj index c79305af0615355491ef2e3e4fc2fe52e610e545..788c6bd7e03ab9fa42aaad86ebe2b79465df447f 100644 --- a/test/metabase/api/dashboard_test.clj +++ b/test/metabase/api/dashboard_test.clj @@ -133,6 +133,7 @@ :display "table" :query_type nil :dataset_query {} + :read_permissions [] :visualization_settings {} :query_average_duration nil :in_public_dashboard false diff --git a/test/metabase/api/embed_test.clj b/test/metabase/api/embed_test.clj index 6818ada0e8b198d27844d0fcf6ad07c8bf549715..74cfc365e5a9362608538b0fc1441e29e163fb7e 100644 --- a/test/metabase/api/embed_test.clj +++ b/test/metabase/api/embed_test.clj @@ -139,10 +139,12 @@ [{:id nil, :type "date/single", :target ["variable" ["template-tag" "d"]], :name "d", :slug "d", :default nil}] (with-embedding-enabled-and-new-secret-key (with-temp-card [card {:enable_embedding true - :dataset_query {:native {:template_tags {:a {:type "date", :name "a", :display_name "a"} - :b {:type "date", :name "b", :display_name "b"} - :c {:type "date", :name "c", :display_name "c"} - :d {:type "date", :name "d", :display_name "d"}}}} + :dataset_query {:database (data/id) + :type :native + :native {:template_tags {:a {:type "date", :name "a", :display_name "a"} + :b {:type "date", :name "b", :display_name "b"} + :c {:type "date", :name "c", :display_name "c"} + :d {:type "date", :name "d", :display_name "d"}}}} :embedding_params {:a "locked", :b "disabled", :c "enabled", :d "enabled"}}] (:parameters (http/client :get 200 (card-url card {:params {:c 100}})))))) diff --git a/test/metabase/api/preview_embed_test.clj b/test/metabase/api/preview_embed_test.clj index e43f6f91636318691512faa2dd146f09d9ba98d7..322278c8b19d45837cd336a6af6c26a0565643ee 100644 --- a/test/metabase/api/preview_embed_test.clj +++ b/test/metabase/api/preview_embed_test.clj @@ -48,10 +48,12 @@ [{:id nil, :type "date/single", :target ["variable" ["template-tag" "d"]], :name "d", :slug "d", :default nil}] (embed-test/with-embedding-enabled-and-new-secret-key (embed-test/with-temp-card [card {:dataset_query - {:native {:template_tags {:a {:type "date", :name "a", :display_name "a"} - :b {:type "date", :name "b", :display_name "b"} - :c {:type "date", :name "c", :display_name "c"} - :d {:type "date", :name "d", :display_name "d"}}}}}] + {:database (data/id) + :type :native + :native {:template_tags {:a {:type "date", :name "a", :display_name "a"} + :b {:type "date", :name "b", :display_name "b"} + :c {:type "date", :name "c", :display_name "c"} + :d {:type "date", :name "d", :display_name "d"}}}}}] (-> ((test-users/user->client :crowberto) :get 200 (card-url card {:_embedding_params {:a "locked" :b "disabled" :c "enabled" diff --git a/test/metabase/api/public_test.clj b/test/metabase/api/public_test.clj index 1356f0ceda35488926818f189546569b3b426793..088808c9ef04d16a3bc822739e49e90d918209c0 100644 --- a/test/metabase/api/public_test.clj +++ b/test/metabase/api/public_test.clj @@ -53,7 +53,9 @@ (defn- add-card-to-dashboard! [card dashboard] (db/insert! DashboardCard :dashboard_id (u/get-id dashboard), :card_id (u/get-id card))) -(defmacro with-temp-public-dashboard-and-card {:style/indent 1} [[dashboard-binding card-binding & [dashcard-binding]] & body] +(defmacro ^:private with-temp-public-dashboard-and-card + {:style/indent 1} + [[dashboard-binding card-binding & [dashcard-binding]] & body] `(with-temp-public-dashboard [dash#] (with-temp-public-card [card#] (let [~dashboard-binding dash# @@ -98,18 +100,20 @@ {(data/id :categories :name) {:values 75 :human_readable_values {} :field_id (data/id :categories :name)}} - (tt/with-temp Card [card {:dataset_query {:type :native - :native {:query (str "SELECT COUNT(*) " - "FROM venues " - "LEFT JOIN categories ON venues.category_id = categories.id " - "WHERE {{category}}") - :collection "CATEGORIES" - :template_tags {:category {:name "category" - :display_name "Category" - :type "dimension" - :dimension ["field-id" (data/id :categories :name)] - :widget_type "category" - :required true}}}}}] + (tt/with-temp Card [card {:dataset_query + {:database (data/id) + :type :native + :native {:query (str "SELECT COUNT(*) " + "FROM venues " + "LEFT JOIN categories ON venues.category_id = categories.id " + "WHERE {{category}}") + :collection "CATEGORIES" + :template_tags {:category {:name "category" + :display_name "Category" + :type "dimension" + :dimension ["field-id" (data/id :categories :name)] + :widget_type "category" + :required true}}}}}] (-> (:param_values (#'public-api/public-card :id (u/get-id card))) (update-in [(data/id :categories :name) :values] count)))) @@ -313,7 +317,9 @@ (tu/with-temporary-setting-values [enable-public-sharing true] (with-temp-public-dashboard-and-card [dash card] (with-temp-public-card [card-2] - (tt/with-temp DashboardCardSeries [_ {:dashboardcard_id (db/select-one-id DashboardCard :card_id (u/get-id card), :dashboard_id (u/get-id dash)) + (tt/with-temp DashboardCardSeries [_ {:dashboardcard_id (db/select-one-id DashboardCard + :card_id (u/get-id card) + :dashboard_id (u/get-id dash)) :card_id (u/get-id card-2)}] (qp-test/rows (http/client :get 200 (dashcard-url-path dash card-2)))))))) @@ -334,7 +340,8 @@ (db/update! Dashboard (u/get-id dashboard) :parameters [{:name "Price", :type "category", :slug "price"}])) (defn- add-dimension-param-mapping-to-dashcard! [dashcard card dimension] - (db/update! DashboardCard (u/get-id dashcard) :parameter_mappings [{:card_id (u/get-id card), :target ["dimension" dimension]}])) + (db/update! DashboardCard (u/get-id dashcard) :parameter_mappings [{:card_id (u/get-id card) + :target ["dimension" dimension]}])) (defn- GET-param-values [dashboard] (tu/with-temporary-setting-values [enable-public-sharing true] @@ -344,7 +351,13 @@ (expect (price-param-values) (with-temp-public-dashboard-and-card [dash card dashcard] - (db/update! Card (u/get-id card) :dataset_query {:native {:template_tags {:price {:name "price", :display_name "Price", :type "dimension", :dimension ["field-id" (data/id :venues :price)]}}}}) + (db/update! Card (u/get-id card) + :dataset_query {:database (data/id) + :type :native + :native {:template_tags {:price {:name "price" + :display_name "Price" + :type "dimension" + :dimension ["field-id" (data/id :venues :price)]}}}}) (add-price-param-to-dashboard! dash) (add-dimension-param-mapping-to-dashcard! dashcard card ["template-tag" "price"]) (GET-param-values dash))) diff --git a/test/metabase/events/revision_test.clj b/test/metabase/events/revision_test.clj index 85ce227362a4d8e699d39a4e354630c32a251dfa..27a7177a1ce304747d6b2197afaffd989fb3625c 100644 --- a/test/metabase/events/revision_test.clj +++ b/test/metabase/events/revision_test.clj @@ -33,6 +33,7 @@ :creator_id (:creator_id card) :database_id (id) :dataset_query (:dataset_query card) + :read_permissions (vec (:read_permissions card)) :description nil :display "table" :enable_embedding false