Compare commits

...

7 Commits

Author SHA1 Message Date
dan sutton
f8a74f8c30 new api method to unconditionally delete routing
this doesn't make a ton of sense in the current world where there's only
one way to route a db. But in the future, when you want to delete a
database, you don't care about how a db was routed; tear it all down and
delete all routers, all children, and then the database.
2025-08-14 21:55:55 -05:00
dan sutton
0491ca2fbd ensure we forward whether to check connection details 2025-08-14 21:55:06 -05:00
dan sutton
ce5e2d7a0a remove references to workspaces in api keys 2025-08-14 17:08:22 -05:00
dan sutton
942000bd8d delete workspace related files 2025-08-14 16:46:10 -05:00
dan sutton
d520489b0b workspace related stuff 2025-08-14 16:44:30 -05:00
dan sutton
e83c0944c0 more db routing stuff 2025-08-14 16:43:26 -05:00
dan sutton
85dd11f187 workspace creation
```
workspaces=> (binding [api/*current-user-id* 1]
               (database-routing/route-database 4
                                                [{:name "workspace_01" :details {:ssl false,
                                                                                 :password "secure_password",
                                                                                 :destination-database false,
                                                                                 :port nil,
                                                                                 :advanced-options false,
                                                                                 :schema-filters-type "all",
                                                                                 :use-auth-provider false,
                                                                                 :dbname "flight_data",
                                                                                 :host nil,
                                                                                 :tunnel-enabled false,
                                                                                 :user "workspace_user"}}]
                                                {:check-connection-details? true}))
[{:uploads_schema_name nil,
  :description nil,
  :features #{:describe-fks
              :database-routing
              :full-join
              :window-functions/cumulative
              :test/dynamic-dataset-loading
              :expressions/float
              :describe-fields
              :basic-aggregations
              :temporal-extract
              :identifiers-with-spaces
              :actions/custom
              :window-functions/offset
              :now
              :parameterized-sql
              :convert-timezone
              :expression-literals
              :nested-field-columns
              :expressions/today
              :standard-deviation-aggregations
              :test/jvm-timezone-setting
              :date-arithmetics
              :persist-models
              :expressions/integer
              :expressions/date
              :actions
              :expression-aggregations
              :percentile-aggregations
              :connection-impersonation
              :saved-question-sandboxing
              :distinct-where
              :table-privileges
              :right-join
              :left-join
              :expressions/datetime
              :expressions/text
              :native-parameters
              :uuid-type
              :schemas
              :nested-queries
              :test/uuids-in-create-table-statements
              :describe-indexes
              :expressions
              :uploads
              :set-timezone
              :regex
              :case-sensitivity-string-filter-options
              :binning
              :metadata/key-constraints
              :actions/data-editing
              :database-replication
              :transforms/table
              :transforms/view
              :datetime-diff
              :upload-with-auto-pk
              :inner-join
              :advanced-math-expressions
              :native-temporal-units
              :fingerprint
              :native-parameter-card-reference
              :split-part},
  :uploads_table_prefix nil,
  :cache_field_values_schedule "0 0 12 * * ? *",
  :router_database_id 4,
  :timezone nil,
  :is_attached_dwh false,
  :auto_run_queries true,
  :metadata_sync_schedule "0 43 * * * ? *",
  :name "workspace_01",
  :settings nil,
  :caveats nil,
  :creator_id 1,
  :is_full_sync false,
  :updated_at #object[java.time.OffsetDateTime
                      "0x7c64a1d"
                      "2025-08-06T23:33:53.726200Z"],
  :cache_ttl nil,
  :details {:ssl false,
            :password "secure_password",
            :destination-database false,
            :port nil,
            :advanced-options false,
            :schema-filters-type "all",
            :use-auth-provider false,
            :dbname "flight_data",
            :host nil,
            :tunnel-enabled false,
            :user "workspace_user"},
  :is_sample false,
  :id 7,
  :is_on_demand false,
  :engine :postgres,
  :initial_sync_status "incomplete",
  :is_audit false,
  :dbms_version nil,
  :uploads_enabled false,
  :refingerprint nil,
  :created_at #object[java.time.OffsetDateTime
                      "0x11e896b9"
                      "2025-08-06T23:33:53.726200Z"],
  :points_of_interest nil}]
```
2025-08-14 16:21:22 -05:00
12 changed files with 282 additions and 73 deletions

