(ns metabase.models.user (:require [clojure.data :as data] [clojure.string :as str] [metabase.api.common :as api] [metabase.config :as config] [metabase.db.query :as mdb.query] [metabase.events :as events] [metabase.integrations.common :as integrations.common] [metabase.models.audit-log :as audit-log] [metabase.models.collection :as collection] [metabase.models.interface :as mi] [metabase.models.permissions :as perms] [metabase.models.permissions-group :as perms-group] [metabase.models.permissions-group-membership :as perms-group-membership :refer [PermissionsGroupMembership]] [metabase.models.serialization :as serdes] [metabase.models.setting :as setting :refer [defsetting]] [metabase.plugins.classloader :as classloader] [metabase.public-settings :as public-settings] [metabase.public-settings.premium-features :as premium-features] [metabase.setup :as setup] [metabase.util :as u] [metabase.util.honey-sql-2 :as h2x] [metabase.util.i18n :as i18n :refer [deferred-tru trs tru]] [metabase.util.log :as log] [metabase.util.malli :as mu] [metabase.util.malli.schema :as ms] [metabase.util.password :as u.password] [methodical.core :as methodical] [toucan2.core :as t2] [toucan2.tools.default-fields :as t2.default-fields])) | |
(set! *warn-on-reflection* true) | |
----------------------------------------------- Entity & Lifecycle ----------------------------------------------- | |
Used to be the toucan1 model name defined using [[toucan.models/defmodel]], not it's a reference to the toucan2 model name. We'll keep this till we replace all these symbols in our codebase. | (def User :model/User) |
(methodical/defmethod t2/table-name :model/User [_model] :core_user) (methodical/defmethod t2/model-for-automagic-hydration [:default :author] [_original-model _k] :model/User) (methodical/defmethod t2/model-for-automagic-hydration [:default :creator] [_original-model _k] :model/User) (methodical/defmethod t2/model-for-automagic-hydration [:default :updated_by] [_original-model _k] :model/User) (methodical/defmethod t2/model-for-automagic-hydration [:default :user] [_original-model _k] :model/User) | |
(doto :model/User (derive :metabase/model) (derive :hook/updated-at-timestamped?) (derive :hook/entity-id)) | |
(t2/deftransforms :model/User {:login_attributes mi/transform-json-no-keywordization :settings mi/transform-encrypted-json :sso_source mi/transform-keyword :type mi/transform-keyword}) | |
(def ^:private allowed-user-types #{:internal :personal :api-key}) | |
(def ^:private insert-default-values {:date_joined :%now :last_login nil :is_active true :is_superuser false}) | |
When User | (defn- hashed-password-values [{:keys [password], :as user}] (when password (assert (not (:password_salt user)) ;; this is dev-facing so it doesn't need to be i18n'ed "Don't try to pass an encrypted password to insert! or update!. Password encryption is handled by pre- methods.") (let [salt (str (random-uuid))] {:password_salt salt :password (u.password/hash-bcrypt (str salt password))}))) |
Returns the user's settings (defaulting to an empty map) or | (defn user-local-settings [user-or-user-id] (when user-or-user-id (or (if (integer? user-or-user-id) (:settings (t2/select-one [User :settings] :id user-or-user-id)) (:settings user-or-user-id)) {}))) |
(t2/define-before-insert :model/User [{:keys [email password reset_token locale sso_source], :as user}] ;; these assertions aren't meant to be user-facing, the API endpoints should be validation these as well. (assert (u/email? email)) (assert ((every-pred string? (complement str/blank?)) password)) (when-let [user-type (:type user)] (assert (contains? allowed-user-types user-type))) (when locale (assert (i18n/available-locale? locale) (tru "Invalid locale: {0}" (pr-str locale)))) (when (and sso_source (not (setup/has-user-setup))) ;; Only allow SSO users to be provisioned if the setup flow has been completed and an admin has been created (throw (Exception. (trs "Instance has not been initialized")))) (premium-features/airgap-check-user-count) (merge insert-default-values user (hashed-password-values user) ;; lower-case the email before saving {:email (u/lower-case-en email)} ;; if there's a reset token encrypt that as well (when reset_token {:reset_token (u.password/hash-bcrypt reset_token)}) ;; normalize the locale (when locale {:locale (i18n/normalized-locale-string locale)}))) | |
(t2/define-after-insert :model/User [{user-id :id, superuser? :is_superuser, :as user}] (u/prog1 user (let [current-version (:tag config/mb-version-info)] (log/infof "Setting User %s's last_acknowledged_version to %s, the current version" user-id current-version) ;; Can't use mw.session/with-current-user due to circular require (binding [api/*current-user-id* user-id setting/*user-local-values* (delay (atom (user-local-settings user)))] (setting/set! :last-acknowledged-version current-version))) ;; add the newly created user to the magic perms groups. (log/infof "Adding User %s to All Users permissions group..." user-id) (when superuser? (log/infof "Adding User %s to All Users permissions group..." user-id)) (let [groups (filter some? [(perms-group/all-users) (when superuser? (perms-group/admin))])] (binding [perms-group-membership/*allow-changing-all-users-group-members* true] ;; do a 'simple' insert against the Table name so we don't trigger the after-insert behavior ;; for [[metabase.models.permissions-group-membership]]... we don't want it recursively trying to update ;; the user (t2/insert! (t2/table-name :model/PermissionsGroupMembership) (for [group groups] {:user_id user-id :group_id (u/the-id group)})))))) | |
(t2/define-before-update :model/User [{:keys [id] :as user}] ;; when `:is_superuser` is toggled add or remove the user from the 'Admin' group as appropriate (let [{reset-token :reset_token superuser? :is_superuser active? :is_active :keys [email locale]} (t2/changes user) in-admin-group? (t2/exists? PermissionsGroupMembership :group_id (:id (perms-group/admin)) :user_id id)] ;; Do not let the last admin archive themselves (when (and in-admin-group? (false? active?)) (perms-group-membership/throw-if-last-admin!)) (when (some? superuser?) (cond (and superuser? (not in-admin-group?)) (t2/insert! (t2/table-name PermissionsGroupMembership) :group_id (u/the-id (perms-group/admin)) :user_id id) ;; don't use [[t2/delete!]] here because that does the opposite and tries to update this user which leads to a ;; stack overflow of calls between the two. TODO - could we fix this issue by using a `post-delete` method? (and (not superuser?) in-admin-group?) (t2/delete! (t2/table-name PermissionsGroupMembership) :group_id (u/the-id (perms-group/admin)) :user_id id))) ;; make sure email and locale are valid if set (when email (assert (u/email? email))) (when locale (assert (i18n/available-locale? locale) (tru "Invalid locale: {0}" (pr-str locale)))) ;; delete all subscriptions to pulses/alerts/etc. if the User is getting archived (`:is_active` status changes) (when (false? active?) (t2/delete! 'PulseChannelRecipient :user_id id)) ;; If we're setting the reset_token then encrypt it before it goes into the DB (cond-> user true (merge (hashed-password-values (t2/changes user))) reset-token (update :reset_token u.password/hash-bcrypt) locale (update :locale i18n/normalized-locale-string) email (update :email u/lower-case-en)))) | |
Conditionally add a | (defn add-common-name [{:keys [first_name last_name email], :as user}] ;; This logic is replicated in SQL in [[metabase-enterprise.query-reference-validation.api]]. If the below logic changes, ;; please update the EE ns as well. (let [common-name (if (or first_name last_name) (str/trim (str first_name " " last_name)) email)] (cond-> user (and (contains? user :first_name) (contains? user :last_name) common-name) (assoc :common_name common-name)))) |
(t2/define-after-select :model/User [user] (add-common-name user)) | |
Sequence of columns that are normally returned when fetching a User from the DB. | (def ^:private default-user-columns [:id :email :date_joined :first_name :last_name :last_login :is_superuser :is_qbnewb]) |
Sequence of columns that we can/should return for admins fetching a list of all Users, or for the current user fetching themselves. Needed to power the admin page. | (def admin-or-self-visible-columns (into default-user-columns [:sso_source :is_active :updated_at :login_attributes :locale])) |
Sequence of columns that we will allow non-admin Users to see when fetching a list of Users. Why can non-admins see other Users at all? I honestly would prefer they couldn't, but we need to give them a list of emails to power Pulses. | (def non-admin-or-self-visible-columns [:id :email :first_name :last_name]) |
Sequence of columns Group Managers can see when fetching a list of Users.. | (def group-manager-visible-columns (into non-admin-or-self-visible-columns [:is_superuser :last_login])) |
(t2.default-fields/define-default-fields :model/User default-user-columns) | |
(defmethod serdes/hash-fields User [_user] [:email]) | |
Fetch set of IDs of PermissionsGroup a User belongs to. | (defn group-ids [user-or-id] (when user-or-id (t2/select-fn-set :group_id PermissionsGroupMembership :user_id (u/the-id user-or-id)))) |
Group Membership info of a User.
In which :isgroupmanager is only included if | (def UserGroupMembership [:map [:id ms/PositiveInt] ;; is_group_manager only included if `advanced-permissions` is enabled [:is_group_manager {:optional true} :boolean]]) |
(defmethod mi/exclude-internal-content-hsql :model/User [_model & {:keys [table-alias]}] [:and [:not= (h2x/identifier :field table-alias :type) [:inline "internal"]]]) | |
-------------------------------------------------- Permissions --------------------------------------------------- | |
Return a set of all permissions object paths that | (defn permissions-set [user-or-id] (let [s (set (when-let [user-id (u/the-id user-or-id)] (concat ;; Current User always gets readwrite perms for their Personal Collection and for its descendants! (1 DB Call) (map perms/collection-readwrite-path (collection/user->personal-collection-and-descendant-ids user-or-id)) ;; include the other Perms entries for any Group this User is in (1 DB Call) (map :object (mdb.query/query {:select [:p.object] :from [[:permissions_group_membership :pgm]] :join [[:permissions_group :pg] [:= :pgm.group_id :pg.id] [:permissions :p] [:= :p.group_id :pg.id]] :where [:= :pgm.user_id user-id]})))))] ;; Append permissions as a vector for more efficient iteration in checks that go over each permission linearly. (with-meta s {:as-vec (vec s)}))) |
--------------------------------------------------- Hydration ---------------------------------------------------- | |
(mi/define-batched-hydration-method add-user-group-memberships :user_group_memberships "Add to each `user` a list of Group Memberships Info with each item is a map with 2 keys [:id :is_group_manager]. In which `is_group_manager` is only added when `advanced-permissions` is enabled." [users] (when (seq users) (let [user-id->memberships (group-by :user_id (t2/select [PermissionsGroupMembership :user_id [:group_id :id] :is_group_manager] :user_id [:in (set (map u/the-id users))])) membership->group (fn [membership] (select-keys membership [:id (when (premium-features/enable-advanced-permissions?) :is_group_manager)]))] (for [user users] (assoc user :user_group_memberships (->> (user-id->memberships (u/the-id user)) (map membership->group) ;; sort these so the id returned is consistent so our tests don't ;; randomly fail (sort-by :id))))))) | |
(mi/define-batched-hydration-method add-group-ids :group_ids "Efficiently add PermissionsGroup `group_ids` to a collection of `users`. TODO: deprecate :group_ids and use :user_group_memberships instead" [users] (when (seq users) (let [user-id->memberships (group-by :user_id (t2/select [PermissionsGroupMembership :user_id :group_id] :user_id [:in (set (map u/the-id users))]))] (for [user users] (assoc user :group_ids (set (map :group_id (user-id->memberships (u/the-id user))))))))) | |
(mi/define-batched-hydration-method add-has-invited-second-user :has_invited_second_user "Adds the `has_invited_second_user` flag to a collection of `users`. This should be `true` for only the user who underwent the initial app setup flow (with an ID of 1), iff more than one user exists. This is used to modify the wording for this user on a homepage banner that prompts them to add their database." [users] (when (seq users) (let [user-count (t2/count User)] (for [user users] (assoc user :has_invited_second_user (and (= (:id user) 1) (> user-count 1))))))) | |
(mi/define-batched-hydration-method add-is-installer :is_installer "Adds the `is_installer` flag to a collection of `users`. This should be `true` for only the user who underwent the initial app setup flow (with an ID of 1). This is used to modify the experience of the starting page for users." [users] (when (seq users) (for [user users] (assoc user :is_installer (= (:id user) 1))))) | |
--------------------------------------------------- Helper Fns --------------------------------------------------- | |
(declare form-password-reset-url set-password-reset-token!) | |
Login attributes, currently not collected for LDAP or Google Auth. Will ultimately be stored as JSON. | (def LoginAttributes (mu/with-api-error-message [:map-of ms/KeywordOrString :any] (deferred-tru "login attribute keys must be a keyword or string"))) |
Required/optionals parameters needed to create a new user (for any backend) | (def NewUser [:map [:first_name {:optional true} [:maybe ms/NonBlankString]] [:last_name {:optional true} [:maybe ms/NonBlankString]] [:email ms/Email] [:password {:optional true} [:maybe ms/NonBlankString]] [:login_attributes {:optional true} [:maybe LoginAttributes]] [:sso_source {:optional true} [:maybe ms/NonBlankString]] [:locale {:optional true} [:maybe ms/KeywordOrString]] [:type {:optional true} [:maybe ms/KeywordOrString]]]) |
Map with info about the admin creating the user, used in the new user notification code | (def ^:private Invitor [:map [:email ms/Email] [:first_name [:maybe ms/NonBlankString]]]) |
Creates a new user, defaulting the password when not provided | (mu/defn insert-new-user! [new-user :- NewUser] (t2/insert-returning-instance! :model/User (update new-user :password #(or % (str (random-uuid)))))) |
Creates a new user with a default password, when deserializing eg. a | (defn serdes-synthesize-user! [new-user] (insert-new-user! new-user)) |
Convenience function for inviting a new | (mu/defn create-and-invite-user! [new-user :- NewUser invitor :- Invitor setup? :- :boolean] ;; create the new user (u/prog1 (insert-new-user! new-user) ;; TODO make sure the email being sent synchronously. (events/publish-event! :event/user-invited {:object (assoc <> :is_from_setup setup? :invite_method "email" :sso_source (:sso_source new-user)) :details {:invitor (select-keys invitor [:email :first_name])}}))) |
Convenience for creating a new user via Google Auth. This account is considered active immediately; thus all active admins will receive an email right away. | (mu/defn create-new-google-auth-user! [new-user :- NewUser] (u/prog1 (insert-new-user! (assoc new-user :sso_source "google")) ;; send an email to everyone including the site admin if that's set (when (integrations.common/send-new-sso-user-admin-email?) (classloader/require 'metabase.email.messages) ((resolve 'metabase.email.messages/send-user-joined-admin-notification-email!) <>, :google-auth? true)))) |
Convenience for creating a new user via LDAP. This account is considered active immediately; thus all active admins will receive an email right away. | (mu/defn create-new-ldap-auth-user! [new-user :- NewUser] (insert-new-user! (-> new-user ;; We should not store LDAP passwords (dissoc :password) (assoc :sso_source "ldap")))) |
Update the stored password for a specified The password is automatically hashed with a random salt; this happens in [[hashed-password-values]] which is called by [[pre-insert]] or [[pre-update]]) TODO -- it seems like maybe this should just be part of the [[pre-update]] logic whenever | (defn set-password! [user-id password] ;; when changing/resetting the password, kill any existing sessions (t2/delete! (t2/table-name :model/Session) :user_id user-id) ;; NOTE: any password change expires the password reset token (t2/update! User user-id {:password password :reset_token nil :reset_triggered nil})) |
Updates a given | (defn set-password-reset-token! [user-id] {:pre [(integer? user-id)]} (u/prog1 (str user-id \_ (random-uuid)) (t2/update! User user-id {:reset_token <> :reset_triggered (System/currentTimeMillis)}))) |
Generate a properly formed password reset url given a password reset token. | (defn form-password-reset-url [reset-token] {:pre [(string? reset-token)]} (str (public-settings/site-url) "/auth/reset_password/" reset-token)) |
Set the user's group memberships to equal the supplied group IDs. Returns | (defn set-permissions-groups! [user-or-id new-groups-or-ids] (let [user-id (u/the-id user-or-id) old-group-ids (group-ids user-id) new-group-ids (set (map u/the-id new-groups-or-ids)) [to-remove to-add] (data/diff old-group-ids new-group-ids)] (when (seq (concat to-remove to-add)) (t2/with-transaction [_conn] (when (seq to-remove) (t2/delete! PermissionsGroupMembership :user_id user-id, :group_id [:in to-remove])) ;; a little inefficient, but we need to do a separate `insert!` for each group we're adding membership to, ;; because `insert-many!` does not currently trigger methods such as `pre-insert`. We rely on those methods to ;; do things like automatically set the `is_superuser` flag for a User ;; TODO use multipel insert here (doseq [group-id to-add] (t2/insert! PermissionsGroupMembership {:user_id user-id, :group_id group-id})))) true)) |
---------------------------------------- USER SETTINGS ---------------------------------------- | |
NB: Settings are also defined where they're used, such as in [[metabase.events.view-log]] | |
(defsetting last-acknowledged-version (deferred-tru "The last version for which a user dismissed the 'What's new?' modal.") :encryption :no :user-local :only :type :string) | |
(defsetting last-used-native-database-id (deferred-tru "The last database a user has selected for a native query or a native model.") :user-local :only :visibility :authenticated :type :integer :getter (fn [] (when-let [id (setting/get-value-of-type :integer :last-used-native-database-id)] (when (t2/exists? :model/Database :id id) id)))) | |
(defsetting dismissed-custom-dashboard-toast (deferred-tru "Toggle which is true after a user has dismissed the custom dashboard toast.") :user-local :only :visibility :authenticated :type :boolean :default false :audit :never) | |
(defsetting dismissed-onboarding-sidebar-link (deferred-tru "Whether the user has dismissed the onboarding link from the main sidebar.") :user-local :only :export? false :visibility :authenticated :type :boolean :default false :audit :never) | |
(defsetting dismissed-browse-models-banner (deferred-tru "Whether the user has dismissed the explanatory banner about models that appears on the Browse Data page") :user-local :only :export? false :visibility :authenticated :type :boolean :default false :audit :never) | |
(defsetting notebook-native-preview-shown (deferred-tru "User preference for the state of the native query preview in the notebook.") :user-local :only :visibility :authenticated :type :boolean :default false) | |
(defsetting notebook-native-preview-sidebar-width (deferred-tru "Last user set sidebar width for the native query preview in the notebook.") :user-local :only :visibility :authenticated :type :integer :default nil) | |
(defsetting expand-browse-in-nav (deferred-tru "User preference for whether the 'Browse' section of the nav is expanded.") :user-local :only :export? false :visibility :authenticated :type :boolean :default true) | |
(defsetting expand-bookmarks-in-nav (deferred-tru "User preference for whether the 'Bookmarks' section of the nav is expanded.") :user-local :only :export? false :visibility :authenticated :type :boolean :default true) | |
(defsetting browse-filter-only-verified-models (deferred-tru "User preference for whether the 'Browse models' page should be filtered to show only verified models.") :user-local :only :export? false :visibility :authenticated :type :boolean :default true) | |
(defsetting browse-filter-only-verified-metrics (deferred-tru "User preference for whether the 'Browse metrics' page should be filtered to show only verified metrics.") :user-local :only :export? false :visibility :authenticated :type :boolean :default true) | |
(defsetting trial-banner-dismissal-timestamp (deferred-tru "The ISO8601 date when a user last dismissed the trial banner.") :user-local :only :encryption :no :export? false :visibility :authenticated :type :string) | |
------------------------------------------ AUDIT LOG ------------------------------------------ | |
(defmethod audit-log/model-details :model/User [entity event-type] (case event-type :user-update (select-keys (t2/hydrate entity :user_group_memberships) [:groups :first_name :last_name :email :invite_method :sso_source :user_group_memberships]) :user-invited (select-keys (t2/hydrate entity :user_group_memberships) [:groups :first_name :last_name :email :invite_method :sso_source :user_group_memberships]) :password-reset-initiated (select-keys entity [:token]) :password-reset-successful (select-keys entity [:token]) {})) | |