mirror of
https://github.com/penpot/penpot.git
synced 2026-10-31 06:50:33 +00:00
✨ 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:
parent
dad6026132
commit
2d37fb4bda
@ -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]
|
||||
|
||||
@ -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*
|
||||
|
||||
93
backend/src/app/loggers/nitrate_actions.clj
Normal file
93
backend/src/app/loggers/nitrate_actions.clj
Normal 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)))
|
||||
@ -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)}})
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
195
backend/test/backend_tests/loggers_nitrate_actions_test.clj
Normal file
195
backend/test/backend_tests/loggers_nitrate_actions_test.clj
Normal 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)})))))))))
|
||||
Loading…
x
Reference in New Issue
Block a user