From cf9b1bf50f2e025ef7a93f88d6a8e18cedfbba7c Mon Sep 17 00:00:00 2001 From: metamben <103100869+metamben@users.noreply.github.com> Date: Sat, 8 Oct 2022 01:05:49 +0300 Subject: [PATCH] Implement EE/Pro app permissions (#25764) * Implement per group and per db app permissions --- .../models/permissions/app_permissions.clj | 102 ++++++++++++++++++ .../permissions/app_permissions_test.clj | 71 ++++++++++++ src/metabase/api/app.clj | 51 ++++++++- src/metabase/models/app/graph.clj | 9 +- src/metabase/models/collection/graph.clj | 22 ++-- 5 files changed, 242 insertions(+), 13 deletions(-) create mode 100644 enterprise/backend/src/metabase_enterprise/advanced_permissions/models/permissions/app_permissions.clj create mode 100644 enterprise/backend/test/metabase_enterprise/advanced_permissions/models/permissions/app_permissions_test.clj diff --git a/enterprise/backend/src/metabase_enterprise/advanced_permissions/models/permissions/app_permissions.clj b/enterprise/backend/src/metabase_enterprise/advanced_permissions/models/permissions/app_permissions.clj new file mode 100644 index 00000000000..f590bb204e6 --- /dev/null +++ b/enterprise/backend/src/metabase_enterprise/advanced_permissions/models/permissions/app_permissions.clj @@ -0,0 +1,102 @@ +(ns metabase-enterprise.advanced-permissions.models.permissions.app-permissions + "Implements granular app permissions by reading and writing the permissions + on the app collections." + (:require [clojure.data :as data] + [clojure.set :as set] + [clojure.tools.logging :as log] + [metabase.models.app.graph :as app.graph] + [metabase.models.collection-permission-graph-revision :as c-perm-revision + :refer [CollectionPermissionGraphRevision]] + [metabase.models.collection.graph :as graph] + [metabase.models.permissions :as perms] + [metabase.public-settings.premium-features :as premium-features] + [metabase.util.i18n :as i18n] + [metabase.util.schema :as su] + [schema.core :as s] + [toucan.db :as db])) + +(def ^:private GroupPermissionsGraph + "collection-id -> status" + {su/IntGreaterThanZero app.graph/AppPermissions}) ; be present, which is why it's *optional* + +(def ^:private PermissionsGraph + {:revision s/Int + :groups {su/IntGreaterThanZero GroupPermissionsGraph}}) + +(defn- check-advanced-permissions [] + (log/fatal "checking advanced permsissions") + (when-not (premium-features/has-feature? :advanced-permissions) + (throw (ex-info (i18n/tru "The granular app permission functionality is only enabled if you have a premium token with the advanced-permissions feature.") + {:status-code 402})))) + +(defn- replace-collection-ids + "Convert the collection group permission graph `g` into an app group + permission graph or vice versa by removing the root collection and replacing + the collection IDs by app IDs according to `mapping`. + + Note that the app permission graph never contains the root collection. So it's + removed when converting a collection graph to an app graph and plays no role + when converting an app graph to a collection graph." + [g mapping] + (update-vals g (fn [group-permissions] + (-> group-permissions + (dissoc :root) + (update-keys mapping))))) + +(defn- merge-graphs + [base override] + (merge-with merge base override)) + +(s/defn graph :- PermissionsGraph + "Returns the app permission graph. Throws an exception if the + advanced-permissions feature is not enabled." + [] + (check-advanced-permissions) + (db/transaction + (let [collection-id->app-id (set/map-invert (db/select-id->field :collection_id 'App))] + (-> collection-id->app-id + keys + graph/collection-permission-graph + (update :groups replace-collection-ids collection-id->app-id))))) + +(defn update-collection-graph! + "Update the Collections permissions graph for Collections of `collection-namespace` (default `nil`, the 'default' + namespace). This works just like [[metabase.models.permission/update-data-perms-graph!]], but for Collections; + refer to that function's extensive documentation to get a sense for how this works." + [old-graph new-graph] + (let [old-perms (:groups old-graph) + new-perms (:groups new-graph) + ;; filter out any groups not in the old graph + new-perms (select-keys new-perms (keys old-perms)) + ;; filter out any collections not in the old graph + new-perms (into {} (for [[group-id collection-id->perms] new-perms] + [group-id (select-keys collection-id->perms (keys (get old-perms group-id)))])) + [diff-old changes] (data/diff old-perms new-perms)] + (perms/log-permissions-changes diff-old changes) + (perms/check-revision-numbers old-graph new-graph) + (when (seq changes) + (db/transaction + (doseq [[group-id new-group-perms] changes + [collection-id new-perms] new-group-perms] + (graph/update-collection-permissions! nil group-id collection-id new-perms)) + (perms/save-perms-revision! CollectionPermissionGraphRevision (:revision old-graph) + (assoc old-graph :namespace nil) changes))))) + +(s/defn update-graph! :- PermissionsGraph + "Updates the app permissions according to `new-graph` and returns the + resulting graph as read from the database. Throws an exception if the + advanced-permissions feature is not enabled." + [new-graph :- PermissionsGraph] + (check-advanced-permissions) + (db/transaction + (let [{:keys [revision groups]} new-graph + app-id->collection-id (db/select-id->field :collection_id 'App) + collection-ids (vals app-id->collection-id) + old-graph (graph/graph) + new-graph (-> old-graph + (assoc :revision revision) + (update :groups update-vals (fn [group-permissions] + (apply dissoc group-permissions collection-ids))) + (update :groups merge-graphs (replace-collection-ids groups app-id->collection-id)))] + (update-collection-graph! old-graph new-graph)) + (graph))) diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/permissions/app_permissions_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/permissions/app_permissions_test.clj new file mode 100644 index 00000000000..10413ff5fda --- /dev/null +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/permissions/app_permissions_test.clj @@ -0,0 +1,71 @@ +(ns metabase-enterprise.advanced-permissions.models.permissions.app-permissions-test + "Tests for /api/collection endpoints." + (:require [clojure.test :refer :all] + [metabase.models :refer [App Collection PermissionsGroup]] + [metabase.models.permissions-group :as perms-group] + [metabase.public-settings.premium-features-test :as premium-features-test] + [metabase.test :as mt])) + +(deftest graph-test + (testing "GET /api/app/graph works only with advanced permissions" + (premium-features-test/with-premium-features #{} + (is (= "The granular app permission functionality is only enabled if you have a premium token with the advanced-permissions feature." + (mt/user-http-request :crowberto :get 402 "app/graph"))))) + + (premium-features-test/with-premium-features #{:advanced-permissions} + (mt/with-temp* [Collection [{app-coll-id :id} {:location "/"}] + App [{app-id :id} {:collection_id app-coll-id}] + PermissionsGroup [{group-id :id}]] + (testing "GET /api/app/graph\n" + (testing "Should be able to fetch the permissions graph for apps" + (is (partial= {(:id (perms-group/admin)) {app-id "write"} + (:id (perms-group/all-users)) {app-id "write"} + group-id {app-id "none"}} + (:groups (mt/user-http-request :crowberto :get 200 "app/graph"))))) + + (testing "have to be a superuser" + (is (= "You don't have permissions to do that." + (mt/user-http-request :rasta :get 403 "app/graph")))))) + + (mt/with-non-admin-groups-no-root-collection-perms + (mt/with-temp* [Collection [_ {:location "/"}] + Collection [{app-coll-id :id} {:location "/"}] + App [{app-id :id} {:collection_id app-coll-id}] + PermissionsGroup [{group-id :id}]] + (testing "All users' right to root collection is respected" + (let [group-perms (:groups (mt/user-http-request :crowberto :get 200 "app/graph"))] + (is (partial= {(:id (perms-group/admin)) {app-id "write"} + (:id (perms-group/all-users)) {app-id "none"} + group-id {app-id "none"}} + group-perms)) + (is (= #{app-id} (into #{} (mapcat keys) (vals group-perms))) + "Shouldn't confuse collection IDs and app IDs"))))))) + +(deftest graph-update-test + (testing "PUT /api/app/graph works only with advanced permissions" + (premium-features-test/with-premium-features #{} + (is (= "The granular app permission functionality is only enabled if you have a premium token with the advanced-permissions feature." + (mt/user-http-request :crowberto :put 402 "app/graph" {:revision 0 + :groups {1 {1 "write"}}}))))) + + (premium-features-test/with-premium-features #{:advanced-permissions} + (mt/with-temp* [Collection [{app-coll-id :id} {:location "/"}] + App [{app-id :id} {:collection_id app-coll-id}] + PermissionsGroup [{group-id :id}]] + (testing "PUT /api/app/graph\n" + (testing "Should be able to update the permissions graph for apps" + (let [initial-graph (mt/user-http-request :crowberto :get 200 "app/graph") + updated-graph (assoc-in initial-graph [:groups group-id app-id] "read")] + (is (partial= {(:id (perms-group/admin)) {app-id "write"} + (:id (perms-group/all-users)) {app-id "write"} + group-id {app-id "none"}} + (:groups initial-graph)) + "Unexpected initial state") + + (testing "have to be a superuser" + (is (= "You don't have permissions to do that." + (mt/user-http-request :rasta :put 403 "app/graph" updated-graph)))) + + (testing "superuser can update" + (is (= (:groups updated-graph) + (:groups (mt/user-http-request :crowberto :put 200 "app/graph" updated-graph))))))))))) diff --git a/src/metabase/api/app.clj b/src/metabase/api/app.clj index 26a3f86702c..2a8557235fe 100644 --- a/src/metabase/api/app.clj +++ b/src/metabase/api/app.clj @@ -11,6 +11,8 @@ [metabase.models.app.graph :as app.graph] [metabase.models.collection :as collection] [metabase.models.dashboard :as dashboard] + [metabase.plugins.classloader :as classloader] + [metabase.util :as u] [metabase.util.i18n :as i18n] [metabase.util.schema :as su] [schema.core :as s] @@ -279,20 +281,52 @@ (api/check-superuser) (app.graph/global-graph)) +(defn- resolve-advanced-app-permission-function + "Resolve `fn-name` in the advanced app permission namespace. `fn-name` can be + a string or an instance of clojure.lang.Named. + Throws an exception if `fn-name` cannot be resolved." + [fn-name] + (or (u/ignore-exceptions + (classloader/require 'metabase-enterprise.advanced-permissions.models.permissions.app-permissions) + (resolve (symbol "metabase-enterprise.advanced-permissions.models.permissions.app-permissions" + (name fn-name)))) + (ex-info + (i18n/tru "The granular app permission functionality is only enabled if you have a premium token with the advanced-permissions feature.") + {:status-code 402}))) + +(api/defendpoint GET "/graph" + "Fetch the graph of all App Permissions." + [] + (api/check-superuser) + (let [graph (resolve-advanced-app-permission-function 'graph)] + (graph))) + (defn- ->int [id] (Integer/parseInt (name id))) -(defn- dejsonify-id->permission-map [m] +(defn- dejsonify-with [f m] (into {} (map (fn [[k v]] - [(->int k) (keyword v)])) + [(->int k) (f v)])) m)) +(defn- dejsonify-id->permission-map [m] + (dejsonify-with keyword m)) + +(defn- dejsonify-groups-map [m] + (dejsonify-with dejsonify-id->permission-map 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)) +(defn- dejsonify-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-groups-map)) + (api/defendpoint PUT "/global-graph" "Do a batch update of the global App Permissions by passing in a modified graph." [:as {body :body}] @@ -300,7 +334,16 @@ (api/check-superuser) (-> body dejsonify-global-graph - app.graph/update-global-graph!) - (app.graph/global-graph)) + app.graph/update-global-graph!)) + +(api/defendpoint PUT "/graph" + "Do a batch update of the advanced App Permissions by passing in a modified graph." + [:as {body :body}] + {body su/Map} + (api/check-superuser) + (let [update-graph! (resolve-advanced-app-permission-function 'update-graph!)] + (-> body + dejsonify-graph + update-graph!))) (api/define-routes) diff --git a/src/metabase/models/app/graph.clj b/src/metabase/models/app/graph.clj index 3f4d3728470..0cabeeccc96 100644 --- a/src/metabase/models/app/graph.clj +++ b/src/metabase/models/app/graph.clj @@ -14,7 +14,9 @@ [schema.core :as s] [toucan.db :as db])) -(def ^:private AppPermissions +(def AppPermissions + "The valid app permissions. Currently corresponds 1:1 to `CollectionPermissions` + since app permissions are implemented in terms of collection permissions." graph/CollectionPermissions) (def ^:private GlobalPermissionsGraph @@ -51,7 +53,7 @@ (s/defn update-global-graph! "Update the global app permission graph to the state specified by - `new-graph`." + `new-graph`. Returns the new graph as read from the updated database." [new-graph :- GlobalPermissionsGraph] (let [old-graph (global-graph) [diff-old changes] (data/diff (:groups old-graph) (:groups new-graph))] @@ -69,4 +71,5 @@ (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)))))) + CollectionPermissionGraphRevision (:revision old-graph) old-graph changes)) + (global-graph))))) diff --git a/src/metabase/models/collection/graph.clj b/src/metabase/models/collection/graph.clj index 32097886363..777fa69e590 100644 --- a/src/metabase/models/collection/graph.clj +++ b/src/metabase/models/collection/graph.clj @@ -69,6 +69,17 @@ [:not [:like :location (hx/literal (format "/%d/%%" collection-id))]]))}] (set (map :id (db/query honeysql-form))))) +(defn collection-permission-graph + "Return the permission graph for the collections with id in `collection-ids` and the root collection." + ([collection-ids] (collection-permission-graph collection-ids nil)) + ([collection-ids collection-namespace] + (let [group-id->perms (group-id->permissions-set)] + {:revision (c-perm-revision/latest-id) + :groups (into {} (for [group-id (db/select-ids PermissionsGroup)] + {group-id (group-permissions-graph collection-namespace + (group-id->perms group-id) + collection-ids)}))}))) + (s/defn graph :- PermissionsGraph "Fetch a graph representing the current permissions status for every group and all permissioned collections. This works just like the function of the same name in `metabase.models.permissions`; see also the documentation for that @@ -86,11 +97,10 @@ (graph nil)) ([collection-namespace :- (s/maybe su/KeywordOrString)] - (let [group-id->perms (group-id->permissions-set) - collection-ids (non-personal-collection-ids collection-namespace)] - {:revision (c-perm-revision/latest-id) - :groups (into {} (for [group-id (db/select-ids PermissionsGroup)] - {group-id (group-permissions-graph collection-namespace (group-id->perms group-id) collection-ids)}))}))) + (db/transaction + (-> collection-namespace + non-personal-collection-ids + (collection-permission-graph collection-namespace))))) ;;; -------------------------------------------------- Update Graph -------------------------------------------------- @@ -152,8 +162,8 @@ [diff-old changes] (data/diff old-perms new-perms)] (perms/log-permissions-changes diff-old changes) (perms/check-revision-numbers old-graph new-graph) - (check-no-app-collections changes) (when (seq changes) + (check-no-app-collections changes) (db/transaction (doseq [[group-id changes] changes] (update-group-permissions! collection-namespace group-id changes)) -- GitLab