diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn index 9993e7f739ee05ba9fda5940d596211a1f6c7373..db7ec479c45be25f740abf76fc1cd7541b41faae 100644 --- a/.clj-kondo/config.edn +++ b/.clj-kondo/config.edn @@ -401,7 +401,7 @@ metabase.async.api-response-test/with-response clojure.core/let metabase.dashboard-subscription-test/with-dashboard-sub-for-card clojure.core/let metabase.db.custom-migrations/defmigration clj-kondo.lint-as/def-catch-all - metabase.db.custom-migrations/def-reversible-migration clj-kondo.lint-as/def-catch-all + metabase.db.custom-migrations/define-reversible-migration clj-kondo.lint-as/def-catch-all metabase.db.data-migrations/defmigration clojure.core/def metabase.db.liquibase/with-liquibase clojure.core/let metabase.db.schema-migrations-test.impl/with-temp-empty-app-db clojure.core/let diff --git a/bin/lint-migrations-file/src/change_set/strict.clj b/bin/lint-migrations-file/src/change_set/strict.clj index 8003877c37515f64c62aeccddbe56a7a3fafa9d7..bf684b867431bf04921fb8f7a3dee17b5c8d1f75 100644 --- a/bin/lint-migrations-file/src/change_set/strict.clj +++ b/bin/lint-migrations-file/src/change_set/strict.clj @@ -56,7 +56,7 @@ :renameTable :renameTrigger :renameView - ;; assumes all custom changes use the `def-migration` or `def-reversible-migration` in + ;; assumes all custom changes use the `def-migration` or `define-reversible-migration` in ;; metabase.db.custom-migrations :customChange}) diff --git a/resources/migrations/000_migrations.yaml b/resources/migrations/000_migrations.yaml index 472c9b9c7e12267922e339b876b59c5a36c9b865..f0b44a3c74b738cae51a3e7b1fa04589c2e31f48 100644 --- a/resources/migrations/000_migrations.yaml +++ b/resources/migrations/000_migrations.yaml @@ -14262,6 +14262,14 @@ databaseChangeLog: - sql: UPDATE metabase_database SET engine = 'presto-jdbc' WHERE engine = 'presto' rollback: # nothing to do, since we don't know which drivers used to be presto + - changeSet: + id: v46.00-080 + author: noahmoss + comment: Migrate data permission paths from v1 to v2 (splitting them into separate data and query permissions) + changes: + - customChange: + class: "metabase.db.custom_migrations.SplitDataPermissions" + - changeSet: id: v46.00-084 author: qnkhuat diff --git a/src/metabase/db/custom_migrations.clj b/src/metabase/db/custom_migrations.clj index ee0fbdb421b26529470ab69061fb4ad9cb5bff8b..8658ae61716a80a85eb0d2e0170292d66d854ff7 100644 --- a/src/metabase/db/custom_migrations.clj +++ b/src/metabase/db/custom_migrations.clj @@ -1,35 +1,32 @@ (ns metabase.db.custom-migrations - (:require [metabase.util.log :as log] - [toucan2.connection :as t2.conn]) - (:import [liquibase.change.custom CustomTaskChange CustomTaskRollback] - liquibase.exception.ValidationErrors)) + (:require + [clojure.set :as set] + [metabase.util.honey-sql-2 :as h2x] + [metabase.util.log :as log] + [toucan2.core :as t2] + [toucan2.execute :as t2.execute]) + (:import + (liquibase.change.custom CustomTaskChange CustomTaskRollback) + (liquibase.exception ValidationErrors))) (set! *warn-on-reflection* true) -(defmacro def-reversible-migration +(defmacro define-reversible-migration "Define a reversible custom migration. Both the forward and reverse migrations are defined using the same structure, similar to the bodies of multi-arity Clojure functions. - The first thing in each migration body must be a one-element vector containing a binding to use for the database - object provided by Liquibase, so that migrations have access to it if needed. This should typically not be used - directly, however, because is also set automatically as the current connection for Toucan 2. - Example: ```clj - (def-reversible-migration ExampleMigrationName - ([_database] - (migration-body)) - - ([_database] - (migration-body)))" - [name [[db-binding-1] & migration-body] [[db-binding-2] reverse-migration-body]] + (define-reversible-migration ExampleMigrationName + (migration-body) + (reverse-migration-body))) + ```" + [name migration-body reverse-migration-body] `(defrecord ~name [] CustomTaskChange (execute [_# database#] - (binding [toucan2.connection/*current-connectable* (.getWrappedConnection (.getConnection database#))] - (let [~db-binding-1 database#] - ~@migration-body))) + ~migration-body) (getConfirmationMessage [_#] (str "Custom migration: " ~name)) (setUp [_#]) @@ -39,9 +36,7 @@ CustomTaskRollback (rollback [_# database#] - (binding [toucan2.connection/*current-connectable* (.getWrappedConnection (.getConnection database#))] - (let [~db-binding-2 database#] - ~@reverse-migration-body))))) + ~reverse-migration-body))) (defn no-op "No-op logging rollback function" @@ -51,4 +46,48 @@ (defmacro defmigration "Define a custom migration." [name migration-body] - `(def-reversible-migration ~name ~migration-body ([~'_] (no-op ~(str name))))) + `(define-reversible-migration ~name ~migration-body (no-op ~(str name)))) + + +;;; +----------------------------------------------------------------------------------------------------------------+ +;;; | MIGRATIONS | +;;; +----------------------------------------------------------------------------------------------------------------+ + +(def ^:private base-path-regex + #"^(/db/\d+(?:/schema/(?:(?:[^\\/])|(?:\\/)|(?:\\\\))*(?:/table/\d+?)?)?/)((native/)|(query/(segmented/)?))?$") + +(defn- ->v2-paths + "Converts v1 data permission paths into v2 data and query permissions paths. This is similar to `->v2-path` in + metabase.models.permissions but somewhat simplified for the migration use case." + [v1-path] + (if-let [base-path (second (re-find base-path-regex v1-path))] + ;; For (almost) all v1 data paths, we simply extract the base path (e.g. "/db/1/schema/PUBLIC/table/1/") + ;; and construct new v2 paths by adding prefixes to the base path. + [(str "/data" base-path) (str "/query" base-path)] + + ;; For the specific v1 path that grants full data access but no native query access, we add a + ;; /schema/ suffix to the corresponding v2 query permission path. + (when-let [db-id (second (re-find #"^/db/(\d+)/schema/$" v1-path))] + [(str "/data/db/" db-id "/") (str "/query/db/" db-id "/schema/")]))) + +(define-reversible-migration SplitDataPermissions + (let [current-perms-set (t2/select-fn-set + (juxt :object :group_id) + :metabase.models.permissions/Permissions + {:where [:or + [:like :object (h2x/literal "/db/%")] + [:like :object (h2x/literal "/data/db/%")] + [:like :object (h2x/literal "/query/db/%")]]}) + v2-perms-set (into #{} (mapcat + (fn [[v1-path group-id]] + (for [v2-path (->v2-paths v1-path)] + [v2-path group-id])) + current-perms-set)) + new-v2-perms (into [] (set/difference v2-perms-set current-perms-set))] + (when (seq new-v2-perms) + (t2.execute/query-one {:insert-into :permissions + :columns [:object :group_id] + :values new-v2-perms}))) + (t2.execute/query-one {:delete-from :permissions + :where [:or [:like :object (h2x/literal "/data/db/%")] + [:like :object (h2x/literal "/query/db/%")]]})) diff --git a/src/metabase/db/setup.clj b/src/metabase/db/setup.clj index a3a1b75a5ef69ce90f85e4250b6d056a540aaa67..df471fe10f3f443e5502be31d76a164c60af6dd4 100644 --- a/src/metabase/db/setup.clj +++ b/src/metabase/db/setup.clj @@ -134,14 +134,11 @@ (s/defn ^:private run-data-migrations! "Do any custom code-based migrations now that the db structure is up to date." - [db-type :- s/Keyword - data-source :- javax.sql.DataSource] + [] ;; TODO -- check whether we can remove the circular ref busting here. (when-not *disable-data-migrations* (classloader/require 'metabase.db.data-migrations) - (binding [mdb.connection/*application-db* (mdb.connection/application-db db-type data-source :create-pool? false) ; should already be a pool. - setting/*disable-cache* true] - ((resolve 'metabase.db.data-migrations/run-all!))))) + ((resolve 'metabase.db.data-migrations/run-all!)))) ;; TODO -- consider renaming to something like `verify-connection-and-migrate!` ;; @@ -154,9 +151,11 @@ auto-migrate? :- (s/maybe s/Bool)] (u/profile (trs "Database setup") (u/with-us-locale - (verify-db-connection db-type data-source) - (run-schema-migrations! db-type data-source auto-migrate?) - (run-data-migrations! db-type data-source))) + (binding [mdb.connection/*application-db* (mdb.connection/application-db db-type data-source :create-pool? false) ; should already be a pool + setting/*disable-cache* true] + (verify-db-connection db-type data-source) + (run-schema-migrations! db-type data-source auto-migrate?) + (run-data-migrations!)))) :done) ;;;; Toucan Setup. diff --git a/test/metabase/db/schema_migrations_test.clj b/test/metabase/db/schema_migrations_test.clj index 040ebf813cd15e30f128d7b59ee80a83d49de784..9f0add63983f06bdc2a5eb141c9f31f96cc05aab 100644 --- a/test/metabase/db/schema_migrations_test.clj +++ b/test/metabase/db/schema_migrations_test.clj @@ -28,6 +28,7 @@ Dimension Field Permissions + PermissionsGroup Pulse Setting Table @@ -39,7 +40,8 @@ [metabase.test.util :as tu] [metabase.util :as u] [toucan.db :as db] - [toucan2.core :as t2]) + [toucan2.core :as t2] + [toucan2.execute :as t2.execute]) (:import (java.sql Connection) (java.util UUID))) @@ -906,3 +908,58 @@ (db/delete! Database :id db-id))) (migrate!) (is (db/delete! Database :id db-id)))))) + +(deftest split-data-permission-test + (testing "Migration v46.00-080: split existing v1 data permission paths into v2 data and query permission paths" + (impl/test-migrations ["v46.00-080"] [migrate!] + (let [[group-1-id] (t2/insert-returning-pks! PermissionsGroup {:name "Test Group 1"}) + [group-2-id] (t2/insert-returning-pks! PermissionsGroup {:name "Test Group 2"}) + v1-paths-and-groups [["/db/1/" group-1-id] + ["/db/2/schema/" group-1-id] + ["/db/3/native/" group-1-id] + ["/db/4/schema/PUBLIC/" group-1-id] + ["/db/5/schema/my\\\\schema/" group-1-id] + ["/db/6/schema/PUBLIC/table/1/" group-1-id] + ["/db/7/schema/PUBLIC/table/1/query/segmented/" group-1-id] + ["/db/8/schema/PUBLIC/table/1/query/segmented/" group-1-id] + ["/db/1/" group-2-id] + ["invalid-path" group-2-id]] + _ (t2.execute/query-one {:insert-into :permissions + :columns [:object :group_id] + :values v1-paths-and-groups}) + _ (migrate!) + new-paths-set (t2/select-fn-set (juxt :object :group_id) + :models/permissions + {:where [:in :group_id [group-1-id group-2-id]]})] + ;; Check that the full permission set for group-1 and group-2 is what we expect post-migration. + ;; Each v1-path from above is listed here, immediately followed by the two resulting v2 paths. + (is (= #{["/db/1/" group-1-id] + ["/data/db/1/" group-1-id] + ["/query/db/1/" group-1-id] + ["/db/2/schema/" group-1-id] + ["/data/db/2/" group-1-id] + ["/query/db/2/schema/" group-1-id] + ["/db/3/native/" group-1-id] + ["/data/db/3/" group-1-id] + ["/query/db/3/" group-1-id] + ["/db/4/schema/PUBLIC/" group-1-id] + ["/data/db/4/schema/PUBLIC/" group-1-id] + ["/query/db/4/schema/PUBLIC/" group-1-id] + ["/db/5/schema/my\\\\schema/" group-1-id] + ["/data/db/5/schema/my\\\\schema/" group-1-id] + ["/query/db/5/schema/my\\\\schema/" group-1-id] + ["/db/6/schema/PUBLIC/table/1/" group-1-id] + ["/data/db/6/schema/PUBLIC/table/1/" group-1-id] + ["/query/db/6/schema/PUBLIC/table/1/" group-1-id] + ["/db/7/schema/PUBLIC/table/1/query/segmented/" group-1-id] + ["/data/db/7/schema/PUBLIC/table/1/" group-1-id] + ["/query/db/7/schema/PUBLIC/table/1/" group-1-id] + ["/db/8/schema/PUBLIC/table/1/query/segmented/" group-1-id] + ["/data/db/8/schema/PUBLIC/table/1/" group-1-id] + ["/query/db/8/schema/PUBLIC/table/1/" group-1-id] + ["/db/1/" group-2-id] + ["/data/db/1/" group-2-id] + ["/query/db/1/" group-2-id] + ;; Invalid path is not touched but also doesn't fail the migration + ["invalid-path" group-2-id]} + new-paths-set))))))