diff --git a/bin/reflection-linter b/bin/reflection-linter index 7e8ca9c6241ba7380114c443619b47e0c275efdb..86adf6b88d32462361b5b82d673027f59fa8010a 100755 --- a/bin/reflection-linter +++ b/bin/reflection-linter @@ -1,13 +1,13 @@ -#!/usr/bin/env bash +#! /usr/bin/env bash -echo -e "\e[1;34mChecking for reflection warnings. This may take a few minutes, so sit tight...\e[0m" +printf "\e[1;34mChecking for reflection warnings. This may take a few minutes, so sit tight...\e[0m\n" -warnings=`lein with-profile +ci check-reflection-warnings 2>&1 | grep Reflection | grep metabase | uniq` +warnings=`lein with-profile +ci check-reflection-warnings 2>&1 | grep Reflection | grep metabase | sort | uniq` if [ ! -z "$warnings" ]; then - echo -e "\e[1;31mYour code has cased introduced some reflection warnings.\e[0m 😞" + printf "\e[1;31mYour code has cased introduced some reflection warnings.\e[0m 😞\n" echo "$warnings"; exit -1; fi -echo -e "\e[1;32mNo reflection warnings! Success.\e[0m" +printf "\e[1;32mNo reflection warnings! Success.\e[0m\n" diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx index 8d810fa98f12153b6dc473bf616d82e420b14f45..a1a0e669ec3c9a9798e43ffc7dfdb08004872605 100644 --- a/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx +++ b/frontend/src/metabase/admin/permissions/components/PermissionsEditor.jsx @@ -2,7 +2,6 @@ import React from "react"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx"; import Confirm from "metabase/components/Confirm.jsx"; -import Modal from "metabase/components/Modal.jsx"; import PermissionsGrid from "../components/PermissionsGrid.jsx"; import PermissionsConfirm from "../components/PermissionsConfirm.jsx"; import EditBar from "metabase/components/EditBar.jsx"; @@ -15,7 +14,6 @@ import _ from "underscore"; const PermissionsEditor = ({ title = t`Permissions`, - modal, admin, grid, onUpdatePermission, @@ -35,7 +33,7 @@ const PermissionsEditor = ({ triggerClasses={cx({ disabled: !isDirty })} key="save" > - <Button primary small={!modal}>{t`Save Changes`}</Button> + <Button primary small>{t`Save Changes`}</Button> </Confirm> ); @@ -46,10 +44,10 @@ const PermissionsEditor = ({ content={t`No changes to permissions will be made.`} key="discard" > - <Button small={!modal}>{t`Cancel`}</Button> + <Button small>{t`Cancel`}</Button> </Confirm> ) : ( - <Button small={!modal} onClick={onCancel} key="cancel">{t`Cancel`}</Button> + <Button small onClick={onCancel} key="cancel">{t`Cancel`}</Button> ); return ( @@ -57,47 +55,30 @@ const PermissionsEditor = ({ loading={!grid} className="flex-full flex flex-column" > - {() => - // eslint-disable-line react/display-name - modal ? ( - <Modal - inline - title={title} - footer={[cancelButton, saveButton]} - onClose={onCancel} - > - <PermissionsGrid - className="flex-full" - grid={grid} - onUpdatePermission={onUpdatePermission} - {...getEntityAndGroupIdFromLocation(location)} + {() => ( + <div className="flex-full flex flex-column"> + {isDirty && ( + <EditBar + admin={admin} + title={t`You've made changes to permissions.`} + buttons={[cancelButton, saveButton]} /> - </Modal> - ) : ( - <div className="flex-full flex flex-column"> - {isDirty && ( - <EditBar - admin={admin} - title={t`You've made changes to permissions.`} - buttons={[cancelButton, saveButton]} - /> + )} + <div className="wrapper pt2"> + {grid && grid.crumbs ? ( + <Breadcrumbs className="py1" crumbs={grid.crumbs} /> + ) : ( + <h2>{title}</h2> )} - <div className="wrapper pt2"> - {grid && grid.crumbs ? ( - <Breadcrumbs className="py1" crumbs={grid.crumbs} /> - ) : ( - <h2>{title}</h2> - )} - </div> - <PermissionsGrid - className="flex-full" - grid={grid} - onUpdatePermission={onUpdatePermission} - {...getEntityAndGroupIdFromLocation(location)} - /> </div> - ) - } + <PermissionsGrid + className="flex-full" + grid={grid} + onUpdatePermission={onUpdatePermission} + {...getEntityAndGroupIdFromLocation(location)} + /> + </div> + )} </LoadingAndErrorWrapper> ); }; diff --git a/frontend/src/metabase/admin/permissions/containers/CollectionsPermissionsApp.jsx b/frontend/src/metabase/admin/permissions/containers/CollectionsPermissionsApp.jsx index 242d4b188a6efc8d61b1a18e4f636c9010b07c12..7929d02b6e6bf38930f6d8ef4c9031544f9a6635 100644 --- a/frontend/src/metabase/admin/permissions/containers/CollectionsPermissionsApp.jsx +++ b/frontend/src/metabase/admin/permissions/containers/CollectionsPermissionsApp.jsx @@ -3,6 +3,7 @@ import { connect } from "react-redux"; import PermissionsEditor from "../components/PermissionsEditor.jsx"; import PermissionsApp from "./PermissionsApp.jsx"; +import fitViewport from "metabase/hoc/FitViewPort"; import { CollectionsApi } from "metabase/services"; @@ -37,6 +38,7 @@ const mapDispatchToProps = { const Editor = connect(mapStateToProps, mapDispatchToProps)(PermissionsEditor); @connect(null, { loadCollections }) +@fitViewport export default class CollectionsPermissionsApp extends Component { componentWillMount() { this.props.loadCollections(); @@ -47,8 +49,9 @@ export default class CollectionsPermissionsApp extends Component { {...this.props} load={CollectionsApi.graph} save={CollectionsApi.updateGraph} + fitClassNames={this.props.fitClassNames} > - <Editor {...this.props} modal confirmCancel={false} /> + <Editor {...this.props} admin={false} confirmCancel={false} /> </PermissionsApp> ); } diff --git a/frontend/src/metabase/admin/permissions/selectors.js b/frontend/src/metabase/admin/permissions/selectors.js index 347ae36892f91b0b284cdce1029ebb0464b198cf..050a63c3c651e51156bd9253d8eeea88918e9b7e 100644 --- a/frontend/src/metabase/admin/permissions/selectors.js +++ b/frontend/src/metabase/admin/permissions/selectors.js @@ -265,13 +265,6 @@ const OPTION_NATIVE_WRITE = { icon: "sql", }; -const OPTION_NATIVE_READ = { - ...OPTION_YELLOW, - value: "read", - title: t`View raw queries`, - tooltip: t`Can view raw queries`, -}; - const OPTION_COLLECTION_WRITE = { ...OPTION_GREEN, value: "write", @@ -591,7 +584,7 @@ export const getDatabasesPermissionsGrid = createSelector( ) { return [OPTION_NONE]; } else { - return [OPTION_NATIVE_WRITE, OPTION_NATIVE_READ, OPTION_NONE]; + return [OPTION_NATIVE_WRITE, OPTION_NONE]; } }, getter(groupId, entityId) { @@ -663,6 +656,9 @@ export const getDatabasesPermissionsGrid = createSelector( }, ); +// "root" collection we should include in the grid even though it's not listed by the endpoints +const ROOT_COLLECTION = { id: "root", name: "Saved Items" }; + const getCollections = state => state.admin.permissions.collections; const getCollectionPermission = (permissions, groupId, { collectionId }) => getIn(permissions, [groupId, collectionId]); @@ -672,10 +668,12 @@ export const getCollectionsPermissionsGrid = createSelector( getGroups, getPermissions, (collections, groups: Array<Group>, permissions: GroupsPermissions) => { - if (!groups || !permissions || !collections) { + if (!groups || groups.length === 0 || !permissions || !collections) { return null; } + collections = [ROOT_COLLECTION, ...collections]; + const defaultGroup = _.find(groups, isDefaultGroup); return { diff --git a/frontend/src/metabase/components/CollectionLanding.jsx b/frontend/src/metabase/components/CollectionLanding.jsx index 9e149d1655c87cf5683ada4e5deb5a8d11420973..db9b7a0e67c8fbfba6620ade852a0505a4aadbb2 100644 --- a/frontend/src/metabase/components/CollectionLanding.jsx +++ b/frontend/src/metabase/components/CollectionLanding.jsx @@ -209,6 +209,7 @@ class CollectionLanding extends React.Component { render() { const { params, currentCollection } = this.props; const collectionId = params.collectionId; + const isRoot = collectionId === "root"; return ( <Box mx={4}> @@ -222,9 +223,7 @@ class CollectionLanding extends React.Component { to={`/collection/${collectionId}`} hover={{ color: normal.blue }} > - {collectionId === "root" - ? "Saved items" - : currentCollection.name} + {isRoot ? "Saved items" : currentCollection.name} </Link> </Flex> )} @@ -260,7 +259,7 @@ class CollectionLanding extends React.Component { <Box mx={1}> <EntityMenu items={[ - ...(collectionId + ...(!isRoot ? [ { title: t`Edit this collection`, @@ -276,7 +275,7 @@ class CollectionLanding extends React.Component { currentCollection.id }`, }, - ...(collectionId + ...(!isRoot ? [ { title: t`Archive this collection`, diff --git a/frontend/src/metabase/components/Modal.jsx b/frontend/src/metabase/components/Modal.jsx index ecbbbd9ee661de56bb966d8ac5101e6efd742152..7510a638900d50f71dd756b22582b91170af668f 100644 --- a/frontend/src/metabase/components/Modal.jsx +++ b/frontend/src/metabase/components/Modal.jsx @@ -188,14 +188,6 @@ export class FullPageModal extends Component { } } -export class InlineModal extends Component { - render() { - return ( - <div>{this.props.isOpen ? <FullPageModal {...this.props} /> : null}</div> - ); - } -} - /** * A modified version of Modal for Jest/Enzyme tests. Renders the modal content inline instead of document root. */ @@ -224,13 +216,11 @@ export class TestModal extends Component { // the "routeless" version should only be used for non-inline modals const RoutelessFullPageModal = routeless(FullPageModal); -const Modal = ({ full, inline, ...props }) => +const Modal = ({ full, ...props }) => full ? ( props.isOpen ? ( <RoutelessFullPageModal {...props} /> ) : null - ) : inline ? ( - <InlineModal {...props} /> ) : ( <WindowModal {...props} /> ); diff --git a/frontend/src/metabase/lib/permissions.js b/frontend/src/metabase/lib/permissions.js index f41ef50e1dd5ea86d405b0e3a1ef80b0fe96d9e1..9654a90333aa7e3c1639c044db84d814241b7c65 100644 --- a/frontend/src/metabase/lib/permissions.js +++ b/frontend/src/metabase/lib/permissions.js @@ -160,12 +160,12 @@ export function downgradeNativePermissionsIfNeeded( currentSchemas === "all" && currentNative === "write" ) { - // if changing schemas to controlled, downgrade native to read + // if changing schemas to controlled, downgrade native to none return updateNativePermission( permissions, groupId, { databaseId }, - "read", + "none", metadata, ); } else { diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index 0cc85f1fa66cdc503679ee9341d3d1a7aa0ccc52..9c2dc73eb9e6e45b08200def6015e8e340d90a2a 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -74,7 +74,6 @@ export const CollectionsApi = { // Temporary route for getting things not in a collection getRoot: GET("/api/collection/root"), update: PUT("/api/collection/:id"), - delete: DELETE("/api/collection/:id"), graph: GET("/api/collection/graph"), updateGraph: PUT("/api/collection/graph"), }; diff --git a/frontend/test/__support__/integrated_tests.js b/frontend/test/__support__/integrated_tests.js index 7ef2bae0fbc4fbc3a31213511d4a7a071f1d8ee1..04f9a7895e7f2b56dccd804f35174ae84135e2f4 100644 --- a/frontend/test/__support__/integrated_tests.js +++ b/frontend/test/__support__/integrated_tests.js @@ -17,6 +17,8 @@ import { CardApi, MetricApi, SegmentApi, + CollectionsApi, + PermissionsApi, } from "metabase/services"; import { METABASE_SESSION_COOKIE } from "metabase/lib/cookies"; import normalReducers from "metabase/reducers-main"; @@ -96,11 +98,15 @@ export function useSharedNormalLogin() { id: process.env.TEST_FIXTURE_SHARED_NORMAL_LOGIN_SESSION_ID, }; } -export const forBothAdminsAndNormalUsers = async tests => { - useSharedAdminLogin(); - await tests(); - useSharedNormalLogin(); - await tests(); +export const forBothAdminsAndNormalUsers = tests => { + describe("for admins", () => { + beforeEach(useSharedAdminLogin); + tests(); + }); + describe("for normal users", () => { + beforeEach(useSharedNormalLogin); + tests(); + }); }; export function logout() { @@ -424,6 +430,22 @@ export const createDashboard = async details => { return savedDashboard; }; +// useful for tests where multiple users need access to the same questions +export async function createAllUsersWritableCollection() { + const group = _.findWhere(await PermissionsApi.groups(), { + name: "All Users", + }); + const collection = await CollectionsApi.create({ + name: "test" + Math.random(), + description: "description", + color: "#F1B556", + }); + const graph = await CollectionsApi.graph(); + graph.groups[group.id][collection.id] = "write"; + await CollectionsApi.updateGraph(graph); + return collection; +} + /** * Waits for a API request with a given method (GET/POST/PUT...) and a url which matches the given regural expression. * Useful in those relatively rare situations where React components do API requests inline instead of using Redux actions. diff --git a/frontend/test/admin/permissions/selectors.unit.spec.js b/frontend/test/admin/permissions/selectors.unit.spec.js index e31a8b0a5fd44e511baa3dd90ed5a4c78a5600dc..f6ea1018d88506d75f442719db325d46e20d9d10 100644 --- a/frontend/test/admin/permissions/selectors.unit.spec.js +++ b/frontend/test/admin/permissions/selectors.unit.spec.js @@ -210,7 +210,7 @@ describe("permissions selectors", () => { permission: "none", }); expect(schemalessDataset.getPermissions({ groupId: 1 })).toMatchObject({ - native: "read", + native: "none", schemas: { "": { "10": "none", @@ -246,16 +246,6 @@ describe("permissions selectors", () => { }); it("should restrict access correctly on db level", () => { - // Should let change the native permission to "read" - schemalessDataset.changeDbNativePermissions({ - groupId: 1, - permission: "read", - }); - expect(schemalessDataset.getPermissions({ groupId: 1 })).toMatchObject({ - native: "read", - schemas: "all", - }); - // Should not let change the native permission to none schemalessDataset.changeDbNativePermissions({ groupId: 1, @@ -321,10 +311,10 @@ describe("permissions selectors", () => { // Should pass changes to native permissions through schemalessDataset.changeDbNativePermissions({ groupId: 2, - permission: "read", + permission: "write", }); expect(schemalessDataset.getPermissions({ groupId: 2 })).toMatchObject({ - native: "read", + native: "write", schemas: "all", }); }); @@ -377,14 +367,14 @@ describe("permissions selectors", () => { }); it("should restrict access correctly on table level", () => { - // Revoking access to one table should downgrade the native permissions to "read" + // Revoking access to one table should downgrade the native permissions to "none" schema1.changeTablePermissions({ tableId: 5, groupId: 1, permission: "none", }); expect(schema1.getPermissions({ groupId: 1 })).toMatchObject({ - native: "read", + native: "none", schemas: { schema_1: { "5": "none", @@ -406,7 +396,7 @@ describe("permissions selectors", () => { permission: "none", }); expect(schema2.getPermissions({ groupId: 1 })).toMatchObject({ - native: "read", + native: "none", schemas: { schema_1: { "5": "none", @@ -428,7 +418,7 @@ describe("permissions selectors", () => { }); expect(schema1.getPermissions({ groupId: 1 })).toMatchObject({ - native: "read", + native: "none", schemas: { schema_1: "none", schema_2: { @@ -455,7 +445,7 @@ describe("permissions selectors", () => { // Revoking access to one schema schema2.changeSchemaPermissions({ groupId: 1, permission: "none" }); expect(schema2.getPermissions({ groupId: 1 })).toMatchObject({ - native: "read", + native: "none", schemas: { schema_1: "all", schema_2: "none", @@ -471,13 +461,6 @@ describe("permissions selectors", () => { }); it("should restrict access correctly on db level", () => { - // Should let change the native permission to "read" - schema1.changeDbNativePermissions({ groupId: 1, permission: "read" }); - expect(schema1.getPermissions({ groupId: 1 })).toMatchObject({ - native: "read", - schemas: "all", - }); - // Should let change the native permission to none schema1.changeDbNativePermissions({ groupId: 1, permission: "none" }); expect(schema1.getPermissions({ groupId: 1 })).toMatchObject({ @@ -573,9 +556,9 @@ describe("permissions selectors", () => { }); // Should pass changes to native permissions through - schema1.changeDbNativePermissions({ groupId: 2, permission: "read" }); + schema1.changeDbNativePermissions({ groupId: 2, permission: "write" }); expect(schema1.getPermissions({ groupId: 2 })).toMatchObject({ - native: "read", + native: "write", schemas: "all", }); }); diff --git a/frontend/test/alert/alert.integ.spec.js b/frontend/test/alert/alert.integ.spec.js index 0e3bd4805526bcfaf5cf82bf004bf81db8cb18fa..583bf029b1457a2576e44c351625ab68676e9c5f 100644 --- a/frontend/test/alert/alert.integ.spec.js +++ b/frontend/test/alert/alert.integ.spec.js @@ -1,6 +1,7 @@ import { createSavedQuestion, createTestStore, + createAllUsersWritableCollection, forBothAdminsAndNormalUsers, useSharedAdminLogin, useSharedNormalLogin, @@ -10,7 +11,13 @@ import { click, clickButton } from "__support__/enzyme_utils"; import { fetchTableMetadata } from "metabase/redux/metadata"; import { mount } from "enzyme"; import { setIn } from "icepick"; -import { AlertApi, CardApi, PulseApi, UserApi } from "metabase/services"; +import { + AlertApi, + CardApi, + PulseApi, + UserApi, + CollectionsApi, +} from "metabase/services"; import Question from "metabase-lib/lib/Question"; import * as Urls from "metabase/lib/urls"; import { INITIALIZE_QB, QUERY_COMPLETED } from "metabase/query_builder/actions"; @@ -81,6 +88,7 @@ const initQbWithAlertMenuItemClicked = async ( }; describe("Alerts", () => { + let collection = null; let rawDataQuestion = null; let timeSeriesQuestion = null; let timeSeriesWithGoalQuestion = null; @@ -92,6 +100,9 @@ describe("Alerts", () => { const store = await createTestStore(); + // create a collection which all users have write permissions in + collection = await createAllUsersWritableCollection(); + // table metadata is needed for `Question.alertType()` calls await store.dispatch(fetchTableMetadata(1)); const metadata = getMetadata(store.getState()); @@ -101,7 +112,8 @@ describe("Alerts", () => { .query() .addFilter(["=", ["field-id", 4], 123456]) .question() - .setDisplayName("Just raw, untamed data"), + .setDisplayName("Just raw, untamed data") + .setCollectionId(collection.id), ); timeSeriesQuestion = await createSavedQuestion( @@ -115,7 +127,8 @@ describe("Alerts", () => { "graph.dimensions": ["CREATED_AT"], "graph.metrics": ["count"], }) - .setDisplayName("Time series line"), + .setDisplayName("Time series line") + .setCollectionId(collection.id), ); timeSeriesWithGoalQuestion = await createSavedQuestion( @@ -131,7 +144,8 @@ describe("Alerts", () => { "graph.dimensions": ["CREATED_AT"], "graph.metrics": ["count"], }) - .setDisplayName("Time series line with goal"), + .setDisplayName("Time series line with goal") + .setCollectionId(collection.id), ); timeMultiSeriesWithGoalQuestion = await createSavedQuestion( @@ -148,8 +162,10 @@ describe("Alerts", () => { "graph.dimensions": ["CREATED_AT"], "graph.metrics": ["count", "sum"], }) - .setDisplayName("Time multiseries line with goal"), + .setDisplayName("Time multiseries line with goal") + .setCollectionId(collection.id), ); + progressBarQuestion = await createSavedQuestion( Question.create({ databaseId: 1, tableId: 1, metadata }) .query() @@ -157,7 +173,8 @@ describe("Alerts", () => { .question() .setDisplay("progress") .setVisualizationSettings({ "progress.goal": 50 }) - .setDisplayName("Progress bar question"), + .setDisplayName("Progress bar question") + .setCollectionId(collection.id), ); }); @@ -167,11 +184,12 @@ describe("Alerts", () => { await CardApi.delete({ cardId: timeSeriesWithGoalQuestion.id() }); await CardApi.delete({ cardId: timeMultiSeriesWithGoalQuestion.id() }); await CardApi.delete({ cardId: progressBarQuestion.id() }); + await CollectionsApi.update({ id: collection.id, archived: true }); }); describe("missing email/slack credentials", () => { - it("should prompt you to add email/slack credentials", async () => { - await forBothAdminsAndNormalUsers(async () => { + forBothAdminsAndNormalUsers(() => { + it("should prompt you to add email/slack credentials", async () => { MetabaseCookies.getHasSeenAlertSplash = () => false; const store = await createTestStore(); @@ -297,21 +315,19 @@ describe("Alerts", () => { }); it("should show you the first time educational screen", async () => { - await forBothAdminsAndNormalUsers(async () => { - useSharedNormalLogin(); - const { app, store } = await initQbWithAlertMenuItemClicked( - rawDataQuestion, - { hasSeenAlertSplash: false }, - ); + useSharedNormalLogin(); + const { app, store } = await initQbWithAlertMenuItemClicked( + rawDataQuestion, + { hasSeenAlertSplash: false }, + ); - await store.waitForActions([FETCH_PULSE_FORM_INPUT]); - const alertModal = app.find(QueryHeader).find(".test-modal"); - const educationalScreen = alertModal.find(AlertEducationalScreen); + await store.waitForActions([FETCH_PULSE_FORM_INPUT]); + const alertModal = app.find(QueryHeader).find(".test-modal"); + const educationalScreen = alertModal.find(AlertEducationalScreen); - clickButton(educationalScreen.find(Button)); - const creationScreen = alertModal.find(CreateAlertModalContent); - expect(creationScreen.length).toBe(1); - }); + clickButton(educationalScreen.find(Button)); + const creationScreen = alertModal.find(CreateAlertModalContent); + expect(creationScreen.length).toBe(1); }); it("should support 'rows present' alert for raw data questions", async () => { diff --git a/frontend/test/query_builder/new_question.integ.spec.js b/frontend/test/query_builder/new_question.integ.spec.js index f114a45a3e73384c54245d64d614a54e13e560a5..e1269a3b1d3fb838133ab5f37512ea761983d655 100644 --- a/frontend/test/query_builder/new_question.integ.spec.js +++ b/frontend/test/query_builder/new_question.integ.spec.js @@ -86,8 +86,8 @@ describe("new question flow", async () => { expect(store.getPath()).toBe("/question/new"); }); - it("renders all options for both admins and normal users if metrics & segments exist", async () => { - await forBothAdminsAndNormalUsers(async () => { + forBothAdminsAndNormalUsers(() => { + it("renders all options for both admins and normal users if metrics & segments exist", async () => { const store = await createTestStore(); store.pushPath(Urls.newQuestion()); @@ -181,8 +181,8 @@ describe("new question flow", async () => { ); }); - it("shows an empty state if there are no databases", async () => { - await forBothAdminsAndNormalUsers(async () => { + forBothAdminsAndNormalUsers(() => { + it("shows an empty state if there are no databases", async () => { await withApiMocks([[Databases.api, "list", () => []]], async () => { const store = await createTestStore(); diff --git a/resources/migrations/000_migrations.yaml b/resources/migrations/000_migrations.yaml index 979da36f3ba767160b87a197ffe95cb01d4359ac..39829a45971c9ced92a934d927fa05dbaa678620 100644 --- a/resources/migrations/000_migrations.yaml +++ b/resources/migrations/000_migrations.yaml @@ -4068,6 +4068,17 @@ databaseChangeLog: name: fields_hash type: text remarks: 'Computed hash of all of the fields associated to this table' + - changeSet: + id: 77 + author: senior + comment: 'Added 0.30.0' + changes: + - addColumn: + tableName: core_user + columns: + name: login_attributes + type: text + remarks: 'JSON serialized map with attributes used for row level permissions' - changeSet: id: 79 author: camsaul diff --git a/src/metabase/api/activity.clj b/src/metabase/api/activity.clj index 9d5540d51309a494357e151a75e2ffc3e37c479e..a495a61934844f92d1ef26b383d5ca4bc459209b 100644 --- a/src/metabase/api/activity.clj +++ b/src/metabase/api/activity.clj @@ -57,7 +57,8 @@ activity (update-in activity [:details :dashcards] (fn [dashcards] (for [dashcard dashcards] - (assoc dashcard :exists (contains? (get existing-objects "card") (:card_id dashcard))))))))))) + (assoc dashcard :exists (contains? (get existing-objects "card") + (:card_id dashcard))))))))))) (defendpoint GET "/" "Get recent activity." @@ -66,6 +67,13 @@ (hydrate :user :table :database) add-model-exists-info))) +(defn- view-log-entry->matching-object [{:keys [model model_id]}] + (when (contains? #{"card" "dashboard"} model) + (db/select-one + (case model + "card" [Card :id :name :collection_id :description :display :dataset_query] + "dashboard" [Dashboard :id :name :collection_id :description]) + :id model_id))) (defendpoint GET "/recent_views" "Get the list of 10 things the current user has been viewing most recently." @@ -78,10 +86,7 @@ {:group-by [:user_id :model :model_id] :order-by [[:max_ts :desc]] :limit 10}) - :let [model-object (case (:model view-log) - "card" (db/select-one [Card :id :name :description :display :dataset_query], :id (:model_id view-log)) - "dashboard" (db/select-one [Dashboard :id :name :description], :id (:model_id view-log)) - nil)] + :let [model-object (view-log-entry->matching-object view-log)] :when (and model-object (mi/can-read? model-object))] (assoc view-log :model_object (dissoc model-object :dataset_query)))) diff --git a/src/metabase/api/alert.clj b/src/metabase/api/alert.clj index f93660ab41045f00c0078c463cc2b8ee8571404a..7b4bdce009c49737e83db5abde75c61f839e08c7 100644 --- a/src/metabase/api/alert.clj +++ b/src/metabase/api/alert.clj @@ -7,12 +7,10 @@ [email :as email] [events :as events] [util :as u]] - [metabase.api - [common :as api] - [pulse :as pulse-api]] + [metabase.api.common :as api] [metabase.email.messages :as messages] [metabase.models - [collection :as collection] + [card :refer [Card]] [interface :as mi] [pulse :as pulse :refer [Pulse]]] [metabase.util.schema :as su] @@ -41,7 +39,7 @@ (defn- only-alert-keys [request] (u/select-keys-when request - :present [:alert_condition :alert_first_only :alert_above_goal :collection_id :collection_position])) + :present [:alert_condition :alert_first_only :alert_above_goal])) (defn- email-channel [alert] (m/find-first #(= :email (:channel_type %)) (:channels alert))) @@ -113,18 +111,16 @@ (api/defendpoint POST "/" "Create a new Alert." - [:as {{:keys [alert_condition card channels alert_first_only alert_above_goal collection_id collection_position] + [:as {{:keys [alert_condition card channels alert_first_only alert_above_goal] :as new-alert-request-body} :body}] {alert_condition pulse/AlertConditions alert_first_only s/Bool alert_above_goal (s/maybe s/Bool) card pulse/CardRef - channels (su/non-empty [su/Map]) - collection_id (s/maybe su/IntGreaterThanZero) - collection_position (s/maybe su/IntGreaterThanZero)} - ;; do various perms checks as needed - (pulse-api/check-card-read-permissions [card]) - (collection/check-write-perms-for-collection collection_id) + channels (su/non-empty [su/Map])} + ;; do various perms checks as needed. Perms for an Alert == perms for its Card. So to create an Alert you need write + ;; perms for its Card + (api/write-check Card (u/get-id card)) ;; ok, now create the Alert (let [alert-card (-> card (maybe-include-csv alert_condition) pulse/card->ref) new-alert (api/check-500 @@ -138,20 +134,21 @@ (api/defendpoint PUT "/:id" "Update a `Alert` with ID." - [id :as {{:keys [alert_condition card channels alert_first_only alert_above_goal card channels collection_id - collection_position] :as alert-updates} :body}] + [id :as {{:keys [alert_condition card channels alert_first_only alert_above_goal card channels] + :as alert-updates} :body}] {alert_condition (s/maybe pulse/AlertConditions) alert_first_only (s/maybe s/Bool) alert_above_goal (s/maybe s/Bool) card (s/maybe pulse/CardRef) - channels (s/maybe (su/non-empty [su/Map])) - collection_id (s/maybe su/IntGreaterThanZero) - collection_position (s/maybe su/IntGreaterThanZero)} + channels (s/maybe (su/non-empty [su/Map]))} ;; fethc the existing Alert in the DB (let [alert-before-update (api/check-404 (pulse/retrieve-alert id))] - ;; check permissions as needed - (api/write-check alert-before-update) - (collection/check-allowed-to-change-collection alert-before-update collection_id) + ;; check permissions as needed. + ;; Check permissions to update existing Card + (api/write-check Card (u/get-id (:card alert-before-update))) + ;; if trying to change the card, check perms for that as well + (when card + (api/write-check Card (u/get-id card))) ;; now update the Alert (let [updated-alert (pulse/update-alert! (merge diff --git a/src/metabase/api/automagic_dashboards.clj b/src/metabase/api/automagic_dashboards.clj index 870758ea156d844463fe08ded0a40f9acc60a59e..7c5709a4d4b6cebaca209cb9fb1579da044aefbb 100644 --- a/src/metabase/api/automagic_dashboards.clj +++ b/src/metabase/api/automagic_dashboards.clj @@ -245,7 +245,7 @@ (-> (Dashboard dashboard) api/check-404 (hydrate [:ordered_cards - [:card :in_public_dashboard] + :card :series])) dashboard) (->segment left) diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index f7a38b24cee8442293eeb77be53dd7d66635e2cf..94d40ab1528d625b37824f12b024779d62e7b408 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -25,6 +25,7 @@ [query :as query] [table :refer [Table]] [view-log :refer [ViewLog]]] + [metabase.models.query.permissions :as query-perms] [metabase.query-processor [interface :as qpi] [util :as qputil]] @@ -32,6 +33,7 @@ [cache :as cache] [results-metadata :as results-metadata]] [metabase.util.schema :as su] + [puppetlabs.i18n.core :refer [trs]] [ring.util.codec :as codec] [schema.core :as s] [toucan @@ -142,7 +144,7 @@ ;; TODO - do we need to hydrate the cards' collections as well? (defn- cards-for-filter-option [filter-option model-id collection-slug] (let [cards (-> ((filter-option->fn (or filter-option :all)) model-id) - (hydrate :creator :collection :in_public_dashboard) + (hydrate :creator :collection) hydrate-favorites)] ;; Since Collections are hydrated in Clojure-land we need to wait until this point to apply Collection filtering. ;; If applicable, `collection` can optionally be an empty string which is used to represent the Root Collection @@ -190,7 +192,7 @@ "Get `Card` with ID." [id] (u/prog1 (-> (Card id) - (hydrate :creator :dashboard_count :can_write :collection :in_public_dashboard) + (hydrate :creator :dashboard_count :can_write :collection) api/read-check) (events/publish-event! :card-read (assoc <> :actor_id api/*current-user-id*)))) @@ -208,7 +210,11 @@ This is obviously a bit wasteful so hopefully we can avoid having to do this." [query] (binding [qpi/*disable-qp-logging* true] - (get-in (qp/process-query query) [:data :results_metadata :columns]))) + (let [{:keys [status], :as results} (qp/process-query query)] + (if (= status :failed) + (log/error (trs "Error running query to determine Card result metadata:") + (u/pprint-to-str 'red results)) + (get-in results [:data :results_metadata :columns]))))) (s/defn ^:private result-metadata :- (s/maybe results-metadata/ResultsMetadata) "Get the right results metadata for this CARD. We'll check to see whether the METADATA passed in seems valid; @@ -239,10 +245,9 @@ metadata_checksum (s/maybe su/NonBlankString)} ;; check that we have permissions to run the query that we're trying to save (api/check-403 (perms/set-has-full-permissions-for-set? @api/*current-user-permissions-set* - (card/query-perms-set dataset_query :write))) + (query-perms/perms-set dataset_query))) ;; check that we have permissions for the collection we're trying to save this card to, if applicable - (when collection_id - (collection/check-write-perms-for-collection collection_id)) + (collection/check-write-perms-for-collection collection_id) ;; everything is g2g, now save the card (let [card (db/insert! Card :creator_id api/*current-user-id* @@ -267,7 +272,7 @@ [query] {:pre [(map? query)]} (api/check-403 (perms/set-has-full-permissions-for-set? @api/*current-user-permissions-set* - (card/query-perms-set query :read)))) + (query-perms/perms-set query)))) (defn- check-allowed-to-modify-query "If the query is being modified, check that we have data permissions to run the query." @@ -302,7 +307,6 @@ [card query metadata checksum] (when (and query (not= query (:dataset_query card))) - (result-metadata query metadata checksum))) (defn- publish-card-update! @@ -418,7 +422,7 @@ metadata_checksum (s/maybe su/NonBlankString)} (let [card-before-update (api/write-check Card id)] ;; Do various permissions checks - (collection/check-allowed-to-change-collection card-before-update collection_id) + (collection/check-allowed-to-change-collection card-before-update card-updates) (check-allowed-to-modify-query card-before-update dataset_query) (check-allowed-to-unarchive card-before-update archived) (check-allowed-to-change-embedding card-before-update enable_embedding embedding_params) @@ -540,7 +544,7 @@ :or {constraints qp/default-query-constraints context :question}}] {:pre [(u/maybe? sequential? parameters)]} - (let [card (api/read-check (hydrate (Card card-id) :in_public_dashboard)) + (let [card (api/read-check (Card card-id)) query (query-for-card card parameters constraints) options {:executed-by api/*current-user-id* :context context diff --git a/src/metabase/api/collection.clj b/src/metabase/api/collection.clj index 2a8c2cb2c7c77326bfca84bb42f1fb74dd6ec79e..7a0b30f89f805d2eeb508ca251fc5ac76bae92c5 100644 --- a/src/metabase/api/collection.clj +++ b/src/metabase/api/collection.clj @@ -9,6 +9,7 @@ [collection :as collection :refer [Collection]] [dashboard :refer [Dashboard]] [interface :as mi] + [permissions :as perms] [pulse :as pulse :refer [Pulse]]] [metabase.util :as u] [metabase.util.schema :as su] @@ -36,47 +37,28 @@ ;;; --------------------------------- Fetching a single Collection & its 'children' ---------------------------------- +(def ^:private model->collection-children-fn + "Functions for fetching the 'children' of a Collection. Each function takes `collection-id` as a param." + {:cards #(db/select [Card :name :id :collection_position], :collection_id %, :archived false) + :dashboards #(db/select [Dashboard :name :id :collection_position], :collection_id %, :archived false) + :pulses #(db/select [Pulse :name :id :collection_position], :collection_id %, :alert_condition nil)}) ; exclude Alerts + (defn- collection-children "Fetch a map of the 'child' objects belonging to a Collection of type `model`, or of all available types if `model` is - nil. `model->children-fn` should be a map of the different types of children that can be included to a function used - to fetch them. Optional `children-fn-params` will be passed to each children-fetching fn. + `nil`. (collection-children :cards model->collection-children-fn 1) ;; -> {:cards [...cards for Collection 1...]} (collection-children nil model->collection-children-fn 1) ;; -> {:cards [...], :dashboards [...], :pulses [...]}" - [model model->children-fn & children-fn-params] - (into {} (for [[a-model children-fn] model->children-fn + [model collection-id] + (into {} (for [[a-model children-fn] model->collection-children-fn ;; only fetch models that are specified by the `model` param; or everything if it's `nil` :when (or (nil? model) (= (name model) (name a-model)))] ;; return the results like {:card <results-of-card-children-fn>} - {a-model (apply children-fn children-fn-params)}))) - -(def ^:private model->collection-children-fn - "Functions for fetching the 'children' of a Collection." - {:cards #(db/select [Card :name :id :collection_position], :collection_id %, :archived false) - :dashboards #(db/select [Dashboard :name :id :collection_position], :collection_id %, :archived false) - :pulses #(db/select [Pulse :name :id :collection_position], :collection_id %)}) - -(def ^:private model->root-collection-children-fn - "Functions for fetching the 'children' of the root Collection." - (let [basic-item-info (fn [items] - (for [item items] - (select-keys item [:name :id :collection_position])))] - {:cards #(->> (db/select [Card :name :id :public_uuid :read_permissions :dataset_query :collection_position] - :collection_id nil, :archived false) - (filter mi/can-read?) - basic-item-info) - :dashboards #(->> (db/select [Dashboard :name :id :public_uuid :collection_position] - :collection_id nil, :archived false) - (filter mi/can-read?) - basic-item-info) - :pulses #(->> (db/select [Pulse :name :id :collection_position] - :collection_id nil) - (filter mi/can-read?) - basic-item-info)})) + {a-model (children-fn collection-id)}))) (api/defendpoint GET "/:id" "Fetch a specific (non-archived) Collection, including objects of a specific `model` that belong to it. If `model` is @@ -85,22 +67,45 @@ {model (s/maybe (s/enum "cards" "dashboards" "pulses"))} (-> (api/read-check Collection id, :archived false) (hydrate :effective_location :effective_children :effective_ancestors :can_write) - (merge (collection-children model model->collection-children-fn id)))) + (merge (collection-children model id)))) + +(defn- current-user-has-root-collection-read-perms? [] + (perms/set-has-full-permissions? @api/*current-user-permissions-set* + (perms/collection-read-path collection/root-collection))) + +(defn- current-user-has-root-collection-write-perms? [] + (perms/set-has-full-permissions? @api/*current-user-permissions-set* + (perms/collection-readwrite-path collection/root-collection))) (api/defendpoint GET "/root" - "Fetch objects in the 'root' Collection. (The 'root' Collection doesn't actually exist at this point, so this just - returns objects that aren't in *any* Collection." + "Fetch objects that the current user should see at their root level. As mentioned elsewhere, the 'Root' Collection + doesn't actually exist as a row in the application DB: it's simply a virtual Collection where things with no + `collection_id` exist. It does, however, have its own set of Permissions. + + This endpoint will actually show objects with no `collection_id` for Users that have Root Collection + permissions, but for people without Root Collection perms, we'll just show the objects that have an effective + location of `/`. + + This endpoint is intended to power a 'Root Folder View' for the Current User, so regardless you'll see all the + top-level objects you're allowed to access." [model] {model (s/maybe (s/enum "cards" "dashboards" "pulses"))} (merge {:name (tru "Root Collection") :id "root" - :can_write api/*is-superuser?* ; temporary until Root Collection perms are merged ! - :effective_location "/" - :effective_children (collection/effective-children collection/root-collection) - :effective_ancestors []} - (collection-children model model->root-collection-children-fn))) + :can_write (current-user-has-root-collection-write-perms?) + :effective_location nil + :effective_ancestors [] + ;; anybody gets to see other Collections that have an Effective Location of being in the Root Collection. + :effective_children (collection/effective-children collection/root-collection)} + ;; Only people with Root Collection Read Permissions get to see objects that have no `collection_id`. + (if (current-user-has-root-collection-read-perms?) + (collection-children model nil) + ;; for people who can't see the loose items in the Root Collection just return empty arrays to avoid confusion + {:cards [] + :dashboards [] + :pulses []}))) ;;; ----------------------------------------- Creating/Editing a Collection ------------------------------------------ @@ -191,7 +196,10 @@ (defn- dejsonify-collections [collections] (into {} (for [[collection-id perms] collections] - {(->int collection-id) (keyword perms)}))) + [(if (= (keyword collection-id) :root) + :root + (->int collection-id)) + (keyword perms)]))) (defn- dejsonify-groups [groups] (into {} (for [[group-id collections] groups] diff --git a/src/metabase/api/dashboard.clj b/src/metabase/api/dashboard.clj index 867bca877bae42c1a377b52aa7302891e8de155e..3271bba9580617a121f39541fb477fb3640b9d5e 100644 --- a/src/metabase/api/dashboard.clj +++ b/src/metabase/api/dashboard.clj @@ -191,7 +191,7 @@ [id] (u/prog1 (-> (Dashboard id) api/check-404 - (hydrate [:ordered_cards [:card :in_public_dashboard] :series]) + (hydrate [:ordered_cards :card :series]) api/read-check api/check-not-archived hide-unreadable-cards @@ -218,7 +218,7 @@ superuser." [id :as {{:keys [description name parameters caveats points_of_interest show_in_getting_started enable_embedding embedding_params position archived collection_id collection_position] - :as dashboard-updates} :body}] + :as dash-updates} :body}] {name (s/maybe su/NonBlankString) description (s/maybe s/Str) caveats (s/maybe s/Str) @@ -233,13 +233,13 @@ collection_position (s/maybe su/IntGreaterThanZero)} (let [dash-before-update (api/write-check Dashboard id)] ;; Do various permissions checks as needed - (collection/check-allowed-to-change-collection dash-before-update collection_id) + (collection/check-allowed-to-change-collection dash-before-update dash-updates) (check-allowed-to-change-embedding dash-before-update enable_embedding embedding_params)) (api/check-500 (db/update! Dashboard id ;; description, position, collection_id, and collection_position are allowed to be `nil`. Everything else must be ;; non-nil - (u/select-keys-when dashboard-updates + (u/select-keys-when dash-updates :present #{:description :position :collection_id :collection_position} :non-nil #{:name :parameters :caveats :points_of_interest :show_in_getting_started :enable_embedding :embedding_params :archived}))) diff --git a/src/metabase/api/database.clj b/src/metabase/api/database.clj index 6933cdd89eeadeee7bc42f81b6fb0ad4fa6f417e..64f7ab10289aedc98466614d856f68f5a149e22a 100644 --- a/src/metabase/api/database.clj +++ b/src/metabase/api/database.clj @@ -51,18 +51,21 @@ (for [db dbs] (assoc db :tables (get db-id->tables (:id db) []))))) -(defn- add-native-perms-info +(s/defn ^:private add-native-perms-info :- [{:native_permissions (s/enum :write :none), s/Keyword s/Any}] "For each database in DBS add a `:native_permissions` field describing the current user's permissions for running - native (e.g. SQL) queries. Will be one of `:write`, `:read`, or `:none`." - [dbs] + native (e.g. SQL) queries. Will be either `:write` or `:none`. `:write` means you can run ad-hoc native queries, + and save new Cards with native queries; `:none` means you can do neither. + + For the curious: the use of `:write` and `:none` is mainly for legacy purposes, when we had data-access-based + permissions; there was a specific option where you could give a Perms Group permissions to run existing Cards with + native queries, but not to create new ones. With the advent of what is currently being called 'Space-Age + Permissions', all Cards' permissions are based on their parent Collection, removing the need for native read perms." + [dbs :- [su/Map]] (for [db dbs] - (let [user-has-perms? (fn [path-fn] - (perms/set-has-full-permissions? @api/*current-user-permissions-set* - (path-fn (u/get-id db))))] - (assoc db :native_permissions (cond - (user-has-perms? perms/native-readwrite-path) :write - (user-has-perms? perms/native-read-path) :read - :else :none))))) + (assoc db :native_permissions (if (perms/set-has-full-permissions? @api/*current-user-permissions-set* + (perms/adhoc-native-query-path (u/get-id db))) + :write + :none)))) (defn- card-database-supports-nested-queries? [{{database-id :database} :dataset_query, :as card}] (when database-id @@ -106,8 +109,7 @@ (defn- source-query-cards "Fetch the Cards that can be used as source queries (e.g. presented as virtual tables)." [] - (as-> (db/select [Card :name :description :database_id :dataset_query :id :collection_id :result_metadata - :read_permissions] + (as-> (db/select [Card :name :description :database_id :dataset_query :id :collection_id :result_metadata] :result_metadata [:not= nil] :archived false {:order-by [[:%lower.name :asc]]}) <> (filter card-database-supports-nested-queries? <>) diff --git a/src/metabase/api/pulse.clj b/src/metabase/api/pulse.clj index df7efdefe1dd75579dd96f5b6b83d4e27b19d4b3..b4d455bda175f0badbe364247cedac20362ff523 100644 --- a/src/metabase/api/pulse.clj +++ b/src/metabase/api/pulse.clj @@ -80,10 +80,9 @@ skip_if_empty (s/maybe s/Bool) collection_id (s/maybe su/IntGreaterThanZero)} ;; do various perms checks - (api/write-check Pulse id) - (check-card-read-permissions cards) - (when collection_id - (collection/check-allowed-to-change-collection (db/select-one [Pulse :collection_id] :id id) collection_id)) + (let [pulse-before-update (api/write-check Pulse id)] + (check-card-read-permissions cards) + (collection/check-allowed-to-change-collection pulse-before-update pulse-updates)) ;; ok, now update the Pulse (pulse/update-pulse! (assoc (select-keys pulse-updates [:name :cards :channels :skip_if_empty :collection_id :collection_position]) diff --git a/src/metabase/api/session.clj b/src/metabase/api/session.clj index bee43518044d31c323e328347be755e2029c1146..eeb578f5373dbf6dd45f341d76898ec30725beb7 100644 --- a/src/metabase/api/session.clj +++ b/src/metabase/api/session.clj @@ -209,16 +209,19 @@ (throw (ex-info (tru "You''ll need an administrator to create a Metabase account before you can use Google to log in.") {:status-code 428})))) -(defn- google-auth-create-new-user! [first-name last-name email] +(s/defn ^:private google-auth-create-new-user! + [{:keys [email] :as new-user} :- user/NewUser] (check-autocreate-user-allowed-for-email email) ;; this will just give the user a random password; they can go reset it if they ever change their mind and want to ;; log in without Google Auth; this lets us keep the NOT NULL constraints on password / salt without having to make ;; things hairy and only enforce those for non-Google Auth users - (user/create-new-google-auth-user! first-name last-name email)) + (user/create-new-google-auth-user! new-user)) (defn- google-auth-fetch-or-create-user! [first-name last-name email] (if-let [user (or (db/select-one [User :id :last_login] :email email) - (google-auth-create-new-user! first-name last-name email))] + (google-auth-create-new-user! {:first_name first-name + :last_name last-name + :email email}))] {:id (create-session! user)})) (api/defendpoint POST "/google_auth" diff --git a/src/metabase/api/table.clj b/src/metabase/api/table.clj index bcab5a45cf9dbb1320d354c66985aafe7dc01b59..f0e2310c04c68d212630c9fc99d5ad1d988e8977 100644 --- a/src/metabase/api/table.clj +++ b/src/metabase/api/table.clj @@ -258,7 +258,7 @@ 'virtual' fields as well." [{:keys [database_id] :as card} & {:keys [include-fields?]}] ;; if collection isn't already hydrated then do so - (let [card (hydrate card :colllection)] + (let [card (hydrate card :collection)] (cond-> {:id (str "card__" (u/get-id card)) :db_id database/virtual-id :display_name (:name card) diff --git a/src/metabase/api/user.clj b/src/metabase/api/user.clj index 61efa430e11def0d21b4b5a61c1ec6b35cbdaa85..dd9c083c8844ff3e567523856577934d30d88f65 100644 --- a/src/metabase/api/user.clj +++ b/src/metabase/api/user.clj @@ -9,6 +9,7 @@ [metabase.models.user :as user :refer [User]] [metabase.util :as u] [metabase.util.schema :as su] + [puppetlabs.i18n.core :refer [tru]] [schema.core :as s] [toucan.db :as db])) @@ -53,14 +54,16 @@ (api/defendpoint POST "/" "Create a new `User`, return a 400 if the email address is already taken" - [:as {{:keys [first_name last_name email password]} :body}] - {first_name su/NonBlankString - last_name su/NonBlankString - email su/Email} + [:as {{:keys [first_name last_name email password login_attributes] :as body} :body}] + {first_name su/NonBlankString + last_name su/NonBlankString + email su/Email + login_attributes (s/maybe user/LoginAttributes)} (api/check-superuser) (api/checkp (not (db/exists? User :email email)) - "email" "Email address already in use.") - (let [new-user-id (u/get-id (user/invite-user! first_name last_name email password @api/*current-user*))] + "email" (tru "Email address already in use.")) + (let [new-user-id (u/get-id (user/invite-user! (select-keys body [:first_name :last_name :email :password :login_attributes]) + @api/*current-user*))] (fetch-user :id new-user-id))) (api/defendpoint GET "/current" @@ -78,22 +81,24 @@ (api/defendpoint PUT "/:id" "Update an existing, active `User`." - [id :as {{:keys [email first_name last_name is_superuser]} :body}] - {email su/Email - first_name (s/maybe su/NonBlankString) - last_name (s/maybe su/NonBlankString)} + [id :as {{:keys [email first_name last_name is_superuser login_attributes] :as body} :body}] + {email (s/maybe su/Email) + first_name (s/maybe su/NonBlankString) + last_name (s/maybe su/NonBlankString) + login_attributes (s/maybe user/LoginAttributes)} (check-self-or-superuser id) ;; only allow updates if the specified account is active (api/check-404 (db/exists? User, :id id, :is_active true)) ;; can't change email if it's already taken BY ANOTHER ACCOUNT (api/checkp (not (db/exists? User, :email email, :id [:not= id])) - "email" "Email address already associated to another user.") - (api/check-500 (db/update-non-nil-keys! User id - :email email - :first_name first_name - :last_name last_name - :is_superuser (when (:is_superuser @api/*current-user*) - is_superuser))) + "email" (tru "Email address already associated to another user.")) + (api/check-500 + (db/update! User id + (u/select-keys-when body + :present #{:login_attributes} + :non-nil (set (concat [:first_name :last_name :email] + (when (:is_superuser @api/*current-user*) + [:is_superuser])))))) (fetch-user :id id)) (api/defendpoint PUT "/:id/reactivate" @@ -104,7 +109,7 @@ (api/check-404 user) ;; Can only reactivate inactive users (api/check (not (:is_active user)) - [400 {:message "Not able to reactivate an active user"}]) + [400 {:message (tru "Not able to reactivate an active user")}]) (reactivate-user! user))) @@ -119,7 +124,7 @@ (when-not (:is_superuser @api/*current-user*) (api/checkp (creds/bcrypt-verify (str (:password_salt user) old_password) (:password user)) "old_password" - "Invalid password"))) + (tru "Invalid password")))) (user/set-password! id password) ;; return the updated User (fetch-user :id id)) diff --git a/src/metabase/db/migrations.clj b/src/metabase/db/migrations.clj index d7c868dd5858b66958e31139e6be62e676c21adb..af61bae2b3ebc3ddbbfd1a1e254b4b85c02fcfd3 100644 --- a/src/metabase/db/migrations.clj +++ b/src/metabase/db/migrations.clj @@ -368,40 +368,6 @@ ;; use `simple-delete!` because `Setting` doesn't have an `:id` column :( (db/simple-delete! Setting {:key "enable-advanced-humanization"}))) -;; for every Card in the DB, pre-calculate the read permissions required to read the Card/run its query and save them -;; under the new `read_permissions` column. Calculating read permissions is too expensive to do on the fly for Cards, -;; since it requires parsing their queries and expanding things like FKs or Segment/Metric macros. Simply calling -;; `update!` on each Card will cause it to be saved with updated `read_permissions` as a side effect of Card's -;; `pre-update` implementation. -;; -;; Caching these permissions will prevent 1000+ DB call API calls. See https://github.com/metabase/metabase/issues/6889 -;; -;; NOTE: This used used to be -;; (defmigration ^{:author "camsaul", :added "0.28.2"} populate-card-read-permissions -;; (run! -;; (fn [card] -;; (db/update! Card (u/get-id card) {})) -;; (db/select-reducible Card :archived false, :read_permissions nil))) -;; But due to bug https://github.com/metabase/metabase/issues/7189 was replaced -(defmigration ^{:author "camsaul", :added "0.28.2"} populate-card-read-permissions - (log/info "Not running migration `populate-card-read-permissions` as it has been replaced by a subsequent migration ")) - -;; Migration from 0.28.2 above had a flaw in that passing in `{}` to the update results in -;; the functions that do pre-insert permissions checking don't have the query dictionary to analyze -;; and always short-circuit due to the missing query dictionary. Passing the card itself into the -;; check mimicks how this works in-app, and appears to fix things. -(defmigration ^{:author "salsakran", :added "0.28.3"} repopulate-card-read-permissions - (run! - (fn [card] - (try - (db/update! Card (u/get-id card) card) - (catch Throwable e - (log/error "Error updating Card to set its read_permissions:" - (class e) - (.getMessage e) - (u/filtered-stacktrace e))))) - (db/select-reducible Card :archived false))) - ;; Starting in version 0.29.0 we switched the way we decide which Fields should get FieldValues. Prior to 29, Fields ;; would be marked as special type Category if they should have FieldValues. In 29+, the Category special type no ;; longer has any meaning as far as the backend is concerned. Instead, we use the new `has_field_values` column to diff --git a/src/metabase/integrations/ldap.clj b/src/metabase/integrations/ldap.clj index 6f4541f3f2c34b680394c70db88068ac3df7ba1a..b3987b00f867468275977b9f96130f15efa2320d 100644 --- a/src/metabase/integrations/ldap.clj +++ b/src/metabase/integrations/ldap.clj @@ -209,7 +209,10 @@ "Using the `user-info` (from `find-user`) get the corresponding Metabase user, creating it if necessary." [{:keys [first-name last-name email groups]} password] (let [user (or (db/select-one [User :id :last_login] :email email) - (user/create-new-ldap-auth-user! first-name last-name email password))] + (user/create-new-ldap-auth-user! {:first_name first-name + :last_name last-name + :email email + :password password}))] (u/prog1 user (when password (user/set-password! (:id user) password)) diff --git a/src/metabase/models/card.clj b/src/metabase/models/card.clj index 76de04ff029e6cf505dd51a186616e759ba36801..befccd05a4b671c739b2a5cd0112da735eb0ee8f 100644 --- a/src/metabase/models/card.clj +++ b/src/metabase/models/card.clj @@ -5,11 +5,9 @@ [clojure.tools.logging :as log] [metabase [public-settings :as public-settings] - [query-processor :as qp] [util :as u]] - [metabase.api.common :as api :refer [*current-user-id*]] + [metabase.api.common :as api :refer [*current-user-id* *current-user-permissions-set*]] [metabase.models - [collection :as collection] [dependency :as dependency] [field-values :as field-values] [interface :as i] @@ -17,9 +15,9 @@ [permissions :as perms] [query :as query] [revision :as revision]] - [metabase.query-processor.middleware.permissions :as qp-perms] - [metabase.query-processor.util :as qputil] + [metabase.models.query.permissions :as query-perms] [metabase.util.query :as q] + [metabase.query-processor.util :as qputil] [puppetlabs.i18n.core :refer [tru]] [toucan [db :as db] @@ -35,208 +33,6 @@ [{:keys [id]}] (db/count 'DashboardCard, :card_id id)) -(defn with-in-public-dashboard - "Efficiently add a `:in_public_dashboard` key to each item in a sequence of `cards`. This boolean key predictably - represents whether the Card in question is a member of one or more public Dashboards, which can be important in - determining its permissions. This will always be `false` if public sharing is disabled." - {:batched-hydrate :in_public_dashboard} - [cards] - (let [card-ids (set (filter some? (map :id cards))) - public-dashboard-card-ids (when (and (seq card-ids) - (public-settings/enable-public-sharing)) - (->> (db/query {:select [[:c.id :id]] - :from [[:report_card :c]] - :left-join [[:report_dashboardcard :dc] [:= :c.id :dc.card_id] - [:report_dashboard :d] [:= :dc.dashboard_id :d.id]] - :where [:and - [:in :c.id card-ids] - [:not= :d.public_uuid nil]]}) - (map :id) - set))] - (for [card cards] - (when (some? card) ; card may be `nil` here if it comes from a text-only Dashcard - (assoc card :in_public_dashboard (contains? public-dashboard-card-ids (u/get-id card))))))) - - -;;; ---------------------------------------------- Permissions Checking ---------------------------------------------- - -;; Is calculating permissions for Cards complicated? Some would say so. Refer to this handy flow chart to see how things -;; get calculated. -;; -;; Note that `can-read?/can-write?` and `pre-insert/pre-update` are the two entry points into the permissions -;; labyrinth. `pre-insert`/`pre-update` calculate permissions for the *query* (disregarding collection and publicness) -;; and thus skip to `query-perms-set`; `can-read?`/`can-write?` want to take those into account (as well as cached -;; read permissions, if available) and thus starts higher up. -;; -;; -;; can-read?/can-write? --> perms-set-taking-collection-etc-into-account -;; | -;; public? <------------------------------+---------------------------> in collection? -;; ↓ else ↓ -;; #{} ↓ collection/perms-objects-set -;; card-perms-set-for-query -;; | -;; write perms <---------------------+---------------------> read perms -;; | | -;; | does not have cached read_permissions <-----+-----> has cached read_permissions -;; | ↓ ↓ -;; +-------------------> query-perms-set <------------------+ return cached read_permssions -;; pre-insert/ ↑ | | -;; pre-update ---------------------------+ | | -;; (maybe-update | | -;; -read-perms) native card? <------+-----> mbql card? | -;; ↓ ↓ | -;; native-perms-path mbql-perms-path-set | (recursively for source card) -;; | | -;; no source card <----+----> has source card -;; ↓ -;; tables->permissions-path-set - -(defn- native-permissions-path - "Return the `:read` (for running) or `:write` (for saving) native permissions path for DATABASE-OR-ID." - [read-or-write database-or-id] - ((case read-or-write - :read perms/native-read-path - :write perms/native-readwrite-path) (u/get-id database-or-id))) - -(defn- query->source-and-join-tables - "Return a sequence of all Tables (as TableInstance maps) referenced by QUERY." - [{:keys [source-table join-tables source-query native], :as query}] - (cond - ;; if we come across a native query just put a placeholder (`::native`) there so we know we need to add native - ;; permissions to the complete set below. - native [::native] - ;; if we have a source-query just recur until we hit either the native source or the MBQL source - source-query (recur source-query) - ;; for root MBQL queries just return source-table + join-tables - :else (cons source-table join-tables))) - -(defn- tables->permissions-path-set - "Given a sequence of TABLES referenced by a query, return a set of required permissions." - [read-or-write database-or-id tables] - (set (for [table tables] - (if (= ::native table) - ;; Any `::native` placeholders from above mean we need READ-OR-WRITE native permissions for this DATABASE - (native-permissions-path read-or-write database-or-id) - ;; anything else (i.e., a normal table) just gets normal table permissions - (perms/object-path (u/get-id database-or-id) - (:schema table) - (or (:id table) (:table-id table))))))) - -(declare query-perms-set) - -(defn- mbql-permissions-path-set - "Return the set of required permissions needed to run QUERY. - - Optionally specify `disallowed-source-card-ids`: this is a sequence of Card IDs that should not be allowed to be a - source Card ID in this case. For example, you would want to disallow a Card from being its own source; when - recursing, this is used to keep track of source Card IDs we've already seen in order to prevent circular - references. - - Also optionally specify `throw-exceptions?` -- normally this function avoids throwing Exceptions to avoid breaking - things when a single Card is busted (e.g. API endpoints that filter out unreadable Cards) and instead returns 'only - admins can see this' permissions -- `#{\"db/0\"}` (DB 0 will never exist, thus normal users will never be able to - get permissions for it, but admins have root perms and will still get to see (and hopefully fix) it)." - [read-or-write query & [disallowed-source-card-ids throw-exceptions?]] - {:pre [(map? query) (map? (:query query))]} - (try - (or - ;; If `query` is based on a source Card (if `:source-table` uses a psuedo-source-table like `card__<id>`) then - ;; return the permissions needed to *read* that Card. Running or saving a Card that uses another Card as a source - ;; query simply requires read permissions for that Card; e.g. if you are allowed to view a query you can save a - ;; new query that uses it as a source. Thus the distinction between read and write permissions in not important - ;; here. - ;; - ;; See issue #6845 for further discussion. - (when-let [source-card-id (qputil/query->source-card-id query)] - ;; If this source card ID is disallowed (e.g. due to it being a circular reference) then throw an Exception. - ;; Bye Felicia! - (when ((set disallowed-source-card-ids) source-card-id) - (throw - (Exception. - (str (tru "Cannot calculate permissions due to circular references.") - (tru "This means a question is either using itself as a source or one or more questions are using each other as sources."))))) - ;; ok, if we've decided that this is not a loooopy situation then go ahead and recurse - (query-perms-set (db/select-one-field :dataset_query Card :id source-card-id) - :read - (conj disallowed-source-card-ids source-card-id) - throw-exceptions?)) - ;; otherwise if there's no source card then calculate perms based on the Tables referenced in the query - (let [{:keys [query database]} (qp/expand query)] - (tables->permissions-path-set read-or-write database (query->source-and-join-tables query)))) - ;; if for some reason we can't expand the Card (i.e. it's an invalid legacy card) just return a set of permissions - ;; that means no one will ever get to see it (except for superusers who get to see everything) - (catch Throwable e - (when throw-exceptions? - (throw e)) - (log/warn "Error getting permissions for card:" (.getMessage e) "\n" - (u/pprint-to-str (u/filtered-stacktrace e))) - #{"/db/0/"}))) ; DB 0 will never exist - -;; Calculating Card read permissions is rather expensive, since we must parse and expand the Card's query in order to -;; find the Tables it references. Since we read Cards relatively often, these permissions are cached in the -;; `:read_permissions` column of Card. There should not be situtations where these permissions are not cached; but if -;; for some strange reason they are we will go ahead and recalculate them. - -;; TODO - what if the query uses a source query, and that query changes? Not sure if that will cause an issue or not. -(defn query-perms-set - "Calculate the set of permissions required to `:read`/run or `:write` (update) a Card with `query`. In normal cases - for read permissions you should look at a Card's `:read_permissions`, which is precalculated. If you specifically - need to calculate permissions for a query directly, and ignore anything precomputed, use this function. Otherwise - you should rely on one of the optimized ones below." - [{query-type :type, database :database, :as query} read-or-write & [disallowed-source-card-ids throw-exceptions?]] - (cond - (empty? query) #{} - (= (keyword query-type) :native) #{(native-permissions-path read-or-write database)} - (= (keyword query-type) :query) (mbql-permissions-path-set read-or-write query disallowed-source-card-ids throw-exceptions?) - :else (throw (Exception. (str (tru "Invalid query type: {0}" query-type)))))) - - -(defn- card-perms-set-for-query - "Return the permissions required to `read-or-write` `card` based on its query, disregarding the collection the Card is - in, whether it is publicly shared, etc. This will return precalculated `:read_permissions` if they are present." - [{read-perms :read_permissions, card-id :id, query :dataset_query} read-or-write] - (cond - ;; for WRITE permissions always recalculate since these will be determined relatively infrequently (hopefully) - ;; e.g. when updating a Card - (= :write read-or-write) (query-perms-set query :write [card-id]) - ;; if the Card has *populated* `:read_permissions` and we're looking up read pems return those rather than - ;; calculating on-the-fly. Cast to `set` to be extra-double-sure it's a set like we'd expect when it gets - ;; deserialized from JSON - (seq read-perms) (set read-perms) - ;; otherwise if :read_permissions was NOT populated. This should not normally happen since the data migration - ;; should have pre-populated values for all the Cards. If it does it might mean something like we fetched the Card - ;; without its `read_permissions` column. Since that would be "doing something wrong" warn about it. - :else (do (log/info "Card" card-id "does not have cached read_permissions.") - (query-perms-set query :read [card-id])))) - -(defn- card-perms-set-taking-collection-etc-into-account - "Calculate the permissions required to `read-or-write` `card`*for a user. This takes into account whether the Card is - publicly available, or in a collection the current user can view; it also attempts to use precalcuated - `read_permissions` when possible. This is the function that should be used for general permissions checking for a - Card. - - This function works the same regardless of whether called with a current user (e.g. `api/*current-user*`, etc.) or - not! It simply calculates the permssions a User would need to see the Card." - [{collection-id :collection_id, public-uuid :public_uuid, in-public-dash? :in_public_dashboard, - outer-query :dataset_query, card-id :id, :as card} - read-or-write] - (when-not (seq card) - (throw (Exception. (str (tru "`card` is nil or empty. Cannot calculate permissions."))))) - (let [source-card-id (qputil/query->source-card-id outer-query)] - (cond - ;; you don't need any permissions to READ a public card, which is PUBLIC by definition :D - (and (public-settings/enable-public-sharing) - (= :read read-or-write) - (or public-uuid in-public-dash?)) - #{} - - collection-id - (collection/perms-objects-set collection-id read-or-write) - - :else - (card-perms-set-for-query card read-or-write)))) - ;;; -------------------------------------------------- Dependencies -------------------------------------------------- @@ -273,44 +69,42 @@ :query_type (keyword query-type)}) card)) -(defn- maybe-update-read-permissions - "When inserting or updating a `card`, if `:dataset_query` is going to change, calculate the updated `:read_permssions` - and `assoc` those to the output so they get changed as well. These cached `read_permissions` are the permissions for - the underlying query, disregarding whether the Card is in a collection or present in a public Dashboard or is itself - public. Only query permissions are expensive to calculate, so that is the only thing we cache. The other stuff is - caclulated every time by `card-perms-set-taking-collection-etc-into-account`. - - (maybe-update-read-permssions card-to-be-saved) ;-> updated-card-to-be-saved" - [{query :dataset_query, card-id :id, :as card}] - (if-not (seq query) - card - ;; Calculate read_permissions using `query-perms-set`, which calculates perms based on the query along (ignoring - ;; collection perms, presence on public dashboards, etc.). - (assoc card :read_permissions (query-perms-set query - :read - ;; If this is an UPDATE operation send along the `card-id` to the - ;; list of `disallowed-source-card-ids` because, needless to say, a - ;; Card should not be allowed to use itself as a source, whether - ;; directly or indirectly. See `query-perms-set` itself for further - ;; discussion. - (when card-id [card-id]) - ;; tell `query-perms-set` to throw Exceptions so we don't end up - ;; saving a Card that is for some reason invalid - :throw-exceptions)))) +(defn- check-for-circular-source-query-references + "Check that a `card`, if it is using another Card as its source, does not have circular references between source + Cards. (e.g. Card A cannot use itself as a source, or if A uses Card B as a source, Card B cannot use Card A, and so + forth.)" + [{query :dataset_query, id :id}] ; don't use `u/get-id` here so that we can use this with `pre-insert` too + (loop [query query, ids-already-seen #{id}] + (let [source-card-id (qputil/query->source-card-id query)] + (cond + (not source-card-id) + :ok + + (ids-already-seen source-card-id) + (throw + (ex-info (tru "Cannot save Question: source query has circular references.") + {:status-code 400})) + + :else + (recur (or (db/select-one-field :dataset_query Card :id source-card-id) + (throw (ex-info (tru "Card {0} does not exist." source-card-id) + {:status-code 404}))) + (conj ids-already-seen source-card-id)))))) (defn- pre-insert [{query :dataset_query, :as card}] - ;; TODO - make sure if `collection_id` is specified that we have write permissions for that collection - ;; - ;; updated Card with updated read permissions when applicable. (New Cards should never be created without a valid - ;; `:dataset_query` so this should always happen) - (u/prog1 (maybe-update-read-permissions card) - ;; for native queries we need to make sure the user saving the card has native query permissions for the DB - ;; because users can always see native Cards and we don't want someone getting around their lack of permissions - ;; that way - (when (and *current-user-id* - (= (keyword (:type query)) :native)) - (let [database (db/select-one ['Database :id :name], :id (:database query))] - (qp-perms/throw-if-cannot-run-new-native-query-referencing-db database))))) + ;; TODO - we usually check permissions to save/update stuff in the API layer rather than here in the Toucan + ;; model-layer functions... Not saying one pattern is better than the other (although this one does make it harder + ;; to do the wrong thing) but we should try to be consistent + (u/prog1 card + ;; Make sure the User saving the Card has the appropriate permissions to run its query. We don't want Users saving + ;; Cards with queries they wouldn't be allowed to run! + (when *current-user-id* + (when-not (perms/set-has-full-permissions-for-set? @*current-user-permissions-set* + (query-perms/perms-set query :throw-exceptions)) + (throw (Exception. (str (tru "You do not have permissions to run ad-hoc native queries against Database {0}." + (:database query))))))) + ;; make sure this Card doesn't have circular source query references + (check-for-circular-source-query-references card))) (defn- post-insert [card] ;; if this Card has any native template tag parameters we need to update FieldValues for any Fields that are @@ -321,8 +115,9 @@ (field-values/update-field-values-for-on-demand-dbs! field-ids)))) (defn- pre-update [{archived? :archived, query :dataset_query, :as card}] - ;; save the updated Card with updated read permissions when applicable. - (u/prog1 (maybe-update-read-permissions card) + ;; TODO - don't we need to be doing the same permissions check we do in `pre-insert` if the query gets changed? Or + ;; does that happen in the `PUT` endpoint? + (u/prog1 card ;; if the Card is archived, then remove it from any Dashboards (when archived? (db/delete! 'DashboardCard :card_id (u/get-id card))) @@ -340,7 +135,10 @@ "Is Now:" new-param-field-ids "Newly Added:" newly-added-param-field-ids) ;; Now update the FieldValues for the Fields referenced by this Card. - (field-values/update-field-values-for-on-demand-dbs! newly-added-param-field-ids))))))) + (field-values/update-field-values-for-on-demand-dbs! newly-added-param-field-ids))))) + ;; make sure this Card doesn't have circular source query references if we're updating the query + (when (:dataset_query card) + (check-for-circular-source-query-references card)))) (defn- pre-delete [{:keys [id]}] (db/delete! 'PulseCard :card_id id) @@ -360,8 +158,7 @@ :embedding_params :json :query_type :keyword :result_metadata :json - :visualization_settings :json - :read_permissions :json-set}) + :visualization_settings :json}) :properties (constantly {:timestamped? true}) :pre-update (comp populate-query-fields pre-update) :pre-insert (comp populate-query-fields pre-insert) @@ -369,11 +166,9 @@ :pre-delete pre-delete :post-select public-settings/remove-public-uuid-if-public-sharing-is-disabled}) + ;; You can read/write a Card if you can read/write its parent Collection i/IObjectPermissions - (merge i/IObjectPermissionsDefaults - {:can-read? (partial i/current-user-has-full-permissions? :read) - :can-write? (partial i/current-user-has-full-permissions? :write) - :perms-objects-set card-perms-set-taking-collection-etc-into-account}) + perms/IObjectPermissionsForParentCollection revision/IRevisioned (assoc revision/IRevisionedDefaults diff --git a/src/metabase/models/collection.clj b/src/metabase/models/collection.clj index 436d45914ec447926088ad3fe670c56372ea27f9..56c3263101a2847af1b171c704395155d1c87a9b 100644 --- a/src/metabase/models/collection.clj +++ b/src/metabase/models/collection.clj @@ -237,6 +237,13 @@ (defn- is-root-collection? [m] (boolean (::is-root? m))) +(def ^:private CollectionWithLocation + (s/pred (fn [collection] + (and (map? collection) + (or (::is-root? collection) + (valid-location-path? (:location collection))))) + "Collection with a valid `:location` or the Root Collection")) + (s/defn children-location :- LocationPath "Given a `collection` return a location path that should match the `:location` value of all the children of the Collection. @@ -245,7 +252,7 @@ ;; To get children of this collection: (db/select Collection :location \"/10/20/30/\")" - [{:keys [location], :as collection} :- su/Map] + [{:keys [location], :as collection} :- CollectionWithLocation] (if (is-root-collection? collection) "/" (str location (u/get-id collection) "/"))) @@ -257,9 +264,9 @@ s/Keyword s/Any})) (s/defn ^:private descendants :- #{Children} - "Return all descendants of a `collection`, including children, grandchildren, and so forth. This is done primarily - to power the `effective-children` feature below, and thus the descendants are returned in a hierarchy, rather than - as a flat set. e.g. results will be something like: + "Return all descendant Collections of a `collection`, including children, grandchildren, and so forth. This is done + primarily to power the `effective-children` feature below, and thus the descendants are returned in a hierarchy, + rather than as a flat set. e.g. results will be something like: +-> B | @@ -269,7 +276,7 @@ where each letter represents a Collection, and the arrows represent values of its respective `:children` set." - [collection :- su/Map] + [collection :- CollectionWithLocation] ;; first, fetch all the descendants of the `collection`, and build a map of location -> children. This will be used ;; so we can fetch the immediate children of each Collection (let [location->children (group-by :location (db/select [Collection :name :id :location] @@ -286,9 +293,9 @@ :children))) -(s/defn effective-children ;; :- #{CollectionInstance} - "Return the descendants of a `collection` that should be presented to the current user as the children of this - Collection. This takes into account descendants that get filtered out when the current user can't see them. For +(s/defn effective-children :- #{CollectionInstance} + "Return the descendant Collections of a `collection` that should be presented to the current user as the children of + this Collection. This takes into account descendants that get filtered out when the current user can't see them. For example, suppose we have some Collections with a hierarchy like this: +-> B @@ -310,7 +317,7 @@ the current User. This needs to be done so we can give a User a way to navigate to nodes that are children of Collections they cannot access; in the example above, E and F are such nodes." {:hydrate :effective_children} - [collection :- su/Map] + [collection :- CollectionWithLocation] ;; Hydrate `:children` if it's not already done (-> (for [child (if (contains? collection :children) (:children collection) @@ -326,12 +333,12 @@ ;;; +----------------------------------------------------------------------------------------------------------------+ -;;; | Moving Collections | +;;; | Recursive Operations: Moving & Archiving | ;;; +----------------------------------------------------------------------------------------------------------------+ (s/defn move-collection! "Move a Collection and all its descendant Collections from its current `location` to a `new-location`." - [collection :- su/Map, new-location :- LocationPath] + [collection :- CollectionWithLocation, new-location :- LocationPath] (let [orig-children-location (children-location collection) new-children-location (children-location (assoc collection :location new-location))] ;; first move this Collection @@ -345,6 +352,41 @@ :set {:location (hsql/call :replace :location orig-children-location new-children-location)} :where [:like :location (str orig-children-location "%")]})))) +(s/defn ^:private collection->descendant-ids :- (s/maybe #{su/IntGreaterThanZero}) + [collection :- CollectionWithLocation, & additional-conditions] + (apply db/select-ids Collection + :location [:like (str (children-location collection) "%")] + additional-conditions)) + +(s/defn ^:private archive-collection! + "Archive a Collection and its descendant Collections and their Cards, Dashboards, and Pulses." + [collection :- CollectionWithLocation] + (let [affected-collection-ids (cons (u/get-id collection) + (collection->descendant-ids collection, :archived false))] + (db/transaction + (db/update-where! Collection {:id [:in affected-collection-ids] + :archived false} + :archived true) + (doseq [model '[Card Dashboard]] + (db/update-where! model {:collection_id [:in affected-collection-ids] + :archived false} + :archived true)) + (db/delete! 'Pulse :collection_id [:in affected-collection-ids])))) + +(s/defn ^:private unarchive-collection! + "Unarchive a Collection and its descendant Collections and their Cards, Dashboards, and Pulses." + [collection :- CollectionWithLocation] + (let [affected-collection-ids (cons (u/get-id collection) + (collection->descendant-ids collection, :archived true))] + (db/transaction + (db/update-where! Collection {:id [:in affected-collection-ids] + :archived true} + :archived false) + (doseq [model '[Card Dashboard]] + (db/update-where! model {:collection_id [:in affected-collection-ids] + :archived true} + :archived false))))) + ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | Toucan IModel & Perms Method Impls | @@ -356,22 +398,40 @@ (assoc collection :slug (u/prog1 (slugify collection-name) (assert-unique-slug <>)))) -(defn- pre-update [{collection-name :name, id :id, color :color, archived? :archived, :as collection}] - (assert-valid-location collection) - ;; make sure hex color is valid - (when (contains? collection :color) - (assert-valid-hex-color color)) - ;; archive / unarchive cards in this collection as needed - (db/update-where! 'Card {:collection_id id} - :archived archived?) - ;; slugify the collection name and make sure it's unique - (if-not collection-name - collection - (assoc collection :slug (u/prog1 (slugify collection-name) - ;; if slug hasn't changed no need to check for uniqueness otherwise check to make sure - ;; the new slug is unique - (or (db/exists? Collection, :slug <>, :id id) - (assert-unique-slug <>)))))) +(s/defn ^:private maybe-archive-or-unarchive + "If `:archived` specified in the updates map, archive/unarchive as needed." + [collection-before-updates :- CollectionWithLocation, collection-updates :- su/Map] + ;; If the updates map contains a value for `:archived`, see if it's actually something different than current value + (when (and (contains? collection-updates :archived) + (not= (:archived collection-before-updates) + (:archived collection-updates))) + ;; check to make sure we're not trying to change location at the same time + (when (and (contains? collection-updates :location) + (not= (:location collection-updates) + (:location collection-before-updates))) + (throw (ex-info (tru "You cannot move a Collection and archive it at the same time.") + {:status-code 400 + :errors {:archived (tru "You cannot move a Collection and archive it at the same time.")}}))) + ;; ok, go ahead and do the archive/unarchive operation + ((if (:archived collection-updates) + archive-collection! + unarchive-collection!) collection-before-updates))) + +(defn- pre-update [{collection-name :name, id :id, color :color, :as collection-updates}] + (let [collection-before-updates (db/select-one Collection :id id)] + (assert-valid-location collection-updates) + ;; make sure hex color is valid + (when (contains? collection-updates :color) + (assert-valid-hex-color color)) + ;; archive or unarchive as appropriate + (maybe-archive-or-unarchive collection-before-updates collection-updates) + ;; slugify the collection name and make sure it's unique *if* we're changing collection-name + (cond-> collection-updates + collection-name (assoc :slug (u/prog1 (slugify collection-name) + ;; if slug hasn't changed no need to check for uniqueness otherwise check to make + ;; sure the new slug is unique + (or (db/exists? Collection, :slug <>, :id id) + (assert-unique-slug <>))))))) (defn- pre-delete [collection] ;; unset the collection_id for Cards/Pulses in this collection. This is mostly for the sake of tests since IRL we @@ -418,7 +478,8 @@ (def ^:private GroupPermissionsGraph "collection-id -> status" - {su/IntGreaterThanZero CollectionPermissions}) + {(s/optional-key :root) CollectionPermissions ; when doing a delta between old graph and new graph root won't always + su/IntGreaterThanZero CollectionPermissions}) ; be present, which is why it's *optional* (def ^:private PermissionsGraph {:revision s/Int @@ -432,17 +493,19 @@ {group-id (set (map :object perms))}))) (s/defn ^:private perms-type-for-collection :- CollectionPermissions - [permissions-set collection-id] + [permissions-set collection-or-id] (cond - (perms/set-has-full-permissions? permissions-set (perms/collection-readwrite-path collection-id)) :write - (perms/set-has-full-permissions? permissions-set (perms/collection-read-path collection-id)) :read - :else :none)) + (perms/set-has-full-permissions? permissions-set (perms/collection-readwrite-path collection-or-id)) :write + (perms/set-has-full-permissions? permissions-set (perms/collection-read-path collection-or-id)) :read + :else :none)) (s/defn ^:private group-permissions-graph :- GroupPermissionsGraph "Return the permissions graph for a single group having PERMISSIONS-SET." [permissions-set collection-ids] - (into {} (for [collection-id collection-ids] - {collection-id (perms-type-for-collection permissions-set collection-id)}))) + (into + {:root (perms-type-for-collection permissions-set root-collection)} + (for [collection-id collection-ids] + {collection-id (perms-type-for-collection permissions-set collection-id)}))) (s/defn graph :- PermissionsGraph "Fetch a graph representing the current permissions status for every group and all permissioned collections. This @@ -460,14 +523,17 @@ (s/defn ^:private update-collection-permissions! [group-id :- su/IntGreaterThanZero - collection-id :- su/IntGreaterThanZero + collection-id :- (s/cond-pre (s/eq :root) su/IntGreaterThanZero) new-collection-perms :- CollectionPermissions] - ;; remove whatever entry is already there (if any) and add a new entry if applicable - (perms/revoke-collection-permissions! group-id collection-id) - (case new-collection-perms - :write (perms/grant-collection-readwrite-permissions! group-id collection-id) - :read (perms/grant-collection-read-permissions! group-id collection-id) - :none nil)) + (let [collection-id (if (= collection-id :root) + root-collection + collection-id)] + ;; remove whatever entry is already there (if any) and add a new entry if applicable + (perms/revoke-collection-permissions! group-id collection-id) + (case new-collection-perms + :write (perms/grant-collection-readwrite-permissions! group-id collection-id) + :read (perms/grant-collection-read-permissions! group-id collection-id) + :none nil))) (s/defn ^:private update-group-permissions! [group-id :- su/IntGreaterThanZero, new-group-perms :- GroupPermissionsGraph] @@ -513,19 +579,32 @@ (defn check-write-perms-for-collection "Check that we have write permissions for Collection with `collection-id`, or throw a 403 Exception. If - `collection-id` is `nil`, this check is skipped." - [collection-or-id] - (when collection-or-id - (api/check-403 (perms/set-has-full-permissions? @*current-user-permissions-set* - (perms/collection-readwrite-path collection-or-id))))) + `collection-id` is `nil`, this check is done for the Root Collection." + [collection-or-id-or-nil] + (api/check-403 (perms/set-has-full-permissions? @*current-user-permissions-set* + (perms/collection-readwrite-path (if collection-or-id-or-nil + collection-or-id-or-nil + root-collection))))) (defn check-allowed-to-change-collection - "If we're changing the `collection_id` of an `object`, make sure we have write permissions for the new Collection, and - throw a 403 if not. If `collection-id` is `nil`, or hasn't changed from the original `object`, this check does - nothing." - [object collection-or-id] - ;; TODO - what about when someone wants to *unset* the Collection? We should tweak this check to handle that case as - ;; well - (when (and collection-or-id - (not= (u/get-id collection-or-id) (:collection_id object))) - (check-write-perms-for-collection collection-or-id))) + "If we're changing the `collection_id` of an object, make sure we have write permissions for both the old and new + Collections, or throw a 403 if not. If `collection_id` isn't present in `object-updates`, or the value is the same + as the original, this check is a no-op. + + As usual, an `collection-id` of `nil` represents the Root Collection. + + + Intended for use with `PUT` or `PATCH`-style operations. Usage should look something like: + + ;; `object-before-update` is the object as it currently exists in the application DB + ;; `object-updates` is a map of updated values for the object + (check-allowed-to-change-collection (Card 100) http-request-body)" + [object-before-update object-updates] + ;; if collection_id is set to change... + (when (contains? object-updates :collection_id) + (when (not= (:collection_id object-updates) + (:collection_id object-before-update)) + ;; check that we're allowed to modify the old Collection + (check-write-perms-for-collection (:collection_id object-before-update)) + ;; check that we're allowed to modify the new Collection + (check-write-perms-for-collection (:collection_id object-updates))))) diff --git a/src/metabase/models/dashboard.clj b/src/metabase/models/dashboard.clj index 16457b5eea2b18ffa209909f1aa23815f132477a..455cc12db1d4628a656719a9e0700579f7a8b443 100644 --- a/src/metabase/models/dashboard.clj +++ b/src/metabase/models/dashboard.clj @@ -4,19 +4,19 @@ [set :as set] [string :as str]] [clojure.tools.logging :as log] - [metabase.automagic-dashboards.populate :as magic.populate] [metabase [events :as events] [public-settings :as public-settings] [query-processor :as qp] [util :as u]] + [metabase.automagic-dashboards.populate :as magic.populate] [metabase.models [card :as card :refer [Card]] - [collection :as collection] [dashboard-card :as dashboard-card :refer [DashboardCard]] [field-values :as field-values] [interface :as i] [params :as params] + [permissions :as perms] [revision :as revision]] [metabase.models.revision.diff :refer [build-sentence]] [metabase.query-processor.interface :as qpi] @@ -25,52 +25,10 @@ [hydrate :refer [hydrate]] [models :as models]])) -;;; ------------------------------------------------- Perms Checking ------------------------------------------------- - -(defn- dashcards->cards [dashcards] - (when (seq dashcards) - (for [dashcard dashcards - :when (:card dashcard) ; skip over ones that are cardless, e.g. text-only DashCards - card (cons (:card dashcard) (:series dashcard))] - card))) - -(defn- can-read-or-write-based-on-cards? [dashboard read-or-write] - ;; otherwise if Dashboard is already hydrated no need to do it a second time - (let [cards (or (dashcards->cards (:ordered_cards dashboard)) - (dashcards->cards (-> (db/select [DashboardCard :id :card_id] - :dashboard_id (u/get-id dashboard) - :card_id [:not= nil]) ; skip text-only Cards - (hydrate [:card :in_public_dashboard] :series))))] - ;; you can read/write a Dashboard if it has *no* Cards or if you can read/write at least one of those Cards - (or (empty? cards) - (some (case read-or-write - :read i/can-read? - :write i/can-write?) - cards)))) - -(defn- can-read-or-write? - [{public-uuid :public_uuid, collection-id :collection_id, :as dashboard} read-or-write] - (cond - ;; if the Dashboard is shared publicly then there is simply no need to check *read* permissions for it because - ;; people can see it already!!! - (and (= read-or-write :read) - (public-settings/enable-public-sharing) - (some? public-uuid)) - true - - ;; otherwise if the Dashboard is in a Collection then use Collection permission... - collection-id - (i/current-user-has-full-permissions? (collection/perms-objects-set collection-id read-or-write)) - - ;; ...finally if not use the "traditional" artifact-based Permissions, which are derived from the Cards in the Dash - :else - (can-read-or-write-based-on-cards? dashboard read-or-write))) - - ;;; --------------------------------------------------- Hydration ---------------------------------------------------- (defn ordered-cards - "Return the `DashboardCards` associated with DASHBOARD, in the order they were created." + "Return the DashboardCards associated with `dashboard`, in the order they were created." {:hydrate :ordered_cards} [dashboard-or-id] (db/do-post-select DashboardCard @@ -107,16 +65,16 @@ :pre-delete pre-delete :pre-insert pre-insert :post-select public-settings/remove-public-uuid-if-public-sharing-is-disabled}) + + ;; You can read/write a Dashboard if you can read/write its parent Collection i/IObjectPermissions - (merge i/IObjectPermissionsDefaults - {:can-read? #(can-read-or-write? % :read) - :can-write? #(can-read-or-write? % :write)})) + perms/IObjectPermissionsForParentCollection) ;;; --------------------------------------------------- Revisions ---------------------------------------------------- (defn serialize-dashboard - "Serialize a `Dashboard` for use in a `Revision`." + "Serialize a Dashboard for use in a Revision." [dashboard] (-> dashboard (select-keys [:description :name]) @@ -125,7 +83,7 @@ (assoc :series (mapv :id (dashboard-card/series dashboard-card))))))))) (defn- revert-dashboard! - "Revert a `Dashboard` to the state defined by SERIALIZED-DASHBOARD." + "Revert a Dashboard to the state defined by `serialized-dashboard`." [dashboard-id user-id serialized-dashboard] ;; Update the dashboard description / name / permissions (db/update! Dashboard dashboard-id, (dissoc serialized-dashboard :cards)) @@ -154,7 +112,7 @@ serialized-dashboard) (defn diff-dashboards-str - "Describe the difference between 2 `Dashboard` instances." + "Describe the difference between two Dashboard instances." [dashboardâ‚ dashboardâ‚‚] (when dashboardâ‚ (let [[removals changes] (diff dashboardâ‚ dashboardâ‚‚) @@ -164,16 +122,22 @@ (let [num-seriesâ‚ (count (get-in dashboardâ‚ [:cards idx :series])) num-seriesâ‚‚ (count (get-in dashboardâ‚‚ [:cards idx :series]))] (cond - (< num-seriesâ‚ num-seriesâ‚‚) (format "added some series to card %d" (get-in dashboardâ‚ [:cards idx :card_id])) - (> num-seriesâ‚ num-seriesâ‚‚) (format "removed some series from card %d" (get-in dashboardâ‚ [:cards idx :card_id])) - :else (format "modified the series on card %d" (get-in dashboardâ‚ [:cards idx :card_id]))))))] + (< num-seriesâ‚ num-seriesâ‚‚) + (format "added some series to card %d" (get-in dashboardâ‚ [:cards idx :card_id])) + + (> num-seriesâ‚ num-seriesâ‚‚) + (format "removed some series from card %d" (get-in dashboardâ‚ [:cards idx :card_id])) + + :else + (format "modified the series on card %d" (get-in dashboardâ‚ [:cards idx :card_id]))))))] (-> [(when (:name changes) (format "renamed it from \"%s\" to \"%s\"" (:name dashboardâ‚) (:name dashboardâ‚‚))) (when (:description changes) (cond (nil? (:description dashboardâ‚)) "added a description" (nil? (:description dashboardâ‚‚)) "removed the description" - :else (format "changed the description from \"%s\" to \"%s\"" (:description dashboardâ‚) (:description dashboardâ‚‚)))) + :else (format "changed the description from \"%s\" to \"%s\"" + (:description dashboardâ‚) (:description dashboardâ‚‚)))) (when (or (:cards changes) (:cards removals)) (let [num-cardsâ‚ (count (:cards dashboardâ‚)) num-cardsâ‚‚ (count (:cards dashboardâ‚‚))] @@ -235,7 +199,7 @@ (update-field-values-for-on-demand-dbs! old-param-field-ids new-param-field-ids))))) (defn update-dashcards! - "Update the DASHCARDS belonging to DASHBOARD-OR-ID. + "Update the `dashcards` belonging to `dashboard-or-id`. This function is provided as a convenience instead of doing this yourself; it also makes sure various cleanup steps are performed when finished, for example updating FieldValues for On-Demand DBs. Returns `nil`." @@ -252,7 +216,7 @@ (defn- result-metadata-for-query - "Fetch the results metadata for a QUERY by running the query and seeing what the QP gives us in return." + "Fetch the results metadata for a `query` by running the query and seeing what the `qp` gives us in return." [query] (binding [qpi/*disable-qp-logging* true] (get-in (qp/process-query query) [:data :results_metadata :columns]))) @@ -286,7 +250,7 @@ (format "%s %s" collection (inc c))))) (defn save-transient-dashboard! - "Save a denormalized description of dashboard." + "Save a denormalized description of `dashboard`." [dashboard] (let [dashcards (:ordered_cards dashboard) dashboard (db/insert! Dashboard diff --git a/src/metabase/models/interface.clj b/src/metabase/models/interface.clj index a8703718e77a971a08b3f38a53cbaedae852ca26..db7adec1dfc7665c9be29fed77b5247db9ad07da 100644 --- a/src/metabase/models/interface.clj +++ b/src/metabase/models/interface.clj @@ -168,14 +168,14 @@ (def ^{:arglists '([read-or-write entity object-id] [read-or-write object] [perms-set])} ^Boolean current-user-has-full-permissions? - "Implementation of `can-read?`/`can-write?` for the new permissions system. `true` if the current user has *full* + "Implementation of `can-read?`/`can-write?` for the old permissions system. `true` if the current user has *full* permissions for the paths returned by its implementation of `perms-objects-set`. (READ-OR-WRITE is either `:read` or `:write` and passed to `perms-objects-set`; you'll usually want to partially bind it in the implementation map)." (make-perms-check-fn 'metabase.models.permissions/set-has-full-permissions-for-set?)) (def ^{:arglists '([read-or-write entity object-id] [read-or-write object] [perms-set])} ^Boolean current-user-has-partial-permissions? - "Implementation of `can-read?`/`can-write?` for the new permissions system. `true` if the current user has *partial* + "Implementation of `can-read?`/`can-write?` for the old permissions system. `true` if the current user has *partial* permissions for the paths returned by its implementation of `perms-objects-set`. (READ-OR-WRITE is either `:read` or `:write` and passed to `perms-objects-set`; you'll usually want to partially bind it in the implementation map)." (make-perms-check-fn 'metabase.models.permissions/set-has-partial-permissions-for-set?)) diff --git a/src/metabase/models/permissions.clj b/src/metabase/models/permissions.clj index 1902e6597fa91ba7dfe9a6f1dfee850c1f844476..fca90f8d9a71e101d5d68c9d6cda1490c17f6956 100644 --- a/src/metabase/models/permissions.clj +++ b/src/metabase/models/permissions.clj @@ -7,6 +7,7 @@ [medley.core :as m] [metabase.api.common :refer [*current-user-id*]] [metabase.models + [interface :as i] [permissions-group :as group] [permissions-revision :as perms-revision :refer [PermissionsRevision]]] [metabase.util :as u] @@ -41,12 +42,13 @@ (def ^:private ^:const valid-object-path-patterns [#"^/db/(\d+)/$" ; permissions for the entire DB -- native and all schemas #"^/db/(\d+)/native/$" ; permissions to create new native queries for the DB - #"^/db/(\d+)/native/read/$" ; (DEPRECATED) permissions to read the results of existing native queries (i.e. view existing cards) for the DB #"^/db/(\d+)/schema/$" ; permissions for all schemas in the DB #"^/db/(\d+)/schema/([^\\/]*)/$" ; permissions for a specific schema #"^/db/(\d+)/schema/([^\\/]*)/table/(\d+)/$" ; permissions for a specific table #"^/collection/(\d+)/$" ; readwrite permissions for a collection - #"^/collection/(\d+)/read/$"]) ; read permissions for a collection + #"^/collection/(\d+)/read/$" ; read permissions for a collection + #"^/collection/root/$" ; readwrite permissions for the 'Root' Collection (things with `nil` collection_id) + #"^/collection/root/read/$"]) ; read permissions for the 'Root' Collection (defn valid-object-path? "Does OBJECT-PATH follow a known, allowed format to an *object*? @@ -94,39 +96,42 @@ ;;; ------------------------------------------------- Path Util Fns -------------------------------------------------- -(defn object-path - "Return the permissions path for a database, schema, or table." - (^String [database-id] {:pre [(integer? database-id)], :post [(valid-object-path? %)]} (str "/db/" database-id "/")) - (^String [database-id schema-name] {:pre [(u/maybe? string? schema-name)], :post [(valid-object-path? %)]} (str (object-path database-id) "schema/" schema-name "/")) - (^String [database-id schema-name table-id] {:pre [(integer? table-id)], :post [(valid-object-path? %)]} (str (object-path database-id schema-name) "table/" table-id "/" ))) +(def ^:private MapOrID + (s/cond-pre su/Map su/IntGreaterThanZero)) -(defn native-readwrite-path +(s/defn object-path :- ObjectPath + "Return the permissions path for a Database, schema, or Table." + ([database-or-id :- MapOrID] + (str "/db/" (u/get-id database-or-id) "/")) + ([database-or-id :- MapOrID, schema-name :- (s/maybe s/Str)] + (str (object-path database-or-id) "schema/" schema-name "/")) + ([database-or-id :- MapOrID, schema-name :- (s/maybe s/Str), table-or-id :- MapOrID] + (str (object-path database-or-id schema-name) "table/" (u/get-id table-or-id) "/" ))) + +(s/defn adhoc-native-query-path :- ObjectPath "Return the native query read/write permissions path for a database. This grants you permissions to run arbitary native queries." - ^String [database-id] - (str (object-path database-id) "native/")) - -(defn ^:deprecated native-read-path - "Return the native query *read* permissions path for a database. - This grants you permissions to view the results of an *existing* native query, i.e. view native Cards created by - others. (Deprecated because native read permissions are being phased out in favor of Collections.)" - ^String [database-id] - (str (object-path database-id) "native/read/")) + [database-or-id :- MapOrID] + (str (object-path database-or-id) "native/")) -(defn all-schemas-path +(s/defn all-schemas-path :- ObjectPath "Return the permissions path for a database that grants full access to all schemas." - ^String [database-id] - (str (object-path database-id) "schema/")) - -(defn collection-read-path - "Return the permissions path for *read* access for a COLLECTION-OR-ID." - ^String [collection-or-id] - (str "/collection/" (u/get-id collection-or-id) "/read/")) + [database-or-id :- MapOrID] + (str (object-path database-or-id) "schema/")) -(defn collection-readwrite-path +(s/defn collection-readwrite-path :- ObjectPath "Return the permissions path for *readwrite* access for a COLLECTION-OR-ID." - ^String [collection-or-id] - (str "/collection/" (u/get-id collection-or-id) "/")) + [collection-or-id :- MapOrID] + (str "/collection/" + (if (get collection-or-id :metabase.models.collection/is-root?) + "root" + (u/get-id collection-or-id)) + "/")) + +(s/defn collection-read-path :- ObjectPath + "Return the permissions path for *read* access for a COLLECTION-OR-ID." + [collection-or-id :- MapOrID] + (str (collection-readwrite-path collection-or-id) "read/")) ;;; -------------------------------------------- Permissions Checking Fns -------------------------------------------- @@ -183,6 +188,33 @@ (every? (partial set-has-partial-permissions? permissions-set) object-paths-set)) +(s/defn perms-objects-set-for-parent-collection :- #{ObjectPath} + "Implementation of `IModel` `perms-objects-set` for models with a `collection_id`, such as Card, Dashboard, or Pulse. + This simply returns the `perms-objects-set` of the parent Collection (based on `collection_id`), or for the Root + Collection if `collection_id` is `nil`." + [this :- {:collection_id (s/maybe su/IntGreaterThanZero), s/Keyword s/Any} + read-or-write :- (s/enum :read :write)] + ;; based on value of read-or-write determine the approprite function used to calculate the perms path + (let [path-fn (case read-or-write + :read collection-read-path + :write collection-readwrite-path)] + ;; now pass that function our collection_id if we have one, or if not, pass it an object representing the Root + ;; Collection + #{(path-fn (or (:collection_id this) + {:metabase.models.collection/is-root? true}))})) + +(def IObjectPermissionsForParentCollection + "Implementation of `IObjectPermissions` for objects that have a `collection_id`, and thus, a parent Collection. + Using this will mean the current User is allowed to read or write these objects if they are allowed to read or + write their parent Collection." + (merge i/IObjectPermissionsDefaults + ;; TODO - we use these same partial implementations of `can-read?` and `can-write?` all over the place for + ;; different models. Consider making them a mixin of some sort. (I was going to do this but I couldn't come + ;; up with a good name for the Mixin. - Cam) + {:can-read? (partial i/current-user-has-full-permissions? :read) + :can-write? (partial i/current-user-has-full-permissions? :write) + :perms-objects-set perms-objects-set-for-parent-collection})) + ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | ENTITY + LIFECYCLE | @@ -223,7 +255,7 @@ {su/IntGreaterThanZero TablePermissionsGraph})) (def ^:private NativePermissionsGraph - (s/enum :write :read :none)) ; :read is DEPRECATED + (s/enum :write :none)) (def ^:private DBPermissionsGraph {(s/optional-key :native) NativePermissionsGraph @@ -279,10 +311,10 @@ (set-has-partial-permissions? permissions-set path) :some :else :none))) -(defn- table->native-readwrite-path [table] (native-readwrite-path (:db_id table))) -(defn- table->schema-object-path [table] (object-path (:db_id table) (:schema table))) -(defn- table->table-object-path [table] (object-path (:db_id table) (:schema table) (:id table))) -(defn- table->all-schemas-path [table] (all-schemas-path (:db_id table))) +(defn- table->adhoc-native-query-path [table] (adhoc-native-query-path (:db_id table))) +(defn- table->schema-object-path [table] (object-path (:db_id table) (:schema table))) +(defn- table->table-object-path [table] (object-path (:db_id table) (:schema table) (:id table))) +(defn- table->all-schemas-path [table] (all-schemas-path (:db_id table))) (s/defn ^:private schema-graph :- SchemaPermissionsGraph [permissions-set tables] @@ -293,7 +325,7 @@ {(u/get-id table) (permissions-for-path permissions-set (table->table-object-path table))})))) (s/defn ^:private db-graph :- DBPermissionsGraph [permissions-set tables] - {:native (case (permissions-for-path permissions-set (table->native-readwrite-path (first tables))) + {:native (case (permissions-for-path permissions-set (table->adhoc-native-query-path (first tables))) :all :write :some :read :none :none) @@ -369,18 +401,12 @@ (defn revoke-native-permissions! "Revoke all native query permissions for GROUP-OR-ID to database with DATABASE-ID." [group-or-id database-id] - (delete-related-permissions! group-or-id (native-readwrite-path database-id))) - -(defn ^:deprecated grant-native-read-permissions! - "Grant native *read* permissions for GROUP-OR-ID for database with DATABASE-ID. - (Deprecated because native read permissions are being phased out in favor of Card Collections.)" - [group-or-id database-id] - (grant-permissions! group-or-id (native-read-path database-id))) + (delete-related-permissions! group-or-id (adhoc-native-query-path database-id))) (defn grant-native-readwrite-permissions! "Grant full readwrite permissions for GROUP-OR-ID to database with DATABASE-ID." [group-or-id database-id] - (grant-permissions! group-or-id (native-readwrite-path database-id))) + (grant-permissions! group-or-id (adhoc-native-query-path database-id))) (defn revoke-db-schema-permissions! "Remove all permissions entires for a DB and *any* child objects. @@ -388,8 +414,7 @@ [group-or-id database-id] ;; TODO - if permissions for this DB are DB root entries like `/db/1/` won't this end up removing our native perms? (delete-related-permissions! group-or-id (object-path database-id) - [:not= :object (native-readwrite-path database-id)] - [:not= :object (native-read-path database-id)])) + [:not= :object (adhoc-native-query-path database-id)])) (defn grant-permissions-for-all-schemas! "Grant full permissions for all schemas belonging to this database. @@ -429,26 +454,28 @@ :none (revoke-permissions! group-id db-id schema table-id))) (s/defn ^:private update-schema-perms! - [group-id :- su/IntGreaterThanZero, db-id :- su/IntGreaterThanZero, schema :- s/Str, new-schema-perms :- SchemaPermissionsGraph] + [group-id :- su/IntGreaterThanZero + db-id :- su/IntGreaterThanZero + schema :- s/Str + new-schema-perms :- SchemaPermissionsGraph] (cond - (= new-schema-perms :all) (do (revoke-permissions! group-id db-id schema) ; clear out any existing related permissions - (grant-permissions! group-id db-id schema)) ; then grant full perms for the schema + (= new-schema-perms :all) (do (revoke-permissions! group-id db-id schema) ; clear out any existing related permissions + (grant-permissions! group-id db-id schema)) ; then grant full perms for the schema (= new-schema-perms :none) (revoke-permissions! group-id db-id schema) (map? new-schema-perms) (doseq [[table-id table-perms] new-schema-perms] (update-table-perms! group-id db-id schema table-id table-perms)))) (s/defn ^:private update-native-permissions! [group-id :- su/IntGreaterThanZero, db-id :- su/IntGreaterThanZero, new-native-perms :- NativePermissionsGraph] - ;; revoke-native-permissions! will delete all entires that would give permissions for native access. - ;; Thus if you had a root DB entry like `/db/11/` this will delete that too. - ;; In that case we want to create a new full schemas entry so you don't lose access to all schemas when we modify native access. + ;; revoke-native-permissions! will delete all entires that would give permissions for native access. Thus if you had + ;; a root DB entry like `/db/11/` this will delete that too. In that case we want to create a new full schemas entry + ;; so you don't lose access to all schemas when we modify native access. (let [has-full-access? (db/exists? Permissions :group_id group-id, :object (object-path db-id))] (revoke-native-permissions! group-id db-id) (when has-full-access? (grant-permissions-for-all-schemas! group-id db-id))) (case new-native-perms :write (grant-native-readwrite-permissions! group-id db-id) - :read (grant-native-read-permissions! group-id db-id) :none nil)) diff --git a/src/metabase/models/pulse.clj b/src/metabase/models/pulse.clj index 3ebc54988669f827bb5c92d8c2a51befca8da633..771f55de4d9a0411c3ae95f93fd95fc04ea96a21 100644 --- a/src/metabase/models/pulse.clj +++ b/src/metabase/models/pulse.clj @@ -14,17 +14,15 @@ functions for fetching a specific Pulse). At some point in the future, we can clean this namespace up and bring the code in line with the rest of the codebase, but for the time being, it probably makes sense to follow the existing patterns in this namespace rather than further confuse things." - (:require [clojure.set :as set] - [clojure.tools.logging :as log] + (:require [clojure.tools.logging :as log] [medley.core :as m] [metabase [events :as events] [util :as u]] - [metabase.api.common :refer [*current-user* *current-user-id*]] [metabase.models [card :refer [Card]] - [collection :as collection] [interface :as i] + [permissions :as perms] [pulse-card :refer [PulseCard]] [pulse-channel :as pulse-channel :refer [PulseChannel]] [pulse-channel-recipient :refer [PulseChannelRecipient]]] @@ -35,66 +33,6 @@ [hydrate :refer [hydrate]] [models :as models]])) -;;; ------------------------------------------------- Perms Checking ------------------------------------------------- - -(defn- channels-with-recipients - "Get the 'channels' associated with this `notification`, including recipients of those 'channels'. If `:channels` is - already hydrated, as it will be when using `retrieve-pulses`, this doesn't need to make any DB calls." - [notifcation] - (or (:channels notifcation) - (-> (db/select PulseChannel, :pulse_id (u/get-id notifcation)) - (hydrate :recipients)))) - -(defn- emails - "Get the set of emails this `notification` will be sent to." - [notification] - (set (for [channel (channels-with-recipients notification) - recipient (:recipients channel)] - (:email recipient)))) - -(defn- notification-perms-based-on-cards - "Calculate permissions required to read a `notification` based on its Cards alone. Unlike Dashboards, to view or edit - a Notification you must have permissions to do the same for *all* the Cards in the Notification." - ;; TODO - I don't think the Permissions for Notification make a ton of sense. Shouldn't you just need read - ;; permissions for all the Cards in the Notification in order to edit it? Either way it doesn't matter a ton since - ;; these artifact perms are being phased out. - [notification] - (set - (when-let [card-ids (seq (db/select-field :card_id PulseCard, :pulse_id (u/get-id notification)))] - (reduce set/union (for [card (db/select [Card :public_uuid :dataset_query :read_permissions], :id [:in card-ids])] - (i/perms-objects-set card :read)))))) - -(defn- current-user-is-recipient? [notification] - (contains? (emails notification) (:email @*current-user*))) - -(defn- perms-objects-set - "Calculate the set of permissions required to `read-or-write` a `notification`." - [{collection-id :collection_id, creator-id :creator_id, :as notification} read-or-write] - (cond - ;; First things first: - ;; * A User can *read* a Notification if they are a recipient - ;; * A User can *write* a Notification if they are a recipient *and* the original creator - (and (current-user-is-recipient? notification) - (or (= read-or-write :read) - (and (= creator-id *current-user-id*)))) - #{} - - ;; if this Pulse is in a Collection you're allowed to read or write the Pulse based on your permissions for the - ;; Collection - collection-id - (collection/perms-objects-set collection-id read-or-write) - - ;; If the Notification is not in a Collection and you are not a recipient and the original creator, you're not - ;; allowed to edit it unless you're an admin... - (= read-or-write :write) - #{"/"} - - ;; ...but for read permissions, fall back to the traditional artifact-based Permissions. At some point in the - ;; furture this entry will be phased out when we move to the 'everything is in a Collection' model. - :else - (notification-perms-based-on-cards notification))) - - ;;; ----------------------------------------------- Entity & Lifecycle ----------------------------------------------- (models/defmodel Pulse :pulse) @@ -103,6 +41,27 @@ (doseq [model [PulseCard PulseChannel]] (db/delete! model :pulse_id (u/get-id notification)))) +(defn- alert->card + "Return the Card associated with an Alert, fetching it if needed, for permissions-checking purposes." + [alert] + (or + ;; if `card` is already present as a top-level key we can just use that directly + (:card alert) + ;; otherwise fetch the associated `:cards` (if not already fetched) and then pull the first one out, since Alerts + ;; can only have one Card + (-> (hydrate alert :cards) :cards first))) + +(defn- perms-objects-set + "Permissions to read or write a *Pulse* are the same as those of its parent Collection. + + Permissions to read or write an *Alert* are the same as those of its 'parent' *Card*. For all intents and purposes, + an Alert cannot be put into a Collection." + [notification read-or-write] + (let [is-alert? (boolean (:alert_condition notification))] + (if is-alert? + (i/perms-objects-set (alert->card notification) read-or-write) + (perms/perms-objects-set-for-parent-collection notification read-or-write)))) + (u/strict-extend (class Pulse) models/IModel (merge models/IModelDefaults @@ -110,31 +69,30 @@ :properties (constantly {:timestamped? true}) :pre-delete pre-delete}) i/IObjectPermissions - (merge i/IObjectPermissionsDefaults - {:perms-objects-set perms-objects-set - :can-read? (partial i/current-user-has-full-permissions? :read) - :can-write? (partial i/current-user-has-full-permissions? :write)})) + {:can-read? (partial i/current-user-has-full-permissions? :read) + :can-write? (partial i/current-user-has-full-permissions? :write) + :perms-objects-set perms-objects-set}) ;;; --------------------------------------------------- Hydration ---------------------------------------------------- (defn ^:hydrate channels - "Return the PulseChannels associated with this PULSE." - [{:keys [id]}] - (db/select PulseChannel, :pulse_id id)) + "Return the PulseChannels associated with this `notification`." + [notification-or-id] + (db/select PulseChannel, :pulse_id (u/get-id notification-or-id))) (defn ^:hydrate cards - "Return the `Cards` associated with this PULSE." - [{:keys [id]}] - (map #(models/do-post-select Card %) + "Return the Cards associated with this `notification`." + [notification-or-id] + (map (partial models/do-post-select Card) (db/query - {:select [:c.id :c.name :c.description :c.display :pc.include_csv :pc.include_xls] + {:select [:c.id :c.name :c.description :c.collection_id :c.display :pc.include_csv :pc.include_xls] :from [[Pulse :p]] :join [[PulseCard :pc] [:= :p.id :pc.pulse_id] [Card :c] [:= :c.id :pc.card_id]] :where [:and - [:= :p.id id] + [:= :p.id (u/get-id notification-or-id)] [:= :c.archived false]] :order-by [[:pc.position :asc]]}))) diff --git a/src/metabase/models/query.clj b/src/metabase/models/query.clj index d6b88581bfacc06a50db025355872ac6aa56e733..84b989cb4586066aeaab954c013ce5cbe4911dd3 100644 --- a/src/metabase/models/query.clj +++ b/src/metabase/models/query.clj @@ -1,4 +1,5 @@ (ns metabase.models.query + "Functions related to the 'Query' model, which records stuff such as average query execution time." (:require [metabase.db :as mdb] [metabase.query-processor.util :as qputil] [metabase.util.honeysql-extensions :as hx] @@ -50,8 +51,8 @@ (or ;; if there's already a matching Query update the rolling average (update-rolling-average-execution-time! query-hash execution-time-ms) - ;; otherwise try adding a new entry. If for some reason there was a race condition and a Query entry was added in the meantime - ;; we'll try updating that existing record + ;; otherwise try adding a new entry. If for some reason there was a race condition and a Query entry was added in + ;; the meantime we'll try updating that existing record (try (record-new-execution-time! query-hash execution-time-ms) (catch Throwable e (or (update-rolling-average-execution-time! query-hash execution-time-ms) diff --git a/src/metabase/models/query/permissions.clj b/src/metabase/models/query/permissions.clj new file mode 100644 index 0000000000000000000000000000000000000000..5bb219184acd0f3d1be96eff9b1b1efc8be2590c --- /dev/null +++ b/src/metabase/models/query/permissions.clj @@ -0,0 +1,105 @@ +(ns metabase.models.query.permissions + "Functions used to calculate the permissions needed to run a query based on old-style DATA ACCESS PERMISSIONS. The + only thing that is subject to these sorts of checks are *ad-hoc* queries, i.e. queries that have not yet been saved + as a Card. Saved Cards are subject to the permissions of the Collection to which they belong." + (:require [clojure.tools.logging :as log] + [metabase.models + [interface :as i] + [permissions :as perms]] + [metabase.query-processor.util :as qputil] + [metabase.util :as u] + [metabase.util.schema :as su] + [puppetlabs.i18n.core :refer [tru]] + [schema.core :as s] + [toucan.db :as db])) + +;;; ---------------------------------------------- Permissions Checking ---------------------------------------------- + +;; Is calculating permissions for queries complicated? Some would say so. Refer to this handy flow chart to see how +;; things get calculated. +;; +;; perms-set +;; | +;; | +;; | +;; native query? <------+-----> mbql query? +;; ↓ ↓ +;; adhoc-native-query-path mbql-perms-path-set +;; | +;; no source card <------+----> has source card +;; ↓ ↓ +;; tables->permissions-path-set source-card-read-perms + +(defn- query->source-and-join-tables + "Return a sequence of all Tables (as TableInstance maps) referenced by QUERY." + [{:keys [source-table join-tables source-query native], :as query}] + (cond + ;; if we come across a native query just put a placeholder (`::native`) there so we know we need to add native + ;; permissions to the complete set below. + native [::native] + ;; if we have a source-query just recur until we hit either the native source or the MBQL source + source-query (recur source-query) + ;; for root MBQL queries just return source-table + join-tables + :else (cons source-table join-tables))) + +(s/defn ^:private tables->permissions-path-set :- #{perms/ObjectPath} + "Given a sequence of `tables` referenced by a query, return a set of required permissions." + [database-or-id tables] + (set (for [table tables] + (if (= ::native table) + ;; Any `::native` placeholders from above mean we need native ad-hoc query permissions for this DATABASE + (perms/adhoc-native-query-path database-or-id) + ;; anything else (i.e., a normal table) just gets normal table permissions + (perms/object-path (u/get-id database-or-id) + (:schema table) + (or (:id table) (:table-id table))))))) + +(s/defn ^:private source-card-read-perms :- #{perms/ObjectPath} + "Calculate the permissions needed to run an ad-hoc query that uses a Card with `source-card-id` as its source + query." + [source-card-id :- su/IntGreaterThanZero] + (i/perms-objects-set (or (db/select-one ['Card :collection_id] :id source-card-id) + (throw (Exception. (str (tru "Card {0} does not exist." source-card-id))))) + :read)) + +(defn- expand-query-if-needed [query] + (if (map? (:database query)) + query + ((resolve 'metabase.query-processor/expand) query))) + +;; TODO - not sure how we can prevent circular source Cards if source Cards permissions are just collection perms now??? +(s/defn ^:private mbql-permissions-path-set :- #{perms/ObjectPath} + "Return the set of required permissions needed to run an adhoc `query`. + + Also optionally specify `throw-exceptions?` -- normally this function avoids throwing Exceptions to avoid breaking + things when a single Card is busted (e.g. API endpoints that filter out unreadable Cards) and instead returns 'only + admins can see this' permissions -- `#{\"db/0\"}` (DB 0 will never exist, thus normal users will never be able to + get permissions for it, but admins have root perms and will still get to see (and hopefully fix) it)." + [query :- {:query su/Map, s/Keyword s/Any} & [throw-exceptions? :- (s/maybe (s/eq :throw-exceptions))]] + (try + ;; if we are using a Card as our perms are that Card's (i.e. that Card's Collection's) read perms + (if-let [source-card-id (qputil/query->source-card-id query)] + (source-card-read-perms source-card-id) + ;; otherwise if there's no source card then calculate perms based on the Tables referenced in the query + (let [{:keys [query database]} (expand-query-if-needed query)] + (tables->permissions-path-set database (query->source-and-join-tables query)))) + ;; if for some reason we can't expand the Card (i.e. it's an invalid legacy card) just return a set of permissions + ;; that means no one will ever get to see it (except for superusers who get to see everything) + (catch Throwable e + (when throw-exceptions? + (throw e)) + (log/warn (tru "Error calculating permissions for query: {0}" (.getMessage e)) + "\n" + (u/pprint-to-str (u/filtered-stacktrace e))) + #{"/db/0/"}))) ; DB 0 will never exist + +(s/defn perms-set :- #{perms/ObjectPath} + "Calculate the set of permissions required to run an ad-hoc `query`." + {:arglists '([outer-query & [throw-exceptions?]])} + ;; TODO - I think we can remove the two optional params because nothing uses them anymore + [{query-type :type, database :database, :as query} & [throw-exceptions? :- (s/maybe (s/eq :throw-exceptions))]] + (cond + (empty? query) #{} + (= (keyword query-type) :native) #{(perms/adhoc-native-query-path database)} + (= (keyword query-type) :query) (mbql-permissions-path-set query throw-exceptions?) + :else (throw (Exception. (str (tru "Invalid query type: {0}" query-type)))))) diff --git a/src/metabase/models/user.clj b/src/metabase/models/user.clj index 6fdfa050a44d1c9aa0d6f0e63cac7bd5816c7e18..50e9076814c542fa122c2bcb5ee67f210d8bbcc4 100644 --- a/src/metabase/models/user.clj +++ b/src/metabase/models/user.clj @@ -1,6 +1,6 @@ (ns metabase.models.user (:require [cemerick.friend.credentials :as creds] - [clojure.string :as s] + [clojure.string :as str] [clojure.tools.logging :as log] [metabase [public-settings :as public-settings] @@ -9,7 +9,10 @@ [metabase.models [permissions-group :as group] [permissions-group-membership :as perm-membership :refer [PermissionsGroupMembership]]] - [metabase.util.date :as du] + [metabase.util + [date :as du] + [schema :as su]] + [schema.core :as s] [toucan [db :as db] [models :as models]]) @@ -23,7 +26,7 @@ (assert (u/email? email) (format "Not a valid email: '%s'" email)) (assert (and (string? password) - (not (s/blank? password)))) + (not (str/blank? password)))) (assert (not (:password_salt user)) "Don't try to pass an encrypted password to (insert! User). Password encryption is handled by pre-insert.") (let [salt (str (UUID/randomUUID)) @@ -102,7 +105,7 @@ (def all-user-fields "Seq of all the columns stored for a user" - (vec (concat default-user-fields [:google_auth :ldap_auth :is_active :updated_at]))) + (vec (concat default-user-fields [:google_auth :ldap_auth :is_active :updated_at :login_attributes]))) (u/strict-extend (class User) models/IModel @@ -114,7 +117,8 @@ :post-insert post-insert :pre-update pre-update :post-select post-select - :pre-delete pre-delete})) + :pre-delete pre-delete + :types (constantly {:login_attributes :json})})) ;;; --------------------------------------------------- Helper Fns --------------------------------------------------- @@ -127,42 +131,51 @@ join-url (str (form-password-reset-url reset-token) "#new")] (email/send-new-user-email! new-user invitor join-url))) -(defn invite-user! +(def LoginAttributes + "Login attributes, currently not collected for LDAP or Google Auth. Will ultimately be stored as JSON" + {su/KeywordOrString (s/cond-pre s/Str s/Num)}) + +(def NewUser + "Required/optionals parameters needed to create a new user (for any backend)" + {:first_name su/NonBlankString + :last_name su/NonBlankString + :email su/Email + (s/optional-key :password) (s/maybe su/NonBlankString) + (s/optional-key :login_attributes) (s/maybe LoginAttributes) + (s/optional-key :google_auth) s/Bool + (s/optional-key :ldap_auth) s/Bool}) + +(def ^:private Invitor + "Map with info about the admin admin creating the user, used in the new user notification code" + {:email su/Email + :first_name su/NonBlankString + s/Any s/Any}) + +(s/defn ^:private insert-new-user! + "Creates a new user, defaulting the password when not provided" + [new-user :- NewUser] + (db/insert! User (update new-user :password #(or % (str (UUID/randomUUID)))))) + +(s/defn invite-user! "Convenience function for inviting a new `User` and sending out the welcome email." - [first-name last-name email-address password invitor] - {:pre [(string? first-name) (string? last-name) (u/email? email-address) (u/maybe? string? password) (map? invitor)]} + [new-user :- NewUser, invitor :- Invitor] ;; create the new user - (u/prog1 (db/insert! User - :email email-address - :first_name first-name - :last_name last-name - :password (or password (str (UUID/randomUUID)))) + (u/prog1 (insert-new-user! new-user) (send-welcome-email! <> invitor))) -(defn create-new-google-auth-user! +(s/defn create-new-google-auth-user! "Convenience for creating a new user via Google Auth. This account is considered active immediately; thus all active admins will recieve an email right away." - [first-name last-name email-address] - {:pre [(string? first-name) (string? last-name) (u/email? email-address)]} - (u/prog1 (db/insert! User - :email email-address - :first_name first-name - :last_name last-name - :password (str (UUID/randomUUID)) - :google_auth true) + [new-user :- NewUser] + (u/prog1 (insert-new-user! (assoc new-user :google_auth true)) ;; send an email to everyone including the site admin if that's set (email/send-user-joined-admin-notification-email! <>, :google-auth? true))) -(defn create-new-ldap-auth-user! +(s/defn create-new-ldap-auth-user! "Convenience for creating a new user via LDAP. This account is considered active immediately; thus all active admins will recieve an email right away." - [first-name last-name email-address password] - {:pre [(string? first-name) (string? last-name) (u/email? email-address)]} - (db/insert! User :email email-address - :first_name first-name - :last_name last-name - :password password - :ldap_auth true)) + [new-user :- NewUser] + (insert-new-user! (assoc new-user :ldap_auth true))) (defn set-password! "Updates the stored password for a specified `User` by hashing the password with a random salt." diff --git a/src/metabase/query_processor.clj b/src/metabase/query_processor.clj index 103529e6431e259b616707d4a53aea1585bcd063..6f65bcdb304847f60629f4a266f5e6bf3782ddf6 100644 --- a/src/metabase/query_processor.clj +++ b/src/metabase/query_processor.clj @@ -115,7 +115,8 @@ cache/maybe-return-cached-results log-query/log-results-metadata catch-exceptions/catch-exceptions)) -;; ▲▲▲ PRE-PROCESSING ▲▲▲ happens from BOTTOM-TO-TOP, e.g. the results of `expand-macros` are (eventually) passed to `expand-resolve` +;; ▲▲▲ PRE-PROCESSING ▲▲▲ happens from BOTTOM-TO-TOP, e.g. the results of `expand-macros` are passed to +;; `substitute-parameters` (defn query->native "Return the native form for QUERY (e.g. for a MBQL query on Postgres this would return a map containing the compiled diff --git a/src/metabase/query_processor/middleware/permissions.clj b/src/metabase/query_processor/middleware/permissions.clj index 02130fde5c6193abfdb99f73136e057c516bf419..931bd91917b1d8d4820ecface7e9bdd50babc0cd 100644 --- a/src/metabase/query_processor/middleware/permissions.clj +++ b/src/metabase/query_processor/middleware/permissions.clj @@ -1,177 +1,37 @@ (ns metabase.query-processor.middleware.permissions "Middleware for checking that the current user has permissions to run the current query." - (:require [clojure.tools.logging :as log] - [honeysql.core :as hsql] - [metabase.api.common :refer [*current-user-id* *current-user-permissions-set*]] - [metabase.models.permissions :as perms] - [metabase.util :as u] - [metabase.util.honeysql-extensions :as hx] - [toucan - [db :as db] - [hydrate :refer [hydrate]]])) - -;;; --------------------------------------------------- Helper Fns --------------------------------------------------- - -(defn- log-permissions-debug-message {:style/indent 2} [color format-str & format-args] - (let [appropriate-lock-emoji (if (= color 'yellow) - "🔒" ; lock (closed) - "🔓")] ; lock (open - (log/debug (u/format-color color (apply format (format "Permissions Check %s : %s" appropriate-lock-emoji format-str) - format-args))))) - -(defn- log-permissions-success-message {:style/indent 1} [format-string & format-args] - (log-permissions-debug-message 'green (str "Yes ✅ " (apply format format-string format-args)))) - -;; DEPRECATED because to use this function we need to have an actual `Permissions` object instead of being able to use -;; *current-user-permissions-set*. -;; Use `log-permissions-success-message` instead. -(defn- ^:deprecated log-permissions-success [user-id permissions] - (log-permissions-success-message "because User %d is a member of Group %d (%s) which has permissions for '%s'" - user-id - (:group_id permissions) - (db/select-one-field :name 'PermissionsGroup :id (:group_id permissions)) - (:object permissions))) - -(defn- log-permissions-error [] - (log/warn (u/format-color 'red "Permissions Check 🔠: No 🚫"))) ; lock (closed) - -;; TODO - what status code / error message should we use when someone doesn't have permissions? -(defn- throw-permissions-exception [format-str & format-args] - (log-permissions-error) - (throw (Exception. ^String (apply format format-str format-args)))) - -;; DEPRECATED because we should just check it the "new" way instead: -;; (perms/set-has-full-permissions? @*current-user-permissions-set* object-path) -(defn- ^:deprecated permissions-for-object - "Return the first `Permissions` entry for USER-ID that grants permissions to OBJECT-PATH." - [user-id object-path] - {:pre [(integer? user-id) (perms/valid-object-path? object-path)]} - (u/prog1 (db/select-one 'Permissions - {:where [:and [:in :group_id (db/select-field :group_id 'PermissionsGroupMembership :user_id user-id)] - [:like object-path (hx/concat :object (hx/literal "%"))]]}) - (when <> - (log-permissions-success user-id <>)))) - - -;;; ------------------------------------------ Permissions for MBQL queries ------------------------------------------ - -;; TODO - All of this below should be rewritten to use `*current-user-permissions-set*` and -;; `metabase.models.card/query-perms-set` instead. The functions that need to be reworked are marked DEPRECATED below. - -(defn- ^:deprecated user-can-run-query-referencing-table? - "Does User with USER-ID have appropriate permissions to run an MBQL query referencing table with TABLE-ID?" - [user-id table-id] - {:pre [(integer? user-id) (integer? table-id)]} - (let [{:keys [schema database-id]} (db/select-one ['Table [:db_id :database-id] :schema] :id table-id)] - (permissions-for-object user-id (perms/object-path database-id schema table-id)))) - - -(defn- ^:deprecated table-id [source-or-join-table] - {:post [(integer? %)]} - (or (:id source-or-join-table) - (:table-id source-or-join-table))) - -(defn- ^:deprecated table-identifier ^String [source-or-join-table] - (name (hsql/qualify (:schema source-or-join-table) (or (:name source-or-join-table) - (:table-name source-or-join-table))))) - - -(defn- ^:deprecated throw-if-cannot-run-query-referencing-table [user-id table] - (log-permissions-debug-message 'yellow "Can User %d access Table %d (%s)?" - user-id (table-id table) (table-identifier table)) - (or (user-can-run-query-referencing-table? user-id (table-id table)) - (throw-permissions-exception "You do not have permissions to run queries referencing table '%s'." - (table-identifier table)))) - -;; TODO - why is this the only function here that takes `user-id`? -(defn- throw-if-cannot-run-query - "Throw an exception if USER-ID doesn't have permissions to run QUERY." - [user-id {:keys [source-table join-tables source-query]}] - (if source-query - (recur user-id source-query) - (doseq [table (cons source-table join-tables)] - (throw-if-cannot-run-query-referencing-table user-id table)))) - - -;;; ----------------------------------------- Permissions for Native Queries ----------------------------------------- - -(defn- throw-if-user-doesnt-have-permissions-for-path - "Check whether current user has permissions for OBJECT-PATH, and throw an exception if not. - Log messages related to the permissions checks as well." - [object-path] - (log-permissions-debug-message 'yellow "Does user have permissions for %s?" object-path) - (when-not (perms/set-has-full-permissions? @*current-user-permissions-set* object-path) - (throw-permissions-exception "You do not have read permissions for %s." object-path)) - ;; permissions check out, now log which perms we've been granted that allowed our escapades to proceed - (log-permissions-success-message "because user has permissions for %s." - (some (fn [permissions-path] - (when (perms/is-permissions-for-object? permissions-path object-path) - permissions-path)) - @*current-user-permissions-set*))) - -(defn throw-if-cannot-run-new-native-query-referencing-db - "Throw an exception if User with USER-ID doesn't have native query *readwrite* permissions for DATABASE." - [database-or-id] - (throw-if-user-doesnt-have-permissions-for-path (perms/native-readwrite-path (u/get-id database-or-id)))) - -(defn- ^:deprecated throw-if-cannot-run-existing-native-query-referencing-db - "Throw an exception if User with USER-ID doesn't have native query *read* permissions for DATABASE. - (DEPRECATED because native read permissions are being eliminated in favor of Collection permissions.)" - [database-or-id] - (throw-if-user-doesnt-have-permissions-for-path (perms/native-read-path (u/get-id database-or-id)))) - -(defn- throw-if-user-doesnt-have-access-to-collection - "Throw an exception if the current User doesn't have permissions to run a Card that is part of COLLECTION." - [collection-id] - (throw-if-user-doesnt-have-permissions-for-path (perms/collection-read-path collection-id))) - - -;;; --------------------------------------------------- Middleware --------------------------------------------------- - -(defn- check-query-permissions-for-user - "Check that User with USER-ID has permissions to run QUERY, or throw an exception." - [user-id {query-type :type - database :database - {:keys [source-query], :as query} :query - {:keys [card-id]} :info - :as outer-query}] - {:pre [(integer? user-id) (map? outer-query)]} - (let [native? (= (keyword query-type) :native) - - {collection-id :collection_id, public-uuid :public_uuid, in-public-dash? :in_public_dashboard} - (-> (db/select-one ['Card :id :collection_id :public_uuid] :id card-id) - (hydrate :in_public_dashboard))] - ;; TODO - repeating all this logic below is DUMB. Why can't we just use `can-read?` on the Card like we do - ;; everywhere else? Now we effectively have two places where we have to keep that logic in sync - (cond - ;; if the card itself is public, or if its in a Public dashboard, you are always allowed to run its query - (or public-uuid in-public-dash?) - :ok - - ;; if the card is in a COLLECTION, then see if the current user has permissions for that collection - collection-id - (throw-if-user-doesnt-have-access-to-collection collection-id) - ;; Otherwise if this is a NESTED query then we should check permissions for the source query - source-query - (if (:native source-query) - (throw-if-cannot-run-existing-native-query-referencing-db database) - (throw-if-cannot-run-query user-id source-query)) - ;; for native queries that are *not* part of an existing card, check that we have native permissions for the DB - (and native? (not card-id)) - (throw-if-cannot-run-new-native-query-referencing-db database) - ;; for native queries that *are* part of an existing card, just check if the have native read permissions - ;; (DEPRECATED) - native? - (throw-if-cannot-run-existing-native-query-referencing-db database) - ;; for MBQL queries (existing card or not), check that we can run against the source-table. and each of the - ;; join-tables, if any - (not native?) - (throw-if-cannot-run-query user-id query)))) - -(defn- check-query-permissions* [query] - (u/prog1 query - (when *current-user-id* - (check-query-permissions-for-user *current-user-id* query)))) + (:require [metabase.api.common :refer [*current-user-id* *current-user-permissions-set*]] + [metabase.models + [card :refer [Card]] + [interface :as mi] + [permissions :as perms]] + [metabase.models.query.permissions :as query-perms] + [metabase.util.schema :as su] + [puppetlabs.i18n.core :refer [tru]] + [schema.core :as s] + [toucan.db :as db])) + +(s/defn ^:private check-card-read-perms + "Check that the current user has permissions to read Card with `card-id`, or throw an Exception. " + [card-id :- su/IntGreaterThanZero] + (when-not (mi/can-read? (or (db/select-one [Card :collection_id] :id card-id) + (throw (Exception. (str (tru "Card {0} does not exist." card-id)))))) + (throw (Exception. (str (tru "You do not have permissions to view Card {0}." card-id)))))) + +(s/defn ^:private check-ad-hoc-query-perms + [outer-query] + (when-not (perms/set-has-full-permissions-for-set? @*current-user-permissions-set* + (query-perms/perms-set outer-query :throw-exceptions)) + (throw (Exception. (str (tru "You do not have permissions to run this query.")))))) + +(s/defn ^:private check-query-permissions* + "Check that User with `user-id` has permissions to run `query`, or throw an exception." + [{{:keys [card-id]} :info, :as outer-query} :- su/Map] + (when *current-user-id* + (if card-id + (check-card-read-perms card-id) + (check-ad-hoc-query-perms outer-query))) + outer-query) (defn check-query-permissions "Middleware that check that the current user has permissions to run the current query. This only applies if diff --git a/test/metabase/api/activity_test.clj b/test/metabase/api/activity_test.clj index 32ffce057f5810e855389135a433b2ddecd3087d..f5bd666940bb601b5c67c575625eaeecaa79bf6b 100644 --- a/test/metabase/api/activity_test.clj +++ b/test/metabase/api/activity_test.clj @@ -9,7 +9,6 @@ [view-log :refer [ViewLog]]] [metabase.test.data.users :refer :all] [metabase.test.util :as tu :refer [match-$]] - [metabase.util :as u] [metabase.util.date :as du] [toucan.db :as db] [toucan.util.test :as tt])) @@ -128,9 +127,9 @@ :display "table" :dataset_query {} :visualization_settings {}}] - Dashboard [dash1 {:name "rand-name" - :description "rand-name" - :creator_id (user->id :crowberto)}] + Dashboard [dash1 {:name "rand-name" + :description "rand-name" + :creator_id (user->id :crowberto)}] Card [card2 {:name "rand-name" :creator_id (user->id :crowberto) :display "table" @@ -140,25 +139,28 @@ :user_id (user->id :crowberto) :model "card" :model_id (:id card1) - :model_object {:id (:id card1) - :name (:name card1) - :description (:description card1) - :display (name (:display card1))}} + :model_object {:id (:id card1) + :name (:name card1) + :collection_id nil + :description (:description card1) + :display (name (:display card1))}} {:cnt 1 :user_id (user->id :crowberto) :model "dashboard" :model_id (:id dash1) - :model_object {:id (:id dash1) - :name (:name dash1) - :description (:description dash1)}} + :model_object {:id (:id dash1) + :name (:name dash1) + :collection_id nil + :description (:description dash1)}} {:cnt 1 :user_id (user->id :crowberto) :model "card" :model_id (:id card2) - :model_object {:id (:id card2) - :name (:name card2) - :description (:description card2) - :display (name (:display card2))}}] + :model_object {:id (:id card2) + :name (:name card2) + :collection_id nil + :description (:description card2) + :display (name (:display card2))}}] (do (create-view! (user->id :crowberto) "card" (:id card2)) (create-view! (user->id :crowberto) "dashboard" (:id dash1)) diff --git a/test/metabase/api/alert_test.clj b/test/metabase/api/alert_test.clj index ddb50e4b35ff7d5ec87dad3e14fd55805fd87931..1f05460864a95db3bfc4b7db7a937e7694c8a45e 100644 --- a/test/metabase/api/alert_test.clj +++ b/test/metabase/api/alert_test.clj @@ -6,6 +6,7 @@ [http-client :as http] [middleware :as middleware] [util :as u]] + [metabase.api.card-test :as card-api-test] [metabase.models [card :refer [Card]] [collection :refer [Collection]] @@ -21,9 +22,15 @@ [util :as tu]] [metabase.test.data.users :as users :refer :all] [metabase.test.mock.util :refer [pulse-channel-defaults]] - [toucan.db :as db] + [toucan + [db :as db] + [hydrate :refer [hydrate]]] [toucan.util.test :as tt])) +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | Helper Fns & Macros | +;;; +----------------------------------------------------------------------------------------------------------------+ + (defn- user-details [user-kwd] (-> user-kwd fetch-user @@ -34,6 +41,7 @@ (-> card (select-keys [:name :description :display]) (update :display name) + (update :collection_id boolean) (assoc :id true, :include_csv false, :include_xls false))) (defn- recipient-details [user-kwd] @@ -58,7 +66,61 @@ :recipients recipients :details {}})) -;; ## /api/alert/* AUTHENTICATION Tests +(defmacro ^:private with-test-email [& body] + `(tu/with-temporary-setting-values [~'site-url "https://metabase.com/testmb"] + (et/with-fake-inbox + ~@body))) + +(defmacro ^:private with-alert-setup + "Macro that will cleanup any created pulses and setups a fake-inbox to validate emails are sent for new alerts" + [& body] + `(tu/with-model-cleanup [Pulse] + (with-test-email + ~@body))) + +(defmacro ^:private with-alert-in-collection + "Do `body` with a temporary Alert whose Card is in a Collection, setting the stage to write various tests below." + {:style/indent 1} + [[db-binding collection-binding alert-binding card-binding] & body] + `(pulse-test/with-pulse-in-collection [~(or db-binding '_) collection# alert# card#] + ;; Make this Alert actually be an alert + (db/update! Pulse (u/get-id alert#) :alert_condition "rows") + ;; Since Alerts do not actually go in Collections, but rather their Cards do, put the Card in the Collection + (db/update! Card (u/get-id card#) :collection_id (u/get-id collection#)) + (let [~(or alert-binding '_) alert# + ~(or collection-binding '_) collection# + ~(or card-binding '_) card#] + ~@body))) + +;; This stuff below is separate from `with-alert-in-collection` above! +(defn- do-with-alerts-in-a-collection + "Do `f` with the Cards associated with `alerts-or-ids` in a new temporary Collection. Grant perms to All Users to that + Collection using `f`. + + (The name of this function is somewhat of a misnomer since the Alerts themselves aren't in Collections; it is their + Cards that are. Alerts do not go in Collections; their perms are derived from their Cards.)" + [grant-collection-perms-fn! alerts-or-ids f] + (tt/with-temp Collection [collection] + (grant-collection-perms-fn! (group/all-users) collection) + ;; Go ahead and put all the Cards for all of the Alerts in the temp Collection + (when (seq alerts-or-ids) + (doseq [alert (hydrate (db/select Pulse :id [:in (map u/get-id alerts-or-ids)]) + :cards) + card (:cards alert)] + (db/update! Card (u/get-id card) :collection_id (u/get-id collection)))) + (f))) + +(defmacro ^:private with-alerts-in-readable-collection [alerts-or-ids & body] + `(do-with-alerts-in-a-collection perms/grant-collection-read-permissions! ~alerts-or-ids (fn [] ~@body))) + +(defmacro ^:private with-alerts-in-writeable-collection [alerts-or-ids & body] + `(do-with-alerts-in-a-collection perms/grant-collection-readwrite-permissions! ~alerts-or-ids (fn [] ~@body))) + + +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | /api/alert/* AUTHENTICATION Tests | +;;; +----------------------------------------------------------------------------------------------------------------+ + ;; We assume that all endpoints for a given context are enforced by the same middleware, so we don't run the same ;; authentication test on every single individual endpoint @@ -104,17 +166,6 @@ :card {:id 100, :include_csv false, :include_xls false} :channels ["abc"]})) -(defmacro ^:private with-test-email [& body] - `(tu/with-temporary-setting-values [~'site-url "https://metabase.com/testmb"] - (et/with-fake-inbox - ~@body))) - -(defmacro ^:private with-alert-setup - "Macro that will cleanup any created pulses and setups a fake-inbox to validate emails are sent for new alerts" - [& body] - `(tu/with-model-cleanup [Pulse] - (with-test-email - ~@body))) (defn- rasta-new-alert-email [body-map] (et/email-to :rasta {:subject "You set up an alert", @@ -171,15 +222,20 @@ :recipients []}) ;; Check creation of a new rows alert with email notification -(tt/expect-with-temp [Card [card1 {:name "My question"}]] - [(-> (default-alert card1) +(tt/expect-with-temp [Collection [collection] + Card [card {:name "My question" + :collection_id (u/get-id collection)}]] + [(-> (default-alert card) (assoc-in [:card :include_csv] true) + (assoc-in [:card :collection_id] true) (update-in [:channels 0] merge {:schedule_hour 12, :schedule_type "daily", :recipients []})) (rasta-new-alert-email {"has any results" true})] (with-alert-setup + (perms/grant-collection-readwrite-permissions! (group/all-users) collection) [(et/with-expected-messages 1 ((alert-client :rasta) :post 200 "alert" - {:card {:id (u/get-id card1), :include_csv false, :include_xls false} + {:card {:id (u/get-id card), :include_csv false, :include_xls false} + :collection_id (u/get-id collection) :alert_condition "rows" :alert_first_only false :channels [daily-email-channel]})) @@ -192,46 +248,50 @@ (map #(update % :recipients set) channels)))) ;; An admin created alert should notify others they've been subscribed -(tt/expect-with-temp [Card [card1 {:name "My question"}]] - [(-> (default-alert card1) - (assoc :creator (user-details :crowberto)) - (assoc-in [:card :include_csv] true) - (update-in [:channels 0] merge {:schedule_hour 12 - :schedule_type "daily" - :recipients (set (map recipient-details [:rasta :crowberto]))})) - (merge (et/email-to :crowberto {:subject "You set up an alert" - :body {"https://metabase.com/testmb" true - "My question" true - "now getting alerts" false - "confirmation that your alert" true}}) - (rasta-added-to-alert-email {"My question" true - "now getting alerts" true - "confirmation that your alert" false}))] +(tt/expect-with-temp [Card [card {:name "My question"}]] + {1 (-> (default-alert card) + (assoc :creator (user-details :crowberto)) + (assoc-in [:card :include_csv] true) + (update-in [:channels 0] merge {:schedule_hour 12 + :schedule_type "daily" + :recipients (set (map recipient-details [:rasta :crowberto]))})) + 2 (merge (et/email-to :crowberto {:subject "You set up an alert" + :body {"https://metabase.com/testmb" true + "My question" true + "now getting alerts" false + "confirmation that your alert" true}}) + (rasta-added-to-alert-email {"My question" true + "now getting alerts" true + "confirmation that your alert" false}))} (with-alert-setup - [(et/with-expected-messages 2 - (-> ((alert-client :crowberto) :post 200 "alert" - {:card {:id (u/get-id card1), :include_csv false, :include_xls false} - :alert_condition "rows" - :alert_first_only false - :channels [(assoc daily-email-channel - :details {:emails nil} - :recipients (mapv fetch-user [:crowberto :rasta]))]}) - setify-recipient-emails)) - (et/regex-email-bodies #"https://metabase.com/testmb" - #"now getting alerts" - #"confirmation that your alert" - #"My question")])) + (array-map + 1 (et/with-expected-messages 2 + (-> ((alert-client :crowberto) :post 200 "alert" + {:card {:id (u/get-id card), :include_csv false, :include_xls false} + :alert_condition "rows" + :alert_first_only false + :channels [(assoc daily-email-channel + :details {:emails nil} + :recipients (mapv fetch-user [:crowberto :rasta]))]}) + setify-recipient-emails)) + 2 (et/regex-email-bodies #"https://metabase.com/testmb" + #"now getting alerts" + #"confirmation that your alert" + #"My question")))) ;; Check creation of a below goal alert (expect (rasta-new-alert-email {"goes below its goal" true}) - (tt/with-temp* [Card [card1 {:name "My question" - :display "line"}]] + (tt/with-temp* [Collection [collection] + Card [card {:name "My question" + :display "line" + :collection_id (u/get-id collection)}]] + (perms/grant-collection-readwrite-permissions! (group/all-users) collection) (with-alert-setup (et/with-expected-messages 1 ((user->client :rasta) :post 200 "alert" - {:card {:id (u/get-id card1), :include_csv false, :include_xls false} + {:card {:id (u/get-id card), :include_csv false, :include_xls false} :alert_condition "goal" :alert_above_goal false :alert_first_only false @@ -243,12 +303,16 @@ ;; Check creation of a above goal alert (expect (rasta-new-alert-email {"meets its goal" true}) - (tt/with-temp* [Card [card1 {:name "My question" - :display "bar"}]] + (tt/with-temp* [Collection [collection] + Card [card {:name "My question" + :display "bar" + :collection_id (u/get-id collection)}]] + (perms/grant-collection-readwrite-permissions! (group/all-users) collection) (with-alert-setup (et/with-expected-messages 1 ((user->client :rasta) :post 200 "alert" - {:card {:id (u/get-id card1), :include_csv false, :include_xls false} + {:card {:id (u/get-id card), :include_csv false, :include_xls false} + :collection_id (u/get-id collection) :alert_condition "goal" :alert_above_goal true :alert_first_only false @@ -257,42 +321,6 @@ #"meets its goal" #"My question")))) -;; Make sure we can create a Pulse with a Collection position -(expect - #metabase.models.pulse.PulseInstance{:collection_id true, :collection_position 1} - (tu/with-model-cleanup [Pulse] - (tt/with-temp* [Card [card] - Collection [collection]] - (perms/grant-collection-readwrite-permissions! (group/all-users) collection) - ((user->client :rasta) :post 200 "alert" {:card {:id (u/get-id card) - :include_csv false - :include_xls false} - :alert_condition "goal" - :alert_above_goal false - :alert_first_only false - :channels [daily-email-channel] - :collection_id (u/get-id collection) - :collection_position 1}) - (some-> (db/select-one [Pulse :collection_id :collection_position] :collection_id (u/get-id collection)) - (update :collection_id (partial = (u/get-id collection))))))) - -;; ...but not if we don't have permissions for the Collection -(expect - nil - (tt/with-temp* [Card [card] - Collection [collection]] - ((user->client :rasta) :post 403 "alert" {:card {:id (u/get-id card) - :include_csv false - :include_xls false} - :alert_condition "goal" - :alert_above_goal false - :alert_first_only false - :channels [daily-email-channel] - :collection_id (u/get-id collection) - :collection_position 1}) - (some-> (db/select-one [Pulse :collection_id :collection_position] :collection_id (u/get-id collection)) - (update :collection_id (partial = (u/get-id collection)))))) - ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | PUT /api/alert/:id | @@ -371,11 +399,12 @@ PulseCard [_ (pulse-card alert card)] PulseChannel [pc (pulse-channel alert)] PulseChannelRecipient [_ (recipient pc :rasta)]] - (default-alert card) - - (tu/with-model-cleanup [Pulse] - ((alert-client :rasta) :put 200 (alert-url alert) - (default-alert-req card pc)))) + (-> (default-alert card) + (assoc-in [:card :collection_id] true)) + (with-alerts-in-writeable-collection [alert] + (tu/with-model-cleanup [Pulse] + ((alert-client :rasta) :put 200 (alert-url alert) + (default-alert-req card pc))))) ;; Admin users can update any alert (tt/expect-with-temp [Pulse [alert (basic-alert)] @@ -468,92 +497,6 @@ ((alert-client :rasta) :put 403 (alert-url alert) (default-alert-req card pc))))) -;; Can we update *just* the Collection ID of an Alert? -(expect - (tt/with-temp* [Pulse [alert {:alert_condition "rows"}] - Collection [collection]] - ((user->client :crowberto) :put 200 (str "alert/" (u/get-id alert)) - {:collection_id (u/get-id collection)}) - (= (db/select-one-field :collection_id Pulse :id (u/get-id alert)) - (u/get-id collection)))) - -(defmacro ^:private with-alert-in-collection - {:style/indent 1} - [[db-binding collection-binding alert-binding] & body] - `(pulse-test/with-pulse-in-collection [~db-binding ~collection-binding alert#] - ;; Make this Alert actually be an alert - (db/update! Pulse (u/get-id alert#) :alert_condition "rows") - (let [~alert-binding alert#] - ~@body))) - -;; Can we change the Collection a Alert is in (assuming we have the permissions to do so)? -(expect - (with-alert-in-collection [_ collection alert] - (tt/with-temp Collection [new-collection] - ;; grant Permissions for both new and old collections - (doseq [coll [collection new-collection]] - (perms/grant-collection-readwrite-permissions! (group/all-users) coll)) - ;; now make an API call to move collections - ((user->client :rasta) :put 200 (str "alert/" (u/get-id alert)) {:collection_id (u/get-id new-collection)}) - ;; Check to make sure the ID has changed in the DB - (= (db/select-one-field :collection_id Pulse :id (u/get-id alert)) - (u/get-id new-collection))))) - -;; ...but if we don't have the Permissions for the old collection, we should get an Exception -(expect - "You don't have permissions to do that." - (with-alert-in-collection [_ collection alert] - (tt/with-temp Collection [new-collection] - ;; grant Permissions for only the *new* collection - (perms/grant-collection-readwrite-permissions! (group/all-users) new-collection) - ;; now make an API call to move collections. Should fail - ((user->client :rasta) :put 403 (str "alert/" (u/get-id alert)) {:collection_id (u/get-id new-collection)})))) - -;; ...and if we don't have the Permissions for the new collection, we should get an Exception -(expect - "You don't have permissions to do that." - (with-alert-in-collection [_ collection alert] - (tt/with-temp Collection [new-collection] - ;; grant Permissions for only the *old* collection - (perms/grant-collection-readwrite-permissions! (group/all-users) collection) - ;; now make an API call to move collections. Should fail - ((user->client :rasta) :put 403 (str "alert/" (u/get-id alert)) {:collection_id (u/get-id new-collection)})))) - -;; Can we change the Collection position of an Alert? -(expect - 1 - (with-alert-in-collection [_ collection pulse] - (perms/grant-collection-readwrite-permissions! (group/all-users) collection) - ((user->client :rasta) :put 200 (str "alert/" (u/get-id pulse)) - {:collection_position 1}) - (db/select-one-field :collection_position Pulse :id (u/get-id pulse)))) - -;; ...and unset (unpin) it as well? -(expect - nil - (with-alert-in-collection [_ collection pulse] - (db/update! Pulse (u/get-id pulse) :collection_position 1) - (perms/grant-collection-readwrite-permissions! (group/all-users) collection) - ((user->client :rasta) :put 200 (str "alert/" (u/get-id pulse)) - {:collection_position nil}) - (db/select-one-field :collection_position Pulse :id (u/get-id pulse)))) - -;; ...we shouldn't be able to if we don't have permissions for the Collection -(expect - nil - (with-alert-in-collection [_ collection pulse] - ((user->client :rasta) :put 403 (str "alert/" (u/get-id pulse)) - {:collection_position 1}) - (db/select-one-field :collection_position Pulse :id (u/get-id pulse)))) - -(expect - 1 - (with-alert-in-collection [_ collection pulse] - (db/update! Pulse (u/get-id pulse) :collection_position 1) - ((user->client :rasta) :put 403 (str "alert/" (u/get-id pulse)) - {:collection_position nil}) - (db/select-one-field :collection_position Pulse :id (u/get-id pulse)))) - ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | GET /alert/question/:id | @@ -584,29 +527,35 @@ PulseChannelRecipient [_ (recipient pc :rasta)]] [(-> (default-alert card) ;; The read_only flag is used by the UI to determine what the user is allowed to update - (assoc :read_only false) - (update-in [:channels 0] merge {:schedule_hour 15 :schedule_type "daily"}))] + (assoc :read_only true) + (update-in [:channels 0] merge {:schedule_hour 15 :schedule_type "daily"}) + (assoc-in [:card :collection_id] true))] (with-alert-setup - ((alert-client :rasta) :get 200 (alert-question-url card)))) + (with-alerts-in-readable-collection [alert] + ((alert-client :rasta) :get 200 (alert-question-url card))))) ;; Non-admin users shouldn't see alerts they created if they're no longer recipients (expect - [1 0] + {:count-1 1 + :count-2 0} (tt/with-temp* [Card [card (basic-alert-query)] Pulse [alert (assoc (basic-alert) :alert_above_goal true)] PulseCard [_ (pulse-card alert card)] PulseChannel [pc (pulse-channel alert)] PulseChannelRecipient [pcr (recipient pc :rasta)] PulseChannelRecipient [_ (recipient pc :crowberto)]] - (with-alert-setup - [(count ((alert-client :rasta) :get 200 (alert-question-url card))) - (do - (db/delete! PulseChannelRecipient :id (u/get-id pcr)) - (api:alert-question-count :rasta card))]))) + (with-alerts-in-readable-collection [alert] + (with-alert-setup + (array-map + :count-1 (count ((alert-client :rasta) :get 200 (alert-question-url card))) + :count-2 (do + (db/delete! PulseChannelRecipient :id (u/get-id pcr)) + (api:alert-question-count :rasta card))))))) ;; Non-admin users should not see others alerts, admins see all alerts (expect - [1 2] + {:rasta 1 + :crowberto 2} (tt/with-temp* [Card [card (basic-alert-query)] Pulse [alert-1 (assoc (basic-alert) :alert_above_goal false)] @@ -620,10 +569,12 @@ PulseCard [_ (pulse-card alert-2 card)] PulseChannel [pc-2 (pulse-channel alert-2)] PulseChannelRecipient [_ (recipient pc-2 :crowberto)] - PulseChannel [pc-3 (assoc (pulse-channel alert-2) :channel_type "slack")]] - (with-alert-setup - [(api:alert-question-count :rasta card) - (api:alert-question-count :crowberto card)]))) + PulseChannel [_ (assoc (pulse-channel alert-2) :channel_type "slack")]] + (with-alerts-in-readable-collection [alert-1 alert-2] + (with-alert-setup + (array-map + :rasta (api:alert-question-count :rasta card) + :crowberto (api:alert-question-count :crowberto card)))))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -651,128 +602,140 @@ ;; Alert has two recipients, remove one (expect - [#{"crowberto@metabase.com" "rasta@metabase.com"} - #{"crowberto@metabase.com"} - (rasta-unsubscribe-email {"Foo" true})] + {:recipients-1 #{"crowberto@metabase.com" "rasta@metabase.com"} + :recipients-2 #{"crowberto@metabase.com"} + :emails (rasta-unsubscribe-email {"Foo" true})} (tt/with-temp* [Card [card (basic-alert-query)] Pulse [alert (basic-alert)] PulseCard [_ (pulse-card alert card)] PulseChannel [pc (pulse-channel alert)] PulseChannelRecipient [_ (recipient pc :rasta)] PulseChannelRecipient [_ (recipient pc :crowberto)]] - (with-alert-setup - [(recipient-emails ((user->client :rasta) :get 200 (alert-question-url card))) - (do - (et/with-expected-messages 1 - (api:unsubscribe! :rasta 204 alert)) - (recipient-emails ((user->client :crowberto) :get 200 (alert-question-url card)))) - (et/regex-email-bodies #"https://metabase.com/testmb" - #"Foo")]))) + (with-alerts-in-readable-collection [alert] + (with-alert-setup + (array-map + :recipients-1 (recipient-emails ((user->client :rasta) :get 200 (alert-question-url card))) + :recipients-2 (do + (et/with-expected-messages 1 + (api:unsubscribe! :rasta 204 alert)) + (recipient-emails ((user->client :crowberto) :get 200 (alert-question-url card)))) + :emails (et/regex-email-bodies #"https://metabase.com/testmb" + #"Foo")))))) ;; Alert should be deleted if the creator unsubscribes and there's no one left (expect - [1 - 0 - (rasta-unsubscribe-email {"Foo" true})] + {:count-1 1 + :count-2 0 + :emails (rasta-unsubscribe-email {"Foo" true})} (tt/with-temp* [Card [card (basic-alert-query)] Pulse [alert (basic-alert)] PulseCard [_ (pulse-card alert card)] PulseChannel [pc (pulse-channel alert)] PulseChannelRecipient [_ (recipient pc :rasta)]] - (with-alert-setup - [(api:alert-question-count :rasta card) - (do - (et/with-expected-messages 1 - (api:unsubscribe! :rasta 204 alert)) - (api:alert-question-count :crowberto card)) - (et/regex-email-bodies #"https://metabase.com/testmb" - #"Foo")]))) + (with-alerts-in-readable-collection [alert] + (with-alert-setup + (array-map + :count-1 (api:alert-question-count :rasta card) + :count-2 (do + (et/with-expected-messages 1 + (api:unsubscribe! :rasta 204 alert)) + (api:alert-question-count :crowberto card)) + :emails (et/regex-email-bodies #"https://metabase.com/testmb" + #"Foo")))))) ;; Alert should not be deleted if there is a slack channel (expect - [1 - 1 ;;<-- Alert should not be deleted - (rasta-unsubscribe-email {"Foo" true})] + {:count-1 1 + :count-2 1 ; <-- Alert should not be deleted + :emails (rasta-unsubscribe-email {"Foo" true})} (tt/with-temp* [Card [card (basic-alert-query)] Pulse [alert (basic-alert)] PulseCard [_ (pulse-card alert card)] PulseChannel [pc-1 (assoc (pulse-channel alert) :channel_type :email)] - PulseChannel [pc-2 (assoc (pulse-channel alert) :channel_type :slack)] + PulseChannel [_ (assoc (pulse-channel alert) :channel_type :slack)] PulseChannelRecipient [_ (recipient pc-1 :rasta)]] - (with-alert-setup - [(api:alert-question-count :rasta card) - (do - (et/with-expected-messages 1 - (api:unsubscribe! :rasta 204 alert)) - (api:alert-question-count :crowberto card)) - (et/regex-email-bodies #"https://metabase.com/testmb" - #"Foo")]))) + (with-alerts-in-readable-collection [alert] + (with-alert-setup + (array-map + :count-1 (api:alert-question-count :rasta card) + :count-2 (do + (et/with-expected-messages 1 + (api:unsubscribe! :rasta 204 alert)) + (api:alert-question-count :crowberto card)) + :emails (et/regex-email-bodies #"https://metabase.com/testmb" + #"Foo")))))) ;; If email is disabled, users should be unsubscribed (expect - [1 - 1 ;;<-- Alert should not be deleted - (et/email-to :rasta {:subject "You’ve been unsubscribed from an alert", - :body {"https://metabase.com/testmb" true, - "letting you know that Crowberto Corv" true}})] + {:count-1 1 + :count-2 1 ; <-- Alert should not be deleted + :emails (et/email-to :rasta {:subject "You’ve been unsubscribed from an alert", + :body {"https://metabase.com/testmb" true, + "letting you know that Crowberto Corv" true}})} (tt/with-temp* [Card [card (basic-alert-query)] Pulse [alert (basic-alert)] PulseCard [_ (pulse-card alert card)] PulseChannel [pc-1 (assoc (pulse-channel alert) :channel_type :email)] PulseChannel [pc-2 (assoc (pulse-channel alert) :channel_type :slack)] PulseChannelRecipient [_ (recipient pc-1 :rasta)]] - (with-alert-setup - [(api:alert-question-count :rasta card) - (do - (et/with-expected-messages 1 - ((alert-client :crowberto) :put 200 (alert-url alert) - (assoc-in (default-alert-req card pc-1) [:channels 0 :enabled] false))) - (api:alert-question-count :crowberto card)) - (et/regex-email-bodies #"https://metabase.com/testmb" - #"letting you know that Crowberto Corv" )]))) + (with-alerts-in-readable-collection [alert] + (with-alert-setup + (array-map + :count-1 (api:alert-question-count :rasta card) + :count-2 (do + (et/with-expected-messages 1 + ((alert-client :crowberto) :put 200 (alert-url alert) + (assoc-in (default-alert-req card pc-1) [:channels 0 :enabled] false))) + (api:alert-question-count :crowberto card)) + :emails (et/regex-email-bodies #"https://metabase.com/testmb" + #"letting you know that Crowberto Corv" )))))) ;; Re-enabling email should send users a subscribe notification (expect - [1 - 1 ;;<-- Alert should not be deleted - (et/email-to :rasta {:subject "Crowberto Corv added you to an alert", - :body {"https://metabase.com/testmb" true, - "now getting alerts about .*Foo" true}})] + {:count-1 1 + :count-2 1 ; <-- Alert should not be deleted + :emails (et/email-to :rasta {:subject "Crowberto Corv added you to an alert", + :body {"https://metabase.com/testmb" true, + "now getting alerts about .*Foo" true}})} (tt/with-temp* [Card [card (basic-alert-query)] Pulse [alert (basic-alert)] PulseCard [_ (pulse-card alert card)] PulseChannel [pc-1 (assoc (pulse-channel alert) :channel_type :email, :enabled false)] PulseChannel [pc-2 (assoc (pulse-channel alert) :channel_type :slack)] PulseChannelRecipient [_ (recipient pc-1 :rasta)]] - (with-alert-setup - [(api:alert-question-count :rasta card) - (do - (et/with-expected-messages 1 - ((alert-client :crowberto) :put 200 (alert-url alert) - (assoc-in (default-alert-req card pc-1) [:channels 0 :enabled] true))) - (api:alert-question-count :crowberto card)) - (et/regex-email-bodies #"https://metabase.com/testmb" - #"now getting alerts about .*Foo" )]))) + (with-alerts-in-readable-collection [alert] + (with-alert-setup + (array-map + :count-1 (api:alert-question-count :rasta card) + :count-2 (do + (et/with-expected-messages 1 + ((alert-client :crowberto) :put 200 (alert-url alert) + (assoc-in (default-alert-req card pc-1) [:channels 0 :enabled] true))) + (api:alert-question-count :crowberto card)) + :emails (et/regex-email-bodies #"https://metabase.com/testmb" + #"now getting alerts about .*Foo" )))))) ;; Alert should not be deleted if the unsubscriber isn't the creator (expect - [1 - 1 ;<-- Alert should not be deleted - (rasta-unsubscribe-email {"Foo" true})] + {:count-1 1 + :count-2 1 ; <-- Alert should not be deleted + :emails (rasta-unsubscribe-email {"Foo" true})} (tt/with-temp* [Card [card (basic-alert-query)] Pulse [alert (assoc (basic-alert) :creator_id (user->id :crowberto))] PulseCard [_ (pulse-card alert card)] PulseChannel [pc-1 (assoc (pulse-channel alert) :channel_type :email)] PulseChannel [pc-2 (assoc (pulse-channel alert) :channel_type :slack)] PulseChannelRecipient [_ (recipient pc-1 :rasta)]] - (with-alert-setup - [(api:alert-question-count :rasta card) - (do - (et/with-expected-messages 1 - (api:unsubscribe! :rasta 204 alert)) - (api:alert-question-count :crowberto card)) - (et/regex-email-bodies #"https://metabase.com/testmb" - #"Foo")]))) + (with-alerts-in-readable-collection [alert] + (with-alert-setup + (array-map + :count-1 (api:alert-question-count :rasta card) + :count-2 (do + (et/with-expected-messages 1 + (api:unsubscribe! :rasta 204 alert)) + (api:alert-question-count :crowberto card)) + :emails (et/regex-email-bodies #"https://metabase.com/testmb" + #"Foo")))))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -784,19 +747,24 @@ ;; Only admins can delete an alert (expect - [1 "You don't have permissions to do that."] + {:count 1 + :response "You don't have permissions to do that."} (tt/with-temp* [Card [card (basic-alert-query)] Pulse [alert (basic-alert)] PulseCard [_ (pulse-card alert card)] PulseChannel [pc (pulse-channel alert)] PulseChannelRecipient [_ (recipient pc :rasta)]] - (with-alert-setup - [(api:alert-question-count :rasta card) - (api:delete! :rasta 403 alert)]))) + (with-alerts-in-readable-collection [alert] + (with-alert-setup + (array-map + :count (api:alert-question-count :rasta card) + :response (api:delete! :rasta 403 alert)))))) ;; Testing a user can't delete an admin's alert (expect - [1 nil 0] + {:count-1 1 + :response nil + :count-2 0} (tt/with-temp* [Card [card (basic-alert-query)] Pulse [alert (basic-alert)] PulseCard [_ (pulse-card alert card)] @@ -808,58 +776,71 @@ ;; A user can't delete an admin's alert (api:delete! :rasta 403 alert) - [(count original-alert-response) - (api:delete! :crowberto 204 alert) - (api:alert-question-count :rasta card)])))) + (array-map + :count-1 (count original-alert-response) + :response (api:delete! :crowberto 204 alert) + :count-2 (api:alert-question-count :rasta card)))))) ;; An admin can delete a user's alert (expect - [1 nil 0 - (rasta-deleted-email {})] + {:count-1 1 + :response nil + :count-2 0 + :emails (rasta-deleted-email {})} (tt/with-temp* [Card [card (basic-alert-query)] Pulse [alert (basic-alert)] PulseCard [_ (pulse-card alert card)] PulseChannel [pc (pulse-channel alert)] PulseChannelRecipient [_ (recipient pc :rasta)]] - (with-alert-setup - [(api:alert-question-count :rasta card) - (et/with-expected-messages 1 - (api:delete! :crowberto 204 alert)) - (api:alert-question-count :rasta card) - (et/regex-email-bodies #"Crowberto Corv deleted an alert")]))) + (with-alerts-in-readable-collection [alert] + (with-alert-setup + (array-map + :count-1 (api:alert-question-count :rasta card) + :response (et/with-expected-messages 1 + (api:delete! :crowberto 204 alert)) + :count-2 (api:alert-question-count :rasta card) + :emails (et/regex-email-bodies #"Crowberto Corv deleted an alert")))))) ;; A deleted alert should notify the creator and any recipients (expect - [1 nil 0 - (merge - (rasta-deleted-email {"Crowberto Corv unsubscribed you from alerts" false}) - (et/email-to :lucky {:subject "You’ve been unsubscribed from an alert", - :body {"Crowberto Corv deleted an alert" false - "Crowberto Corv unsubscribed you from alerts" true}}))] + {:count-1 1 + :response nil + :count-2 0 + :emails (merge + (rasta-deleted-email {"Crowberto Corv unsubscribed you from alerts" false}) + (et/email-to :lucky {:subject "You’ve been unsubscribed from an alert", + :body {"Crowberto Corv deleted an alert" false + "Crowberto Corv unsubscribed you from alerts" true}}))} (tt/with-temp* [Card [card (basic-alert-query)] Pulse [alert (basic-alert)] PulseCard [_ (pulse-card alert card)] PulseChannel [pc (pulse-channel alert)] PulseChannelRecipient [_ (recipient pc :rasta)] PulseChannelRecipient [_ (recipient pc :lucky)]] - (with-alert-setup - [(api:alert-question-count :rasta card) - (et/with-expected-messages 2 - (api:delete! :crowberto 204 alert)) - (api:alert-question-count :rasta card) - (et/regex-email-bodies #"Crowberto Corv deleted an alert" - #"Crowberto Corv unsubscribed you from alerts")]))) + (with-alerts-in-readable-collection [alert] + (with-alert-setup + (array-map + :count-1 (api:alert-question-count :rasta card) + :response (et/with-expected-messages 2 + (api:delete! :crowberto 204 alert)) + :count-2 (api:alert-question-count :rasta card) + :emails (et/regex-email-bodies #"Crowberto Corv deleted an alert" + #"Crowberto Corv unsubscribed you from alerts")))))) ;; When an admin deletes their own alert, it should not notify them (expect - [1 nil 0 {}] + {:count-1 1 + :response nil + :count-2 0 + :emails {}} (tt/with-temp* [Card [card (basic-alert-query)] Pulse [alert (assoc (basic-alert) :creator_id (user->id :crowberto))] PulseCard [_ (pulse-card alert card)] PulseChannel [pc (pulse-channel alert)] PulseChannelRecipient [_ (recipient pc :crowberto)]] (with-alert-setup - [(api:alert-question-count :crowberto card) - (api:delete! :crowberto 204 alert) - (api:alert-question-count :crowberto card) - (et/regex-email-bodies #".*")]))) + (array-map + :count-1 (api:alert-question-count :crowberto card) + :response (api:delete! :crowberto 204 alert) + :count-2 (api:alert-question-count :crowberto card) + :emails (et/regex-email-bodies #".*"))))) diff --git a/test/metabase/api/automagic_dashboards_test.clj b/test/metabase/api/automagic_dashboards_test.clj index ad5214376633975cfe7b84215e7346911803991b..4d18c5d0949f5687ec287f66df49c412d323fa9e 100644 --- a/test/metabase/api/automagic_dashboards_test.clj +++ b/test/metabase/api/automagic_dashboards_test.clj @@ -24,8 +24,7 @@ (defmacro ^:private with-dashboard-cleanup [& body] - `(tu/with-model-cleanup [(quote ~'Card) (quote ~'Dashboard) (quote ~'Collection) - (quote ~'DashboardCard)] + `(tu/with-model-cleanup ['~'Card '~'Dashboard '~'Collection '~'DashboardCard] ~@body)) (defn- api-call diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index 22d9c9693481527514a1bf92f2afe04aad788a30..8ea3ec250e0054555424d912106a483c5f4c4fb2 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -24,7 +24,7 @@ [view-log :refer [ViewLog]]] [metabase.query-processor.middleware.results-metadata :as results-metadata] [metabase.test - [data :as data :refer :all] + [data :as data] [util :as tu :refer [match-$ random-name]]] [metabase.test.data.users :refer :all] [metabase.util.date :as du] @@ -33,9 +33,11 @@ (:import java.io.ByteArrayInputStream java.util.UUID)) -;;; Helpers +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | Helper Fns & Macros | +;;; +----------------------------------------------------------------------------------------------------------------+ -(def ^:const card-defaults +(def card-defaults {:archived false :collection_id nil :collection_position nil @@ -49,14 +51,19 @@ :cache_ttl nil :result_metadata nil}) -(defn- mbql-count-query [database-id table-id] - {:database database-id - :type "query" - :query {:source-table table-id, :aggregation {:aggregation-type "count"}}}) +(defn- mbql-count-query + ([] + (mbql-count-query (data/id) (data/id :venues))) + ([db-or-id table-or-id] + {:database (u/get-id db-or-id) + :type "query" + :query {:source-table (u/get-id table-or-id), :aggregation {:aggregation-type "count"}}})) (defn- card-with-name-and-query + ([] + (card-with-name-and-query (tu/random-name))) ([card-name] - (card-with-name-and-query card-name (mbql-count-query (data/id) (data/id :venues)))) + (card-with-name-and-query card-name (mbql-count-query))) ([card-name query] {:name card-name :display "scalar" @@ -64,26 +71,90 @@ :visualization_settings {:global {:title nil}}})) +(defn- do-with-temp-native-card + {:style/indent 0} + [f] + (tt/with-temp* [Database [db {:details (:details (Database (data/id))), :engine :h2}] + Table [table {:db_id (u/get-id db), :name "CATEGORIES"}] + Card [card {:dataset_query {:database (u/get-id db) + :type :native + :native {:query "SELECT COUNT(*) FROM CATEGORIES;"}}}]] + (f db card))) + +(defmacro ^:private with-temp-native-card + {:style/indent 1} + [[db-binding card-binding] & body] + `(do-with-temp-native-card (fn [~(or db-binding '_) ~(or card-binding '_)] + ~@body))) + + +(defn do-with-cards-in-a-collection [card-or-cards-or-ids grant-perms-fn! f] + (tt/with-temp Collection [collection] + ;; put all the Card(s) in our temp `collection` + (doseq [card-or-id (if (sequential? card-or-cards-or-ids) + card-or-cards-or-ids + [card-or-cards-or-ids])] + (db/update! Card (u/get-id card-or-id) {:collection_id (u/get-id collection)})) + ;; now use `grant-perms-fn!` to grant appropriate perms + (grant-perms-fn! (perms-group/all-users) collection) + ;; call (f) + (f))) + +(defmacro with-cards-in-readable-collection + "Execute `body` with `card-or-cards-or-ids` added to a temporary Collection that All Users have read permissions for." + {:style/indent 1} + [card-or-cards-or-ids & body] + `(do-with-cards-in-a-collection ~card-or-cards-or-ids perms/grant-collection-read-permissions! (fn [] ~@body))) + +(defmacro with-cards-in-writeable-collection + "Execute `body` with `card-or-cards-or-ids` added to a temporary Collection that All Users have *write* permissions + for." + {:style/indent 1} + [card-or-cards-or-ids & body] + `(do-with-cards-in-a-collection ~card-or-cards-or-ids perms/grant-collection-readwrite-permissions! (fn [] ~@body))) + + +(defn- do-with-temp-native-card-with-params {:style/indent 0} [f] + (tt/with-temp* + [Database [db {:details (:details (Database (data/id))), :engine :h2}] + Table [table {:db_id (u/get-id db), :name "VENUES"}] + Card [card {:dataset_query + {:database (u/get-id db) + :type :native + :native {:query "SELECT COUNT(*) FROM VENUES WHERE CATEGORY_ID = {{category}};" + :template_tags {:category {:id "a9001580-3bcc-b827-ce26-1dbc82429163" + :name "category" + :display_name "Category" + :type "number" + :required true}}}}}]] + (f db card))) + +(defmacro ^:private with-temp-native-card-with-params {:style/indent 1} [[db-binding card-binding] & body] + `(do-with-temp-native-card-with-params (fn [~(or db-binding '_) ~(or card-binding '_)] ~@body))) + + ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | FETCHING CARDS & FILTERING | ;;; +----------------------------------------------------------------------------------------------------------------+ +(defn- card-returned? [model object-or-id card-or-id] + (contains? (set (for [card ((user->client :rasta) :get 200 "card", :f model, :model_id (u/get-id object-or-id))] + (u/get-id card))) + (u/get-id card-or-id))) + ;; Filter cards by database (expect - [true - false - true] - (tt/with-temp* [Database [{db-id :id}] - Card [{card-1-id :id} {:database_id (id)}] - Card [{card-2-id :id} {:database_id db-id}]] - (let [card-returned? (fn [database-id card-id] - (contains? (set (for [card ((user->client :rasta) :get 200 "card" - :f :database, :model_id database-id)] - (u/get-id card))) - card-id))] - [(card-returned? (id) card-1-id) - (card-returned? db-id card-1-id) - (card-returned? db-id card-2-id)]))) + {1 true + 2 false + 3 true} + (tt/with-temp* [Database [db] + Card [card-1 {:database_id (data/id)}] + Card [card-2 {:database_id (u/get-id db)}]] + (with-cards-in-readable-collection [card-1 card-2] + (array-map + 1 (card-returned? :database (data/id) card-1) + 2 (card-returned? :database db card-1) + 3 (card-returned? :database db card-2))))) (expect (get middleware/response-unauthentic :body) (http/client :get 401 "card")) @@ -95,82 +166,91 @@ ((user->client :crowberto) :get 400 "card" :f :database)) ;; Filter cards by table -(defn- card-returned? [table-id card-id] - (contains? (set (for [card ((user->client :rasta) :get 200 "card", :f :table, :model_id table-id)] - (u/get-id card))) - card-id)) - (expect - [true - false - true] - (tt/with-temp* [Database [{database-id :id}] - Table [{table-1-id :id} {:db_id database-id}] - Table [{table-2-id :id} {:db_id database-id}] - Card [{card-1-id :id} {:table_id table-1-id}] - Card [{card-2-id :id} {:table_id table-2-id}]] - [(card-returned? table-1-id card-1-id) - (card-returned? table-2-id card-1-id) - (card-returned? table-2-id card-2-id)])) + {1 true + 2 false + 3 true} + (tt/with-temp* [Database [db] + Table [table-1 {:db_id (u/get-id db)}] + Table [table-2 {:db_id (u/get-id db)}] + Card [card-1 {:table_id (u/get-id table-1)}] + Card [card-2 {:table_id (u/get-id table-2)}]] + (with-cards-in-readable-collection [card-1 card-2] + (array-map + 1 (card-returned? :table (u/get-id table-1) (u/get-id card-1)) + 2 (card-returned? :table (u/get-id table-2) (u/get-id card-1)) + 3 (card-returned? :table (u/get-id table-2) (u/get-id card-2)))))) ;; Make sure `model_id` is required when `f` is :table -(expect {:errors {:model_id "model_id is a required parameter when filter mode is 'table'"}} - ((user->client :crowberto) :get 400 "card", :f :table)) +(expect + {:errors {:model_id "model_id is a required parameter when filter mode is 'table'"}} + ((user->client :crowberto) :get 400 "card", :f :table)) ;;; Filter by `recent` ;; Should return cards that were recently viewed by current user only -(tt/expect-with-temp [Card [{card-1-id :id}] - Card [{card-2-id :id}] - Card [{card-3-id :id}] - Card [{card-4-id :id}] - ;; 3 was viewed most recently, followed by 4, then 1. Card 2 was viewed by a different user so - ;; shouldn't be returned - ViewLog [_ {:model "card", :model_id card-1-id, :user_id (user->id :rasta) - :timestamp (du/->Timestamp #inst "2015-12-01")}] - ViewLog [_ {:model "card", :model_id card-2-id, :user_id (user->id :trashbird) - :timestamp (du/->Timestamp #inst "2016-01-01")}] - ViewLog [_ {:model "card", :model_id card-3-id, :user_id (user->id :rasta) - :timestamp (du/->Timestamp #inst "2016-02-01")}] - ViewLog [_ {:model "card", :model_id card-4-id, :user_id (user->id :rasta) - :timestamp (du/->Timestamp #inst "2016-03-01")}] - ViewLog [_ {:model "card", :model_id card-3-id, :user_id (user->id :rasta) - :timestamp (du/->Timestamp #inst "2016-04-01")}]] - [card-3-id card-4-id card-1-id] - (mapv :id ((user->client :rasta) :get 200 "card", :f :recent))) +(expect + ["Card 3" + "Card 4" + "Card 1"] + (tt/with-temp* [Card [card-1 {:name "Card 1"}] + Card [card-2 {:name "Card 2"}] + Card [card-3 {:name "Card 3"}] + Card [card-4 {:name "Card 4"}] + ;; 3 was viewed most recently, followed by 4, then 1. Card 2 was viewed by a different user so + ;; shouldn't be returned + ViewLog [_ {:model "card", :model_id (u/get-id card-1), :user_id (user->id :rasta) + :timestamp (du/->Timestamp #inst "2015-12-01")}] + ViewLog [_ {:model "card", :model_id (u/get-id card-2), :user_id (user->id :trashbird) + :timestamp (du/->Timestamp #inst "2016-01-01")}] + ViewLog [_ {:model "card", :model_id (u/get-id card-3), :user_id (user->id :rasta) + :timestamp (du/->Timestamp #inst "2016-02-01")}] + ViewLog [_ {:model "card", :model_id (u/get-id card-4), :user_id (user->id :rasta) + :timestamp (du/->Timestamp #inst "2016-03-01")}] + ViewLog [_ {:model "card", :model_id (u/get-id card-3), :user_id (user->id :rasta) + :timestamp (du/->Timestamp #inst "2016-04-01")}]] + (with-cards-in-readable-collection [card-1 card-2 card-3 card-4] + (map :name ((user->client :rasta) :get 200 "card", :f :recent))))) ;;; Filter by `popular` ;; `f=popular` should return cards sorted by number of ViewLog entries for all users; cards with no entries should be ;; excluded -(tt/expect-with-temp [Card [{card-1-id :id}] - Card [{card-2-id :id}] - Card [{card-3-id :id}] - ;; 3 entries for card 3, 2 for card 2, none for card 1, - ViewLog [_ {:model "card", :model_id card-3-id, :user_id (user->id :rasta)}] - ViewLog [_ {:model "card", :model_id card-2-id, :user_id (user->id :trashbird)}] - ViewLog [_ {:model "card", :model_id card-2-id, :user_id (user->id :rasta)}] - ViewLog [_ {:model "card", :model_id card-3-id, :user_id (user->id :crowberto)}] - ViewLog [_ {:model "card", :model_id card-3-id, :user_id (user->id :rasta)}]] - [card-3-id card-2-id] - (map :id ((user->client :rasta) :get 200 "card", :f :popular))) +(expect + ["Card 3" + "Card 2"] + (tt/with-temp* [Card [card-1 {:name "Card 1"}] + Card [card-2 {:name "Card 2"}] + Card [card-3 {:name "Card 3"}] + ;; 3 entries for card 3, 2 for card 2, none for card 1, + ViewLog [_ {:model "card", :model_id (u/get-id card-3), :user_id (user->id :rasta)}] + ViewLog [_ {:model "card", :model_id (u/get-id card-2), :user_id (user->id :trashbird)}] + ViewLog [_ {:model "card", :model_id (u/get-id card-2), :user_id (user->id :rasta)}] + ViewLog [_ {:model "card", :model_id (u/get-id card-3), :user_id (user->id :crowberto)}] + ViewLog [_ {:model "card", :model_id (u/get-id card-3), :user_id (user->id :rasta)}]] + (with-cards-in-readable-collection [card-1 card-2 card-3] + (map :name ((user->client :rasta) :get 200 "card", :f :popular))))) ;;; Filter by `archived` ;; check that the set of Card IDs returned with f=archived is equal to the set of archived cards -(tt/expect-with-temp [Card [{card-1-id :id}] - Card [{card-2-id :id} {:archived true}] - Card [{card-3-id :id} {:archived true}]] - #{card-2-id card-3-id} - (set (map :id ((user->client :rasta) :get 200 "card", :f :archived)))) +(expect + #{"Card 2" "Card 3"} + (tt/with-temp* [Card [card-1 {:name "Card 1"}] + Card [card-2 {:name "Card 2", :archived true}] + Card [card-3 {:name "Card 3", :archived true}]] + (with-cards-in-readable-collection [card-1 card-2 card-3] + (set (map :name ((user->client :rasta) :get 200 "card", :f :archived)))))) ;;; Filter by `fav` -(tt/expect-with-temp [Card [{card-id-1 :id}] - Card [{card-id-2 :id}] - Card [{card-id-3 :id}] - CardFavorite [_ {:card_id card-id-1, :owner_id (user->id :rasta)}] - CardFavorite [_ {:card_id card-id-2, :owner_id (user->id :crowberto)}]] - [{:id card-id-1, :favorite true}] - (for [card ((user->client :rasta) :get 200 "card", :f :fav)] - (select-keys card [:id :favorite]))) +(expect + [{:name "Card 1", :favorite true}] + (tt/with-temp* [Card [card-1 {:name "Card 1"}] + Card [card-2 {:name "Card 2"}] + Card [card-3 {:name "Card 3"}] + CardFavorite [_ {:card_id (u/get-id card-1), :owner_id (user->id :rasta)}] + CardFavorite [_ {:card_id (u/get-id card-2), :owner_id (user->id :crowberto)}]] + (with-cards-in-readable-collection [card-1 card-2 card-3] + (for [card ((user->client :rasta) :get 200 "card", :f :fav)] + (select-keys card [:name :favorite]))))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -179,19 +259,21 @@ ;; Test that we can make a card (let [card-name (random-name)] - (tt/expect-with-temp [Database [{database-id :id}] - Table [{table-id :id} {:db_id database-id}]] + (tt/expect-with-temp [Database [db] + Table [table {:db_id (u/get-id db)}] + Collection [collection]] (merge card-defaults {:name card-name + :collection_id (u/get-id collection) + :collection collection :creator_id (user->id :rasta) - :dataset_query (mbql-count-query database-id table-id) + :dataset_query (mbql-count-query (u/get-id db) (u/get-id table)) :visualization_settings {:global {:title nil}} - :database_id database-id ; these should be inferred automatically - :table_id table-id + :database_id (u/get-id db) ; these should be inferred automatically + :table_id (u/get-id table) :can_write true :dashboard_count 0 - :collection nil - :read_permissions [(format "/db/%d/schema//table/%d/" database-id table-id)] + :read_permissions nil :creator (match-$ (fetch-user :rasta) {:common_name "Rasta Toucan" :is_superuser false @@ -203,9 +285,11 @@ :email "rasta@metabase.com" :id $})}) (tu/with-model-cleanup [Card] - (dissoc ((user->client :rasta) :post 200 "card" - (card-with-name-and-query card-name (mbql-count-query database-id table-id))) - :created_at :updated_at :id)))) + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + (-> ((user->client :rasta) :post 200 "card" + (assoc (card-with-name-and-query card-name (mbql-count-query (u/get-id db) (u/get-id table))) + :collection_id (u/get-id collection))) + (dissoc :created_at :updated_at :id))))) ;; Make sure when saving a Card the query metadata is saved (if correct) (expect @@ -218,14 +302,17 @@ :name "count_chocula" :special_type :type/Number}] card-name (tu/random-name)] - (tu/with-model-cleanup [Card] - ;; create a card with the metadata - ((user->client :rasta) :post 200 "card" - (assoc (card-with-name-and-query card-name) - :result_metadata metadata - :metadata_checksum (#'results-metadata/metadata-checksum metadata))) - ;; now check the metadata that was saved in the DB - (db/select-one-field :result_metadata Card :name card-name)))) + (tt/with-temp Collection [collection] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + (tu/with-model-cleanup [Card] + ;; create a card with the metadata + ((user->client :rasta) :post 200 "card" + (assoc (card-with-name-and-query card-name) + :collection_id (u/get-id collection) + :result_metadata metadata + :metadata_checksum (#'results-metadata/metadata-checksum metadata))) + ;; now check the metadata that was saved in the DB + (db/select-one-field :result_metadata Card :name card-name))))) ;; make sure when saving a Card the correct query metadata is fetched (if incorrect) (expect @@ -238,14 +325,17 @@ :name "count_chocula" :special_type :type/Number}] card-name (tu/random-name)] - (tu/with-model-cleanup [Card] - ;; create a card with the metadata - ((user->client :rasta) :post 200 "card" - (assoc (card-with-name-and-query card-name) - :result_metadata metadata - :metadata_checksum "ABCDEF")) ; bad checksum - ;; now check the correct metadata was fetched and was saved in the DB - (db/select-one-field :result_metadata Card :name card-name)))) + (tt/with-temp Collection [collection] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + (tu/with-model-cleanup [Card] + ;; create a card with the metadata + ((user->client :rasta) :post 200 "card" + (assoc (card-with-name-and-query card-name) + :collection_id (u/get-id collection) + :result_metadata metadata + :metadata_checksum "ABCDEF")) ; bad checksum + ;; now check the correct metadata was fetched and was saved in the DB + (db/select-one-field :result_metadata Card :name card-name))))) ;; Make sure we can create a Card with a Collection position (expect @@ -276,9 +366,11 @@ ;;; +----------------------------------------------------------------------------------------------------------------+ ;; Test that we can fetch a card -(tt/expect-with-temp [Database [{database-id :id}] - Table [{table-id :id} {:db_id database-id}] - Card [card {:dataset_query (mbql-count-query database-id table-id)}]] +(tt/expect-with-temp [Database [db] + Table [table {:db_id (u/get-id db)}] + Collection [collection] + Card [card {:collection_id (u/get-id collection) + :dataset_query (mbql-count-query (u/get-id db) (u/get-id table))}]] (merge card-defaults (match-$ card {:dashboard_count 0 @@ -296,26 +388,28 @@ :id $}) :updated_at $ :dataset_query $ - :read_permissions [(format "/db/%d/schema//table/%d/" database-id table-id)] + :read_permissions nil :id $ :display "table" :visualization_settings {} - :can_write true + :can_write false :created_at $ - :database_id database-id ; these should be inferred from the dataset_query - :table_id table-id - :in_public_dashboard false - :collection nil})) - ((user->client :rasta) :get 200 (str "card/" (u/get-id card)))) + :database_id (u/get-id db) ; these should be inferred from the dataset_query + :table_id (u/get-id table) + :collection_id (u/get-id collection) + :collection collection})) + (do + (perms/grant-collection-read-permissions! (perms-group/all-users) collection) + ((user->client :rasta) :get 200 (str "card/" (u/get-id card))))) ;; Check that a user without permissions isn't allowed to fetch the card (expect "You don't have permissions to do that." - (tt/with-temp* [Database [{database-id :id}] - Table [{table-id :id} {:db_id database-id}] - Card [card {:dataset_query (mbql-count-query database-id table-id)}]] + (tt/with-temp* [Database [db] + Table [table {:db_id (u/get-id db)}] + Card [card {:dataset_query (mbql-count-query (u/get-id db) (u/get-id table))}]] ;; revoke permissions for default group to this database - (perms/delete-related-permissions! (perms-group/all-users) (perms/object-path database-id)) + (perms/delete-related-permissions! (perms-group/all-users) (perms/object-path (u/get-id db))) ;; now a non-admin user shouldn't be able to fetch this card ((user->client :rasta) :get 403 (str "card/" (u/get-id card))))) @@ -329,48 +423,60 @@ ((user->client :crowberto) :put 404 "card/12345")) ;; Test that we can edit a Card -(let [updated-name (random-name)] - (tt/expect-with-temp [Card [{card-id :id, original-name :name}]] - [original-name - updated-name] - [(db/select-one-field :name Card, :id card-id) - (do ((user->client :rasta) :put 200 (str "card/" card-id) {:name updated-name}) - (db/select-one-field :name Card, :id card-id))])) - -(defmacro ^:private with-temp-card {:style/indent 1} [binding & body] - `(tt/with-temp Card ~binding - ~@body)) +(expect + {1 "Original Name" + 2 "Updated Name"} + (tt/with-temp Card [card {:name "Original Name"}] + (with-cards-in-writeable-collection card + (array-map + 1 (db/select-one-field :name Card, :id (u/get-id card)) + 2 (do ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:name "Updated Name"}) + (db/select-one-field :name Card, :id (u/get-id card))))))) ;; Can we update a Card's archived status? (expect - [false true false] - (with-temp-card [{:keys [id]}] - (let [archived? (fn [] (:archived (Card id))) - set-archived! (fn [archived] - ((user->client :rasta) :put 200 (str "card/" id) {:archived archived}) - (archived?))] - [(archived?) - (set-archived! true) - (set-archived! false)]))) + {1 false + 2 true + 3 false} + (tt/with-temp Card [card] + (with-cards-in-writeable-collection card + (let [archived? (fn [] (:archived (Card (u/get-id card)))) + set-archived! (fn [archived] + ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:archived archived}) + (archived?))] + (array-map + 1 (archived?) + 2 (set-archived! true) + 3 (set-archived! false)))))) + +;; we shouldn't be able to update archived status if we don't have collection *write* perms +(expect + "You don't have permissions to do that." + (tt/with-temp* [Collection [collection] + Card [card {:collection_id (u/get-id collection)}]] + (perms/grant-collection-read-permissions! (perms-group/all-users) collection) + ((user->client :rasta) :put 403 (str "card/" (u/get-id card)) {:archived true}))) ;; Can we clear the description of a Card? (#4738) (expect nil - (with-temp-card [card {:description "What a nice Card"}] - ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:description nil}) - (db/select-one-field :description Card :id (u/get-id card)))) + (tt/with-temp Card [card {:description "What a nice Card"}] + (with-cards-in-writeable-collection card + ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:description nil}) + (db/select-one-field :description Card :id (u/get-id card))))) ;; description should be blankable as well (expect "" - (with-temp-card [card {:description "What a nice Card"}] - ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:description ""}) - (db/select-one-field :description Card :id (u/get-id card)))) + (tt/with-temp Card [card {:description "What a nice Card"}] + (with-cards-in-writeable-collection card + ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:description ""}) + (db/select-one-field :description Card :id (u/get-id card))))) ;; Can we update a card's embedding_params? (expect {:abc "enabled"} - (with-temp-card [card] + (tt/with-temp Card [card] (tu/with-temporary-setting-values [enable-embedding true] ((user->client :crowberto) :put 200 (str "card/" (u/get-id card)) {:embedding_params {:abc "enabled"}})) (db/select-one-field :embedding_params Card :id (u/get-id card)))) @@ -378,14 +484,14 @@ ;; We shouldn't be able to update them if we're not an admin... (expect "You don't have permissions to do that." - (with-temp-card [card] + (tt/with-temp Card [card] (tu/with-temporary-setting-values [enable-embedding true] ((user->client :rasta) :put 403 (str "card/" (u/get-id card)) {:embedding_params {:abc "enabled"}})))) ;; ...or if embedding isn't enabled (expect "Embedding is not enabled." - (with-temp-card [card] + (tt/with-temp Card [card] (tu/with-temporary-setting-values [enable-embedding false] ((user->client :crowberto) :put 400 (str "card/" (u/get-id card)) {:embedding_params {:abc "enabled"}})))) @@ -400,13 +506,14 @@ :name "count_chocula" :special_type :type/Number}]] (tt/with-temp Card [card] - ;; update the Card's query - ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) - {:dataset_query (mbql-count-query (data/id) (data/id :venues)) - :result_metadata metadata - :metadata_checksum (#'results-metadata/metadata-checksum metadata)}) - ;; now check the metadata that was saved in the DB - (db/select-one-field :result_metadata Card :id (u/get-id card))))) + (with-cards-in-writeable-collection card + ;; update the Card's query + ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) + {:dataset_query (mbql-count-query) + :result_metadata metadata + :metadata_checksum (#'results-metadata/metadata-checksum metadata)}) + ;; now check the metadata that was saved in the DB + (db/select-one-field :result_metadata Card :id (u/get-id card)))))) ;; Make sure when updating a Card the correct query metadata is fetched (if incorrect) (expect @@ -419,33 +526,32 @@ :name "count_chocula" :special_type :type/Number}]] (tt/with-temp Card [card] - ;; update the Card's query - ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) - {:dataset_query (mbql-count-query (data/id) (data/id :venues)) - :result_metadata metadata - :metadata_checksum "ABC123"}) ; invalid checksum - ;; now check the metadata that was saved in the DB - (db/select-one-field :result_metadata Card :id (u/get-id card))))) + (with-cards-in-writeable-collection card + ;; update the Card's query + ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) + {:dataset_query (mbql-count-query) + :result_metadata metadata + :metadata_checksum "ABC123"}) ; invalid checksum + ;; now check the metadata that was saved in the DB + (db/select-one-field :result_metadata Card :id (u/get-id card)))))) ;; Can we change the Collection position of a Card? (expect 1 - (tt/with-temp* [Collection [collection] - Card [card {:collection_id (u/get-id collection)}]] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) - {:collection_position 1}) - (db/select-one-field :collection_position Card :id (u/get-id card)))) + (tt/with-temp Card [card] + (with-cards-in-writeable-collection card + ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) + {:collection_position 1}) + (db/select-one-field :collection_position Card :id (u/get-id card))))) ;; ...and unset (unpin) it as well? (expect nil - (tt/with-temp* [Collection [collection] - Card [card {:collection_id (u/get-id collection), :collection_position 1}]] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) - {:collection_position nil}) - (db/select-one-field :collection_position Card :id (u/get-id card)))) + (tt/with-temp Card [card {:collection_position 1}] + (with-cards-in-writeable-collection card + ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) + {:collection_position nil}) + (db/select-one-field :collection_position Card :id (u/get-id card))))) ;; ...we shouldn't be able to if we don't have permissions for the Collection (expect @@ -478,156 +584,169 @@ :body body-map})) ;; Validate archiving a card trigers alert deletion -(tt/expect-with-temp [Card [{card-id :id :as card}] - Pulse [{pulse-id :id} {:alert_condition "rows" - :alert_first_only false - :creator_id (user->id :rasta) - :name "Original Alert Name"}] - - PulseCard [_ {:pulse_id pulse-id - :card_id card-id - :position 0}] - PulseChannel [{pc-id :id} {:pulse_id pulse-id}] - PulseChannelRecipient [{pcr-id-1 :id} {:user_id (user->id :crowberto) - :pulse_channel_id pc-id}] - PulseChannelRecipient [{pcr-id-2 :id} {:user_id (user->id :rasta) - :pulse_channel_id pc-id}]] - [(merge (crowberto-alert-not-working {"the question was archived by Rasta Toucan" true}) - (rasta-alert-not-working {"the question was archived by Rasta Toucan" true})) - nil] - (et/with-fake-inbox - (et/with-expected-messages 2 - ((user->client :rasta) :put 200 (str "card/" card-id) {:archived true})) - [(et/regex-email-bodies #"the question was archived by Rasta Toucan") - (Pulse pulse-id)])) +(expect + {:emails (merge (crowberto-alert-not-working {"the question was archived by Rasta Toucan" true}) + (rasta-alert-not-working {"the question was archived by Rasta Toucan" true})) + :pulse nil} + (tt/with-temp* [Card [card] + Pulse [pulse {:alert_condition "rows" + :alert_first_only false + :creator_id (user->id :rasta) + :name "Original Alert Name"}] + + PulseCard [_ {:pulse_id (u/get-id pulse) + :card_id (u/get-id card) + :position 0}] + PulseChannel [pc {:pulse_id (u/get-id pulse)}] + PulseChannelRecipient [_ {:user_id (user->id :crowberto) + :pulse_channel_id (u/get-id pc)}] + PulseChannelRecipient [_ {:user_id (user->id :rasta) + :pulse_channel_id (u/get-id pc)}]] + (with-cards-in-writeable-collection card + (et/with-fake-inbox + (et/with-expected-messages 2 + ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:archived true})) + (array-map + :emails (et/regex-email-bodies #"the question was archived by Rasta Toucan") + :pulse (Pulse (u/get-id pulse))))))) ;; Validate changing a display type trigers alert deletion -(tt/expect-with-temp [Card [{card-id :id :as card} {:display :table}] - Pulse [{pulse-id :id} {:alert_condition "rows" - :alert_first_only false - :creator_id (user->id :rasta) - :name "Original Alert Name"}] - - PulseCard [_ {:pulse_id pulse-id - :card_id card-id - :position 0}] - PulseChannel [{pc-id :id} {:pulse_id pulse-id}] - PulseChannelRecipient [{pcr-id-1 :id} {:user_id (user->id :crowberto) - :pulse_channel_id pc-id}] - PulseChannelRecipient [{pcr-id-2 :id} {:user_id (user->id :rasta) - :pulse_channel_id pc-id}]] - [(merge (crowberto-alert-not-working {"the question was edited by Rasta Toucan" true}) - (rasta-alert-not-working {"the question was edited by Rasta Toucan" true})) - - nil] - (et/with-fake-inbox - (et/with-expected-messages 2 - ((user->client :rasta) :put 200 (str "card/" card-id) {:display :line})) - [(et/regex-email-bodies #"the question was edited by Rasta Toucan") - (Pulse pulse-id)])) +(expect + {:emails (merge (crowberto-alert-not-working {"the question was edited by Rasta Toucan" true}) + (rasta-alert-not-working {"the question was edited by Rasta Toucan" true})) + + :pulse nil} + (tt/with-temp* [Card [card {:display :table}] + Pulse [pulse {:alert_condition "rows" + :alert_first_only false + :creator_id (user->id :rasta) + :name "Original Alert Name"}] + + PulseCard [_ {:pulse_id (u/get-id pulse) + :card_id (u/get-id card) + :position 0}] + PulseChannel [pc {:pulse_id (u/get-id pulse)}] + PulseChannelRecipient [_ {:user_id (user->id :crowberto) + :pulse_channel_id (u/get-id pc)}] + PulseChannelRecipient [_ {:user_id (user->id :rasta) + :pulse_channel_id (u/get-id pc)}]] + (with-cards-in-writeable-collection card + (et/with-fake-inbox + (et/with-expected-messages 2 + ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:display :line})) + (array-map + :emails (et/regex-email-bodies #"the question was edited by Rasta Toucan") + :pulse (Pulse (u/get-id pulse))))))) ;; Changing the display type from line to table should force a delete -(tt/expect-with-temp [Card [{card-id :id :as card} {:display :line - :visualization_settings {:graph.goal_value 10}}] - Pulse [{pulse-id :id} {:alert_condition "goal" - :alert_first_only false - :creator_id (user->id :rasta) - :name "Original Alert Name"}] - PulseCard [_ {:pulse_id pulse-id - :card_id card-id - :position 0}] - PulseChannel [{pc-id :id} {:pulse_id pulse-id}] - PulseChannelRecipient [{pcr-id :id} {:user_id (user->id :rasta) - :pulse_channel_id pc-id}]] - [(rasta-alert-not-working {"the question was edited by Rasta Toucan" true}) - nil] - (et/with-fake-inbox - (et/with-expected-messages 1 - ((user->client :rasta) :put 200 (str "card/" card-id) {:display :table})) - [(et/regex-email-bodies #"the question was edited by Rasta Toucan") - (Pulse pulse-id)])) +(expect + {:emails (rasta-alert-not-working {"the question was edited by Rasta Toucan" true}) + :pulse nil} + (tt/with-temp* [Card [card {:display :line + :visualization_settings {:graph.goal_value 10}}] + Pulse [pulse {:alert_condition "goal" + :alert_first_only false + :creator_id (user->id :rasta) + :name "Original Alert Name"}] + PulseCard [_ {:pulse_id (u/get-id pulse) + :card_id (u/get-id card) + :position 0}] + PulseChannel [pc {:pulse_id (u/get-id pulse)}] + PulseChannelRecipient [_ {:user_id (user->id :rasta) + :pulse_channel_id (u/get-id pc)}]] + (with-cards-in-writeable-collection card + (et/with-fake-inbox + (et/with-expected-messages 1 + ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:display :table})) + (array-map + :emails (et/regex-email-bodies #"the question was edited by Rasta Toucan") + :pulse (Pulse (u/get-id pulse))))))) ;; Changing the display type from line to area/bar is fine and doesn't delete the alert -(tt/expect-with-temp [Card [{card-id :id :as card} {:display :line - :visualization_settings {:graph.goal_value 10}}] - Pulse [{pulse-id :id} {:alert_condition "goal" - :alert_first_only false - :creator_id (user->id :rasta) - :name "Original Alert Name"}] - PulseCard [_ {:pulse_id pulse-id - :card_id card-id - :position 0}] - PulseChannel [{pc-id :id} {:pulse_id pulse-id}] - PulseChannelRecipient [{pcr-id :id} {:user_id (user->id :rasta) - :pulse_channel_id pc-id}]] - [{} true {} true] - (et/with-fake-inbox - [(do - ((user->client :rasta) :put 200 (str "card/" card-id) {:display :area}) - (et/regex-email-bodies #"the question was edited by Rasta Toucan")) - (boolean (Pulse pulse-id)) - (do - ((user->client :rasta) :put 200 (str "card/" card-id) {:display :bar}) - (et/regex-email-bodies #"the question was edited by Rasta Toucan")) - (boolean (Pulse pulse-id))])) +(expect + {:emails-1 {} + :pulse-1 true + :emails-2 {} + :pulse-2 true} + (tt/with-temp* [Card [card {:display :line + :visualization_settings {:graph.goal_value 10}}] + Pulse [pulse {:alert_condition "goal" + :alert_first_only false + :creator_id (user->id :rasta) + :name "Original Alert Name"}] + PulseCard [_ {:pulse_id (u/get-id pulse) + :card_id (u/get-id card) + :position 0}] + PulseChannel [pc {:pulse_id (u/get-id pulse)}] + PulseChannelRecipient [_ {:user_id (user->id :rasta) + :pulse_channel_id (u/get-id pc)}]] + (with-cards-in-writeable-collection card + (et/with-fake-inbox + (array-map + :emails-1 (do + ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:display :area}) + (et/regex-email-bodies #"the question was edited by Rasta Toucan")) + :pulse-1 (boolean (Pulse (u/get-id pulse))) + :emails-2 (do + ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:display :bar}) + (et/regex-email-bodies #"the question was edited by Rasta Toucan")) + :pulse-2 (boolean (Pulse (u/get-id pulse)))))))) ;; Removing the goal value will trigger the alert to be deleted -(tt/expect-with-temp [Card [{card-id :id :as card} {:display :line - :visualization_settings {:graph.goal_value 10}}] - Pulse [{pulse-id :id} {:alert_condition "goal" - :alert_first_only false - :creator_id (user->id :rasta) - :name "Original Alert Name"}] - PulseCard [_ {:pulse_id pulse-id - :card_id card-id - :position 0}] - PulseChannel [{pc-id :id} {:pulse_id pulse-id}] - PulseChannelRecipient [{pcr-id :id} {:user_id (user->id :rasta) - :pulse_channel_id pc-id}]] - [(rasta-alert-not-working {"the question was edited by Rasta Toucan" true}) - nil] - (et/with-fake-inbox - (et/with-expected-messages 1 - ((user->client :rasta) :put 200 (str "card/" card-id) {:visualization_settings {:something "else"}})) - [(et/regex-email-bodies #"the question was edited by Rasta Toucan") - (Pulse pulse-id)])) +(expect + {:emails (rasta-alert-not-working {"the question was edited by Rasta Toucan" true}) + :pulse nil} + (tt/with-temp* [Card [card {:display :line + :visualization_settings {:graph.goal_value 10}}] + Pulse [pulse {:alert_condition "goal" + :alert_first_only false + :creator_id (user->id :rasta) + :name "Original Alert Name"}] + PulseCard [_ {:pulse_id (u/get-id pulse) + :card_id (u/get-id card) + :position 0}] + PulseChannel [pc {:pulse_id (u/get-id pulse)}] + PulseChannelRecipient [pcr {:user_id (user->id :rasta) + :pulse_channel_id (u/get-id pc)}]] + (with-cards-in-writeable-collection card + (et/with-fake-inbox + (et/with-expected-messages 1 + ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:visualization_settings {:something "else"}})) + (array-map + :emails (et/regex-email-bodies #"the question was edited by Rasta Toucan") + :pulse (Pulse (u/get-id pulse))))))) ;; Adding an additional breakout will cause the alert to be removed -(tt/expect-with-temp [Card - [card {:display :line - :visualization_settings {:graph.goal_value 10} - :dataset_query (assoc-in - (mbql-count-query (data/id) (data/id :checkins)) - [:query :breakout] - [["datetime-field" (data/id :checkins :date) "hour"]])}] - - Pulse - [{pulse-id :id} {:alert_condition "goal" - :alert_first_only false - :creator_id (user->id :rasta) - :name "Original Alert Name"}] - - PulseCard - [_ {:pulse_id pulse-id - :card_id (u/get-id card) - :position 0}] - - PulseChannel - [{pc-id :id} {:pulse_id pulse-id}] - - PulseChannelRecipient - [{pcr-id :id} {:user_id (user->id :rasta) - :pulse_channel_id pc-id}]] - [(rasta-alert-not-working {"the question was edited by Crowberto Corv" true}) - nil] - (et/with-fake-inbox - (et/with-expected-messages 1 - ((user->client :crowberto) :put 200 (str "card/" (u/get-id card)) - {:dataset_query (assoc-in (mbql-count-query (data/id) (data/id :checkins)) - [:query :breakout] [["datetime-field" (data/id :checkins :date) "hour"] - ["datetime-field" (data/id :checkins :date) "minute"]])})) - [(et/regex-email-bodies #"the question was edited by Crowberto Corv") - (Pulse pulse-id)])) +(expect + {:emails (rasta-alert-not-working {"the question was edited by Crowberto Corv" true}) + :pulse nil} + (tt/with-temp* [Card [card {:display :line + :visualization_settings {:graph.goal_value 10} + :dataset_query (assoc-in + (mbql-count-query (data/id) (data/id :checkins)) + [:query :breakout] + [["datetime-field" + (data/id :checkins :date) + "hour"]])}] + Pulse [pulse {:alert_condition "goal" + :alert_first_only false + :creator_id (user->id :rasta) + :name "Original Alert Name"}] + PulseCard [_ {:pulse_id (u/get-id pulse) + :card_id (u/get-id card) + :position 0}] + PulseChannel [pc {:pulse_id (u/get-id pulse)}] + PulseChannelRecipient [pcr {:user_id (user->id :rasta) + :pulse_channel_id (u/get-id pc)}]] + (et/with-fake-inbox + (et/with-expected-messages 1 + ((user->client :crowberto) :put 200 (str "card/" (u/get-id card)) + {:dataset_query (assoc-in (mbql-count-query (data/id) (data/id :checkins)) + [:query :breakout] [["datetime-field" (data/id :checkins :date) "hour"] + ["datetime-field" (data/id :checkins :date) "minute"]])})) + (array-map + :emails (et/regex-email-bodies #"the question was edited by Crowberto Corv") + :pulse (Pulse (u/get-id pulse)))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | DELETING A CARD (DEPRECATED) | @@ -637,9 +756,10 @@ ;; Check that we can delete a card (expect nil - (with-temp-card [{:keys [id]}] - ((user->client :rasta) :delete 204 (str "card/" id)) - (Card id))) + (tt/with-temp Card [card] + (with-cards-in-writeable-collection card + ((user->client :rasta) :delete 204 (str "card/" (u/get-id card))) + (Card (u/get-id card))))) ;; deleting a card that doesn't exist should return a 404 (#1957) (expect @@ -665,83 +785,56 @@ ;; Can we see if a Card is a favorite ? (expect false - (with-temp-card [card] - (fave? card))) + (tt/with-temp Card [card] + (with-cards-in-readable-collection card + (fave? card)))) ;; ## POST /api/card/:id/favorite ;; Can we favorite a card? (expect - [false - true] - (with-temp-card [card] - [(fave? card) - (do (fave! card) - (fave? card))])) + {1 false + 2 true} + (tt/with-temp Card [card] + (with-cards-in-readable-collection card + (array-map + 1 (fave? card) + 2 (do (fave! card) + (fave? card)))))) ;; DELETE /api/card/:id/favorite ;; Can we unfavorite a card? (expect - [false - true - false] - (with-temp-card [card] - [(fave? card) - (do (fave! card) - (fave? card)) - (do (unfave! card) - (fave? card))])) + {1 false + 2 true + 3 false} + (tt/with-temp Card [card] + (with-cards-in-readable-collection card + (array-map + 1 (fave? card) + 2 (do (fave! card) + (fave? card)) + 3 (do (unfave! card) + (fave? card)))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | CSV/JSON/XLSX DOWNLOADS | ;;; +----------------------------------------------------------------------------------------------------------------+ -;;; POST /api/:card-id/query/csv - -(defn- do-with-temp-native-card {:style/indent 0} [f] - (tt/with-temp* [Database [{database-id :id} {:details (:details (Database (id))), :engine :h2}] - Table [{table-id :id} {:db_id database-id, :name "CATEGORIES"}] - Card [card {:dataset_query {:database database-id - :type :native - :native {:query "SELECT COUNT(*) FROM CATEGORIES;"}}}]] - ;; delete all permissions for this DB - (perms/delete-related-permissions! (perms-group/all-users) (perms/object-path database-id)) - (f database-id card))) - -;; can someone with native query *read* permissions see a CSV card? (Issue #3648) -(expect - (str "COUNT(*)\n" - "75\n") - (do-with-temp-native-card - (fn [database-id card] - ;; insert new permissions for native read access - (perms/grant-native-read-permissions! (perms-group/all-users) database-id) - ;; now run the query - ((user->client :rasta) :post 200 (format "card/%d/query/csv" (u/get-id card)))))) - -;; does someone without *read* permissions get DENIED? -(expect - "You don't have permissions to do that." - (do-with-temp-native-card - (fn [database-id card] - ((user->client :rasta) :post 403 (format "card/%d/query/csv" (u/get-id card)))))) - - ;;; Tests for GET /api/card/:id/json + ;; endpoint should return an array of maps, one for each row (expect [{(keyword "COUNT(*)") 75}] - (do-with-temp-native-card - (fn [database-id card] - (perms/grant-native-read-permissions! (perms-group/all-users) database-id) + (with-temp-native-card [_ card] + (with-cards-in-readable-collection card ((user->client :rasta) :post 200 (format "card/%d/query/json" (u/get-id card)))))) ;;; Tests for GET /api/card/:id/xlsx (expect [{:col "COUNT(*)"} {:col 75.0}] - (do-with-temp-native-card - (fn [database-id card] - (perms/grant-native-read-permissions! (perms-group/all-users) database-id) + (with-temp-native-card [_ card] + (with-cards-in-readable-collection card (->> ((user->client :rasta) :post 200 (format "card/%d/query/xlsx" (u/get-id card)) {:request-options {:as :byte-array}}) ByteArrayInputStream. @@ -750,22 +843,6 @@ (spreadsheet/select-columns {:A :col}))))) ;;; Test GET /api/card/:id/query/csv & GET /api/card/:id/json & GET /api/card/:id/query/xlsx **WITH PARAMETERS** - -(defn- do-with-temp-native-card-with-params {:style/indent 0} [f] - (tt/with-temp* - [Database [{database-id :id} {:details (:details (Database (id))), :engine :h2}] - Table [{table-id :id} {:db_id database-id, :name "VENUES"}] - Card [card {:dataset_query - {:database database-id - :type :native - :native {:query "SELECT COUNT(*) FROM VENUES WHERE CATEGORY_ID = {{category}};" - :template_tags {:category {:id "a9001580-3bcc-b827-ce26-1dbc82429163" - :name "category" - :display_name "Category" - :type "number" - :required true}}}}}]] - (f database-id card))) - (def ^:private ^:const ^String encoded-params (json/generate-string [{:type :category :target [:variable [:template-tag :category]] @@ -775,22 +852,22 @@ (expect (str "COUNT(*)\n" "8\n") - (do-with-temp-native-card-with-params - (fn [database-id card] + (with-temp-native-card-with-params [_ card] + (with-cards-in-readable-collection card ((user->client :rasta) :post 200 (format "card/%d/query/csv?parameters=%s" (u/get-id card) encoded-params))))) ;; JSON (expect [{(keyword "COUNT(*)") 8}] - (do-with-temp-native-card-with-params - (fn [database-id card] + (with-temp-native-card-with-params [_ card] + (with-cards-in-readable-collection card ((user->client :rasta) :post 200 (format "card/%d/query/json?parameters=%s" (u/get-id card) encoded-params))))) ;; XLSX (expect [{:col "COUNT(*)"} {:col 8.0}] - (do-with-temp-native-card-with-params - (fn [database-id card] + (with-temp-native-card-with-params [_ card] + (with-cards-in-readable-collection card (->> ((user->client :rasta) :post 200 (format "card/%d/query/xlsx?parameters=%s" (u/get-id card) encoded-params) {:request-options {:as :byte-array}}) ByteArrayInputStream. @@ -804,14 +881,15 @@ ;;; +----------------------------------------------------------------------------------------------------------------+ ;; Make sure we can create a card and specify its `collection_id` at the same time -(tt/expect-with-temp [Collection [collection]] - (u/get-id collection) - (tu/with-model-cleanup [Card] - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - (let [{card-id :id} ((user->client :rasta) :post 200 "card" - (assoc (card-with-name-and-query (tu/random-name) (mbql-count-query (data/id) (data/id :venues))) - :collection_id (u/get-id collection)))] - (db/select-one-field :collection_id Card :id card-id)))) +(expect + (tt/with-temp Collection [collection] + (tu/with-model-cleanup [Card] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + (let [card ((user->client :rasta) :post 200 "card" + (assoc (card-with-name-and-query) + :collection_id (u/get-id collection)))] + (= (db/select-one-field :collection_id Card :id (u/get-id card)) + (u/get-id collection)))))) ;; Make sure we card creation fails if we try to set a `collection_id` we don't have permissions for (expect @@ -819,16 +897,16 @@ (tu/with-model-cleanup [Card] (tt/with-temp Collection [collection] ((user->client :rasta) :post 403 "card" - (assoc (card-with-name-and-query (tu/random-name) (mbql-count-query (data/id) (data/id :venues))) + (assoc (card-with-name-and-query) :collection_id (u/get-id collection)))))) ;; Make sure we can change the `collection_id` of a Card if it's not in any collection -(tt/expect-with-temp [Card [card] - Collection [collection]] - (u/get-id collection) - (do +(expect + (tt/with-temp* [Card [card] + Collection [collection]] ((user->client :crowberto) :put 200 (str "card/" (u/get-id card)) {:collection_id (u/get-id collection)}) - (db/select-one-field :collection_id Card :id (u/get-id card)))) + (= (db/select-one-field :collection_id Card :id (u/get-id card)) + (u/get-id collection)))) ;; Make sure we can still change *anything* for a Card if we don't have permissions for the Collection it belongs to (expect @@ -858,34 +936,33 @@ ((user->client :rasta) :put 403 (str "card/" (u/get-id card)) {:collection_id (u/get-id new-collection)}))) ;; But if we do have permissions for both, we should be able to change it. -(tt/expect-with-temp [Collection [original-collection] - Collection [new-collection] - Card [card {:collection_id (u/get-id original-collection)}]] - (u/get-id new-collection) - (do +(expect + (tt/with-temp* [Collection [original-collection] + Collection [new-collection] + Card [card {:collection_id (u/get-id original-collection)}]] (perms/grant-collection-readwrite-permissions! (perms-group/all-users) original-collection) (perms/grant-collection-readwrite-permissions! (perms-group/all-users) new-collection) ((user->client :rasta) :put 200 (str "card/" (u/get-id card)) {:collection_id (u/get-id new-collection)}) - (db/select-one-field :collection_id Card :id (u/get-id card)))) + (= (db/select-one-field :collection_id Card :id (u/get-id card)) + (u/get-id new-collection)))) -;;; Test GET /api/card?collection= -- Test that we can use empty string to return Cards not in any collection -(tt/expect-with-temp [Collection [collection] - Card [card-1 {:collection_id (u/get-id collection)}] - Card [card-2]] - [(u/get-id card-2)] - (do - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - (map :id ((user->client :rasta) :get 200 "card/" :collection "")))) +;;; Test GET /api/card?collection= -- Test that we can use empty string to return Cards in the Root Collection +(expect + ["Card 2"] + (tt/with-temp* [Collection [collection] + Card [card-1 {:name "Card 1", :collection_id (u/get-id collection)}] + Card [card-2 {:name "Card 2"}]] + (map :name ((user->client :crowberto) :get 200 "card/" :collection "")))) ;; Test GET /api/card?collection=<slug> filters by collection with slug -(tt/expect-with-temp [Collection [collection {:name "Favorite Places"}] - Card [card-1 {:collection_id (u/get-id collection)}] - Card [card-2]] - [(u/get-id card-1)] - (do - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - (map :id ((user->client :rasta) :get 200 "card/" :collection :favorite_places)))) +(expect + ["Card 1"] + (tt/with-temp* [Collection [collection {:name "Favorite Places"}] + Card [card-1 {:name "Card 1", :collection_id (u/get-id collection)}] + Card [card-2 {:name "Card 2"}]] + (perms/grant-collection-read-permissions! (perms-group/all-users) collection) + (map :name ((user->client :rasta) :get 200 "card/" :collection :favorite_places)))) ;; Test GET /api/card?collection=<slug> should return a 404 if no such collection exists (expect @@ -896,56 +973,69 @@ (expect [] (tt/with-temp Collection [collection {:name "ObsÅ‚uga klienta"}] - (do - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - ((user->client :rasta) :get 200 "card/" :collection "obs%C5%82uga_klienta")))) + (perms/grant-collection-read-permissions! (perms-group/all-users) collection) + ((user->client :rasta) :get 200 "card/" :collection "obs%C5%82uga_klienta"))) ;; ...even if the slug isn't passed in URL-encoded (expect [] (tt/with-temp Collection [collection {:name "ObsÅ‚uga klienta"}] - (do - (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - ((user->client :rasta) :get 200 "card/" :collection "obsÅ‚uga_klienta")))) + (perms/grant-collection-read-permissions! (perms-group/all-users) collection) + ((user->client :rasta) :get 200 "card/" :collection "obsÅ‚uga_klienta"))) ;;; ------------------------------ Bulk Collections Update (POST /api/card/collections) ------------------------------ -(defn- collection-ids [cards-or-card-ids] - (map :collection_id (db/select [Card :collection_id] - :id [:in (map u/get-id cards-or-card-ids)]))) +(defn- collection-names + "Given a sequences of `cards-or-card-ids`, return a corresponding sequence of names of the Collection each Card is + in." + [cards-or-card-ids] + (when (seq cards-or-card-ids) + (let [cards (db/select [Card :collection_id] :id [:in (map u/get-id cards-or-card-ids)]) + collection-ids (set (filter identity (map :collection_id cards))) + collection-id->name (when (seq collection-ids) + (db/select-id->field :name Collection :id [:in collection-ids]))] + (for [card cards] + (get collection-id->name (:collection_id card)))))) (defn- POST-card-collections! "Update the Collection of CARDS-OR-CARD-IDS via the `POST /api/card/collections` endpoint using USERNAME; return the response of this API request and the latest Collection IDs from the database." [username expected-status-code collection-or-collection-id-or-nil cards-or-card-ids] - [((user->client username) :post expected-status-code "card/collections" - {:collection_id (when collection-or-collection-id-or-nil - (u/get-id collection-or-collection-id-or-nil)) - :card_ids (map u/get-id cards-or-card-ids)}) - (collection-ids cards-or-card-ids)]) + (array-map + :response + ((user->client username) :post expected-status-code "card/collections" + {:collection_id (when collection-or-collection-id-or-nil + (u/get-id collection-or-collection-id-or-nil)) + :card_ids (map u/get-id cards-or-card-ids)}) + + :collections + (collection-names cards-or-card-ids))) ;; Test that we can bulk move some Cards with no collection into a collection -(tt/expect-with-temp [Collection [collection] - Card [card-1] - Card [card-2]] - [{:status "ok"} - [(u/get-id collection) (u/get-id collection)]] - (POST-card-collections! :crowberto 200 collection [card-1 card-2])) +(expect + {:response {:status "ok"} + :collections ["Pog Collection" + "Pog Collection"]} + (tt/with-temp* [Collection [collection {:name "Pog Collection"}] + Card [card-1] + Card [card-2]] + (POST-card-collections! :crowberto 200 collection [card-1 card-2]))) ;; Test that we can bulk move some Cards from one collection to another -(tt/expect-with-temp [Collection [old-collection] - Collection [new-collection] - Card [card-1 {:collection_id (u/get-id old-collection)}] - Card [card-2 {:collection_id (u/get-id old-collection)}]] - [{:status "ok"} - [(u/get-id new-collection) (u/get-id new-collection)]] - (POST-card-collections! :crowberto 200 new-collection [card-1 card-2])) +(expect + {:response {:status "ok"} + :collections ["New Collection" "New Collection"]} + (tt/with-temp* [Collection [old-collection {:name "Old Collection"}] + Collection [new-collection {:name "New Collection"}] + Card [card-1 {:collection_id (u/get-id old-collection)}] + Card [card-2 {:collection_id (u/get-id old-collection)}]] + (POST-card-collections! :crowberto 200 new-collection [card-1 card-2]))) ;; Test that we can bulk remove some Cards from a collection (expect - [{:status "ok"} - [nil nil]] + {:response {:status "ok"} + :collections [nil nil]} (tt/with-temp* [Collection [collection] Card [card-1 {:collection_id (u/get-id collection)}] Card [card-2 {:collection_id (u/get-id collection)}]] @@ -953,25 +1043,26 @@ ;; Check that we aren't allowed to move Cards if we don't have permissions for destination collection (expect - ["You don't have permissions to do that." - [nil nil]] + {:response "You don't have permissions to do that." + :collections [nil nil]} (tt/with-temp* [Collection [collection] Card [card-1] Card [card-2]] (POST-card-collections! :rasta 403 collection [card-1 card-2]))) ;; Check that we aren't allowed to move Cards if we don't have permissions for source collection -(tt/expect-with-temp [Collection [collection] - Card [card-1 {:collection_id (u/get-id collection)}] - Card [card-2 {:collection_id (u/get-id collection)}]] - ["You don't have permissions to do that." - [(u/get-id collection) (u/get-id collection)]] - (POST-card-collections! :rasta 403 nil [card-1 card-2])) +(expect + {:response "You don't have permissions to do that." + :collections ["Horseshoe Collection" "Horseshoe Collection"]} + (tt/with-temp* [Collection [collection {:name "Horseshoe Collection"}] + Card [card-1 {:collection_id (u/get-id collection)}] + Card [card-2 {:collection_id (u/get-id collection)}]] + (POST-card-collections! :rasta 403 nil [card-1 card-2]))) ;; Check that we aren't allowed to move Cards if we don't have permissions for the Card (expect - ["You don't have permissions to do that." - [nil nil]] + {:response "You don't have permissions to do that." + :collections [nil nil]} (tt/with-temp* [Collection [collection] Database [database] Table [table {:db_id (u/get-id database)}] @@ -1083,5 +1174,5 @@ ;; Test related/recommended entities (expect #{:table :metrics :segments :dashboard-mates :similar-questions :canonical-metric :dashboards :collections} - (tt/with-temp* [Card [{card-id :id}]] - (-> ((user->client :crowberto) :get 200 (format "card/%s/related" card-id)) keys set))) + (tt/with-temp Card [card] + (-> ((user->client :crowberto) :get 200 (format "card/%s/related" (u/get-id card))) keys set))) diff --git a/test/metabase/api/collection_test.clj b/test/metabase/api/collection_test.clj index 4da100d45fb1c6d9cea5a881177b46eafc6b2695..9e141f07a99c7380047215b0bb8e3c106ec5ed7f 100644 --- a/test/metabase/api/collection_test.clj +++ b/test/metabase/api/collection_test.clj @@ -9,17 +9,15 @@ [collection :as collection :refer [Collection]] [collection-test :as collection-test] [dashboard :refer [Dashboard]] - [database :refer [Database]] [permissions :as perms] - [permissions-group :as group] + [permissions-group :as group :refer [PermissionsGroup]] + [permissions-group-membership :refer [PermissionsGroupMembership]] [pulse :refer [Pulse]] [pulse-card :refer [PulseCard]] [pulse-channel :refer [PulseChannel]] - [pulse-channel-recipient :refer [PulseChannelRecipient]] - [table :refer [Table]]] + [pulse-channel-recipient :refer [PulseChannelRecipient]]] [metabase.test.data.users :refer [user->client user->id]] [metabase.test.util :as tu] - [toucan.db :as db] [toucan.util.test :as tt])) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -258,31 +256,28 @@ {:name "Root Collection" :id "root" :cards [] - :dashboards [{:name "Dine & Dashboard", :collection_position nil}] - :pulses [{:name "Electro-Magnetic Pulse", :collection_position nil}] + :dashboards [] + :pulses [] :can_write false} - ;; create a fake DB and don't give all users perms to it - (tt/with-temp* [Database [db] - Table [table {:db_id (u/get-id db)}]] - (perms/revoke-permissions! (group/all-users) (u/get-id db)) - ;; create the normal 'Child' objects - (with-some-children-of-collection nil - ;; move the Card into the DB that we have no perms for - (db/update! Card (db/select-one-id Card :name "Birthday Card") - :dataset_query {:database (u/get-id db), :type :query, :query {:source-table (u/get-id table)}}) - ;; ok, a regular user shouldn't get to see it any more :( - (-> ((user->client :rasta) :get 200 "collection/root") - (remove-ids-from-collection-detail :keep-collection-id? true))))) + ;; if a User doesn't have perms for the Root Collection then they don't get to see things with no collection_id + (with-some-children-of-collection nil + (-> ((user->client :rasta) :get 200 "collection/root") + (remove-ids-from-collection-detail :keep-collection-id? true)))) -;; Make sure this endpoint can also filter things +;; ...but if they have read perms for the Root Collection they should get to see them (expect - {:name "Root Collection" - :id "root" - :cards [{:name "Birthday Card", :collection_position nil}] - :can_write true} + {:name "Root Collection" + :id "root" + :cards [{:name "Birthday Card" :collection_position nil}] + :dashboards [{:name "Dine & Dashboard" :collection_position nil}] + :pulses [{:name "Electro-Magnetic Pulse" :collection_position nil}] + :can_write false} (with-some-children-of-collection nil - (-> ((user->client :crowberto) :get 200 "collection/root?model=cards") - (remove-ids-from-collection-detail :keep-collection-id? true)))) + (tt/with-temp* [PermissionsGroup [group] + PermissionsGroupMembership [_ {:user_id (user->id :rasta), :group_id (u/get-id group)}]] + (perms/grant-permissions! group (perms/collection-read-path {:metabase.models.collection/is-root? true})) + (-> ((user->client :rasta) :get 200 "collection/root") + (remove-ids-from-collection-detail :keep-collection-id? true))))) ;;; ----------------------------------- Effective Children, Ancestors, & Location ------------------------------------ @@ -296,19 +291,19 @@ ;; Do top-level collections show up as children of the Root Collection? (expect - {:effective_children #{{:name "A", :id true}} + {:effective_children #{{:name "A", :id true}} :effective_ancestors [] - :effective_location "/"} + :effective_location nil} (with-collection-hierarchy [a b c d e f g] (api-get-root-collection-ancestors-and-children))) ;; ...and collapsing children should work for the Root Collection as well (expect - {:effective_children #{{:name "B", :id true} - {:name "D", :id true} - {:name "F", :id true}} + {:effective_children #{{:name "B", :id true} + {:name "D", :id true} + {:name "F", :id true}} :effective_ancestors [] - :effective_location "/"} + :effective_location nil} (with-collection-hierarchy [b d e f g] (api-get-root-collection-ancestors-and-children))) @@ -379,33 +374,34 @@ {:name "My Beautiful Collection", :color "#ABCDEF"}))) ;; Archiving a collection should delete any alerts associated with questions in the collection -(tt/expect-with-temp [Collection [{collection-id :id}] - Card [{card-id :id :as card} {:collection_id collection-id}] - Pulse [{pulse-id :id} {:alert_condition "rows" - :alert_first_only false - :creator_id (user->id :rasta) - :name "Original Alert Name"}] - - PulseCard [_ {:pulse_id pulse-id - :card_id card-id - :position 0}] - PulseChannel [{pc-id :id} {:pulse_id pulse-id}] - PulseChannelRecipient [{pcr-id-1 :id} {:user_id (user->id :crowberto) - :pulse_channel_id pc-id}] - PulseChannelRecipient [{pcr-id-2 :id} {:user_id (user->id :rasta) - :pulse_channel_id pc-id}]] - - [(merge (et/email-to :crowberto {:subject "One of your alerts has stopped working", - :body {"the question was archived by Crowberto Corv" true}}) - (et/email-to :rasta {:subject "One of your alerts has stopped working", - :body {"the question was archived by Crowberto Corv" true}})) - nil] - (et/with-fake-inbox - (et/with-expected-messages 2 - ((user->client :crowberto) :put 200 (str "collection/" collection-id) - {:name "My Beautiful Collection", :color "#ABCDEF", :archived true})) - [(et/regex-email-bodies #"the question was archived by Crowberto Corv") - (Pulse pulse-id)])) +(expect + {:emails (merge (et/email-to :crowberto {:subject "One of your alerts has stopped working", + :body {"the question was archived by Crowberto Corv" true}}) + (et/email-to :rasta {:subject "One of your alerts has stopped working", + :body {"the question was archived by Crowberto Corv" true}})) + :pulse nil} + (tt/with-temp* [Collection [{collection-id :id}] + Card [{card-id :id :as card} {:collection_id collection-id}] + Pulse [{pulse-id :id} {:alert_condition "rows" + :alert_first_only false + :creator_id (user->id :rasta) + :name "Original Alert Name"}] + + PulseCard [_ {:pulse_id pulse-id + :card_id card-id + :position 0}] + PulseChannel [{pc-id :id} {:pulse_id pulse-id}] + PulseChannelRecipient [{pcr-id-1 :id} {:user_id (user->id :crowberto) + :pulse_channel_id pc-id}] + PulseChannelRecipient [{pcr-id-2 :id} {:user_id (user->id :rasta) + :pulse_channel_id pc-id}]] + (et/with-fake-inbox + (et/with-expected-messages 2 + ((user->client :crowberto) :put 200 (str "collection/" collection-id) + {:name "My Beautiful Collection", :color "#ABCDEF", :archived true})) + (array-map + :emails (et/regex-email-bodies #"the question was archived by Crowberto Corv") + :pulse (Pulse pulse-id))))) ;; Can I *change* the `location` of a Collection? (i.e. move it into a different parent Colleciton) (expect diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj index 3341a57f1ae6101bf46e7fb2fd2cfb35f7b06590..272b849d48de2356294c4fed56b5c346b698cd5f 100644 --- a/test/metabase/api/dashboard_test.clj +++ b/test/metabase/api/dashboard_test.clj @@ -25,13 +25,15 @@ [toucan.util.test :as tt]) (:import java.util.UUID)) -;; ## Helper Fns +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | Helper Fns & Macros | +;;; +----------------------------------------------------------------------------------------------------------------+ -(defn remove-ids-and-boolean-timestamps [m] +(defn- remove-ids-and-booleanize-timestamps [m] (let [f (fn [v] (cond - (map? v) (remove-ids-and-boolean-timestamps v) - (coll? v) (mapv remove-ids-and-boolean-timestamps v) + (map? v) (remove-ids-and-booleanize-timestamps v) + (coll? v) (mapv remove-ids-and-booleanize-timestamps v) :else v))] (into {} (for [[k v] m] (when-not (or (= :id k) @@ -59,19 +61,37 @@ (assoc :created_at (boolean created_at) :updated_at (boolean updated_at) :card (-> (into {} card) - (dissoc :id :database_id :table_id :created_at :updated_at))))) + (dissoc :id :database_id :table_id :created_at :updated_at) + (update :collection_id boolean))))) (defn- dashboard-response [{:keys [creator ordered_cards created_at updated_at] :as dashboard}] (let [dash (-> (into {} dashboard) (dissoc :id) (assoc :created_at (boolean created_at) - :updated_at (boolean updated_at)))] + :updated_at (boolean updated_at)) + (update :collection_id boolean))] (cond-> dash creator (update :creator #(into {} %)) ordered_cards (update :ordered_cards #(mapv dashcard-response %))))) +(defn- do-with-dashboards-in-a-collection [grant-collection-perms-fn! dashboards-or-ids f] + (tt/with-temp Collection [collection] + (grant-collection-perms-fn! (group/all-users) collection) + (doseq [dashboard-or-id dashboards-or-ids] + (db/update! Dashboard (u/get-id dashboard-or-id) :collection_id (u/get-id collection))) + (f))) + +(defmacro ^:private with-dashboards-in-readable-collection [dashboards-or-ids & body] + `(do-with-dashboards-in-a-collection perms/grant-collection-read-permissions! ~dashboards-or-ids (fn [] ~@body))) + +(defmacro ^:private with-dashboards-in-writeable-collection [dashboards-or-ids & body] + `(do-with-dashboards-in-a-collection perms/grant-collection-readwrite-permissions! ~dashboards-or-ids (fn [] ~@body))) + + +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | /api/dashboard/* AUTHENTICATION Tests | +;;; +----------------------------------------------------------------------------------------------------------------+ -;; ## /api/dashboard/* AUTHENTICATION Tests ;; We assume that all endpoints for a given context are enforced by the same middleware, so we don't run the same ;; authentication test on every single individual endpoint @@ -92,7 +112,7 @@ ((user->client :crowberto) :post 400 "dashboard" {:name "Test" :parameters "abc"})) -(def ^:private ^:const dashboard-defaults +(def ^:private dashboard-defaults {:archived false :caveats nil :collection_id nil @@ -111,15 +131,19 @@ (expect (merge dashboard-defaults - {:name "Test Create Dashboard" - :creator_id (user->id :rasta) - :parameters [{:hash "abc123", :name "test", :type "date"}] - :updated_at true - :created_at true}) + {:name "Test Create Dashboard" + :creator_id (user->id :rasta) + :parameters [{:hash "abc123", :name "test", :type "date"}] + :updated_at true + :created_at true + :collection_id true}) (tu/with-model-cleanup [Dashboard] - (-> ((user->client :rasta) :post 200 "dashboard" {:name "Test Create Dashboard" - :parameters [{:hash "abc123", :name "test", :type "date"}]}) - dashboard-response))) + (tt/with-temp Collection [collection] + (perms/grant-collection-readwrite-permissions! (group/all-users) collection) + (-> ((user->client :rasta) :post 200 "dashboard" {:name "Test Create Dashboard" + :parameters [{:hash "abc123", :name "test", :type "date"}] + :collection_id (u/get-id collection)}) + dashboard-response)))) ;; Make sure we can create a Dashboard with a Collection position (expect @@ -155,6 +179,7 @@ (merge dashboard-defaults {:name "Test Dashboard" :creator_id (user->id :rasta) + :collection_id true :ordered_cards [{:sizeX 2 :sizeY 2 :col 0 @@ -166,20 +191,22 @@ :card (merge card-api-test/card-defaults {:name "Dashboard Test Card" :creator_id (user->id :rasta) + :collection_id true :display "table" :query_type nil :dataset_query {} :read_permissions nil :visualization_settings {} :query_average_duration nil - :in_public_dashboard false :result_metadata nil}) :series []}]}) ;; fetch a dashboard WITH a dashboard card on it (tt/with-temp* [Dashboard [{dashboard-id :id} {:name "Test Dashboard"}] Card [{card-id :id} {:name "Dashboard Test Card"}] DashboardCard [_ {:dashboard_id dashboard-id, :card_id card-id}]] - (dashboard-response ((user->client :rasta) :get 200 (format "dashboard/%d" dashboard-id))))) + (with-dashboards-in-readable-collection [dashboard-id] + (card-api-test/with-cards-in-readable-collection [card-id] + (dashboard-response ((user->client :rasta) :get 200 (format "dashboard/%d" dashboard-id))))))) ;; ## GET /api/dashboard/:id with a series, should fail if the user doesn't have access to the collection (expect @@ -201,48 +228,58 @@ ;;; +----------------------------------------------------------------------------------------------------------------+ (expect - [(merge dashboard-defaults {:name "Test Dashboard" - :creator_id (user->id :rasta)}) - (merge dashboard-defaults {:name "My Cool Dashboard" - :description "Some awesome description" - :creator_id (user->id :rasta)}) - (merge dashboard-defaults {:name "My Cool Dashboard" - :description "Some awesome description" - :creator_id (user->id :rasta)})] + {1 (merge dashboard-defaults {:name "Test Dashboard" + :creator_id (user->id :rasta) + :collection_id true}) + 2 (merge dashboard-defaults {:name "My Cool Dashboard" + :description "Some awesome description" + :creator_id (user->id :rasta) + :collection_id true}) + 3 (merge dashboard-defaults {:name "My Cool Dashboard" + :description "Some awesome description" + :creator_id (user->id :rasta) + :collection_id true})} (tt/with-temp Dashboard [{dashboard-id :id} {:name "Test Dashboard"}] - (mapv dashboard-response [(Dashboard dashboard-id) - ((user->client :rasta) :put 200 (str "dashboard/" dashboard-id) - {:name "My Cool Dashboard" - :description "Some awesome description" - ;; these things should fail to update - :creator_id (user->id :trashbird)}) - (Dashboard dashboard-id)]))) + (with-dashboards-in-writeable-collection [dashboard-id] + (array-map + 1 (dashboard-response (Dashboard dashboard-id)) + 2 (dashboard-response + ((user->client :rasta) :put 200 (str "dashboard/" dashboard-id) + {:name "My Cool Dashboard" + :description "Some awesome description" + ;; these things should fail to update + :creator_id (user->id :trashbird)})) + 3 (dashboard-response (Dashboard dashboard-id)))))) ;; allow "caveats" and "points_of_interest" to be empty strings, and "show_in_getting_started" should be a boolean (expect - (merge dashboard-defaults {:name "Test Dashboard" - :creator_id (user->id :rasta) + (merge dashboard-defaults {:name "Test Dashboard" + :creator_id (user->id :rasta) + :collection_id true :caveats "" :points_of_interest "" :show_in_getting_started true}) (tt/with-temp Dashboard [{dashboard-id :id} {:name "Test Dashboard"}] - (dashboard-response ((user->client :rasta) :put 200 (str "dashboard/" dashboard-id) - {:caveats "" - :points_of_interest "" - :show_in_getting_started true})))) + (with-dashboards-in-writeable-collection [dashboard-id] + (dashboard-response ((user->client :rasta) :put 200 (str "dashboard/" dashboard-id) + {:caveats "" + :points_of_interest "" + :show_in_getting_started true}))))) ;; Can we clear the description of a Dashboard? (#4738) (expect nil (tt/with-temp Dashboard [dashboard {:description "What a nice Dashboard"}] - ((user->client :rasta) :put 200 (str "dashboard/" (u/get-id dashboard)) {:description nil}) - (db/select-one-field :description Dashboard :id (u/get-id dashboard)))) + (with-dashboards-in-writeable-collection [dashboard] + ((user->client :rasta) :put 200 (str "dashboard/" (u/get-id dashboard)) {:description nil}) + (db/select-one-field :description Dashboard :id (u/get-id dashboard))))) (expect "" (tt/with-temp Dashboard [dashboard {:description "What a nice Dashboard"}] - ((user->client :rasta) :put 200 (str "dashboard/" (u/get-id dashboard)) {:description ""}) - (db/select-one-field :description Dashboard :id (u/get-id dashboard)))) + (with-dashboards-in-writeable-collection [dashboard] + ((user->client :rasta) :put 200 (str "dashboard/" (u/get-id dashboard)) {:description ""}) + (db/select-one-field :description Dashboard :id (u/get-id dashboard))))) ;; Can we change the Collection a Dashboard is in (assuming we have the permissions to do so)? (expect @@ -323,8 +360,9 @@ (expect [nil nil] (tt/with-temp Dashboard [{dashboard-id :id}] - [((user->client :rasta) :delete 204 (format "dashboard/%d" dashboard-id)) - (Dashboard dashboard-id)])) + (with-dashboards-in-writeable-collection [dashboard-id] + [((user->client :rasta) :delete 204 (format "dashboard/%d" dashboard-id)) + (Dashboard dashboard-id)]))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -333,68 +371,74 @@ ;; simple creation with no additional series (expect - [{:sizeX 2 - :sizeY 2 - :col 4 - :row 4 - :series [] - :parameter_mappings [{:card-id 123, :hash "abc", :target "foo"}] - :visualization_settings {} - :created_at true - :updated_at true} - [{:sizeX 2 - :sizeY 2 - :col 4 - :row 4 - :parameter_mappings [{:card-id 123, :hash "abc", :target "foo"}] - :visualization_settings {}}]] + {1 {:sizeX 2 + :sizeY 2 + :col 4 + :row 4 + :series [] + :parameter_mappings [{:card-id 123, :hash "abc", :target "foo"}] + :visualization_settings {} + :created_at true + :updated_at true} + 2 [{:sizeX 2 + :sizeY 2 + :col 4 + :row 4 + :parameter_mappings [{:card-id 123, :hash "abc", :target "foo"}] + :visualization_settings {}}]} (tt/with-temp* [Dashboard [{dashboard-id :id}] Card [{card-id :id}]] - [(-> ((user->client :rasta) :post 200 (format "dashboard/%d/cards" dashboard-id) - {:cardId card-id - :row 4 - :col 4 - :parameter_mappings [{:card-id 123, :hash "abc", :target "foo"}] - :visualization_settings {}}) - (dissoc :id :dashboard_id :card_id) - (update :created_at boolean) - (update :updated_at boolean)) - (map (partial into {}) - (db/select [DashboardCard :sizeX :sizeY :col :row :parameter_mappings :visualization_settings] - :dashboard_id dashboard-id))])) + (with-dashboards-in-writeable-collection [dashboard-id] + (card-api-test/with-cards-in-readable-collection [card-id] + (array-map + 1 (-> ((user->client :rasta) :post 200 (format "dashboard/%d/cards" dashboard-id) + {:cardId card-id + :row 4 + :col 4 + :parameter_mappings [{:card-id 123, :hash "abc", :target "foo"}] + :visualization_settings {}}) + (dissoc :id :dashboard_id :card_id) + (update :created_at boolean) + (update :updated_at boolean)) + 2 (map (partial into {}) + (db/select [DashboardCard :sizeX :sizeY :col :row :parameter_mappings :visualization_settings] + :dashboard_id dashboard-id))))))) ;; new dashboard card w/ additional series (expect - [{:sizeX 2 - :sizeY 2 - :col 4 - :row 4 - :parameter_mappings [] - :visualization_settings {} - :series [{:name "Series Card" - :description nil - :display "table" - :dataset_query {} - :visualization_settings {}}] - :created_at true - :updated_at true} - [{:sizeX 2 - :sizeY 2 - :col 4 - :row 4}] - #{0}] + {1 {:sizeX 2 + :sizeY 2 + :col 4 + :row 4 + :parameter_mappings [] + :visualization_settings {} + :series [{:name "Series Card" + :description nil + :display "table" + :dataset_query {} + :visualization_settings {}}] + :created_at true + :updated_at true} + 2 [{:sizeX 2 + :sizeY 2 + :col 4 + :row 4}] + 3 #{0}} (tt/with-temp* [Dashboard [{dashboard-id :id}] Card [{card-id :id}] Card [{series-id-1 :id} {:name "Series Card"}]] - (let [dashboard-card ((user->client :rasta) :post 200 (format "dashboard/%d/cards" dashboard-id) - {:cardId card-id - :row 4 - :col 4 - :series [{:id series-id-1}]})] - [(remove-ids-and-boolean-timestamps dashboard-card) - (map (partial into {}) - (db/select [DashboardCard :sizeX :sizeY :col :row], :dashboard_id dashboard-id)) - (db/select-field :position DashboardCardSeries, :dashboardcard_id (:id dashboard-card))]))) + (with-dashboards-in-writeable-collection [dashboard-id] + (card-api-test/with-cards-in-readable-collection [card-id series-id-1] + (let [dashboard-card ((user->client :rasta) :post 200 (format "dashboard/%d/cards" dashboard-id) + {:cardId card-id + :row 4 + :col 4 + :series [{:id series-id-1}]})] + (array-map + 1 (remove-ids-and-booleanize-timestamps dashboard-card) + 2 (map (partial into {}) + (db/select [DashboardCard :sizeX :sizeY :col :row], :dashboard_id dashboard-id)) + 3 (db/select-field :position DashboardCardSeries, :dashboardcard_id (:id dashboard-card)))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -402,9 +446,9 @@ ;;; +----------------------------------------------------------------------------------------------------------------+ (expect - [1 - {:success true} - 0] + {1 1 + 2 {:success true} + 3 0} ;; fetch a dashboard WITH a dashboard card on it (tt/with-temp* [Dashboard [{dashboard-id :id}] Card [{card-id :id}] @@ -413,9 +457,11 @@ DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id, :card_id card-id}] DashboardCardSeries [_ {:dashboardcard_id dashcard-id, :card_id series-id-1, :position 0}] DashboardCardSeries [_ {:dashboardcard_id dashcard-id, :card_id series-id-2, :position 1}]] - [(count (db/select-ids DashboardCard, :dashboard_id dashboard-id)) - ((user->client :rasta) :delete 200 (format "dashboard/%d/cards" dashboard-id) :dashcardId dashcard-id) - (count (db/select-ids DashboardCard, :dashboard_id dashboard-id))])) + (with-dashboards-in-writeable-collection [dashboard-id] + (array-map + 1 (count (db/select-ids DashboardCard, :dashboard_id dashboard-id)) + 2 ((user->client :rasta) :delete 200 (format "dashboard/%d/cards" dashboard-id) :dashcardId dashcard-id) + 3 (count (db/select-ids DashboardCard, :dashboard_id dashboard-id)))))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -423,68 +469,70 @@ ;;; +----------------------------------------------------------------------------------------------------------------+ (expect - [[{:sizeX 2 - :sizeY 2 - :col 0 - :row 0 - :series [] - :parameter_mappings [] - :visualization_settings {} - :created_at true - :updated_at true} - {:sizeX 2 - :sizeY 2 - :col 0 - :row 0 - :parameter_mappings [] - :visualization_settings {} - :series [] - :created_at true - :updated_at true}] - {:status "ok"} - [{:sizeX 4 - :sizeY 2 - :col 0 - :row 0 - :parameter_mappings [] - :visualization_settings {} - :series [{:name "Series Card" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}}] - :created_at true - :updated_at true} - {:sizeX 1 - :sizeY 1 - :col 1 - :row 3 - :parameter_mappings [] - :visualization_settings {} - :series [] - :created_at true - :updated_at true}]] + {1 [{:sizeX 2 + :sizeY 2 + :col 0 + :row 0 + :series [] + :parameter_mappings [] + :visualization_settings {} + :created_at true + :updated_at true} + {:sizeX 2 + :sizeY 2 + :col 0 + :row 0 + :parameter_mappings [] + :visualization_settings {} + :series [] + :created_at true + :updated_at true}] + 2 {:status "ok"} + 3 [{:sizeX 4 + :sizeY 2 + :col 0 + :row 0 + :parameter_mappings [] + :visualization_settings {} + :series [{:name "Series Card" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}}] + :created_at true + :updated_at true} + {:sizeX 1 + :sizeY 1 + :col 1 + :row 3 + :parameter_mappings [] + :visualization_settings {} + :series [] + :created_at true + :updated_at true}]} ;; fetch a dashboard WITH a dashboard card on it (tt/with-temp* [Dashboard [{dashboard-id :id}] Card [{card-id :id}] DashboardCard [{dashcard-id-1 :id} {:dashboard_id dashboard-id, :card_id card-id}] DashboardCard [{dashcard-id-2 :id} {:dashboard_id dashboard-id, :card_id card-id}] Card [{series-id-1 :id} {:name "Series Card"}]] - [[(remove-ids-and-boolean-timestamps (retrieve-dashboard-card dashcard-id-1)) - (remove-ids-and-boolean-timestamps (retrieve-dashboard-card dashcard-id-2))] - ((user->client :rasta) :put 200 (format "dashboard/%d/cards" dashboard-id) {:cards [{:id dashcard-id-1 - :sizeX 4 - :sizeY 2 - :col 0 - :row 0 - :series [{:id series-id-1}]} - {:id dashcard-id-2 - :sizeX 1 - :sizeY 1 - :col 1 - :row 3}]}) - [(remove-ids-and-boolean-timestamps (retrieve-dashboard-card dashcard-id-1)) - (remove-ids-and-boolean-timestamps (retrieve-dashboard-card dashcard-id-2))]])) + (with-dashboards-in-writeable-collection [dashboard-id] + (array-map + 1 [(remove-ids-and-booleanize-timestamps (retrieve-dashboard-card dashcard-id-1)) + (remove-ids-and-booleanize-timestamps (retrieve-dashboard-card dashcard-id-2))] + 2 ((user->client :rasta) :put 200 (format "dashboard/%d/cards" dashboard-id) {:cards [{:id dashcard-id-1 + :sizeX 4 + :sizeY 2 + :col 0 + :row 0 + :series [{:id series-id-1}]} + {:id dashcard-id-2 + :sizeX 1 + :sizeY 1 + :col 1 + :row 3}]}) + 3 [(remove-ids-and-booleanize-timestamps (retrieve-dashboard-card dashcard-id-1)) + (remove-ids-and-booleanize-timestamps (retrieve-dashboard-card dashcard-id-2))])))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -551,7 +599,7 @@ (expect - [ ;; the api response + {:response {:is_reversion true :is_creation false :message nil @@ -560,7 +608,8 @@ :diff {:before {:name "b"} :after {:name "a"}} :description "renamed it from \"b\" to \"a\"."} - ;; full list of final revisions, first one should be same as the revision returned by the endpoint + + :revisions [{:is_reversion true :is_creation false :message nil @@ -583,7 +632,7 @@ :user (-> (user-details (fetch-user :rasta)) (dissoc :email :date_joined :last_login :is_superuser :is_qbnewb)) :diff nil - :description nil}]] + :description nil}]} (tt/with-temp* [Dashboard [{dashboard-id :id}] Revision [{revision-id :id} {:model "Dashboard" :model_id dashboard-id @@ -598,11 +647,15 @@ :description nil :cards []} :message "updated"}]] - [(dissoc ((user->client :crowberto) :post 200 (format "dashboard/%d/revert" dashboard-id) + (array-map + :response + (dissoc ((user->client :crowberto) :post 200 (format "dashboard/%d/revert" dashboard-id) {:revision_id revision-id}) :id :timestamp) + + :revisions (doall (for [revision ((user->client :crowberto) :get 200 (format "dashboard/%d/revisions" dashboard-id))] - (dissoc revision :timestamp :id)))])) + (dissoc revision :timestamp :id)))))) ;;; +----------------------------------------------------------------------------------------------------------------+ diff --git a/test/metabase/api/dataset_test.clj b/test/metabase/api/dataset_test.clj index bb42648738dc820e0b7d62af5b41d22ef78d6842..2063529d8757ed2be9cf63f141378998711c665b 100644 --- a/test/metabase/api/dataset_test.clj +++ b/test/metabase/api/dataset_test.clj @@ -4,22 +4,18 @@ [core :as json] [generate :as generate]] [clojure.data.csv :as csv] - [clojure.java.jdbc :as jdbc] [dk.ative.docjure.spreadsheet :as spreadsheet] [expectations :refer :all] [medley.core :as m] - [metabase.models - [database :refer [Database]] - [query-execution :refer [QueryExecution]]] + [metabase.models.query-execution :refer [QueryExecution]] [metabase.query-processor :as qp] [metabase.query-processor.middleware.expand :as ql] - [metabase.sync :as sync] [metabase.test [data :refer :all] [util :as tu]] [metabase.test.data - [datasets :refer [expect-with-engine]] [dataset-definitions :as defs] + [datasets :refer [expect-with-engine]] [users :refer :all]] [toucan.db :as db])) @@ -34,20 +30,6 @@ :is_qbnewb $ :common_name $})) -(defn remove-ids-and-boolean-timestamps [m] - (let [f (fn [v] - (cond - (map? v) (remove-ids-and-boolean-timestamps v) - (coll? v) (mapv remove-ids-and-boolean-timestamps v) - :else v))] - (into {} (for [[k v] m] - (when-not (or (= :id k) - (.endsWith (name k) "_id")) - (if (or (= :created_at k) - (= :updated_at k)) - [k (some? v)] - [k (f v)])))))) - (defn format-response [m] (into {} (for [[k v] (m/dissoc-in m [:data :results_metadata])] (cond diff --git a/test/metabase/api/field_test.clj b/test/metabase/api/field_test.clj index 8b2757186ac284b3de71c531e39977ce22b0f6e9..5217edf2ce441d4334cae5d565a53f32da93bf65 100644 --- a/test/metabase/api/field_test.clj +++ b/test/metabase/api/field_test.clj @@ -92,15 +92,17 @@ ((user->client :rasta) :get 200 (format "field/%d" (data/id :users :name)))) -;; ## GET /api/field/:id/summary + +;;; ------------------------------------------- GET /api/field/:id/summary ------------------------------------------- + (expect [["count" 75] ; why doesn't this come back as a dictionary ? ["distincts" 75]] ((user->client :rasta) :get 200 (format "field/%d/summary" (data/id :categories :name)))) -;; ## PUT /api/field/:id +;;; ----------------------------------------------- PUT /api/field/:id ----------------------------------------------- -(defn- simple-field-details [field] +(defn simple-field-details [field] (select-keys field [:name :display_name :description :visibility_type :special_type :fk_target_field_id])) ;; test that we can do basic field update work, including unsetting some fields such as special-type @@ -141,15 +143,16 @@ ;; when we set the special-type from :type/FK to something else, make sure fk_target_field_id is set to nil (expect - [true - nil] + {1 true + 2 nil} (tt/with-temp* [Field [{fk-field-id :id}] Field [{field-id :id} {:special_type :type/FK, :fk_target_field_id fk-field-id}]] (let [original-val (boolean (db/select-one-field :fk_target_field_id Field, :id field-id))] ;; unset the :type/FK special-type ((user->client :crowberto) :put 200 (format "field/%d" field-id) {:special_type :type/Name}) - [original-val - (db/select-one-field :fk_target_field_id Field, :id field-id)]))) + (array-map + 1 original-val + 2 (db/select-one-field :fk_target_field_id Field, :id field-id))))) ;; check that you *can* set it if it *is* the proper base type @@ -189,9 +192,10 @@ {:values [], :field_id (data/id :users :password)} ((user->client :rasta) :get 200 (format "field/%d/values" (data/id :users :password)))) -(def ^:private list-field {:name "Field Test", :base_type :type/Integer, :has_field_values "list"}) -;; ## POST /api/field/:id/values +;;; ------------------------------------------- POST /api/field/:id/values ------------------------------------------- + +(def ^:private list-field {:name "Field Test", :base_type :type/Integer, :has_field_values "list"}) ;; Human readable values are optional (expect diff --git a/test/metabase/api/pulse_test.clj b/test/metabase/api/pulse_test.clj index 78650b4162aff9b38d19e2f1764f1c5ca39e78ef..3cfb040ae48136df6136202e6888762f65b34507 100644 --- a/test/metabase/api/pulse_test.clj +++ b/test/metabase/api/pulse_test.clj @@ -6,6 +6,7 @@ [http-client :as http] [middleware :as middleware] [util :as u]] + [metabase.api.card-test :as card-api-test] [metabase.models [card :refer [Card]] [collection :refer [Collection]] @@ -13,9 +14,9 @@ [permissions :as perms] [permissions-group :as perms-group] [pulse :as pulse :refer [Pulse]] + [pulse-card :refer [PulseCard]] [pulse-channel :refer [PulseChannel]] [pulse-channel-recipient :refer [PulseChannelRecipient]] - [pulse-card :refer [PulseCard]] [pulse-test :as pulse-test] [table :refer [Table]]] [metabase.test @@ -28,15 +29,18 @@ [toucan.db :as db] [toucan.util.test :as tt])) -;; ## Helper Fns +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | Helper Fns & Macros | +;;; +----------------------------------------------------------------------------------------------------------------+ (defn- user-details [user] (select-keys user [:email :first_name :last_login :is_qbnewb :is_superuser :id :last_name :date_joined :common_name])) (defn- pulse-card-details [card] - (-> (select-keys card [:id :name :description :display]) + (-> (select-keys card [:id :collection_id :name :description :display]) (update :display name) - (assoc :include_csv false :include_xls false))) + (update :collection_id boolean) + (assoc :include_csv false, :include_xls false))) ; why?? (defn- pulse-channel-details [channel] (select-keys channel [:schedule_type :schedule_details :channel_type :updated_at :details :pulse_id :id :enabled @@ -60,10 +64,32 @@ (-> pulse (dissoc :id) (assoc :created_at (some? created_at) - :updated_at (some? updated_at)))) + :updated_at (some? updated_at)) + (update :collection_id boolean) + (update :cards #(for [card %] + (update card :collection_id boolean))))) +(defn- do-with-pulses-in-a-collection [grant-collection-perms-fn! pulses-or-ids f] + (tt/with-temp Collection [collection] + (grant-collection-perms-fn! (perms-group/all-users) collection) + ;; use db/execute! instead of db/update! so the updated_at field doesn't get automatically updated! + (when (seq pulses-or-ids) + (db/execute! {:update Pulse + :set [[:collection_id (u/get-id collection)]] + :where [:in :id (set (map u/get-id pulses-or-ids))]})) + (f))) + +(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))) + +(defmacro ^:private with-pulses-in-writeable-collection [pulses-or-ids & body] + `(do-with-pulses-in-a-collection perms/grant-collection-readwrite-permissions! ~pulses-or-ids (fn [] ~@body))) + + +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | /api/pulse/* AUTHENTICATION Tests | +;;; +----------------------------------------------------------------------------------------------------------------+ -;; ## /api/pulse/* AUTHENTICATION Tests ;; We assume that all endpoints for a given context are enforced by the same middleware, so we don't run the same ;; authentication test on every single individual endpoint @@ -139,53 +165,67 @@ Card [card-2]] (merge pulse-defaults - {:name "A Pulse" - :creator_id (user->id :rasta) - :creator (user-details (fetch-user :rasta)) - :cards (mapv pulse-card-details [card-1 card-2]) - :channels [(merge pulse-channel-defaults - {:channel_type "email" - :schedule_type "daily" - :schedule_hour 12 - :recipients []})]}) - (tu/with-model-cleanup [Pulse] - (-> (pulse-response ((user->client :rasta) :post 200 "pulse" {:name "A Pulse" - :cards [{:id (u/get-id card-1) - :include_csv false - :include_xls false} - {:id (u/get-id card-2) - :include_csv false - :include_xls false}] - :channels [daily-email-channel] - :skip_if_empty false})) - (update :channels remove-extra-channels-fields)))) + {:name "A Pulse" + :creator_id (user->id :rasta) + :creator (user-details (fetch-user :rasta)) + :cards (for [card [card-1 card-2]] + (assoc (pulse-card-details card) + :collection_id true)) + :channels [(merge pulse-channel-defaults + {:channel_type "email" + :schedule_type "daily" + :schedule_hour 12 + :recipients []})] + :collection_id true}) + (card-api-test/with-cards-in-readable-collection [card-1 card-2] + (tt/with-temp Collection [collection] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + (tu/with-model-cleanup [Pulse] + (-> ((user->client :rasta) :post 200 "pulse" {:name "A Pulse" + :collection_id (u/get-id collection) + :cards [{:id (u/get-id card-1) + :include_csv false + :include_xls false} + {:id (u/get-id card-2) + :include_csv false + :include_xls false}] + :channels [daily-email-channel] + :skip_if_empty false}) + pulse-response + (update :channels remove-extra-channels-fields)))))) ;; Create a pulse with a csv and xls (tt/expect-with-temp [Card [card-1] Card [card-2]] (merge pulse-defaults - {:name "A Pulse" - :creator_id (user->id :rasta) - :creator (user-details (fetch-user :rasta)) - :cards [(assoc (pulse-card-details card-1) :include_csv true :include_xls true) - (pulse-card-details card-2)] - :channels [(merge pulse-channel-defaults - {:channel_type "email" - :schedule_type "daily" - :schedule_hour 12 - :recipients []})]}) - (tu/with-model-cleanup [Pulse] - (-> (pulse-response ((user->client :rasta) :post 200 "pulse" {:name "A Pulse" - :cards [{:id (u/get-id card-1) - :include_csv true - :include_xls true} - {:id (u/get-id card-2) - :include_csv false - :include_xls false}] - :channels [daily-email-channel] - :skip_if_empty false})) - (update :channels remove-extra-channels-fields)))) + {:name "A Pulse" + :creator_id (user->id :rasta) + :creator (user-details (fetch-user :rasta)) + :cards [(assoc (pulse-card-details card-1) :include_csv true, :include_xls true, :collection_id true) + (assoc (pulse-card-details card-2) :collection_id true)] + :channels [(merge pulse-channel-defaults + {:channel_type "email" + :schedule_type "daily" + :schedule_hour 12 + :recipients []})] + :collection_id true}) + (tt/with-temp Collection [collection] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + (tu/with-model-cleanup [Pulse] + (card-api-test/with-cards-in-readable-collection [card-1 card-2] + (-> ((user->client :rasta) :post 200 "pulse" {:name "A Pulse" + :collection_id (u/get-id collection) + :cards [{:id (u/get-id card-1) + :include_csv true + :include_xls true} + {:id (u/get-id card-2) + :include_csv false + :include_xls false}] + :channels [daily-email-channel] + :skip_if_empty false}) + pulse-response + (update :channels remove-extra-channels-fields)))))) ;; Make sure we can create a Pulse with a Collection position (expect @@ -195,16 +235,17 @@ (tt/with-temp* [Card [card] Collection [collection]] (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) - ((user->client :rasta) :post 200 "pulse" {:name pulse-name - :cards [{:id (u/get-id card) - :include_csv false - :include_xls false}] - :channels [daily-email-channel] - :skip_if_empty false - :collection_id (u/get-id collection) - :collection_position 1}) - (some-> (db/select-one [Pulse :collection_id :collection_position] :name pulse-name) - (update :collection_id (partial = (u/get-id collection)))))))) + (card-api-test/with-cards-in-readable-collection [card] + ((user->client :rasta) :post 200 "pulse" {:name pulse-name + :cards [{:id (u/get-id card) + :include_csv false + :include_xls false}] + :channels [daily-email-channel] + :skip_if_empty false + :collection_id (u/get-id collection) + :collection_position 1}) + (some-> (db/select-one [Pulse :collection_id :collection_position] :name pulse-name) + (update :collection_id (partial = (u/get-id collection))))))))) ;; ...but not if we don't have permissions for the Collection (expect @@ -269,29 +310,34 @@ Card [card]] (merge pulse-defaults - {:name "Updated Pulse" - :creator_id (user->id :rasta) - :creator (user-details (fetch-user :rasta)) - :cards [(pulse-card-details card)] - :channels [(merge pulse-channel-defaults - {:channel_type "slack" - :schedule_type "hourly" - :details {:channels "#general"} - :recipients []})]}) - (-> (pulse-response ((user->client :rasta) :put 200 (format "pulse/%d" (u/get-id pulse)) - {:name "Updated Pulse" - :cards [{:id (u/get-id card) - :include_csv false - :include_xls false}] - :channels [{:enabled true - :channel_type "slack" - :schedule_type "hourly" - :schedule_hour 12 - :schedule_day "mon" - :recipients [] - :details {:channels "#general"}}] - :skip_if_empty false})) - (update :channels remove-extra-channels-fields))) + {:name "Updated Pulse" + :creator_id (user->id :rasta) + :creator (user-details (fetch-user :rasta)) + :cards [(assoc (pulse-card-details card) + :collection_id true)] + :channels [(merge pulse-channel-defaults + {:channel_type "slack" + :schedule_type "hourly" + :details {:channels "#general"} + :recipients []})] + :collection_id true}) + (with-pulses-in-writeable-collection [pulse] + (card-api-test/with-cards-in-readable-collection [card] + (-> ((user->client :rasta) :put 200 (format "pulse/%d" (u/get-id pulse)) + {:name "Updated Pulse" + :cards [{:id (u/get-id card) + :include_csv false + :include_xls false}] + :channels [{:enabled true + :channel_type "slack" + :schedule_type "hourly" + :schedule_hour 12 + :schedule_day "mon" + :recipients [] + :details {:channels "#general"}}] + :skip_if_empty false}) + pulse-response + (update :channels remove-extra-channels-fields))))) ;; Can we update *just* the Collection ID of a Pulse? (expect @@ -375,30 +421,32 @@ ;;; | DELETE /api/pulse/:id | ;;; +----------------------------------------------------------------------------------------------------------------+ -;; check that a regular user can delete a Pulse if they are the original creator & a recipient +;; check that a regular user can delete a Pulse if they have write permissions for its collection (!) (expect nil (tt/with-temp* [Pulse [pulse] PulseChannel [pc {:pulse_id (u/get-id pulse)}] PulseChannelRecipient [_ {:pulse_channel_id (u/get-id pc), :user_id (user->id :rasta)}]] - ((user->client :rasta) :delete 204 (format "pulse/%d" (u/get-id pulse))) - (pulse/retrieve-pulse (u/get-id pulse)))) + (with-pulses-in-writeable-collection [pulse] + ((user->client :rasta) :delete 204 (format "pulse/%d" (u/get-id pulse))) + (pulse/retrieve-pulse (u/get-id pulse))))) -;; Check that a rando isn't allowed to delete a pulse +;; Check that a rando (e.g. someone without collection write access) isn't allowed to delete a pulse (expect "You don't have permissions to do that." (tt/with-temp* [Database [db] - Table [table {:db_id (u/get-id db)}] - Card [card {:dataset_query {:database (u/get-id db) - :type "query" - :query {:source-table (u/get-id table) - :aggregation {:aggregation-type "count"}}}}] - Pulse [pulse {:name "Daily Sad Toucans"}] - PulseCard [pulse-card {:pulse_id (u/get-id pulse), :card_id (u/get-id card)}]] - ;; revoke permissions for default group to this database - (perms/delete-related-permissions! (perms-group/all-users) (perms/object-path (u/get-id db))) - ;; now a user without permissions to the Card in question should *not* be allowed to delete the pulse - ((user->client :rasta) :delete 403 (format "pulse/%d" (u/get-id pulse))))) + Table [table {:db_id (u/get-id db)}] + Card [card {:dataset_query {:database (u/get-id db) + :type "query" + :query {:source-table (u/get-id table) + :aggregation {:aggregation-type "count"}}}}] + Pulse [pulse {:name "Daily Sad Toucans"}] + PulseCard [_ {:pulse_id (u/get-id pulse), :card_id (u/get-id card)}]] + (with-pulses-in-readable-collection [pulse] + ;; revoke permissions for default group to this database + (perms/delete-related-permissions! (perms-group/all-users) (perms/object-path (u/get-id db))) + ;; now a user without permissions to the Card in question should *not* be allowed to delete the pulse + ((user->client :rasta) :delete 403 (format "pulse/%d" (u/get-id pulse)))))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -408,13 +456,14 @@ ;; should come back in alphabetical order (tt/expect-with-temp [Pulse [pulse-1 {:name "ABCDEF"}] Pulse [pulse-2 {:name "GHIJKL"}]] - [(assoc (pulse-details pulse-1) :read_only true) - (assoc (pulse-details pulse-2) :read_only true)] - (do + [(assoc (pulse-details pulse-1) :read_only true, :collection_id true) + (assoc (pulse-details pulse-2) :read_only true, :collection_id true)] + (with-pulses-in-readable-collection [pulse-1 pulse-2] ;; delete anything else in DB just to be sure; this step may not be neccesary any more (db/delete! Pulse :id [:not-in #{(u/get-id pulse-1) (u/get-id pulse-2)}]) - ((user->client :rasta) :get 200 "pulse"))) + (for [pulse ((user->client :rasta) :get 200 "pulse")] + (update pulse :collection_id boolean)))) ;; `read_only` property should get updated correctly based on whether current user can write (tt/expect-with-temp [Pulse [pulse-1 {:name "ABCDEF"}] @@ -432,9 +481,11 @@ Pulse [pulse-2 {:name "GHIJKL"}] Pulse [pulse-3 {:name "AAAAAA" :alert_condition "rows"}]] - [(assoc (pulse-details pulse-1) :read_only true) - (assoc (pulse-details pulse-2) :read_only true)] - ((user->client :rasta) :get 200 "pulse")) + [(assoc (pulse-details pulse-1) :read_only true, :collection_id true) + (assoc (pulse-details pulse-2) :read_only true, :collection_id true)] + (with-pulses-in-readable-collection [pulse-1 pulse-2 pulse-3] + (for [pulse ((user->client :rasta) :get 200 "pulse")] + (update pulse :collection_id boolean)))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -442,13 +493,18 @@ ;;; +----------------------------------------------------------------------------------------------------------------+ (tt/expect-with-temp [Pulse [pulse]] - (pulse-details pulse) - ((user->client :rasta) :get 200 (str "pulse/" (u/get-id pulse)))) + (assoc (pulse-details pulse) + :collection_id true) + (with-pulses-in-readable-collection [pulse] + (-> ((user->client :rasta) :get 200 (str "pulse/" (u/get-id pulse))) + (update :collection_id boolean)))) ;; Should 404 for an Alert -(tt/expect-with-temp [Pulse [{pulse-id :id} {:alert_condition "rows"}]] +(expect "Not found." - ((user->client :rasta) :get 404 (str "pulse/" pulse-id))) + (tt/with-temp Pulse [{pulse-id :id} {:alert_condition "rows"}] + (with-pulses-in-readable-collection [pulse-id] + ((user->client :rasta) :get 404 (str "pulse/" pulse-id))))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -456,27 +512,35 @@ ;;; +----------------------------------------------------------------------------------------------------------------+ (expect - [{:ok true} - (et/email-to :rasta {:subject "Pulse: Daily Sad Toucans" - :body {"Daily Sad Toucans" true}})] + {:response {:ok true} + :emails (et/email-to :rasta {:subject "Pulse: Daily Sad Toucans" + :body {"Daily Sad Toucans" true}})} (tu/with-model-cleanup [Pulse] (et/with-fake-inbox (data/with-db (data/get-or-create-database! defs/sad-toucan-incidents) - (tt/with-temp* [Database [db] - Table [table {:db_id (u/get-id db)}] - Card [card {:dataset_query {:database (u/get-id db) - :type "query" - :query {:source-table (u/get-id table), - :aggregation {:aggregation-type "count"}}}}]] - [((user->client :rasta) :post 200 "pulse/test" {:name "Daily Sad Toucans" - :cards [{:id (u/get-id card) - :include_csv false - :include_xls false}] - :channels [{:enabled true - :channel_type "email" - :schedule_type "daily" - :schedule_hour 12 - :schedule_day nil - :recipients [(fetch-user :rasta)]}] - :skip_if_empty false}) - (et/regex-email-bodies #"Daily Sad Toucans")]))))) + (tt/with-temp* [Collection [collection] + Database [db] + Table [table {:db_id (u/get-id db)}] + Card [card {:dataset_query {:database (u/get-id db) + :type "query" + :query {:source-table (u/get-id table), + :aggregation {:aggregation-type "count"}}}}]] + (perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection) + (card-api-test/with-cards-in-readable-collection [card] + (array-map + :response + ((user->client :rasta) :post 200 "pulse/test" {:name "Daily Sad Toucans" + :collection_id (u/get-id collection) + :cards [{:id (u/get-id card) + :include_csv false + :include_xls false}] + :channels [{:enabled true + :channel_type "email" + :schedule_type "daily" + :schedule_hour 12 + :schedule_day nil + :recipients [(fetch-user :rasta)]}] + :skip_if_empty false}) + + :emails + (et/regex-email-bodies #"Daily Sad Toucans")))))))) diff --git a/test/metabase/api/session_test.clj b/test/metabase/api/session_test.clj index 4a8c49e7fb8fd7f52e675037959a852f8bb8a5de..43fe66d506bd7d8e552b28768e1f146d05dad59d 100644 --- a/test/metabase/api/session_test.clj +++ b/test/metabase/api/session_test.clj @@ -216,7 +216,9 @@ (expect clojure.lang.ExceptionInfo (tu/with-temporary-setting-values [google-auth-auto-create-accounts-domain "sf-toucannery.com"] - (#'session-api/google-auth-create-new-user! "Rasta" "Toucan" "rasta@metabase.com"))) + (#'session-api/google-auth-create-new-user! {:first_name "Rasta" + :last_name "Toucan" + :email "rasta@metabase.com"}))) ;; should totally work if the email domains match up (expect @@ -224,7 +226,9 @@ (et/with-fake-inbox (tu/with-temporary-setting-values [google-auth-auto-create-accounts-domain "sf-toucannery.com" admin-email "rasta@toucans.com"] - (select-keys (u/prog1 (#'session-api/google-auth-create-new-user! "Rasta" "Toucan" "rasta@sf-toucannery.com") + (select-keys (u/prog1 (#'session-api/google-auth-create-new-user! {:first_name "Rasta" + :last_name "Toucan" + :email "rasta@sf-toucannery.com"}) (db/delete! User :id (:id <>))) ; make sure we clean up after ourselves ! [:first_name :last_name :email])))) diff --git a/test/metabase/api/user_test.clj b/test/metabase/api/user_test.clj index 2d99afa579d7c6f98f346c29412f736c26424443..0aaa40334a1a2819eba5810749ac1658d8226c5d 100644 --- a/test/metabase/api/user_test.clj +++ b/test/metabase/api/user_test.clj @@ -22,15 +22,16 @@ (expect (get middleware/response-unauthentic :body) (http/client :get 401 "user/current")) (def ^:private user-defaults - {:ldap_auth false - :is_active true - :is_superuser false - :google_auth false - :is_qbnewb true - :id true - :last_login nil - :updated_at true - :date_joined true}) + {:ldap_auth false + :is_active true + :is_superuser false + :google_auth false + :is_qbnewb true + :id true + :last_login nil + :updated_at true + :date_joined true + :login_attributes nil}) (def ^:private test-users (map #(merge user-defaults %) @@ -81,16 +82,18 @@ email (str user-name "@metabase.com")] (expect (merge user-defaults - {:email email - :first_name user-name - :last_name user-name - :common_name (str user-name " " user-name)}) + {:email email + :first_name user-name + :last_name user-name + :common_name (str user-name " " user-name) + :login_attributes {:test "value"}}) (et/with-fake-inbox (try (tu/boolean-ids-and-timestamps - ((user->client :crowberto) :post 200 "user" {:first_name user-name - :last_name user-name - :email email})) + ((user->client :crowberto) :post 200 "user" {:first_name user-name + :last_name user-name + :email email + :login_attributes {:test "value"}})) (finally ;; clean up after ourselves (db/delete! User :email email)))))) @@ -98,7 +101,7 @@ ;; Test that reactivating a disabled account works (expect ;; create a random inactive user - (tt/with-temp User [ user {:is_active false}] + (tt/with-temp User [user {:is_active false}] ;; now try creating the same user again, should re-activiate the original ((user->client :crowberto) :put 200 (format "user/%s/reactivate" (u/get-id user)) {:first_name (:first_name user) @@ -208,6 +211,16 @@ :email "cam.eron@metabase.com"})) (user)]))) +;; Test that we can update login attributes after a user has been created +(expect + (merge user-defaults + {:is_superuser true, :email "testuser@metabase.com", :first_name "Test", + :login_attributes {:test "value"}, :common_name "Test User", :last_name "User"}) + (tt/with-temp User [{user-id :id} {:first_name "Test", :last_name "User", :email "testuser@metabase.com", :is_superuser true}] + (tu/boolean-ids-and-timestamps + ((user->client :crowberto) :put 200 (str "user/" user-id) {:email "testuser@metabase.com" + :login_attributes {:test "value"}})))) + ;; ## PUT /api/user/:id ;; Test that updating a user's email to an existing inactive user's email fails (expect diff --git a/test/metabase/automagic_dashboards/core_test.clj b/test/metabase/automagic_dashboards/core_test.clj index 6b57bfd03aa0ac9bb14ee7eca4629e530fccbb08..42056e3bf7271b8b0856a7bd624fd4161acbfba5 100644 --- a/test/metabase/automagic_dashboards/core_test.clj +++ b/test/metabase/automagic_dashboards/core_test.clj @@ -88,8 +88,7 @@ (defmacro ^:private with-dashboard-cleanup [& body] - `(tu/with-model-cleanup [(quote ~'Card) (quote ~'Dashboard) (quote ~'Collection) - (quote ~'DashboardCard)] + `(tu/with-model-cleanup ['~'Card '~'Dashboard '~'Collection '~'DashboardCard] ~@body)) (expect diff --git a/test/metabase/events/revision_test.clj b/test/metabase/events/revision_test.clj index accd6dddf4ad6d958dd950d1673a8bdf02da3b61..1c28ed1c134acf8ed8bb4a11cbd122b20efdf870 100644 --- a/test/metabase/events/revision_test.clj +++ b/test/metabase/events/revision_test.clj @@ -34,7 +34,7 @@ :creator_id (:creator_id card) :database_id (data/id) :dataset_query (:dataset_query card) - :read_permissions (vec (:read_permissions card)) + :read_permissions nil :description nil :display "table" :enable_embedding false diff --git a/test/metabase/models/card_test.clj b/test/metabase/models/card_test.clj index 418e72e908c4fe775dd49dbf5fd7eb494b9894e2..e67f016da8ac75feb067854b06cf383b59db48c9 100644 --- a/test/metabase/models/card_test.clj +++ b/test/metabase/models/card_test.clj @@ -1,19 +1,14 @@ (ns metabase.models.card-test (:require [cheshire.core :as json] [expectations :refer :all] - [metabase.api.common :refer [*current-user-permissions-set*]] [metabase.models - [card :refer :all :as card] + [card :as card :refer :all] [dashboard :refer [Dashboard]] [dashboard-card :refer [DashboardCard]] - [database :as database] - [interface :as mi] [permissions :as perms]] - [metabase.query-processor.middleware.expand :as ql] [metabase.test [data :as data] [util :as tu]] - [metabase.test.data.users :refer :all] [metabase.util :as u] [toucan.db :as db] [toucan.util.test :as tt])) @@ -59,124 +54,6 @@ :filter nil}}})) -;;; ---------------------------------------------- Permissions Checking ---------------------------------------------- - -(expect - false - (tt/with-temp Card [card {:dataset_query {:database (data/id), :type "native"}}] - (binding [*current-user-permissions-set* (delay #{})] - (mi/can-read? card)))) - -(expect - (tt/with-temp Card [card {:dataset_query {:database (data/id), :type "native"}}] - (binding [*current-user-permissions-set* (delay #{(perms/native-read-path (data/id))})] - (mi/can-read? card)))) - -;; in order to *write* a native card user should need native readwrite access -(expect - false - (tt/with-temp Card [card {:dataset_query {:database (data/id), :type "native"}}] - (binding [*current-user-permissions-set* (delay #{(perms/native-read-path (data/id))})] - (mi/can-write? card)))) - -(expect - (tt/with-temp Card [card {:dataset_query {:database (data/id), :type "native"}}] - (binding [*current-user-permissions-set* (delay #{(perms/native-readwrite-path (data/id))})] - (mi/can-write? card)))) - - -;;; check permissions sets for queries -;; native read -(defn- native [query] - {:database 1 - :type :native - :native {:query query}}) - -(expect - #{"/db/1/native/read/"} - (query-perms-set (native "SELECT count(*) FROM toucan_sightings;") :read)) - -;; native write -(expect - #{"/db/1/native/"} - (query-perms-set (native "SELECT count(*) FROM toucan_sightings;") :write)) - - -(defn- mbql [query] - {:database (data/id) - :type :query - :query query}) - -;; MBQL w/o JOIN -(expect - #{(perms/object-path (data/id) "PUBLIC" (data/id :venues))} - (query-perms-set (mbql (ql/query - (ql/source-table (data/id :venues)))) - :read)) - -;; MBQL w/ JOIN -(expect - #{(perms/object-path (data/id) "PUBLIC" (data/id :checkins)) - (perms/object-path (data/id) "PUBLIC" (data/id :venues))} - (query-perms-set (mbql (ql/query - (ql/source-table (data/id :checkins)) - (ql/order-by (ql/asc (ql/fk-> (data/id :checkins :venue_id) (data/id :venues :name)))))) - :read)) - -;; MBQL w/ nested MBQL query -(defn- query-with-source-card [card] - {:database database/virtual-id, :type "query", :query {:source_table (str "card__" (u/get-id card))}}) - -(expect - #{(perms/object-path (data/id) "PUBLIC" (data/id :venues))} - (tt/with-temp Card [card {:dataset_query {:database (data/id) - :type :query - :query {:source-table (data/id :venues)}}}] - (query-perms-set (query-with-source-card card) :read))) - -;; MBQL w/ nested MBQL query including a JOIN -(expect - #{(perms/object-path (data/id) "PUBLIC" (data/id :checkins)) - (perms/object-path (data/id) "PUBLIC" (data/id :users))} - (tt/with-temp Card [card {:dataset_query {:database (data/id) - :type :query - :query {:source-table (data/id :checkins) - :order-by [[:asc [:fk-> (data/id :checkins :user_id) (data/id :users :id)]]]}}}] - (query-perms-set (query-with-source-card card) :read))) - -;; MBQL w/ nested NATIVE query -(expect - #{(perms/native-read-path (data/id))} - (tt/with-temp Card [card {:dataset_query {:database (data/id) - :type :native - :native {:query "SELECT * FROM CHECKINS"}}}] - (query-perms-set (query-with-source-card card) :read))) - -;; You should still only need native READ permissions if you want to save a Card based on another Card you can already -;; READ. -(expect - #{(perms/native-read-path (data/id))} - (tt/with-temp Card [card {:dataset_query {:database (data/id) - :type :native - :native {:query "SELECT * FROM CHECKINS"}}}] - (query-perms-set (query-with-source-card card) :write))) - -;; However if you just pass in the same query directly as a `:source-query` you will still require READWRITE -;; permissions to save the query since we can't verify that it belongs to a Card that you can view. -(expect - #{(perms/native-readwrite-path (data/id))} - (query-perms-set {:database (data/id) - :type :query - :query {:source-query {:native "SELECT * FROM CHECKINS"}}} - :write)) - -;; invalid/legacy card should return perms for something that doesn't exist so no one gets to see it -(expect - #{"/db/0/"} - (query-perms-set (mbql {:filter [:WOW 100 200]}) - :read)) - - ;; Test that when somebody archives a Card, it is removed from any Dashboards it belongs to (expect 0 @@ -234,22 +111,10 @@ "Skip normal pre-update stuff so we can force a Card to get into an invalid state." [card source-table] (db/update! Card {:where [:= :id (u/get-id card)] - :set (-> (card-with-source-table source-table - ;; clear out cached read permissions to make sure those aren't used for calcs - :read_permissions nil) + :set (-> (card-with-source-table source-table) ;; we have to manually JSON-encode since we're skipping normal pre-update stuff (update :dataset_query json/generate-string))})) -;; No circular references = it should work! -(expect - {:card-a #{(perms/object-path (data/id) "PUBLIC" (data/id :venues))} - :card-b #{(perms/object-path (data/id) "PUBLIC" (data/id :venues))}} - ;; Make two cards. Card B references Card A. - (tt/with-temp* [Card [card-a (card-with-source-table (data/id :venues))] - Card [card-b (card-with-source-table (str "card__" (u/get-id card-a)))]] - {:card-a (#'card/card-perms-set-taking-collection-etc-into-account (Card (u/get-id card-a)) :read) - :card-b (#'card/card-perms-set-taking-collection-etc-into-account (Card (u/get-id card-b)) :read)})) - ;; If a Card uses itself as a source, perms calculations should fallback to the 'only admins can see it' perms of ;; #{"/db/0"} (DB 0 will never exist, so regular users will never get to see it, but because admins have root perms, ;; they will still get to see it and perhaps fix it.) @@ -260,16 +125,6 @@ (db/update! Card (u/get-id card) (card-with-source-table (str "card__" (u/get-id card)))))) -;; if for some reason somebody such an invalid Card was already saved in the DB make sure that calculating permissions -;; for it just returns the admin-only #{"/db/0"} perms set -(expect - #{"/db/0/"} - (tt/with-temp Card [card (card-with-source-table (data/id :venues))] - ;; now *make* the Card reference itself - (force-update-card-to-reference-source-table! card (str "card__" (u/get-id card))) - ;; ok. Calculate perms. Should fail and fall back to admin-only perms - (#'card/card-perms-set-taking-collection-etc-into-account (Card (u/get-id card)) :read))) - ;; Do the same stuff with circular reference between two Cards... (A -> B -> A) (expect Exception @@ -278,16 +133,6 @@ (db/update! Card (u/get-id card-a) (card-with-source-table (str "card__" (u/get-id card-b)))))) -(expect - #{"/db/0/"} - ;; Make two cards. Card B references Card A - (tt/with-temp* [Card [card-a (card-with-source-table (data/id :venues))] - Card [card-b (card-with-source-table (str "card__" (u/get-id card-a)))]] - ;; force Card A to reference Card B - (force-update-card-to-reference-source-table! card-a (str "card__" (u/get-id card-b))) - ;; perms calc should fail and we should get admin-only perms - (#'card/card-perms-set-taking-collection-etc-into-account (Card (u/get-id card-a)) :read))) - ;; ok now try it with A -> C -> B -> A (expect Exception @@ -296,49 +141,3 @@ Card [card-c (card-with-source-table (str "card__" (u/get-id card-b)))]] (db/update! Card (u/get-id card-a) (card-with-source-table (str "card__" (u/get-id card-c)))))) - -(expect - #{"/db/0/"} - (tt/with-temp* [Card [card-a (card-with-source-table (data/id :venues))] - Card [card-b (card-with-source-table (str "card__" (u/get-id card-a)))] - Card [card-c (card-with-source-table (str "card__" (u/get-id card-b)))]] - ;; force Card A to reference Card C - (force-update-card-to-reference-source-table! card-a (str "card__" (u/get-id card-c))) - ;; perms calc should fail and we should get admin-only perms - (#'card/card-perms-set-taking-collection-etc-into-account (Card (u/get-id card-a)) :read))) - - -;;; ---------------------------------------------- Updating Read Perms ----------------------------------------------- - -;; Make sure when saving a new Card read perms get calculated -(expect - #{(format "/db/%d/native/read/" (data/id))} - (tt/with-temp Card [card {:dataset_query {:database (data/id) - :type :native - :native {:query "SELECT 1"}}}] - ;; read_permissions should have been populated - (db/select-one-field :read_permissions Card :id (u/get-id card)))) - -;; Make sure when updating a Card's query read perms get updated -(expect - #{(format "/db/%d/schema/PUBLIC/table/%d/" (data/id) (data/id :venues))} - (tt/with-temp Card [card {:dataset_query {:database (data/id) - :type :native - :native {:query "SELECT 1"}}}] - ;; now change the query... - (db/update! Card (u/get-id card) :dataset_query {:database (data/id) - :type :query - :query {:source-table (data/id :venues)}}) - ;; read permissions should have been updated - (db/select-one-field :read_permissions Card :id (u/get-id card)))) - -;; Make sure when updating a Card but not changing query read perms do not get changed -(expect - #{(format "/db/%d/native/read/" (data/id))} - (tt/with-temp Card [card {:dataset_query {:database (data/id) - :type :native - :native {:query "SELECT 1"}}}] - ;; now change something *besides* the query... - (db/update! Card (u/get-id card) :name "Cam's super-awesome CARD") - ;; read permissions should *not* have been updated - (db/select-one-field :read_permissions Card :id (u/get-id card)))) diff --git a/test/metabase/models/collection_test.clj b/test/metabase/models/collection_test.clj index 2c0b2a16b7a33f3a2e1b73d0f70960994ab86c2a..7080f16d74dff600ca2130dcb59ffe59cddb8019 100644 --- a/test/metabase/models/collection_test.clj +++ b/test/metabase/models/collection_test.clj @@ -2,11 +2,15 @@ (:refer-clojure :exclude [ancestors descendants]) (:require [clojure.string :as str] [expectations :refer :all] - [metabase.api.common :refer [*current-user-permissions-set*]] + [metabase.api.common :refer [*current-user-id* *current-user-permissions-set*]] [metabase.models [card :refer [Card]] [collection :as collection :refer [Collection]] - [permissions :as perms]] + [dashboard :refer [Dashboard]] + [permissions :as perms] + [permissions-group :as group :refer [PermissionsGroup]] + [pulse :refer [Pulse]]] + [metabase.test.data.users :as test-users] [metabase.test.util :as tu] [metabase.util :as u] [toucan.db :as db] @@ -81,6 +85,177 @@ (db/update! Collection (u/get-id collection) :name ""))) +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | Graph Tests | +;;; +----------------------------------------------------------------------------------------------------------------+ + +(defn- graph [& {:keys [clear-revisions?]}] + ;; delete any previously existing collection revision entries so we get revision = 0 + (when clear-revisions? + (db/delete! 'CollectionRevision)) + ;; force lazy creation of the three magic groups as needed + (group/all-users) + (group/admin) + (group/metabot) + ;; now fetch the graph + (collection/graph)) + +;; Check that the basic graph works +(expect + {:revision 0 + :groups {(u/get-id (group/all-users)) {:root :none} + (u/get-id (group/metabot)) {:root :none} + (u/get-id (group/admin)) {:root :write}}} + (graph :clear-revisions? true)) + +;; Creating a new Collection shouldn't give perms to anyone but admins +(tt/expect-with-temp [Collection [collection]] + {:revision 0 + :groups {(u/get-id (group/all-users)) {:root :none, (u/get-id collection) :none} + (u/get-id (group/metabot)) {:root :none, (u/get-id collection) :none} + (u/get-id (group/admin)) {:root :write, (u/get-id collection) :write}}} + (graph :clear-revisions? true)) + +;; make sure read perms show up correctly +(tt/expect-with-temp [Collection [collection]] + {:revision 0 + :groups {(u/get-id (group/all-users)) {:root :none, (u/get-id collection) :read} + (u/get-id (group/metabot)) {:root :none, (u/get-id collection) :none} + (u/get-id (group/admin)) {:root :write, (u/get-id collection) :write}}} + (do + (perms/grant-collection-read-permissions! (group/all-users) collection) + (graph :clear-revisions? true))) + +;; make sure we can grant write perms for new collections (!) +(tt/expect-with-temp [Collection [collection]] + {:revision 0 + :groups {(u/get-id (group/all-users)) {:root :none, (u/get-id collection) :write} + (u/get-id (group/metabot)) {:root :none, (u/get-id collection) :none} + (u/get-id (group/admin)) {:root :write, (u/get-id collection) :write}}} + (do + (perms/grant-collection-readwrite-permissions! (group/all-users) collection) + (graph :clear-revisions? true))) + +;; make sure a non-magical group will show up +(tt/expect-with-temp [PermissionsGroup [new-group]] + {:revision 0 + :groups {(u/get-id (group/all-users)) {:root :none} + (u/get-id (group/metabot)) {:root :none} + (u/get-id (group/admin)) {:root :write} + (u/get-id new-group) {:root :none}}} + (graph :clear-revisions? true)) + +;; How abut *read* permissions for the Root Collection? +(tt/expect-with-temp [PermissionsGroup [new-group]] + {:revision 0 + :groups {(u/get-id (group/all-users)) {:root :none} + (u/get-id (group/metabot)) {:root :none} + (u/get-id (group/admin)) {:root :write} + (u/get-id new-group) {:root :read}}} + (do + (perms/grant-collection-read-permissions! new-group collection/root-collection) + (graph :clear-revisions? true))) + +;; How about granting *write* permissions for the Root Collection? +(tt/expect-with-temp [PermissionsGroup [new-group]] + {:revision 0 + :groups {(u/get-id (group/all-users)) {:root :none} + (u/get-id (group/metabot)) {:root :none} + (u/get-id (group/admin)) {:root :write} + (u/get-id new-group) {:root :write}}} + (do + (perms/grant-collection-readwrite-permissions! new-group collection/root-collection) + (graph :clear-revisions? true))) + +;; Can we do a no-op update? +(expect + ;; revision should not have changed, because there was nothing to do... + {:revision 0 + :groups {(u/get-id (group/all-users)) {:root :none} + (u/get-id (group/metabot)) {:root :none} + (u/get-id (group/admin)) {:root :write}}} + ;; need to bind *current-user-id* or the Revision won't get updated + (binding [*current-user-id* (test-users/user->id :crowberto)] + (collection/update-graph! (graph :clear-revisions? true)) + (graph))) + +;; Can we give someone read perms via the graph? +(tt/expect-with-temp [Collection [collection]] + {:revision 1 + :groups {(u/get-id (group/all-users)) {:root :none, (u/get-id collection) :read} + (u/get-id (group/metabot)) {:root :none, (u/get-id collection) :none} + (u/get-id (group/admin)) {:root :write, (u/get-id collection) :write}}} + (binding [*current-user-id* (test-users/user->id :crowberto)] + (collection/update-graph! (assoc-in (graph :clear-revisions? true) + [:groups (u/get-id (group/all-users)) (u/get-id collection)] + :read)) + (graph))) + +;; can we give them *write* perms? +(tt/expect-with-temp [Collection [collection]] + {:revision 1 + :groups {(u/get-id (group/all-users)) {:root :none, (u/get-id collection) :write} + (u/get-id (group/metabot)) {:root :none, (u/get-id collection) :none} + (u/get-id (group/admin)) {:root :write, (u/get-id collection) :write}}} + (binding [*current-user-id* (test-users/user->id :crowberto)] + (collection/update-graph! (assoc-in (graph :clear-revisions? true) + [:groups (u/get-id (group/all-users)) (u/get-id collection)] + :write)) + (graph))) + +;; can we *revoke* perms? +(tt/expect-with-temp [Collection [collection]] + {:revision 1 + :groups {(u/get-id (group/all-users)) {:root :none, (u/get-id collection) :none} + (u/get-id (group/metabot)) {:root :none, (u/get-id collection) :none} + (u/get-id (group/admin)) {:root :write, (u/get-id collection) :write}}} + (binding [*current-user-id* (test-users/user->id :crowberto)] + (perms/grant-collection-read-permissions! (group/all-users) collection) + (collection/update-graph! (assoc-in (graph :clear-revisions? true) + [:groups (u/get-id (group/all-users)) (u/get-id collection)] + :none)) + (graph))) + +;; How abut *read* permissions for the Root Collection? +(tt/expect-with-temp [PermissionsGroup [new-group]] + {:revision 1 + :groups {(u/get-id (group/all-users)) {:root :none} + (u/get-id (group/metabot)) {:root :none} + (u/get-id (group/admin)) {:root :write} + (u/get-id new-group) {:root :read}}} + (binding [*current-user-id* (test-users/user->id :crowberto)] + (collection/update-graph! (assoc-in (graph :clear-revisions? true) + [:groups (u/get-id new-group) :root] + :read)) + (graph))) + +;; How about granting *write* permissions for the Root Collection? +(tt/expect-with-temp [PermissionsGroup [new-group]] + {:revision 1 + :groups {(u/get-id (group/all-users)) {:root :none} + (u/get-id (group/metabot)) {:root :none} + (u/get-id (group/admin)) {:root :write} + (u/get-id new-group) {:root :write}}} + (binding [*current-user-id* (test-users/user->id :crowberto)] + (collection/update-graph! (assoc-in (graph :clear-revisions? true) + [:groups (u/get-id new-group) :root] + :write)) + (graph))) + +;; can we *revoke* RootCollection perms? +(tt/expect-with-temp [PermissionsGroup [new-group]] + {:revision 1 + :groups {(u/get-id (group/all-users)) {:root :none} + (u/get-id (group/metabot)) {:root :none} + (u/get-id (group/admin)) {:root :write} + (u/get-id new-group) {:root :none}}} + (binding [*current-user-id* (test-users/user->id :crowberto)] + (perms/grant-collection-readwrite-permissions! new-group collection/root-collection) + (collection/update-graph! (assoc-in (graph :clear-revisions? true) + [:groups (u/get-id new-group) :root] + :none)) + (graph))) + ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | Nested Collections Helper Fns & Macros | @@ -129,16 +304,17 @@ it possible to compare Collection location paths in tests without having to know the randomly-generated IDs." [path] ;; split the path into IDs and then fetch a map of ID -> Name for each ID - (let [ids (collection/location-path->ids path) - id->name (when (seq ids) - (db/select-field->field :id :name Collection :id [:in ids]))] - ;; now loop through each ID and replace the ID part like (ex. /10/) with a name (ex. /A/) - (loop [path path, [id & more] ids] - (if-not id - path - (recur - (str/replace path (re-pattern (str "/" id "/")) (str "/" (id->name id) "/")) - more))))) + (when (seq path) + (let [ids (collection/location-path->ids path) + id->name (when (seq ids) + (db/select-field->field :id :name Collection :id [:in ids]))] + ;; now loop through each ID and replace the ID part like (ex. /10/) with a name (ex. /A/) + (loop [path path, [id & more] ids] + (if-not id + path + (recur + (str/replace path (re-pattern (str "/" id "/")) (str "/" (id->name id) "/")) + more)))))) ;;; +----------------------------------------------------------------------------------------------------------------+ @@ -191,7 +367,6 @@ (collection/permissions-set->visible-collection-ids #{"/db/1/" "/db/2/native/" - "/db/3/native/read/" "/db/4/schema/" "/db/5/schema/PUBLIC/" "/db/6/schema/PUBLIC/table/7/" @@ -584,10 +759,10 @@ (defn- collection-locations "Print out an amazingly useful map that charts the hierarchy of `collections`." - [collections] + [collections & additional-conditions] (apply merge-with combine - (for [collection (-> (db/select Collection :id [:in (map u/get-id collections)]) + (for [collection (-> (apply db/select Collection, :id [:in (map u/get-id collections)], additional-conditions) format-collections)] (assoc-in {} (concat (filter seq (str/split (:location collection) #"/")) [(:name collection)]) @@ -659,7 +834,6 @@ ;; A -+-> C -+-> D -> E ===> F -+-> A -+-> C -+-> D -> E ;; | | ;; +-> F -> G +-> G - (expect {"F" {"A" {"B" {} "C" {"D" {"E" {}}}} @@ -668,3 +842,194 @@ (collection/move-collection! f (collection/children-location collection/root-collection)) (collection/move-collection! a (collection/children-location (Collection (u/get-id f)))) (collection-locations (vals collections)))) + + +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | Nested Collections: Archiving/Unarchiving | +;;; +----------------------------------------------------------------------------------------------------------------+ + +;; Make sure the 'additional-conditions' for collection-locations is working normally +(expect + {"A" {"B" {} + "C" {"D" {"E" {}} + "F" {"G" {}}}}} + (with-collection-hierarchy [collections] + (collection-locations (vals collections) :archived false))) + +;; Test that we can archive a Collection with no descendants! +;; +;; +-> B +-> B +;; | | +;; A -+-> C -+-> D -> E ===> A -+-> C -+-> D +;; | | +;; +-> F -> G +-> F -> G +(expect + {"A" {"B" {} + "C" {"D" {} + "F" {"G" {}}}}} + (with-collection-hierarchy [{:keys [e], :as collections}] + (db/update! Collection (u/get-id e) :archived true) + (collection-locations (vals collections) :archived false))) + +;; Test that we can archive a Collection *with* descendants +;; +;; +-> B +-> B +;; | | +;; A -+-> C -+-> D -> E ===> A -+ +;; | +;; +-> F -> G +(expect + {"A" {"B" {}}} + (with-collection-hierarchy [{:keys [c], :as collections}] + (db/update! Collection (u/get-id c) :archived true) + (collection-locations (vals collections) :archived false))) + +;; Test that we can unarchive a Collection with no descendants +;; +;; +-> B +-> B +;; | | +;; A -+-> C -+-> D ===> A -+-> C -+-> D -> E +;; | | +;; +-> F -> G +-> F -> G +(expect + {"A" {"B" {} + "C" {"D" {"E" {}} + "F" {"G" {}}}}} + (with-collection-hierarchy [{:keys [e], :as collections}] + (db/update! Collection (u/get-id e) :archived true) + (db/update! Collection (u/get-id e) :archived false) + (collection-locations (vals collections) :archived false))) + + +;; Test that we can unarchive a Collection *with* descendants +;; +;; +-> B +-> B +;; | | +;; A -+ ===> A -+-> C -+-> D -> E +;; | +;; +-> F -> G +(expect + {"A" {"B" {} + "C" {"D" {"E" {}} + "F" {"G" {}}}}} + (with-collection-hierarchy [{:keys [c], :as collections}] + (db/update! Collection (u/get-id c) :archived true) + (db/update! Collection (u/get-id c) :archived false) + (collection-locations (vals collections) :archived false))) + +;; Test that archiving applies to Cards +;; Card is in E; archiving E should cause Card to be archived +(expect + (with-collection-hierarchy [{:keys [e], :as collections}] + (tt/with-temp Card [card {:collection_id (u/get-id e)}] + (db/update! Collection (u/get-id e) :archived true) + (db/select-one-field :archived Card :id (u/get-id card))))) + +;; Test that archiving applies to Cards belonging to descendant Collections +;; Card is in E, a descendant of C; archiving C should cause Card to be archived +(expect + (with-collection-hierarchy [{:keys [c e], :as collections}] + (tt/with-temp Card [card {:collection_id (u/get-id e)}] + (db/update! Collection (u/get-id c) :archived true) + (db/select-one-field :archived Card :id (u/get-id card))))) + +;; Test that archiving applies to Dashboards +;; Dashboard is in E; archiving E should cause Dashboard to be archived +(expect + (with-collection-hierarchy [{:keys [e], :as collections}] + (tt/with-temp Dashboard [dashboard {:collection_id (u/get-id e)}] + (db/update! Collection (u/get-id e) :archived true) + (db/select-one-field :archived Dashboard :id (u/get-id dashboard))))) + +;; Test that archiving applies to Dashboards belonging to descendant Collections +;; Dashboard is in E, a descendant of C; archiving C should cause Dashboard to be archived +(expect + (with-collection-hierarchy [{:keys [c e], :as collections}] + (tt/with-temp Dashboard [dashboard {:collection_id (u/get-id e)}] + (db/update! Collection (u/get-id c) :archived true) + (db/select-one-field :archived Dashboard :id (u/get-id dashboard))))) + +;; Test that archiving *deletes* Pulses (Pulses cannot currently be archived) +;; Pulse is in E; archiving E should cause Pulse to get deleted +(expect + false + (with-collection-hierarchy [{:keys [e], :as collections}] + (tt/with-temp Pulse [pulse {:collection_id (u/get-id e)}] + (db/update! Collection (u/get-id e) :archived true) + (db/exists? Pulse :id (u/get-id pulse))))) + +;; Test that archiving *deletes* Pulses belonging to descendant Collections +;; Pulse is in E, a descendant of C; archiving C should cause Pulse to be archived +(expect + false + (with-collection-hierarchy [{:keys [c e], :as collections}] + (tt/with-temp Pulse [pulse {:collection_id (u/get-id e)}] + (db/update! Collection (u/get-id c) :archived true) + (db/exists? Pulse :id (u/get-id pulse))))) + +;; Test that unarchiving applies to Cards +;; Card is in E; unarchiving E should cause Card to be unarchived +(expect + false + (with-collection-hierarchy [{:keys [e], :as collections}] + (db/update! Collection (u/get-id e) :archived true) + (tt/with-temp Card [card {:collection_id (u/get-id e), :archived true}] + (db/update! Collection (u/get-id e) :archived false) + (db/select-one-field :archived Card :id (u/get-id card))))) + +;; Test that unarchiving applies to Cards belonging to descendant Collections +;; Card is in E, a descendant of C; unarchiving C should cause Card to be unarchived +(expect + false + (with-collection-hierarchy [{:keys [c e], :as collections}] + (db/update! Collection (u/get-id c) :archived true) + (tt/with-temp Card [card {:collection_id (u/get-id e), :archived true}] + (db/update! Collection (u/get-id c) :archived false) + (db/select-one-field :archived Card :id (u/get-id card))))) + +;; Test that unarchiving applies to Dashboards +;; Dashboard is in E; unarchiving E should cause Dashboard to be unarchived +(expect + false + (with-collection-hierarchy [{:keys [e], :as collections}] + (db/update! Collection (u/get-id e) :archived true) + (tt/with-temp Dashboard [dashboard {:collection_id (u/get-id e), :archived true}] + (db/update! Collection (u/get-id e) :archived false) + (db/select-one-field :archived Dashboard :id (u/get-id dashboard))))) + +;; Test that unarchiving applies to Dashboards belonging to descendant Collections +;; Dashboard is in E, a descendant of C; unarchiving C should cause Dashboard to be unarchived +(expect + false + (with-collection-hierarchy [{:keys [c e], :as collections}] + (db/update! Collection (u/get-id c) :archived true) + (tt/with-temp Dashboard [dashboard {:collection_id (u/get-id e), :archived true}] + (db/update! Collection (u/get-id c) :archived false) + (db/select-one-field :archived Dashboard :id (u/get-id dashboard))))) + +;; Test that we cannot archive a Collection at the same time we are moving it +(expect + Exception + (with-collection-hierarchy [{:keys [c], :as collections}] + (db/update! Collection (u/get-id c), :archived true, :location "/"))) + +;; Test that we cannot unarchive a Collection at the same time we are moving it +(expect + Exception + (with-collection-hierarchy [{:keys [c], :as collections}] + (db/update! Collection (u/get-id c), :archived true) + (db/update! Collection (u/get-id c), :archived false, :location "/"))) + +;; Passing in a value of archived that is the same as the value in the DB shouldn't affect anything however! +(expect + (with-collection-hierarchy [{:keys [c], :as collections}] + (db/update! Collection (u/get-id c), :archived false, :location "/"))) + +;; Check that attempting to unarchive a Card that's not archived doesn't affect arcived descendants +(expect + (with-collection-hierarchy [{:keys [c e], :as collections}] + (db/update! Collection (u/get-id e), :archived true) + (db/update! Collection (u/get-id c), :archived false) + (db/select-one-field :archived Collection :id (u/get-id e)))) + +;; TODO - can you unarchive a Card that is inside an archived Collection?? diff --git a/test/metabase/models/permissions_test.clj b/test/metabase/models/permissions_test.clj index 53fcec186ca29ad0c00c19b192b86d709826f0e7..575cdd9f9d123c3c18e900fbd3b61bbe0be797cd 100644 --- a/test/metabase/models/permissions_test.clj +++ b/test/metabase/models/permissions_test.clj @@ -9,11 +9,10 @@ [metabase.util :as u] [toucan.util.test :as tt])) -;;; ------------------------------------------------------------ valid-object-path? ------------------------------------------------------------ +;;; ----------------------------------------------- valid-object-path? ----------------------------------------------- (expect (perms/valid-object-path? "/db/1/")) (expect (perms/valid-object-path? "/db/1/native/")) -(expect (perms/valid-object-path? "/db/1/native/read/")) (expect (perms/valid-object-path? "/db/1/schema/")) (expect (perms/valid-object-path? "/db/1/schema/public/")) (expect (perms/valid-object-path? "/db/1/schema/PUBLIC/")) @@ -27,10 +26,13 @@ (expect (perms/valid-object-path? "/db/1/schema//table/1/")) (expect (perms/valid-object-path? "/db/1/schema/1234/table/1/")) +;; Native READ permissions are DEPRECATED as of v0.30 so they should no longer be treated as valid +(expect false (perms/valid-object-path? "/db/1/native/read/")) + ;; missing trailing slashes (expect false (perms/valid-object-path? "/db/1")) (expect false (perms/valid-object-path? "/db/1/native")) -(expect false (perms/valid-object-path? "/db/1/native/read")) + (expect false (perms/valid-object-path? "/db/1/schema")) (expect false (perms/valid-object-path? "/db/1/schema/public")) (expect false (perms/valid-object-path? "/db/1/schema/PUBLIC")) @@ -45,7 +47,6 @@ ;; too many slashes (expect false (perms/valid-object-path? "/db/1//")) (expect false (perms/valid-object-path? "/db/1/native//")) -(expect false (perms/valid-object-path? "/db/1/native/read//")) (expect false (perms/valid-object-path? "/db/1/schema/public//")) (expect false (perms/valid-object-path? "/db/1/schema/PUBLIC//")) (expect false (perms/valid-object-path? "/db/1/schema///")) @@ -65,7 +66,6 @@ ;; duplicate path components (expect false (perms/valid-object-path? "/db/db/1/")) (expect false (perms/valid-object-path? "/db/1/native/native/")) -(expect false (perms/valid-object-path? "/db/1/native/read/read/")) (expect false (perms/valid-object-path? "/db/1/schema/schema/public/")) (expect false (perms/valid-object-path? "/db/1/schema/public/public/")) (expect false (perms/valid-object-path? "/db/1/schema/public/db/1/table/table/")) @@ -74,7 +74,6 @@ ;; missing beginning slash (expect false (perms/valid-object-path? "db/1/")) (expect false (perms/valid-object-path? "db/1/native/")) -(expect false (perms/valid-object-path? "db/1/native/read/")) (expect false (perms/valid-object-path? "db/1/schema/public/")) (expect false (perms/valid-object-path? "db/1/schema/PUBLIC/")) (expect false (perms/valid-object-path? "db/1/schema//")) @@ -103,7 +102,6 @@ (expect false (perms/valid-object-path? "/db/1/table/2/")) (expect false (perms/valid-object-path? "/db/1/native/schema/")) (expect false (perms/valid-object-path? "/db/1/native/write/")) -(expect false (perms/valid-object-path? "/db/1/schema/native/read/")) (expect false (perms/valid-object-path? "/rainforest/")) (expect false (perms/valid-object-path? "/rainforest/toucans/")) (expect false (perms/valid-object-path? "")) @@ -115,7 +113,7 @@ (expect false (perms/valid-object-path? "/db/1/schema/PUBLIC/TABLE/1/")) -;;; ------------------------------------------------------------ object-path ------------------------------------------------------------ +;;; -------------------------------------------------- object-path --------------------------------------------------- (expect "/db/1/" (perms/object-path 1)) (expect "/db/1/schema/public/" (perms/object-path 1 "public")) @@ -125,29 +123,29 @@ (expect clojure.lang.ArityException (perms/object-path)) (expect clojure.lang.ArityException (perms/object-path 1 "public" 2 3)) -(expect AssertionError (perms/object-path nil)) -(expect AssertionError (perms/object-path "sales")) -(expect AssertionError (perms/object-path :sales)) -(expect AssertionError (perms/object-path true)) -(expect AssertionError (perms/object-path false)) -(expect AssertionError (perms/object-path {})) -(expect AssertionError (perms/object-path [])) -(expect AssertionError (perms/object-path :sales)) -(expect AssertionError (perms/object-path 1 true)) -(expect AssertionError (perms/object-path 1 false)) -(expect AssertionError (perms/object-path 1 {})) -(expect AssertionError (perms/object-path 1 [])) -(expect AssertionError (perms/object-path 1 :sales)) -(expect AssertionError (perms/object-path 1 "public" nil)) -(expect AssertionError (perms/object-path 1 "public" "sales")) -(expect AssertionError (perms/object-path 1 "public" :sales)) -(expect AssertionError (perms/object-path 1 "public" true)) -(expect AssertionError (perms/object-path 1 "public" false)) -(expect AssertionError (perms/object-path 1 "public"{})) -(expect AssertionError (perms/object-path 1 "public"[])) - - -;;; ------------------------------------------------------------ is-permissions-for-object? ------------------------------------------------------------ +(expect Exception (perms/object-path nil)) +(expect Exception (perms/object-path "sales")) +(expect Exception (perms/object-path :sales)) +(expect Exception (perms/object-path true)) +(expect Exception (perms/object-path false)) +(expect Exception (perms/object-path {})) +(expect Exception (perms/object-path [])) +(expect Exception (perms/object-path :sales)) +(expect Exception (perms/object-path 1 true)) +(expect Exception (perms/object-path 1 false)) +(expect Exception (perms/object-path 1 {})) +(expect Exception (perms/object-path 1 [])) +(expect Exception (perms/object-path 1 :sales)) +(expect Exception (perms/object-path 1 "public" nil)) +(expect Exception (perms/object-path 1 "public" "sales")) +(expect Exception (perms/object-path 1 "public" :sales)) +(expect Exception (perms/object-path 1 "public" true)) +(expect Exception (perms/object-path 1 "public" false)) +(expect Exception (perms/object-path 1 "public"{})) +(expect Exception (perms/object-path 1 "public"[])) + + +;;; ------------------------------------------- is-permissions-for-object? ------------------------------------------- (expect (perms/is-permissions-for-object? "/" "/db/1/schema/PUBLIC/table/1/")) (expect (perms/is-permissions-for-object? "/db/" "/db/1/schema/PUBLIC/table/1/")) @@ -158,13 +156,12 @@ (expect false (perms/is-permissions-for-object? "/db/2/" "/db/1/schema/PUBLIC/table/1/")) (expect false (perms/is-permissions-for-object? "/db/2/native/" "/db/1/schema/PUBLIC/table/1/")) -(expect false (perms/is-permissions-for-object? "/db/2/native/read/" "/db/1/schema/PUBLIC/table/1/")) (expect false (perms/is-permissions-for-object? "/db/1/schema/public/" "/db/1/schema/PUBLIC/table/1/")) ; different case (expect false (perms/is-permissions-for-object? "/db/1/schema/private/" "/db/1/schema/PUBLIC/table/1/")) (expect false (perms/is-permissions-for-object? "/db/1/schema/PUBLIC/table/2/" "/db/1/schema/PUBLIC/table/1/")) -;;; ------------------------------------------------------------ is-partial-permissions-for-object? ------------------------------------------------------------ +;;; --------------------------------------- is-partial-permissions-for-object? --------------------------------------- (expect (perms/is-partial-permissions-for-object? "/" "/db/1/")) (expect (perms/is-partial-permissions-for-object? "/db/" "/db/1/")) @@ -178,10 +175,9 @@ (expect false (perms/is-partial-permissions-for-object? "/db/2/" "/db/1/")) (expect false (perms/is-partial-permissions-for-object? "/db/2/native/" "/db/1/")) -(expect false (perms/is-partial-permissions-for-object? "/db/2/native/read/" "/db/1/")) -;;; ------------------------------------------------------------ is-permissions-set? ------------------------------------------------------------ +;;; ---------------------------------------------- is-permissions-set? ----------------------------------------------- (expect (perms/is-permissions-set? #{})) (expect (perms/is-permissions-set? #{"/"})) @@ -215,7 +211,7 @@ (expect false (perms/is-permissions-set? #{"/db/1/schema/public/table/1/" "/ocean/"})) -;;; ------------------------------------------------------------ set-has-full-permissions? ------------------------------------------------------------ +;;; ------------------------------------------- set-has-full-permissions? -------------------------------------------- (expect (perms/set-has-full-permissions? #{"/"} "/db/1/schema/public/table/2/")) @@ -235,9 +231,6 @@ (expect (perms/set-has-full-permissions? #{"/db/1/native/"} "/db/1/native/")) -(expect (perms/set-has-full-permissions? #{"/db/1/native/"} - "/db/1/native/read/")) - (expect false (perms/set-has-full-permissions? #{} "/db/1/schema/public/table/2/")) @@ -260,7 +253,7 @@ "/db/1/schema/public/table/2/")) -;;; ------------------------------------------------------------ set-has-partial-permissions? ------------------------------------------------------------ +;;; ------------------------------------------ set-has-partial-permissions? ------------------------------------------ (expect (perms/set-has-partial-permissions? #{"/"} "/db/1/schema/public/table/2/")) @@ -280,9 +273,6 @@ (expect (perms/set-has-partial-permissions? #{"/db/1/schema/public/"} "/db/1/")) -(expect (perms/set-has-partial-permissions? #{"/db/1/native/read/"} - "/db/1/")) - (expect (perms/set-has-partial-permissions? #{"/db/1/schema/"} "/db/1/")) @@ -292,9 +282,6 @@ (expect (perms/set-has-partial-permissions? #{"/db/1/schema/public/table/1/"} "/db/1/")) -(expect (perms/set-has-partial-permissions? #{"/db/1/native/read/"} - "/db/1/native/")) - (expect (perms/set-has-partial-permissions? #{"/db/1/schema/public/"} "/db/1/schema/")) @@ -326,7 +313,7 @@ "/db/1/schema/public/table/2/")) -;;; ------------------------------------------------------------ set-has-full-permissions-for-set? ------------------------------------------------------------ +;;; --------------------------------------- set-has-full-permissions-for-set? ---------------------------------------- (expect (perms/set-has-full-permissions-for-set? #{"/"} #{"/db/1/schema/public/table/2/" "/db/3/schema//table/4/"})) @@ -379,7 +366,7 @@ #{"/db/1/schema/public/table/1/" "/ocean/"})) -;;; ------------------------------------------------------------ set-has-partial-permissions-for-set? ------------------------------------------------------------ +;;; -------------------------------------- set-has-partial-permissions-for-set? -------------------------------------- (expect (perms/set-has-partial-permissions-for-set? #{"/"} #{"/db/1/schema/public/table/2/" "/db/2/"})) @@ -399,9 +386,6 @@ (expect (perms/set-has-partial-permissions-for-set? #{"/db/1/schema/public/"} #{"/db/1/"})) -(expect (perms/set-has-partial-permissions-for-set? #{"/db/1/native/read/"} - #{"/db/1/"})) - (expect (perms/set-has-partial-permissions-for-set? #{"/db/1/schema/"} #{"/db/1/"})) @@ -420,9 +404,6 @@ (expect (perms/set-has-partial-permissions-for-set? #{"/db/1/native/"} #{"/db/1/native/"})) -(expect (perms/set-has-partial-permissions-for-set? #{"/db/1/native/read/"} - #{"/db/1/native/"})) - (expect (perms/set-has-partial-permissions-for-set? #{"/db/1/schema/public/"} #{"/db/1/schema/"})) @@ -468,9 +449,6 @@ (expect false (perms/set-has-partial-permissions-for-set? #{"/db/1/schema/public/"} #{"/db/1/" "/db/9/"})) -(expect false (perms/set-has-partial-permissions-for-set? #{"/db/1/native/read/"} - #{"/db/1/" "/db/9/"})) - (expect false (perms/set-has-partial-permissions-for-set? #{"/db/1/schema/"} #{"/db/1/" "/db/9/"})) @@ -489,9 +467,6 @@ (expect false (perms/set-has-partial-permissions-for-set? #{"/db/1/native/"} #{"/db/1/native/" "/db/9/"})) -(expect false (perms/set-has-partial-permissions-for-set? #{"/db/1/native/read/"} - #{"/db/1/native/" "/db/9/"})) - (expect false (perms/set-has-partial-permissions-for-set? #{"/db/1/schema/public/"} #{"/db/1/schema/" "/db/9/"})) @@ -505,9 +480,47 @@ #{"/db/1/" "/db/9/"})) -;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+ -;;; | Permissions Graph Tests | -;;; +----------------------------------------------------------------------------------------------------------------------------------------------------------------+ +;;; ------------------------------------ perms-objects-set-for-parent-collection ------------------------------------- + +(expect + #{"/collection/1337/read/"} + (perms/perms-objects-set-for-parent-collection {:collection_id 1337} :read)) + +(expect + #{"/collection/1337/"} + (perms/perms-objects-set-for-parent-collection {:collection_id 1337} :write)) + +(expect + #{"/collection/root/read/"} + (perms/perms-objects-set-for-parent-collection {:collection_id nil} :read)) + +(expect + #{"/collection/root/"} + (perms/perms-objects-set-for-parent-collection {:collection_id nil} :write)) + +;; map must have `:collection_id` key +(expect + Exception + (perms/perms-objects-set-for-parent-collection {} :read)) + +;; must be a map +(expect + Exception + (perms/perms-objects-set-for-parent-collection 100 :read)) + +(expect + Exception + (perms/perms-objects-set-for-parent-collection nil :read)) + +;; `read-or-write` must be `:read` or `:write` +(expect + Exception + (perms/perms-objects-set-for-parent-collection {:collection_id nil} :readwrite)) + + +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | Permissions Graph Tests | +;;; +----------------------------------------------------------------------------------------------------------------+ (defn- test-data-graph [group] (get-in (perms/graph) [:groups (u/get-id group) (data/id) :schemas "PUBLIC"])) @@ -520,12 +533,13 @@ ;; first, graph permissions only for VENUES (perms/grant-permissions! group (perms/object-path (data/id) "PUBLIC" (data/id :venues))) [(test-data-graph group) - ;; next, grant permissions via `update-graph!` for CATEGORIES as well. Make sure permissions for VENUES are retained (#3888) + ;; next, grant permissions via `update-graph!` for CATEGORIES as well. Make sure permissions for VENUES are + ;; retained (#3888) (do (perms/update-graph! [(u/get-id group) (data/id) :schemas "PUBLIC" (data/id :categories)] :all) (test-data-graph group))])) -;;; Make sure that the graph functions work correctly for DBs with no schemas +;; Make sure that the graph functions work correctly for DBs with no schemas ;; See https://github.com/metabase/metabase/issues/4000 (tt/expect-with-temp [PermissionsGroup [group] Database [database] diff --git a/test/metabase/models/pulse_test.clj b/test/metabase/models/pulse_test.clj index be2f75d0d442524818112c0536fe54377812afb3..703a0f11fad758eba8d93bb54df1595ed66e8d6b 100644 --- a/test/metabase/models/pulse_test.clj +++ b/test/metabase/models/pulse_test.clj @@ -65,11 +65,12 @@ {:creator_id (user->id :rasta) :creator (user-details :rasta) :name "Lodi Dodi" - :cards [{:name "Test Card" - :description nil - :display :table - :include_csv false - :include_xls false}] + :cards [{:name "Test Card" + :description nil + :collection_id nil + :display :table + :include_csv false + :include_xls false}] :channels [(merge pulse-channel-defaults {:schedule_type :daily :schedule_hour 15 @@ -145,11 +146,12 @@ :schedule_hour 18 :channel_type :email :recipients [{:email "foo@bar.com"}]})] - :cards [{:name "Test Card" - :description nil - :display :table - :include_csv false - :include_xls false}]}) + :cards [{:name "Test Card" + :description nil + :collection_id nil + :display :table + :include_csv false + :include_xls false}]}) (tt/with-temp Card [card {:name "Test Card"}] (tu/with-model-cleanup [Pulse] (create-pulse-then-select! "Booyah!" @@ -173,16 +175,18 @@ pulse-defaults {:creator_id (user->id :rasta) :name "We like to party" - :cards [{:name "Bar Card" - :description nil - :display :bar - :include_csv false - :include_xls false} - {:name "Test Card" - :description nil - :display :table - :include_csv false - :include_xls false}] + :cards [{:name "Bar Card" + :description nil + :collection_id nil + :display :bar + :include_csv false + :include_xls false} + {:name "Test Card" + :description nil + :collection_id nil + :display :table + :include_csv false + :include_xls false}] :channels [(merge pulse-channel-defaults {:schedule_type :daily :schedule_hour 18 @@ -226,14 +230,14 @@ :type :query :query {:source-table (u/get-id table)}}}] PulseCard [_ {:pulse_id (u/get-id pulse), :card_id (u/get-id card)}]] - (f db collection pulse))) + (f db collection pulse card))) (defmacro with-pulse-in-collection "Execute `body` with a temporary Pulse, in a Colleciton, containing a single Card." {:style/indent 1} - [[db-binding collection-binding pulse-binding] & body] + [[db-binding collection-binding pulse-binding card-binding] & body] `(do-with-pulse-in-collection - (fn [~db-binding ~collection-binding ~pulse-binding] + (fn [~(or db-binding '_) ~(or collection-binding '_) ~(or pulse-binding '_) ~(or card-binding '_)] ~@body))) ;; Check that if a Pulse is in a Collection, someone who would not be able to see it under the old diff --git a/test/metabase/models/query/permissions_test.clj b/test/metabase/models/query/permissions_test.clj new file mode 100644 index 0000000000000000000000000000000000000000..9eb0a12b9bd2cb70c91fab56d48246725a1593b7 --- /dev/null +++ b/test/metabase/models/query/permissions_test.clj @@ -0,0 +1,210 @@ +(ns metabase.models.query.permissions-test + (:require [expectations :refer :all] + [metabase.api.common :refer [*current-user-permissions-set*]] + [metabase.models + [card :as card :refer :all] + [collection :refer [Collection]] + [database :as database] + [interface :as mi] + [permissions :as perms]] + [metabase.models.query.permissions :as query-perms] + [metabase.query-processor.middleware.expand :as ql] + [metabase.test.data :as data] + [metabase.util :as u] + [toucan.util.test :as tt])) + +;;; ---------------------------------------------- Permissions Checking ---------------------------------------------- + +(defn- card [] + {:dataset_query {:database (data/id), :type "native"}}) + +(defn- card-in-collection [collection-or-id] + (assoc (card) :collection_id (u/get-id collection-or-id))) + +;; Shouldn't be able to read a Card not in Collection without permissions +(expect + false + (tt/with-temp Card [card (card)] + (binding [*current-user-permissions-set* (delay #{})] + (mi/can-read? card)))) + +;; ...or one in a Collection either! +(expect + false + (tt/with-temp* [Collection [collection] + Card [card (card-in-collection collection)]] + (binding [*current-user-permissions-set* (delay #{})] + (mi/can-read? card)))) + +;; *should* be allowed to read a Card not in a Collection if you have Root collection perms +(expect + (tt/with-temp Card [card (card)] + (binding [*current-user-permissions-set* (delay #{"/collection/root/read/"})] + (mi/can-read? card)))) + +;; ...but not if you have perms for some other Collection +(expect + false + (tt/with-temp Card [card (card)] + (binding [*current-user-permissions-set* (delay #{"/collection/1337/read/"})] + (mi/can-read? card)))) + +;; should be allowed to *read* a Card in a Collection if you have read perms for that Collection +(expect + (tt/with-temp* [Collection [collection] + Card [card (card-in-collection collection)]] + (binding [*current-user-permissions-set* (delay #{(perms/collection-read-path collection)})] + (mi/can-read? card)))) + +;; ...but not if you only have Root Collection perms +(expect + false + (tt/with-temp* [Collection [collection] + Card [card (card-in-collection collection)]] + (binding [*current-user-permissions-set* (delay #{"/collection/root/read/"})] + (mi/can-read? card)))) + +;; to *write* a Card not in a Collection you need Root Collection Write Perms +(expect + (tt/with-temp Card [card (card)] + (binding [*current-user-permissions-set* (delay #{"/collection/root/"})] + (mi/can-write? card)))) + +;; ...root Collection Read Perms shouldn't work +(expect + false + (tt/with-temp Card [card (card)] + (binding [*current-user-permissions-set* (delay #{"/collection/root/read/"})] + (mi/can-write? card)))) + +;; ...nor should write perms for another collection +(expect + false + (tt/with-temp Card [card (card)] + (binding [*current-user-permissions-set* (delay #{"/collection/1337/"})] + (mi/can-write? card)))) + +;; to *write* a Card *in* a Collection you need Collection Write Perms +(expect + (tt/with-temp* [Collection [collection] + Card [card (card-in-collection collection)]] + (binding [*current-user-permissions-set* (delay #{(perms/collection-readwrite-path collection)})] + (mi/can-write? card)))) + +;; ...Collection read perms shouldn't work +(expect + false + (tt/with-temp* [Collection [collection] + Card [card (card-in-collection collection)]] + (binding [*current-user-permissions-set* (delay #{(perms/collection-read-path collection)})] + (mi/can-write? card)))) + +;; ...nor should write perms for the Root Collection +(expect + false + (tt/with-temp* [Collection [collection] + Card [card (card-in-collection collection)]] + (binding [*current-user-permissions-set* (delay #{"/collection/root/"})] + (mi/can-write? card)))) + + + +;;; ----------------------------------------------- native read perms ------------------------------------------------ + +(defn- native [query] + {:database 1 + :type :native + :native {:query query}}) + +(expect + #{"/db/1/native/"} + (query-perms/perms-set (native "SELECT count(*) FROM toucan_sightings;"))) + + +;;; ------------------------------------------------- MBQL w/o JOIN -------------------------------------------------- + +(defn- mbql [query] + {:database (data/id) + :type :query + :query query}) + +(expect + #{(perms/object-path (data/id) "PUBLIC" (data/id :venues))} + (query-perms/perms-set (mbql (ql/query + (ql/source-table (data/id :venues)))))) + + +;;; -------------------------------------------------- MBQL w/ JOIN -------------------------------------------------- + +;; you should need perms for both tables if you include a JOIN +(expect + #{(perms/object-path (data/id) "PUBLIC" (data/id :checkins)) + (perms/object-path (data/id) "PUBLIC" (data/id :venues))} + (query-perms/perms-set + (mbql (ql/query + (ql/source-table (data/id :checkins)) + (ql/order-by (ql/asc (ql/fk-> (data/id :checkins :venue_id) (data/id :venues :name)))))))) + + +;;; ------------------------------------------- MBQL w/ nested MBQL query -------------------------------------------- + +(defn- query-with-source-card [card] + {:database database/virtual-id, :type "query", :query {:source_table (str "card__" (u/get-id card))}}) + +;; if source card is *not* in a Collection, we require Root Collection read perms +(expect + #{"/collection/root/read/"} + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :venues)}}}] + (query-perms/perms-set (query-with-source-card card)))) + +;; if source Card *is* in a Collection, we require read perms for that Collection +(tt/expect-with-temp [Collection [collection {}]] + #{(perms/collection-read-path collection)} + (tt/with-temp Card [card {:collection_id (u/get-id collection) + :dataset_query {:database (data/id) + :type :query + :query {:source-table (data/id :venues)}}}] + (query-perms/perms-set (query-with-source-card card)))) + + +;;; ----------------------------------- MBQL w/ nested MBQL query including a JOIN ----------------------------------- + +;; If you run a query that uses a Card as its source query, and the source query has a JOIN, then you should still +;; only need Permissions for the Collection that Card is in. +(expect + #{"/collection/root/read/"} + (tt/with-temp Card [card {:dataset_query + {:database (data/id) + :type :query + :query {:source-table (data/id :checkins) + :order-by [[:asc [:fk-> (data/id :checkins :user_id) (data/id :users :id)]]]}}}] + (query-perms/perms-set (query-with-source-card card)))) + + +;;; ------------------------------------------ MBQL w/ nested NATIVE query ------------------------------------------- + +;; doesn't matter if it's a NATIVE query as the source; you should still just need read perms for the Card's collection +(expect + #{"/collection/root/read/"} + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT * FROM CHECKINS"}}}] + (query-perms/perms-set (query-with-source-card card)))) + +;; However if you just pass in the same query directly as a `:source-query` you will still require READWRITE +;; permissions to save the query since we can't verify that it belongs to a Card that you can view. +(expect + #{(perms/adhoc-native-query-path (data/id))} + (query-perms/perms-set {:database (data/id) + :type :query + :query {:source-query {:native "SELECT * FROM CHECKINS"}}})) + + +;;; --------------------------------------------- invalid/legacy queries --------------------------------------------- + +;; invalid/legacy queries should return perms for something that doesn't exist so no one gets to see it +(expect + #{"/db/0/"} + (query-perms/perms-set (mbql {:filter [:WOW 100 200]}))) diff --git a/test/metabase/models/user_test.clj b/test/metabase/models/user_test.clj index 2dc575a809e82b67dbf190aa8e8acaf3d13daf5d..e93de00db3e9423d858e8c49ef1e81d25690bcaa 100644 --- a/test/metabase/models/user_test.clj +++ b/test/metabase/models/user_test.clj @@ -61,11 +61,15 @@ (email-test/with-fake-inbox (let [new-user-email (tu/random-email) new-user-first-name (tu/random-name) - new-user-last-name (tu/random-name)] + new-user-last-name (tu/random-name) + new-user {:first_name new-user-first-name + :last_name new-user-last-name + :email new-user-email + :password password}] (try (if google-auth? - (user/create-new-google-auth-user! new-user-first-name new-user-last-name new-user-email) - (user/invite-user! new-user-first-name new-user-last-name new-user-email password invitor)) + (user/create-new-google-auth-user! (dissoc new-user :password)) + (user/invite-user! new-user invitor)) (when accept-invite? (maybe-accept-invite! new-user-email)) (sent-emails new-user-email new-user-first-name new-user-last-name) @@ -73,13 +77,15 @@ (finally (db/delete! User :email new-user-email))))))) +(def ^:private default-invitor + {:email "crowberto@metabase.com", :is_active true, :first_name "Crowberto"}) ;; admin shouldn't get email saying user joined until they accept the invite (i.e., reset their password) (expect {"<New User>" ["You're invited to join Metabase's Metabase"]} (do (test-users/delete-temp-users!) - (invite-user-accept-and-check-inboxes! :invitor {:email "crowberto@metabase.com", :is_active true}, :accept-invite? false))) + (invite-user-accept-and-check-inboxes! :invitor default-invitor, :accept-invite? false))) ;; admin should get an email when a new user joins... (expect @@ -87,7 +93,7 @@ "crowberto@metabase.com" ["<New User> accepted their Metabase invite"]} (do (test-users/delete-temp-users!) - (invite-user-accept-and-check-inboxes! :invitor {:email "crowberto@metabase.com", :is_active true}))) + (invite-user-accept-and-check-inboxes! :invitor default-invitor))) ;; ...including the site admin if it is set... (expect @@ -96,7 +102,7 @@ "cam@metabase.com" ["<New User> accepted their Metabase invite"]} (tu/with-temporary-setting-values [admin-email "cam@metabase.com"] (test-users/delete-temp-users!) - (invite-user-accept-and-check-inboxes! :invitor {:email "crowberto@metabase.com", :is_active true}))) + (invite-user-accept-and-check-inboxes! :invitor default-invitor))) ;; ... but if that admin is inactive they shouldn't get an email (expect diff --git a/test/metabase/permissions_collection_test.clj b/test/metabase/permissions_collection_test.clj deleted file mode 100644 index cd31ba76d1b88904784f5d982c87d8de281d3615..0000000000000000000000000000000000000000 --- a/test/metabase/permissions_collection_test.clj +++ /dev/null @@ -1,106 +0,0 @@ -(ns metabase.permissions-collection-test - "A test suite for permissions `Collections`. Reüses functions from `metabase.permissions-test`." - (:require [expectations :refer :all] - [metabase - [permissions-test :as perms-test :refer [*card:db2-count-of-venues* *db2*]] - [util :as u]] - [metabase.models - [card :as card :refer [Card]] - [collection :refer [Collection]] - [permissions :as permissions] - [permissions-group :as group] - [revision :refer [Revision]]] - [metabase.test.data.users :as test-users] - [toucan.db :as db] - [toucan.util.test :as tt])) - -;; the Card used in the tests below is one Crowberto (an admin) should be allowed to read/write based on data permissions, -;; but not Rasta (all-users) - -(defn- api-call-was-successful? {:style/indent 0} [response] - (and (not= response "You don't have permissions to do that.") - (not= response "Unauthenticated"))) - -(defn- can-run-query? [username] - (api-call-was-successful? ((test-users/user->client username) :post (format "card/%d/query" (u/get-id *card:db2-count-of-venues*))))) - -(defn- set-card-collection! [collection-or-id] - (db/update! Card (u/get-id *card:db2-count-of-venues*) - :collection_id (u/get-id collection-or-id))) - - -;; if a card is in no collection but we have data permissions, we should be able to run it -;; [Disabled for now since this test seems to randomly fail all the time for reasons I don't understand) -#_(perms-test/expect-with-test-data - true - (can-run-query? :crowberto)) - -;; if a card is in no collection and we don't have data permissions, we should not be able to run it -(perms-test/expect-with-test-data - false - (can-run-query? :rasta)) - -;; if a card is in a collection and we don't have permissions for that collection, we shouldn't be able to run it -(perms-test/expect-with-test-data - false - (tt/with-temp Collection [collection] - (set-card-collection! collection) - (can-run-query? :rasta))) - -;; if a card is in a collection and we have permissions for that collection, we should be able to run it -;; [Disabled for now since this test seems to randomly fail all the time for reasons I don't understand) -#_(perms-test/expect-with-test-data - true - (tt/with-temp Collection [collection] - (set-card-collection! collection) - (permissions/grant-collection-read-permissions! (group/all-users) collection) - (can-run-query? :rasta))) - -;; Make sure a User isn't allowed to save a Card they have collections readwrite permissions for -;; if they don't have data perms for the query -(defn- query-rasta-has-no-data-perms-for [] - {:database (u/get-id *db2*) - :type "query" - :query {:source-table (u/get-id (perms-test/table *db2* :venues))}}) - - -(expect - false - (perms-test/with-test-data - (tt/with-temp Collection [collection] - (set-card-collection! collection) - (permissions/grant-collection-readwrite-permissions! (group/all-users) collection) - (api-call-was-successful? - ((test-users/user->client :rasta) :put (str "card/" (u/get-id *card:db2-count-of-venues*)) - {:dataset_query (query-rasta-has-no-data-perms-for)}))))) - -;; Make sure a User isn't allowed to unarchive a Card if they don't have data perms for the query -(expect - false - (perms-test/with-test-data - (tt/with-temp Collection [collection] - (set-card-collection! collection) - (permissions/grant-collection-readwrite-permissions! (group/all-users) collection) - (db/update! Card (u/get-id *card:db2-count-of-venues*) - :archived true - :dataset_query (query-rasta-has-no-data-perms-for)) - (api-call-was-successful? - ((test-users/user->client :rasta) :put (str "card/" (u/get-id *card:db2-count-of-venues*)) - {:archived false}))))) - -;; Make sure a User isn't allowed to restore a Card to a previous revision if they don't have data perms for the query -(expect - false - (perms-test/with-test-data - (tt/with-temp Collection [collection] - (set-card-collection! collection) - (permissions/grant-collection-readwrite-permissions! (group/all-users) collection) - (tt/with-temp Revision [revision {:model "Card" - :model_id (u/get-id *card:db2-count-of-venues*) - :object (card/serialize-instance (assoc (Card (u/get-id *card:db2-count-of-venues*)) - :dataset_query (query-rasta-has-no-data-perms-for)))}] - (api-call-was-successful? - ((test-users/user->client :rasta) :post "revision/revert" - {:entity "card" - :id (u/get-id (u/get-id *card:db2-count-of-venues*)) - :revision_id (u/get-id revision)})))))) diff --git a/test/metabase/permissions_test.clj b/test/metabase/permissions_test.clj deleted file mode 100644 index 1625c0f519d45ed818ab3b1dcffb50c763ef6b69..0000000000000000000000000000000000000000 --- a/test/metabase/permissions_test.clj +++ /dev/null @@ -1,904 +0,0 @@ -(ns metabase.permissions-test - "A test suite around permissions. Nice!" - (:require [clojure.string :as str] - [expectations :refer :all] - [metabase.models - [card :refer [Card]] - [dashboard :refer [Dashboard]] - [dashboard-card :refer [DashboardCard]] - [database :refer [Database]] - [field :refer [Field]] - [metric :refer [Metric]] - [permissions :as perms] - [permissions-group :as group :refer [PermissionsGroup]] - [permissions-group-membership :refer [PermissionsGroupMembership]] - [pulse :refer [Pulse]] - [pulse-card :refer [PulseCard]] - [pulse-channel :refer [PulseChannel]] - [pulse-channel-recipient :refer [PulseChannelRecipient]] - [segment :refer [Segment]] - [table :refer [Table]]] - [metabase.query-processor.middleware.expand :as ql] - [metabase.test - [data :as data] - [util :as tu]] - [metabase.test.data.users :as test-users] - [metabase.util :as u] - [toucan.db :as db] - [toucan.util.test :as tt]) - (:import java.util.UUID)) - -;; 3 users: -;; crowberto, member of Admin, All Users -;; rasta, member of All Users -;; lucky, member of All Users, Ops - - -;;; -------------------------------------------------- Ops Group ---------------------------------------------------- - -;; ops group is a group with only one member: lucky -(def ^:dynamic *ops-group*) - -(defn- with-ops-group [f] - (fn [] - (tt/with-temp* [PermissionsGroup [group {:name "Operations"}]] - ;; add lucky to Ops group - (db/insert! PermissionsGroupMembership - :group_id (u/get-id group) - :user_id (test-users/user->id :lucky)) - ;; cool, g2g <3 - (binding [*ops-group* group] - (f))))) - - -;;; --------------------------------------------- DBs, Tables, & Fields --------------------------------------------- - -(def db-details - (delay (db/select-one [Database :details :engine] :id (data/id)))) - -(defn- test-db [db-name] - (assoc (select-keys @db-details [:details :engine]) - :name db-name)) - -(defn table [db table-name] - (db/select-one Table - :%lower.name (str/lower-case (name table-name)) - :db_id (u/get-id db))) - -(defn- field - ([db table-name field-name] - (field (table db table-name) field-name)) - ([table field-name] - (db/select-one Field - :%lower.name (str/lower-case (name field-name)) - :table_id (u/get-id table)))) - -(defn- with-db [db-name f] - (fn [] - (tt/with-temp Database [db (test-db db-name)] - ;; syncing is slow as f**k so just manually insert the Tables - (doseq [table-name ["VENUES" "USERS" "CHECKINS"]] - (db/insert! Table :db_id (u/get-id db), :active true, :name (str/upper-case table-name))) - ;; do the same for Fields - (doseq [field [{:table_id (u/get-id (table db :venues)) - :name "PRICE" - :database_type "INT" - :base_type :type/Integer - :special_type :type/Category} - {:table_id (u/get-id (table db :users)) - :name "LAST_LOGIN" - :database_type "TIMESTAMP" - :base_type :type/DateTime}]] - (db/insert! Field field)) - ;; ok ! - (f db)))) - -(def ^:dynamic *db1*) -(def ^:dynamic *db2*) - -(defn- all-db-ids [] - #{(u/get-id *db1*) - (u/get-id *db2*)}) - -(defn- with-db-1 [f] - (with-db "DB One" (fn [db] - (binding [*db1* db] - (f))))) - -(defn- with-db-2 [f] - (with-db "DB Two" (fn [db] - ;; all-users has no access to DB 2 - (perms/revoke-permissions! (group/all-users) (u/get-id db)) - ;; ops group only has access venues table + *reading* SQL - (when *ops-group* - (perms/revoke-permissions! *ops-group* (u/get-id db)) - (perms/grant-native-read-permissions! *ops-group* (u/get-id db)) - (let [venues-table (table db :venues)] - (perms/grant-permissions! *ops-group* (u/get-id db) (:schema venues-table) (u/get-id venues-table)))) - (binding [*db2* db] - (f))))) - - -;;; ----------------------------------------------------- Cards ----------------------------------------------------- - -(defn- count-card [db table-name card-name] - (let [table (table db table-name)] - {:name card-name - :database_id (u/get-id db) - :table_id (u/get-id table) - :dataset_query {:database (u/get-id db) - :type "query" - :query (ql/query - (ql/source-table (u/get-id table)) - (ql/aggregation (ql/count)))}})) - -(defn- sql-count-card [db table-name card-name] - (let [table (table db table-name)] - {:name card-name - :database_id (u/get-id db) - :table_id (u/get-id table) - :dataset_query {:database (u/get-id db) - :type "native" - :native {:query (format "SELECT count(*) FROM \"%s\";" (str/upper-case (:name table)))}}})) - - -(def ^:dynamic *card:db1-count-of-venues*) -(def ^:dynamic *card:db1-count-of-users*) -(def ^:dynamic *card:db1-count-of-checkins*) -(def ^:dynamic *card:db1-sql-count-of-users*) -(def ^:dynamic *card:db2-count-of-venues*) ; all-users (rasta) has no access to DB2 -(def ^:dynamic *card:db2-count-of-users*) ; ops (lucky) has access to venues and reading SQL (deprecated) -(def ^:dynamic *card:db2-count-of-checkins*) -(def ^:dynamic *card:db2-sql-count-of-users*) -(def ^:dynamic *card:db2-public*) ; a publicly shared Card -(def ^:dynamic *card:db2-in-public-dash*) ; a private Card that is in a public Dashboard - -(defn- all-cards [] ; Crowberto [Admin] | Lucky [Ops] | Rasta [Default] - #{*card:db1-count-of-venues* ; ✓ | ✓ | ✓ - *card:db1-count-of-users* ; ✓ | ✓ | ✓ - *card:db1-count-of-checkins* ; ✓ | ✓ | ✓ - *card:db1-sql-count-of-users* ; ✓ | ✓ | ✓ - *card:db2-count-of-venues* ; ✓ | ✓ | x - *card:db2-count-of-users* ; ✓ | x | x - *card:db2-count-of-checkins* ; ✓ | x | x - *card:db2-sql-count-of-users* ; ✓ | ✓ | x - *card:db2-public* ; ✓ | ✓ | ✓ - *card:db2-in-public-dash*}) ; ✓ | ✓ | ✓ - -(defn- all-card-ids [] - (set (map :id (all-cards)))) - -(defn- with-cards [f] - (fn [] - (tt/with-temp* [Card [db1-count-of-venues (count-card *db1* :venues "DB 1 Count of Venues")] - Card [db1-count-of-users (count-card *db1* :users "DB 1 Count of Users")] - Card [db1-count-of-checkins (count-card *db1* :checkins "DB 1 Count of Checkins")] - Card [db1-sql-count-of-users (sql-count-card *db1* :venues "DB 1 SQL Count of Users")] - Card [db2-count-of-venues (count-card *db2* :venues "DB 2 Count of Venues")] - Card [db2-count-of-users (count-card *db2* :users "DB 2 Count of Users")] - Card [db2-count-of-checkins (count-card *db2* :checkins "DB 2 Count of Checkins")] - Card [db2-sql-count-of-users (sql-count-card *db2* :users "DB 2 SQL Count of Users")] - Card [db2-public (assoc (count-card *db2* :users "DB 2 Public") - :made_public_by_id (test-users/user->id :crowberto) - :public_uuid (str (UUID/randomUUID)))] - Card [db2-in-public-dash (count-card *db2* :users "DB 2 In Public Dash")]] - (binding [*card:db1-count-of-venues* db1-count-of-venues - *card:db1-count-of-users* db1-count-of-users - *card:db1-count-of-checkins* db1-count-of-checkins - *card:db1-sql-count-of-users* db1-sql-count-of-users - *card:db2-count-of-venues* db2-count-of-venues - *card:db2-count-of-users* db2-count-of-users - *card:db2-count-of-checkins* db2-count-of-checkins - *card:db2-sql-count-of-users* db2-sql-count-of-users - *card:db2-public* db2-public - *card:db2-in-public-dash* db2-in-public-dash] - (f))))) - -;;; --------------------------------------------------- Dashboards --------------------------------------------------- - -(def ^:dynamic *dash:db1-all*) ; Dash containing all the non-public cards for DB 1 -(def ^:dynamic *dash:db2-all*) ; Dash containing all the non-public cards for DB 2 -(def ^:dynamic *dash:db2-private*) ; Dash containing only DB 2 Count of Users card. Only admin can see -(def ^:dynamic *dash:db2-public*) ; Public dash containing DB 2 In Public Dash Card (count of Users), normally private - -(defn- all-dashboards [] - #{*dash:db1-all* - *dash:db2-all* - *dash:db2-private* - *dash:db2-public*}) - -(defn- all-dashboard-ids [] - (set (map :id (all-dashboards)))) - -(defn- add-cards-to-dashboard! {:style/indent 1} [dashboard & cards] - (doseq [card cards] - (db/insert! DashboardCard - :dashboard_id (u/get-id dashboard) - :card_id (u/get-id card)))) - -(defn- with-dashboards [f] - (fn [] - (tt/with-temp* [Dashboard [db1-all {:name "All DB 1"}] - Dashboard [db2-all {:name "All DB 2"}] - Dashboard [db2-private {:name "Private DB 2"}] - Dashboard [db2-public {:name "Public DB 2" - :made_public_by_id (test-users/user->id :crowberto) - :public_uuid (str (UUID/randomUUID))}]] - (add-cards-to-dashboard! db1-all - *card:db1-count-of-venues* - *card:db1-count-of-users* - *card:db1-count-of-checkins* - *card:db1-sql-count-of-users*) - (add-cards-to-dashboard! db2-all - *card:db2-count-of-venues* - *card:db2-count-of-users* - *card:db2-count-of-checkins* - *card:db2-sql-count-of-users*) - (add-cards-to-dashboard! db2-private - *card:db2-count-of-users*) - (add-cards-to-dashboard! db2-public - *card:db2-in-public-dash*) - (binding [*dash:db1-all* db1-all - *dash:db2-all* db2-all - *dash:db2-private* db2-private - *dash:db2-public* db2-public] - (f))))) - - -;;; ----------------------------------------------------- Pulses ----------------------------------------------------- - -(def ^:dynamic *pulse:all*) -(def ^:dynamic *pulse:db1-all*) -(def ^:dynamic *pulse:db2-all*) -(def ^:dynamic *pulse:db2-private*) -(def ^:dynamic *pulse:db2-restricted*) - -(defn- all-pulse-ids [] - #{(u/get-id *pulse:all*) - (u/get-id *pulse:db1-all*) - (u/get-id *pulse:db2-all*) - (u/get-id *pulse:db2-private*) - (u/get-id *pulse:db2-restricted*)}) - -(defn- add-cards-to-pulse! {:style/indent 1} [pulse & cards] - (doseq [[i card] (map-indexed vector cards)] - (db/insert! PulseCard - :card_id (u/get-id card) - :position i - :pulse_id (u/get-id pulse)))) - -(defn- add-recipients-to-pulse! {:style/indent 1} [pulse & usernames] - (let [channel (db/insert! PulseChannel - :pulse_id (u/get-id pulse) - :channel_type "email" - :schedule_type "daily" - :details {})] - (doseq [username usernames] - (db/insert! PulseChannelRecipient - :pulse_channel_id (u/get-id channel) - :user_id (test-users/user->id username))))) - -(defn- with-pulses [f] - (fn [] - (tt/with-temp* [Pulse [all {:name "All of Everything"}] - Pulse [db1-all {:name "All DB 1"}] - Pulse [db2-all {:name "All DB 2"}] - Pulse [db2-private {:name "Private DB 2"}] - Pulse [db2-restricted {:name "Restricted DB 2"}]] - ;; add cards - (add-cards-to-pulse! all - *card:db1-count-of-venues* - *card:db1-count-of-users* - *card:db1-count-of-checkins* - *card:db1-sql-count-of-users* - *card:db2-count-of-venues* - *card:db2-count-of-users* - *card:db2-count-of-checkins* - *card:db2-sql-count-of-users*) - (add-cards-to-pulse! db1-all - *card:db1-count-of-venues* - *card:db1-count-of-users* - *card:db1-count-of-checkins* - *card:db1-sql-count-of-users*) - (add-cards-to-pulse! db2-all - *card:db2-count-of-venues* - *card:db2-count-of-users* - *card:db2-count-of-checkins* - *card:db2-sql-count-of-users*) - (add-cards-to-pulse! db2-private - *card:db2-count-of-venues*) - (add-cards-to-pulse! db2-restricted - *card:db2-count-of-users* - *card:db2-count-of-checkins*) - ;; add recipients - (add-recipients-to-pulse! all - :crowberto - :rasta - :lucky) - ;; ok! - (binding [*pulse:all* all - *pulse:db1-all* db1-all - *pulse:db2-all* db2-all - *pulse:db2-private* db2-private - *pulse:db2-restricted* db2-restricted] - (f))))) - - -;;; ---------------------------------------------------- Metrics ----------------------------------------------------- - -(def ^:dynamic *metric:db1-venues-count*) -(def ^:dynamic *metric:db2-venues-count*) -(def ^:dynamic *metric:db2-users-count*) - -(defn- all-metric-ids [] - #{(u/get-id *metric:db1-venues-count*) - (u/get-id *metric:db2-venues-count*) - (u/get-id *metric:db2-users-count*)}) - -(defn- count-metric [metric-name table] - {:name metric-name - :description metric-name - :table_id (u/get-id table) - :definition (ql/query - (ql/source-table (u/get-id table)) - (ql/aggregation (ql/count)))}) - -(defn- with-metrics [f] - (fn [] - (tt/with-temp* [Metric [db1-venues-count (count-metric "DB 1 Count of Venues" (table *db1* :venues))] - Metric [db2-venues-count (count-metric "DB 2 Count of Venues" (table *db2* :venues))] - Metric [db2-users-count (count-metric "DB 2 Count of Users" (table *db2* :users))]] - (binding [*metric:db1-venues-count* db1-venues-count - *metric:db2-venues-count* db2-venues-count - *metric:db2-users-count* db2-users-count] - (f))))) - - -;;; ---------------------------------------------------- Segments ---------------------------------------------------- - -(def ^:dynamic *segment:db1-expensive-venues*) -(def ^:dynamic *segment:db2-expensive-venues*) -(def ^:dynamic *segment:db2-todays-users*) - -(defn- all-segment-ids [] - #{(u/get-id *segment:db1-expensive-venues*) - (u/get-id *segment:db2-expensive-venues*) - (u/get-id *segment:db2-todays-users*)}) - -(defn- segment [segment-name table definition] - {:name segment-name - :description segment-name - :table_id (u/get-id table) - :definition definition}) - -(defn- expensive-venues-segment [segment-name table] - (segment segment-name table (ql/query - (ql/source-table (u/get-id table)) - (ql/filter (ql/= (ql/field-id (u/get-id (field table :price))) - 4))))) - -(defn- todays-users-segment [segment-name table] - (segment segment-name table (ql/query - (ql/source-table (u/get-id table)) - (ql/filter (ql/= (ql/field-id (u/get-id (field table :last_login))) - (ql/relative-datetime :current)))))) - -(defn- with-segments [f] - (fn [] - (tt/with-temp* [Segment [db1-expensive-venues (expensive-venues-segment "DB 1 Expensive Venues" (table *db1* :venues))] - Segment [db2-expensive-venues (expensive-venues-segment "DB 2 Expensive Venues" (table *db2* :venues))] - Segment [db2-todays-users (todays-users-segment "DB 2 Today's Users" (table *db2* :users))]] - (binding [*segment:db1-expensive-venues* db1-expensive-venues - *segment:db2-expensive-venues* db2-expensive-venues - *segment:db2-todays-users* db2-todays-users] - (f))))) - - -;;; ------------------------------------------------ with everything! ------------------------------------------------ - - - -(defn -do-with-test-data [f] - ((comp - ;; run everything with enable-public-sharing set to true, needed for some public perms tests - (partial tu/do-with-temporary-setting-value :enable-public-sharing true) - with-ops-group - with-db-2 - with-db-1 - with-cards - with-dashboards - with-pulses - with-metrics - with-segments) f)) - -(defmacro with-test-data {:style/indent 0} [& body] - `(-do-with-test-data (fn [] - ~@body))) - -(defmacro expect-with-test-data {:style/indent 0} [expected actual] - `(expect - ~expected - (with-test-data - ~actual))) - - -;;; +----------------------------------------------------------------------------------------------------------------+ -;;; | QUERY BUILDER | -;;; +----------------------------------------------------------------------------------------------------------------+ - -;;; -------------------------- GET /api/database?include_tables=true (Visible DBs + Tables) -------------------------- - -(defn- GET-database [username] - (vec (for [db ((test-users/user->client username) :get 200 "database", :include_tables true) - :when ((all-db-ids) (u/get-id db))] - [(:name db) (mapv :name (:tables db))]))) - -;; admin should be able to see everything -(expect-with-test-data - [["DB One" ["CHECKINS" "USERS" "VENUES"]] - ["DB Two" ["CHECKINS" "USERS" "VENUES"]]] - (GET-database :crowberto)) - -;; basic user should only see DB 1 -(expect-with-test-data - [["DB One" ["CHECKINS" "USERS" "VENUES"]]] - (GET-database :rasta)) - -;; ops user should see DB 1 and venues in DB 2 -(expect-with-test-data - [["DB One" ["CHECKINS" "USERS" "VENUES"]] - ["DB Two" ["VENUES"]]] - (GET-database :lucky)) - - -;;; --------------------------------------- GET /api/table/:id/query_metadata ---------------------------------------- - -(defn- GET-table-query-metadata [username db table-name] - (not= ((test-users/user->client username) :get (format "table/%d/query_metadata" (u/get-id (table db table-name)))) - "You don't have permissions to do that.")) - -;; admin should be able to get metadata for all tables -(expect-with-test-data true (GET-table-query-metadata :crowberto *db1* :checkins)) -(expect-with-test-data true (GET-table-query-metadata :crowberto *db1* :users)) -(expect-with-test-data true (GET-table-query-metadata :crowberto *db1* :venues)) -(expect-with-test-data true (GET-table-query-metadata :crowberto *db2* :checkins)) -(expect-with-test-data true (GET-table-query-metadata :crowberto *db2* :users)) -(expect-with-test-data true (GET-table-query-metadata :crowberto *db2* :venues)) - -;; normal user should only be able to get metadata for DB 1's tables -(expect-with-test-data true (GET-table-query-metadata :rasta *db1* :checkins)) -(expect-with-test-data true (GET-table-query-metadata :rasta *db1* :users)) -(expect-with-test-data true (GET-table-query-metadata :rasta *db1* :venues)) -(expect-with-test-data false (GET-table-query-metadata :rasta *db2* :checkins)) -(expect-with-test-data false (GET-table-query-metadata :rasta *db2* :users)) -(expect-with-test-data false (GET-table-query-metadata :rasta *db2* :venues)) - -;; ops user should be able to get metadata for DB 1 or for venues in DB 2 -(expect-with-test-data true (GET-table-query-metadata :lucky *db1* :checkins)) -(expect-with-test-data true (GET-table-query-metadata :lucky *db1* :users)) -(expect-with-test-data true (GET-table-query-metadata :lucky *db1* :venues)) -(expect-with-test-data false (GET-table-query-metadata :lucky *db2* :checkins)) -(expect-with-test-data false (GET-table-query-metadata :lucky *db2* :users)) -(expect-with-test-data true (GET-table-query-metadata :lucky *db2* :venues)) - - -;;; ----------------------------------------- POST /api/dataset (SQL query) ------------------------------------------ - -(defn- sql-query [username db] - (let [results ((test-users/user->client username) :post "dataset" - {:database (u/get-id db) - :type :native - :native {:query "SELECT COUNT(*) FROM VENUES"}})] - (if (string? results) - results - (or (:error results) - (get-in results [:data :rows]))))) - -;; everyone should be able to ask SQL questions against DB 1 -(expect-with-test-data [[100]] (sql-query :crowberto *db1*)) -(expect-with-test-data [[100]] (sql-query :rasta *db1*)) -(expect-with-test-data [[100]] (sql-query :lucky *db1*)) - -;; Only Admin should be able to ask SQL questions against DB 2. Error message is slightly different for Rasta & Lucky -;; because Rasta has no permissions whatsoever for DB 2 while Lucky has partial perms -(expect-with-test-data [[100]] (sql-query :crowberto *db2*)) -(expect-with-test-data "You don't have permissions to do that." (sql-query :rasta *db2*)) -(expect-with-test-data #"You do not have read permissions for /db/\d+/native/\." (sql-query :lucky *db2*)) - - -;;; +----------------------------------------------------------------------------------------------------------------+ -;;; | SAVED QUESTIONS | -;;; +----------------------------------------------------------------------------------------------------------------+ - -;;; ----------------------------------------- GET /api/card (Visible Cards) ------------------------------------------ - -(defn- GET-card [username] - (vec (for [card ((test-users/user->client username) :get 200 "card") - :when ((all-card-ids) (u/get-id card))] - (:name card)))) - -;; Admin should be able to see all 10 questions -(expect-with-test-data - ["DB 1 Count of Checkins" - "DB 1 Count of Users" - "DB 1 Count of Venues" - "DB 1 SQL Count of Users" - "DB 2 Count of Checkins" - "DB 2 Count of Users" - "DB 2 Count of Venues" - "DB 2 In Public Dash" - "DB 2 Public" - "DB 2 SQL Count of Users"] - (GET-card :crowberto)) - -;; All Users should only be able to see questions in DB 1, and Public Cards -(expect-with-test-data - ["DB 1 Count of Checkins" - "DB 1 Count of Users" - "DB 1 Count of Venues" - "DB 1 SQL Count of Users" - "DB 2 In Public Dash" - "DB 2 Public"] - (GET-card :rasta)) - -;; Ops should be able to see questions in DB 1; DB 2 venues & SQL questions; Public Cards -(expect-with-test-data - ["DB 1 Count of Checkins" - "DB 1 Count of Users" - "DB 1 Count of Venues" - "DB 1 SQL Count of Users" - "DB 2 Count of Venues" - "DB 2 In Public Dash" - "DB 2 Public" - "DB 2 SQL Count of Users"] - (GET-card :lucky)) - - -;;; ----------------------------------------------- GET /api/card/:id ------------------------------------------------ - -;; just return true/false based on whether they were allowed to see the card -(defn- GET-card-id [username card] - (not= ((test-users/user->client username) :get (str "card/" (u/get-id card))) - "You don't have permissions to do that.")) - -;; admin can fetch all 10 cards -(expect-with-test-data true (GET-card-id :crowberto *card:db1-count-of-venues*)) -(expect-with-test-data true (GET-card-id :crowberto *card:db1-count-of-users*)) -(expect-with-test-data true (GET-card-id :crowberto *card:db1-count-of-checkins*)) -(expect-with-test-data true (GET-card-id :crowberto *card:db1-sql-count-of-users*)) -(expect-with-test-data true (GET-card-id :crowberto *card:db2-count-of-venues*)) -(expect-with-test-data true (GET-card-id :crowberto *card:db2-count-of-users*)) -(expect-with-test-data true (GET-card-id :crowberto *card:db2-count-of-checkins*)) -(expect-with-test-data true (GET-card-id :crowberto *card:db2-sql-count-of-users*)) -(expect-with-test-data true (GET-card-id :crowberto *card:db2-public*)) -(expect-with-test-data true (GET-card-id :crowberto *card:db2-in-public-dash*)) - -;; regular user can only fetch Cards for DB 1 or public Cards -(expect-with-test-data true (GET-card-id :rasta *card:db1-count-of-venues*)) -(expect-with-test-data true (GET-card-id :rasta *card:db1-count-of-users*)) -(expect-with-test-data true (GET-card-id :rasta *card:db1-count-of-checkins*)) -(expect-with-test-data true (GET-card-id :rasta *card:db1-sql-count-of-users*)) -(expect-with-test-data false (GET-card-id :rasta *card:db2-count-of-venues*)) -(expect-with-test-data false (GET-card-id :rasta *card:db2-count-of-users*)) -(expect-with-test-data false (GET-card-id :rasta *card:db2-count-of-checkins*)) -(expect-with-test-data false (GET-card-id :rasta *card:db2-sql-count-of-users*)) -(expect-with-test-data true (GET-card-id :rasta *card:db2-public*)) -(expect-with-test-data true (GET-card-id :rasta *card:db2-in-public-dash*)) - -;; ops user can fetch DB 1 cards, DB 2 Venues cards, or Public cards -(expect-with-test-data true (GET-card-id :lucky *card:db1-count-of-venues*)) -(expect-with-test-data true (GET-card-id :lucky *card:db1-count-of-users*)) -(expect-with-test-data true (GET-card-id :lucky *card:db1-count-of-checkins*)) -(expect-with-test-data true (GET-card-id :lucky *card:db1-sql-count-of-users*)) -(expect-with-test-data true (GET-card-id :lucky *card:db2-count-of-venues*)) -(expect-with-test-data false (GET-card-id :lucky *card:db2-count-of-users*)) -(expect-with-test-data false (GET-card-id :lucky *card:db2-count-of-checkins*)) -(expect-with-test-data true (GET-card-id :lucky *card:db2-sql-count-of-users*)) -(expect-with-test-data true (GET-card-id :lucky *card:db2-public*)) -(expect-with-test-data true (GET-card-id :lucky *card:db2-in-public-dash*)) - - -;;; -------------------------------------------- POST /api/card/:id/query -------------------------------------------- - -;; Check whether we're allowed to run the cards as well -(defn- POST-card-id-query [username card] - (let [results ((test-users/user->client username) :post (str "card/" (u/get-id card) "/query"))] - (and (map? results) - (= (:status results) "completed")))) - -;; admin can run all 10 cards -(expect-with-test-data true (POST-card-id-query :crowberto *card:db1-count-of-venues*)) -(expect-with-test-data true (POST-card-id-query :crowberto *card:db1-count-of-users*)) -(expect-with-test-data true (POST-card-id-query :crowberto *card:db1-count-of-checkins*)) -(expect-with-test-data true (POST-card-id-query :crowberto *card:db1-sql-count-of-users*)) -(expect-with-test-data true (POST-card-id-query :crowberto *card:db2-count-of-venues*)) -(expect-with-test-data true (POST-card-id-query :crowberto *card:db2-count-of-users*)) -(expect-with-test-data true (POST-card-id-query :crowberto *card:db2-count-of-checkins*)) -(expect-with-test-data true (POST-card-id-query :crowberto *card:db2-sql-count-of-users*)) -(expect-with-test-data true (POST-card-id-query :crowberto *card:db2-public*)) -(expect-with-test-data true (POST-card-id-query :crowberto *card:db2-in-public-dash*)) - -;; regular user can only run Cards for DB 1 or public Card -(expect-with-test-data true (POST-card-id-query :rasta *card:db1-count-of-venues*)) -(expect-with-test-data true (POST-card-id-query :rasta *card:db1-count-of-users*)) -(expect-with-test-data true (POST-card-id-query :rasta *card:db1-count-of-checkins*)) -(expect-with-test-data true (POST-card-id-query :rasta *card:db1-sql-count-of-users*)) -(expect-with-test-data false (POST-card-id-query :rasta *card:db2-count-of-venues*)) -(expect-with-test-data false (POST-card-id-query :rasta *card:db2-count-of-users*)) -(expect-with-test-data false (POST-card-id-query :rasta *card:db2-count-of-checkins*)) -(expect-with-test-data false (POST-card-id-query :rasta *card:db2-sql-count-of-users*)) -(expect-with-test-data true (POST-card-id-query :rasta *card:db2-public*)) -(expect-with-test-data true (POST-card-id-query :rasta *card:db2-in-public-dash*)) - -;; ops user can run DB 1 cards, DB 2 Venues cards, or Public card -(expect-with-test-data true (POST-card-id-query :lucky *card:db1-count-of-venues*)) -(expect-with-test-data true (POST-card-id-query :lucky *card:db1-count-of-users*)) -(expect-with-test-data true (POST-card-id-query :lucky *card:db1-count-of-checkins*)) -(expect-with-test-data true (POST-card-id-query :lucky *card:db1-sql-count-of-users*)) -(expect-with-test-data true (POST-card-id-query :lucky *card:db2-count-of-venues*)) -(expect-with-test-data false (POST-card-id-query :lucky *card:db2-count-of-users*)) -(expect-with-test-data false (POST-card-id-query :lucky *card:db2-count-of-checkins*)) -(expect-with-test-data true (POST-card-id-query :lucky *card:db2-sql-count-of-users*)) -(expect-with-test-data true (POST-card-id-query :lucky *card:db2-public*)) -(expect-with-test-data true (POST-card-id-query :lucky *card:db2-in-public-dash*)) - - -;;; +----------------------------------------------------------------------------------------------------------------+ -;;; | DASHBOARDS | -;;; +----------------------------------------------------------------------------------------------------------------+ - -;;; ------------------------------------ GET /api/dashboard (Visible Dashboards) ------------------------------------- - -(defn- GET-dashboard [username] - (vec (for [dashboard ((test-users/user->client username) :get 200 "dashboard") - :when ((all-dashboard-ids) (u/get-id dashboard))] - (:name dashboard)))) - -;; Admin should be able to see all dashboards -(expect-with-test-data - ["All DB 1" - "All DB 2" - "Private DB 2" - "Public DB 2"] - (GET-dashboard :crowberto)) - -;; All Users should only be able to see All DB 1 and Public DB 2. -;; Shouldn't see the other DB 2 dashboards because they have no access to DB 2 -(expect-with-test-data - ["All DB 1" - "Public DB 2"] - (GET-dashboard :rasta)) - -;; Ops should be able to see All DB 1 & All DB 2 & Public DB 2 -;; Shouldn't see DB 2 Private because they have no access to the db2-count-of-users card, its only card -(expect-with-test-data - ["All DB 1" - "All DB 2" - "Public DB 2"] - (GET-dashboard :lucky)) - - -;;; --------------------------------------------- GET /api/dashboard/:id --------------------------------------------- - -(defn- GET-dashboard-id - "Fetch a `dashboard` with credentials for `username`. - - Return `false` if unable to fetch the dashboard; otherwise return a sequence of names of Cards returned. For Cards - without proper read permissions, i.e. those whose presence was acknowledged, but whose data has been removed, will - be returned as `nil` since the name should not be available. (The endpoint will strip data from Cards you're not - allowed to see but leave display info in place (so a placeholder can be shown) for Dashboards for which you have - partial permissions; if you're not allowed to see *any* Cards in the Dashboard, you're not allowed to see the - Dashboard; it should return a 403 Forbidden response.)" - [username dashboard] - (let [response ((test-users/user->client username) :get (str "dashboard/" (u/get-id dashboard)))] - (and - (map? response) - (for [dashcard (sort-by :card_id (:ordered_cards response))] - (get-in dashcard [:card :name]))))) - -;; admin -(expect-with-test-data - ["DB 1 Count of Venues" - "DB 1 Count of Users" - "DB 1 Count of Checkins" - "DB 1 SQL Count of Users"] - (GET-dashboard-id :crowberto *dash:db1-all*)) - -(expect-with-test-data - ["DB 2 Count of Venues" - "DB 2 Count of Users" - "DB 2 Count of Checkins" - "DB 2 SQL Count of Users"] - (GET-dashboard-id :crowberto *dash:db2-all*)) - -(expect-with-test-data - ["DB 2 Count of Users"] - (GET-dashboard-id :crowberto *dash:db2-private*)) - -(expect-with-test-data - ["DB 2 In Public Dash"] - (GET-dashboard-id :crowberto *dash:db2-public*)) - - -;; normal user -(expect-with-test-data - ["DB 1 Count of Venues" - "DB 1 Count of Users" - "DB 1 Count of Checkins" - "DB 1 SQL Count of Users"] - (GET-dashboard-id :rasta *dash:db1-all*)) - -(expect-with-test-data - false - (GET-dashboard-id :rasta *dash:db2-all*)) - -(expect-with-test-data - false - (GET-dashboard-id :rasta *dash:db2-private*)) - -(expect-with-test-data - ["DB 2 In Public Dash"] - (GET-dashboard-id :rasta *dash:db2-public*)) - - -;; ops user -(expect-with-test-data - ["DB 1 Count of Venues" - "DB 1 Count of Users" - "DB 1 Count of Checkins" - "DB 1 SQL Count of Users"] - (GET-dashboard-id :lucky *dash:db1-all*)) - -(expect-with-test-data - ["DB 2 Count of Venues" - nil - nil - "DB 2 SQL Count of Users"] - (GET-dashboard-id :lucky *dash:db2-all*)) - -(expect-with-test-data - false - (GET-dashboard-id :lucky *dash:db2-private*)) - -(expect-with-test-data - ["DB 2 In Public Dash"] - (GET-dashboard-id :lucky *dash:db2-public*)) - - -;;; +----------------------------------------------------------------------------------------------------------------+ -;;; | PULSES | -;;; +----------------------------------------------------------------------------------------------------------------+ - -;;; ------------------------------------------------- GET /api/pulse ------------------------------------------------- - -(defn- GET-pulse [username] - (vec (for [pulse ((test-users/user->client username) :get 200 "pulse") - :when ((all-pulse-ids) (u/get-id pulse))] - (:name pulse)))) - -;; admin -(expect-with-test-data - ["All DB 1" - "All DB 2" - "All of Everything" - "Private DB 2" - "Restricted DB 2"] - (GET-pulse :crowberto)) - -;; normal user -(expect-with-test-data - ["All DB 1" - "All of Everything"] - (GET-pulse :rasta)) - -;; ops user -(expect-with-test-data - ["All DB 1" - "All of Everything" - "Private DB 2"] - (GET-pulse :lucky)) - - -;;; ----------------------------------------------- GET /api/pulse/:id ----------------------------------------------- - -(defn- GET-pulse-id [username pulse] - (not= ((test-users/user->client username) :get (str "pulse/" (u/get-id pulse))) - "You don't have permissions to do that.")) - -;; admin -(expect-with-test-data true (GET-pulse-id :crowberto *pulse:all*)) -(expect-with-test-data true (GET-pulse-id :crowberto *pulse:db1-all*)) -(expect-with-test-data true (GET-pulse-id :crowberto *pulse:db2-all*)) -(expect-with-test-data true (GET-pulse-id :crowberto *pulse:db2-private*)) -(expect-with-test-data true (GET-pulse-id :crowberto *pulse:db2-restricted*)) - -;; normal user -(expect-with-test-data true (GET-pulse-id :rasta *pulse:all*)) -(expect-with-test-data true (GET-pulse-id :rasta *pulse:db1-all*)) -(expect-with-test-data false (GET-pulse-id :rasta *pulse:db2-all*)) -(expect-with-test-data false (GET-pulse-id :rasta *pulse:db2-private*)) -(expect-with-test-data false (GET-pulse-id :rasta *pulse:db2-restricted*)) - -;; ops user -(expect-with-test-data true (GET-pulse-id :lucky *pulse:all*)) -(expect-with-test-data true (GET-pulse-id :lucky *pulse:db1-all*)) -(expect-with-test-data false (GET-pulse-id :lucky *pulse:db2-all*)) -(expect-with-test-data true (GET-pulse-id :lucky *pulse:db2-private*)) -(expect-with-test-data false (GET-pulse-id :lucky *pulse:db2-restricted*)) - - -;;; +----------------------------------------------------------------------------------------------------------------+ -;;; | DATA REFERENCE | -;;; +----------------------------------------------------------------------------------------------------------------+ - -;;; --------------------------------------- GET /api/metric (Visible Metrics) ---------------------------------------- - -(defn- GET-metric [username] - (vec (for [metric ((test-users/user->client username) :get 200 "metric") - :when ((all-metric-ids) (u/get-id metric))] - (:name metric)))) - -;; admin should see all 3 -(expect-with-test-data - ["DB 1 Count of Venues" - "DB 2 Count of Users" - "DB 2 Count of Venues"] - (GET-metric :crowberto)) - -;; regular should only see metric for DB 1 -(expect-with-test-data - ["DB 1 Count of Venues"] - (GET-metric :rasta)) - -;; ops shouldn't see DB 2 count of users because they don't have access -(expect-with-test-data - ["DB 1 Count of Venues" - "DB 2 Count of Venues"] - (GET-metric :lucky)) - - -;;; -------------------------------------- GET /api/segment (Visible Segments) --------------------------------------- - -(defn- GET-segment [username] - (vec (for [segment ((test-users/user->client username) :get 200 "segment") - :when ((all-segment-ids) (u/get-id segment))] - (:name segment)))) - -;; admin should see all 3 -(expect-with-test-data - ["DB 1 Expensive Venues" - "DB 2 Expensive Venues" - "DB 2 Today's Users"] - (GET-segment :crowberto)) - -;; regular user should only see segment for DB 1 -(expect-with-test-data - ["DB 1 Expensive Venues"] - (GET-segment :rasta)) - -;; ops users should see segment for DB 1 and DB 2 venues, but not DB 2 users -(expect-with-test-data - ["DB 1 Expensive Venues" - "DB 2 Expensive Venues"] - (GET-segment :lucky)) - - -;;; -------------------------------- GET /api/database/:id/metadata (Visible Tables) --------------------------------- - -(defn- GET-database-id-metadata [username db] - (let [db ((test-users/user->client username) :get (format "database/%d/metadata" (u/get-id db)))] - (if (string? db) - db - (mapv :name (:tables db))))) - -;; admin should be able to see everything -(expect-with-test-data ["CHECKINS" "USERS" "VENUES"] (GET-database-id-metadata :crowberto *db1*)) -(expect-with-test-data ["CHECKINS" "USERS" "VENUES"] (GET-database-id-metadata :crowberto *db2*)) - -;; regular user should only be able to see DB 1 -(expect-with-test-data ["CHECKINS" "USERS" "VENUES"] (GET-database-id-metadata :rasta *db1*)) -(expect-with-test-data "You don't have permissions to do that." (GET-database-id-metadata :rasta *db2*)) - -;; ops user should be able to see DB 1 + venues in DB 2 -(expect-with-test-data ["CHECKINS" "USERS" "VENUES"] (GET-database-id-metadata :lucky *db1*)) -(expect-with-test-data ["VENUES"] (GET-database-id-metadata :lucky *db2*)) diff --git a/test/metabase/pulse_test.clj b/test/metabase/pulse_test.clj index 10963a33c4718fa2a0102c69acfa5eba0a628061..dd74847b9895151392591f17e6953afeef1a9849 100644 --- a/test/metabase/pulse_test.clj +++ b/test/metabase/pulse_test.clj @@ -1,16 +1,17 @@ (ns metabase.pulse-test - (:require [clojure.string :as str] - [clojure.walk :as walk] + (:require [clojure + [string :as str] + [walk :as walk]] [expectations :refer :all] [medley.core :as m] - [metabase.integrations.slack :as slack] [metabase [email-test :as et] [pulse :refer :all] [query-processor :as qp]] + [metabase.integrations.slack :as slack] [metabase.models [card :refer [Card]] - [pulse :refer [Pulse retrieve-pulse retrieve-notification]] + [pulse :refer [Pulse retrieve-notification retrieve-pulse]] [pulse-card :refer [PulseCard]] [pulse-channel :refer [PulseChannel]] [pulse-channel-recipient :refer [PulseChannelRecipient]]] @@ -67,10 +68,10 @@ (pulse-test-fixture (fn [] ~@body)))) (def ^:private png-attachment - {:type :inline, - :content-id true, - :content-type "image/png", - :content java.net.URL}) + {:type :inline + :content-id true + :content-type "image/png" + :content java.net.URL}) (defn- rasta-pulse-email [& [email]] (et/email-to :rasta (merge {:subject "Pulse: Pulse Name", diff --git a/test/metabase/query_processor/middleware/permissions_test.clj b/test/metabase/query_processor/middleware/permissions_test.clj index 69eb16e37783b6f79217028df02158a04f320715..f926a718052e0888942f36c30264c94030521d5e 100644 --- a/test/metabase/query_processor/middleware/permissions_test.clj +++ b/test/metabase/query_processor/middleware/permissions_test.clj @@ -28,7 +28,8 @@ [query] (do-with-rasta (fn [] (check-perms query)))) -;;; ------------------------------------------------------------ Native Queries ------------------------------------------------------------ + +;;; ------------------------------------------------- Native Queries ------------------------------------------------- ;; Make sure the NATIVE query fails to run if current user doesn't have perms (expect @@ -50,7 +51,7 @@ :native {:query "SELECT * FROM VENUES"}})) -;;; ------------------------------------------------------------ MBQL Queries ------------------------------------------------------------ +;;; -------------------------------------------------- MBQL Queries -------------------------------------------------- (expect Exception @@ -67,14 +68,14 @@ ;; query should be returned by middleware unchanged {:database (u/get-id db) :type :query - :query {:source-table {:name "Toucans", :id (u/get-id table)}}} + :query {:source-table (u/get-id table)}} (check-perms-for-rasta {:database (u/get-id db) :type :query - :query {:source-table {:name "Toucans", :id (u/get-id table)}}})) + :query {:source-table (u/get-id table)}})) -;;; ------------------------------------------------------------ Nested Native Queries ------------------------------------------------------------ +;;; --------------------------------------------- Nested Native Queries ---------------------------------------------- (expect Exception @@ -94,7 +95,7 @@ :query {:source-query {:native "SELECT * FROM VENUES"}}})) -;;; ------------------------------------------------------------ Nested MBQL Queries ------------------------------------------------------------ +;;; ---------------------------------------------- Nested MBQL Queries ----------------------------------------------- ;; For nested queries MBQL make sure perms are checked (expect @@ -111,8 +112,8 @@ Table [table {:db_id (u/get-id db)}]] {:database (u/get-id db) :type :query - :query {:source-query {:source-table {:name "Toucans", :id (u/get-id table)}}}} + :query {:source-query {:source-table (u/get-id table)}}} (check-perms-for-rasta {:database (u/get-id db) :type :query - :query {:source-query {:source-table {:name "Toucans", :id (u/get-id table)}}}})) + :query {:source-query {:source-table (u/get-id table)}}})) diff --git a/test/metabase/query_processor/middleware/results_metadata_test.clj b/test/metabase/query_processor/middleware/results_metadata_test.clj index aa6e58ffee15920c04a22e735ed9ebf6a6bb204d..301942ad7b933d765b69439bd70d4824425d4fc2 100644 --- a/test/metabase/query_processor/middleware/results_metadata_test.clj +++ b/test/metabase/query_processor/middleware/results_metadata_test.clj @@ -5,7 +5,10 @@ [util :as u]] [metabase.models [card :refer [Card]] - [database :as database]] + [collection :refer [Collection]] + [database :as database] + [permissions :as perms] + [permissions-group :as group]] [metabase.query-processor.middleware.results-metadata :as results-metadata] [metabase.test.data :as data] [metabase.test.data.users :as users] @@ -47,8 +50,11 @@ ;; ...even when running via the API endpoint (expect {:name "NAME", :display_name "Name", :base_type "type/Text"} - (tt/with-temp Card [card {:dataset_query (native-query "SELECT * FROM VENUES") - :result_metadata {:name "NAME", :display_name "Name", :base_type "type/Text"}}] + (tt/with-temp* [Collection [collection] + Card [card {:collection_id (u/get-id collection) + :dataset_query (native-query "SELECT * FROM VENUES") + :result_metadata {:name "NAME", :display_name "Name", :base_type "type/Text"}}]] + (perms/grant-collection-read-permissions! (group/all-users) collection) ((users/user->client :rasta) :post 200 "dataset" {:database database/virtual-id :type :query :query {:source-table (str "card__" (u/get-id card))}}) @@ -75,11 +81,12 @@ {:base_type :type/Float, :display_name "Longitude", :name "LONGITUDE"}]} (-> (qp/process-query {:database (data/id) :type :native - :native {:query (format "SELECT ID, NAME, PRICE, CATEGORY_ID, LATITUDE, LONGITUDE FROM VENUES")}}) + :native {:query "SELECT ID, NAME, PRICE, CATEGORY_ID, LATITUDE, LONGITUDE FROM VENUES"}}) (get-in [:data :results_metadata]) (update :checksum class))) -;; make sure that a Card where a DateTime column is broken out by year advertises that column as Text, since you can't do datetime breakouts on years +;; make sure that a Card where a DateTime column is broken out by year advertises that column as Text, since you can't +;; do datetime breakouts on years (expect [{:base_type "type/Text" :display_name "Date" diff --git a/test/metabase/query_processor_test/nested_queries_test.clj b/test/metabase/query_processor_test/nested_queries_test.clj index 25d00276b94b8a9f77ed13ceae8d7ff4fc169d55..d1d7be8641e11855b9fc6139120956828bdc63e4 100644 --- a/test/metabase/query_processor_test/nested_queries_test.clj +++ b/test/metabase/query_processor_test/nested_queries_test.clj @@ -10,18 +10,20 @@ [metabase.driver.generic-sql :as generic-sql] [metabase.models [card :as card :refer [Card]] + [collection :as collection :refer [Collection]] [database :as database :refer [Database]] [field :refer [Field]] [permissions :as perms] - [permissions-group :as perms-group] + [permissions-group :as group] [segment :refer [Segment]] [table :refer [Table]]] + [metabase.models.query.permissions :as query-perms] [metabase.test [data :as data] [util :as tu]] [metabase.test.data - [datasets :as datasets] [dataset-definitions :as defs] + [datasets :as datasets] [users :refer [create-users-if-needed! user->client]]] [toucan.db :as db] [toucan.util.test :as tt])) @@ -496,16 +498,21 @@ ;; Make suer you're allowed to save a query that uses a SQL-based source query even if you don't have SQL *write* ;; permissions (#6845) -;; Check that write perms for a Card with a source query require that you are able to *read* (i.e., view) the source -;; query rather than be able to write (i.e., save) it. For example you should be able to save a query that uses a -;; native query as its source query if you have permissions to view that query, even if you aren't allowed to create -;; new ad-hoc SQL queries yourself. +;; Check that perms for a Card with a source query require that you have read permissions for its Collection! (expect - #{(perms/native-read-path (data/id))} - (tt/with-temp Card [card {:dataset_query {:database (data/id) - :type :native - :native {:query "SELECT * FROM VENUES"}}}] - (card/query-perms-set (query-with-source-card card :aggregation [:count]) :write))) + #{(perms/collection-read-path collection/root-collection)} + (tt/with-temp Card [card {:dataset_query {:database (data/id) + :type :native + :native {:query "SELECT * FROM VENUES"}}}] + (query-perms/perms-set (query-with-source-card card :aggregation [:count])))) + +(tt/expect-with-temp [Collection [collection]] + #{(perms/collection-read-path collection)} + (tt/with-temp Card [card {:collection_id (u/get-id collection) + :dataset_query {:database (data/id) + :type :native + :native {:query "SELECT * FROM VENUES"}}}] + (query-perms/perms-set (query-with-source-card card :aggregation [:count])))) ;; try this in an end-to-end fashion using the API and make sure we can save a Card if we have appropriate read ;; permissions for the source query @@ -519,36 +526,74 @@ (f db)))) (defn- save-card-via-API-with-native-source-query! - "Attempt to save a Card that uses a native source query for Database with `db-id` via the API using Rasta. Use this to - test how the API endpoint behaves based on certain permissions grants for the `All Users` group." - [expected-status-code db-id] - (tt/with-temp Card [card {:dataset_query {:database db-id + "Attempt to save a Card that uses a native source query and belongs to a Collection with `collection-id` via the API + using Rasta. Use this to test how the API endpoint behaves based on certain permissions grants for the `All Users` + group." + [expected-status-code db-or-id source-collection-or-id-or-nil dest-collection-or-id-or-nil] + (tt/with-temp Card [card {:collection_id (some-> source-collection-or-id-or-nil u/get-id) + :dataset_query {:database (u/get-id db-or-id) :type :native :native {:query "SELECT * FROM VENUES"}}}] ((user->client :rasta) :post expected-status-code "card" {:name (tu/random-name) + :collection_id (some-> dest-collection-or-id-or-nil u/get-id) :display "scalar" :visualization_settings {} :dataset_query (query-with-source-card card :aggregation [:count])}))) -;; ok... grant native *read* permissions which means we should be able to view our source query generated with the -;; function above. API should allow use to save here because write permissions for a query require read permissions -;; for any source queries +;; to save a Card that uses another Card as its source, you only need read permissions for the Collection the Source +;; Card is in, and write permissions for the Collection you're trying to save the new Card in (expect :ok (do-with-temp-copy-of-test-db (fn [db] - (perms/revoke-permissions! (perms-group/all-users) (u/get-id db)) - (perms/grant-permissions! (perms-group/all-users) (perms/native-read-path (u/get-id db))) - (save-card-via-API-with-native-source-query! 200 (u/get-id db)) - :ok))) + (tt/with-temp* [Collection [source-card-collection] + Collection [dest-card-collection]] + (perms/grant-collection-read-permissions! (group/all-users) source-card-collection) + (perms/grant-collection-readwrite-permissions! (group/all-users) dest-card-collection) + (save-card-via-API-with-native-source-query! 200 db source-card-collection dest-card-collection) + :ok)))) + +;; however, if we do *not* have read permissions for the source Card's collection we shouldn't be allowed to save the +;; query. This API call should fail + +;; Card in the Root Collection +(expect + "You don't have permissions to do that." + (do-with-temp-copy-of-test-db + (fn [db] + (tt/with-temp Collection [dest-card-collection] + (perms/grant-collection-readwrite-permissions! (group/all-users) dest-card-collection) + (save-card-via-API-with-native-source-query! 403 db nil dest-card-collection))))) + +;; Card in a different Collection for which we do not have perms +(expect + "You don't have permissions to do that." + (do-with-temp-copy-of-test-db + (fn [db] + (tt/with-temp* [Collection [source-card-collection] + Collection [dest-card-collection]] + (perms/grant-collection-readwrite-permissions! (group/all-users) dest-card-collection) + (save-card-via-API-with-native-source-query! 403 db source-card-collection dest-card-collection))))) + +;; similarly, if we don't have *write* perms for the dest collection it should also fail + +;; Try to save in the Root Collection +(expect + "You don't have permissions to do that." + (do-with-temp-copy-of-test-db + (fn [db] + (tt/with-temp Collection [source-card-collection] + (perms/grant-collection-read-permissions! (group/all-users) source-card-collection) + (save-card-via-API-with-native-source-query! 403 db source-card-collection nil))))) -;; however, if we do *not* have read permissions for the source query, we shouldn't be allowed to save the query. This -;; API call should fail +;; Try to save in a different Collection for which we do not have perms (expect "You don't have permissions to do that." (do-with-temp-copy-of-test-db (fn [db] - (perms/revoke-permissions! (perms-group/all-users) (u/get-id db)) - (save-card-via-API-with-native-source-query! 403 (u/get-id db))))) + (tt/with-temp* [Collection [source-card-collection] + Collection [dest-card-collection]] + (perms/grant-collection-read-permissions! (group/all-users) source-card-collection) + (save-card-via-API-with-native-source-query! 403 db source-card-collection dest-card-collection)))))