From 6db14952636e507835ac23723a4e58ce2adc4be2 Mon Sep 17 00:00:00 2001
From: Noah Moss <32746338+noahmoss@users.noreply.github.com>
Date: Thu, 17 Oct 2024 18:23:34 -0400
Subject: [PATCH] Track SCIM users + API response success/error counts (#48702)

---
 .../src/metabase_enterprise/scim/v2/api.clj   | 272 ++++++++++--------
 .../metabase_enterprise/scim/v2/api_test.clj  |  28 +-
 src/metabase/analytics/prometheus.clj         |   4 +
 src/metabase/analytics/stats.clj              |  25 +-
 4 files changed, 194 insertions(+), 135 deletions(-)

diff --git a/enterprise/backend/src/metabase_enterprise/scim/v2/api.clj b/enterprise/backend/src/metabase_enterprise/scim/v2/api.clj
index 24c8a0ac93f..dff41dec686 100644
--- a/enterprise/backend/src/metabase_enterprise/scim/v2/api.clj
+++ b/enterprise/backend/src/metabase_enterprise/scim/v2/api.clj
@@ -6,6 +6,7 @@
   (:require
    [compojure.core :refer [GET POST]]
    [metabase-enterprise.scim.api :as scim]
+   [metabase.analytics.prometheus :as prometheus]
    [metabase.api.common :as api :refer [defendpoint]]
    [metabase.models.interface :as mi]
    [metabase.models.permissions-group :as perms-group]
@@ -106,6 +107,22 @@
    :body    object
    :headers {"Content-Type" "application/scim+json"}})
 
