Compare commits

..

4 Commits

Author SHA1 Message Date
dan sutton
a632a4c206 Scope existence preconditions to their table; lint against name-only checks
A foreignKeyConstraintExists precondition with only foreignKeyName (or
indexExists with only indexName) forces Liquibase to snapshot the entire
schema to answer the check -- ~2s per precondition on a large postgres
appdb. 27 such preconditions (24 FK, 3 index; 21 in one v59 workspace
block) added ~46s to every full migration: 50.2s of total changeset time,
of which 24 changesets cost 45.9s and the other 1,182 cost 3.7s.

Add the scoping key to all 27. Measured on a fresh postgres migration:
total changeset time drops 50.2s -> 5.1s, with zero changesets over 1s.

Editing released changesets' preconditions is safe: Liquibase checksums
cover the changes, not preConditions. Verified two ways -- ChangeSet
.generateCheckSum is byte-identical before/after for all 27 (v9
checksums), and a clone of an already-migrated appdb re-validates
cleanly against the edited files.

lint-migrations-file: generalize require-primary-key-exists-has-table-name
into require-table-scoped-existence-preconditions, covering
primaryKeyExists/indexExists/uniqueConstraintExists (tableName) and
foreignKeyConstraintExists (foreignKeyTableName), walking the full
precondition tree in both its flat-map and list forms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f
2026-07-22 18:02:31 -05:00
dan sutton
248a2bc0cd Hold the audit DB cluster lock on a detached connection (fixes boot stall)
Since #76551 the audit install/load/sync pipeline runs inside a cluster
lock whose SELECT FOR UPDATE ties lock lifetime to transaction lifetime:
the whole pipeline became one long transaction on the lock's connection.
The serdes load's ~3,600 per-entity nested transactions piled up as
savepoints and subtransaction XIDs, and every row lock was held until a
single commit at the end -- stalling first boot for 10-25+ minutes and
blocking concurrent work like config-file database provisioning (#78238).

with-cluster-lock now supports :detached? -- the lock rows are held on a
dedicated unshared connection (toucan2 unshared-connection!) while the
body runs on ordinary pooled connections, committing incrementally in
its own short transactions. Locks release at body completion (commit) or
throw (pool check-in rollback). Re-acquiring a lock this thread already
holds detached is a no-op in both detached and transactional form;
upgrading a detached :share hold to :exclusive throws instead of
self-deadlocking; :retry-transient? is rejected (a re-run would
double-apply incrementally-committed work).

The audit pipeline opts in: per-entity serdes isolation (#74412) is
restored, and a crash mid-pipeline is repaired by the next boot (the
checksum only advances on completion; reconcile runs every boot), which
is what makes incremental commit safe here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f
2026-07-22 14:14:58 -05:00
dan sutton
4ba59c3b8b Use released toucan2 1.0.572 for unshared-connection support
camsaul/toucan2#212 merged and released; drop the :git/sha pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f
2026-07-22 13:35:38 -05:00
dan sutton
7a9bc3286c Add mdb/with-unshared-connection: appdb connections exempt from ambient reuse
A connection holding fragile long-lived state -- a streaming result set
(postgres portal/cursor), or FOR UPDATE row locks -- must not be picked
up by ambient connection reuse: a borrower that inherits it via
*current-connectable* and commits destroys that state. This is the
failure mode behind the first-boot hang in #78238, the revert (#77048)
of appdb streaming (#76645), and "cursor/portal does not exist" errors
when field updates run mid-sync-stream.

Built on toucan2's new unshared-connection! (camsaul/toucan2#212): the
connection is used when passed explicitly -- (t2/reducible-query conn
...), (t2/with-transaction [t conn] ...) -- but never bound as
*current-connectable*, so toucan calls that resolve their connection
ambiently run (and commit) on other pooled connections.

mdb/with-unshared-connection is checkout -> mark -> body -> close; in
every other respect the connection is ordinary JDBC, configured by the
body (e.g. .setAutoCommit false to stream). The pg test reproduces the
exact #77048 failure on an unmarked control connection (portal killed by
a nested commit mid-reduction) and shows the unshared connection
streaming to completion.

deps.edn temporarily pins toucan2 to the PR sha via :git/sha; a
follow-up commit will switch back to :mvn/version once a toucan2
release includes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f
2026-07-21 17:16:59 -05:00
52 changed files with 737 additions and 623 deletions

View File

@@ -465,6 +465,7 @@
metabase.actions.test-util/with-actions clojure.core/let
metabase.transforms.test-util/with-transform-cleanup! clojure.core/let
metabase.api.common/let-404 clojure.core/let
metabase.app-db.connection/with-unshared-connection clojure.core/fn
metabase.app-db.custom-migrations.util/with-temp-scheduler! clojure.core/fn
metabase.app-db.custom-migrations/define-migration clj-kondo.lint-as/def-catch-all
metabase.app-db.custom-migrations/define-reversible-migration clj-kondo.lint-as/def-catch-all

View File

@@ -19,8 +19,7 @@ while [[ $# -gt 0 ]]; do
done
echo "Cloning python-runner repository..."
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git --branch seccomp python-runner
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
if [ "$SKIP_START" = false ]; then
echo "Starting python-runner..."

View File

@@ -207,16 +207,42 @@
(str/join ", " bad-ids))
{:invalid-ids (vec bad-ids)}))))))
(defn require-primary-key-exists-has-table-name
"Ensures that all primaryKeyExists preconditions specify a tableName."
(def ^:private table-scoped-preconditions
"Existence preconditions mapped to the key that scopes them to a single table. Without that key,
Liquibase answers the check by snapshotting the whole schema — ~2 seconds per precondition on a
large postgres appdb, which is how 27 name-only preconditions once added ~50s to every full
migration."
{:primaryKeyExists :tableName
:indexExists :tableName
:uniqueConstraintExists :tableName
:foreignKeyConstraintExists :foreignKeyTableName})
(defn- unscoped-preconditions
"Walk a preConditions tree (maps nested under and/or/not, in either flat-map or list form) and
return [precondition-key required-key] pairs for every table-scoped existence check that is
missing its table key."
[form]
(cond
(map? form) (concat
(for [[precondition required] table-scoped-preconditions
:let [check (get form precondition)]
:when (and (map? check) (not (contains? check required)))]
[precondition required])
(mapcat unscoped-preconditions (vals form)))
(sequential? form) (mapcat unscoped-preconditions form)
:else nil))
(defn require-table-scoped-existence-preconditions
"Ensures that primaryKeyExists/indexExists/uniqueConstraintExists preconditions specify a
tableName, and foreignKeyConstraintExists a foreignKeyTableName."
[change-log]
(doseq [{:keys [changeSet]} change-log
:when changeSet
:let [s (pr-str (:preConditions changeSet))]
:when (and (str/includes? s ":primaryKeyExists")
(not (str/includes? s ":tableName")))]
:let [[precondition required] (first (unscoped-preconditions (:preConditions changeSet)))]
:when precondition]
(throw (validation-error
(format "Migration '%s' has primaryKeyExists precondition without tableName" (:id changeSet))
(format "Migration '%s' has %s precondition without %s: without it Liquibase snapshots the entire schema to answer the check (~2s per precondition on a large postgres appdb)"
(:id changeSet) (name precondition) (name required))
{:id (:id changeSet)}))))
(s/def ::changeSet
@@ -292,7 +318,7 @@
(require-no-bare-blob-or-text-types change-log)
(require-no-bare-boolean-types change-log file)
(require-no-datetime-type change-log file)
(require-primary-key-exists-has-table-name change-log)
(require-table-scoped-existence-preconditions change-log)
(let [{:keys [changeSet]
:as all} (group-by only-key change-log)]
(when-not (set/subset? (set (keys all)) #{:property :objectQuotingStrategy :changeSet :logicalFilePath})

View File

@@ -398,3 +398,40 @@
:remarks "none"
:type "timestamp with time zone"}}]}}]
:rollback nil))))))
(deftest ^:parallel require-table-scoped-existence-preconditions-test
(testing "foreignKeyConstraintExists without foreignKeyTableName is rejected"
(is-thrown-with-error-info?
"Migration 'v49.2024-01-01T10:30:00' has foreignKeyConstraintExists precondition without foreignKeyTableName: without it Liquibase snapshots the entire schema to answer the check (~2s per precondition on a large postgres appdb)"
{:id "v49.2024-01-01T10:30:00"}
(validate
(mock-change-set
:preConditions [{:onFail "MARK_RAN"}
{:not [{:foreignKeyConstraintExists {:foreignKeyName "fk_my_table_other_id"}}]}]))))
(testing "foreignKeyConstraintExists with foreignKeyTableName passes"
(is (= :ok
(validate
(mock-change-set
:preConditions [{:onFail "MARK_RAN"}
{:not [{:foreignKeyConstraintExists {:foreignKeyName "fk_my_table_other_id"
:foreignKeyTableName "my_table"}}]}])))))
(testing "indexExists without tableName is rejected, including in flat-map not form"
(is-thrown-with-error-info?
"Migration 'v49.2024-01-01T10:30:00' has indexExists precondition without tableName: without it Liquibase snapshots the entire schema to answer the check (~2s per precondition on a large postgres appdb)"
{:id "v49.2024-01-01T10:30:00"}
(validate
(mock-change-set
:preConditions [{:not {:indexExists {:indexName "idx_my_table_other_id"}}}]))))
(testing "indexExists with tableName passes"
(is (= :ok
(validate
(mock-change-set
:preConditions [{:not {:indexExists {:indexName "idx_my_table_other_id"
:tableName "my_table"}}}])))))
(testing "primaryKeyExists without tableName is still rejected (previous rule subsumed)"
(is-thrown-with-error-info?
"Migration 'v49.2024-01-01T10:30:00' has primaryKeyExists precondition without tableName: without it Liquibase snapshots the entire schema to answer the check (~2s per precondition on a large postgres appdb)"
{:id "v49.2024-01-01T10:30:00"}
(validate
(mock-change-set
:preConditions [{:primaryKeyExists {:primaryKeyName "pk_my_table"}}])))))

View File

@@ -80,7 +80,7 @@
hiccup/hiccup {:mvn/version "2.0.0"} ; HTML templating
inflections/inflections {:mvn/version "0.15.0"} ; Clojure/Script library used for prularizing words
instaparse/instaparse {:mvn/version "1.5.0"} ; Make your own parser
io.github.camsaul/toucan2 {:mvn/version "1.0.571"}
io.github.camsaul/toucan2 {:mvn/version "1.0.572"}
io.github.eerohele/pp {#_#_:git/tag "2024-11-13.77" ; super fast pretty-printing library
:git/sha "ffc4edc482c057cd9412c62f018a41e9140c618b"
:git/url "https://github.com/eerohele/pp"}

82
docs/api.json generated
View File

