Skip to content
Snippets Groups Projects
Unverified Commit f85ccd77 authored by Noah Moss's avatar Noah Moss Committed by GitHub
Browse files

Connection impersonation (#30714)

* initial prototype w/out statement count parameter

* new approach

* default-database-role driver method

* migration for connection_impersonations table

* conn impersonation model, API and tests

* impersonation fetch and deletion endpoints

* switch test to t2 with-temp

* read conn impersonation settings from DB

* fix merge issue

* add impersonated key to data perms graph and treat it the same as full self-service access

* include impersonated key in returned permissions graph

* make sure impersonated graph passes StrictDataPerms validation

* fix boolean logic

* make sure impersonated keyword doesnt cause error when deleting gtaps

* clear impersonations as necessary when perms graphs changes

* add impersonation support for postgres

* fix typo

* make sure impersonation updates are a non-lazy seq

* add impersonated-user? fn

* fix impersonation api tests

* fix snippet tests

* fix build & exclude connection impersonations from serialization

* switch a test to use t2.with-temp

* add with-impersonations helper and util tests

* move macro and add a connection impersonation driver-level test for postgres

* fix rebase issue

* more tests and code reorganization

* add snowflake test

* clarify comment

* fix lint errors

* fix final kondo error

* reorganization

* fix one test

* fix lint errors

* revert change to sql_jdbc.execute from bad merge

* make sure perms for all users gets reset after conn impersonation tests

* ignore exceptions when restoring perms

* fix postgres test

* refactor to address bryan's comment

* add note about new methods to database changelog

* driver method refactor
parent 79c592b4
No related branches found
No related tags found
No related merge requests found
Showing
with 565 additions and 28 deletions
......@@ -30,6 +30,11 @@ title: Driver interface changelog
more information. You should use `metabase.driver.sql-jdbc.execute/do-with-connection-with-options` instead of
`clojure.java.jdbc/with-db-connection` or `clojure.java.jdbc/get-connection` going forward.
- The multimethods `set-role!`, `set-role-statement`, and `default-database-role` have been added. These methods are
used to enable connection impersonation, which is a new feature added in 0.47.0. Connection impersonation allows users
to be assigned to specific database roles which are set before any queries are executed, so that access to tables can
be restricted at the database level instead of (or in conjunction with) Metabase's built-in permissions system.
## Metabase 0.46.0
- The process for building a driver has changed slightly in Metabase 0.46.0. Your build command should now look
......
(ns metabase-enterprise.advanced-permissions.api.impersonation
(:require
[compojure.core :refer [GET]]
[metabase.api.common :as api]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
(api/defendpoint GET "/"
"Fetch a list of all Impersonation policies currently in effect, or a single policy if both `group_id` and `db_id`
are provided."
[group_id db_id]
{group_id [:maybe ms/PositiveInt]
db_id [:maybe ms/PositiveInt]}
(api/check-superuser)
(if (and group_id db_id)
(t2/select-one :model/ConnectionImpersonation :group_id group_id :db_id db_id)
(t2/select :model/ConnectionImpersonation {:order-by [[:id :asc]]})))
(api/defendpoint DELETE "/:id"
"Delete a Connection Impersonation entry."
[id]
{id ms/PositiveInt}
(api/check-superuser)
(api/check-404 (t2/select-one :model/ConnectionImpersonation :id id))
(t2/delete! :model/ConnectionImpersonation :id id)
api/generic-204-no-content)
(api/define-routes)
(ns metabase-enterprise.advanced-permissions.api.routes
(:require
[compojure.core :as compojure]
[metabase-enterprise.advanced-permissions.api.application :as application]
[metabase-enterprise.advanced-permissions.api.application
:as application]
[metabase-enterprise.advanced-permissions.api.impersonation
:as impersonation]
[metabase.api.routes.common :refer [+auth]]))
(compojure/defroutes ^{:doc "Ring routes for advanced permissions API endpoints."} routes
(compojure/context "/application" [] (+auth application/routes)))
(compojure/context "/application" [] (+auth application/routes))
(compojure/context "/impersonation" [] (+auth impersonation/routes)))
(ns metabase-enterprise.advanced-permissions.api.util
(:require
[metabase.api.common :refer [*current-user-id* *is-superuser?*]]
[metabase.models.permissions-group-membership
:refer [PermissionsGroupMembership]]
[metabase.public-settings.premium-features :refer [defenterprise]]
[metabase.util.i18n :refer [tru]]
[toucan2.core :as t2]))
(defenterprise impersonated-user?
"Returns a boolean if the current user uses connection impersonation for any database. Will throw an error if
[[api/*current-user-id*]] is not bound."
:feature :advanced-permissions
[]
(boolean
(when-not *is-superuser?*
(if *current-user-id*
(let [group-ids (t2/select-fn-set :group_id PermissionsGroupMembership :user_id *current-user-id*)]
(seq
(when (seq group-ids)
(t2/select :model/ConnectionImpersonation :group_id [:in group-ids]))))
;; If no *current-user-id* is bound we can't check for impersonations, so we should throw in this case to avoid
;; returning `false` for users who should actually be using impersonation.
(throw (ex-info (str (tru "No current user found"))
{:status-code 403}))))))
(ns metabase-enterprise.advanced-permissions.driver.impersonation
(:require
[metabase.api.common :as api]
[metabase.driver :as driver]
[metabase.driver.sql :as driver.sql]
[metabase.models.permissions-group-membership
:refer [PermissionsGroupMembership]]
[metabase.public-settings.premium-features :refer [defenterprise]]
[metabase.util :as u]
[metabase.util.i18n :refer [tru]]
[metabase.util.log :as log]
[toucan2.core :as t2])
(:import
(java.sql Connection)))
(set! *warn-on-reflection* true)
(defn- connection-impersonation-role
"Fetches the database role that should be used for the current user, if connection impersonation is in effect.
Returns `nil` if connection impersonation should not be used for the current user. Throws an exception if multiple
conflicting connection impersonation policies are found."
[database]
(when-not api/*is-superuser?*
(let [group-ids (t2/select-fn-set :group_id PermissionsGroupMembership :user_id api/*current-user-id*)
conn-impersonations (when (seq group-ids)
(t2/select :model/ConnectionImpersonation
:group_id [:in group-ids]
:db_id (u/the-id database)))
role-attributes (set (map :attribute conn-impersonations))]
(when (> (count role-attributes) 1)
(throw (ex-info (tru "Multiple conflicting connection impersonation policies found for current user")
{:user-id api/*current-user-id*
:conn-impersonations conn-impersonations})))
(when (not-empty role-attributes)
(let [conn-impersonation (first conn-impersonations)
role-attribute (:attribute conn-impersonation)
user-attributes (:login_attributes @api/*current-user*)]
(get user-attributes role-attribute))))))
(defenterprise set-role-if-supported!
"Executes a `USE ROLE` or similar statement on the given connection, if connection impersonation is enabled for the
given driver. For these drivers, the role is set to either the default role, or to a specific role configured for
the current user, depending on the connection impersonation settings. This is a no-op for databases that do not
support connection impersonation, or for non-EE instances."
:feature :advanced-permissions
[driver ^Connection conn database]
(when (driver/database-supports? driver :connection-impersonation database)
(try
(let [default-role (driver.sql/default-database-role driver database)
impersonation-role (connection-impersonation-role database)]
(driver/set-role! driver conn (or impersonation-role default-role)))
(catch Throwable e
(log/debug e (tru "Error setting role on connection"))
(throw e)))))
(ns metabase-enterprise.advanced-permissions.models.connection-impersonation
"Model definition for Connection Impersonations, which are used to define specific database roles used by users in
certain permission groups when running queries."
(:require
[medley.core :as m]
[metabase.models.interface :as mi]
[metabase.public-settings.premium-features :refer [defenterprise]]
[metabase.util.log :as log]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(doto :model/ConnectionImpersonation
(derive :metabase/model)
;; Only admins can work with Connection Impersonation configs
(derive ::mi/read-policy.superuser)
(derive ::mi/write-policy.superuser))
(methodical/defmethod t2/table-name :model/ConnectionImpersonation [_model] :connection_impersonations)
(defenterprise add-impersonations-to-permissions-graph
"Augment a provided permissions graph with active connection impersonation policies."
:feature :advanced-permissions
[graph]
(m/deep-merge
graph
(let [impersonations (t2/select :model/ConnectionImpersonation)]
(reduce (fn [acc {:keys [db_id group_id]}]
(assoc-in acc [group_id db_id] {:data {:schemas :impersonated}}))
{}
impersonations))))
(defenterprise upsert-impersonations!
"Create new Connection Impersonation records or update existing ones, if they have an `:id`."
:feature :advanced-permissions
[impersonations]
(doall
(for [impersonation impersonations]
(if-let [id (:id impersonation)]
(t2/update! :model/ConnectionImpersonation id impersonation)
(-> (t2/insert-returning-instances! :model/ConnectionImpersonation impersonation)
first)))))
(defn- delete-impersonations-for-group-database! [{:keys [group-id database-id]} changes]
(log/debugf "Deleting unneeded Connection Impersonations for Group %d for Database %d. Graph changes: %s"
group-id database-id (pr-str changes))
(when (not= :impersonated changes)
(log/debugf "Group %d %s for Database %d, deleting all Connection Impersonations for this DB"
group-id
(case changes
:none "no longer has any perms"
:all "now has full data perms"
:block "is now BLOCKED from all non-data-perms access")
database-id)
(t2/delete! :model/ConnectionImpersonation :group_id group-id :db_id database-id)))
(defn- delete-impersonations-for-group! [{:keys [group-id]} changes]
(log/debugf "Deleting unneeded Connection Impersonation policies for Group %d. Graph changes: %s" group-id (pr-str changes))
(doseq [database-id (set (keys changes))]
(delete-impersonations-for-group-database!
{:group-id group-id, :database-id database-id}
(get-in changes [database-id :data :schemas]))))
(defenterprise delete-impersonations-if-needed-after-permissions-change!
"For use only inside `metabase.models.permissions`; don't call this elsewhere. Delete Connection Impersonations that
are no longer needed after the permissions graph is updated. `changes` are the parts of the graph that have changed,
i.e. the `things-only-in-new` returned by `clojure.data/diff`."
:feature :advanced-permissions
[changes]
(log/debug "Permissions updated, deleting unneeded Connection Impersonations...")
(doseq [group-id (set (keys changes))]
(delete-impersonations-for-group! {:group-id group-id} (get changes group-id)))
(log/debug "Done deleting unneeded Connection Impersonations."))
(ns metabase-enterprise.enhancements.models.native-query-snippet.permissions
"EE implementation of NativeQuerySnippet permissions."
(:require
[metabase-enterprise.sandbox.api.util :as mt.api.u]
[metabase.models.interface :as mi]
[metabase.models.native-query-snippet.permissions :as snippet.perms]
[metabase.models.permissions :as perms]
[metabase.public-settings.premium-features :refer [defenterprise]]
[metabase.public-settings.premium-features
:as premium-features
:refer [defenterprise]]
[metabase.util.schema :as su]
[schema.core :as s]
[toucan2.core :as t2]))
......@@ -20,7 +21,7 @@
:feature :any
([snippet]
(and
(not (mt.api.u/segmented-user?))
(not (premium-features/sandboxed-user?))
(snippet.perms/has-any-native-permissions?)
(has-parent-collection-perms? snippet :read)))
([model id]
......@@ -31,7 +32,7 @@
:feature :any
([snippet]
(and
(not (mt.api.u/segmented-user?))
(not (premium-features/sandboxed-user?))
(snippet.perms/has-any-native-permissions?)
(has-parent-collection-perms? snippet :write)))
([model id]
......@@ -42,7 +43,7 @@
:feature :any
[_model m]
(and
(not (mt.api.u/segmented-user?))
(not (premium-features/sandboxed-user?))
(snippet.perms/has-any-native-permissions?)
(has-parent-collection-perms? m :write)))
......@@ -51,7 +52,7 @@
:feature :any
[snippet changes]
(and
(not (mt.api.u/segmented-user?))
(not (premium-features/sandboxed-user?))
(snippet.perms/has-any-native-permissions?)
(has-parent-collection-perms? snippet :write)
(or (not (contains? changes :collection_id))
......
......@@ -35,7 +35,7 @@
(filter (partial enforce-sandbox? group-id->perms-set)
sandboxes)))
(defenterprise segmented-user?
(defenterprise sandboxed-user?
"Returns true if the currently logged in user has segmented permissions. Throws an exception if no current user
is bound."
:feature :sandboxes
......
......@@ -90,7 +90,7 @@
(defn- delete-gtaps-for-group-database! [{:keys [group-id database-id], :as context} changes]
(log/debugf "Deleting unneeded GTAPs for Group %d for Database %d. Graph changes: %s"
group-id database-id (pr-str changes))
(if (#{:none :all :block} changes)
(if (#{:none :all :block :impersonated} changes)
(do
(log/debugf "Group %d %s for Database %d, deleting all GTAPs for this DB"
group-id
......
(ns metabase-enterprise.advanced-permissions.api.impersonation-test
"Tests for creating and updating Connection Impersonation configs via the permisisons API"
(:require
[clojure.test :refer :all]
[metabase.models :refer [PermissionsGroup]]
[metabase.models.permissions :as perms]
[metabase.public-settings.premium-features-test
:as premium-features-test]
[metabase.test :as mt]
[metabase.util :as u]
[toucan2.core :as t2]
[toucan2.tools.with-temp :as t2.with-temp]))
(deftest create-impersonation-policy-test
(testing "/api/permissions/graph"
(premium-features-test/with-premium-features #{:advanced-permissions}
(testing "A connection impersonation policy can be created via the permissions graph endpoint"
(mt/with-user-in-groups
[group {:name "New Group"}
_ [group]]
(let [impersonation {:group_id (u/the-id group)
:db_id (mt/id)
:attribute "Attribute Name"}
graph (assoc (perms/data-perms-graph) :impersonations [impersonation])
result (mt/user-http-request :crowberto :put 200 "permissions/graph" graph)]
(is (= [(assoc impersonation :id (-> result :impersonations first :id))]
(t2/select :model/ConnectionImpersonation :group_id (u/the-id group)))))
(testing "A connection impersonation policy can be updated via the permissions graph endpoint"
(let [impersonation (-> (t2/select :model/ConnectionImpersonation
:group_id (u/the-id group))
first
(assoc :attribute "New Attribute Name"))
graph (assoc (perms/data-perms-graph) :impersonations [impersonation])]
(mt/user-http-request :crowberto :put 200 "permissions/graph" graph)
(is (= [impersonation]
(t2/select :model/ConnectionImpersonation
:group_id (u/the-id group)))))))))))
(deftest fetch-impersonation-policy-test
(testing "GET /api/ee/advanced-permissions/impersonation"
(t2.with-temp/with-temp [PermissionsGroup {group-id-1 :id} {}
PermissionsGroup {group-id-2 :id} {}
:model/ConnectionImpersonation {impersonation-id-1 :id :as impersonation-1} {:group_id group-id-1
:db_id (mt/id)
:attribute "Attribute Name 1"}
:model/ConnectionImpersonation {impersonation-id-2 :id :as impersonation-2} {:group_id group-id-2
:db_id (mt/id)
:attribute "Attribute Name 2"}]
(premium-features-test/with-premium-features #{:advanced-permissions}
(testing "Test that we can fetch a list of all Connection Impersonations"
(is (= [impersonation-1 impersonation-2]
(filter
#(#{impersonation-id-1 impersonation-id-2} (:id %))
(mt/user-http-request :crowberto :get 200 "ee/advanced-permissions/impersonation")))))
(testing "Test that we can fetch the Connection Impersonation for a specific DB and group"
(is (= impersonation-1
(mt/user-http-request :crowberto :get 200 "ee/advanced-permissions/impersonation"
:group_id group-id-1 :db_id (mt/id)))))
(testing "Test that a non-admin cannot fetch Connection Impersonation details"
(mt/user-http-request :rasta :get 403 "ee/advanced-permissions/impersonation")))
(testing "Test that the :advanced-permissions flag is required to fetch Connection Impersonation Details"
(premium-features-test/with-premium-features #{}
(mt/user-http-request :crowberto :get 402 "ee/advanced-permissions/impersonation"))))))
(deftest delete-impersonation-policy
(testing "DELETE /api/ee/advanced-permissions/impersonation"
(premium-features-test/with-premium-features #{:advanced-permissions}
(testing "Test that a Connection Impersonation can be deleted by ID"
(t2.with-temp/with-temp [PermissionsGroup {group-id :id} {}
:model/ConnectionImpersonation {impersonation-id :id} {:group_id group-id
:db_id (mt/id)
:attribute "Attribute Name"}]
(mt/user-http-request :crowberto :delete 204 (format "ee/advanced-permissions/impersonation/%d" impersonation-id))
(is (nil? (t2/select-one :model/ConnectionImpersonation :id impersonation-id)))))
(testing "Test that a non-admin cannot delete a Connection Impersonation"
(t2.with-temp/with-temp [PermissionsGroup {group-id :id} {}
:model/ConnectionImpersonation {impersonation-id :id :as impersonation}
{:group_id group-id
:db_id (mt/id)
:attribute "Attribute Name"}]
(mt/user-http-request :rasta :delete 403 (format "ee/advanced-permissions/impersonation/%d" impersonation-id))
(is (= impersonation (t2/select-one :model/ConnectionImpersonation :id impersonation-id))))))
(testing "Test that the :advanced-permissions flag is required to delete a Connection Impersonation"
(premium-features-test/with-premium-features #{}
(t2.with-temp/with-temp [PermissionsGroup {group-id :id} {}
:model/ConnectionImpersonation {impersonation-id :id :as impersonation}
{:group_id group-id
:db_id (mt/id)
:attribute "Attribute Name"}]
(mt/user-http-request :crowberto :get 402 "ee/advanced-permissions/impersonation")
(is (= impersonation (t2/select-one :model/ConnectionImpersonation :id impersonation-id))))))))
(ns metabase-enterprise.advanced-permissions.api.util-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.advanced-permissions.api.util
:as advanced-perms.api.u]
[metabase-enterprise.sandbox.test-util :as met]
[metabase.api.common :as api]
[metabase.models.permissions :as perms]
[metabase.models.permissions-group :as perms-group]
[metabase.public-settings.premium-features-test
:as premium-features-test]
[metabase.server.middleware.session :as mw.session]
[metabase.test :as mt]
[metabase.test.data :as data]
[metabase.test.data.users :as test.users]
[metabase.util :as u]
[toucan2.tools.with-temp :as t2.with-temp]))
(defn- do-with-conn-impersonation-defs
{:style/indent 2}
[group [{:keys [db-id attribute] :as impersonation-def} & more] f]
(if-not impersonation-def
(f)
(t2.with-temp/with-temp [:model/ConnectionImpersonation _ {:db_id db-id
:group_id (u/the-id group)
:attribute attribute}]
(do-with-conn-impersonation-defs group more f))))
(defn do-with-impersonations-for-user [args test-user-name-or-user-id f]
(letfn [(thunk []
;; remove perms for All Users group
(perms/revoke-data-perms! (perms-group/all-users) (data/db))
;; create new perms group
(test.users/with-group-for-user [group test-user-name-or-user-id]
;; grant native access to the group
(perms/update-data-perms-graph! [(u/the-id group) (data/id) :data :native] :write)
(let [{:keys [impersonations attributes]} args]
;; set user login_attributes
(met/with-user-attributes test-user-name-or-user-id attributes
(do-with-conn-impersonation-defs group impersonations
(fn []
;; bind user as current user, then run f
(if (keyword? test-user-name-or-user-id)
(test.users/with-test-user test-user-name-or-user-id
(f group))
(mw.session/with-current-user (u/the-id test-user-name-or-user-id)
(f group))))))))
;; re-grant perms for All Users group
(u/ignore-exceptions
(perms/grant-full-data-permissions! (perms-group/all-users) (data/db))))]
(thunk)))
(defmacro with-impersonations-for-user
"Like `with-impersonations`, but for an arbitrary User. `test-user-name-or-user-id` can be a predefined test user e.g.
`:rasta` or an arbitrary User ID."
[test-user-name-or-user-id impersonations-and-attributes-map & body]
`(do-with-impersonations-for-user ~impersonations-and-attributes-map
~test-user-name-or-user-id
(fn [~'&group] ~@body)))
(defmacro with-impersonations
"Execute `body` with `impersonations` and optionally user `attributes` in effect for the :rasta test user, for the
current test database. All underlying objects and permissions are created automatically.
Introduces `&group` anaphor, bound to the PermissionsGroup associated with this Connection Impersonation policy."
{:style/indent :defn}
[impersonations-and-attributes-map & body]
`(do-with-impersonations-for-user ~impersonations-and-attributes-map :rasta (fn [~'&group] ~@body)))
(deftest impersonated-user-test
(premium-features-test/with-premium-features #{:advanced-permissions}
(testing "Returns true when a user has an active connection impersonation policy"
(with-impersonations {:impersonations [{:db-id (mt/id) :attribute "KEY"}]
:attributes {"KEY" "VAL"}}
(is (advanced-perms.api.u/impersonated-user?))))
(testing "Returns true if current user is a superuser, even if they are in a group with an impersonation policy in place"
(with-impersonations-for-user :crowberto {:impersonations [{:db-id (mt/id) :attribute "KEY"}]
:attributes {"KEY" "VAL"}}
(is (not (advanced-perms.api.u/impersonated-user?)))))
(testing "An exception is thrown if no user is bound"
(binding [api/*current-user-id* nil]
(is (thrown-with-msg? clojure.lang.ExceptionInfo
#"No current user found"
(advanced-perms.api.u/impersonated-user?)))))))
(ns metabase-enterprise.advanced-permissions.driver.impersonation-test
(:require
[clojure.java.jdbc :as jdbc]
[clojure.test :refer :all]
[metabase-enterprise.advanced-permissions.api.util-test
:as advanced-perms.api.tu]
[metabase-enterprise.advanced-permissions.driver.impersonation
:as impersonation]
[metabase.driver.postgres-test :as postgres-test]
[metabase.driver.sql-jdbc.connection :as sql-jdbc.conn]
[metabase.models.database :refer [Database]]
[metabase.public-settings.premium-features-test
:as premium-features-test]
[metabase.server.middleware.session :as mw.session]
[metabase.sync :as sync]
[metabase.test :as mt]
[toucan2.tools.with-temp :as t2.with-temp]))
(deftest connection-impersonation-role-test
(testing "Returns nil when no impersonations are in effect"
(is (nil? (@#'impersonation/connection-impersonation-role (mt/db)))))
(testing "Correctly fetches the impersonation when one is in effect"
(advanced-perms.api.tu/with-impersonations {:impersonations [{:db-id (mt/id) :attribute "impersonation_attr"}]
:attributes {"impersonation_attr" "impersonation_role"}}
(is (= "impersonation_role"
(@#'impersonation/connection-impersonation-role (mt/db))))))
(testing "Throws exception if multiple conflicting impersonations are in effect"
;; Use nested `with-impersonations` macros so that different groups are used
(advanced-perms.api.tu/with-impersonations {:impersonations [{:db-id (mt/id) :attribute "impersonation_attr_1"}]
:attributes {"impersonation_attr_1" "impersonation_role_1"}}
(advanced-perms.api.tu/with-impersonations {:impersonations [{:db-id (mt/id) :attribute "impersonation_attr_2"}]
:attributes {"impersonation_attr_2" "impersonation_role_2"}}
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Multiple conflicting connection impersonation policies found for current user"
(@#'impersonation/connection-impersonation-role (mt/db)))))))
(testing "Returns nil for superuser, even if they are in a group with an impersonation policy defined"
(advanced-perms.api.tu/with-impersonations {:impersonations [{:db-id (mt/id) :attribute "impersonation_attr"}]
:attributes {"impersonation_attr" "impersonation_role"}}
(is (= "impersonation_role"
(@#'impersonation/connection-impersonation-role (mt/db)))))))
(deftest conn-impersonation-test-postgres
(mt/test-driver :postgres
(premium-features-test/with-premium-features #{:advanced-permissions}
(let [db-name "conn_impersonation_test"
details (mt/dbdef->connection-details :postgres :db {:database-name db-name})
spec (sql-jdbc.conn/connection-details->spec :postgres details)]
(postgres-test/drop-if-exists-and-create-db! db-name)
(doseq [statement ["DROP TABLE IF EXISTS PUBLIC.table_with_access;"
"DROP TABLE IF EXISTS PUBLIC.table_without_access;"
"CREATE TABLE PUBLIC.table_with_access (x INTEGER NOT NULL);"
"CREATE TABLE PUBLIC.table_without_access (y INTEGER NOT NULL);"
"DROP ROLE IF EXISTS impersonation_role;"
"CREATE ROLE impersonation_role;"
"REVOKE ALL PRIVILEGES ON DATABASE \"conn_impersonation_test\" FROM impersonation_role;"
"GRANT SELECT ON TABLE \"conn_impersonation_test\".PUBLIC.table_with_access TO impersonation_role;"]]
(jdbc/execute! spec [statement]))
(t2.with-temp/with-temp [Database database {:engine :postgres, :details details}]
(mt/with-db database (sync/sync-database! database)
(advanced-perms.api.tu/with-impersonations {:impersonations [{:db-id (mt/id) :attribute "impersonation_attr"}]
:attributes {"impersonation_attr" "impersonation_role"}}
(is (= []
(-> {:query "SELECT * FROM \"table_with_access\";"}
mt/native-query
mt/process-query
mt/rows)))
(is (thrown-with-msg? clojure.lang.ExceptionInfo
#"permission denied"
(-> {:query "SELECT * FROM \"table_without_access\";"}
mt/native-query
mt/process-query
mt/rows))))))))))
(deftest conn-impersonation-test-snowflake
(mt/test-driver :snowflake
(premium-features-test/with-premium-features #{:advanced-permissions}
(advanced-perms.api.tu/with-impersonations {:impersonations [{:db-id (mt/id) :attribute "impersonation_attr"}]
:attributes {"impersonation_attr" "limited_role"}}
;; User with connection impersonation should not be able to query a table they don't have access to
;; (`limited_role` in CI Snowflake has no data access)
(is (thrown-with-msg? clojure.lang.ExceptionInfo
#"You do not have permissions to run this query."
(mt/run-mbql-query venues
{:aggregation [[:count]]})))
;; Non-impersonated user should stil be able to query the table
(mw.session/as-admin
(is (= [100]
(mt/first-row
(mt/run-mbql-query venues
{:aggregation [[:count]]})))))))))
......@@ -72,7 +72,8 @@
:model/TimelineEvent
:model/User
:model/ViewLog
:model/GroupTableAccessPolicy})
:model/GroupTableAccessPolicy
:model/ConnectionImpersonation})
(deftest ^:parallel comprehensive-entity-id-test
(doseq [model (->> (v2.seed-entity-ids/toucan-models)
......
......@@ -80,7 +80,7 @@
#(#{gtap-id-1 gtap-id-2} (:id %))
(mt/user-http-request :crowberto :get 200 "mt/gtap/")))))
(testing "Test that we can fetch the list of GTAPs for a specific table and group"
(testing "Test that we can fetch the GTAP for a specific table and group"
(is (partial=
{:id gtap-id-1 :table_id table-id-1 :group_id group-id-1}
(mt/user-http-request :crowberto :get 200 "mt/gtap/"
......
......@@ -6,7 +6,7 @@
(defn- has-segmented-perms-when-segmented-db-exists? [user-kw]
(met/with-gtaps-for-user user-kw {:gtaps {:venues {}}}
(mt.api.u/segmented-user?)))
(mt.api.u/sandboxed-user?)))
(deftest never-segment-admins-test
(testing "Admins should not be classified as segmented users -- enterprise #147"
......
......@@ -173,7 +173,7 @@
recipients (-> pulse :channels first :recipients)]
(sort (map :id recipients))))]
(mt/with-test-user :rasta
(with-redefs [premium-features/segmented-user? (constantly false)]
(with-redefs [premium-features/sandboxed-or-impersonated-user? (constantly false)]
(is (= (sort [(mt/user->id :rasta) (mt/user->id :crowberto)])
(-> (mt/user-http-request :rasta :get 200 "pulse/")
recipient-ids)))
......@@ -183,7 +183,7 @@
vector
recipient-ids))))
(with-redefs [premium-features/segmented-user? (constantly true)]
(with-redefs [premium-features/sandboxed-or-impersonated-user? (constantly true)]
(is (= [(mt/user->id :rasta)]
(-> (mt/user-http-request :rasta :get 200 "pulse/")
recipient-ids)))
......@@ -204,7 +204,7 @@
PulseChannelRecipient [_ {:pulse_channel_id pc-id, :user_id (mt/user->id :rasta)}]]
(mt/with-test-user :rasta
(with-redefs [premium-features/segmented-user? (constantly true)]
(with-redefs [premium-features/sandboxed-or-impersonated-user? (constantly true)]
;; Rasta, a sandboxed user, updates the pulse, but does not include Crowberto in the recipients list
(mt/user-http-request :rasta :put 200 (format "pulse/%d" pulse-id)
{:channels [(assoc pc :recipients [{:id (mt/user->id :rasta)}])]}))
......@@ -213,7 +213,7 @@
(is (= (sort [(mt/user->id :rasta) (mt/user->id :crowberto)])
(->> (api.alert/email-channel (pulse/retrieve-pulse pulse-id)) :recipients (map :id) sort)))
(with-redefs [premium-features/segmented-user? (constantly false)]
(with-redefs [premium-features/sandboxed-or-impersonated-user? (constantly false)]
;; Rasta, a non-sandboxed user, updates the pulse, but does not include Crowberto in the recipients list
(mt/user-http-request :rasta :put 200 (format "pulse/%d" pulse-id)
{:channels [(assoc pc :recipients [{:id (mt/user->id :rasta)}])]})
......
......@@ -8,6 +8,7 @@
[medley.core :as m]
[metabase.driver :as driver]
[metabase.driver.common :as driver.common]
[metabase.driver.sql :as driver.sql]
[metabase.driver.sql-jdbc :as sql-jdbc]
[metabase.driver.sql-jdbc.common :as sql-jdbc.common]
[metabase.driver.sql-jdbc.connection :as sql-jdbc.conn]
......@@ -15,7 +16,8 @@
[metabase.driver.sql-jdbc.execute.legacy-impl :as sql-jdbc.legacy]
[metabase.driver.sql-jdbc.sync :as sql-jdbc.sync]
[metabase.driver.sql-jdbc.sync.common :as sql-jdbc.sync.common]
[metabase.driver.sql-jdbc.sync.describe-table :as sql-jdbc.describe-table]
[metabase.driver.sql-jdbc.sync.describe-table
:as sql-jdbc.describe-table]
[metabase.driver.sql.query-processor :as sql.qp]
[metabase.driver.sql.util :as sql.u]
[metabase.driver.sql.util.unprepare :as unprepare]
......@@ -40,9 +42,10 @@
(driver/register! :snowflake, :parent #{:sql-jdbc ::sql-jdbc.legacy/use-legacy-classes-for-read-and-set})
(doseq [[feature supported?] {:datetime-diff true
:now true
:convert-timezone true}]
(doseq [[feature supported?] {:datetime-diff true
:now true
:convert-timezone true
:connection-impersonation true}]
(defmethod driver/database-supports? [:snowflake feature] [_driver _feature _db] supported?))
(defmethod driver/humanize-connection-error-message :snowflake
......@@ -575,3 +578,16 @@
(defmethod sql-jdbc.execute/set-parameter [:snowflake java.time.ZonedDateTime]
[driver ps i t]
(sql-jdbc.execute/set-parameter driver ps i (t/sql-timestamp (t/with-zone-same-instant t (t/zone-id "UTC")))))
;;; ------------------------------------------------- User Impersonation --------------------------------------------------
(defmethod driver.sql/set-role-statement :snowflake
[_ role]
(format "USE ROLE %s;" role))
(defmethod driver.sql/default-database-role :snowflake
[_ database]
(or
(-> database :details :role)
"ACCOUNTADMIN"))
......@@ -14759,6 +14759,47 @@ databaseChangeLog:
nullable: false
deleteCascade: true
 
- changeSet:
id: v47.00-026
author: noahmoss
comment: Added 0.47.0 - New table for connection impersonation policies
changes:
- createTable:
tableName: connection_impersonations
remarks: Table for holding connection impersonation policies
columns:
- column:
name: id
type: int
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: db_id
type: int
remarks: 'ID of the database this connection impersonation policy affects'
constraints:
nullable: false
referencedTableName: metabase_database
referencedColumnNames: id
foreignKeyName: fk_conn_impersonation_db_id
deleteCascade: true
- column:
name: group_id
type: int
remarks: 'ID of the permissions group this connection impersonation policy affects'
constraints:
nullable: false
referencedTableName: permissions_group
referencedColumnNames: id
foreignKeyName: fk_conn_impersonation_group_id
deleteCascade: true
- column:
name: attribute
type: ${text.type}
remarks: 'User attribute associated with the database role to use for this connection impersonation policy'
- changeSet:
id: v47.00-027
author: calherries
......
......@@ -72,7 +72,7 @@
(def ^:private Schemas
[:or
[:enum :all :segmented :none :block :full :limited]
[:enum :all :segmented :none :block :full :limited :impersonated]
SchemaGraph])
(def ^:private DataPerms
......@@ -87,7 +87,7 @@
DataPerms
[:fn {:error/fn (fn [_ _] (trs "Invalid DB permissions: If you have write access for native queries, you must have data access to all schemas."))}
(fn [{:keys [native schemas]}]
(not (and (= native :write) schemas (not= schemas :all))))]])
(not (and (= native :write) schemas (not (#{:all :impersonated} schemas)))))]])
(def ^:private DbGraph
[:schema {:registry {"DataPerms" DataPerms}}
......
......@@ -53,6 +53,13 @@
(throw (ex-info (tru "Sandboxes are an Enterprise feature. Please upgrade to a paid plan to use this feature.")
{:status-code 402})))
(defenterprise upsert-impersonations!
"OSS implementation of `upsert-impersonations!`. Errors since this is an enterprise feature."
metabase-enterprise.advanced-permissions.models.connection-impersonation
[_impersonations]
(throw (ex-info (tru "Connection impersonation is an Enterprise feature. Please upgrade to a paid plan to use this feature.")
{:status-code 402})))
(api/defendpoint PUT "/graph"
"Do a batch update of Permissions by passing in a modified graph. This should return the same graph, in the same
format, that you got from `GET /api/permissions/graph`, with any changes made in the wherever necessary. This
......@@ -84,11 +91,17 @@
"\n"
(pr-str explained))}))))
(t2/with-transaction [_conn]
(perms/update-data-perms-graph! (dissoc graph :sandboxes))
(if-let [sandboxes (:sandboxes body)]
(let [new-sandboxes (upsert-sandboxes! sandboxes)]
(assoc (perms/data-perms-graph) :sandboxes new-sandboxes))
(perms/data-perms-graph)))))
(perms/update-data-perms-graph! (dissoc graph :sandboxes :impersonations))
(let [sandbox-updates (:sandboxes graph)
sandboxes (when sandbox-updates
(upsert-sandboxes! sandbox-updates))
impersonation-updates (:impersonations graph)
impersonations (when impersonation-updates
(upsert-impersonations! impersonation-updates))]
(merge
(perms/data-perms-graph)
(when sandboxes {:sandboxes sandboxes})
(when impersonations {:impersonations impersonations}))))))
;;; +----------------------------------------------------------------------------------------------------------------+
;;; | PERMISSIONS GROUP ENDPOINTS |
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment