diff --git a/e2e/support/cypress_sample_instance_data.js b/e2e/support/cypress_sample_instance_data.js index 4f4bdf418054a5e6e685d49fd7cecb9abf247e6a..d4d699f80f627685f8995eac3ea2551acee2fac6 100644 --- a/e2e/support/cypress_sample_instance_data.js +++ b/e2e/support/cypress_sample_instance_data.js @@ -35,6 +35,11 @@ export const NORMAL_PERSONAL_COLLECTION_ID = _.findWhere( { name: "Robert Tableton's Personal Collection" }, ).id; +export const READ_ONLY_PERSONAL_COLLECTION_ID = _.findWhere( + SAMPLE_INSTANCE_DATA.collections, + { name: "Read Only Tableton's Personal Collection" }, +).id; + export const NO_DATA_PERSONAL_COLLECTION_ID = _.findWhere( SAMPLE_INSTANCE_DATA.collections, { name: "No Data Tableton's Personal Collection" }, diff --git a/e2e/test/scenarios/collections/archive.cy.spec.js b/e2e/test/scenarios/collections/archive.cy.spec.js index bfb8d4a177713fdbab059af3bc4eb968a7f8067f..500265c6b930c8012e629d1829546d06d4908dfe 100644 --- a/e2e/test/scenarios/collections/archive.cy.spec.js +++ b/e2e/test/scenarios/collections/archive.cy.spec.js @@ -1,6 +1,9 @@ import { restore } from "e2e/support/helpers"; import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; -import { FIRST_COLLECTION_ID } from "e2e/support/cypress_sample_instance_data"; +import { + FIRST_COLLECTION_ID, + READ_ONLY_PERSONAL_COLLECTION_ID, +} from "e2e/support/cypress_sample_instance_data"; const { ORDERS, ORDERS_ID, PEOPLE_ID } = SAMPLE_DATABASE; @@ -141,6 +144,42 @@ describe("scenarios > collections > archive", () => { cy.findByTestId(`archive-item-${QUESTION_NAME}`).should("not.exist"); }); + it("should hide read-only archived items (metabase#24018)", () => { + const READ_ONLY_NAME = "read-only dashboard"; + const CURATEABLE_NAME = "curate-able dashboard"; + + // setup archive with read-only collection items + createAndArchiveDashboard({ + name: READ_ONLY_NAME, + collection_id: null, + }); + + // setup archive with curate-able collection items (user created items) + cy.signIn("readonly"); + + createAndArchiveDashboard({ + name: CURATEABLE_NAME, + collection_id: READ_ONLY_PERSONAL_COLLECTION_ID, + }); + + // assert on desired behavior for read-only user + cy.visit("/archive"); + + cy.get("main").within(() => { + cy.findByText(READ_ONLY_NAME).should("not.exist"); + cy.findByText(CURATEABLE_NAME).should("be.visible"); + }); + + // assert on desired behavior for admin user + cy.signInAsAdmin(); + cy.visit("/archive"); + + cy.get("main").within(() => { + cy.findByText(READ_ONLY_NAME).should("be.visible"); + cy.findByText(CURATEABLE_NAME).should("be.visible"); + }); + }); + it("should load initially hidden archived items on scroll (metabase#24213)", () => { const stubbedItems = Array.from({ length: 50 }, (v, i) => ({ name: "Item " + i, @@ -180,3 +219,12 @@ describe("scenarios > collections > archive", () => { }); }); }); + +function createAndArchiveDashboard({ name, collection_id }) { + cy.request("POST", "/api/dashboard/", { + name: name, + collection_id: collection_id, + }).then(({ body: { id: dashboardId } }) => { + cy.request("PUT", `/api/dashboard/${dashboardId}`, { archived: true }); + }); +} diff --git a/frontend/src/metabase-types/api/collection.ts b/frontend/src/metabase-types/api/collection.ts index 77ad2b34e0afc56f486ba1054ed4cc3d693efcce..de34977a6299a3078cb4e9d9d8e08b691975a87f 100644 --- a/frontend/src/metabase-types/api/collection.ts +++ b/frontend/src/metabase-types/api/collection.ts @@ -82,6 +82,7 @@ export interface CollectionItem { database_id?: DatabaseId; moderated_status?: string; type?: string; + can_write?: boolean; getIcon: () => { name: IconName }; getUrl: (opts?: Record<string, unknown>) => string; setArchived?: (isArchived: boolean) => void; diff --git a/frontend/src/metabase/archive/containers/ArchiveApp.tsx b/frontend/src/metabase/archive/containers/ArchiveApp.tsx index 2889a7d416afe7e08e0b3e6266436838306c6069..01c2e166bbde07645e7f9354e71193081d581860 100644 --- a/frontend/src/metabase/archive/containers/ArchiveApp.tsx +++ b/frontend/src/metabase/archive/containers/ArchiveApp.tsx @@ -1,9 +1,18 @@ import { useCallback, useEffect, useMemo } from "react"; import { t } from "ttag"; +import type { CollectionItem } from "metabase-types/api"; + import { useDispatch, useSelector } from "metabase/lib/redux"; -import type { CollectionItem } from "metabase-types/api"; +import Search from "metabase/entities/search"; +import { useListSelect } from "metabase/hooks/use-list-select"; +import { useSearchListQuery } from "metabase/common/hooks"; +import { isSmallScreen, getMainElement } from "metabase/lib/dom"; + +import { openNavbar } from "metabase/redux/app"; +import { getIsNavbarOpen } from "metabase/selectors/app"; +import { getUserIsAdmin } from "metabase/selectors/user"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper/LoadingAndErrorWrapper"; import { Button } from "metabase/ui"; @@ -14,15 +23,6 @@ import { StackedCheckBox } from "metabase/components/StackedCheckBox"; import VirtualizedList from "metabase/components/VirtualizedList"; import { ArchivedItem } from "metabase/components/ArchivedItem/ArchivedItem"; -import Search from "metabase/entities/search"; -import { useListSelect } from "metabase/hooks/use-list-select"; -import { useSearchListQuery } from "metabase/common/hooks"; - -import { openNavbar } from "metabase/redux/app"; -import { getIsNavbarOpen } from "metabase/selectors/app"; -import { getUserIsAdmin } from "metabase/selectors/user"; -import { isSmallScreen, getMainElement } from "metabase/lib/dom"; - import { ArchiveBarContent, ArchiveBarText, @@ -54,12 +54,19 @@ export function ArchiveApp() { useListSelect<CollectionItem>(item => `${item.model}:${item.id}`); const list = useMemo(() => { - clear(); - return data ?? []; - }, [data, clear]); + clear(); // clear selected items if data is ever refreshed + + if (!Array.isArray(data)) { + return []; + } + + return data; + }, [clear, data]); + const selectAllItems = useCallback(() => { selectOnlyTheseItems(list); }, [list, selectOnlyTheseItems]); + const allSelected = useMemo( () => selected.length === list.length, [selected, list], diff --git a/frontend/src/metabase/entities/search.js b/frontend/src/metabase/entities/search.js index 0a4329dc7a5f00eec7ab54d3dd6935ede5e303a8..2a53be28f8546569047177720f6b498fbb47b449 100644 --- a/frontend/src/metabase/entities/search.js +++ b/frontend/src/metabase/entities/search.js @@ -124,6 +124,15 @@ export default createEntity({ }, objectSelectors: { + getCollection: object => { + const entity = entityForObject(object); + return entity + ? entity?.objectSelectors?.getCollection?.(object) ?? + object?.collection ?? + null + : warnEntityAndReturnObject(object); + }, + getName: object => { const entity = entityForObject(object); return entity diff --git a/src/metabase/api/search.clj b/src/metabase/api/search.clj index 13c0bd030c406d54742eb92df17cd5bc03007c93..4fbec8df0a65e189bc800b947ded624b7c8b5d74 100644 --- a/src/metabase/api/search.clj +++ b/src/metabase/api/search.clj @@ -333,25 +333,33 @@ [(into [:case] case-clauses)])) (defmulti ^:private check-permissions-for-model - {:arglists '([search-result])} - (comp keyword :model)) + {:arglists '([archived? search-result])} + (fn [_ search-result] ((comp keyword :model) search-result))) (defmethod check-permissions-for-model :default - [_] - ;; We filter what we can (ie. everything that is in a collection) out already when querying - true) + [archived? instance] + (if archived? + (mi/can-write? instance) + ;; We filter what we can (ie. everything that is in a collection) out already when querying + true)) (defmethod check-permissions-for-model :metric - [instance] - (mi/can-read? instance)) + [archived? instance] + (if archived? + (mi/can-write? instance) + (mi/can-read? instance))) (defmethod check-permissions-for-model :segment - [instance] - (mi/can-read? instance)) + [archived? instance] + (if archived? + (mi/can-write? instance) + (mi/can-read? instance))) (defmethod check-permissions-for-model :database - [instance] - (mi/can-read? instance)) + [archived? instance] + (if archived? + (mi/can-write? instance) + (mi/can-read? instance))) (mu/defn query-model-set :- [:set SearchableModel] "Queries all models with respect to query for one result to see if we get a result or not" @@ -416,7 +424,7 @@ xf (comp (map t2.realize/realize) (map to-toucan-instance) - (filter check-permissions-for-model) + (filter (partial check-permissions-for-model (:archived? search-ctx))) ;; MySQL returns `:bookmark` and `:archived` as `1` or `0` so convert those to boolean as ;; needed (map #(update % :bookmark api/bit->boolean)) diff --git a/test/metabase/api/search_test.clj b/test/metabase/api/search_test.clj index e9d36c956301ed705854a64d5318499eff94def3..581f34396c73436b1588bbdcd9e309ed56025e13 100644 --- a/test/metabase/api/search_test.clj +++ b/test/metabase/api/search_test.clj @@ -1519,3 +1519,21 @@ (is (= #{"dashboard" "collection"} (set (mt/user-http-request :crowberto :get 200 "search/models" :q search-term :filter_items_in_personal_collection "exclude")))))))) + +(deftest archived-search-results-with-no-write-perms-test + (testing "Results which the searching user has no write permissions for are filtered out. #33602" + (mt/with-temp [Collection {collection-id :id} (archived {:name "collection test collection"}) + Card _ (archived {:name "card test card is returned"}) + Card _ (archived {:name "card test card" + :collection_id collection-id}) + Card _ (archived {:name "dataset test dataset" :dataset true + :collection_id collection-id}) + Dashboard _ (archived {:name "dashboard test dashboard" + :collection_id collection-id})] + ;; remove read/write access and add back read access to the collection + (perms/revoke-collection-permissions! (perms-group/all-users) collection-id) + (perms/grant-collection-read-permissions! (perms-group/all-users) collection-id) + (is (= ["card test card is returned"] + (->> (mt/user-http-request :lucky :get 200 "search" :archived true :q "test") + :data + (map :name)))))))