@@ -5033,13 +5033,7 @@
}
},
"required" : [ "channel_type", "details" ]
}, {
"type" : "object",
"properties" : {
"channel_type" : { }
},
"required" : [ "channel_type" ]
} ]
}, { } ]
},
"metabase.channel.models.channel.ChannelTemplateEmailDetails" : {
"oneOf" : [ {
@@ -5108,13 +5102,7 @@
}
},
"required" : [ "channel_type", "details" ]
}, {
"type" : "object",
"properties" : {
"channel_type" : { }
},
"required" : [ "channel_type" ]
} ]
}, { } ]
},
"metabase.collections-rest.api.DashboardQuestionCandidate" : {
"type" : "object",
@@ -37226,7 +37214,7 @@
"post" : {
"operationId" : "post-api-measure",
"summary" : "POST /api/measure",
"description" : "Create a new `Measure`. The Measure's table is derived from its `definition`.",
"description" : "Create a new `Measure`.",
"parameters" : [ ],
"responses" : {
"2XX" : {
@@ -37267,9 +37255,14 @@
"name" : {
"type" : "string",
"minLength" : 1
},
"table_id" : {
"description" : "value must be an integer greater than zero.",
"type" : "integer",
"minimum" : 1
}
},
"required" : [ "name", "definition" ]
"required" : [ "name", "table_id", "definition" ]
}
}
}
@@ -44363,7 +44356,7 @@
"get" : {
"operationId" : "get-api-search",
"summary" : "GET /api/search",
"description" : "Search for items in Metabase.\n For the list of supported models, check [[metabase.search.config/all-models]].\n\n `context` identifies the surface issuing the search; it selects the ranking weights and filter defaults.\n It defaults to `api`, the value for programmatic callers.\n\n Filters:\n - `archived`: set to true to search archived items only, default is false\n - `table_db_id`: search for tables, cards, and models of a certain DB\n - `models`: only search for items of specific models. If not provided, search for all models\n - `filter_items_in_personal_collection`: only search for items in personal collections\n - `created_at`: search for items created at a specific timestamp\n - `created_by`: search for items created by a specific user\n - `last_edited_at`: search for items last edited at a specific timestamp\n - `last_edited_by`: search for items last edited by a specific user\n - `search_native_query`: set to true to search the content of native queries\n - `vector_search_strategy`: for the semantic engine: `hnsw` (approximate index search, default),\n `brute-force` (exact filter-first search), or `hnsw-iterative-relaxed` / `hnsw-iterative-strict`\n (index-backed iterative scans with inline filters); ignored by other engines\n - `vector_search_ef_search`: override pgvector's `hnsw.ef_search` (1-1000) for the iterative strategies; (admin only)\n - `vector_search_max_scan_tuples`: override pgvector's `hnsw.max_scan_tuples` for the iterative strategies; (admin only)\n - `vector_search_explain`: set to true to record vector-scan instrumentation for this search (expensive); (admin only)\n - `verified`: set to true to search for verified items only (requires Content Management or Official Collections premium feature)\n - `ids`: search for items with those ids, works iff single value passed to `models`\n - `display_type`: search for cards/models with specific display types\n\n Note that not all item types support all filters, and the results will include only models that support the provided\n filters. For example:\n\n - The `created-by` filter supports dashboards, models, actions, and cards.\n - The `verified` filter supports models and cards.\n\n A search query that has both filters applied will only return models and cards.",
"description" : "Search for items in Metabase.\n For the list of supported models, check [[metabase.search.config/all-models]].\n\n `context` identifies the surface issuing the search; it selects the ranking weights and filter defaults.\n It defaults to `api`, the value for programmatic callers.\n\n Filters:\n - `archived`: set to true to search archived items only, default is false\n - `table_db_id`: search for tables, cards, and models of a certain DB\n - `models`: only search for items of specific models. If not provided, search for all models\n - `filter_items_in_personal_collection`: only search for items in personal collections\n - `created_at`: search for items created at a specific timestamp\n - `created_by`: search for items created by a specific user\n - `last_edited_at`: search for items last edited at a specific timestamp\n - `last_edited_by`: search for items last edited by a specific user\n - `search_native_query`: set to true to search the content of native queries\n - `vector_search_strategy`: for the semantic engine: `hnsw` (approximate index search, default),\n `brute-force` (exact filter-first search), or `hnsw-iterative-relaxed` / `hnsw-iterative-strict`\n (index-backed iterative scans with inline filters); ignored by other engines\n - `vector_search_ef_search`: override pgvector's `hnsw.ef_search` (1-1000) for the iterative strategies; (admin only)\n - `vector_search_max_scan_tuples`: override pgvector's `hnsw.max_scan_tuples` for the iterative strategies; (admin only)\n - `vector_search_explain`: set to true to record vector-scan instrumentation for this search (expensive); (admin only)\n - `verified`: set to true to search for verified items only (requires Content Management or Official Collections premium feature)\n - `ids`: search for items with those ids, works iff single value passed to `models`\n - `display_type`: search for cards/models with specific display types\n - `non_temporal_dim_ids`: search for cards/metrics/datasets with this exact set of non temporal dimension field IDs (requires appdb engine)\n - `has_temporal_dim`: set to true for cards/metrics/datasets with 1 or more temporal dimensions (requires appdb engine)\n\n Note that not all item types support all filters, and the results will include only models that support the provided\n filters. For example:\n\n - The `created-by` filter supports dashboards, models, actions, and cards.\n - The `verified` filter supports models and cards.\n\n A search query that has both filters applied will only return models and cards.",
"parameters" : [ {
"in" : "query",
"name" : "q",
@@ -44665,6 +44658,29 @@
"type" : "null"
} ]
}
}, {
"in" : "query",
"name" : "non_temporal_dim_ids",
"required" : false,
"schema" : {
"oneOf" : [ {
"type" : "string",
"minLength" : 1
}, {
"type" : "null"
} ]
}
}, {
"in" : "query",
"name" : "has_temporal_dim",
"required" : false,
"schema" : {
"oneOf" : [ {
"type" : "boolean"
}, {
"type" : "null"
} ]
}
} ],
"responses" : {
"2XX" : {
@@ -44986,6 +45002,29 @@
"type" : "null"
} ]
}
}, {
"in" : "query",
"name" : "non_temporal_dim_ids",
"required" : false,
"schema" : {
"oneOf" : [ {
"type" : "string",
"minLength" : 1
}, {
"type" : "null"
} ]
}
}, {
"in" : "query",
"name" : "has_temporal_dim",
"required" : false,
"schema" : {
"oneOf" : [ {
"type" : "boolean"
}, {
"type" : "null"
} ]
}
}, {
"in" : "query",
"name" : "expected_result_type",
@@ -45134,7 +45173,7 @@
"post" : {
"operationId" : "post-api-segment",
"summary" : "POST /api/segment",
"description" : "Create a new `Segment`. The Segment's table is derived from its `definition`.",
"description" : "Create a new `Segment`.",
"parameters" : [ ],
"responses" : {
"2XX" : {
@@ -45168,9 +45207,14 @@
"name" : {
"type" : "string",
"minLength" : 1
},
"table_id" : {
"description" : "value must be an integer greater than zero.",
"type" : "integer",
"minimum" : 1
}
},
"required" : [ "name", "definition" ]
"required" : [ "name", "table_id", "definition" ]
}
}
}

View File