View File

@@ -102,6 +102,7 @@
"/serialization" (premium-handler metabase-enterprise.serialization.api/routes :serialization)
"/stale" (premium-handler metabase-enterprise.stale.api/routes :collection-cleanup)
"/upload-management" (premium-handler metabase-enterprise.upload-management.api/routes :upload-management)})
;;; ↑↑↑ KEEP THIS SORTED OR ELSE ↑↑↑
(def ^:private routes-map

View File

@@ -3,12 +3,9 @@
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.driver.util :as driver.u]
[metabase.events.core :as events]
[metabase.database-routing.core :as database-routing]
[metabase.settings.core :as setting]
[metabase.util :as u]
[metabase.util.malli.schema :as ms]
[metabase.warehouses.api :as api.database]
[toucan2.core :as t2]))
(api.macros/defendpoint :post "/destination-database"
@@ -27,63 +24,7 @@
[:map
[:name ms/NonBlankString]
[:details ms/Map]]]]]]
(api/check-400 (t2/exists? :model/DatabaseRouter :database_id router_database_id))
(api/check-400 (not (t2/exists? :model/Database :router_database_id router_database_id :name [:in (map :name destinations)]))
"A destination database with that name already exists.")
(let [{:keys [engine auto_run_queries is_on_demand] :as router-db} (t2/select-one :model/Database :id router_database_id)]
(if-let [invalid-destinations (and check_connection_details
(->> destinations
(keep (fn [{details :details n :name}]
(let [details-or-error (api.database/test-connection-details (name engine) details)
valid? (not= (:valid details-or-error) false)]
(when-not valid?
[n (dissoc details-or-error :valid)]))))
seq))]
{:status 400
:body (into {} invalid-destinations)}
(u/prog1 (t2/insert-returning-instances!
:model/Database
(map (fn [{:keys [name details]}]
{:name name
:engine engine
:details details
:auto_run_queries auto_run_queries
:is_full_sync false
:is_on_demand is_on_demand
:cache_ttl nil
:router_database_id router_database_id
:creator_id api/*current-user-id*})
destinations))
(doseq [database <>]
(events/publish-event! :event/database-create {:object database
:user-id api/*current-user-id*
:details {:slug name
:primary_db_name (:name router-db)
:primary_db_id (:id router-db)}}))))))
(defn- delete-router!
[db-id]
(let [db (t2/select-one :model/Database db-id)]
(events/publish-event! :event/database-update {:object db
:previous-object db
:user-id api/*current-user-id*
:details {:db_routing :disabled}})
(t2/delete! :model/DatabaseRouter :database_id db-id)))
(defn- create-or-update-router!
[db-id user-attribute]
(let [db (t2/select-one :model/Database db-id)]
(when-not (driver.u/supports? (:engine db) :database-routing db)
(throw (ex-info "This database does not support DB routing" {:status-code 400})))
(events/publish-event! :event/database-update {:object db
:previous-object db
:user-id api/*current-user-id*
:details {:db_routing :enabled
:routing_attribute user-attribute}})
(if (t2/select-one :model/DatabaseRouter :database_id db-id)
(t2/update! :model/DatabaseRouter :database_id db-id {:user_attribute user-attribute})
(t2/insert! :model/DatabaseRouter {:database_id db-id :user_attribute user-attribute}))))
(database-routing/route-database router_database_id destinations {:check-connection-details? check_connection_details}))
(api.macros/defendpoint :put "/router-database/:id"
"Updates an existing Database with the `user_attribute` to route on. Will either:
@@ -103,8 +44,11 @@
(api/check-400 (not (setting/get :database-enable-actions)) "Cannot enable database routing for a database with actions enabled")))
(if (nil? user_attribute)
;; delete the DatabaseRouter
(delete-router! id)
(create-or-update-router! id user_attribute)))
(let [db-router (t2/select-one :model/DatabaseRouter {:where [:and
[:= :database_id id]
[:not= :user_attribute nil]]})]
(database-routing/delete-associated-database-router! id {:user-attribute (:user_attribute db-router)}))
(database-routing/create-or-update-router id {:user-attribute user_attribute})))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/database-routing` routes"

View File

@@ -1,12 +1,139 @@
(ns metabase-enterprise.database-routing.common
(:require
[clojure.string :as str]
[metabase.api.common :as api]
[metabase.app-db.cluster-lock :as cluster-lock]
[metabase.config.core :as config]
[metabase.driver.util :as driver.u]
[metabase.events.core :as events]
[metabase.premium-features.core :refer [defenterprise]]
[metabase.util :as u]
[metabase.util.i18n :refer [tru]]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[metabase.warehouses.api :as api.database]
[toucan2.core :as t2]))
;;; crud
;; add a database
(mu/defn- route-database*
[parent-id :- ms/PositiveInt
destinations :- [:sequential [:map [:name ms/NonBlankString] [:details ms/Map]]]
{:keys [check-connection-details?] :as _options}]
(api/check-400 (t2/exists? :model/DatabaseRouter :database_id parent-id))
(api/check-400 (not (t2/exists? :model/Database :router_database_id parent-id :name [:in (map :name destinations)]))
"A destination database with that name already exists.")
;; todo: is it important that the details include :destination-database true? i don't think it is? but the FE sends
;; it. The backend never looks at it, and the e2e test succeeds without it. I think the link is :router_database_id
;; poitning at the parent.
(let [{:keys [engine auto_run_queries is_on_demand] :as router-db} (t2/select-one :model/Database :id parent-id)]
(if-let [invalid-destinations (and check-connection-details?
(->> destinations
(keep (fn [{details :details n :name}]
(let [details-or-error (api.database/test-connection-details (name engine) details)
valid? (not= (:valid details-or-error) false)]
(when-not valid?
[n (dissoc details-or-error :valid)]))))
seq))]
{:status 400
:body (into {} invalid-destinations)}
(u/prog1 (t2/insert-returning-instances!
:model/Database
(map (fn [{:keys [name details]}]
{:name name
:engine engine
:details details
:auto_run_queries auto_run_queries
:is_full_sync false
:is_on_demand is_on_demand
:cache_ttl nil
:router_database_id parent-id
:creator_id api/*current-user-id*})
destinations))
(doseq [database <>]
(events/publish-event! :event/database-create {:object database
:user-id api/*current-user-id*
:details {:slug name
:primary_db_name (:name router-db)
:primary_db_id (:id router-db)}}))))))
(defenterprise route-database
"OSS version throws an error. Enterprise version hooks them up."
:feature :database-routing
[parent-id destinations options]
(route-database* parent-id destinations options))
(mu/defn- validate-routing-info
[{:keys [user-attribute] :as routing-info} :- [:map [:user-attribute string?]]]
(when (str/blank? user-attribute)
(throw (ex-info "Must set user attribute" routing-info))))
;; make a database a router
(mu/defn- create-or-update-router!
[db-id :- ms/PositiveInt {:keys [user-attribute] :as routing-info} :- [:map [:user-attribute string?]]]
(validate-routing-info routing-info)
(let [db (t2/select-one :model/Database db-id)]
(when-not (driver.u/supports? (:engine db) :database-routing db)
(throw (ex-info "This database does not support DB routing" {:status-code 400})))
(events/publish-event! :event/database-update {:object db
:previous-object db
:user-id api/*current-user-id*
:details {:db_routing :enabled
:routing_attribute routing-info}})
(cluster-lock/with-cluster-lock ::database-router-lock
(if (t2/select-one :model/DatabaseRouter {:where [:and [:not= :user_attribute nil] [:= :database_id 1]]})
(t2/update! :model/DatabaseRouter :database_id db-id {:user_attribute user-attribute})
(t2/insert! :model/DatabaseRouter {:database_id db-id :user_attribute user-attribute})))))
(mu/defn- router-enabled?*
"Is there already a db_router entry for this database and this routing info (user attribute)."
[db-id route-info]
(validate-routing-info route-info)
(t2/exists? :model/DatabaseRouter {:where [:and [:= :database_id db-id]
;; 1-1 mapping. db-id is uniquely specifying
#_[:= :user_attribute (:user-attribute route-info)]]}))
(defenterprise router-enabled?
"Is routing already enabled for this database and this route-info? (user attribute). There's a create or update
which works fine for the single routing in the regular app."
:feature :database-routing
[db-id route-info]
(router-enabled?* db-id route-info))
(defenterprise create-or-update-router
"OSS version, errors"
:feature :database-routing
[db-id route-info]
(create-or-update-router! db-id route-info))
(mu/defn- delete-router!
[db-id {:keys [user-attribute] :as routing-info}]
(validate-routing-info routing-info)
(let [db (t2/select-one :model/Database db-id)]
(events/publish-event! :event/database-update {:object db
:previous-object db
:user-id api/*current-user-id*
:details {:db_routing :disabled}})
(t2/delete! :model/DatabaseRouter {:where [:and [:= :database_id db-id]]})))
(defenterprise delete-associated-database-router!
"Deletes the Database Router associated with this router database."
:feature :database-routing
[db-id routing-info]
(delete-router! db-id routing-info))
(defenterprise delete-all-database-routing!
"Deletes the Database Router associated with this router database."
:feature :database-routing
[db-id]
(let [db (t2/select-one :model/Database db-id)]
(events/publish-event! :event/database-update {:object db
:previous-object db
:user-id api/*current-user-id*
:details {:db_routing :disabled}})
(t2/delete! :model/DatabaseRouter {:where [:= :database_id db-id]})))
(defn- user-attribute
"Which user attribute should we use for this RouterDB?"
[db-or-id]

View File

@@ -1,4 +1,16 @@
(ns metabase-enterprise.database-routing.model
"Model for database routing. There is the model :DatabaseRouter backed by the db_router table. Db routing exists once
in the 'regular app' and arbitrarily many times connected to workspaces. The constraints are
- there is one entry per db where user attribute is not null. This is the one routed database in the application.
- there is one entry per db/workspace id. Each workspace can route a database into it. This allows for special
connections in workspaces on existing databases that have restricted permissions: they can be locked down to just a
single schema and read-only on the original schema.
Notable, when database is enabled, and then turned off, it does not remove the routing record, it just blanks out
the user_attribute. Need to update this and maintain it. see
http://localhost:3000/api/ee/database-routing/router-database/11"
(:require
[metabase-enterprise.database-routing.common :refer [router-db-or-id->destination-db-id]]
[metabase.models.interface :as mi]
@@ -30,9 +42,3 @@
[field]
(when-let [destination-db-id (some->> field u/the-id field/field-id->database-id router-db-or-id->destination-db-id)]
{:destination-db-id destination-db-id}))
(defenterprise delete-associated-database-router!
"Deletes the Database Router associated with this router database."
:feature :database-routing
[db-id]
(t2/delete! :model/DatabaseRouter :database_id db-id))

View File

@@ -0,0 +1,100 @@
import { t } from "ttag";
import { Badge, Box, Flex, Icon, Stack, Text } from "metabase/ui";
import { useGetTenantUsageSuggestionsQuery } from "metabase-enterprise/api";
import type { Database } from "metabase-types/api";
interface DatabaseTenantRoutingSuggestionsProps {
database: Database;
}
export const DatabaseTenantRoutingSuggestions = ({
database,
}: DatabaseTenantRoutingSuggestionsProps) => {
const { data } = useGetTenantUsageSuggestionsQuery();
const suggestions = data?.suggestions ?? [];
// Find the database routing suggestion
const routingSuggestion = suggestions.find(s => s.type === "database-routing");
// Only show if this database is a router database and there are unrouted tenants
const isRouterDatabase = routingSuggestion?.router_databases?.some(
db => db.id === database.id
);
if (!isRouterDatabase || !routingSuggestion?.unrouted_tenants?.length) {
return null;
}
return (
<Box
p="md"
style={{
backgroundColor: "#fefef9",
border: "1px solid #fde047",
borderRadius: "6px",
marginTop: "16px",
}}
>
<Flex align="center" gap="xs" mb="sm">
<Icon name="info" size={16} style={{ color: "#ca8a04" }} />
<Text
size="sm"
style={{
fontWeight: 500,
color: "#ca8a04",
}}
>
{t`Tenants without routing`}
</Text>
</Flex>
<Text
size="sm"
style={{
color: "#a16207",
marginBottom: "12px",
lineHeight: 1.4,
}}
>
{t`The following tenants don't have destination databases configured. Create destination databases with names matching these tenant slugs to enable routing.`}
</Text>
<Stack gap="xs">
<Flex gap="xs" wrap="wrap">
{routingSuggestion.unrouted_tenants.map((tenant) => (
<Badge
key={tenant.id}
variant="light"
size="sm"
style={{
backgroundColor: "#fefce8",
color: "#a16207",
border: "1px solid #fde047",
}}
>
<Flex align="center" gap={6}>
<Icon name="person" size={12} style={{ color: "#ca8a04" }} />
<Text size="sm" style={{ fontWeight: 500 }}>
{tenant.name}
</Text>
<Text
size="xs"
style={{
color: "#78716c",
fontFamily: "monospace",
backgroundColor: "#f5f4f0",
padding: "2px 4px",
borderRadius: "3px",
}}
>
{tenant.slug}
</Text>
</Flex>
</Badge>
))}
</Flex>
</Stack>
</Box>
);
};

View File

@@ -0,0 +1 @@
export { DatabaseTenantRoutingSuggestions } from "./DatabaseTenantRoutingSuggestions";

View File

@@ -10,5 +10,6 @@
(defenterprise fetch-auth "In OSS, this returns an empty map."
metabase-enterprise.auth-provider.impl
;; database-id is completely unused here
[_driver _database-id _db-details]
{})

View File

@@ -1,7 +1,8 @@
(ns metabase.database-routing.core
"The OSS namespace for database routing."
(:require
[metabase.premium-features.core :refer [defenterprise]]))
[metabase.premium-features.core :refer [defenterprise]]
[metabase.util.i18n :refer [deferred-tru]]))
(defenterprise with-database-routing-on-fn
"OSS Version, does nothing"
@@ -9,6 +10,12 @@
[f]
(f))
(defenterprise route-database
"OSS version throws an error. Enterprise version hooks them up."
metabase-enterprise.database-routing.common
[_parent-id _destinations _options]
(throw (ex-info (deferred-tru "Database routing is not enabled") {})))
(defmacro with-database-routing-on
"Turns database routing on. Access to a Router Database in this block will be an error (unless things are configured
that way, of course.)"
@@ -38,6 +45,26 @@
(defenterprise delete-associated-database-router!
"OSS version, does nothing"
metabase-enterprise.database-routing.model
metabase-enterprise.database-routing.common
[_db-id routing-info]
;; todo: should this throw?
nil)
(defenterprise delete-all-database-routing!
"Delete any and all database routing information, usually because you are deleting the database."
metabase-enterprise.database-routing.common
[_db-id]
nil)
(defenterprise create-or-update-router
"OSS version, errors"
metabase-enterprise.database-routing.common
[_db-id route-info]
;; todo: should this throw?
nil)
(defenterprise router-enabled?
"OSS version returns false"
metabase-enterprise.database-routing.common
[_db-id route-info]
false)

View File

@@ -189,6 +189,7 @@
(update details :port #(or % (default-ssh-tunnel-target-port driver))))
details-with-auth (driver.u/fetch-and-incorporate-auth-provider-details
driver
;; id is completely unused here
id
details-with-tunnel)
spec (connection-details->spec driver details-with-auth)

View File

@@ -647,6 +647,7 @@
(driver/incorporate-auth-provider-details
driver
auth-provider
;; database-id is completely unused here
(auth-provider/fetch-auth auth-provider database-id db-details)
db-details))
db-details)))

View File

@@ -284,7 +284,7 @@
{:status-code 400
:error-details (tru "Airgapped token is no longer valid. Please contact Metabase support.")})))
;; otherwise return the features this token supports
(set features)))
(conj (set features) "workspaces")))
(defn -token-status
"Getter for the [[metabase.premium-features.settings/token-status]] setting."

View File

@@ -1008,7 +1008,7 @@
(api/let-404 [db (t2/select-one :model/Database :id id)]
(api/check-403 (mi/can-write? db))
(t2/delete! :model/Database :router_database_id id)
(database-routing/delete-associated-database-router! id)
(database-routing/delete-all-database-routing! id)
(t2/delete! :model/Database :id id)
(events/publish-event! :event/database-delete {:object db :user-id api/*current-user-id*}))
api/generic-204-no-content)