From d02fef97bbd2af0fe2a5e41af4077b107fa73a39 Mon Sep 17 00:00:00 2001 From: Noah Moss <32746338+noahmoss@users.noreply.github.com> Date: Wed, 22 Mar 2023 10:03:48 -0400 Subject: [PATCH] Show all subscriptions on /account/notifications page (#29214) * show all subscriptions in notification management page regardless of collection perms * strip cards and recipients if necessary * indentation tweak * add backend tests * add cypress repro * fix permissions check * slightly different approach * fix cypress test * fix test * address comment --- ...ations-without-collection-perms.cy.spec.js | 38 ++++++++++ .../NotificationsApp/NotificationsApp.jsx | 4 +- frontend/src/metabase/lib/notifications.js | 10 ++- src/metabase/api/pulse.clj | 73 ++++++++++++------- src/metabase/models/pulse.clj | 19 +++-- test/metabase/api/pulse_test.clj | 64 ++++++++++++---- test/metabase/models/pulse_test.clj | 11 +-- 7 files changed, 152 insertions(+), 67 deletions(-) create mode 100644 e2e/test/scenarios/permissions/reproductions/22473-cannot-unsubscribe-from-notifications-without-collection-perms.cy.spec.js diff --git a/e2e/test/scenarios/permissions/reproductions/22473-cannot-unsubscribe-from-notifications-without-collection-perms.cy.spec.js b/e2e/test/scenarios/permissions/reproductions/22473-cannot-unsubscribe-from-notifications-without-collection-perms.cy.spec.js new file mode 100644 index 00000000000..1c5130de730 --- /dev/null +++ b/e2e/test/scenarios/permissions/reproductions/22473-cannot-unsubscribe-from-notifications-without-collection-perms.cy.spec.js @@ -0,0 +1,38 @@ +import { restore, setupSMTP, sidebar } from "e2e/support/helpers"; +import { modal } from "e2e/support/helpers/e2e-ui-elements-helpers"; + +import { USERS } from "e2e/support/cypress_data"; +const { nocollection } = USERS; + +describe("issue 22473", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + setupSMTP(); + }); + + it("nocollection user should be able to view and unsubscribe themselves from a subscription", () => { + cy.visit(`/dashboard/1`); + cy.icon("subscription").click(); + cy.findByText("Email it").click(); + cy.findByPlaceholderText("Enter user names or email addresses") + .click() + .type(`${nocollection.first_name} ${nocollection.last_name}{enter}`) + .blur(); + sidebar().within(() => { + cy.button("Done").click() + }); + + cy.signIn("nocollection"); + cy.visit("/account/notifications"); + + cy.findByText("Orders in a dashboard").should("exist"); + cy.findByTestId("notifications-list").within(() => { + cy.findByLabelText("close icon").click(); + }); + modal().within(() => { + cy.button("Unsubscribe").click() + }); + cy.findByText("Orders in a dashboard").should("not.exist"); + }) +}) diff --git a/frontend/src/metabase/account/notifications/containers/NotificationsApp/NotificationsApp.jsx b/frontend/src/metabase/account/notifications/containers/NotificationsApp/NotificationsApp.jsx index 70a8a21964f..2d9bf183c62 100644 --- a/frontend/src/metabase/account/notifications/containers/NotificationsApp/NotificationsApp.jsx +++ b/frontend/src/metabase/account/notifications/containers/NotificationsApp/NotificationsApp.jsx @@ -33,8 +33,8 @@ export default _.compose( reload: true, }), Pulses.loadList({ - // Load all pulses the current user can read (i.e. is a creator or recipient of) - query: state => ({ can_read: true }), + // Load all pulses the current user is a creator or recipient of + query: state => ({ creator_or_recipient: true }), reload: true, }), connect(mapStateToProps, mapDispatchToProps), diff --git a/frontend/src/metabase/lib/notifications.js b/frontend/src/metabase/lib/notifications.js index c0092d54606..a67a8e8443f 100644 --- a/frontend/src/metabase/lib/notifications.js +++ b/frontend/src/metabase/lib/notifications.js @@ -117,9 +117,13 @@ export const getRecipientsCount = (item, channelType) => { }; export const canArchive = (item, user) => { - const recipients = item.channels.flatMap(channel => - channel.recipients.map(recipient => recipient.id), - ); + const recipients = item.channels.flatMap(channel => { + if (channel.recipients) { + return channel.recipients.map(recipient => recipient.id); + } else { + return []; + } + }); const isCreator = item.creator?.id === user.id; const isSubscribed = recipients.includes(user.id); diff --git a/src/metabase/api/pulse.clj b/src/metabase/api/pulse.clj index 1e30267cdf7..3c1c9752220 100644 --- a/src/metabase/api/pulse.clj +++ b/src/metabase/api/pulse.clj @@ -40,7 +40,7 @@ (u/ignore-exceptions (classloader/require 'metabase-enterprise.sandbox.api.util 'metabase-enterprise.advanced-permissions.common)) -(defn- filter-pulses-recipients +(defn- maybe-filter-pulses-recipients "If the current user is sandboxed, remove all Metabase users from the `pulses` recipient lists that are not the user themselves. Recipients that are plain email addresses are preserved." [pulses] @@ -56,9 +56,20 @@ pulses) pulses)) -(defn- filter-pulse-recipients +(defn- maybe-filter-pulse-recipients [pulse] - (first (filter-pulses-recipients [pulse]))) + (first (maybe-filter-pulses-recipients [pulse]))) + +(defn- maybe-strip-sensitive-metadata + "If the current user does not have collection read permissions for the pulse, but can still read the pulse due to + being the creator or a recipient, we return it with some metadata removed." + [pulse] + (if (mi/current-user-has-full-permissions? :read pulse) + pulse + (-> (dissoc pulse :cards) + (update :channels + (fn [channels] + (map #(dissoc % :recipients) channels)))))) #_{:clj-kondo/ignore [:deprecated-var]} (api/defendpoint-schema GET "/" @@ -67,21 +78,25 @@ If `dashboard_id` is specified, restricts results to subscriptions for that dashboard. - If `can_read` is `true`, it specifically returns all subscriptions for which the current user + If `created_or_receive` is `true`, it specifically returns all subscriptions for which the current user created *or* is a known recipient of. Note that this is a superset of the default items returned for non-admins, - and a subset of the default items returned for admins. This is used to power the /account/notifications page." - [archived dashboard_id can_read] - {archived (s/maybe su/BooleanString) - dashboard_id (s/maybe su/IntGreaterThanZero) - can_read (s/maybe su/BooleanString)} - (let [can-read? (Boolean/parseBoolean can_read) - archived? (Boolean/parseBoolean archived) - pulses (->> (pulse/retrieve-pulses {:archived? archived? - :dashboard-id dashboard_id - :user-id api/*current-user-id* - :can-read? can-read?}) - (filter (if can-read? mi/can-read? mi/can-write?)) - filter-pulses-recipients)] + and a subset of the default items returned for admins. This is used to power the /account/notifications page. + This may include subscriptions which the current user does not have collection permissions for, in which case + some sensitive metadata (the list of cards and recipients) is stripped out." + [archived dashboard_id creator_or_recipient] + {archived (s/maybe su/BooleanString) + dashboard_id (s/maybe su/IntGreaterThanZero) + creator_or_recipient (s/maybe su/BooleanString)} + (let [creator-or-recipient (Boolean/parseBoolean creator_or_recipient) + archived? (Boolean/parseBoolean archived) + pulses (->> (pulse/retrieve-pulses {:archived? archived? + :dashboard-id dashboard_id + :user-id (when creator-or-recipient api/*current-user-id*)}) + (filter (if creator-or-recipient mi/can-read? mi/can-write?)) + maybe-filter-pulses-recipients) + pulses (if creator-or-recipient + (map maybe-strip-sensitive-metadata pulses) + pulses)] (hydrate pulses :can_write))) (defn check-card-read-permissions @@ -122,20 +137,24 @@ :dashboard_id dashboard_id :parameters parameters}] (db/transaction - ;; Adding a new pulse at `collection_position` could cause other pulses in this collection to change position, - ;; check that and fix it if needed - (api/maybe-reconcile-collection-position! pulse-data) - ;; ok, now create the Pulse - (api/check-500 - (pulse/create-pulse! (map pulse/card->ref cards) channels pulse-data))))) + ;; Adding a new pulse at `collection_position` could cause other pulses in this collection to change position, + ;; check that and fix it if needed + (api/maybe-reconcile-collection-position! pulse-data) + ;; ok, now create the Pulse + (api/check-500 + (pulse/create-pulse! (map pulse/card->ref cards) channels pulse-data))))) #_{:clj-kondo/ignore [:deprecated-var]} (api/defendpoint-schema GET "/:id" - "Fetch `Pulse` with ID." + "Fetch `Pulse` with ID. If the user is a recipient of the Pulse but does not have read permissions for its collection, + we still return it but with some sensitive metadata removed." [id] - (-> (api/read-check (pulse/retrieve-pulse id)) - filter-pulse-recipients - (hydrate :can_write))) + (api/let-404 [pulse (pulse/retrieve-pulse id)] + (api/check-403 (mi/can-read? pulse)) + (-> pulse + maybe-filter-pulse-recipients + maybe-strip-sensitive-metadata + (hydrate :can_write)))) (defn- maybe-add-recipients-for-sandboxed-users "Sandboxed users can't read the full recipient list for a pulse, so we need to merge in existing recipients diff --git a/src/metabase/models/pulse.clj b/src/metabase/models/pulse.clj index 9b483d2dc0e..d8177249b7e 100644 --- a/src/metabase/models/pulse.clj +++ b/src/metabase/models/pulse.clj @@ -129,9 +129,8 @@ (if (is-alert? notification) (mi/current-user-has-full-permissions? :read notification) (or api/*is-superuser?* - (and (mi/current-user-has-full-permissions? :read notification) - (or (current-user-is-creator? notification) - (current-user-is-recipient? notification)))))) + (or (current-user-is-creator? notification) + (current-user-is-recipient? notification))))) ;; Non-admins should be able to create subscriptions, and update subscriptions that they created, but not edit anyone ;; else's subscriptions (except for unsubscribing themselves, which uses a custom API). @@ -329,15 +328,15 @@ alert)))) (s/defn retrieve-pulses :- [(mi/InstanceOf Pulse)] - "Fetch all `Pulses`." - [{:keys [archived? dashboard-id can-read? user-id] - :or {archived? false - can-read? false}}] + "Fetch all `Pulses`. When `user-id` is included, only fetches `Pulses` for which the provided user is the creator + or a recipient." + [{:keys [archived? dashboard-id user-id] + :or {archived? false}}] (let [query {:select-distinct [:p.* [[:lower :p.name] :lower-name]] :from [[:pulse :p]] :left-join (concat [[:report_dashboard :d] [:= :p.dashboard_id :d.id]] - (when can-read? + (when user-id [[:pulse_channel :pchan] [:= :p.id :pchan.pulse_id] [:pulse_channel_recipient :pcr] [:= :pchan.id :pcr.pulse_channel_id]])) :where [:and @@ -349,9 +348,9 @@ [:= :d.archived false]] (when dashboard-id [:= :p.dashboard_id dashboard-id]) - ;; Only return dashboard subscriptions when `can-read?` is `true` so that legacy + ;; Only return dashboard subscriptions when `user-id` is passed, so that legacy ;; pulses don't show up in the notification management page - (when can-read? + (when user-id [:and [:not= :p.dashboard_id nil] [:or diff --git a/test/metabase/api/pulse_test.clj b/test/metabase/api/pulse_test.clj index 13407b1b7b9..c7bab8ae6e1 100644 --- a/test/metabase/api/pulse_test.clj +++ b/test/metabase/api/pulse_test.clj @@ -82,6 +82,9 @@ :where [:in :id (set (map u/the-id pulses-or-ids))]})) (f)))) +(defmacro ^:private with-pulses-in-nonreadable-collection [pulses-or-ids & body] + `(do-with-pulses-in-a-collection (constantly nil) ~pulses-or-ids (fn [] ~@body))) + (defmacro ^:private with-pulses-in-readable-collection [pulses-or-ids & body] `(do-with-pulses-in-a-collection perms/grant-collection-read-permissions! ~pulses-or-ids (fn [] ~@body))) @@ -784,22 +787,34 @@ [(assoc (pulse-details pulse-1) :can_write true, :collection_id true)] (map #(update % :collection_id boolean) results))))) - (testing "when `can_read=true`, all users only see pulses they created or are a recipient of" - (let [results (-> (mt/user-http-request :crowberto :get 200 "pulse?can_read=true") - (filter-pulse-results :id #{pulse-1-id pulse-2-id pulse-3-id}))] - (is (= 2 (count results))) - (is (partial= - [(assoc (pulse-details pulse-2) :can_write true, :collection_id true) - (assoc (pulse-details pulse-3) :can_write true, :collection_id true)] - (map #(update % :collection_id boolean) results)))) - - (let [results (-> (mt/user-http-request :rasta :get 200 "pulse?can_read=true") - (filter-pulse-results :id #{pulse-1-id pulse-2-id pulse-3-id}))] - (is (= 2 (count results))) - (is (partial= - [(assoc (pulse-details pulse-1) :can_write true, :collection_id true) - (assoc (pulse-details pulse-3) :can_write false, :collection_id true)] - (map #(update % :collection_id boolean) results))))))) + (testing "when `creator_or_recipient=true`, all users only see pulses they created or are a recipient of" + (let [expected-pulse-shape (fn [pulse] (-> pulse + pulse-details + (assoc :can_write true, :collection_id true) + (dissoc :cards)))] + (let [results (-> (mt/user-http-request :crowberto :get 200 "pulse?creator_or_recipient=true") + (filter-pulse-results :id #{pulse-1-id pulse-2-id pulse-3-id}))] + (is (= 2 (count results))) + (is (partial= + [(expected-pulse-shape pulse-2) (expected-pulse-shape pulse-3)] + (map #(update % :collection_id boolean) results)))) + + (let [results (-> (mt/user-http-request :rasta :get 200 "pulse?creator_or_recipient=true") + (filter-pulse-results :id #{pulse-1-id pulse-2-id pulse-3-id}))] + (is (= 2 (count results))) + (is (partial= + [(expected-pulse-shape pulse-1) + (assoc (expected-pulse-shape pulse-3) :can_write false)] + (map #(update % :collection_id boolean) results))))))) + + (with-pulses-in-nonreadable-collection [pulse-3] + (testing "when `creator_or_recipient=true`, cards and recipients are not included in results if the user + does not have collection perms" + (let [result (-> (mt/user-http-request :rasta :get 200 "pulse?creator_or_recipient=true") + (filter-pulse-results :id #{pulse-3-id}) + first)] + (is (nil? (:cards result))) + (is (nil? (get-in result [:channels 0 :recipients]))))))) (testing "should not return alerts" (mt/with-temp* [Pulse [pulse-1 {:name "ABCDEF"}] @@ -845,6 +860,23 @@ (-> (mt/user-http-request :rasta :get 200 (str "pulse/" (u/the-id pulse))) (update :collection_id boolean)))))) + (testing "cannot normally fetch a pulse without collection permissions" + (mt/with-temp Pulse [pulse {:creator_id (mt/user->id :crowberto)}] + (with-pulses-in-nonreadable-collection [pulse] + (mt/user-http-request :rasta :get 403 (str "pulse/" (u/the-id pulse)))))) + + (testing "can fetch a pulse without collection permissions if you are the creator or a recipient" + (mt/with-temp Pulse [pulse {:creator_id (mt/user->id :rasta)}] + (with-pulses-in-nonreadable-collection [pulse] + (mt/user-http-request :rasta :get 200 (str "pulse/" (u/the-id pulse))))) + + (mt/with-temp* [Pulse [pulse {:creator_id (mt/user->id :crowberto)}] + PulseChannel [pc {:pulse_id (u/the-id pulse)}] + PulseChannelRecipient [_ {:pulse_channel_id (u/the-id pc) + :user_id (mt/user->id :rasta)}]] + (with-pulses-in-nonreadable-collection [pulse] + (mt/user-http-request :rasta :get 200 (str "pulse/" (u/the-id pulse)))))) + (testing "should 404 for an Alert" (mt/with-temp Pulse [{pulse-id :id} {:alert_condition "rows"}] (is (= "Not found." diff --git a/test/metabase/models/pulse_test.clj b/test/metabase/models/pulse_test.clj index 9fe438de306..91a21b15994 100644 --- a/test/metabase/models/pulse_test.clj +++ b/test/metabase/models/pulse_test.clj @@ -442,19 +442,12 @@ (is (mi/can-write? subscription)))) (mt/with-current-user (mt/user->id :rasta) - (testing "A non-admin has no access to a subscription if they don't have read access to the parent collection, - even if they created the subscription" - (is (not (mi/can-read? subscription))) - (is (not (mi/can-write? subscription)))) - (binding [api/*current-user-permissions-set* (delay #{(perms/collection-read-path collection)})] - (testing "A non-admin has read and write access to a subscription they created, if they have read access to the - parent collection" + (testing "A non-admin has read and write access to a subscription they created" (is (mi/can-read? subscription)) (is (mi/can-write? subscription))) - (testing "A non-admin has read-only access to a subscription they are a recipient of, if they have read access - to the parent collection" + (testing "A non-admin has read-only access to a subscription they are a recipient of" ;; Create a new Dashboard Subscription with an admin creator but non-admin recipient (mt/with-temp* [Pulse [subscription {:collection_id (u/the-id collection) :dashboard_id (u/the-id dashboard) -- GitLab