+(defn- do-with-prometheus-counters
+  [thunk]
+  (try
+    (let [response (thunk)]
+      (prometheus/inc! :metabase-scim/response-ok)
+      response)
+    (catch Throwable e
+      (prometheus/inc! :metabase-scim/response-error)
+      (throw e))))
+
+(defmacro with-prometheus-counters
+  "Macro to wrap SCIM endpoints and automatically increment Prometheus counters to track success and error API
+  responses."
+  [& body]
+  `(do-with-prometheus-counters (fn [] ~@body)))
+
 ;;; +----------------------------------------------------------------------------------------------------------------+
 ;;; |                                               User operations                                                  |
 ;;; +----------------------------------------------------------------------------------------------------------------+
@@ -163,7 +180,8 @@
       :last_name  familyName
       :email      email
       :is_active  is-active?
-      :type       :personal}
+      :type       :personal
+      :sso_source "scim"}
      (when (and locale (i18n/available-locale? locale))
        {:locale locale}))))
 
@@ -188,98 +206,103 @@
   {start-index  [:maybe ms/PositiveInt]
    c            [:maybe ms/PositiveInt]
    filter-param [:maybe ms/NonBlankString]}
-  (let [limit          (or c default-pagination-limit)
-        ;; SCIM start-index is 1-indexed, so we need to decrement it here
-        offset         (if start-index (dec start-index) default-pagination-offset)
-        filter-param   (when filter-param (codec/url-decode filter-param))
-        where-clause   [:and [:= :type "personal"]
-                        (when filter-param (user-filter-clause filter-param))]
-        users          (t2/select (cons :model/User user-cols)
-                                  {:where    where-clause
-                                   :limit    limit
-                                   :offset   offset
-                                   :order-by [[:id :asc]]})
-        hydrated-users (t2/hydrate users :scim_user_group_memberships)
-        results-count  (count hydrated-users)
-        items-per-page (if (< results-count limit) results-count limit)
-        result         {:schemas      [list-schema-uri]
-                        :totalResults (t2/count :model/User {:where where-clause})
-                        :startIndex   (inc offset)
-                        :itemsPerPage items-per-page
-                        :Resources    (map mb-user->scim hydrated-users)}]
-    (scim-response result)))
+  (with-prometheus-counters
+    (let [limit          (or c default-pagination-limit)
+          ;; SCIM start-index is 1-indexed, so we need to decrement it here
+          offset         (if start-index (dec start-index) default-pagination-offset)
+          filter-param   (when filter-param (codec/url-decode filter-param))
+          where-clause   [:and [:= :type "personal"]
+                          (when filter-param (user-filter-clause filter-param))]
+          users          (t2/select (cons :model/User user-cols)
+                                    {:where    where-clause
+                                     :limit    limit
+                                     :offset   offset
+                                     :order-by [[:id :asc]]})
+          hydrated-users (t2/hydrate users :scim_user_group_memberships)
+          results-count  (count hydrated-users)
+          items-per-page (if (< results-count limit) results-count limit)
+          result         {:schemas      [list-schema-uri]
+                          :totalResults (t2/count :model/User {:where where-clause})
+                          :startIndex   (inc offset)
+                          :itemsPerPage items-per-page
+                          :Resources    (map mb-user->scim hydrated-users)}]
+      (scim-response result))))
 
 (defendpoint GET "/Users/:id"
   "Fetch a single user."
   [id]
   {id ms/NonBlankString}
-  (-> (get-user-by-entity-id id)
-      (t2/hydrate :scim_user_group_memberships)
-      mb-user->scim))
+  (with-prometheus-counters
+    (-> (get-user-by-entity-id id)
+        (t2/hydrate :scim_user_group_memberships)
+        mb-user->scim)))
 
 (defendpoint POST "/Users"
   "Create a single user."
   [:as {scim-user :body}]
   {scim-user SCIMUser}
-  (let [mb-user (scim-user->mb scim-user)
-        email   (:email mb-user)]
-    (when (t2/exists? :model/User :%lower.email (u/lower-case-en email))
-      (throw-scim-error 409 "Email address is already in use"))
-    (let [new-user (t2/with-transaction [_]
-                     (user/insert-new-user! mb-user)
-                     (-> (t2/select-one (cons :model/User user-cols)
-                                        :email (u/lower-case-en email))
-                         mb-user->scim))]
-      (scim-response new-user 201))))
+  (with-prometheus-counters
+    (let [mb-user (scim-user->mb scim-user)
+          email   (:email mb-user)]
+      (when (t2/exists? :model/User :%lower.email (u/lower-case-en email))
+        (throw-scim-error 409 "Email address is already in use"))
+      (let [new-user (t2/with-transaction [_]
+                       (user/insert-new-user! mb-user)
+                       (-> (t2/select-one (cons :model/User user-cols)
+                                          :email (u/lower-case-en email))
+                           mb-user->scim))]
+        (scim-response new-user 201)))))
 
 (defendpoint PUT "/Users/:id"
   "Update a user."
   [:as {scim-user :body {id :id} :params}]
   {scim-user SCIMUser}
-  (let [updates      (scim-user->mb scim-user)
-        email        (-> scim-user :emails first :value)
-        current-user (get-user-by-entity-id id)]
-    (if (not= email (:email current-user))
-      (throw-scim-error 400 "You may not update the email of an existing user.")
-      (try
-        (t2/with-transaction [_conn]
-          (t2/update! :model/User (u/the-id current-user) updates)
-          (let [user (-> (t2/select-one (cons :model/User user-cols)
-                                        :entity_id id)
-                         mb-user->scim)]
-            (scim-response user)))
-        (catch Exception e
-          (let [message (format "Error updating user: %s" (ex-message e))]
-            (throw (ex-info message
-                            {:schemas     [error-schema-uri]
-                             :detail      message
-                             :status      400
-                             :status-code 400}))))))))
+  (with-prometheus-counters
+    (let [updates      (scim-user->mb scim-user)
+          email        (-> scim-user :emails first :value)
+          current-user (get-user-by-entity-id id)]
+      (if (not= email (:email current-user))
+        (throw-scim-error 400 "You may not update the email of an existing user.")
+        (try
+          (t2/with-transaction [_conn]
+            (t2/update! :model/User (u/the-id current-user) updates)
+            (let [user (-> (t2/select-one (cons :model/User user-cols)
+                                          :entity_id id)
+                           mb-user->scim)]
+              (scim-response user)))
+          (catch Exception e
+            (let [message (format "Error updating user: %s" (ex-message e))]
+              (throw (ex-info message
+                              {:schemas     [error-schema-uri]
+                               :detail      message
+                               :status      400
+                               :status-code 400})))))))))
 
 (defendpoint PATCH "/Users/:id"
   "Activate or deactivate a user. Supports specific replace operations, but not arbitrary patches."
   [:as {patch-ops :body {id :id} :params}]
   {patch-ops UserPatch}
   {id ms/NonBlankString}
-  (t2/with-transaction [_conn]
-    (let [user    (get-user-by-entity-id id)
-          updates (reduce
-                   (fn [acc operation]
-                     (let [{:keys [op path value]} operation]
-                       (if (= (u/lower-case-en op) "replace")
-                         (case path
-                           "active"          (assoc acc :is_active (Boolean/valueOf (u/lower-case-en value)))
-                           "userName"        (assoc acc :email value)
-                           "name.givenName"  (assoc acc :first_name value)
-                           "name.familyName" (assoc acc :last_name value)
-                           (throw-scim-error 400 (format "Unsupported path: %s" path)))
-                         acc)))
-                   {}
-                   (:Operations patch-ops))]
-      (t2/update! :model/User (u/the-id user) updates)
-      (-> (get-user-by-entity-id id)
-          mb-user->scim
-          scim-response))))
+  (with-prometheus-counters
+    (t2/with-transaction [_conn]
+      (let [user    (get-user-by-entity-id id)
+            updates (reduce
+                     (fn [acc operation]
+                       (let [{:keys [op path value]} operation]
+                         (if (= (u/lower-case-en op) "replace")
+                           (case path
+                             "active"          (assoc acc :is_active (Boolean/valueOf (u/lower-case-en value)))
+                             "userName"        (assoc acc :email value)
+                             "name.givenName"  (assoc acc :first_name value)
+                             "name.familyName" (assoc acc :last_name value)
+                             (throw-scim-error 400 (format "Unsupported path: %s" path)))
+                           acc)))
+                     {}
+                     (:Operations patch-ops))]
+        (t2/update! :model/User (u/the-id user) updates)
+        (-> (get-user-by-entity-id id)
+            mb-user->scim
+            scim-response)))))
 
 ;;; +----------------------------------------------------------------------------------------------------------------+
 ;;; |                                              Group operations                                                  |
@@ -344,35 +367,37 @@
   {start-index  [:maybe ms/PositiveInt]
    c            [:maybe ms/PositiveInt]
    filter-param [:maybe ms/NonBlankString]}
-  (let [limit          (or c default-pagination-limit)
-        ;; SCIM start-index is 1-indexed, so we need to decrement it here
-        offset         (if start-index (dec start-index) default-pagination-offset)
-        filter-param   (when filter-param (codec/url-decode filter-param))
-        where-clause   [:and
-                        [:not= :id (:id perms-group/all-users)]
-                        [:not= :id (:id perms-group/admin)]
-                        (when filter-param (group-filter-clause filter-param))]
-        groups         (t2/select (cons :model/PermissionsGroup group-cols)
-                                  {:where    where-clause
-                                   :limit    limit
-                                   :offset   offset
-                                   :order-by [[:id :asc]]})
-        results-count  (count groups)
-        items-per-page (if (< results-count limit) results-count limit)
-        result         {:schemas      [list-schema-uri]
-                        :totalResults (t2/count :model/PermissionsGroup {:where where-clause})
-                        :startIndex   (inc offset)
-                        :itemsPerPage items-per-page
-                        :Resources    (map mb-group->scim groups)}]
-    (scim-response result)))
+  (with-prometheus-counters
+    (let [limit          (or c default-pagination-limit)
+          ;; SCIM start-index is 1-indexed, so we need to decrement it here
+          offset         (if start-index (dec start-index) default-pagination-offset)
+          filter-param   (when filter-param (codec/url-decode filter-param))
+          where-clause   [:and
+                          [:not= :id (:id perms-group/all-users)]
+                          [:not= :id (:id perms-group/admin)]
+                          (when filter-param (group-filter-clause filter-param))]
+          groups         (t2/select (cons :model/PermissionsGroup group-cols)
+                                    {:where    where-clause
+                                     :limit    limit
+                                     :offset   offset
+                                     :order-by [[:id :asc]]})
+          results-count  (count groups)
+          items-per-page (if (< results-count limit) results-count limit)
+          result         {:schemas      [list-schema-uri]
+                          :totalResults (t2/count :model/PermissionsGroup {:where where-clause})
+                          :startIndex   (inc offset)
+                          :itemsPerPage items-per-page
+                          :Resources    (map mb-group->scim groups)}]
+      (scim-response result))))
 
 (defendpoint GET "/Groups/:id"
   "Fetch a single group."
   [id]
   {id ms/NonBlankString}
-  (-> (get-group-by-entity-id id)
-      (t2/hydrate :scim_group_members)
-      mb-group->scim))
+  (with-prometheus-counters
+    (-> (get-group-by-entity-id id)
+        (t2/hydrate :scim_group_members)
+        mb-group->scim)))
 
 (defn- update-group-membership
   "Updates the membership of `group-id` to be the set of users in the collection `user-entity-ids`. Clears
@@ -389,41 +414,44 @@
   "Create a single group, and populates it if necessary."
   [:as {scim-group :body}]
   {scim-group SCIMGroup}
-  (let [group-name (:displayName scim-group)
-        entity-ids (map :value (:members scim-group))]
-    (when (t2/exists? :model/PermissionsGroup :%lower.name (u/lower-case-en group-name))
-      (throw-scim-error 409 "A group with that name already exists"))
-    (t2/with-transaction [_conn]
-      (let [new-group (first (t2/insert-returning-instances! :model/PermissionsGroup {:name group-name}))]
-        (when (seq entity-ids)
-          (update-group-membership (:id new-group) entity-ids))
-        (-> new-group
-            (t2/hydrate :scim_group_members)
-            mb-group->scim
-            (scim-response 201))))))
+  (with-prometheus-counters
+    (let [group-name (:displayName scim-group)
+          entity-ids (map :value (:members scim-group))]
+      (when (t2/exists? :model/PermissionsGroup :%lower.name (u/lower-case-en group-name))
+        (throw-scim-error 409 "A group with that name already exists"))
+      (t2/with-transaction [_conn]
+        (let [new-group (first (t2/insert-returning-instances! :model/PermissionsGroup {:name group-name}))]
+          (when (seq entity-ids)
+            (update-group-membership (:id new-group) entity-ids))
+          (-> new-group
+              (t2/hydrate :scim_group_members)
+              mb-group->scim
+              (scim-response 201)))))))
 
 (defendpoint PUT "/Groups/:id"
   "Update a group."
   [:as {scim-group :body {id :id} :params}]
   {scim-group SCIMGroup}
-  (let [group-name (:displayName scim-group)
-        entity-ids (map :value (:members scim-group))]
-    (t2/with-transaction [_conn]
-      (let [group (get-group-by-entity-id id)]
-        (t2/update! :model/PermissionsGroup (u/the-id group) {:name group-name})
-        (when (seq entity-ids)
-          (update-group-membership (u/the-id group) entity-ids))
-        (-> (get-group-by-entity-id id)
-            (t2/hydrate :scim_group_members)
-            mb-group->scim
-            scim-response)))))
+  (with-prometheus-counters
+    (let [group-name (:displayName scim-group)
+          entity-ids (map :value (:members scim-group))]
+      (t2/with-transaction [_conn]
+        (let [group (get-group-by-entity-id id)]
+          (t2/update! :model/PermissionsGroup (u/the-id group) {:name group-name})
+          (when (seq entity-ids)
+            (update-group-membership (u/the-id group) entity-ids))
+          (-> (get-group-by-entity-id id)
+              (t2/hydrate :scim_group_members)
+              mb-group->scim
+              scim-response))))))
 
 (defendpoint DELETE "/Groups/:id"
   "Delete a group."
   [id]
   {id ms/NonBlankString}
-  (let [group (get-group-by-entity-id id)]
-    (t2/delete! :model/PermissionsGroup (u/the-id group))
-    (scim-response nil 204)))
+  (with-prometheus-counters
+    (let [group (get-group-by-entity-id id)]
+      (t2/delete! :model/PermissionsGroup (u/the-id group))
+      (scim-response nil 204))))
 
 (api/define-routes)
diff --git a/enterprise/backend/test/metabase_enterprise/scim/v2/api_test.clj b/enterprise/backend/test/metabase_enterprise/scim/v2/api_test.clj
index 20dd7d6ca68..8e7dbc1382e 100644
--- a/enterprise/backend/test/metabase_enterprise/scim/v2/api_test.clj
+++ b/enterprise/backend/test/metabase_enterprise/scim/v2/api_test.clj
@@ -3,6 +3,7 @@
    [clojure.test :refer :all]
    [metabase-enterprise.scim.api :as scim]
    [metabase-enterprise.scim.v2.api :as scim-api]
+   [metabase.analytics.prometheus :as prometheus]
    [metabase.http-client :as client]
    [metabase.models.permissions-group :as perms-group]
    [metabase.test :as mt]
@@ -64,6 +65,28 @@
     (testing "A SCIM API key cannot be passed via the x-api-key header"
       (client/client :get 401 "ee/scim/v2/Users" {:request-options {:headers {"x-api-key" *scim-api-key*}}}))))
 
+(deftest prometheus-metrics-test
+  (testing "Prometheus counters get incremented for success responses and errors"
+    (with-scim-setup!
+      (let [calls (atom nil)]
+        (with-redefs [prometheus/inc! #(swap! calls conj %)]
+          (testing "Success response"
+            (scim-client :get 200 "ee/scim/v2/Users")
+            (is (= 1 (count (filter #{:metabase-scim/response-ok} @calls))))
+            (is (= 0 (count (filter #{:metabase-scim/response-error} @calls)))))
+
+          (testing "Bad request (400)"
+            (scim-client :get 400 (format "ee/scim/v2/Users?filter=%s"
+                                          (codec/url-encode "id ne \"newuser@metabase.com\"")))
+            (is (= 1 (count (filter #{:metabase-scim/response-ok} @calls))))
+            (is (= 1 (count (filter #{:metabase-scim/response-error} @calls)))))
+
+          (testing "Unexpected server error (500)"
+            (with-redefs [scim-api/scim-response #(throw (Exception.))]
+              (scim-client :get 500 "ee/scim/v2/Users")
+              (is (= 1 (count (filter #{:metabase-scim/response-ok} @calls))))
+              (is (= 2 (count (filter #{:metabase-scim/response-error} @calls)))))))))))
+
 (deftest fetch-user-test
   (with-scim-setup!
     (testing "A single user can be fetched in the SCIM format by entity ID with its groups"
@@ -142,8 +165,9 @@
                           :active true}
                 response (scim-client :post 201 "ee/scim/v2/Users" new-user)]
             (is (malli= scim-api/SCIMUser response))
-            (is (=? (select-keys user [:email :first_name :last_name :is_active])
-                    (t2/select-one [:model/User :email :first_name :last_name :is_active]
+            (is (=? (assoc (select-keys user [:email :first_name :last_name :is_active])
+                           :sso_source :scim)
+                    (t2/select-one [:model/User :email :first_name :last_name :is_active :sso_source]
                                    :entity_id (:id response)))))
           (finally (t2/delete! :model/User :email (:email user))))))
 
diff --git a/src/metabase/analytics/prometheus.clj b/src/metabase/analytics/prometheus.clj
index d427d039df3..0279513c344 100644
--- a/src/metabase/analytics/prometheus.clj
+++ b/src/metabase/analytics/prometheus.clj
@@ -203,6 +203,10 @@
                        {:description (trs "Number of successful SDK requests.")})
    (prometheus/counter :metabase-sdk/response-error
                        {:description (trs "Number of errors when responding to SDK requests.")})
+   (prometheus/counter :metabase-scim/response-ok
+                       {:description (trs "Number of successful responses from SCIM endpoints")})
+   (prometheus/counter :metabase-scim/response-error
+                       {:description (trs "Number of error responses from SCIM endpoints")})
    (prometheus/counter :metabase-query-processor/metrics
                        {:description (trs "Number of queries consuming metrics processed by the query processor.")})
    (prometheus/counter :metabase-query-processor/metric-errors
diff --git a/src/metabase/analytics/stats.clj b/src/metabase/analytics/stats.clj
index e8f378fb556..d87b9fc82c3 100644
--- a/src/metabase/analytics/stats.clj
+++ b/src/metabase/analytics/stats.clj
@@ -633,17 +633,20 @@
   (let [one-day-ago (->one-day-ago)
         total-translation-count (:total (get-translation-count))
         _ (clear-translation-count!)]
-    {:models                  (t2/count :model/Card :type :model :archived false)
-     :new_embedded_dashboards (t2/count :model/Dashboard
-                                        :enable_embedding true
-                                        :archived false
-                                        :created_at [:>= one-day-ago])
-     :new_users_last_24h        (t2/count :model/User
-                                          :is_active true
-                                          :date_joined [:>= one-day-ago])
-     :pivot_tables              (t2/count :model/Card :display :pivot :archived false)
-     :query_executions_last_24h (t2/count :model/QueryExecution :started_at [:>= one-day-ago])
-     :entity_id_translations_last_24h total-translation-count}))
+    {:models                          (t2/count :model/Card :type :model :archived false)
+     :new_embedded_dashboards         (t2/count :model/Dashboard
+                                                :enable_embedding true
+                                                :archived false
+                                                :created_at [:>= one-day-ago])
+     :new_users_last_24h              (t2/count :model/User
+                                                :is_active true
+                                                :date_joined [:>= one-day-ago])
+     :pivot_tables                    (t2/count :model/Card :display :pivot :archived false)
+     :query_executions_last_24h       (t2/count :model/QueryExecution :started_at [:>= one-day-ago])
+     :entity_id_translations_last_24h total-translation-count
+     :scim_users_last_24h             (t2/count :model/User :sso_source :scim
+                                                :is_active true
+                                                :date_joined [:>= one-day-ago])}))
 
 (mu/defn- snowplow-metrics
   [stats metric-info :- [:map
-- 
GitLab