From 2455537bc1392a9919b520f1a2d0f4b5a600f194 Mon Sep 17 00:00:00 2001 From: adam-james <21064735+adam-james-v@users.noreply.github.com> Date: Wed, 8 May 2024 10:54:17 -0700 Subject: [PATCH] Dashboard Param Values Common Impl for Embedding/Preview Embedding (#42052) * Dashboard Param Values Common Impl for Embedding/Preview Embedding Refactor the embedding api namespace to allow a bit of reuse between the embedding api and the embedding preview api. In some cases, the implementations are not identical (in terms of the shape of data expected/endpoints available), so this is a first step to making them the same. Related: https://www.notion.so/metabase/Make-embedding-preview-behave-consistently-with-actual-static-embeds-855353d8e5e8411d8164c7ac563c7d2f * Add a test to verify that preview-embed param-values endpoint works * Use preview embed endpoint and fix relevant tests * Add tests * Change name to reflect what actually happens in the function --------- Co-authored-by: Mahatthana Nomsawadi <mahatthana.n@gmail.com> --- ...-embed-preview-parameter-values.cy.spec.js | 233 +++++++++ ...ew-parameter-values-not-working.cy.spec.js | 149 ------ ...-when-embedding-long-dashboards.cy.spec.js | 1 - .../dashboard/actions/data-fetching.js | 3 +- frontend/src/metabase/services.js | 10 +- src/metabase/api/embed.clj | 488 +----------------- src/metabase/api/embed/common.clj | 478 +++++++++++++++++ src/metabase/api/preview_embed.clj | 33 +- test/metabase/api/embed_test.clj | 28 +- test/metabase/api/preview_embed_test.clj | 46 ++ 10 files changed, 821 insertions(+), 648 deletions(-) create mode 100644 e2e/test/scenarios/embedding/reproductions/37914-41635-embed-preview-parameter-values.cy.spec.js delete mode 100644 e2e/test/scenarios/embedding/reproductions/37914-embed-preview-parameter-values-not-working.cy.spec.js create mode 100644 src/metabase/api/embed/common.clj diff --git a/e2e/test/scenarios/embedding/reproductions/37914-41635-embed-preview-parameter-values.cy.spec.js b/e2e/test/scenarios/embedding/reproductions/37914-41635-embed-preview-parameter-values.cy.spec.js new file mode 100644 index 00000000000..567ad40eb39 --- /dev/null +++ b/e2e/test/scenarios/embedding/reproductions/37914-41635-embed-preview-parameter-values.cy.spec.js @@ -0,0 +1,233 @@ +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; +import { + addOrUpdateDashboardCard, + getIframeBody, + modal, + openStaticEmbeddingModal, + popover, + restore, + visitDashboard, +} from "e2e/support/helpers"; + +const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; + +const questionDetails = { + name: "Products", + query: { "source-table": PRODUCTS_ID }, +}; + +const filter3 = { + name: "Text 2", + slug: "text_2", + id: "b0665b6a", + type: "string/=", + sectionId: "string", +}; + +const filter2 = { + name: "Text 1", + slug: "text_1", + id: "d4c9f2e5", + type: "string/=", + sectionId: "string", +}; + +const filter = { + filteringParameters: [filter2.id], + name: "Text", + slug: "text", + id: "d1b69627", + type: "string/=", + sectionId: "string", +}; + +describe("dashboard preview", () => { + beforeEach(() => { + cy.intercept("GET", "/api/preview_embed/dashboard/**").as( + "previewDashboard", + ); + cy.intercept("GET", "/api/preview_embed/dashboard/**/params/**/values").as( + "previewValues", + ); + + restore(); + cy.signInAsAdmin(); + }); + + it("dashboard linked filters values don't work in static embed preview (metabase#37914)", () => { + const dashboardDetails = { + parameters: [filter, filter2, filter3], + enable_embedding: true, + embedding_params: { + [filter.slug]: "enabled", + [filter2.slug]: "enabled", + [filter3.slug]: "enabled", + }, + }; + cy.createQuestionAndDashboard({ + questionDetails, + dashboardDetails, + }).then(({ body: { card_id, dashboard_id } }) => { + addOrUpdateDashboardCard({ + dashboard_id, + card_id, + card: { + parameter_mappings: [ + { + card_id, + parameter_id: filter.id, + target: [ + "dimension", + ["field", PRODUCTS.CATEGORY, { "base-type": "type/Text" }], + ], + }, + { + card_id, + parameter_id: filter2.id, + target: [ + "dimension", + ["field", PRODUCTS.CATEGORY, { "base-type": "type/Text" }], + ], + }, + { + card_id, + parameter_id: filter3.id, + target: [ + "dimension", + ["field", PRODUCTS.CATEGORY, { "base-type": "type/Text" }], + ], + }, + ], + }, + }); + + visitDashboard(dashboard_id); + }); + + openStaticEmbeddingModal({ + activeTab: "parameters", + previewMode: "preview", + }); + + modal().within(() => { + // Makes it less likely to flake. + cy.wait("@previewDashboard"); + + getIframeBody().within(() => { + cy.log( + "Set filter 2 value, so filter 1 should be filtered by filter 2", + ); + cy.button(filter2.name).click(); + cy.wait("@previewValues"); + popover().within(() => { + cy.findByText("Gadget").should("be.visible"); + cy.findByText("Gizmo").should("be.visible"); + cy.findByText("Widget").should("be.visible"); + cy.findByText("Doohickey").click(); + cy.button("Add filter").click(); + }); + + cy.log("Assert filter 1"); + cy.button(filter.name).click(); + popover().within(() => { + cy.findByText("Gadget").should("not.exist"); + cy.findByText("Gizmo").should("not.exist"); + cy.findByText("Widget").should("not.exist"); + cy.findByText("Doohickey").should("be.visible"); + }); + }); + }); + }); + + it("dashboard linked filters values in embed preview don't behave like embedding (metabase#41635)", () => { + const dashboardDetails = { + parameters: [filter, filter2, filter3], + enable_embedding: true, + embedding_params: { + [filter.slug]: "enabled", + [filter2.slug]: "locked", + [filter3.slug]: "locked", + }, + }; + cy.createQuestionAndDashboard({ + questionDetails, + dashboardDetails, + }).then(({ body: { card_id, dashboard_id } }) => { + addOrUpdateDashboardCard({ + dashboard_id, + card_id, + card: { + parameter_mappings: [ + { + card_id, + parameter_id: filter.id, + target: [ + "dimension", + ["field", PRODUCTS.CATEGORY, { "base-type": "type/Text" }], + ], + }, + { + card_id, + parameter_id: filter2.id, + target: [ + "dimension", + ["field", PRODUCTS.CATEGORY, { "base-type": "type/Text" }], + ], + }, + { + card_id, + parameter_id: filter3.id, + target: [ + "dimension", + ["field", PRODUCTS.CATEGORY, { "base-type": "type/Text" }], + ], + }, + ], + }, + }); + + visitDashboard(dashboard_id); + }); + + openStaticEmbeddingModal({ + activeTab: "parameters", + previewMode: "preview", + }); + + // Makes it less likely to flake. + cy.wait("@previewDashboard"); + + cy.log("Set the first locked parameter values"); + modal() + .findByRole("generic", { name: "Previewing locked parameters" }) + .findByText("Text 1") + .click(); + popover().within(() => { + cy.findByText("Doohickey").click(); + cy.button("Add filter").click(); + }); + + cy.log("Set the second locked parameter values"); + modal() + .findByRole("generic", { name: "Previewing locked parameters" }) + .findByText("Text 2") + .click(); + popover().within(() => { + cy.findByText("Doohickey").click(); + cy.findByText("Gizmo").click(); + cy.findByText("Gadget").click(); + cy.button("Add filter").click(); + }); + + getIframeBody().within(() => { + cy.log("Assert filter 1"); + cy.button(filter.name).click(); + popover().within(() => { + cy.findByText("Gadget").should("not.exist"); + cy.findByText("Gizmo").should("not.exist"); + cy.findByText("Widget").should("not.exist"); + cy.findByText("Doohickey").should("be.visible"); + }); + }); + }); +}); diff --git a/e2e/test/scenarios/embedding/reproductions/37914-embed-preview-parameter-values-not-working.cy.spec.js b/e2e/test/scenarios/embedding/reproductions/37914-embed-preview-parameter-values-not-working.cy.spec.js deleted file mode 100644 index a4210e07666..00000000000 --- a/e2e/test/scenarios/embedding/reproductions/37914-embed-preview-parameter-values-not-working.cy.spec.js +++ /dev/null @@ -1,149 +0,0 @@ -import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; -import { - addOrUpdateDashboardCard, - dashboardHeader, - getIframeBody, - modal, - popover, - restore, - visitDashboard, -} from "e2e/support/helpers"; - -const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; - -const questionDetails = { - name: "Products", - query: { "source-table": PRODUCTS_ID }, -}; - -const filter3 = { - name: "Text 2", - slug: "text_2", - id: "b0665b6a", - type: "string/=", - sectionId: "string", -}; - -const filter2 = { - name: "Text 1", - slug: "text_1", - id: "d4c9f2e5", - type: "string/=", - sectionId: "string", -}; - -const filter = { - filteringParameters: [filter2.id], - name: "Text", - slug: "text", - id: "d1b69627", - type: "string/=", - sectionId: "string", -}; - -const dashboardDetails = { - parameters: [filter, filter2, filter3], - enable_embedding: true, - embedding_params: { - [filter.slug]: "enabled", - [filter2.slug]: "enabled", - [filter3.slug]: "enabled", - }, -}; - -describe("issue 37914", () => { - beforeEach(() => { - cy.intercept("GET", "/api/preview_embed/dashboard/**").as( - "previewDashboard", - ); - cy.intercept("GET", "/api/dashboard/**/params/**/values").as("values"); - - restore(); - cy.signInAsAdmin(); - - cy.createQuestionAndDashboard({ - questionDetails, - dashboardDetails, - }).then(({ body: { id, card_id, dashboard_id } }) => { - addOrUpdateDashboardCard({ - dashboard_id, - card_id, - card: { - parameter_mappings: [ - { - card_id, - parameter_id: filter.id, - target: [ - "dimension", - ["field", PRODUCTS.CATEGORY, { "base-type": "type/Text" }], - ], - }, - { - card_id, - parameter_id: filter2.id, - target: [ - "dimension", - ["field", PRODUCTS.CATEGORY, { "base-type": "type/Text" }], - ], - }, - { - card_id, - parameter_id: filter3.id, - target: [ - "dimension", - ["field", PRODUCTS.CATEGORY, { "base-type": "type/Text" }], - ], - }, - ], - }, - }); - - visitDashboard(dashboard_id); - }); - }); - - it("dashboard linked filters values doesn't work in static embed preview (metabase#37914)", () => { - dashboardHeader().within(() => { - cy.icon("share").click(); - }); - - popover().findByText("Embed").click(); - - modal().within(() => { - cy.findByText("Static embed").click(); - - cy.button("Agree and continue").click(); - - cy.findByRole("tab", { name: "Parameters" }).click(); - - cy.findByText("Preview").click(); - - // Makes it less likely to flake. - cy.wait("@previewDashboard"); - - getIframeBody().within(() => { - cy.log( - "Set filter 2 value, so filter 1 should be filtered by filter 2", - ); - cy.button(filter2.name).click(); - cy.wait("@values"); - popover().within(() => { - cy.findByText("Gadget").should("be.visible"); - cy.findByText("Gizmo").should("be.visible"); - cy.findByText("Widget").should("be.visible"); - cy.findByText("Doohickey").click(); - cy.button("Add filter").click(); - }); - - cy.log("Assert filter 1"); - cy.button(filter.name).click(); - popover().within(() => { - cy.findByText("Gadget").should("not.exist"); - cy.findByText("Gizmo").should("not.exist"); - cy.findByText("Widget").should("not.exist"); - cy.findByText("Doohickey").should("be.visible"); - }); - }); - }); - }); -}); diff --git a/e2e/test/scenarios/embedding/reproductions/40660-overflow-when-embedding-long-dashboards.cy.spec.js b/e2e/test/scenarios/embedding/reproductions/40660-overflow-when-embedding-long-dashboards.cy.spec.js index d0b68551d2f..2a0c57d7623 100644 --- a/e2e/test/scenarios/embedding/reproductions/40660-overflow-when-embedding-long-dashboards.cy.spec.js +++ b/e2e/test/scenarios/embedding/reproductions/40660-overflow-when-embedding-long-dashboards.cy.spec.js @@ -24,7 +24,6 @@ describe("issue 40660", () => { cy.intercept("GET", "/api/preview_embed/dashboard/**").as( "previewDashboard", ); - cy.intercept("GET", "/api/dashboard/**/params/**/values").as("values"); restore(); cy.signInAsAdmin(); diff --git a/frontend/src/metabase/dashboard/actions/data-fetching.js b/frontend/src/metabase/dashboard/actions/data-fetching.js index 7054366686c..04679e03f48 100644 --- a/frontend/src/metabase/dashboard/actions/data-fetching.js +++ b/frontend/src/metabase/dashboard/actions/data-fetching.js @@ -3,7 +3,6 @@ import { denormalize, normalize, schema } from "normalizr"; import { t } from "ttag"; import { showAutoApplyFiltersToast } from "metabase/dashboard/actions/parameters"; -import { IS_EMBED_PREVIEW } from "metabase/lib/embed"; import { defer } from "metabase/lib/promise"; import { createAction, @@ -192,7 +191,7 @@ export const fetchDashboard = createAsyncThunk( ); result = { ...result, - id: IS_EMBED_PREVIEW ? result.id : dashId, + id: dashId, dashcards: result.dashcards.map(dc => ({ ...dc, dashboard_id: dashId, diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index 814093abbdf..f74660547d6 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -480,7 +480,9 @@ export function setEmbedQuestionEndpoints(token) { export function setEmbedDashboardEndpoints() { if (!IS_EMBED_PREVIEW) { - setDashboardEndpoints("/api/embed"); + setDashboardEndpoints(embedBase); + } else { + setDashboardParameterValuesEndpoint(embedBase); } } @@ -523,6 +525,12 @@ function setDashboardEndpoints(prefix) { ); } +function setDashboardParameterValuesEndpoint(prefix) { + DashboardApi.parameterValues = GET( + `${prefix}/dashboard/:dashId/params/:paramId/values`, + ); +} + export const ActionsApi = { list: GET("/api/action"), get: GET("/api/action/:id"), diff --git a/src/metabase/api/embed.clj b/src/metabase/api/embed.clj index eb067236aa7..6159fbafe05 100644 --- a/src/metabase/api/embed.clj +++ b/src/metabase/api/embed.clj @@ -15,370 +15,25 @@ :dashboard <dashboard-id>} :params <params>}" (:require - [clojure.set :as set] - [clojure.string :as str] [compojure.core :refer [GET]] [medley.core :as m] - [metabase.api.card :as api.card] [metabase.api.common :as api] - [metabase.api.common.validation :as validation] - [metabase.api.dashboard :as api.dashboard] [metabase.api.dataset :as api.dataset] + [metabase.api.embed.common :as api.embed.common] [metabase.api.public :as api.public] - [metabase.driver.common.parameters.operators :as params.ops] [metabase.events :as events] [metabase.models.card :as card :refer [Card]] [metabase.models.dashboard :refer [Dashboard]] - [metabase.models.params :as params] - [metabase.pulse.parameters :as pulse-params] [metabase.query-processor.card :as qp.card] [metabase.query-processor.middleware.constraints :as qp.constraints] [metabase.query-processor.pivot :as qp.pivot] [metabase.util :as u] [metabase.util.embed :as embed] - [metabase.util.i18n - :as i18n - :refer [tru]] - [metabase.util.log :as log] - [metabase.util.malli :as mu] [metabase.util.malli.schema :as ms] [toucan2.core :as t2])) (set! *warn-on-reflection* true) -;;; ------------------------------------------------- Param Checking ------------------------------------------------- - -(defn- check-params-are-allowed - "Check that the conditions specified by `object-embedding-params` are satisfied." - [object-embedding-params token-params user-params] - (let [all-params (set/union token-params user-params) - duplicated-params (set/intersection token-params user-params)] - (doseq [[param status] object-embedding-params] - (case status - ;; disabled means a param is not allowed to be specified by either token or user - "disabled" (api/check (not (contains? all-params param)) - [400 (tru "You''re not allowed to specify a value for {0}." param)]) - ;; enabled means either JWT *or* user can specify the param, but not both. Param is *not* required - "enabled" (api/check (not (contains? duplicated-params param)) - [400 (tru "You can''t specify a value for {0} if it''s already set in the JWT." param)]) - ;; locked means JWT must specify param - "locked" (api/check - (contains? token-params param) [400 (tru "You must specify a value for {0} in the JWT." param)] - (not (contains? user-params param)) [400 (tru "You can only specify a value for {0} in the JWT." param)]))))) - -(defn- check-params-exist - "Make sure all the params specified are specified in `object-embedding-params`." - [object-embedding-params all-params] - (let [embedding-params (set (keys object-embedding-params))] - (doseq [k all-params] - (api/check (contains? embedding-params k) - [400 (format "Unknown parameter %s." k)])))) - -(defn- check-param-sets - "Validate that sets of params passed as part of the JWT token and by the user (as query params, i.e. as part of the - URL) are valid for the `object-embedding-params`. `token-params` and `user-params` should be sets of all valid param - keys specified in the JWT or by the user, respectively." - [object-embedding-params token-params user-params] - ;; TODO - maybe make this log/debug once embedding is wrapped up - (log/debug "Validating params for embedded object:\n" - "object embedding params:" object-embedding-params - "token params:" token-params - "user params:" user-params) - (check-params-are-allowed object-embedding-params token-params user-params) - (check-params-exist object-embedding-params (set/union token-params user-params))) - -(defn- valid-param? - "Is V a valid param value? (If it is a String, is it non-blank?)" - [v] - (or (not (string? v)) - (not (str/blank? v)))) - -(mu/defn ^:private validate-and-merge-params :- [:map-of :keyword :any] - "Validate that the `token-params` passed in the JWT and the `user-params` (passed as part of the URL) are allowed, and - that ones that are required are specified by checking them against a Card or Dashboard's `object-embedding-params` - (the object's value of `:embedding_params`). Throws a 400 if any of the checks fail. If all checks are successful, - returns a *merged* parameters map." - [object-embedding-params :- ms/EmbeddingParams - token-params :- [:map-of :keyword :any] - user-params :- [:map-of :keyword :any]] - (check-param-sets object-embedding-params - (set (keys (m/filter-vals valid-param? token-params))) - (set (keys (m/filter-vals valid-param? user-params)))) - ;; ok, everything checks out, now return the merged params map - (merge user-params token-params)) - - -;;; ---------------------------------------------- Other Param Util Fns ---------------------------------------------- - -(defn- remove-params-in-set - "Remove any `params` from the list whose `:slug` is in the `params-to-remove` set." - [params params-to-remove] - (for [param params - :when (not (contains? params-to-remove (keyword (:slug param))))] - param)) - -(defn- classify-params-as-keep-or-remove - "Classifies the params in the `dashboard-or-card-params` seq and the param slugs in `embedding-params` map according to: - Parameters in `dashboard-or-card-params` whose slugs are NOT in the `embedding-params` map must be removed. - Parameter slugs in `embedding-params` with the value 'enabled' are kept, 'disabled' or 'locked' are not kept. - - The resulting classification is returned as a map with keys :keep and :remove whose values are sets of parameter slugs." - [dashboard-or-card-params embedding-params] - (let [param-slugs (map #(keyword (:slug %)) dashboard-or-card-params) - grouped-param-slugs {:remove (remove (fn [k] (contains? embedding-params k)) param-slugs)} - grouped-embedding-param-slugs (-> (group-by #(= (second %) "enabled") embedding-params) - (update-keys {true :keep false :remove}) - (update-vals #(into #{} (map first) %)))] - (merge-with (comp set concat) - {:keep #{} :remove #{}} - grouped-param-slugs - grouped-embedding-param-slugs))) - -(defn- get-params-to-remove - [dashboard-or-card-params embedding-params] - (:remove (classify-params-as-keep-or-remove dashboard-or-card-params embedding-params))) - -(mu/defn ^:private remove-locked-and-disabled-params - "Remove the `:parameters` for `dashboard-or-card` that listed as `disabled` or `locked` in the `embedding-params` - whitelist, or not present in the whitelist. This is done so the frontend doesn't display widgets for params the user - can't set." - [dashboard-or-card embedding-params :- ms/EmbeddingParams] - (let [params-to-remove (get-params-to-remove (:parameters dashboard-or-card) embedding-params)] - (update dashboard-or-card :parameters remove-params-in-set params-to-remove))) - -(defn- remove-token-parameters - "Removes any parameters with slugs matching keys provided in `token-params`, as these should not be exposed to the - user." - [dashboard-or-card token-params] - (update dashboard-or-card :parameters remove-params-in-set (set (keys token-params)))) - -(defn- substitute-token-parameters-in-text - "For any dashboard parameters with slugs matching keys provided in `token-params`, substitute their values from the - token into any Markdown dashboard cards with linked variables. This needs to be done on the backend because we don't - make these parameters visible at all to the frontend." - [dashboard token-params] - (let [params (:parameters dashboard) - dashcards (:dashcards dashboard) - params-with-values (reduce - (fn [acc param] - (if-let [value (get token-params (keyword (:slug param)))] - (conj acc (assoc param :value value)) - acc)) - [] - params)] - (assoc dashboard - :dashcards - (map - (fn [card] - (if (-> card :visualization_settings :virtual_card) - (pulse-params/process-virtual-dashcard card params-with-values) - card)) - dashcards)))) - -(mu/defn ^:private apply-slug->value :- [:maybe [:sequential - [:map - [:slug ms/NonBlankString] - [:type :keyword] - [:target :any] - [:value :any]]]] - "Adds `value` to parameters with `slug` matching a key in `merged-slug->value` and removes parameters without a - `value`." - [parameters slug->value] - (when (seq parameters) - (for [param parameters - :let [slug (keyword (:slug param)) - value (get slug->value slug) - ;; operator parameters expect a sequence of values so if we get a lone value (e.g. from a single URL - ;; query parameter) wrap it in a sequence - value (if (and (some? value) - (params.ops/operator? (:type param))) - (u/one-or-many value) - value)] - :when (contains? slug->value slug)] - (assoc (select-keys param [:type :target :slug]) - :value value)))) - -(defn- resolve-card-parameters - "Returns parameters for a card (HUH?)" ; TODO - better docstring - [card-or-id] - (-> (t2/select-one [Card :dataset_query :parameters], :id (u/the-id card-or-id)) - api.public/combine-parameters-and-template-tags - :parameters)) - -(mu/defn ^:private resolve-dashboard-parameters :- [:sequential api.dashboard/ParameterWithID] - "Given a `dashboard-id` and parameters map in the format `slug->value`, return a sequence of parameters with `:id`s - that can be passed to various functions in the `metabase.api.dashboard` namespace such as - [[metabase.api.dashboard/process-query-for-dashcard]]." - [dashboard-id :- ms/PositiveInt - slug->value :- :map] - (let [parameters (t2/select-one-fn :parameters Dashboard :id dashboard-id) - slug->id (into {} (map (juxt :slug :id)) parameters)] - (vec (for [[slug value] slug->value - :let [slug (u/qualified-name slug)]] - {:slug slug - :id (or (get slug->id slug) - (throw (ex-info (tru "No matching parameter with slug {0}. Found: {1}" (pr-str slug) (pr-str (keys slug->id))) - {:status-code 400 - :slug slug - :dashboard-parameters parameters}))) - :value value})))) - -(mu/defn ^:private normalize-query-params :- [:map-of :keyword :any] - "Take a map of `query-params` and make sure they're in the right format for the rest of our code. Our - `wrap-keyword-params` middleware normally converts all query params keys to keywords, but only if they seem like - ones that make sense as keywords. Some params, such as ones that start with a number, do not pass this test, and are - not automatically converted. Thus we must do it ourselves here to make sure things are done as we'd expect. - Also, any param values that are blank strings should be parsed as nil, representing the absence of a value." - [query-params] - (-> query-params - (update-keys keyword) - (update-vals (fn [v] (if (= v "") nil v))))) - - -;;; ---------------------------- Card Fns used by both /api/embed and /api/preview_embed ----------------------------- - -(defn card-for-unsigned-token - "Return the info needed for embedding about Card specified in `token`. Additional `constraints` can be passed to the - `public-card` function that fetches the Card." - [unsigned-token & {:keys [embedding-params constraints]}] - {:pre [((some-fn empty? sequential?) constraints) (even? (count constraints))]} - (let [card-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :question]) - token-params (embed/get-in-unsigned-token-or-throw unsigned-token [:params])] - (-> (apply api.public/public-card :id card-id, constraints) - api.public/combine-parameters-and-template-tags - (remove-token-parameters token-params) - (remove-locked-and-disabled-params (or embedding-params - (t2/select-one-fn :embedding_params Card :id card-id)))))) - -(defn process-query-for-card-with-params - "Run the query associated with Card with `card-id` using JWT `token-params`, user-supplied URL `query-params`, - an `embedding-params` whitelist, and additional query `options`. Returns `StreamingResponse` that should be - returned as the API endpoint result." - [& {:keys [export-format card-id embedding-params token-params query-params qp constraints options] - :or {qp qp.card/process-query-for-card-default-qp}}] - {:pre [(integer? card-id) (u/maybe? map? embedding-params) (map? token-params) (map? query-params)]} - (let [merged-slug->value (validate-and-merge-params embedding-params token-params (normalize-query-params query-params)) - parameters (apply-slug->value (resolve-card-parameters card-id) merged-slug->value)] - (m/mapply api.public/process-query-for-card-with-id - card-id export-format parameters - :context :embedded-question - :constraints constraints - :qp qp - options))) - - -;;; -------------------------- Dashboard Fns used by both /api/embed and /api/preview_embed -------------------------- - -(defn- remove-linked-filters-param-values [dashboard] - (let [param-ids (set (map :id (:parameters dashboard))) - param-ids-to-remove (set (for [{param-id :id - filtering-parameters :filteringParameters} (:parameters dashboard) - filtering-parameter-id filtering-parameters - :when (not (contains? param-ids filtering-parameter-id))] - param-id)) - linked-field-ids (set (mapcat (params/get-linked-field-ids (:dashcards dashboard)) param-ids-to-remove))] - (update dashboard :param_values #(->> % - (map (fn [[param-id param]] - {param-id (cond-> param - (contains? linked-field-ids param-id) ;; is param linked? - (assoc :values []))})) - (into {}))))) - -(defn- remove-locked-parameters [dashboard embedding-params] - (let [params (:parameters dashboard) - {params-to-remove :remove - params-to-keep :keep} (classify-params-as-keep-or-remove params embedding-params) - param-ids-to-remove (set (keep (fn [{:keys [slug id]}] - (when (contains? params-to-remove (keyword slug)) id)) - params)) - param-ids-to-keep (set (keep (fn [{:keys [slug id]}] - (when (contains? params-to-keep (keyword slug)) id)) - params)) - field-ids-to-maybe-remove (set (mapcat (params/get-linked-field-ids (:dashcards dashboard)) param-ids-to-remove)) - field-ids-to-keep (set (mapcat (params/get-linked-field-ids (:dashcards dashboard)) param-ids-to-keep)) - field-ids-to-remove (set/difference field-ids-to-maybe-remove field-ids-to-keep) - remove-parameters (fn [dashcard] - (update dashcard :parameter_mappings - (fn [param-mappings] - (remove (fn [{:keys [parameter_id]}] - (contains? param-ids-to-remove parameter_id)) param-mappings))))] - (-> dashboard - (update :dashcards #(map remove-parameters %)) - (update :param_fields #(apply dissoc % field-ids-to-remove)) - (update :param_values #(apply dissoc % field-ids-to-remove))))) - -(defn dashboard-for-unsigned-token - "Return the info needed for embedding about Dashboard specified in `token`. Additional `constraints` can be passed to - the `public-dashboard` function that fetches the Dashboard." - [unsigned-token & {:keys [embedding-params constraints]}] - {:pre [((some-fn empty? sequential?) constraints) (even? (count constraints))]} - (let [dashboard-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :dashboard]) - embedding-params (or embedding-params - (t2/select-one-fn :embedding_params Dashboard, :id dashboard-id)) - token-params (embed/get-in-unsigned-token-or-throw unsigned-token [:params])] - (-> (apply api.public/public-dashboard :id dashboard-id, constraints) - (substitute-token-parameters-in-text token-params) - (remove-locked-parameters embedding-params) - (remove-token-parameters token-params) - (remove-locked-and-disabled-params embedding-params) - (remove-linked-filters-param-values)))) - -(defn- get-embed-dashboard-context - "If a certain export-format is given, return the correct embedded dashboard context." - [export-format] - (case export-format - "csv" :embedded-csv-download - "xlsx" :embedded-xlsx-download - "json" :embedded-json-download - :embedded-dashboard)) - -(defn process-query-for-dashcard - "Return results for running the query belonging to a DashboardCard. Returns a `StreamingResponse`." - [& {:keys [dashboard-id dashcard-id card-id export-format embedding-params token-params middleware - query-params constraints qp] - :or {constraints (qp.constraints/default-query-constraints) - qp qp.card/process-query-for-card-default-qp}}] - {:pre [(integer? dashboard-id) (integer? dashcard-id) (integer? card-id) (u/maybe? map? embedding-params) - (map? token-params) (map? query-params)]} - (let [slug->value (validate-and-merge-params embedding-params token-params (normalize-query-params query-params)) - parameters (resolve-dashboard-parameters dashboard-id slug->value)] - (api.public/process-query-for-dashcard - :dashboard-id dashboard-id - :card-id card-id - :dashcard-id dashcard-id - :export-format export-format - :parameters parameters - :qp qp - :context (get-embed-dashboard-context export-format) - :constraints constraints - :middleware middleware))) - - -;;; ------------------------------------- Other /api/embed-specific utility fns -------------------------------------- - -(defn- check-embedding-enabled-for-object - "Check that embedding is enabled, that `object` exists, and embedding for `object` is enabled." - ([entity id] - (api/check (pos-int? id) - [400 (tru "{0} id should be a positive integer." (name entity))]) - (check-embedding-enabled-for-object (t2/select-one [entity :enable_embedding] :id id))) - - ([object] - (validation/check-embedding-enabled) - (api/check-404 object) - (api/check-not-archived object) - (api/check (:enable_embedding object) - [400 (tru "Embedding is not enabled for this object.")]))) - -(def ^:private ^{:arglists '([dashboard-id])} check-embedding-enabled-for-dashboard - "Runs check-embedding-enabled-for-object for a given Dashboard id" - (partial check-embedding-enabled-for-object Dashboard)) - -(def ^:private ^{:arglists '([card-id])} check-embedding-enabled-for-card - "Runs check-embedding-enabled-for-object for a given Card id" - (partial check-embedding-enabled-for-object Card)) - - ;;; ------------------------------------------- /api/embed/card endpoints -------------------------------------------- (api/defendpoint GET "/card/:token" @@ -389,8 +44,8 @@ {:resource {:question <card-id>}}" [token] (let [unsigned (embed/unsign token)] - (check-embedding-enabled-for-card (embed/get-in-unsigned-token-or-throw unsigned [:resource :question])) - (u/prog1 (card-for-unsigned-token unsigned, :constraints [:enable_embedding true]) + (api.embed.common/check-embedding-enabled-for-card (embed/get-in-unsigned-token-or-throw unsigned [:resource :question])) + (u/prog1 (api.embed.common/card-for-unsigned-token unsigned, :constraints [:enable_embedding true]) (events/publish-event! :event/card-read {:object <>, :user-id api/*current-user-id*})))) (defn ^:private run-query-for-unsigned-token-async @@ -401,8 +56,8 @@ qp qp.card/process-query-for-card-default-qp} :as options}] (let [card-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :question])] - (check-embedding-enabled-for-card card-id) - (process-query-for-card-with-params + (api.embed.common/check-embedding-enabled-for-card card-id) + (api.embed.common/process-query-for-card-with-params :export-format export-format :card-id card-id :token-params (embed/get-in-unsigned-token-or-throw unsigned-token [:params]) @@ -447,8 +102,8 @@ {:resource {:dashboard <dashboard-id>}}" [token] (let [unsigned (embed/unsign token)] - (check-embedding-enabled-for-dashboard (embed/get-in-unsigned-token-or-throw unsigned [:resource :dashboard])) - (u/prog1 (dashboard-for-unsigned-token unsigned, :constraints [:enable_embedding true]) + (api.embed.common/check-embedding-enabled-for-dashboard (embed/get-in-unsigned-token-or-throw unsigned [:resource :dashboard])) + (u/prog1 (api.embed.common/dashboard-for-unsigned-token unsigned, :constraints [:enable_embedding true]) (events/publish-event! :event/dashboard-read {:user-id api/*current-user-id* :object <>})))) @@ -470,8 +125,8 @@ qp qp.card/process-query-for-card-default-qp}}] (let [unsigned-token (embed/unsign token) dashboard-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :dashboard])] - (check-embedding-enabled-for-dashboard dashboard-id) - (process-query-for-dashcard + (api.embed.common/check-embedding-enabled-for-dashboard dashboard-id) + (api.embed.common/process-query-for-dashcard :export-format export-format :dashboard-id dashboard-id :dashcard-id dashcard-id @@ -504,7 +159,7 @@ {field-id ms/PositiveInt} (let [unsigned-token (embed/unsign token) card-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :question])] - (check-embedding-enabled-for-card card-id) + (api.embed.common/check-embedding-enabled-for-card card-id) (api.public/card-and-field-id->values card-id field-id))) (api/defendpoint GET "/dashboard/:token/field/:field-id/values" @@ -513,7 +168,7 @@ {field-id ms/PositiveInt} (let [unsigned-token (embed/unsign token) dashboard-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :dashboard])] - (check-embedding-enabled-for-dashboard dashboard-id) + (api.embed.common/check-embedding-enabled-for-dashboard dashboard-id) (api.public/dashboard-and-field-id->values dashboard-id field-id))) @@ -528,7 +183,7 @@ limit [:maybe ms/PositiveInt]} (let [unsigned-token (embed/unsign token) card-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :question])] - (check-embedding-enabled-for-card card-id) + (api.embed.common/check-embedding-enabled-for-card card-id) (api.public/search-card-fields card-id field-id search-field-id value (when limit (Integer/parseInt limit))))) (api/defendpoint GET "/dashboard/:token/field/:field-id/search/:search-field-id" @@ -540,7 +195,7 @@ limit [:maybe ms/PositiveInt]} (let [unsigned-token (embed/unsign token) dashboard-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :dashboard])] - (check-embedding-enabled-for-dashboard dashboard-id) + (api.embed.common/check-embedding-enabled-for-dashboard dashboard-id) (api.public/search-dashboard-fields dashboard-id field-id search-field-id value (when limit (Integer/parseInt limit))))) @@ -556,7 +211,7 @@ value ms/NonBlankString} (let [unsigned-token (embed/unsign token) card-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :question])] - (check-embedding-enabled-for-card card-id) + (api.embed.common/check-embedding-enabled-for-card card-id) (api.public/card-field-remapped-values card-id field-id remapped-id value))) (api/defendpoint GET "/dashboard/:token/field/:field-id/remapping/:remapped-id" @@ -568,7 +223,7 @@ value ms/NonBlankString} (let [unsigned-token (embed/unsign token) dashboard-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :dashboard])] - (check-embedding-enabled-for-dashboard dashboard-id) + (api.embed.common/check-embedding-enabled-for-dashboard dashboard-id) (api.public/dashboard-field-remapped-values dashboard-id field-id remapped-id value))) (api/defendpoint GET ["/dashboard/:token/dashcard/:dashcard-id/card/:card-id/:export-format" @@ -599,117 +254,16 @@ ;; variables whose name includes `id-` e.g. `id-query-params` below are ones that are keyed by ID; ones whose name ;; includes `slug-` are keyed by slug. -(mu/defn ^:private param-values-merged-params :- [:map-of ms/NonBlankString :any] - [id->slug slug->id embedding-params token-params id-query-params] - (let [slug-query-params (into {} - (for [[id v] id-query-params] - [(or (get id->slug (name id)) - (throw (ex-info (tru "Invalid query params: could not determine slug for parameter with ID {0}" - (pr-str id)) - {:id (name id) - :id->slug id->slug - :id-query-params id-query-params}))) - v])) - slug-query-params (normalize-query-params slug-query-params) - merged-slug->value (validate-and-merge-params embedding-params token-params slug-query-params)] - (into {} (for [[slug value] merged-slug->value] - [(get slug->id (name slug)) value])))) - -(defn card-param-values - "Search for card parameter values. Does security checks to ensure the parameter is on the card and then gets param - values according to [[api.card/param-values]]." - [{:keys [unsigned-token card param-key search-prefix]}] - (let [slug-token-params (embed/get-in-unsigned-token-or-throw unsigned-token [:params]) - parameters (or (seq (:parameters card)) - (card/template-tag-parameters card)) - id->slug (into {} (map (juxt :id :slug) parameters)) - slug->id (into {} (map (juxt :slug :id) parameters)) - searched-param-slug (get id->slug param-key) - embedding-params (:embedding_params card)] - (try - (when-not (= (get embedding-params (keyword searched-param-slug)) "enabled") - (throw (ex-info (tru "Cannot search for values: {0} is not an enabled parameter." - (pr-str searched-param-slug)) - {:status-code 400}))) - (when (get slug-token-params (keyword searched-param-slug)) - (throw (ex-info (tru "You can''t specify a value for {0} if it's already set in the JWT." (pr-str searched-param-slug)) - {:status-code 400}))) - (try - (binding [api/*current-user-permissions-set* (atom #{"/"}) - api/*is-superuser?* true] - (api.card/param-values card param-key search-prefix)) - (catch Throwable e - (throw (ex-info (.getMessage e) - {:card-id (u/the-id card) - :param-key param-key - :search-prefix search-prefix} - e)))) - (catch Throwable e - (let [e (ex-info (.getMessage e) - {:card-id (u/the-id card) - :card-params (:parametres card) - :allowed-param-slugs embedding-params - :slug->id slug->id - :id->slug id->slug - :param-id param-key - :param-slug searched-param-slug - :token-params slug-token-params} - e)] - (log/errorf e "embedded card-param-values error\n%s" - (u/pprint-to-str (u/all-ex-data e))) - (throw e)))))) - -(defn- dashboard-param-values [token searched-param-id prefix id-query-params] - (let [unsigned-token (embed/unsign token) - dashboard-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :dashboard]) - _ (check-embedding-enabled-for-dashboard dashboard-id) - slug-token-params (embed/get-in-unsigned-token-or-throw unsigned-token [:params]) - {parameters :parameters - embedding-params :embedding_params} (t2/select-one Dashboard :id dashboard-id) - id->slug (into {} (map (juxt :id :slug) parameters)) - slug->id (into {} (map (juxt :slug :id) parameters)) - searched-param-slug (get id->slug searched-param-id)] - (try - ;; you can only search for values of a parameter if it is ENABLED and NOT PRESENT in the JWT. - (when-not (= (get embedding-params (keyword searched-param-slug)) "enabled") - (throw (ex-info (tru "Cannot search for values: {0} is not an enabled parameter." (pr-str searched-param-slug)) - {:status-code 400}))) - (when (get slug-token-params (keyword searched-param-slug)) - (throw (ex-info (tru "You can''t specify a value for {0} if it's already set in the JWT." (pr-str searched-param-slug)) - {:status-code 400}))) - ;; ok, at this point we can run the query - (let [merged-id-params (param-values-merged-params id->slug slug->id embedding-params slug-token-params id-query-params)] - (try - (binding [api/*current-user-permissions-set* (atom #{"/"}) - api/*is-superuser?* true] - (api.dashboard/param-values (t2/select-one Dashboard :id dashboard-id) searched-param-id merged-id-params prefix)) - (catch Throwable e - (throw (ex-info (.getMessage e) - {:merged-id-params merged-id-params} - e))))) - (catch Throwable e - (let [e (ex-info (.getMessage e) - {:dashboard-id dashboard-id - :dashboard-params parameters - :allowed-param-slugs embedding-params - :slug->id slug->id - :id->slug id->slug - :param-id searched-param-id - :param-slug searched-param-slug - :token-params slug-token-params} - e)] - (log/errorf e "Chain filter error\n%s" (u/pprint-to-str (u/all-ex-data e))) - (throw e)))))) (api/defendpoint GET "/dashboard/:token/params/:param-key/values" "Embedded version of chain filter values endpoint." [token param-key :as {:keys [query-params]}] - (dashboard-param-values token param-key nil query-params)) + (api.embed.common/dashboard-param-values token param-key nil query-params)) (api/defendpoint GET "/dashboard/:token/params/:param-key/search/:prefix" "Embedded version of chain filter search endpoint." [token param-key prefix :as {:keys [query-params]}] - (dashboard-param-values token param-key prefix query-params)) + (api.embed.common/dashboard-param-values token param-key prefix query-params)) (api/defendpoint GET "/card/:token/params/:param-key/values" "Embedded version of api.card filter values endpoint." @@ -717,8 +271,8 @@ (let [unsigned (embed/unsign token) card-id (embed/get-in-unsigned-token-or-throw unsigned [:resource :question]) card (t2/select-one Card :id card-id)] - (check-embedding-enabled-for-card card-id) - (card-param-values {:unsigned-token unsigned + (api.embed.common/check-embedding-enabled-for-card card-id) + (api.embed.common/card-param-values {:unsigned-token unsigned :card card :param-key param-key}))) @@ -728,8 +282,8 @@ (let [unsigned (embed/unsign token) card-id (embed/get-in-unsigned-token-or-throw unsigned [:resource :question]) card (t2/select-one Card :id card-id)] - (check-embedding-enabled-for-card card-id) - (card-param-values {:unsigned-token unsigned + (api.embed.common/check-embedding-enabled-for-card card-id) + (api.embed.common/card-param-values {:unsigned-token unsigned :card card :param-key param-key :search-prefix prefix}))) diff --git a/src/metabase/api/embed/common.clj b/src/metabase/api/embed/common.clj new file mode 100644 index 00000000000..3f25b55db1c --- /dev/null +++ b/src/metabase/api/embed/common.clj @@ -0,0 +1,478 @@ +(ns metabase.api.embed.common + (:require + [clojure.set :as set] + [clojure.string :as str] + [medley.core :as m] + [metabase.api.card :as api.card] + [metabase.api.common :as api] + [metabase.api.common.validation :as validation] + [metabase.api.dashboard :as api.dashboard] + [metabase.api.public :as api.public] + [metabase.driver.common.parameters.operators :as params.ops] + [metabase.models.card :as card] + [metabase.models.params :as params] + [metabase.pulse.parameters :as pulse-params] + [metabase.query-processor.card :as qp.card] + [metabase.query-processor.middleware.constraints :as qp.constraints] + [metabase.util :as u] + [metabase.util.embed :as embed] + [metabase.util.i18n + :as i18n + :refer [tru]] + [metabase.util.log :as log] + [metabase.util.malli :as mu] + [metabase.util.malli.schema :as ms] + [toucan2.core :as t2])) + +(set! *warn-on-reflection* true) + +(defn- valid-param? + "Is V a valid param value? (If it is a String, is it non-blank?)" + [v] + (or (not (string? v)) + (not (str/blank? v)))) + +(defn- check-params-are-allowed + "Check that the conditions specified by `object-embedding-params` are satisfied." + [object-embedding-params token-params user-params] + (let [all-params (set/union token-params user-params) + duplicated-params (set/intersection token-params user-params)] + (doseq [[param status] object-embedding-params] + (case status + ;; disabled means a param is not allowed to be specified by either token or user + "disabled" (api/check (not (contains? all-params param)) + [400 (tru "You''re not allowed to specify a value for {0}." param)]) + ;; enabled means either JWT *or* user can specify the param, but not both. Param is *not* required + "enabled" (api/check (not (contains? duplicated-params param)) + [400 (tru "You can''t specify a value for {0} if it''s already set in the JWT." param)]) + ;; locked means JWT must specify param + "locked" (api/check + (contains? token-params param) [400 (tru "You must specify a value for {0} in the JWT." param)] + (not (contains? user-params param)) [400 (tru "You can only specify a value for {0} in the JWT." param)]))))) + +(defn- check-params-exist + "Make sure all the params specified are specified in `object-embedding-params`." + [object-embedding-params all-params] + (let [embedding-params (set (keys object-embedding-params))] + (doseq [k all-params] + (api/check (contains? embedding-params k) + [400 (format "Unknown parameter %s." k)])))) + +(defn- check-param-sets + "Validate that sets of params passed as part of the JWT token and by the user (as query params, i.e. as part of the + URL) are valid for the `object-embedding-params`. `token-params` and `user-params` should be sets of all valid param + keys specified in the JWT or by the user, respectively." + [object-embedding-params token-params user-params] + ;; TODO - maybe make this log/debug once embedding is wrapped up + (log/debug "Validating params for embedded object:\n" + "object embedding params:" object-embedding-params + "token params:" token-params + "user params:" user-params) + (check-params-are-allowed object-embedding-params token-params user-params) + (check-params-exist object-embedding-params (set/union token-params user-params))) + +(defn- check-embedding-enabled-for-object + "Check that embedding is enabled, that `object` exists, and embedding for `object` is enabled." + ([entity id] + (api/check (pos-int? id) + [400 (tru "{0} id should be a positive integer." (name entity))]) + (check-embedding-enabled-for-object (t2/select-one [entity :enable_embedding] :id id))) + + ([object] + (validation/check-embedding-enabled) + (api/check-404 object) + (api/check-not-archived object) + (api/check (:enable_embedding object) + [400 (tru "Embedding is not enabled for this object.")]))) + +(def ^{:arglists '([card-id])} check-embedding-enabled-for-card + "Runs check-embedding-enabled-for-object for a given Card id" + (partial check-embedding-enabled-for-object :model/Card)) + +(def ^{:arglists '([dashboard-id])} check-embedding-enabled-for-dashboard + "Runs check-embedding-enabled-for-object for a given Dashboard id" + (partial check-embedding-enabled-for-object :model/Dashboard)) + +(defn- resolve-card-parameters + "Returns parameters for a card (HUH?)" ; TODO - better docstring + [card-or-id] + (-> (t2/select-one [:model/Card :dataset_query :parameters], :id (u/the-id card-or-id)) + api.public/combine-parameters-and-template-tags + :parameters)) + +(mu/defn ^:private resolve-dashboard-parameters :- [:sequential api.dashboard/ParameterWithID] + "Given a `dashboard-id` and parameters map in the format `slug->value`, return a sequence of parameters with `:id`s + that can be passed to various functions in the `metabase.api.dashboard` namespace such as + [[metabase.api.dashboard/process-query-for-dashcard]]." + [dashboard-id :- ms/PositiveInt + slug->value :- :map] + (let [parameters (t2/select-one-fn :parameters :model/Dashboard :id dashboard-id) + slug->id (into {} (map (juxt :slug :id)) parameters)] + (vec (for [[slug value] slug->value + :let [slug (u/qualified-name slug)]] + {:slug slug + :id (or (get slug->id slug) + (throw (ex-info (tru "No matching parameter with slug {0}. Found: {1}" (pr-str slug) (pr-str (keys slug->id))) + {:status-code 400 + :slug slug + :dashboard-parameters parameters}))) + :value value})))) + +(mu/defn normalize-query-params :- [:map-of :keyword :any] + "Take a map of `query-params` and make sure they're in the right format for the rest of our code. Our + `wrap-keyword-params` middleware normally converts all query params keys to keywords, but only if they seem like + ones that make sense as keywords. Some params, such as ones that start with a number, do not pass this test, and are + not automatically converted. Thus we must do it ourselves here to make sure things are done as we'd expect. + Also, any param values that are blank strings should be parsed as nil, representing the absence of a value." + [query-params] + (-> query-params + (update-keys keyword) + (update-vals (fn [v] (if (= v "") nil v))))) + +(mu/defn validate-and-merge-params :- [:map-of :keyword :any] + "Validate that the `token-params` passed in the JWT and the `user-params` (passed as part of the URL) are allowed, and + that ones that are required are specified by checking them against a Card or Dashboard's `object-embedding-params` + (the object's value of `:embedding_params`). Throws a 400 if any of the checks fail. If all checks are successful, + returns a *merged* parameters map." + [object-embedding-params :- ms/EmbeddingParams + token-params :- [:map-of :keyword :any] + user-params :- [:map-of :keyword :any]] + (check-param-sets object-embedding-params + (set (keys (m/filter-vals valid-param? token-params))) + (set (keys (m/filter-vals valid-param? user-params)))) + ;; ok, everything checks out, now return the merged params map + (merge user-params token-params)) + +(mu/defn ^:private param-values-merged-params :- [:map-of ms/NonBlankString :any] + [id->slug slug->id embedding-params token-params id-query-params] + (let [slug-query-params (into {} + (for [[id v] id-query-params] + [(or (get id->slug (name id)) + (throw (ex-info (tru "Invalid query params: could not determine slug for parameter with ID {0}" + (pr-str id)) + {:id (name id) + :id->slug id->slug + :id-query-params id-query-params}))) + v])) + slug-query-params (normalize-query-params slug-query-params) + merged-slug->value (validate-and-merge-params embedding-params token-params slug-query-params)] + (into {} (for [[slug value] merged-slug->value] + [(get slug->id (name slug)) value])))) + + + +;;; ---------------------------------------------- Other Param Util Fns ---------------------------------------------- + +(defn- remove-params-in-set + "Remove any `params` from the list whose `:slug` is in the `params-to-remove` set." + [params params-to-remove] + (for [param params + :when (not (contains? params-to-remove (keyword (:slug param))))] + param)) + +(defn- classify-params-as-keep-or-remove + "Classifies the params in the `dashboard-or-card-params` seq and the param slugs in `embedding-params` map according to: + Parameters in `dashboard-or-card-params` whose slugs are NOT in the `embedding-params` map must be removed. + Parameter slugs in `embedding-params` with the value 'enabled' are kept, 'disabled' or 'locked' are not kept. + + The resulting classification is returned as a map with keys :keep and :remove whose values are sets of parameter slugs." + [dashboard-or-card-params embedding-params] + (let [param-slugs (map #(keyword (:slug %)) dashboard-or-card-params) + grouped-param-slugs {:remove (remove (fn [k] (contains? embedding-params k)) param-slugs)} + grouped-embedding-param-slugs (-> (group-by #(= (second %) "enabled") embedding-params) + (update-keys {true :keep false :remove}) + (update-vals #(into #{} (map first) %)))] + (merge-with (comp set concat) + {:keep #{} :remove #{}} + grouped-param-slugs + grouped-embedding-param-slugs))) + +(defn- get-params-to-remove + [dashboard-or-card-params embedding-params] + (:remove (classify-params-as-keep-or-remove dashboard-or-card-params embedding-params))) + +(mu/defn ^:private remove-locked-and-disabled-params + "Remove the `:parameters` for `dashboard-or-card` that listed as `disabled` or `locked` in the `embedding-params` + whitelist, or not present in the whitelist. This is done so the frontend doesn't display widgets for params the user + can't set." + [dashboard-or-card embedding-params :- ms/EmbeddingParams] + (let [params-to-remove (get-params-to-remove (:parameters dashboard-or-card) embedding-params)] + (update dashboard-or-card :parameters remove-params-in-set params-to-remove))) + +(defn- remove-token-parameters + "Removes any parameters with slugs matching keys provided in `token-params`, as these should not be exposed to the + user." + [dashboard-or-card token-params] + (update dashboard-or-card :parameters remove-params-in-set (set (keys token-params)))) + +(defn- substitute-token-parameters-in-text + "For any dashboard parameters with slugs matching keys provided in `token-params`, substitute their values from the + token into any Markdown dashboard cards with linked variables. This needs to be done on the backend because we don't + make these parameters visible at all to the frontend." + [dashboard token-params] + (let [params (:parameters dashboard) + dashcards (:dashcards dashboard) + params-with-values (reduce + (fn [acc param] + (if-let [value (get token-params (keyword (:slug param)))] + (conj acc (assoc param :value value)) + acc)) + [] + params)] + (assoc dashboard + :dashcards + (map + (fn [card] + (if (-> card :visualization_settings :virtual_card) + (pulse-params/process-virtual-dashcard card params-with-values) + card)) + dashcards)))) + +(mu/defn ^:private apply-slug->value :- [:maybe [:sequential + [:map + [:slug ms/NonBlankString] + [:type :keyword] + [:target :any] + [:value :any]]]] + "Adds `value` to parameters with `slug` matching a key in `merged-slug->value` and removes parameters without a + `value`." + [parameters slug->value] + (when (seq parameters) + (for [param parameters + :let [slug (keyword (:slug param)) + value (get slug->value slug) + ;; operator parameters expect a sequence of values so if we get a lone value (e.g. from a single URL + ;; query parameter) wrap it in a sequence + value (if (and (some? value) + (params.ops/operator? (:type param))) + (u/one-or-many value) + value)] + :when (contains? slug->value slug)] + (assoc (select-keys param [:type :target :slug]) + :value value)))) + + + + + + + + + + + + + + + + + + + + + + +;;; ---------------------------- Card Fns used by both /api/embed and /api/preview_embed ----------------------------- + +(defn card-for-unsigned-token + "Return the info needed for embedding about Card specified in `token`. Additional `constraints` can be passed to the + `public-card` function that fetches the Card." + [unsigned-token & {:keys [embedding-params constraints]}] + {:pre [((some-fn empty? sequential?) constraints) (even? (count constraints))]} + (let [card-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :question]) + token-params (embed/get-in-unsigned-token-or-throw unsigned-token [:params])] + (-> (apply api.public/public-card :id card-id, constraints) + api.public/combine-parameters-and-template-tags + (remove-token-parameters token-params) + (remove-locked-and-disabled-params (or embedding-params + (t2/select-one-fn :embedding_params :model/Card :id card-id)))))) + +(defn process-query-for-card-with-params + "Run the query associated with Card with `card-id` using JWT `token-params`, user-supplied URL `query-params`, + an `embedding-params` whitelist, and additional query `options`. Returns `StreamingResponse` that should be + returned as the API endpoint result." + [& {:keys [export-format card-id embedding-params token-params query-params qp constraints options] + :or {qp qp.card/process-query-for-card-default-qp}}] + {:pre [(integer? card-id) (u/maybe? map? embedding-params) (map? token-params) (map? query-params)]} + (let [merged-slug->value (validate-and-merge-params embedding-params token-params (normalize-query-params query-params)) + parameters (apply-slug->value (resolve-card-parameters card-id) merged-slug->value)] + (m/mapply api.public/process-query-for-card-with-id + card-id export-format parameters + :context :embedded-question + :constraints constraints + :qp qp + options))) + +;;; -------------------------- Dashboard Fns used by both /api/embed and /api/preview_embed -------------------------- + +(defn- remove-linked-filters-param-values [dashboard] + (let [param-ids (set (map :id (:parameters dashboard))) + param-ids-to-remove (set (for [{param-id :id + filtering-parameters :filteringParameters} (:parameters dashboard) + filtering-parameter-id filtering-parameters + :when (not (contains? param-ids filtering-parameter-id))] + param-id)) + linked-field-ids (set (mapcat (params/get-linked-field-ids (:dashcards dashboard)) param-ids-to-remove))] + (update dashboard :param_values #(->> % + (map (fn [[param-id param]] + {param-id (cond-> param + (contains? linked-field-ids param-id) ;; is param linked? + (assoc :values []))})) + (into {}))))) + +(defn- remove-locked-parameters [dashboard embedding-params] + (let [params (:parameters dashboard) + {params-to-remove :remove + params-to-keep :keep} (classify-params-as-keep-or-remove params embedding-params) + param-ids-to-remove (set (keep (fn [{:keys [slug id]}] + (when (contains? params-to-remove (keyword slug)) id)) + params)) + param-ids-to-keep (set (keep (fn [{:keys [slug id]}] + (when (contains? params-to-keep (keyword slug)) id)) + params)) + field-ids-to-maybe-remove (set (mapcat (params/get-linked-field-ids (:dashcards dashboard)) param-ids-to-remove)) + field-ids-to-keep (set (mapcat (params/get-linked-field-ids (:dashcards dashboard)) param-ids-to-keep)) + field-ids-to-remove (set/difference field-ids-to-maybe-remove field-ids-to-keep) + remove-parameters (fn [dashcard] + (update dashcard :parameter_mappings + (fn [param-mappings] + (remove (fn [{:keys [parameter_id]}] + (contains? param-ids-to-remove parameter_id)) param-mappings))))] + (-> dashboard + (update :dashcards #(map remove-parameters %)) + (update :param_fields #(apply dissoc % field-ids-to-remove)) + (update :param_values #(apply dissoc % field-ids-to-remove))))) + +(defn dashboard-for-unsigned-token + "Return the info needed for embedding about Dashboard specified in `token`. Additional `constraints` can be passed to + the `public-dashboard` function that fetches the Dashboard." + [unsigned-token & {:keys [embedding-params constraints]}] + {:pre [((some-fn empty? sequential?) constraints) (even? (count constraints))]} + (let [dashboard-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :dashboard]) + embedding-params (or embedding-params + (t2/select-one-fn :embedding_params :model/Dashboard, :id dashboard-id)) + token-params (embed/get-in-unsigned-token-or-throw unsigned-token [:params])] + (-> (apply api.public/public-dashboard :id dashboard-id, constraints) + (substitute-token-parameters-in-text token-params) + (remove-locked-parameters embedding-params) + (remove-token-parameters token-params) + (remove-locked-and-disabled-params embedding-params) + (remove-linked-filters-param-values)))) + +(defn- get-embed-dashboard-context + "If a certain export-format is given, return the correct embedded dashboard context." + [export-format] + (case export-format + "csv" :embedded-csv-download + "xlsx" :embedded-xlsx-download + "json" :embedded-json-download + :embedded-dashboard)) + +(defn process-query-for-dashcard + "Return results for running the query belonging to a DashboardCard. Returns a `StreamingResponse`." + [& {:keys [dashboard-id dashcard-id card-id export-format embedding-params token-params middleware + query-params constraints qp] + :or {constraints (qp.constraints/default-query-constraints) + qp qp.card/process-query-for-card-default-qp}}] + {:pre [(integer? dashboard-id) (integer? dashcard-id) (integer? card-id) (u/maybe? map? embedding-params) + (map? token-params) (map? query-params)]} + (let [slug->value (validate-and-merge-params embedding-params token-params (normalize-query-params query-params)) + parameters (resolve-dashboard-parameters dashboard-id slug->value)] + (api.public/process-query-for-dashcard + :dashboard-id dashboard-id + :card-id card-id + :dashcard-id dashcard-id + :export-format export-format + :parameters parameters + :qp qp + :context (get-embed-dashboard-context export-format) + :constraints constraints + :middleware middleware))) + +(defn card-param-values + "Search for card parameter values. Does security checks to ensure the parameter is on the card and then gets param + values according to [[api.card/param-values]]." + [{:keys [unsigned-token card param-key search-prefix]}] + (let [slug-token-params (embed/get-in-unsigned-token-or-throw unsigned-token [:params]) + parameters (or (seq (:parameters card)) + (card/template-tag-parameters card)) + id->slug (into {} (map (juxt :id :slug) parameters)) + slug->id (into {} (map (juxt :slug :id) parameters)) + searched-param-slug (get id->slug param-key) + embedding-params (:embedding_params card)] + (try + (when-not (= (get embedding-params (keyword searched-param-slug)) "enabled") + (throw (ex-info (tru "Cannot search for values: {0} is not an enabled parameter." + (pr-str searched-param-slug)) + {:status-code 400}))) + (when (get slug-token-params (keyword searched-param-slug)) + (throw (ex-info (tru "You can''t specify a value for {0} if it's already set in the JWT." (pr-str searched-param-slug)) + {:status-code 400}))) + (try + (binding [api/*current-user-permissions-set* (atom #{"/"}) + api/*is-superuser?* true] + (api.card/param-values card param-key search-prefix)) + (catch Throwable e + (throw (ex-info (.getMessage e) + {:card-id (u/the-id card) + :param-key param-key + :search-prefix search-prefix} + e)))) + (catch Throwable e + (let [e (ex-info (.getMessage e) + {:card-id (u/the-id card) + :card-params (:parametres card) + :allowed-param-slugs embedding-params + :slug->id slug->id + :id->slug id->slug + :param-id param-key + :param-slug searched-param-slug + :token-params slug-token-params} + e)] + (log/errorf e "embedded card-param-values error\n%s" + (u/pprint-to-str (u/all-ex-data e))) + (throw e)))))) + +(defn dashboard-param-values + "Common implementation for fetching parameter values for embedding and preview-embedding." + [token searched-param-id prefix id-query-params] + (let [unsigned-token (embed/unsign token) + dashboard-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :dashboard]) + _ (check-embedding-enabled-for-dashboard dashboard-id) + slug-token-params (embed/get-in-unsigned-token-or-throw unsigned-token [:params]) + {parameters :parameters + embedding-params :embedding_params} (t2/select-one :model/Dashboard :id dashboard-id) + id->slug (into {} (map (juxt :id :slug) parameters)) + slug->id (into {} (map (juxt :slug :id) parameters)) + searched-param-slug (get id->slug searched-param-id)] + (try + ;; you can only search for values of a parameter if it is ENABLED and NOT PRESENT in the JWT. + (when-not (= (get embedding-params (keyword searched-param-slug)) "enabled") + (throw (ex-info (tru "Cannot search for values: {0} is not an enabled parameter." (pr-str searched-param-slug)) + {:status-code 400}))) + (when (get slug-token-params (keyword searched-param-slug)) + (throw (ex-info (tru "You can''t specify a value for {0} if it's already set in the JWT." (pr-str searched-param-slug)) + {:status-code 400}))) + ;; ok, at this point we can run the query + (let [merged-id-params (param-values-merged-params id->slug slug->id embedding-params slug-token-params id-query-params)] + (try + (binding [api/*current-user-permissions-set* (atom #{"/"}) + api/*is-superuser?* true] + (api.dashboard/param-values (t2/select-one :model/Dashboard :id dashboard-id) searched-param-id merged-id-params prefix)) + (catch Throwable e + (throw (ex-info (.getMessage e) + {:merged-id-params merged-id-params} + e))))) + (catch Throwable e + (let [e (ex-info (.getMessage e) + {:dashboard-id dashboard-id + :dashboard-params parameters + :allowed-param-slugs embedding-params + :slug->id slug->id + :id->slug id->slug + :param-id searched-param-id + :param-slug searched-param-slug + :token-params slug-token-params} + e)] + (log/errorf e "Chain filter error\n%s" (u/pprint-to-str (u/all-ex-data e))) + (throw e)))))) diff --git a/src/metabase/api/preview_embed.clj b/src/metabase/api/preview_embed.clj index be5a6456210..90d58fa6aad 100644 --- a/src/metabase/api/preview_embed.clj +++ b/src/metabase/api/preview_embed.clj @@ -12,7 +12,7 @@ [compojure.core :refer [GET]] [metabase.api.common :as api] [metabase.api.common.validation :as validation] - [metabase.api.embed :as api.embed] + [metabase.api.embed.common :as api.embed.common] [metabase.query-processor.pivot :as qp.pivot] [metabase.util.embed :as embed] [metabase.util.malli.schema :as ms])) @@ -27,7 +27,7 @@ [token] {token ms/NonBlankString} (let [unsigned-token (check-and-unsign token)] - (api.embed/card-for-unsigned-token unsigned-token + (api.embed.common/card-for-unsigned-token unsigned-token :embedding-params (embed/get-in-unsigned-token-or-throw unsigned-token [:_embedding_params])))) (def ^:private max-results @@ -40,7 +40,7 @@ {token ms/NonBlankString} (let [unsigned-token (check-and-unsign token) card-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :question])] - (api.embed/process-query-for-card-with-params + (api.embed.common/process-query-for-card-with-params :export-format :api :card-id card-id :token-params (embed/get-in-unsigned-token-or-throw unsigned-token [:params]) @@ -53,9 +53,14 @@ [token] {token ms/NonBlankString} (let [unsigned-token (check-and-unsign token)] - (api.embed/dashboard-for-unsigned-token unsigned-token + (api.embed.common/dashboard-for-unsigned-token unsigned-token :embedding-params (embed/get-in-unsigned-token-or-throw unsigned-token [:_embedding_params])))) +(api/defendpoint GET "/dashboard/:token/params/:param-key/values" + "Embedded version of chain filter values endpoint." + [token param-key :as {:keys [query-params]}] + (api.embed.common/dashboard-param-values token param-key nil query-params)) + (api/defendpoint GET "/dashboard/:token/dashcard/:dashcard-id/card/:card-id" "Fetch the results of running a Card belonging to a Dashboard you're considering embedding with JWT `token`." [token dashcard-id card-id & query-params] @@ -66,14 +71,14 @@ dashboard-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :dashboard]) embedding-params (embed/get-in-unsigned-token-or-throw unsigned-token [:_embedding_params]) token-params (embed/get-in-unsigned-token-or-throw unsigned-token [:params])] - (api.embed/process-query-for-dashcard - :export-format :api - :dashboard-id dashboard-id - :dashcard-id dashcard-id - :card-id card-id - :embedding-params embedding-params - :token-params token-params - :query-params query-params))) + (api.embed.common/process-query-for-dashcard + :export-format :api + :dashboard-id dashboard-id + :dashcard-id dashcard-id + :card-id card-id + :embedding-params embedding-params + :token-params token-params + :query-params query-params))) (api/defendpoint GET "/pivot/card/:token/query" "Fetch the query results for a Card you're considering embedding by passing a JWT `token`." @@ -81,7 +86,7 @@ {token ms/NonBlankString} (let [unsigned-token (check-and-unsign token) card-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :question])] - (api.embed/process-query-for-card-with-params + (api.embed.common/process-query-for-card-with-params :export-format :api :card-id card-id :token-params (embed/get-in-unsigned-token-or-throw unsigned-token [:params]) @@ -99,7 +104,7 @@ dashboard-id (embed/get-in-unsigned-token-or-throw unsigned-token [:resource :dashboard]) embedding-params (embed/get-in-unsigned-token-or-throw unsigned-token [:_embedding_params]) token-params (embed/get-in-unsigned-token-or-throw unsigned-token [:params])] - (api.embed/process-query-for-dashcard + (api.embed.common/process-query-for-dashcard :export-format :api :dashboard-id dashboard-id :dashcard-id dashcard-id diff --git a/test/metabase/api/embed_test.clj b/test/metabase/api/embed_test.clj index 97f1b2a6997..9967ce5b5c6 100644 --- a/test/metabase/api/embed_test.clj +++ b/test/metabase/api/embed_test.clj @@ -11,7 +11,7 @@ [dk.ative.docjure.spreadsheet :as spreadsheet] [metabase.api.card-test :as api.card-test] [metabase.api.dashboard-test :as api.dashboard-test] - [metabase.api.embed :as api.embed] + [metabase.api.embed.common :as api.embed.common] [metabase.api.pivots :as api.pivots] [metabase.api.public-test :as public-test] [metabase.config :as config] @@ -940,7 +940,7 @@ (testing (str "parameters that are not in the `embedding-params` map at all should get removed by " "`remove-locked-and-disabled-params`") (is (= {:parameters []} - (#'api.embed/remove-locked-and-disabled-params {:parameters {:slug "foo"}} {}))))) + (#'api.embed.common/remove-locked-and-disabled-params {:parameters {:slug "foo"}} {}))))) (deftest make-sure-that-multiline-series-word-as-expected---4768- @@ -1567,18 +1567,18 @@ (deftest apply-slug->value-test (testing "For operator filter types treat a lone value as a one-value sequence (#20438)" - (is (= (#'api.embed/apply-slug->value [{:type :string/= - :target [:dimension [:template-tag "NAME"]] - :name "Name" - :slug "NAME" - :default nil}] - {:NAME ["Aaron Hand"]}) - (#'api.embed/apply-slug->value [{:type :string/= - :target [:dimension [:template-tag "NAME"]] - :name "Name" - :slug "NAME" - :default nil}] - {:NAME "Aaron Hand"}))))) + (is (= (#'api.embed.common/apply-slug->value [{:type :string/= + :target [:dimension [:template-tag "NAME"]] + :name "Name" + :slug "NAME" + :default nil}] + {:NAME ["Aaron Hand"]}) + (#'api.embed.common/apply-slug->value [{:type :string/= + :target [:dimension [:template-tag "NAME"]] + :name "Name" + :slug "NAME" + :default nil}] + {:NAME "Aaron Hand"}))))) (deftest handle-single-params-for-operator-filters-test (testing "Query endpoints should work with a single URL parameter for an operator filter (#20438)" diff --git a/test/metabase/api/preview_embed_test.clj b/test/metabase/api/preview_embed_test.clj index 330f34bd1ba..fad4b6d5bf2 100644 --- a/test/metabase/api/preview_embed_test.clj +++ b/test/metabase/api/preview_embed_test.clj @@ -1,6 +1,9 @@ (ns metabase.api.preview-embed-test (:require + [buddy.sign.jwt :as jwt] [clojure.test :refer :all] + [crypto.random :as crypto-random] + [metabase.api.dashboard-test :as api.dashboard-test] [metabase.api.embed-test :as embed-test] [metabase.api.pivots :as api.pivots] [metabase.api.preview-embed :as api.preview-embed] @@ -9,6 +12,7 @@ [metabase.models.dashboard-card :refer [DashboardCard]] [metabase.test :as mt] [metabase.util :as u] + [toucan2.core :as t2] [toucan2.tools.with-temp :as t2.with-temp])) ;;; --------------------------------------- GET /api/preview_embed/card/:token --------------------------------------- @@ -531,3 +535,45 @@ (is (= [[1]] (mt/rows (mt/user-http-request :crowberto :get 202 url :name "Hudson Borer")) (mt/rows (mt/user-http-request :crowberto :get 202 url :name "Hudson Borer" :name "x")))))))))))) + +;;; ------------------------------------------------ Chain filtering ------------------------------------------------- + +(defn random-embedding-secret-key [] (crypto-random/hex 32)) + +(def ^:dynamic *secret-key* nil) + +(defn sign [claims] (jwt/sign claims *secret-key*)) + +(defn do-with-new-secret-key [f] + (binding [*secret-key* (random-embedding-secret-key)] + (mt/with-temporary-setting-values [embedding-secret-key *secret-key*] + (f)))) + +(defmacro with-new-secret-key {:style/indent 0} [& body] + `(do-with-new-secret-key (fn [] ~@body))) + +(defmacro with-embedding-enabled-and-new-secret-key {:style/indent 0} [& body] + `(mt/with-temporary-setting-values [~'enable-embedding true] + (with-new-secret-key + ~@body))) + +(defn dash-token + [dash-or-id & [additional-token-params]] + (sign (merge {:resource {:dashboard (u/the-id dash-or-id)} + :params {}} + additional-token-params))) + +(deftest params-with-static-list-test + (testing "embedding with parameter that has source is a static list" + (with-embedding-enabled-and-new-secret-key + (api.dashboard-test/with-chain-filter-fixtures [{:keys [dashboard]}] + (t2/update! Dashboard (u/the-id dashboard) {:enable_embedding true + :embedding_params {"static_category" "enabled" + "static_category_label" "enabled"}}) + (let [signed-token (dash-token dashboard) + url (format "preview_embed/dashboard/%s/params/%s/values" signed-token "_STATIC_CATEGORY_")] + (testing "Should work if the param we're fetching values for is enabled" + (testing "\nGET /api/preview-embed/dashboard/:token/params/:param-key/values" + (is (= {:values [["African"] ["American"] ["Asian"]] + :has_more_values false} + (mt/user-http-request :rasta :get 200 url)))))))))) -- GitLab