From 63e950f256b115b8756ef631d075d681bb9b4114 Mon Sep 17 00:00:00 2001 From: metamben <103100869+metamben@users.noreply.github.com> Date: Fri, 7 Oct 2022 18:45:46 +0300 Subject: [PATCH] Implement global app permissions for the "All Users" group (#25679) --- src/metabase/api/app.clj | 35 ++++++++ src/metabase/models/app/graph.clj | 72 ++++++++++++++++ src/metabase/models/collection/graph.clj | 35 ++++---- test/metabase/api/app_test.clj | 100 +++++++++++++++-------- 4 files changed, 194 insertions(+), 48 deletions(-) create mode 100644 src/metabase/models/app/graph.clj diff --git a/src/metabase/api/app.clj b/src/metabase/api/app.clj index f7b796162ca..26a3f86702c 100644 --- a/src/metabase/api/app.clj +++ b/src/metabase/api/app.clj @@ -8,6 +8,7 @@ [metabase.api.collection :as api.collection] [metabase.api.common :as api] [metabase.models :refer [App Collection Dashboard Table]] + [metabase.models.app.graph :as app.graph] [metabase.models.collection :as collection] [metabase.models.dashboard :as dashboard] [metabase.util.i18n :as i18n] @@ -27,6 +28,7 @@ (select-keys [:dashboard_id :options :nav_items]) (assoc :collection_id (:id collection-instance))) app (db/insert! App app-params)] + (app.graph/set-default-permissions! app) (hydrate-details app)))) (api/defendpoint POST "/" @@ -268,4 +270,37 @@ (create-scaffold-dashcards! scaffold-target->id pages) (hydrate-details (db/select-one App :id app-id))))) + +;;; ------------------------------------------------ GRAPH ENDPOINTS ------------------------------------------------- + +(api/defendpoint GET "/global-graph" + "Fetch the global graph of all App Permissions." + [] + (api/check-superuser) + (app.graph/global-graph)) + +(defn- ->int [id] (Integer/parseInt (name id))) + +(defn- dejsonify-id->permission-map [m] + (into {} + (map (fn [[k v]] + [(->int k) (keyword v)])) + m)) + +(defn- dejsonify-global-graph + "Fix the types in the graph when it comes in from the API, e.g. converting things like `\"none\"` to `:none` and + parsing object keys as integers." + [graph] + (update graph :groups dejsonify-id->permission-map)) + +(api/defendpoint PUT "/global-graph" + "Do a batch update of the global App Permissions by passing in a modified graph." + [:as {body :body}] + {body su/Map} + (api/check-superuser) + (-> body + dejsonify-global-graph + app.graph/update-global-graph!) + (app.graph/global-graph)) + (api/define-routes) diff --git a/src/metabase/models/app/graph.clj b/src/metabase/models/app/graph.clj new file mode 100644 index 00000000000..3f4d3728470 --- /dev/null +++ b/src/metabase/models/app/graph.clj @@ -0,0 +1,72 @@ +(ns metabase.models.app.graph + "Code for generating and updating the App permissions graph. App permissions + are based on the permissions of the app's collection." + (:require [clojure.data :as data] + [metabase.models.collection-permission-graph-revision :as c-perm-revision + :refer [CollectionPermissionGraphRevision]] + [metabase.models.collection.graph :as graph] + [metabase.models.permissions :as perms] + [metabase.models.permissions-group :as perms-group] + [metabase.models.setting :as setting :refer [defsetting]] + [metabase.public-settings.premium-features :as premium-features] + [metabase.util.i18n :as i18n :refer [tru]] + [metabase.util.schema :as su] + [schema.core :as s] + [toucan.db :as db])) + +(def ^:private AppPermissions + graph/CollectionPermissions) + +(def ^:private GlobalPermissionsGraph + {:revision s/Int + :groups {su/IntGreaterThanZero AppPermissions}}) + +(s/defn ^:private set-all-users-app-permission! + [permission :- AppPermissions] + (setting/set-value-of-type! :keyword :all-users-app-permission permission)) + +(defsetting all-users-app-permission + "App permission of the All Users group" + :type :keyword + :visibility :internal + :default :none + :setter (fn [v] + (set-all-users-app-permission! (cond-> v (string? v) keyword)))) + +(defn set-default-permissions! + "Sets the default permissions for the ''All Users'' group on`app` as specified + by `all-users-app-permission` if advanced permissions are not available." + [app] + (when-not (premium-features/has-feature? :advanced-permissions) + (graph/update-collection-permissions! (:id (perms-group/all-users)) + (:collection_id app) + (all-users-app-permission)))) + +(s/defn global-graph :- GlobalPermissionsGraph + "Fetch the global app permission graph." + [] + {:revision (c-perm-revision/latest-id) + :groups {(:id (perms-group/admin)) :write + (:id (perms-group/all-users)) (all-users-app-permission)}}) + +(s/defn update-global-graph! + "Update the global app permission graph to the state specified by + `new-graph`." + [new-graph :- GlobalPermissionsGraph] + (let [old-graph (global-graph) + [diff-old changes] (data/diff (:groups old-graph) (:groups new-graph))] + (perms/log-permissions-changes diff-old changes) + (perms/check-revision-numbers old-graph new-graph) + (when-let [[[group-id permission] & other-groups] (seq changes)] + (when (or (not= group-id (:id (perms-group/all-users))) + (seq other-groups)) + (throw (ex-info (tru "You can configure for the ''All Users'' group only") + {:group-ids (keys changes) + :status-code 400}))) + (db/transaction + (when (not= permission (all-users-app-permission)) + (all-users-app-permission! permission) + (doseq [collection-id (db/select-field :collection_id 'App)] + (graph/update-collection-permissions! group-id collection-id permission)) + (perms/save-perms-revision! + CollectionPermissionGraphRevision (:revision old-graph) old-graph changes)))))) diff --git a/src/metabase/models/collection/graph.clj b/src/metabase/models/collection/graph.clj index f9b4110bec6..32097886363 100644 --- a/src/metabase/models/collection/graph.clj +++ b/src/metabase/models/collection/graph.clj @@ -20,7 +20,8 @@ ;;; ---------------------------------------------------- Schemas ----------------------------------------------------- -(def ^:private CollectionPermissions +(def CollectionPermissions + "The valid collection permissions." (s/enum :write :read :none)) (def ^:private GroupPermissionsGraph @@ -106,20 +107,24 @@ {:status-code 400 :app-ids app-ids}))))) -(s/defn ^:private update-collection-permissions! - [collection-namespace :- (s/maybe su/KeywordOrString) - group-id :- su/IntGreaterThanZero - collection-id :- (s/cond-pre (s/eq :root) su/IntGreaterThanZero) - new-collection-perms :- CollectionPermissions] - (let [collection-id (if (= collection-id :root) - (assoc collection/root-collection :namespace collection-namespace) - 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 update-collection-permissions! + "Update the permissions for group ID with `group-id` on collection with ID + `collection-id` in the optional `collection-namespace` to `new-collection-perms`." + ([group-id collection-id new-collection-perms] + (update-collection-permissions! nil group-id collection-id new-collection-perms)) + ([collection-namespace :- (s/maybe su/KeywordOrString) + group-id :- su/IntGreaterThanZero + collection-id :- (s/cond-pre (s/eq :root) su/IntGreaterThanZero) + new-collection-perms :- CollectionPermissions] + (let [collection-id (if (= collection-id :root) + (assoc collection/root-collection :namespace collection-namespace) + 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! [collection-namespace :- (s/maybe su/KeywordOrString) diff --git a/test/metabase/api/app_test.clj b/test/metabase/api/app_test.clj index 6bf76590b2f..10b3db9d3c9 100644 --- a/test/metabase/api/app_test.clj +++ b/test/metabase/api/app_test.clj @@ -2,7 +2,8 @@ (:require [clojure.test :refer [deftest is testing]] [medley.core :as m] - [metabase.models :refer [App Card Collection Dashboard]] + [metabase.models :refer [App Card Collection Dashboard Permissions]] + [metabase.models.collection.graph :as graph] [metabase.models.permissions :as perms] [metabase.models.permissions-group :as perms-group] [metabase.test :as mt] @@ -11,24 +12,32 @@ [toucan.hydrate :refer [hydrate]])) (deftest create-test - (mt/with-model-cleanup [Collection] + (mt/with-model-cleanup [Collection Permissions] (let [base-params {:name "App collection" :color "#123456"}] (mt/test-drivers (mt/normal-drivers-with-feature :actions/custom) (testing "parent_id is ignored when creating apps" - (mt/with-temp* [Collection [{collection-id :id}]] - (let [coll-params (assoc base-params :parent_id collection-id) - response (mt/user-http-request :crowberto :post 200 "app" {:collection coll-params})] - (is (pos-int? (:id response))) - (is (pos-int? (:collection_id response))) - (is (partial= (assoc base-params :location "/") - (:collection response)))))) + (mt/with-temporary-setting-values [all-users-app-permission :none] + (mt/with-temp* [Collection [{collection-id :id}]] + (let [coll-params (assoc base-params :parent_id collection-id) + response (mt/user-http-request :crowberto :post 200 "app" {:collection coll-params})] + (is (pos-int? (:id response))) + (is (pos-int? (:collection_id response))) + (is (partial= (assoc base-params :location "/") + (:collection response))) + (is (partial= {:groups {(:id (perms-group/all-users)) {(:collection_id response) :none}}} + (graph/graph)) + "''All Users'' should have the default permission on the app collection"))))) (testing "Create app in the root" - (let [response (mt/user-http-request :crowberto :post 200 "app" {:collection base-params})] - (is (pos-int? (:id response))) - (is (pos-int? (:collection_id response))) - (is (partial= (assoc base-params :location "/") - (:collection response))))) + (mt/with-temporary-setting-values [all-users-app-permission :read] + (let [response (mt/user-http-request :crowberto :post 200 "app" {:collection base-params})] + (is (pos-int? (:id response))) + (is (pos-int? (:collection_id response))) + (is (partial= (assoc base-params :location "/") + (:collection response))) + (is (partial= {:groups {(:id (perms-group/all-users)) {(:collection_id response) :read}}} + (graph/graph)) + "''All Users'' should have the default permission on the app collection")))) (testing "With initial dashboard and nav_items" (mt/with-temp Dashboard [{dashboard-id :id}] (let [nav_items [{:options {:click_behavior {}}}]] @@ -137,25 +146,29 @@ (mt/user-http-request :rasta :get 403 (str "app/" app-id))))))))) (deftest scaffold-test - (mt/with-model-cleanup [Card Dashboard Collection] + (mt/with-model-cleanup [Card Dashboard Collection Permissions] (testing "Golden path" - (let [app (mt/user-http-request - :crowberto :post 200 "app/scaffold" - {:table-ids [(data/id :venues)] - :app-name "My test app"}) - pages (m/index-by :name (hydrate (db/select Dashboard :collection_id (:collection_id app)) :ordered_cards)) - list-page (get pages "Venues List") - detail-page (get pages "Venues Detail")] - (is (partial= {:nav_items [{:page_id (:id list-page)} - {:page_id (:id detail-page) :hidden true :indent 1}] - :dashboard_id (:id list-page)} - app)) - (is (partial= {:ordered_cards [{:visualization_settings {:click_behavior - {:type "link", - :linkType "page", - :targetId (:id detail-page)}}} - {}]} - list-page)))) + (mt/with-temporary-setting-values [all-users-app-permission :read] + (let [app (mt/user-http-request + :crowberto :post 200 "app/scaffold" + {:table-ids [(data/id :venues)] + :app-name "My test app"}) + pages (m/index-by :name (hydrate (db/select Dashboard :collection_id (:collection_id app)) :ordered_cards)) + list-page (get pages "Venues List") + detail-page (get pages "Venues Detail")] + (is (partial= {:nav_items [{:page_id (:id list-page)} + {:page_id (:id detail-page) :hidden true :indent 1}] + :dashboard_id (:id list-page)} + app)) + (is (partial= {:ordered_cards [{:visualization_settings {:click_behavior + {:type "link", + :linkType "page", + :targetId (:id detail-page)}}} + {}]} + list-page)) + (is (partial= {:groups {(:id (perms-group/all-users)) {(:collection_id app) :read}}} + (graph/graph)) + "''All Users'' should have the default permission on the app collection")))) (testing "Bad or duplicate tables" (is (= (format "Some tables could not be found. Given: (%s %s) Found: (%s)" (data/id :venues) @@ -167,7 +180,7 @@ :app-name (str "My test app " (gensym))})))))) (deftest scaffold-app-test - (mt/with-model-cleanup [Card Dashboard Collection] + (mt/with-model-cleanup [Card Dashboard] (mt/with-temp* [Collection [{collection-id :id}] App [{app-id :id} {:collection_id collection-id}]] (testing "Without existing pages" @@ -204,3 +217,24 @@ :targetId (:id detail-page)}}} {}]} list-page))))))) + +(deftest global-graph-test + (mt/with-model-cleanup [Collection Permissions] + (let [base-params {:name "App collection" + :color "#123456"}] + (mt/test-drivers (mt/normal-drivers-with-feature :actions/custom) + (testing "changing default permission" + (mt/with-temp* [Collection [{collection-id :id}]] + (let [coll-params (assoc base-params :parent_id collection-id) + response1 (mt/user-http-request :crowberto :post 200 "app" {:collection coll-params}) + response2 (mt/user-http-request :crowberto :post 200 "app" {:collection base-params})] + (is (partial= {:groups {(:id (perms-group/all-users)) {(:collection_id response1) :none + (:collection_id response2) :none}}} + (graph/graph))) + (mt/user-http-request :crowberto :put 200 "app/global-graph" + (assoc-in (mt/user-http-request :crowberto :get 200 "app/global-graph") + [:groups (:id (perms-group/all-users))] + :write)) + (is (partial= {:groups {(:id (perms-group/all-users)) {(:collection_id response1) :write + (:collection_id response2) :write}}} + (graph/graph)))))))))) -- GitLab