/api/user endpoints

(ns metabase.api.user
  (:require
   [compojure.core :refer [DELETE GET POST PUT]]
   [honey.sql.helpers :as sql.helpers]
   [java-time.api :as t]
   [metabase.analytics.snowplow :as snowplow]
   [metabase.api.common :as api]
   [metabase.api.common.validation :as validation]
   [metabase.api.ldap :as api.ldap]
   [metabase.api.session :as api.session]
   [metabase.config :as config]
   [metabase.events :as events]
   [metabase.integrations.google :as google]
   [metabase.models.collection :as collection :refer [Collection]]
   [metabase.models.dashboard :refer [Dashboard]]
   [metabase.models.interface :as mi]
   [metabase.models.login-history :refer [LoginHistory]]
   [metabase.models.permissions-group :as perms-group]
   [metabase.models.setting :refer [defsetting]]
   [metabase.models.user :as user]
   [metabase.plugins.classloader :as classloader]
   [metabase.public-settings :as public-settings]
   [metabase.public-settings.premium-features :as premium-features]
   [metabase.request.core :as request]
   [metabase.util :as u]
   [metabase.util.i18n :refer [deferred-tru tru]]
   [metabase.util.malli.schema :as ms]
   [metabase.util.password :as u.password]
   [toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(when config/ee-available?
  (classloader/require 'metabase-enterprise.sandbox.api.util
                       'metabase-enterprise.advanced-permissions.common
                       'metabase-enterprise.advanced-permissions.models.permissions.group-manager))
(defsetting user-visibility
  (deferred-tru "Note: Sandboxed users will never see suggestions.")
  :visibility   :authenticated
  :feature      :email-restrict-recipients
  :type         :keyword
  :default      :all
  :audit        :raw-value)

Check that user-id is current-user-idor thatcurrent-user` is a superuser, or throw a 403.

(defn check-self-or-superuser
  [user-id]
  {:pre [(integer? user-id)]}
  (api/check-403
   (or
    (= user-id api/*current-user-id*)
    api/*is-superuser?*)))

Check that user-id is not the id of the Internal User.

(defn check-not-internal-user
  [user-id]
  {:pre [(integer? user-id)]}
  (api/check (not= user-id config/internal-mb-user-id)
             [400 (tru "Not able to modify the internal user")]))
(defn- fetch-user [& query-criteria]
  (apply t2/select-one (vec (cons :model/User user/admin-or-self-visible-columns)) query-criteria))
(defn- maybe-set-user-permissions-groups! [user-or-id new-groups-or-ids]
  (when (and new-groups-or-ids
             (not (= (user/group-ids user-or-id)
                     (set (map u/the-id new-groups-or-ids)))))
    (api/check-superuser)
    (user/set-permissions-groups! user-or-id new-groups-or-ids)))
(defn- maybe-set-user-group-memberships!
  [user-or-id new-user-group-memberships & [is-superuser?]]
  (when new-user-group-memberships
    ;; if someone passed in both `:is_superuser` and `:group_ids`, make sure the whether the admin group is in group_ids
    ;; agrees with is_superuser -- don't want to have ambiguous behavior
    (when (some? is-superuser?)
      (api/checkp (= is-superuser? (contains? (set (map :id new-user-group-memberships)) (u/the-id (perms-group/admin))))
                  "is_superuser" (tru "Value of is_superuser must correspond to presence of Admin group ID in group_ids.")))
    (if-let [f (and (premium-features/enable-advanced-permissions?)
                    (resolve 'metabase-enterprise.advanced-permissions.models.permissions.group-manager/set-user-group-memberships!))]
      (f user-or-id new-user-group-memberships)
      (maybe-set-user-permissions-groups! user-or-id (map :id new-user-group-memberships)))))
(defn- updated-user-name [user-before-update changes]
  (let [[previous current] (map #(select-keys % [:first_name :last_name]) [user-before-update changes])
        updated-names (merge previous current)]
    (when (not= previous updated-names)
      updated-names)))
(defn- maybe-update-user-personal-collection-name! [user-before-update changes]
  ;; If the user name is updated, we shall also update the personal collection name (if such collection exists).
  (when-some [{:keys [first_name last_name]} (updated-user-name user-before-update changes)]
    (when-some [collection (collection/user->existing-personal-collection (u/the-id user-before-update))]
      (let [{email :email} user-before-update
            new-collection-name (collection/format-personal-collection-name first_name last_name email :site)]
        (when-not (= new-collection-name (:name collection))
          (t2/update! Collection (:id collection) {:name new-collection-name}))))))

+----------------------------------------------------------------------------------------------------------------+ | Fetching Users -- GET /api/user, GET /api/user/current, GET /api/user/:id | +----------------------------------------------------------------------------------------------------------------+

Figure out what where clause to add to the user query when we get a fiddly status and include_deactivated query.

This is to keep backwards compatibility with include_deactivated while adding `status.

(defn- status-clause
  [status include_deactivated]
  (if include_deactivated
    nil
    (case status
      "all"         nil
      "deactivated" [:= :is_active false]
      "active"      [:= :is_active true]
      [:= :is_active true])))
(defn- wildcard-query [query] (str "%" (u/lower-case-en query) "%"))

Honeysql clause to shove into user query if there's a query

(defn- query-clause
  [query]
  [:or
   [:like :%lower.first_name (wildcard-query query)]
   [:like :%lower.last_name  (wildcard-query query)]
   [:like :%lower.email      (wildcard-query query)]])

Columns of user table visible to current caller of API.

(defn- user-visible-columns
  []
  (cond
    api/*is-superuser?*
    user/admin-or-self-visible-columns
    api/*is-group-manager?*
    user/group-manager-visible-columns
    :else
    user/non-admin-or-self-visible-columns))

Honeysql clauses for filtering on users - with a status, - with a query, - with a group_id, - with include_deactivated

(defn- user-clauses
  [status query group_ids include_deactivated]
  (cond-> {}
    true                                               (sql.helpers/where [:= :core_user.type "personal"])
    true                                               (sql.helpers/where (status-clause status include_deactivated))
    ;; don't send the internal user
    (premium-features/sandboxed-or-impersonated-user?) (sql.helpers/where [:= :core_user.id api/*current-user-id*])
    (some? query)                                      (sql.helpers/where (query-clause query))
    (some? group_ids)                                  (sql.helpers/right-join
                                                        :permissions_group_membership
                                                        [:= :core_user.id :permissions_group_membership.user_id])
    (some? group_ids)                                  (sql.helpers/where
                                                        [:in :permissions_group_membership.group_id group_ids])
    (some? (request/limit))                            (sql.helpers/limit (request/limit))
    (some? (request/offset))                           (sql.helpers/offset (request/offset))))

Given a where clause, return a clause that can be used to count.

(defn- filter-clauses-without-paging
  [clauses]
  (dissoc clauses :order-by :limit :offset))

/

(api/defendpoint GET 
  "Fetch a list of `Users` for admins or group managers.
  By default returns only active users for admins and only active users within groups that the group manager is managing for group managers.
   - If `status` is `deactivated`, include deactivated users only.
   - If `status` is `all`, include all users (active and inactive).
   - Also supports `include_deactivated`, which if true, is equivalent to `status=all`; If is false, is equivalent to `status=active`.
   `status` and `include_deactivated` requires superuser permissions.
   - `include_deactivated` is a legacy alias for `status` and will be removed in a future release, users are advised to use `status` for better support and flexibility.
   If both params are passed, `status` takes precedence.
  For users with segmented permissions, return only themselves.
  Takes `limit`, `offset` for pagination.
  Takes `query` for filtering on first name, last name, email.
  Also takes `group_id`, which filters on group id."
  [status query group_id include_deactivated]
  {status              [:maybe :string]
   query               [:maybe :string]
   group_id            [:maybe ms/PositiveInt]
   include_deactivated [:maybe ms/BooleanValue]}
  (or
   api/*is-superuser?*
   (if group_id
     (validation/check-manager-of-group group_id)
     (validation/check-group-manager)))
  (let [include_deactivated include_deactivated
        group-id-clause     (when group_id [group_id])
        clauses             (user-clauses status query group-id-clause include_deactivated)]
    {:data (cond-> (t2/select
                    (vec (cons :model/User (user-visible-columns)))
                    (cond-> clauses
                      (and (some? group_id) group-id-clause) (sql.helpers/order-by [:core_user.is_superuser :desc] [:is_group_manager :desc])
                      true             (sql.helpers/order-by [:%lower.first_name :asc]
                                                             [:%lower.last_name :asc]
                                                             [:id :asc])))
             ;; For admins also include the IDs of Users' Personal Collections
             api/*is-superuser?*
             (t2/hydrate :personal_collection_id)
             (or api/*is-superuser?*
                 api/*is-group-manager?*)
             (t2/hydrate :group_ids)
             ;; if there is a group_id clause, make sure the list is deduped in case the same user is in multiple gropus
             group-id-clause
             distinct)
     :total  (-> (t2/query
                  (merge {:select [[[:count [:distinct :core_user.id]] :count]]
                          :from   :core_user}
                         (filter-clauses-without-paging clauses)))
                 first
                 :count)
     :limit  (request/limit)
     :offset (request/offset)}))

Return a list of all user-ids in the same group with the user with id user-id. Ignore the All-user groups.

(defn- same-groups-user-ids
  [user-id]
  (map :user_id
       (t2/query {:select-distinct [:permissions_group_membership.user_id]
                  :from [:permissions_group_membership]
                  :where [:in :permissions_group_membership.group_id
                          ;; get all the groups ids that the current user is in
                          {:select-distinct [:permissions_group_membership.group_id]
                           :from  [:permissions_group_membership]
                           :where [:and [:= :permissions_group_membership.user_id user-id]
                                   [:not= :permissions_group_membership.group_id (:id (perms-group/all-users))]]}]})))

/recipients

(api/defendpoint GET 
  "Fetch a list of `Users`. Returns only active users. Meant for non-admins unlike GET /api/user.
   - If user-visibility is :all or the user is an admin, include all users.
   - If user-visibility is :group, include only users in the same group (excluding the all users group).
   - If user-visibility is :none or the user is sandboxed, include only themselves."
  []
  ;; defining these functions so the branching logic below can be as clear as possible
  (letfn [(all [] (let [clauses (-> (user-clauses nil nil nil nil)
                                    (sql.helpers/order-by [:%lower.last_name :asc] [:%lower.first_name :asc]))]
                    {:data   (t2/select (vec (cons :model/User (user-visible-columns))) clauses)
                     :total  (t2/count :model/User (filter-clauses-without-paging clauses))
                     :limit  (request/limit)
                     :offset (request/offset)}))
          (within-group [] (let [user-ids (same-groups-user-ids api/*current-user-id*)
                                 clauses  (cond-> (user-clauses nil nil nil nil)
                                            (seq user-ids) (sql.helpers/where [:in :core_user.id user-ids])
                                            true           (sql.helpers/order-by [:%lower.last_name :asc] [:%lower.first_name :asc]))]
                             {:data   (t2/select (vec (cons :model/User (user-visible-columns))) clauses)
                              :total  (t2/count :model/User (filter-clauses-without-paging clauses))
                              :limit  (request/limit)
                              :offset (request/offset)}))
          (just-me [] {:data   [(fetch-user :id api/*current-user-id*)]
                       :total  1
                       :limit  (request/limit)
                       :offset (request/offset)})]
    (cond
      ;; if they're sandboxed OR if they're a superuser, ignore the setting and just give them nothing or everything,
      ;; respectively.
      (premium-features/sandboxed-user?)
      (just-me)
      api/*is-superuser?*
      (all)
      ;; otherwise give them what the setting says on the tin
      :else
      (case (user-visibility)
        :none (just-me)
        :group (within-group)
        :all (all)))))

If advanced-permissions is enabled, add to user a permissions map.

(defn- maybe-add-advanced-permissions
  [user]
  (if-let [with-advanced-permissions
           (and (premium-features/enable-advanced-permissions?)
                (resolve 'metabase-enterprise.advanced-permissions.common/with-advanced-permissions))]
    (with-advanced-permissions user)
    user))

Adds sso_source key to the User, so FE could determine if the user is logged in via SSO.

(defn- maybe-add-sso-source
  [{:keys [id] :as user}]
  (if (premium-features/enable-any-sso?)
    (assoc user :sso_source (t2/select-one-fn :sso_source :model/User :id id))
    user))

True when the user has permissions for at least one un-archived question and one un-archived dashboard, excluding internal/automatically-loaded content.

(defn- add-has-question-and-dashboard
  [user]
  (let [collection-filter (collection/visible-collection-filter-clause)
        entity-exists? (fn [model & additional-clauses] (t2/exists? model
                                                                    {:where (into [:and
                                                                                   [:= :archived false]
                                                                                   collection-filter
                                                                                   (mi/exclude-internal-content-hsql model)]
                                                                                  additional-clauses)}))]
    (-> user
        (assoc :has_question_and_dashboard
               (and (entity-exists? :model/Card)
                    (entity-exists? :model/Dashboard)))
        (assoc :has_model (entity-exists? :model/Card [:= :type "model"])))))

Adds first_login key to the User with the oldest timestamp from that user's login history. Otherwise give the current time, as it's the user's first login.

(defn- add-first-login
  [{:keys [id] :as user}]
  (let [ts (or
            (:timestamp (t2/select-one [LoginHistory :timestamp] :user_id id
                                       {:order-by [[:timestamp :asc]]}))
            (t/offset-date-time))]
    (assoc user :first_login ts)))

Adds custom homepage dashboard information to the current user.

(defn add-custom-homepage-info
  [user]
  (let [enabled? (public-settings/custom-homepage)
        id       (public-settings/custom-homepage-dashboard)
        dash     (t2/select-one Dashboard :id id)
        valid?   (and enabled? id (some? dash) (not (:archived dash)) (mi/can-read? dash))]
    (assoc user
           :custom_homepage (when valid? {:dashboard_id id}))))

/current

(api/defendpoint GET 
  "Fetch the current `User`."
  []
  (-> (api/check-404 @api/*current-user*)
      (t2/hydrate :personal_collection_id :group_ids :is_installer :has_invited_second_user)
      add-has-question-and-dashboard
      add-first-login
      maybe-add-advanced-permissions
      maybe-add-sso-source
      add-custom-homepage-info))

/:id

(api/defendpoint GET 
  "Fetch a `User`. You must be fetching yourself *or* be a superuser *or* a Group Manager."
  [id]
  {id ms/PositiveInt}
  (try
    (check-self-or-superuser id)
    (catch clojure.lang.ExceptionInfo _e
      (validation/check-group-manager)))
  (-> (api/check-404 (fetch-user :id id))
      (t2/hydrate :user_group_memberships)))

+----------------------------------------------------------------------------------------------------------------+ | Creating a new User -- POST /api/user | +----------------------------------------------------------------------------------------------------------------+

/

(api/defendpoint POST 
  "Create a new `User`, return a 400 if the email address is already taken"
  [:as {{:keys [first_name last_name email user_group_memberships login_attributes] :as body} :body}]
  {first_name             [:maybe ms/NonBlankString]
   last_name              [:maybe ms/NonBlankString]
   email                  ms/Email
   user_group_memberships [:maybe [:sequential user/UserGroupMembership]]
   login_attributes       [:maybe user/LoginAttributes]}
  (api/check-superuser)
  (api/checkp (not (t2/exists? :model/User :%lower.email (u/lower-case-en email)))
              "email" (tru "Email address already in use."))
  (t2/with-transaction [_conn]
    (let [new-user-id (u/the-id (user/create-and-invite-user!
                                 (u/select-keys-when body
                                                     :non-nil [:first_name :last_name :email :password :login_attributes])
                                 @api/*current-user*
                                 false))]
      (maybe-set-user-group-memberships! new-user-id user_group_memberships)
      (snowplow/track-event! ::snowplow/invite
                             {:event           :invite-sent
                              :invited-user-id new-user-id
                              :source          "admin"})
      (-> (fetch-user :id new-user-id)
          (t2/hydrate :user_group_memberships)))))

+----------------------------------------------------------------------------------------------------------------+ | Updating a User -- PUT /api/user/:id | +----------------------------------------------------------------------------------------------------------------+

This predicate tests whether or not the user is allowed to update the email address associated with this account.

(defn- valid-email-update?
  [{:keys [sso_source email]} maybe-new-email]
  (or
   ;; Admin users can update
   api/*is-superuser?*
   ;; If the email address didn't change, let it through
   (= email maybe-new-email)
   ;; We should not allow a regular user to change their email address if they are a google/ldap user
   (and
    (not (= :google sso_source))
    (not (= :ldap sso_source)))))

This predicate tests whether or not the user is allowed to update the first/last name associated with this account. If the user is an SSO user, no name edits are allowed, but we accept if the new names are equal to the existing names.

(defn- valid-name-update?
  [{:keys [sso_source] :as user} name-key new-name]
  (or
   (= (get user name-key) new-name)
   (not sso_source)))

/:id

(api/defendpoint PUT 
  "Update an existing, active `User`.
  Self or superusers can update user info and groups.
  Group Managers can only add/remove users from groups they are manager of."
  [id :as {{:keys [email first_name last_name user_group_memberships
                   is_superuser is_group_manager login_attributes locale] :as body} :body}]
  {id                     ms/PositiveInt
   email                  [:maybe ms/Email]
   first_name             [:maybe ms/NonBlankString]
   last_name              [:maybe ms/NonBlankString]
   user_group_memberships [:maybe [:sequential user/UserGroupMembership]]
   is_superuser           [:maybe :boolean]
   is_group_manager       [:maybe :boolean]
   login_attributes       [:maybe user/LoginAttributes]
   locale                 [:maybe ms/ValidLocale]}
  (try
    (check-self-or-superuser id)
    (catch clojure.lang.ExceptionInfo _e
      (validation/check-group-manager)))
  (check-not-internal-user id)
  ;; only allow updates if the specified account is active
  (api/let-404 [user-before-update (fetch-user :id id, :is_active true)]
    ;; Google/LDAP non-admin users can't change their email to prevent account hijacking
    (api/check-403 (valid-email-update? user-before-update email))
    ;; SSO users (JWT, SAML, LDAP, Google) can't change their first/last names
    (when (contains? body :first_name)
      (api/checkp (valid-name-update? user-before-update :first_name first_name)
                  "first_name" (tru "Editing first name is not allowed for SSO users.")))
    (when (contains? body :last_name)
      (api/checkp (valid-name-update? user-before-update :last_name last_name)
                  "last_name" (tru "Editing last name is not allowed for SSO users.")))
    ;; can't change email if it's already taken BY ANOTHER ACCOUNT
    (api/checkp (not (t2/exists? :model/User, :%lower.email (if email (u/lower-case-en email) email), :id [:not= id]))
                "email" (tru "Email address already associated to another user."))
    (t2/with-transaction [_conn]
      ;; only superuser or self can update user info
      ;; implicitly prevent group manager from updating users' info
      (when (or (= id api/*current-user-id*)
                api/*is-superuser?*)
        (when-let [changes (not-empty
                            (u/select-keys-when body
                                                :present (cond-> #{:first_name :last_name :locale}
                                                           api/*is-superuser?* (conj :login_attributes))
                                                :non-nil (cond-> #{:email}
                                                           api/*is-superuser?* (conj :is_superuser))))]
          (t2/update! :model/User id changes)
          (events/publish-event! :event/user-update {:object (t2/select-one :model/User :id id)
                                                     :previous-object user-before-update
                                                     :user-id api/*current-user-id*}))
        (maybe-update-user-personal-collection-name! user-before-update body))
      (maybe-set-user-group-memberships! id user_group_memberships is_superuser)))
  (-> (fetch-user :id id)
      (t2/hydrate :user_group_memberships)))

+----------------------------------------------------------------------------------------------------------------+ | Reactivating a User -- PUT /api/user/:id/reactivate | +----------------------------------------------------------------------------------------------------------------+

(defn- reactivate-user! [existing-user]
  (t2/update! :model/User (u/the-id existing-user)
              {:is_active     true
               :is_superuser  false
               ;; if the user orignally logged in via Google Auth/LDAP and it's no longer enabled, convert them into a regular user
               ;; (see metabase#3323)
               :sso_source   (case (:sso_source existing-user)
                               :google (when (google/google-auth-enabled) :google)
                               :ldap   (when (api.ldap/ldap-enabled) :ldap)
                               (:sso_source existing-user))})
  ;; now return the existing user whether they were originally active or not
  (fetch-user :id (u/the-id existing-user)))

/:id/reactivate

(api/defendpoint PUT 
  "Reactivate user at `:id`"
  [id]
  {id ms/PositiveInt}
  (api/check-superuser)
  (check-not-internal-user id)
  (let [user (t2/select-one [:model/User :id :email :first_name :last_name :is_active :sso_source]
                            :type :personal
                            :id id)]
    (api/check-404 user)
    ;; Can only reactivate inactive users
    (api/check (not (:is_active user))
               [400 {:message (tru "Not able to reactivate an active user")}])
    (events/publish-event! :event/user-reactivated {:object user :user-id api/*current-user-id*})
    (reactivate-user! (dissoc user [:email :first_name :last_name]))))

+----------------------------------------------------------------------------------------------------------------+ | Updating a Password -- PUT /api/user/:id/password | +----------------------------------------------------------------------------------------------------------------+

/:id/password

(api/defendpoint PUT 
  "Update a user's password."
  [id :as {{:keys [password old_password]} :body, :as request}]
  {id       ms/PositiveInt
   password ms/ValidPassword}
  (check-self-or-superuser id)
  (api/let-404 [user (t2/select-one [:model/User :id :last_login :password_salt :password],
                                    :id id,
                                    :type :personal,
                                    :is_active true)]
    ;; admins are allowed to reset anyone's password (in the admin people list) so no need to check the value of
    ;; `old_password` for them regular users have to know their password, however
    (when-not api/*is-superuser?*
      (api/checkp (u.password/bcrypt-verify (str (:password_salt user) old_password) (:password user))
                  "old_password"
                  (tru "Invalid password")))
    (user/set-password! id password)
    ;; after a successful password update go ahead and offer the client a new session that they can use
    (when (= id api/*current-user-id*)
      (let [{session-uuid :id, :as session} (api.session/create-session! :password user (request/device-info request))
            response                        {:success    true
                                             :session_id (str session-uuid)}]
        (request/set-session-cookies request response session (t/zoned-date-time (t/zone-id "GMT")))))))

+----------------------------------------------------------------------------------------------------------------+ | Deleting (Deactivating) a User -- DELETE /api/user/:id | +----------------------------------------------------------------------------------------------------------------+

/:id

(api/defendpoint DELETE 
  "Disable a `User`.  This does not remove the `User` from the DB, but instead disables their account."
  [id]
  {id ms/PositiveInt}
  (api/check-superuser)
  ;; don't technically need to because the internal user is already 'deleted' (deactivated), but keeps the warnings consistent
  (check-not-internal-user id)
  (api/check-500
   (when (pos? (t2/update! :model/User id {:type :personal} {:is_active false}))
     (events/publish-event! :event/user-deactivated {:object (t2/select-one :model/User :id id) :user-id api/*current-user-id*})))
  {:success true})

+----------------------------------------------------------------------------------------------------------------+ | Other Endpoints | +----------------------------------------------------------------------------------------------------------------+

/:id/modal/:modal

TODO - This could be handled by PUT /api/user/:id, we don't need a separate endpoint

(api/defendpoint PUT 
  "Indicate that a user has been informed about the vast intricacies of 'the' Query Builder."
  [id modal]
  {id ms/PositiveInt}
  (check-self-or-superuser id)
  (check-not-internal-user id)
  (let [k (or (get {"qbnewb"      :is_qbnewb
                    "datasetnewb" :is_datasetnewb}
                   modal)
              (throw (ex-info (tru "Unrecognized modal: {0}" modal)
                              {:modal modal
                               :allowable-modals #{"qbnewb" "datasetnewb"}})))]
    (api/check-500 (pos? (t2/update! :model/User id {:type :personal} {k false}))))
  {:success true})
(api/define-routes)