@@ -197,8 +197,6 @@ See [Modular embedding parameters](./parameters.md#pass-parameter-values-to-embe
Locked parameters let you filter data without exposing the filter to the end-user. Locking parameters is useful for restricting data based on who's viewing the embed (for example, showing each customer only their own data).
![Locked parameters](./images/locked-parameters.png)
To use locked parameters, you need to:
1. Set the parameter to **Locked** in the embed settings.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -308,8 +308,6 @@ For an overview of how data permissions work in Metabase, see [Data permissions]
If every tenant has their data in a separate database, then instead of using permissions for data access control, you can use [**database routing**](../permissions/database-routing.md) to route queries to appropriate databases directly.
For a comparison of data isolation methods, see [Choosing a data isolation method](../permissions/data-isolation-methods.md).
- **Create queries** controls whether tenant users can create queries on the data they see. If you want to give your tenant users the ability to drill through (e.g., through `drills` parameter in [modular embedding](../embedding/modular-embedding.md)), you need to give them "Create queries" permissions, because a drill through is a new query.
- **Download results** controls, unsurprisingly, whether people can download results of queries. You need to set download permissions if you want to give your users the option to download their data as a spreadsheet (for example, through `with-downloads` parameter in [modular embedding](../embedding/modular-embedding.md)).
@@ -439,4 +437,3 @@ If you disable multi-tenant strategy, _all your tenant users will be deactivated
- [Embedding overview](./start.md)
- [JWT authentication](../people-and-groups/authenticating-with-jwt.md)
- [Permissions overview](../permissions/start.md)
- [Data isolation methods](../permissions/data-isolation-methods.md)

View File

@@ -1,90 +0,0 @@
---
title: Data isolation methods
summary: Compare the ways Metabase can restrict data access for internal analytics or multi-tenant setups, including row and column security, impersonation, database routing, and locked parameters.
---
# Data isolation methods
> Row and column security, impersonation, and database routing are only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Metabase offers several ways to ensure people only see the data they're supposed to see, whether you're restricting data by team for internal analytics or isolating customer data in a multi-tenant setup. The best method depends on how you store your data and where you want to enforce access rules:
- If all data lives in the same tables, use [row and column security](#row-and-column-security) to filter data in Metabase, or [impersonation](#impersonation) to enforce access rules at the database level.
- If each customer or team has their own database, use [database routing](#database-routing).
- If you're embedding dashboards for people without Metabase accounts, use [locked parameters](#locked-parameters).
You can combine these methods. For example, you can use locked parameters for a guest embed while using row and column security for signed-in users.
## Row and column security
[Row and column security](./row-and-column-security.md) lets you control which rows and columns each group can see. You can define rules that filter data based on [user attributes](../people-and-groups/managing.md#adding-a-user-attribute). Metabase applies your rules to questions you build in the query builder.
![Row and column security](./images/row-and-column-level-security.png)
Use row and column security when:
- You need granular control over which rows and columns each group or customer can see.
- You want to manage access rules in the Metabase UI instead of in your database.
Don't use row and column security when:
- People need to write native SQL queries. Row and column security doesn't apply to the SQL editor.
- You need column-level security on MongoDB or Druid, which only support row-level security.
## Impersonation
[Impersonation](./impersonation.md) lets your database control what each person can see. You define roles and row-level security policies in your database. Metabase connects using the role specified by each person's [user attribute](../people-and-groups/managing.md#adding-a-user-attribute), and your database enforces what that role can see.
![Impersonation](./images/connection-impersonation.png)
Use impersonation when:
- You want to centralize access logic in your database instead of in Metabase.
- People need to write native SQL queries. Since your database enforces the rules, the rules apply in the SQL editor too.
Don't use impersonation when:
- Your database [doesn't support it](./impersonation.md#databases-that-support-impersonation).
- You don't want to set up and maintain roles and policies in your database.
- You rely heavily on caching. You can't share cached results between roles.
## Database routing
[Database routing](./database-routing.md) sends each person's queries to their own database. You build questions and dashboards once against a primary database, and Metabase routes queries to the right destination database based on each person's [user attribute](../people-and-groups/managing.md#adding-a-user-attribute). Database routing requires that each group's data lives in a separate database, and that all destination databases have identical schemas.
![Database routing](./images/database-routing-diagram.png)
Use database routing when:
- You want the strongest isolation. Each group's data lives in a separate database.
- You want to avoid one group's queries affecting the performance of another group's queries.
Don't use database routing when:
- Groups share tables. Use [row and column security](#row-and-column-security) or [impersonation](#impersonation) instead.
- You want to manage access rules in the Metabase UI instead of maintaining a separate database for each group.
## Locked parameters
[Locked parameters](../embedding/guest-embedding.md#locked-parameters) filter an embedded dashboard or question before Metabase displays it. Your app passes the filter value in a signed JWT. The filter is invisible to the person viewing the embed, and they can't change it.
![Locked parameters](../embedding/images/locked-parameters.png)
Use locked parameters when:
- You want to restrict data without setting up Metabase accounts, user attributes, or permissions.
- You want to filter data without exposing the filter or its values to the people viewing the embed.
Don't use locked parameters when:
- You want account-level or role-based access control across your Metabase.
- You're working with sensitive data. Use a method that enforces access at the permissions or database level instead, like [row and column security](#row-and-column-security), [impersonation](#impersonation), or [database routing](#database-routing).
## Further reading
- [Permissions overview](./start.md)
- [Tenants](../embedding/tenants.md)
- [Row and column security](./row-and-column-security.md)
- [Impersonation](./impersonation.md)
- [Database routing](./database-routing.md)
- [Locked parameters for guest embeds](../embedding/guest-embedding.md#locked-parameters)

View File

@@ -9,8 +9,6 @@ summary: Route queries to different databases based on who's viewing them. Great
With database routing, an admin can build a question once using one database, and the question will run its query against a different database with the same schema depending on who is viewing the question.
![Database routing diagram](./images/database-routing-diagram.png)
Database routing is useful for:
- Managing modular and full app embedding setups where each customer has their own database with identical schemas.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

View File

@@ -10,8 +10,6 @@ This page covers the [View data](./data.md#view-data-permissions) permission lev
**Impersonation access** allows admins to "outsource" View data permissions to roles in your database. Admins can associate user attributes with database-defined roles and their privileges. If someone is in a group with their View data permission set to Impersonation, the person will be able to view and query data based on the privileges granted to the role specified by their user attribute.
![Connection impersonation](./images/connection-impersonation.png)
## Databases that support impersonation
For now, impersonation access is only available for the following databases:

View File

@@ -14,8 +14,6 @@ Row and column security lets you give granular permissions for different groups
You can use row and column security to set up [self-service analytics](https://www.metabase.com/learn/metabase-basics/embedding/multi-tenant-self-service-analytics), so that each of your customers can only view the rows that match their customer ID. For example, if you have an Accounts table with information about your customers, you can add permissions to the table so that each customer only sees the data relevant to them.
![Row and column security](./images/row-and-column-level-security.png)
> Row and column security was formerly called data sandboxing. It's the same feature, it just now has a more descriptive name.
## Row and column security examples

View File

@@ -28,10 +28,6 @@ Granting group access to different Metabase features.
Setting up row and column security to restrict access to rows and columns in tables.
## [Choosing a data isolation method](./data-isolation-methods.md)
Compare row and column security, impersonation, database routing, and locked parameters for multi-tenant setups.
## [Snippet folder permissions](./snippets.md)
Organize snippets into folders that require permissions to view.

View File

@@ -338,9 +338,7 @@
- the `instance_analytics_views` SQL files have changed since the last successful sync,
meaning a migration may have added a new view that isn't yet in `metabase_table`.
The two triggers share one sync because they both want the same operation. Runs synchronously
(not in a background future) so it stays inside the caller's cross-node cluster lock and
transaction — a sync on another thread would escape the lock and, on a transactional appdb,
deadlock against the caller's uncommitted `metabase_table` writes."
(not in a background future) so it stays serialized under the caller's cross-node cluster lock."
[audit-db engine-changed?]
(let [current (views-checksum)
views-stale? (and current (not= current (audit-app.settings/last-analytics-views-checksum)))]
@@ -478,11 +476,19 @@
;; serialize install+adjust+load+sync+reconcile across nodes so a rolling upgrade can't run an adjust or sync
;; against a half-adjusted schema (`with-duplicate-ops-prevented` is per-process only). The install runs inside the
;; lock too, so a node that acquires it after the installer committed sees the DB already exists and falls through
;; to a no-op rather than colliding on the audit DB primary key. The sync runs synchronously inside the lock so it
;; stays in the lock's transaction. If another node already holds the lock it is doing this same work, so we skip
;; rather than fail the boot.
;; to a no-op rather than colliding on the audit DB primary key. The sync runs synchronously so it stays serialized
;; under the lock. If another node already holds the lock it is doing this same work, so we skip rather than fail
;; the boot.
;;
;; The lock is :detached? so the pipeline does NOT run as one long transaction on the lock's connection: the serdes
;; load's per-entity transactions commit incrementally (restoring #74412's per-entity isolation) instead of piling
;; up thousands of savepoints/subtransaction XIDs and holding every row lock until one big commit at the end —
;; which stalled first boot for many minutes and blocked concurrent work (#78238). Safe because this whole pipeline
;; is idempotent and self-healing: the checksum only advances on completion and reconcile runs every boot, so a
;; crash mid-pipeline is repaired by the next boot.
(try
(cluster-lock/with-cluster-lock {:lock audit-db-cluster-lock :timeout-seconds 5 :retry-config {:max-retries 2}}
(cluster-lock/with-cluster-lock {:lock audit-db-cluster-lock :timeout-seconds 5 :retry-config {:max-retries 2}
:detached? true}
(u/prog1 (maybe-install-audit-db!)
(when-let [audit-db (t2/select-one :model/Database :is_audit true)]
((sync-util/with-duplicate-ops-prevented

View File

@@ -25,7 +25,6 @@
[metabase.sso.core :as sso]
[metabase.util.encryption :as encryption]
[metabase.util.i18n :refer [tru]]
[metabase.util.log :as log]
[metabase.util.malli.schema :as ms]
[metabase.util.password :as u.password]
[throttle.core :as throttle]
@@ -68,15 +67,9 @@
(t2/select-one-fn :credentials :model/AuthIdentity :user_id user-id :provider "password")]
(and password_hash (u.password/verify-password password password_salt password_hash)))
(when (sso/ldap-enabled)
;; an unreachable directory fails closed: re-auth is denied (the user retries when the
;; directory is back; admin/remove needs no re-auth), never an unhandled 500
(try
(when-let [user-email (t2/select-one-fn :email :model/User user-id)]
(when-let [user-info (sso/find-user user-email)]
(sso/verify-password user-info password)))
(catch Exception e
(log/warn e "LDAP re-auth failed because the directory is unreachable")
false)))))))
(when-let [user-email (t2/select-one-fn :email :model/User user-id)]
(when-let [user-info (sso/find-user user-email)]
(sso/verify-password user-info password))))))))
;; Notification emails here are fire-and-log by construction: the messages/send-mfa-*-email!
;; senders route through email/send-message!, which catches and logs delivery failures — so an
@@ -171,23 +164,14 @@
;;; -------------------------------------------------- Admin --------------------------------------------------
(api.macros/defendpoint :post "/admin/remove" :- nil
"Admin: remove *another* user's two-factor enrollment entirely — the lockout escape hatch for a
lost authenticator with no recovery codes. Never feature-gated (a lapsed license must not make
lockouts permanent). The affected user is notified by email. They re-enroll from scratch — there
is nothing to \"reset\", the secret lives on their device.
Removing your *own* enrollment is deliberately refused here: this is a user-management endpoint
that takes no second factor (the target can't produce one — that's why an admin is removing it),
and the admin UI never collects a code. Self-removal goes through the normal Security page, which
re-auths with a fresh factor via `/disable`. Without this guard a hijacked admin session could
strip its own 2FA with only a cookie, turning transient access into a permanent password bypass."
"Admin: remove a user's two-factor enrollment entirely — the lockout escape hatch for a lost
authenticator with no recovery codes. Never feature-gated (a lapsed license must not make
lockouts permanent). The affected user is notified by email. They re-enroll from scratch —
there is nothing to \"reset\", the secret lives on their device."
[_route-params
_query-params
{user-id :user_id} :- [:map [:user_id ms/PositiveInt]]]
(api/check-superuser)
(when (= user-id api/*current-user-id*)
(throw (ex-info (tru "You cannot administratively remove your own two-factor authentication. Please use the normal removal method in your account settings.")
{:status-code 400})))
(when (enrollment/disable! user-id)
(let [user (t2/select-one :model/User :id user-id)]
(messages/send-mfa-removed-by-admin-email! (:email user))

View File

@@ -5,8 +5,7 @@
[metabase-enterprise.mfa.enrollment :as enrollment]
[metabase-enterprise.mfa.totp :as totp]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[toucan2.core :as t2]))
[metabase.test.fixtures :as fixtures]))
(use-fixtures :once (fixtures/initialize :db :web-server :test-users))
@@ -23,23 +22,6 @@
(mt/user-http-request :crowberto :post 204 "ee/mfa/admin/remove" {:user_id user-id})
(is (nil? (enrollment/enrolled-method user-id)))))))
(deftest admin-cannot-self-remove-test
;; admin/remove is "manage OTHER users" — self-removal is refused so a hijacked admin cookie
;; can't strip its own 2FA (this endpoint takes no factor; a stolen session has none to give).
;; Removing your own enrollment goes through /disable, which re-auths with a fresh code.
(mt/with-temp [:model/AuthIdentity _ {:user_id (mt/user->id :crowberto)
:provider "totp"
:confirmed_at (t/instant)
:credentials {:secret (totp/generate-secret)}}]
(try
(testing "removing your own id is refused, enrollment intact"
(is (re-find #"cannot administratively remove your own"
(mt/user-http-request :crowberto :post 400 "ee/mfa/admin/remove"
{:user_id (mt/user->id :crowberto)})))
(is (= :totp (enrollment/enrolled-method (mt/user->id :crowberto)))))
(finally
(t2/delete! :model/AuthIdentity :user_id (mt/user->id :crowberto) :provider "totp")))))
(deftest admin-remove-is-audited-test
;; audit rows require the :audit-app feature at event time (premium-features/log-enabled?)
(mt/with-premium-features #{:audit-app}

View File

@@ -68,81 +68,6 @@
(is (true? (:mfa/pending? gated)))
(is (nil? (:mfa/first-factor gated)))))))))))
(deftest gate-sso-provider-matrix-test
(testing "every SSO provider is exempt for an enrolled user — the IdP owns MFA there"
(mt/with-premium-features #{:multi-factor-auth}
(mt/with-temporary-setting-values [mfa-enforcement :optional]
(with-enrolled-user!
(fn [user-id]
(let [result {:success? true :user {:id user-id}}]
(doseq [provider [:provider/google
:provider/saml
:provider/jwt
:provider/oidc
:provider/custom-oidc
:provider/slack-connect]]
(testing provider
(is (= result (auth-identity.provider/apply-mfa-gate provider result))))))))))))
(deftest gate-provider-registry-exhaustiveness-test
;; The gate's fall-through branch is exempt, so a provider nobody classified silently bypasses
;; MFA. This test forces the decision: every provider in the auth-identity hierarchy must appear
;; below. If you added a provider and landed here, decide its MFA posture — challenged providers
;; also go in `metabase-enterprise.mfa.gate/challenged-providers`, session-suppressed ones in
;; `session-suppressed-providers`; exemption is the default and needs only this map.
(let [classification {:provider/password :challenged
:provider/ldap :challenged
:provider/emailed-secret-password-reset :session-suppressed
;; IdP owns MFA for SSO logins
:provider/google :exempt
:provider/saml :exempt
:provider/jwt :exempt
:provider/oidc :exempt
:provider/custom-oidc :exempt
:provider/slack-connect :exempt
;; admin-granted, time-boxed, audited; staff can't hold the second factor
:provider/support-access-grant :exempt
;; abstract parent (reset/verification/magic links) — a concrete login
;; child must be classified here on its own when it ships
:provider/emailed-secret :exempt
;; the second factor itself, never a first factor
:provider/totp :exempt}]
(testing "every registered provider has a deliberate MFA classification"
(is (= (set (keys classification))
(set (descendants :metabase.auth-identity.provider/provider)))))
(testing "the gate's behavior matches each classification for an enrolled user"
(mt/with-premium-features #{:multi-factor-auth}
(mt/with-temporary-setting-values [mfa-enforcement :optional]
(with-enrolled-user!
(fn [user-id]
(let [result {:success? true :user {:id user-id}}]
(doseq [[provider posture] classification]
(testing provider
(let [gated (auth-identity.provider/apply-mfa-gate provider result)]
(case posture
:challenged (is (= :mfa-required (:success? gated)))
:session-suppressed (do (is (true? (:success? gated)))
(is (true? (:mfa/pending? gated))))
:exempt (is (= result gated))))))))))))))
(deftest gate-env-var-escape-hatch-test
;; The last-admin lockout recovery: the only superuser loses authenticator + recovery codes, so
;; nobody can log in to flip the setting. Restarting with MB_MFA_ENFORCEMENT=off must win over
;; the :optional stored in the DB and let them in single-step.
(mt/with-premium-features #{:multi-factor-auth}
(mt/with-temporary-setting-values [mfa-enforcement :optional]
(with-enrolled-user!
(fn [user-id]
(let [result {:success? true :user {:id user-id}}]
(is (= :mfa-required
(:success? (auth-identity.provider/apply-mfa-gate :provider/password result)))
"sanity: challenged before the override")
(mt/with-temp-env-var-value! [mb-mfa-enforcement "off"]
(is (false? (mfa.settings/mfa-enabled?))
"the env var beats the DB value")
(is (= result (auth-identity.provider/apply-mfa-gate :provider/password result))
"the gate no-ops, so the locked-out admin can log in"))))))))
(deftest mfa-enforcement-write-path-test
(mt/with-temporary-setting-values [mfa-enforcement :off]
(testing "enabling (:optional) requires the :multi-factor-auth feature (setup is gated; enforcement is not)"

View File

@@ -5,11 +5,9 @@
(:require
[clojure.test :refer :all]
[java-time.api :as t]
[metabase-enterprise.mfa.enrollment :as enrollment]
[metabase-enterprise.mfa.management :as mfa.management]
[metabase-enterprise.mfa.totp :as totp]
[metabase.session.api :as api.session]
[metabase.sso.core :as sso]
[metabase.sso.ldap-test-util :as ldap.test]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
@@ -75,35 +73,6 @@
(testing "the enroll endpoint's schema also refuses a blank password"
(mt/client session-key :post 400 "ee/mfa/enroll" {:password ""})))))))
(deftest ldap-directory-unreachable-fails-closed-test
;; Lockout-corner: the directory goes down while a user is enrolled. Re-auth (disable) must fail
;; closed as a denied verification — not a 500 — and must not touch the enrollment. The recovery
;; path that needs no directory, admin/remove, keeps working.
(mt/with-premium-features #{:multi-factor-auth}
(mt/with-temporary-setting-values [mfa-enforcement :optional]
(ldap.test/with-ldap-server!
(let [{session-key :id} (mt/client :post 200 "session" {:username sally-email
:password sally-directory-password})
user-id (t2/select-one-fn :id :model/User :email sally-email)
secret (totp/generate-secret)]
(try
(t2/insert! :model/AuthIdentity {:user_id user-id
:provider "totp"
:confirmed_at (t/instant)
:credentials {:secret secret}})
(with-redefs [sso/find-user (fn [& _] (throw (ex-info "connection refused" {})))]
(testing "enroll re-auth with the directory down is a 400, not a 500"
(is (=? {:errors {:password some?}}
(mt/client session-key :post 400 "ee/mfa/enroll"
{:password sally-directory-password}))))
(testing "the enrollment is untouched"
(is (= :totp (enrollment/enrolled-method user-id))))
(testing "admin/remove needs no directory and still recovers the account"
(mt/user-http-request :crowberto :post 204 "ee/mfa/admin/remove" {:user_id user-id})
(is (nil? (enrollment/enrolled-method user-id)))))
(finally
(t2/delete! :model/AuthIdentity :user_id user-id :provider "totp"))))))))
(deftest ldap-only-user-enrolls-with-directory-password-test
(mt/with-premium-features #{:multi-factor-auth}
(mt/with-temporary-setting-values [mfa-enforcement :optional]

View File

@@ -2,7 +2,6 @@
(:require
[clojure.test :refer :all]
[java-time.api :as t]
[metabase-enterprise.mfa.enrollment :as enrollment]
[metabase-enterprise.mfa.recovery-codes :as recovery-codes]
[metabase-enterprise.mfa.totp :as totp]
[metabase-enterprise.mfa.verification :as verification]
@@ -195,25 +194,3 @@
(t2/select-one-fn :credentials :model/AuthIdentity
:user_id user-id :provider "totp"))
"sanity: the old key can no longer read the row"))))))
(deftest lost-encryption-key-fails-closed-test
(testing "an instance started with the wrong key: verify errors (no bypass, no session), and
admin removal — which never decrypts — still recovers the account"
(let [secret (totp/generate-secret)
k1 "0123456789abcdef-key-A"
k2 "totally-different-key-B!"]
(mt/with-temp [:model/User {user-id :id} {}]
(encryption-test/with-secret-key k1
(t2/insert! :model/AuthIdentity {:user_id user-id
:provider "totp"
:confirmed_at (t/instant)
:credentials {:secret secret}}))
(encryption-test/with-secret-key k2
(testing "verification fails closed — an error, never a false-positive login"
(is (thrown? Exception
(verification/verify-attempt! user-id (totp/generate-code secret) (fresh-jti)))))
(testing "the enrollment row survives the failed attempt"
(is (= 1 (t2/count :auth_identity :user_id user-id :provider "totp"))))
(testing "disable! deletes without decrypting, so the admin escape hatch works"
(is (true? (enrollment/disable! user-id)))
(is (zero? (t2/count :auth_identity :user_id user-id :provider "totp")))))))))

View File

@@ -41,7 +41,17 @@
}
}
.ItemLink:hover,
.CollectionLink :hover {
color: var(--mb-color-core-brand);
.ItemLink {
font-size: inherit !important;
&:hover {
color: var(--mb-color-core-brand);
}
}
.ItemLink,
.CollectionLink {
:hover {
color: var(--mb-color-core-brand);
}
}

View File

@@ -3,6 +3,7 @@ import { useMemo } from "react";
import { getShortStrategyLabel } from "metabase/admin/performance/utils";
import { EllipsifiedCollectionPath } from "metabase/common/components/EllipsifiedPath/EllipsifiedCollectionPath";
import { Link } from "metabase/common/components/Link";
import { MaybeLink } from "metabase/common/components/MaybeLink/MaybeLink.styled";
import { useGetIcon } from "metabase/hooks/use-icon";
import { Box, Button, Ellipsified, FixedSizeIcon, Flex } from "metabase/ui";
import * as Urls from "metabase/urls";
@@ -47,20 +48,6 @@ export const TableRowForCacheableItem = ({
}
};
const cellContent = (
<Flex
className={StrategyEditorForQuestionsAndDashboardsS.ItemLink}
align="center"
wrap="nowrap"
gap="sm"
fw={700}
style={{ overflow: "hidden" }}
>
{iconName ? <FixedSizeIcon name={iconName} /> : <Box h="sm" w="md" />}
<Ellipsified>{name}</Ellipsified>
</Flex>
);
return (
<tr
className={
@@ -69,7 +56,26 @@ export const TableRowForCacheableItem = ({
: undefined
}
>
<td>{url ? <Link to={url}>{cellContent}</Link> : cellContent}</td>
<td>
<MaybeLink
className={StrategyEditorForQuestionsAndDashboardsS.ItemLink}
to={url}
>
<Flex
align="center"
wrap="nowrap"
gap="sm"
style={{ overflow: "hidden" }}
>
{iconName ? (
<FixedSizeIcon name={iconName} />
) : (
<Box h="sm" w="md" />
)}
<Ellipsified style={{ fontWeight: "bold" }}>{name}</Ellipsified>
</Flex>
</MaybeLink>
</td>
<td>
{collection && (
<Link

View File

@@ -14,6 +14,7 @@ import { SortableColumnHeader } from "metabase/common/components/ItemsTable/Base
import {
ColumnHeader,
ItemNameCell,
MaybeItemLink,
TBody,
Table,
TableColumn,
@@ -228,28 +229,36 @@ function preventDefault(event: MouseEvent) {
function NameCell({ metric }: { metric?: MetricResult }) {
const headingId = `metric-${metric?.id ?? "dummy"}-heading`;
return (
<ItemNameCell
data-testid="metric-name"
aria-labelledby={headingId}
{...nameProps}
>
<Flex align="center" gap="0.5rem" ps="1.4rem" pe="0.5rem">
<MaybeItemLink
to={
metric
? Urls.metric({ id: metric.id, name: metric.name, type: "metric" })
: undefined
}
style={{
// To align the icons with "Name" in the <th>
paddingInlineStart: "1.4rem",
paddingInlineEnd: ".5rem",
}}
onClick={preventDefault}
>
{metric ? (
<Link
to={Urls.metric({
id: metric.id,
name: metric.name,
type: "metric",
})}
onClick={preventDefault}
>
<EntityItem.Name name={metric.name} variant="list" id={headingId} />
</Link>
<EntityItem.Name
name={metric?.name || ""}
variant="list"
id={headingId}
/>
) : (
<SkeletonText />
)}
</Flex>
</MaybeItemLink>
</ItemNameCell>
);
}

View File

@@ -9,6 +9,7 @@ import { EntityItem } from "metabase/common/components/EntityItem";
import { SortableColumnHeader } from "metabase/common/components/ItemsTable/BaseItemsTable";
import {
ItemNameCell,
MaybeItemLink,
TBody,
Table,
TableColumn,
@@ -209,28 +210,35 @@ function NameCell({ model }: { model?: ModelResult }) {
const getIcon = useGetIcon();
const headingId = `model-${model?.id || "dummy"}-heading`;
const icon = getIcon(model ?? { model: "dataset" }) ?? { name: "folder" };
const name = <EntityItem.Name name={model?.name || ""} variant="list" />;
return (
<ItemNameCell data-testid="model-name" aria-labelledby={headingId}>
<Flex id={headingId} align="center" gap="0.5rem" ps="1.4rem" pe="0.5rem">
<MaybeItemLink
to={
model
? Urls.model({ id: model.id, name: model.name, type: "model" })
: undefined
}
style={{
// To align the icons with "Name" in the <th>
paddingInlineStart: "1.4rem",
paddingInlineEnd: ".5rem",
}}
onClick={preventDefault}
>
<EntityIcon
size="1rem"
{...icon}
color="icon-brand"
style={{ flexShrink: 0 }}
/>
{model ? (
<Link
to={Urls.model({ id: model.id, name: model.name, type: "model" })}
onClick={preventDefault}
style={{ overflow: "hidden" }}
>
{name}
</Link>
) : (
name
)}
</Flex>
{
<EntityItem.Name
name={model?.name || ""}
variant="list"
id={headingId}
/>
}
</MaybeItemLink>
</ItemNameCell>
);
}

View File

@@ -1,9 +1,25 @@
// eslint-disable-next-line no-restricted-imports
import { css } from "@emotion/react";
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import { MarkdownPreview } from "metabase/common/components/MarkdownPreview";
import { RawMaybeLink } from "metabase/common/components/MaybeLink/MaybeLink.styled";
import { Box, type BoxProps } from "metabase/ui";
export const ItemLink = styled(RawMaybeLink)<{ to?: string }>`
display: block;
height: min-content;
${(props) =>
props.to
? ""
: css`
${Body} {
cursor: default;
}
`}
`;
// Unjustified type cast. FIXME
export const ActionsContainer = styled(Box)<BoxProps>`
display: flex;

View File

@@ -9,10 +9,9 @@ import type {
} from "metabase/common/collections/types";
import { EntityIcon } from "metabase/common/components/EntityIcon";
import { EventSandbox } from "metabase/common/components/EventSandbox";
import { Link } from "metabase/common/components/Link";
import { useGetIcon } from "metabase/hooks/use-icon";
import { PLUGIN_MODERATION } from "metabase/plugins";
import { Box, Card, Flex, Skeleton, Tooltip } from "metabase/ui";
import { Card, Flex, Skeleton, Tooltip } from "metabase/ui";
import { modelToUrl } from "metabase/urls";
import type Database from "metabase-lib/v1/metadata/Database";
import type {
@@ -28,6 +27,7 @@ import {
Body,
Description,
Header,
ItemLink,
Title,
} from "./PinnedItemCard.styled";
@@ -119,74 +119,71 @@ function PinnedItemCard({
isCollectionItem(item) &&
(onCopy || onMove || createBookmark || deleteBookmark || collection);
const card = (
<Card p={0} radius="md" shadow="none" withBorder>
<Body style={item ? undefined : { cursor: "default" }}>
<Header>
<EntityIcon {...iconData} size="1.5rem" color="core-brand" />
<ActionsContainer h={item ? undefined : "2rem"}>
{hasActions && (
// This component is used within a `<Link>` component,
// so we must prevent events from triggering the activation of the link
<EventSandbox preventDefault sandboxedEvents={["onClick"]}>
<ActionMenu
databases={databases}
bookmarks={bookmarks}
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
item={item}
collection={collection}
onCopy={onCopy}
onMove={onMove}
/>
</EventSandbox>
)}
</ActionsContainer>
</Header>
{item ? (
<>
<Tooltip
label={item.name}
position="bottom"
disabled={!showTitleTooltip}
maw={TOOLTIP_MAX_WIDTH}
>
<Title
onMouseEnter={(e) => maybeEnableTooltip(e, setShowTitleTooltip)}
>
<Flex align="center" gap="0.5rem">
{item.name}
<PLUGIN_MODERATION.ModerationStatusIcon
status={item.moderated_status}
filled
size={14}
/>
</Flex>
</Title>
</Tooltip>
<Description tooltipMaxWidth={TOOLTIP_MAX_WIDTH}>
{item.description || DEFAULT_DESCRIPTION[item.model] || ""}
</Description>
</>
) : (
<>
<Skeleton natural h="1.5rem" />
<Skeleton natural mt="xs" mb="4px" h="1rem" />
</>
)}
</Body>
</Card>
);
const to = item ? (modelToUrl(item) ?? "/") : undefined;
return (
<Box
display="block"
h="min-content"
<ItemLink
className={className}
to={item ? (modelToUrl(item) ?? "/") : undefined}
onClick={onClick}
>
{to ? <Link to={to}>{card}</Link> : card}
</Box>
<Card p={0} radius="md" shadow="none" withBorder>
<Body>
<Header>
<EntityIcon {...iconData} size="1.5rem" color="core-brand" />
<ActionsContainer h={item ? undefined : "2rem"}>
{hasActions && (
// This component is used within a `<Link>` component,
// so we must prevent events from triggering the activation of the link
<EventSandbox preventDefault sandboxedEvents={["onClick"]}>
<ActionMenu
databases={databases}
bookmarks={bookmarks}
createBookmark={createBookmark}
deleteBookmark={deleteBookmark}
item={item}
collection={collection}
onCopy={onCopy}
onMove={onMove}
/>
</EventSandbox>
)}
</ActionsContainer>
</Header>
{item ? (
<>
<Tooltip
label={item.name}
position="bottom"
disabled={!showTitleTooltip}
maw={TOOLTIP_MAX_WIDTH}
>
<Title
onMouseEnter={(e) =>
maybeEnableTooltip(e, setShowTitleTooltip)
}
>
<Flex align="center" gap="0.5rem">
{item.name}
<PLUGIN_MODERATION.ModerationStatusIcon
status={item.moderated_status}
filled
size={14}
/>
</Flex>
</Title>
</Tooltip>
<Description tooltipMaxWidth={TOOLTIP_MAX_WIDTH}>
{item.description || DEFAULT_DESCRIPTION[item.model] || ""}
</Description>
</>
) : (
<>
<Skeleton natural h="1.5rem" />
<Skeleton natural mt="xs" mb="4px" h="1rem" />
</>
)}
</Body>
</Card>
</ItemLink>
);
}

View File

@@ -12,6 +12,7 @@ import {
import { EntityItem } from "metabase/common/components/EntityItem";
import { IconButtonWrapper } from "metabase/common/components/IconButtonWrapper";
import { Link } from "metabase/common/components/Link";
import { RawMaybeLink } from "metabase/common/components/MaybeLink/MaybeLink.styled";
import AdminS from "metabase/css/admin.module.css";
import type { IconProps, TextProps } from "metabase/ui";
import { FixedSizeIcon, Text } from "metabase/ui";
@@ -111,15 +112,17 @@ export const ItemButton = styled(Text)<
export const ItemLink = styled(Link)(itemLinkStyle);
export const MaybeItemLink = styled(RawMaybeLink)(itemLinkStyle);
export const ItemNameCell = styled.td`
padding: 0 !important;
& > * {
${ItemLink}, ${MaybeItemLink}, ${ItemButton} {
padding: 1em;
}
&:hover {
& > * {
${ItemLink}, ${MaybeItemLink}, ${ItemButton} {
color: var(--mb-color-core-brand);
}

View File

@@ -0,0 +1,48 @@
// eslint-disable-next-line no-restricted-imports
import { css } from "@emotion/react";
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import type { HTMLAttributes } from "react";
import type { LinkProps } from "metabase/common/components/Link";
import { Link } from "metabase/common/components/Link";
import { maybeColor } from "metabase/ui/utils/colors";
interface RawMaybeLinkProps {
to?: string;
activeColor?: string;
inactiveColor?: string;
isSingleLine?: boolean;
}
export function RawMaybeLink({
to,
activeColor,
inactiveColor,
isSingleLine,
...props
}: RawMaybeLinkProps & (LinkProps | HTMLAttributes<HTMLSpanElement>)) {
return to ? <Link to={to} {...props} /> : <span {...props} />;
}
const hoverStyle = (props: RawMaybeLinkProps) => css`
cursor: pointer;
${props.activeColor ? `color: ${maybeColor(props.activeColor)};` : ""}
`;
/**
* @deprecated styled components are deprecated
*/
export const MaybeLink = styled(RawMaybeLink)`
display: flex;
align-items: center;
font-size: 0.875em;
font-weight: bold;
${(props) =>
props.inactiveColor ? `color: ${maybeColor(props.inactiveColor)};` : ""}
min-width: ${(props) => (props.isSingleLine ? 0 : "")};
:hover {
${(props) => (props.to || props.onClick) && hoverStyle(props)}
}
`;

View File

@@ -1,10 +1,21 @@
import type { ReactNode } from "react";
import { t } from "ttag";
import { DataStudioBreadcrumbs } from "metabase/common/data-studio/components/DataStudioBreadcrumbs";
import { PageContainer } from "metabase/common/data-studio/components/PageContainer";
import { Stack } from "metabase/ui";
import {
PaneHeader,
PaneHeaderInput,
} from "metabase/common/data-studio/components/PaneHeader";
import { useSetting } from "metabase/common/hooks";
import { Link } from "metabase/router";
import { Group, Stack } from "metabase/ui";
import * as Urls from "metabase/urls";
import type { ScheduleDisplayType, TransformTagId } from "metabase-types/api";
import { JobHeader } from "../JobHeader";
import { NAME_MAX_LENGTH } from "../../constants";
import { LockedTransformsHoverCard } from "../LockedTransformsHoverCard/LockedTransformsHoverCard";
import { TransformBadge } from "../TransformBadge/TransformBadge";
import { ScheduleSection } from "./ScheduleSection";
import { TagSection } from "./TagSection";
@@ -18,7 +29,6 @@ type JobEditorProps = {
tabs?: ReactNode;
readOnly?: boolean;
isCheckingPermissions?: boolean;
showMetabotButton?: boolean;
onNameChange: (name: string) => void;
onScheduleChange: (
schedule: string,
@@ -34,21 +44,48 @@ export function JobEditor({
tabs,
readOnly,
isCheckingPermissions,
showMetabotButton,
onNameChange,
onScheduleChange,
onTagListChange,
}: JobEditorProps) {
const isMeterLocked = useSetting("transforms-meter-locked");
return (
<PageContainer data-testid="transforms-job-editor" gap="2.5rem">
<JobHeader
job={job}
<PaneHeader
title={
<Group align="center" gap="sm" wrap="nowrap">
<PaneHeaderInput
initialValue={job.name}
maxLength={NAME_MAX_LENGTH}
onChange={onNameChange}
readOnly={readOnly}
/>
{isMeterLocked && (
<LockedTransformsHoverCard>
<TransformBadge bg="background_surface-warning-strong">{t`Disabled`}</TransformBadge>
</LockedTransformsHoverCard>
)}
{!isMeterLocked && !job.active && (
<TransformBadge bg="background_surface-warning-strong">
{t`Disabled`}
</TransformBadge>
)}
</Group>
}
py={0}
breadcrumbs={
<DataStudioBreadcrumbs>
<Link key="transform-job-list" to={Urls.transformJobList()}>
{t`Jobs`}
</Link>
{job.name}
</DataStudioBreadcrumbs>
}
menu={menu}
actions={actions}
tabs={tabs}
readOnly={readOnly}
showMetabotButton={showMetabotButton}
onNameChange={onNameChange}
data-testid="jobs-header"
/>
<Stack gap="3.5rem">
<ScheduleSection

View File

@@ -1,78 +0,0 @@
import type { ReactNode } from "react";
import { t } from "ttag";
import { DataStudioBreadcrumbs } from "metabase/common/data-studio/components/DataStudioBreadcrumbs";
import {
PaneHeader,
PaneHeaderInput,
} from "metabase/common/data-studio/components/PaneHeader";
import { useSetting } from "metabase/common/hooks";
import { Link } from "metabase/router";
import { Group } from "metabase/ui";
import * as Urls from "metabase/urls";
import { NAME_MAX_LENGTH } from "../../constants";
import type { TransformJobInfo } from "../JobEditor/types";
import { LockedTransformsHoverCard } from "../LockedTransformsHoverCard/LockedTransformsHoverCard";
import { TransformBadge } from "../TransformBadge/TransformBadge";
type JobHeaderProps = {
job: TransformJobInfo;
menu?: ReactNode;
actions?: ReactNode;
tabs?: ReactNode;
readOnly?: boolean;
showMetabotButton?: boolean;
onNameChange: (name: string) => void;
};
export function JobHeader({
job,
menu,
actions,
tabs,
readOnly,
showMetabotButton,
onNameChange,
}: JobHeaderProps) {
const isMeterLocked = useSetting("transforms-meter-locked");
return (
<PaneHeader
title={
<Group align="center" gap="sm" wrap="nowrap">
<PaneHeaderInput
initialValue={job.name}
maxLength={NAME_MAX_LENGTH}
onChange={onNameChange}
readOnly={readOnly}
/>
{isMeterLocked && (
<LockedTransformsHoverCard>
<TransformBadge bg="background_surface-warning-strong">{t`Disabled`}</TransformBadge>
</LockedTransformsHoverCard>
)}
{!isMeterLocked && !job.active && (
<TransformBadge bg="background_surface-warning-strong">
{t`Disabled`}
</TransformBadge>
)}
</Group>
}
py={0}
breadcrumbs={
<DataStudioBreadcrumbs>
<Link key="transform-job-list" to={Urls.transformJobList()}>
{t`Jobs`}
</Link>
{job.name}
</DataStudioBreadcrumbs>
}
menu={menu}
actions={actions}
tabs={tabs}
showMetabotButton={showMetabotButton}
data-testid="jobs-header"
/>
);
}

View File

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

View File

@@ -1,46 +0,0 @@
import { useMemo } from "react";
import { t } from "ttag";
import {
skipToken,
useListTransformJobTransformsQuery,
useUpdateTransformJobMutation,
} from "metabase/api";
import { useMetadataToasts } from "metabase/metadata/hooks";
import type { TransformJobId } from "metabase-types/api";
import {
canEditTransform,
useTransformPermissions,
} from "./use-transform-permissions";
export function useJobHeaderState(jobId: TransformJobId | undefined) {
const [updateJob] = useUpdateTransformJobMutation();
const { sendErrorToast, sendSuccessToast } = useMetadataToasts();
const { transformsDatabases } = useTransformPermissions();
const { data: transforms, isLoading: isCheckingPermissions } =
useListTransformJobTransformsQuery(jobId ?? skipToken);
const readOnly = useMemo(() => {
if (!transformsDatabases || !transforms) {
return true;
}
return !transforms.every((transform) =>
canEditTransform(transform, transformsDatabases),
);
}, [transforms, transformsDatabases]);
const handleNameChange = async (name: string) => {
if (jobId === undefined) {
return;
}
const { error } = await updateJob({ id: jobId, name });
if (error) {
sendErrorToast(t`Failed to update job name`);
} else {
sendSuccessToast(t`Job name updated`);
}
};
return { readOnly, isCheckingPermissions, onNameChange: handleNameChange };
}

View File

@@ -1,15 +1,18 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { t } from "ttag";
import {
skipToken,
useGetTransformJobQuery,
useListTransformJobTransformsQuery,
useUpdateTransformJobMutation,
} from "metabase/api";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { useMetadataToasts } from "metabase/metadata/hooks";
import { useJobHeaderState } from "metabase/transforms/hooks/use-job-header-state";
import { useTransformPermissions } from "metabase/transforms/hooks/use-transform-permissions";
import {
canEditTransform,
useTransformPermissions,
} from "metabase/transforms/hooks/use-transform-permissions";
import { Center } from "metabase/ui";
import * as Urls from "metabase/urls";
import type {
@@ -47,9 +50,16 @@ export function JobPage({ params }: JobPageProps) {
pollingInterval: isPolling ? POLLING_INTERVAL : undefined,
});
const { isLoadingDatabases, databasesError } = useTransformPermissions();
const { readOnly, isCheckingPermissions, onNameChange } =
useJobHeaderState(jobId);
const { transformsDatabases, isLoadingDatabases, databasesError } =
useTransformPermissions();
const { data: transforms, isLoading: isLoadingTransforms } =
useListTransformJobTransformsQuery(jobId || skipToken);
const readOnly = useMemo(() => {
if (!transformsDatabases || !transforms) {
return true;
}
return !transforms.every((t) => canEditTransform(t, transformsDatabases));
}, [transforms, transformsDatabases]);
if (isPolling !== isPollingNeeded(job)) {
setIsPolling(isPollingNeeded(job));
@@ -69,8 +79,7 @@ export function JobPage({ params }: JobPageProps) {
<JobPageBody
job={job}
readOnly={readOnly}
isCheckingPermissions={isCheckingPermissions}
onNameChange={onNameChange}
isCheckingPermissions={isLoadingTransforms}
/>
);
}
@@ -79,19 +88,30 @@ type JobPageBodyProps = {
job: TransformJob;
readOnly?: boolean;
isCheckingPermissions?: boolean;
onNameChange: (name: string) => void;
};
function JobPageBody({
job,
readOnly,
isCheckingPermissions,
onNameChange,
}: JobPageBodyProps) {
const [updateJob] = useUpdateTransformJobMutation();
const { sendErrorToast, sendSuccessToast, sendUndoToast } =
useMetadataToasts();
const handleNameChange = async (name: string) => {
const { error } = await updateJob({
id: job.id,
name,
});
if (error) {
sendErrorToast(t`Failed to update job name`);
} else {
sendSuccessToast(t`Job name updated`);
}
};
const handleScheduleChange = async (
schedule: string,
uiDisplayType: ScheduleDisplayType,
@@ -141,8 +161,7 @@ function JobPageBody({
tabs={<JobTabs jobId={job.id} />}
readOnly={readOnly}
isCheckingPermissions={isCheckingPermissions}
showMetabotButton
onNameChange={onNameChange}
onNameChange={handleNameChange}
onScheduleChange={handleScheduleChange}
onTagListChange={handleTagListChange}
/>

View File

@@ -1,10 +1,8 @@
import {
setupDatabaseListEndpoint,
setupUserMetabotPermissionsEndpoint,
} from "__support__/server-mocks";
import fetchMock from "fetch-mock";
import { setupDatabaseListEndpoint } from "__support__/server-mocks";
import {
setupGetTransformJobEndpoint,
setupListTransformJobTransformsEndpoint,
setupListTransformTagsEndpoint,
} from "__support__/server-mocks/transform";
import { act, renderWithProviders, screen, within } from "__support__/ui";
@@ -32,8 +30,7 @@ const setup = ({
setupGetTransformJobEndpoint(job);
setupDatabaseListEndpoint([createMockDatabase()]);
setupListTransformTagsEndpoint([]);
setupUserMetabotPermissionsEndpoint();
setupListTransformJobTransformsEndpoint(job.id, [], {
fetchMock.get(`path:/api/transform-job/${job.id}/transforms`, [], {
delay: transformsDelay,
});

View File

@@ -11,11 +11,15 @@ import {
} from "metabase/api";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { PaginationControls } from "metabase/common/components/PaginationControls";
import { DataStudioBreadcrumbs } from "metabase/common/data-studio/components/DataStudioBreadcrumbs";
import {
PaneHeader,
PanelHeaderTitle,
} from "metabase/common/data-studio/components/PaneHeader";
import { usePageTitle } from "metabase/hooks/use-page-title";
import { useDispatch } from "metabase/redux";
import { replace } from "metabase/router";
import { Link, replace } from "metabase/router";
import { POLLING_INTERVAL } from "metabase/transforms/constants";
import { useJobHeaderState } from "metabase/transforms/hooks/use-job-header-state";
import { formatRunMethod, formatStatus } from "metabase/transforms/utils";
import { Center, Flex, Group, Select, Stack } from "metabase/ui";
import * as Urls from "metabase/urls";
@@ -28,8 +32,6 @@ import {
type TransformRunMethod,
} from "metabase-types/api";
import { JobHeader } from "../../components/JobHeader";
import { JobMoreMenu } from "../../components/JobMoreMenu";
import { JobTabs } from "../../components/JobTabs";
import S from "./JobRunListPage.module.css";
@@ -61,7 +63,6 @@ export function JobRunListPage({ params, location }: JobRunListPageProps) {
const dispatch = useDispatch();
const { data: job } = useGetTransformJobQuery(jobId ?? skipToken);
const { readOnly, onNameChange } = useJobHeaderState(jobId);
const { data, isLoading, error } = useListTransformJobRunsQuery(
jobId != null
@@ -163,16 +164,18 @@ export function JobRunListPage({ params, location }: JobRunListPageProps) {
data-testid="job-run-list"
>
<Stack className={S.main} flex={1} px="3.5rem" pb="md" gap={0}>
{job !== undefined && (
<JobHeader
job={job}
menu={!readOnly && <JobMoreMenu job={job} />}
tabs={<JobTabs jobId={job.id} />}
readOnly={readOnly}
showMetabotButton
onNameChange={onNameChange}
/>
)}
<PaneHeader
title={job != null && <PanelHeaderTitle>{job.name}</PanelHeaderTitle>}
tabs={jobId != null && <JobTabs jobId={jobId} />}
breadcrumbs={
<DataStudioBreadcrumbs>
<Link to={Urls.transformJobList()}>{t`Jobs`}</Link>
{job?.name ?? t`Run history`}
</DataStudioBreadcrumbs>
}
py={0}
showMetabotButton
/>
{isLoading || error != null ? (
<Center h="100%">
<LoadingAndErrorWrapper loading={isLoading} error={error} />

View File

@@ -2,11 +2,9 @@ import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock";
import {
setupDatabaseListEndpoint,
setupGetTransformJobEndpoint,
setupListJobRunTransformRunsEndpoint,
setupListTransformJobRunsEndpoint,
setupListTransformJobTransformsEndpoint,
setupUserMetabotPermissionsEndpoint,
} from "__support__/server-mocks";
import {
@@ -24,7 +22,6 @@ import type {
TransformRunForJobRun,
} from "metabase-types/api";
import {
createMockDatabase,
createMockListTransformJobRunsResponse,
createMockTransformJob,
createMockTransformJobRun,
@@ -51,8 +48,6 @@ function setup({
setupUserMetabotPermissionsEndpoint();
setupGetTransformJobEndpoint(job);
setupDatabaseListEndpoint([createMockDatabase()]);
setupListTransformJobTransformsEndpoint(JOB_ID, []);
setupListTransformJobRunsEndpoint(JOB_ID, () =>
createMockListTransformJobRunsResponse({
data: currentRuns,

View File

@@ -43,13 +43,8 @@ export function setupListTransformJobsEndpoint(jobs: TransformJob[]) {
export function setupListTransformJobTransformsEndpoint(
jobId: TransformJobId,
transforms: Transform[],
options?: { delay?: number },
) {
fetchMock.get(
`path:/api/transform-job/${jobId}/transforms`,
transforms,
options,
);
fetchMock.get(`path:/api/transform-job/${jobId}/transforms`, transforms);
}
export function setupListTransformJobTransformsEndpointWithError(

View File

@@ -11733,6 +11733,7 @@ databaseChangeLog:
- not:
- indexExists:
indexName: idx_security_advisory_acknowledged_by
tableName: security_advisory
changes:
- createIndex:
tableName: security_advisory
@@ -11750,6 +11751,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_security_advisory_acknowledged_by
foreignKeyTableName: security_advisory
changes:
- addForeignKeyConstraint:
baseTableName: security_advisory
@@ -11768,6 +11770,7 @@ databaseChangeLog:
- not:
- indexExists:
indexName: idx_security_advisory_match_status
tableName: security_advisory
changes:
- createIndex:
tableName: security_advisory

View File

@@ -2240,6 +2240,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_document_made_public_by_id
foreignKeyTableName: document
changes:
- addForeignKeyConstraint:
baseTableName: document

View File

@@ -118,6 +118,7 @@ databaseChangeLog:
- not:
foreignKeyConstraintExists:
foreignKeyName: fk_transform_collection_id
foreignKeyTableName: transform
changes:
- addForeignKeyConstraint:
baseTableName: transform
@@ -1526,6 +1527,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_collection_workspace_id
foreignKeyTableName: collection
changes:
- addForeignKeyConstraint:
baseTableName: collection
@@ -1629,6 +1631,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_log_workspace_id
foreignKeyTableName: workspace_log
changes:
- addForeignKeyConstraint:
baseTableName: workspace_log
@@ -1864,6 +1867,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_transform_workspace_id
foreignKeyTableName: workspace_transform
changes:
- addForeignKeyConstraint:
baseTableName: workspace_transform
@@ -1882,6 +1886,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_transform_global_id
foreignKeyTableName: workspace_transform
changes:
- addForeignKeyConstraint:
baseTableName: workspace_transform
@@ -1900,6 +1905,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_transform_collection_id
foreignKeyTableName: workspace_transform
changes:
- addForeignKeyConstraint:
baseTableName: workspace_transform
@@ -1918,6 +1924,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_transform_creator_id
foreignKeyTableName: workspace_transform
changes:
- addForeignKeyConstraint:
baseTableName: workspace_transform
@@ -2119,6 +2126,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_input_workspace_id
foreignKeyTableName: workspace_input
changes:
- addForeignKeyConstraint:
baseTableName: workspace_input
@@ -2137,6 +2145,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_input_table_id
foreignKeyTableName: workspace_input
changes:
- addForeignKeyConstraint:
baseTableName: workspace_input
@@ -2307,6 +2316,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_output_workspace_id
foreignKeyTableName: workspace_output
changes:
- addForeignKeyConstraint:
baseTableName: workspace_output
@@ -2325,6 +2335,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_output_ref_id
foreignKeyTableName: workspace_output
changes:
- addForeignKeyConstraint:
baseTableName: workspace_output
@@ -2415,6 +2426,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_merge_workspace_id
foreignKeyTableName: workspace_merge
changes:
- addForeignKeyConstraint:
baseTableName: workspace_merge
@@ -2433,6 +2445,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_merge_creator_id
foreignKeyTableName: workspace_merge
changes:
- addForeignKeyConstraint:
baseTableName: workspace_merge
@@ -2555,6 +2568,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_merge_transform_workspace_merge_id
foreignKeyTableName: workspace_merge_transform
changes:
- addForeignKeyConstraint:
baseTableName: workspace_merge_transform
@@ -2573,6 +2587,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_merge_transform_workspace_id
foreignKeyTableName: workspace_merge_transform
changes:
- addForeignKeyConstraint:
baseTableName: workspace_merge_transform
@@ -2591,6 +2606,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_merge_transform_transform_id
foreignKeyTableName: workspace_merge_transform
changes:
- addForeignKeyConstraint:
baseTableName: workspace_merge_transform
@@ -2609,6 +2625,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_merge_transform_merging_user_id
foreignKeyTableName: workspace_merge_transform
changes:
- addForeignKeyConstraint:
baseTableName: workspace_merge_transform
@@ -2837,6 +2854,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_output_external_workspace_id
foreignKeyTableName: workspace_output_external
changes:
- addForeignKeyConstraint:
baseTableName: workspace_output_external
@@ -2855,6 +2873,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_output_external_transform_id
foreignKeyTableName: workspace_output_external
changes:
- addForeignKeyConstraint:
baseTableName: workspace_output_external
@@ -2989,6 +3008,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_input_external_workspace_id
foreignKeyTableName: workspace_input_external
changes:
- addForeignKeyConstraint:
baseTableName: workspace_input_external
@@ -3103,6 +3123,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_workspace_graph_workspace_id
foreignKeyTableName: workspace_graph
changes:
- addForeignKeyConstraint:
baseTableName: workspace_graph

View File

@@ -171,6 +171,7 @@ databaseChangeLog:
- not:
- foreignKeyConstraintExists:
foreignKeyName: fk_metabot_source_feedback_message_id
foreignKeyTableName: metabot_source_feedback
changes:
- addForeignKeyConstraint:
baseTableName: metabot_source_feedback

View File

@@ -40,6 +40,7 @@ databaseChangeLog:
- not:
- indexExists:
indexName: idx_task_run_notification_id_started_at
tableName: task_run
changes:
- createIndex:
tableName: task_run

View File

@@ -105,21 +105,73 @@
result-set (.executeQuery stmt)]
(when-not (.next result-set)
;; this record will not be visible until the tx commits, so there's no need to lock it
;; we instead rely on concurrent threads having constraint violation trying to insert their own record
(t2/query-one {:insert-into [:metabase_cluster_lock]
:columns [:lock_name]
:values [[lock-name-str]]})))
;; we instead rely on concurrent threads having constraint violation trying to insert their own record.
;; the insert must go on `conn` explicitly: in detached mode there is no ambient transaction, and an
;; insert on a pooled connection would commit immediately without us holding the row lock
(t2/query-one conn {:insert-into [:metabase_cluster_lock]
:columns [:lock_name]
:values [[lock-name-str]]})))
(log/debugf "Obtained cluster lock: %s (%s)" lock-name-str mode))
(def ^:private ^:dynamic *detached-locks-held*
"Map of lock-name-str -> mode for detached cluster locks held by the current thread. Used to make
re-acquisition of a held detached lock a no-op: a second acquisition would run on a second dedicated
connection and deadlock against our own row lock."
{})
(defn- held-mode-covers?
"Whether an already-held detached lock in `held-mode` satisfies a request for `requested-mode`."
[held-mode requested-mode]
(or (= held-mode :exclusive)
(= held-mode requested-mode)))
(defn- detached-held? [{:keys [lock-name-str mode]}]
(when-let [held-mode (*detached-locks-held* lock-name-str)]
(held-mode-covers? held-mode mode)))
(defn- do-with-cluster-locks*
"Acquire all `locks` (each a `{:lock-name-str, :mode}` map) inside a single
transaction, then run `thunk`."
transaction, then run `thunk`. Locks this thread already holds detached are skipped -- acquiring them
here would block against our own dedicated connection."
[locks timeout-seconds thunk]
(t2/with-transaction [conn]
(doseq [{:keys [lock-name-str mode]} locks]
(doseq [{:keys [lock-name-str mode] :as lock} locks
:when (not (detached-held? lock))]
(acquire-lock-row! conn lock-name-str timeout-seconds mode))
(thunk)))
(defn- do-with-detached-cluster-locks*
"Like [[do-with-cluster-locks*]], but holds the lock rows on a dedicated unshared connection
(see [[metabase.app-db.connection/with-unshared-connection]]) while `thunk` runs on ordinary pooled
connections: `thunk`'s toucan work commits incrementally in its own short transactions instead of
riding one long transaction on the lock's connection. The locks are released when `thunk` completes
(commit) or throws (the pool rolls the dedicated connection back on check-in) -- but `thunk`'s own
already-committed work is NOT rolled back on a throw.
Requesting `:exclusive` while already holding the same lock detached in `:share` mode is not
supported (upgrading would self-deadlock) and throws."
[locks timeout-seconds thunk]
(let [needed (remove (fn [{:keys [lock-name-str mode] :as lock}]
(when (and (contains? *detached-locks-held* lock-name-str)
(not (detached-held? lock)))
(throw (ex-info "Cannot upgrade a held detached cluster lock from :share to :exclusive"
{:lock-name lock-name-str :held (*detached-locks-held* lock-name-str) :requested mode})))
(detached-held? lock))
locks)]
(if (empty? needed)
(thunk)
(mdb.connection/with-unshared-connection [conn]
(.setAutoCommit ^Connection conn false)
(doseq [{:keys [lock-name-str mode]} needed]
(acquire-lock-row! conn lock-name-str timeout-seconds mode))
(let [result (binding [*detached-locks-held* (into *detached-locks-held*
(map (juxt :lock-name-str :mode))
needed)]
(thunk))]
;; releases the row locks and persists any lock rows we inserted
(.commit ^Connection conn)
result)))))
;; ---------- h2 in-process rw locks ----------
;;
;; h2 does not respect query timeout when taking SELECT ... FOR UPDATE locks, and
@@ -178,18 +230,24 @@
{:locks [(normalize-lock-spec opts)]}
(map? opts)
(let [{:keys [lock locks timeout-seconds retry-config retry-transient?]} opts]
(let [{:keys [lock locks timeout-seconds retry-config retry-transient? detached?]} opts]
(when (and lock locks)
(throw (ex-info "Cluster-lock opts must specify exactly one of :lock or :locks"
{:opts opts})))
(when-not (or lock locks)
(throw (ex-info "Cluster-lock opts must specify :lock or :locks" {:opts opts})))
(when (and detached? retry-transient?)
;; :retry-transient? re-runs the body assuming a rollback undid its work; detached bodies commit
;; incrementally, so a re-run would double-apply everything before the failure
(throw (ex-info "Cluster-lock opts :detached? and :retry-transient? are mutually exclusive"
{:opts opts})))
(cond-> {:locks (if lock
[(normalize-lock-spec (if (map? lock)
lock
{:lock lock :mode (or (:mode opts) :exclusive)}))]
(mapv normalize-lock-spec locks))
:retry-transient? (boolean retry-transient?)}
:retry-transient? (boolean retry-transient?)
:detached? (boolean detached?)}
timeout-seconds (assoc :timeout-seconds timeout-seconds)
retry-config (assoc :retry-config retry-config)))
@@ -227,7 +285,16 @@
replicated across nodes, so the lock can't serialize writers and the conflicting
commit comes back as a deadlock. Only opt in when the body is safe to re-run from
scratch — idempotent, with no side effects outside the appdb transaction (a
rolled-back deadlock undoes only the db writes, not external calls)."
rolled-back deadlock undoes only the db writes, not external calls).
`:detached?` (default false) holds the lock rows on a dedicated connection instead
of the caller's transaction: the body's toucan work then runs on ordinary pooled
connections in its own short transactions, committing incrementally, rather than
riding one long transaction that holds the lock. Use for long-running locked work
(e.g. the audit DB install/load/sync at boot) whose body is idempotent/self-healing —
a failure mid-body does NOT roll back its already-committed work. Re-acquiring a
lock this thread already holds detached is a no-op (in both detached and
transactional form). Mutually exclusive with `:retry-transient?`."
[opts :- [:or
:keyword
[:map
@@ -240,22 +307,25 @@
[:mode {:optional true} [:enum :exclusive :share]]
[:timeout-seconds {:optional true} :int]
[:retry-config {:optional true} [:ref ::retry/retry-overrides]]
[:retry-transient? {:optional true} :boolean]]]
[:retry-transient? {:optional true} :boolean]
[:detached? {:optional true} :boolean]]]
thunk :- ifn?]
(let [{:keys [locks timeout-seconds retry-config retry-transient?]
(let [{:keys [locks timeout-seconds retry-config retry-transient? detached?]
:or {timeout-seconds cluster-lock-timeout-seconds}} (parse-opts opts)]
(cond
;; h2 does not respect the query timeout when taking the lock and is not cross-process,
;; so we fall back to an in-process ReentrantReadWriteLock per lock name.
;; so we fall back to an in-process ReentrantReadWriteLock per lock name. The h2 locks never
;; hold a transaction open, so :detached? is already the behavior and needs no special casing.
(= (mdb.connection/db-type) :h2)
(do-with-h2-cluster-locks* locks thunk)
:else
(let [config (assoc (merge default-retry-config retry-config)
:retry-if (fn [_ e] (retry-if-error? retry-transient? e)))]
:retry-if (fn [_ e] (retry-if-error? retry-transient? e)))
impl (if detached? do-with-detached-cluster-locks* do-with-cluster-locks*)]
(try
(retry/with-retry config
(do-with-cluster-locks* locks timeout-seconds thunk))
(impl locks timeout-seconds thunk))
(catch Throwable e
;; only a genuine lock-acquisition failure gets the "Failed to obtain cluster lock" wrapper;
;; an exhausted transient body error (e.g. deadlock) propagates raw so the message stays truthful.

View File

@@ -300,6 +300,37 @@
(run-after-commit-callbacks! callbacks))
result)))
;;;; Unshared connections
;;;;
;;;; A connection that holds fragile long-lived state -- a streaming result set (postgres portal), a row
;;;; lock held while other work runs -- must not be picked up by ambient connection reuse: a borrower that
;;;; inherits it via [[toucan2.connection/*current-connectable*]] and commits destroys that state (see
;;;; #78238 and the revert of #76645). [[toucan2.connection/unshared-connection!]] makes toucan use such a
;;;; connection only when passed it explicitly, and never advertise it ambiently -- ambient work inside
;;;; `body` resolves to the default connectable and runs on other pooled connections.
(defn do-with-unshared-connection
"Impl for [[with-unshared-connection]]."
[f]
(with-open [conn (.getConnection (data-source))]
(f (t2.conn/unshared-connection! conn))))
(defmacro with-unshared-connection
"Execute `body` with `conn-binding` bound to a fresh app-db connection that ambient connection reuse can
never pick up: toucan runs queries and transactions on it when `body` passes it explicitly, but never
binds it as `*current-connectable*`, so toucan calls that resolve their connection ambiently -- including
mid-reduction of a long-running query on this connection -- run (and commit) on other pooled connections.
In every other respect this is an ordinary JDBC connection, configured by `body`: e.g. call
`.setAutoCommit false` for a streaming result set (postgres portal/cursor) or a held row lock. It is
closed when `body` ends; the pool resets its state (rolling back any unresolved transaction) on check-in.
(mdb/with-unshared-connection [conn]
(.setAutoCommit conn false)
(reduce rf init (t2/reducible-query conn a-huge-query)))"
[[conn-binding] & body]
`(do-with-unshared-connection (fn [~conn-binding] ~@body)))
(methodical/defmethod t2.pipeline/transduce-query :before :default
"Make sure application database calls are not done inside core.async dispatch pool threads. This is done relatively
early in the pipeline so the stacktrace when this fails isn't super enormous."

View File

@@ -42,7 +42,8 @@
in-transaction?
quoting-style
unique-identifier
transaction-state]
transaction-state
with-unshared-connection]
[mdb.connection-pool-setup
recent-activity?]
[mdb.data-source

View File

@@ -201,8 +201,6 @@
(defmethod initialize! :default [_]) ; no-op
;; bump
(defmulti display-name
"A nice name for the driver that we'll display to in the admin panel, e.g. \"PostgreSQL\" for `:postgres`. Default
implementation capitializes the name of the driver, e.g. `:oracle` becomes \"Oracle\".

View File

@@ -5,6 +5,7 @@
[metabase.app-db.cluster-lock :as sut]
[metabase.app-db.core :as mdb]
[metabase.app-db.transient-error :as transient-error]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[metabase.util :as u]
[toucan2.core :as t2])
@@ -226,3 +227,61 @@
(is (= [[:a :failed :timeout]
[:b :done]]
@results))))))
(deftest detached-lock-basic-test
(testing "returns the body's value and works non-concurrently"
(is (= :ok (sut/with-cluster-lock {:lock ::detached-basic :detached? true} :ok))))
(testing ":detached? and :retry-transient? are mutually exclusive"
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"mutually exclusive"
(sut/with-cluster-lock {:lock ::detached-basic :detached? true :retry-transient? true}
:nope)))))
(deftest detached-lock-body-commits-incrementally-test
(let [email (mt/random-email)]
(try
(testing "body work commits as it goes and survives a later throw in the body"
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"boom"
(sut/with-cluster-lock {:lock ::detached-commit :detached? true}
(t2/insert! :model/User (assoc (mt/with-temp-defaults :model/User) :email email))
(throw (ex-info "boom" {})))))
(is (t2/exists? :model/User :email email)))
(finally
(t2/delete! :model/User :email email)))))
(deftest detached-lock-reentrant-test
(testing "re-acquiring a held detached lock is a no-op instead of a self-deadlock"
(is (= :ok (sut/with-cluster-lock {:lock ::detached-reentrant :detached? true}
(sut/with-cluster-lock {:lock ::detached-reentrant :detached? true
:timeout-seconds 1 :retry-config {:max-retries 0}}
:ok)))))
(when (not= (mdb/db-type) :h2)
(testing "a transactional acquisition of a lock this thread holds detached is also a no-op"
(is (= :ok (sut/with-cluster-lock {:lock ::detached-reentrant :detached? true}
(sut/with-cluster-lock {:lock ::detached-reentrant
:timeout-seconds 1 :retry-config {:max-retries 0}}
:ok)))))
(testing "upgrading a held detached :share lock to :exclusive throws instead of self-deadlocking"
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Cannot upgrade"
(sut/with-cluster-lock {:lock ::detached-reentrant :mode :share :detached? true}
(sut/with-cluster-lock {:lock ::detached-reentrant :detached? true}
:nope)))))))
(deftest detached-lock-mutual-exclusion-test
;; h2 uses the in-process rw-lock path where :detached? is a no-op; real row-lock semantics only.
(when (not= (mdb/db-type) :h2)
;; warm up the row first so we're not racing on the initial INSERT
(sut/with-cluster-lock {:lock ::detached-mutex :detached? true} :warm)
(let [held (run-with-lock {:lock ::detached-mutex :detached? true})]
(is (.await ^CountDownLatch (:entered held) 3 TimeUnit/SECONDS))
(testing "a detached holder blocks another detached acquirer"
(is (not (acquirable-within? {:lock ::detached-mutex :detached? true
:timeout-seconds 1 :retry-config {:max-retries 0}}
3000))))
(testing "a detached holder blocks a transactional acquirer"
(is (not (acquirable-within? {:lock ::detached-mutex
:timeout-seconds 1 :retry-config {:max-retries 0}}
3000))))
(.countDown ^CountDownLatch (:release held))
(is (= :ok (deref (:done held) 3000 :timeout)))
(testing "released once the body completes"
(is (acquirable-within? {:lock ::detached-mutex :detached? true} 3000))))))

View File

@@ -5,7 +5,8 @@
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[toucan2.connection :as t2.connection]
[toucan2.core :as t2])
[toucan2.core :as t2]
[toucan2.jdbc.options :as t2.jdbc.options])
(:import
(java.sql Connection)
(java.util.concurrent Semaphore)))
@@ -339,3 +340,67 @@
(throw (ex-info "force savepoint rollback" {})))))
(is (= {:outer-key "outer-val"} @mdb.connection/*transaction-state*)
"data stashed by a rolled-back nested transaction is discarded from transaction-state")))
(deftest unshared-connection-not-bound-during-use-test
(mdb.connection/with-unshared-connection [conn]
(testing "explicitly passing the unshared connection works, but it is never bound as *current-connectable*"
(let [rows (reduce (fn [acc row]
(is (not (identical? conn t2.connection/*current-connectable*))
"the unshared connection must not be ambiently visible mid-reduction")
(conj acc (into {} row)))
[]
(t2/reducible-query conn ["select 1 as x"]))]
(is (= 1 (count rows)))))
(testing "a transaction opened while the unshared connection is in use gets its own connection"
(reduce (fn [_ _row]
(t2/with-transaction [tx-conn]
(is (not (identical? tx-conn conn)))))
nil
(t2/reducible-query conn ["select 1 as x"])))))
(deftest unshared-connection-borrower-work-commits-independently-test
(let [email (mt/random-email)]
(testing "toucan work while the unshared connection is in use commits on its own connection"
(mdb.connection/with-unshared-connection [conn]
;; open a transaction on the unshared connection that is never committed: if the insert below
;; wrongly rode this connection, it would be rolled back at pool check-in and the user would not exist
(.setAutoCommit ^Connection conn false)
(reduce (fn [_ _row]
(t2/insert! :model/User (assoc (mt/with-temp-defaults :model/User) :email email)))
nil
(t2/reducible-query conn ["select 1 as x"])))
(is (t2/exists? :model/User :email email))
(t2/delete! :model/User :email email))))
(deftest unshared-connection-postgres-portal-survival-test
;; postgres streams a result set through a portal that dies if anything commits on the connection
;; mid-iteration -- the failure mode that forced the revert of #76645. Other appdbs buffer, so there is
;; nothing to kill.
(when (= (mdb.connection/db-type) :postgres)
(let [portal-death? (fn [e]
(loop [e e]
(cond
(nil? e) false
(re-find #"portal" (str (ex-message e))) true
:else (recur (ex-cause e)))))
stream! (fn [^Connection conn]
;; fetch-size + autocommit off = pg streams through a portal
(.setAutoCommit conn false)
(binding [t2.jdbc.options/*options* {:fetch-size 50}]
(reduce (fn [n _row]
;; unrelated toucan work mid-stream, resolved ambiently, that commits
(when (= n 30)
(t2/with-transaction [_]
(t2/query ["select 1"])))
(inc n))
0
(t2/reducible-query conn ["select g from generate_series(1, 1000) g"]))))]
(testing "control: an ordinary connection is ambiently visible mid-reduction, so the borrower commit kills the portal"
(with-open [^Connection raw (.getConnection (mdb.connection/data-source))]
(let [e (try (stream! raw) nil (catch Throwable e e))]
(is (portal-death? e)))
(.rollback raw)
(.setAutoCommit raw true)))
(testing "an unshared connection is invisible to the borrower; the portal survives"
(mdb.connection/with-unshared-connection [conn]
(is (= 1000 (stream! conn))))))))

View File

@@ -83,8 +83,8 @@
"7" "rgba(255, 0, 0, 0.65)"
"8" "rgba(255, 0, 0, 0.2)"
"9" "rgba(0, 0, 255, 0.75)"
"1.001,5" "rgba(0, 0, 255, 0.75)"
"1,001.5" "rgba(0, 0, 255, 0.75)"}
"1.001,5" "rgba(255, 0, 0, 0.2)"
"1,001.5" "rgba(255, 0, 0, 0.2)"}
(-> (select-keys query-results [:cols :rows])
(#'table/render-table {:col-names ["a" "b" "c"]
:cols-for-color-lookup ["a" "b" "c"]}