From 2d37fb4bda9d9a83684b963fdc20f4f7ac30f5ad Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Wed, 23 Sep 2026 15:52:43 +0200 Subject: [PATCH] :sparkles: Add nitrate-actions reactor for create-organization events Hook Nitrate-pushed audit events in process-event through a name allowlist. Use an async worker that POSTs allowlisted events to Admin Console /api/audit-log via the Nitrate client, so AC remains the store of record for those product events. AI-assisted-by: Composer Co-authored-by: Cursor --- backend/src/app/config.clj | 1 + backend/src/app/loggers/audit.clj | 3 + backend/src/app/loggers/nitrate_actions.clj | 93 +++++++++ backend/src/app/main.clj | 17 +- backend/src/app/nitrate.clj | 29 ++- backend/test/backend_tests/helpers.clj | 3 +- .../loggers_nitrate_actions_test.clj | 195 ++++++++++++++++++ 7 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 backend/src/app/loggers/nitrate_actions.clj create mode 100644 backend/test/backend_tests/loggers_nitrate_actions_test.clj diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index a0ef5d344d..4a452d459d 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -184,6 +184,7 @@ [:scheduled-executor-parallelism {:optional true} ::sm/int] ;; REVIEW [:worker-default-parallelism {:optional true} ::sm/int] [:worker-webhook-parallelism {:optional true} ::sm/int] + [:worker-admin-console-parallelism {:optional true} ::sm/int] [:database-password {:optional true} [:maybe :string]] [:database-uri {:optional true} ::sm/uri] diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index 152e0d7cb4..56df467d1a 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -20,6 +20,7 @@ [app.http :as-alias http] [app.http.access-token :as-alias actoken] [app.loggers.audit.tasks :as-alias tasks] + [app.loggers.nitrate-actions :as nitrate-actions] [app.loggers.webhooks :as-alias webhooks] [app.rpc :as-alias rpc] [app.setup :as-alias setup] @@ -289,6 +290,8 @@ (dissoc :context) (dissoc :ip-addr) (dissoc :type))))))) + + (nitrate-actions/handle-event! cfg event) event) (defn submit* diff --git a/backend/src/app/loggers/nitrate_actions.clj b/backend/src/app/loggers/nitrate_actions.clj new file mode 100644 index 0000000000..0af3b4c218 --- /dev/null +++ b/backend/src/app/loggers/nitrate_actions.clj @@ -0,0 +1,93 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.loggers.nitrate-actions + "React to Nitrate-pushed audit events by name allowlist." + (:require + [app.common.data :as d] + [app.config :as cf] + [app.nitrate :as nitrate] + [app.worker :as wrk] + [integrant.core :as ig])) + +(def event-names-to-keep + #{"accept-organization-invitation" + "accept-team-invitation" + "accept-team-invitation-from" + "add-member-to-organization" + "add-team-to-organization" + "cancel-organization-invitation" + "change-organization-advanced-permission" + "create-file" + "create-organization" + "create-organization-invitation" + "create-project" + "create-team" + "create-team-access-request" + "create-team-invitation" + "create-team-invitations" + "create-webhook" + "delete-font" + "delete-organization" + "delete-project" + "delete-team" + "delete-team-invitation" + "delete-team-member" + "leave-team" + "move-project" + "move-team-to-organization" + "organization-sso-auth-failed" + "organization-sso-auth-started" + "organization-sso-auth-succeeded" + "permanently-delete-team-files" + "remove-organization-team" + "remove-team-from-organization" + "rename-organization" + "rename-project" + "restore-deleted-team-files" + "update-organization-invitation" + "update-organization-permissions" + "update-team-invitation" + "update-team-invitation-role" + "update-team-member-role" + "update-team-photo" + "verify-token"}) + +(def ^:private task-param-keys + [:name :type :profile-id :props :created-at :tracked-at + :source :ip-addr :context]) + +(defn- event->task-params + "Keep only fields needed for Admin Console ingest (explicit allowlist)." + [event] + (-> event + (select-keys task-param-keys) + (d/without-nils))) + +(defn handle-event! + "Enqueue a worker task when `:admin-console` is enabled and the + event name is in `event-names-to-keep`. + + Uses the same pattern as webhooks: work runs off the audit + transaction so handler failures cannot roll back audit_log writes." + [cfg event] + (when (and (contains? cf/flags :admin-console) + (contains? event-names-to-keep (:name event))) + (wrk/submit! (-> cfg + (assoc ::wrk/task :process-nitrate-action) + (assoc ::wrk/queue :admin-console) + (assoc ::wrk/max-retries 0) + (assoc ::wrk/params (event->task-params event)))))) + +(defmethod ig/assert-key ::handler + [_ params] + (assert (contains? params :app.nitrate/client) "expected nitrate client")) + +(defmethod ig/init-key ::handler + [_ cfg] + (fn [{:keys [props] :as _task}] + ;; Nil return from a successful AC 204 is OK; exceptions fail the task. + (nitrate/call cfg :ingest-audit-log props))) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 6bd8da3d13..697587e364 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -25,6 +25,7 @@ [app.http.session :as session] [app.http.session.tasks :as-alias session.tasks] [app.http.websocket :as http.ws] + [app.loggers.nitrate-actions :as-alias nitrate-actions] [app.loggers.webhooks :as-alias webhooks] [app.metrics :as-alias mtx] [app.metrics.definition :as-alias mdef] @@ -482,7 +483,9 @@ :process-webhook-event (ig/ref ::webhooks/process-event-handler) :run-webhook - (ig/ref ::webhooks/run-webhook-handler)}} + (ig/ref ::webhooks/run-webhook-handler) + :process-nitrate-action + (ig/ref ::nitrate-actions/handler)}} ::email/blacklist {} @@ -580,6 +583,9 @@ {::db/pool (ig/ref ::db/pool) ::http.client/client (ig/ref ::http.client/client)} + ::nitrate-actions/handler + {:app.nitrate/client (ig/ref :app.nitrate/client)} + :app.loggers.mattermost/reporter {::http.client/client (ig/ref ::http.client/client)} @@ -676,6 +682,15 @@ ::rds/client (ig/ref ::rds/client) ::wrk/registry (ig/ref ::wrk/registry) ::mtx/metrics (ig/ref ::mtx/metrics) + ::db/pool (ig/ref ::db/pool)} + + [::admin-console ::wrk/runner] + {::wrk/parallelism (cf/get ::worker-admin-console-parallelism 1) + ::wrk/queue :admin-console + ::wrk/tenant (cf/get :tenant) + ::rds/client (ig/ref ::rds/client) + ::wrk/registry (ig/ref ::wrk/registry) + ::mtx/metrics (ig/ref ::mtx/metrics) ::db/pool (ig/ref ::db/pool)}}) diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 3b4b41eff0..aa5c0bf1a5 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -7,6 +7,7 @@ (ns app.nitrate "Module that make calls to the external nitrate aplication" (:require + [app.common.data :as d] [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.json :as json] @@ -496,6 +497,31 @@ schema:redeem-result (assoc params :throw-on-error? true))) +(defn- ingest-audit-log-api + "POST one audit event to Admin Console `/api/audit-log`. + Expects HTTP 204; returns nil on success. 4xx raises when + `:throw-on-error?` is set." + [cfg {:keys [name type profile-id props context + created-at tracked-at source ip-addr] :as params}] + (let [request-params (-> {:name name + :type type + :profile-id profile-id + :props props + :context context + :created-at created-at + :tracked-at tracked-at + :source source + :ip-addr ip-addr} + (d/without-nils)) + params (assoc params + :request-params request-params + ::rpc/profile-id profile-id + :throw-on-error? true)] + (request-to-nitrate cfg :post + (generate-nitrate-uri "api/audit-log") + nil + params))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; INITIALIZATION ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -526,7 +552,8 @@ :get-subscription-warning (partial get-subscription-warning-api cfg) :connectivity (partial get-connectivity-api cfg) :get-identity (partial get-identity-api cfg) - :redeem-activation-code (partial redeem-activation-code-api cfg)})) + :redeem-activation-code (partial redeem-activation-code-api cfg) + :ingest-audit-log (partial ingest-audit-log-api cfg)})) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; UTILS diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index b41bde7770..99454c5b40 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -127,7 +127,8 @@ :app.worker/cron :app.worker/dispatcher [:app.main/default :app.worker/runner] - [:app.main/webhook :app.worker/runner])) + [:app.main/webhook :app.worker/runner] + [:app.main/admin-console :app.worker/runner])) _ (ig/load-namespaces system) system (-> (ig/expand system) (ig/init))] (try diff --git a/backend/test/backend_tests/loggers_nitrate_actions_test.clj b/backend/test/backend_tests/loggers_nitrate_actions_test.clj new file mode 100644 index 0000000000..e0602ed744 --- /dev/null +++ b/backend/test/backend_tests/loggers_nitrate_actions_test.clj @@ -0,0 +1,195 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.loggers-nitrate-actions-test + (:require + [app.common.exceptions :as ex] + [app.common.json :as json] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.http.client :as http] + [app.loggers.audit :as audit] + [app.loggers.nitrate-actions :as nitrate-actions] + [app.nitrate :as nitrate] + [app.setup :as-alias setup] + [app.worker :as wrk] + [backend-tests.helpers :as th] + [clojure.string :as str] + [clojure.test :as t] + [integrant.core :as ig] + [mockery.core :refer [with-mocks]])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(defn- sample-event + [profile-id & {:keys [name] :or {name "create-organization"}}] + {:id (uuid/next) + :type "action" + :name name + :profile-id profile-id + :props {:organization-id (uuid/next)} + :context {:version "2.0.0" :initiator "app"} + :tracked-at (ct/now) + :created-at (ct/now) + :source "backend" + :ip-addr "192.168.1.10"}) + +(defn- expected-task-params + [event] + (select-keys event [:name :type :profile-id :props + :created-at :tracked-at :source :ip-addr :context])) + +(defn- with-admin-console-uri + "Redef `cf/get` so `:admin-console-uri` is a string (production shape)." + [uri-str f] + (let [orig-get cf/get] + (with-redefs [cf/get (fn [k & args] + (if (= k :admin-console-uri) + uri-str + (apply orig-get k args)))] + (f)))) + +(t/deftest handle-event-submits-all-interesting-names + (with-redefs [cf/flags (conj cf/flags :admin-console)] + (doseq [event-name nitrate-actions/event-names-to-keep] + (with-mocks [submit-mock {:target 'app.worker/submit! :return nil}] + (let [event (sample-event (uuid/next) :name event-name) + opts (do (nitrate-actions/handle-event! {} event) + (first (:call-args @submit-mock)))] + (t/is (= 1 (:call-count @submit-mock)) event-name) + (t/is (= event-name (get-in opts [::wrk/params :name])))))))) + +(t/deftest handle-event-submits-create-organization + (with-redefs [cf/flags (conj cf/flags :admin-console)] + (with-mocks [submit-mock {:target 'app.worker/submit! :return nil}] + (let [event (sample-event (uuid/next)) + params (expected-task-params event)] + (nitrate-actions/handle-event! {} event) + (t/is (= 1 (:call-count @submit-mock))) + (let [opts (first (:call-args @submit-mock))] + (t/is (= :process-nitrate-action (::wrk/task opts))) + (t/is (= :admin-console (::wrk/queue opts))) + (t/is (= 0 (::wrk/max-retries opts))) + (t/is (= params (::wrk/params opts)))))))) + +(t/deftest handle-event-ignores-other-names + (with-redefs [cf/flags (conj cf/flags :admin-console)] + (with-mocks [submit-mock {:target 'app.worker/submit! :return nil}] + (let [event (sample-event (uuid/next) :name "update-profile")] + (nitrate-actions/handle-event! {} event) + (t/is (= 0 (:call-count @submit-mock))))))) + +(t/deftest allowlist-excludes-frontend-only-names + ;; push-audit-events never calls process-event / handle-event!; keep + ;; these names out of the allowlist so we do not claim AC coverage. + (doseq [name #{"add-font" "delete-files" "restore-files" + "delete-organization-member"}] + (t/is (not (contains? nitrate-actions/event-names-to-keep name)) + name))) + +(t/deftest handle-event-requires-admin-console-flag + (with-redefs [cf/flags #{}] + (with-mocks [submit-mock {:target 'app.worker/submit! :return nil}] + (nitrate-actions/handle-event! {} (sample-event (uuid/next))) + (t/is (= 0 (:call-count @submit-mock)))))) + +(t/deftest process-event-enqueues-create-organization + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags (conj cf/flags :admin-console)] + (with-mocks [submit-mock {:target 'app.worker/submit! :return nil}] + (let [event (sample-event (:id prof))] + (audit/submit* th/*system* event) + (t/is (= 1 (:call-count @submit-mock))) + (let [opts (first (:call-args @submit-mock)) + params (::wrk/params opts)] + (t/is (= :process-nitrate-action (::wrk/task opts))) + (t/is (= "create-organization" (:name params))) + (t/is (= "backend" (:source params))) + (t/is (= "192.168.1.10" (:ip-addr params))) + (t/is (= (:context event) (:context params))))))))) + +(t/deftest process-event-skips-non-interesting-names + (let [prof (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags (conj cf/flags :admin-console)] + (with-mocks [submit-mock {:target 'app.worker/submit! :return nil}] + (let [event (sample-event (:id prof) :name "update-profile")] + (audit/submit* th/*system* event) + (t/is (= 0 (:call-count @submit-mock)))))))) + +(t/deftest process-nitrate-action-handler-ingests + (let [called (atom nil) + event (sample-event (uuid/next))] + (with-redefs [nitrate/call (fn [_cfg method params] + (reset! called {:method method :params params}) + nil)] + (th/run-task! :process-nitrate-action event) + (t/is (= :ingest-audit-log (:method @called))) + (t/is (= (:name event) (get-in @called [:params :name]))) + (t/is (= (:profile-id event) (get-in @called [:params :profile-id])))))) + +(t/deftest process-nitrate-action-handler-propagates-errors + (with-redefs [nitrate/call (fn [& _] + (ex/raise :type :nitrate-http-error + :hint "boom"))] + (t/is (thrown? clojure.lang.ExceptionInfo + (th/run-task! :process-nitrate-action + (sample-event (uuid/next))))))) + +(t/deftest ingest-audit-log-posts-json-body + (with-redefs [cf/flags (conj cf/flags :admin-console)] + (with-admin-console-uri + "http://ac.example/admin-console/" + (fn [] + (with-mocks [http-mock {:target 'app.http.client/req + :return {:status 204 :body nil}}] + (let [profile-id (uuid/next) + client (ig/init-key :app.nitrate/client + {::http/client (Object.) + ::setup/shared-keys {:admin-console "test-shared-key"}}) + cfg {:app.nitrate/client client} + event {:name "create-organization" + :type "action" + :profile-id profile-id + :props {:organization-id (uuid/next)} + :context {:initiator "app"} + :source "backend" + :ip-addr "127.0.0.1" + :created-at (ct/inst "2026-09-23T14:13:19.368Z") + :tracked-at (ct/inst "2026-09-23T14:13:19.368Z")} + result (nitrate/call cfg :ingest-audit-log event)] + (t/is (nil? result)) + (t/is (= 1 (:call-count @http-mock))) + (let [[_req-cfg req] (:call-args @http-mock)] + (t/is (= :post (:method req))) + (t/is (= "test-shared-key" (get-in req [:headers "x-shared-key"]))) + (t/is (str/ends-with? (str (:uri req)) "api/audit-log")) + (let [body (json/decode (:body req) :key-fn json/read-kebab-key)] + (t/is (= "create-organization" (:name body))) + (t/is (= "action" (:type body))) + (t/is (= (str profile-id) (str (:profile-id body)))) + (t/is (= "backend" (:source body))) + (t/is (= "127.0.0.1" (:ip-addr body))))))))))) + +(t/deftest ingest-audit-log-throws-on-4xx + (with-redefs [cf/flags (conj cf/flags :admin-console)] + (with-admin-console-uri + "http://ac.example/admin-console/" + (fn [] + (with-mocks [http-mock {:target 'app.http.client/req + :return {:status 400 :body "{\"error\":\"bad\"}"}}] + (let [client (ig/init-key :app.nitrate/client + {::http/client (Object.) + ::setup/shared-keys {:admin-console "test-shared-key"}}) + cfg {:app.nitrate/client client}] + (t/is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"nitrate HTTP 400" + (nitrate/call cfg :ingest-audit-log + {:name "create-organization" + :type "action" + :profile-id (uuid/next)})))))))))