✨ 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 <cursoragent@cursor.com>
This commit is contained in:
Pablo Alba 2026-09-23 15:52:43 +02:00
parent dad6026132
commit 2d37fb4bda
7 changed files with 338 additions and 3 deletions

View File

@ -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]

View File

@ -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*

View File

@ -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)))

View File

@ -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)}})

View File

@ -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

View File

@ -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

View File

@ -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)})))))))))