Merge remote-tracking branch 'origin/main' into staging

This commit is contained in:
Andrey Antukh 2026-09-29 23:07:26 +02:00
commit 94d6f5a25c
14 changed files with 379 additions and 283 deletions

View File

@ -16,7 +16,10 @@ Penpot records what users do as events in the Postgres `audit_log` table. There
- Most backend events need no manual code: `wrap-audit` in `app.rpc` runs after every RPC handler when `:webhooks`, `:audit-log` or `:telemetry` is on (unless the command sets `::audit/skip`) and builds the event via `prepare-rpc-event`. The event name defaults to the command name (prefixed with `<module>-` outside `main`), props default to the request params, and timestamps come from the server request time. - Most backend events need no manual code: `wrap-audit` in `app.rpc` runs after every RPC handler when `:webhooks`, `:audit-log` or `:telemetry` is on (unless the command sets `::audit/skip`) and builds the event via `prepare-rpc-event`. The event name defaults to the command name (prefixed with `<module>-` outside `main`), props default to the request params, and timestamps come from the server request time.
- Commands customize through result metadata (`rph/with-meta`): `::audit/replace-props` swaps the props wholesale (auth commands use `profile->props` so a register event carries the profile, not the password), `::audit/props` merges extras, `::audit/context`/`profile-id`/`name`/`type` override the defaults. `clean-props` always strips nils, qualified keys and `:session-id/:password/:old-password/:token/:client-secret` as a last line of defense. - Commands customize through result metadata (`rph/with-meta`): `::audit/replace-props` swaps the props wholesale (auth commands use `profile->props` so a register event carries the profile, not the password), `::audit/props` merges extras, `::audit/context`/`profile-id`/`name`/`type` override the defaults. `clean-props` always strips nils, qualified keys and `:session-id/:password/:old-password/:token/:client-secret` as a last line of defense.
- INVARIANT: the event's `profile-id` is the caller, resolved as `::audit/profile-id` metadata -> `::rpc/profile-id` -> `uuid/zero`. It is NEVER taken from the response. Results carry `:profile-id` keys that belong to someone else (`get-error-report` returns the report with its content merged, so the profile that owned the report; `verify-token` on a team invitation returns the inviter), and honoring them misattributes the action. Commands with `::rpc/auth false` (`login-*`, `register-profile`, `create-demo-profile`, `verify-token`, `prepare-register-profile`) have no caller to fall back on, so they MUST set `::audit/profile-id` in the result metadata or the event lands on `uuid/zero`.
- The `::audit/profile-id` override goes through `coerce-profile-id`: a string is parsed, anything that cannot become a uuid is discarded (with a warning) and the event falls back to the caller. Do not pass the value straight through: `schema:event` requires `::sm/uuid` and `submit*` swallows the validation error, so a non-uuid override silently loses the row instead of failing loudly. Hand-built events built with `event-from-rpc-params` + `submit` (clone-template, accept-team-invitation, `management/nitrate` push-audit-events) do NOT go through that coercion: they must supply a uuid.
- `submit` is the normal entry point (fills defaults, validates `schema:event`, runs inside `tx-run!`, logs failures without failing the RPC). `insert` is the low-level one for CLI/helpers and the webhook subsystem: direct write, no webhook/telemetry fan-out, silent unless `:audit-log` is on. Boot emits `trigger/instance-start` from `setup/props` so every restart is visible in the log. - `submit` is the normal entry point (fills defaults, validates `schema:event`, runs inside `tx-run!`, logs failures without failing the RPC). `insert` is the low-level one for CLI/helpers and the webhook subsystem: direct write, no webhook/telemetry fan-out, silent unless `:audit-log` is on. Boot emits `trigger/instance-start` from `setup/props` so every restart is visible in the log.
- `accept-organization-invitation` distinguishes its two flows with the props, not with an origin: `:organization-member-add-source` is `"direct-organization-invitation"` for a direct organization invitation and `"team-invitation"` for a team invitation that also adds to the organization, and `:belongs-to-team-on-add` repeats the same distinction as a boolean. Do not add a third prop for it. The event `context` has an `:event-origin`, but it belongs to the browser: `make-data-event` in `app.main.data.event` puts the UI origin there and it is on the frontend allowlist, while `safe-backend-context-keys` lists no `:event-origin`, so a backend event has nowhere to put one. Text props are dropped from the telemetry shadow rows by `filter-telemetry-props`; the full `audit_log` row and the Nexus archive still carry them.
## Consumers I: webhooks (`app.loggers.webhooks`) ## Consumers I: webhooks (`app.loggers.webhooks`)
@ -48,4 +51,4 @@ Penpot records what users do as events in the Postgres `audit_log` table. There
## Tests ## Tests
- `backend_tests/rpc-audit-test.clj` exercises the whole backend path (full-row insert, telemetry-only and dual-row modes, `submit*`, no-op without flags, `insert` gating, `prepare-rpc-event` resolution) with `with-redefs [cf/flags #{...}]` against real `audit_log` rows. - `backend_tests/rpc-audit-test.clj` exercises the whole backend path (full-row insert, telemetry-only and dual-row modes, `submit*`, no-op without flags, `insert` gating, `prepare-rpc-event` resolution) with `with-redefs [cf/flags #{...}]` against real `audit_log` rows.
- Other RPC suites mock `app.loggers.audit/submit` (nil return; `helpers.clj` stubs it globally) and assert on `:call-args-list`; any new command that must (or must not) emit an event needs the same treatment. - Other RPC suites mock `app.loggers.audit/submit` (nil return; `helpers.clj` stubs it globally) and assert on `:call-args-list`; any new command that must (or must not) emit an event needs the same treatment. `rpc-team-test/accept-organization-invitation-audit-event` is the reference for invitation events: three scenarios (direct org invitation, team invitation that adds to the org, already-a-member) asserting the emitted props, the add source and the team flag that tell the two flows apart, and the exact number of rows per event name. The count matters: asserting props against the first match with `first` passes even when a command emits the same name twice. Props have no schema on either ingest path, so these assertions are the only guard on the event contract.

View File

@ -6,6 +6,7 @@
- Do not treat nil as a distinct persisted state from absence. Import/export and cleanup paths may filter nil attrs away. - Do not treat nil as a distinct persisted state from absence. Import/export and cleanup paths may filter nil attrs away.
- Avoid Clojure-special naming in exported object attrs, especially boolean names ending in `?`; exported/imported data must survive JSON/SVG/Transit and external tooling. - Avoid Clojure-special naming in exported object attrs, especially boolean names ending in `?`; exported/imported data must survive JSON/SVG/Transit and external tooling.
- Any new shape attr that participates in component sync must be listed in `app.common.types.component/sync-attrs` with the correct touched group. Attrs absent from `sync-attrs` are ignored by component synchronization. - Any new shape attr that participates in component sync must be listed in `app.common.types.component/sync-attrs` with the correct touched group. Attrs absent from `sync-attrs` are ignored by component synchronization.
- Shape `:svg-attrs` is the one map whose keys are camelCase React prop names (`fillRule`, not `fill-rule`), because the SVG import path runs them through `app.common.svg/attrs->props`. The `.penpot` reader (`app.common.json/read-kebab-key`) rewrites every json key of every entry to kebab-case, nested maps included, so any code that decodes a binary file must run `:svg-attrs` back through `attrs->props` or the names are lost. `:svg-defs` and the `:content` tree of `svg-raw` shapes keep their source names and round-trip on their own.
## Cross-module update checklist ## Cross-module update checklist

View File

@ -9,6 +9,7 @@
for recently imported shapes." for recently imported shapes."
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.svg :as csvg]
[app.common.types.shape :as cts] [app.common.types.shape :as cts]
[app.common.uuid :as uuid])) [app.common.uuid :as uuid]))
@ -105,6 +106,15 @@
:reverse-column :column-reverse :reverse-column :column-reverse
dir)))) dir))))
(defn- fix-svg-attrs
"The json reader of the binfile rewrites every key of every entry to
kebab-case, but `:svg-attrs` keys are react prop names and are stored
in camelCase. `attrs->props` is the transform the svg import path
already applies to them, so running it again restores the names; it
is idempotent, so shapes that come in correct are left untouched."
[shape]
(d/update-when shape :svg-attrs csvg/attrs->props))
(defn clean-shape-post-decode (defn clean-shape-post-decode
"A shape procesor that expected to be executed after schema decoding "A shape procesor that expected to be executed after schema decoding
process but before validation." process but before validation."
@ -112,7 +122,8 @@
(-> shape (-> shape
(fix-shape-shadow-color) (fix-shape-shadow-color)
(fix-root-shape) (fix-root-shape)
(fix-legacy-flex-dir))) (fix-legacy-flex-dir)
(fix-svg-attrs)))
(defn- fix-container (defn- fix-container
[container] [container]

View File

@ -350,14 +350,44 @@
{})] {})]
(assoc params :context context))) (assoc params :context context)))
(defn- coerce-profile-id
"Normalize a hand-written `::audit/profile-id` override to a uuid.
`schema:event` requires a uuid and `submit*` swallows the validation
error, so a value that is not a uuid loses the event instead of
failing loudly. Commands read the override from places that are not
typed by us (token claims, stringly-typed drivers), so a string has
to be accepted. Anything that cannot become a uuid is discarded, and
the event falls back to the caller, which is always a valid uuid."
[v]
(let [coerced (cond
;; Fast path: the override comes straight from a `profile`
;; row in almost every command, so it is already a uuid.
(uuid? v)
v
(string? v)
(uuid/parse* v)
:else
nil)]
(when (and (nil? coerced) (some? v))
(l/error :hint "ignoring unusable ::audit/profile-id"
:profile-id v))
coerced))
(defn prepare-rpc-event (defn prepare-rpc-event
[cfg mdata params result] [cfg mdata params result]
(let [resultm (meta result) (let [resultm (meta result)
request (-> params meta ::http/request) request (-> params meta ::http/request)
profile-id (or (::profile-id resultm) ;; SECURITY: the event belongs to whoever made the request. The only
(some-> (:profile-id result) ;; sanctioned override is the `::audit/profile-id` metadata, set
(cond-> (string? (:profile-id result)) ;; explicitly by the command. Never derive it from the response:
uuid/parse*)) ;; results can carry a `:profile-id` that belongs to somebody else
;; (the owner of an error report, the inviter of an invitation, ...)
;; and that silently misattributes the action.
profile-id (or (coerce-profile-id (::profile-id resultm))
(::rpc/profile-id params) (::rpc/profile-id params)
uuid/zero) uuid/zero)

View File

@ -6,7 +6,6 @@
(ns app.rpc.commands.verify-token (ns app.rpc.commands.verify-token
(:require (:require
[app.common.data :as d]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.time :as ct] [app.common.time :as ct]
@ -99,7 +98,11 @@
(profile/strip-private-attrs) (profile/strip-private-attrs)
(update :props profile/filter-props) (update :props profile/filter-props)
(with-nitrate-licence cfg))] (with-nitrate-licence cfg))]
(assoc claims :profile profile))) ;; The command is anonymous, so the audit event has no caller to fall
;; back on and the profile must be declared here. The claims also carry
;; a `:profile-id`, but the audit layer ignores the response on purpose.
(-> (assoc claims :profile profile)
(rph/with-meta {::audit/profile-id (:id profile)}))))
;; --- Team Invitation ;; --- Team Invitation
@ -240,6 +243,12 @@
(not (:is-member membership))) (not (:is-member membership)))
(:organization-id membership)) (:organization-id membership))
organization-add-source
(when organization-id-on-add
(if organization-id
"direct-organization-invitation"
"team-invitation"))
organization-member-count-before organization-member-count-before
(when organization-id-on-add (when organization-id-on-add
(count (count
@ -280,22 +289,34 @@
(-> (audit/event-from-rpc-params params) (-> (audit/event-from-rpc-params params)
(assoc :profile-id created-by) (assoc :profile-id created-by)
(assoc :name "accept-team-invitation-from") (assoc :name "accept-team-invitation-from")
(assoc :props (assoc props (assoc :props (-> props
:profile-id (:id profile) (assoc :invited-by (:created-by invitation))
:email (:email profile))))))) (assoc :profile-id (:id profile))
(assoc :profile-email (:email profile))
(audit/clean-props)))))))
(let [accepted-team-id (accept-invitation cfg claims invitation profile)] (let [accepted-team-id (accept-invitation cfg claims invitation profile)]
;; NOTE: the browser used to re-submit a copy of this event from
;; the `:organization-invitation-audit` payload of this response,
;; which wrote two rows per acceptance with two prop vocabularies
;; for the same name, and tied the record to the browser finishing
;; the flow. Everything the copy carried is computed above.
(when organization-id-on-add (when organization-id-on-add
(audit/submit (audit/submit cfg (-> (audit/event-from-rpc-params params)
cfg (assoc :name "accept-organization-invitation")
(-> (audit/event-from-rpc-params params) (assoc :props
(assoc :name "accept-organization-invitation") (-> props
(assoc :props (assoc :organization-id organization-id-on-add)
(-> props (assoc :invited-by (:created-by invitation))
(assoc :organization-id organization-id-on-add (assoc :profile-id (:id profile))
:user-id (:id profile) (assoc :profile-email (:email profile))
:user-who-send-invitation (:created-by invitation)) (assoc :organization-member-add-source
(audit/clean-props)))))) organization-add-source)
(assoc :belongs-to-team-on-add
(boolean team-id))
(assoc :organization-member-count-before
organization-member-count-before)
(audit/clean-props))))))
(cond-> (assoc claims (cond-> (assoc claims
:state :created :state :created
@ -316,15 +337,11 @@
;; accepted-team-id as :organization-team-id ;; accepted-team-id as :organization-team-id
(:organization-id claims) (:organization-id claims)
(assoc :organization-team-id accepted-team-id) (assoc :organization-team-id accepted-team-id)
;; The response carries the inviter's profile-id, so the
organization-id-on-add ;; audit event has to name the accepting profile explicitly
(merge (d/without-nils ;; or the invitation gets logged against the wrong user.
{:invitation-id (:id invitation) :always
:organization-member-count-before (rph/with-meta {::audit/profile-id (:id profile)}))))))
organization-member-count-before}))
(and organization-id-on-add team-id)
(assoc :organization-id organization-id-on-add))))))
(do (do
;; If the user is not logged-in and the invitation has been canceled ;; If the user is not logged-in and the invitation has been canceled

View File

@ -29,6 +29,7 @@
[app.storage.tmp :as tmp] [app.storage.tmp :as tmp]
[backend-tests.helpers :as th] [backend-tests.helpers :as th]
[backend-tests.storage-test :as stt] [backend-tests.storage-test :as stt]
[clojure.java.io :as jio]
[clojure.test :as t] [clojure.test :as t]
[cuerdas.core :as str] [cuerdas.core :as str]
[datoteka.fs :as fs] [datoteka.fs :as fs]
@ -201,6 +202,57 @@
;; of failing with :child-not-found on the next update-file. ;; of failing with :child-not-found on the next update-file.
(t/is (nil? (cfv/validate-file imported [])))))) (t/is (nil? (cfv/validate-file imported []))))))
(defn- import-svg-attrs-asset
"Imports the `svg-attrs-camel-case.penpot` asset, a real penpot export
whose shapes carry `:svg-attrs` keys in camelCase (the format the
binary export writes), and returns the imported file."
[profile]
(let [input (-> "backend_tests/test_files/svg-attrs-camel-case.penpot"
io/resource
jio/file)
result (-> th/*system*
(assoc ::bfc/project-id (:default-project-id profile))
(assoc ::bfc/profile-id (:id profile))
(assoc ::bfc/input input)
(v3/import-files!))]
(bfc/get-file th/*system* (first result))))
(t/deftest import-binfile-v3-preserves-camel-case-svg-attrs
;; The json reader used by the v3 import rewrites every key of every
;; zip entry to kebab-case, and `:svg-attrs` is the one shape map
;; whose keys are camelCase react prop names. A shape exported with
;; `fillRule: "evenodd"` must not come back as `:fill-rule`, or the
;; renderer falls back to the default fill rule and the shape is
;; painted without its hole.
(let [profile (th/create-profile* 1)
file (import-svg-attrs-asset profile)
shape (get-in file [:data :pages-index
(uuid/uuid "fc80ab5f-1bf2-817c-8008-b5408f039100")
:objects
(uuid/uuid "ce3641bd-48c8-804c-8008-b54d35cc6f80")])]
(t/is (= {:fillRule "evenodd"}
(:svg-attrs shape)))))
(t/deftest import-binfile-v3-preserves-camel-case-svg-attrs-on-components
;; Same guarantee for shapes stored inside a component: the v3 import
;; cleans those in a different code path than page shapes.
(let [profile (th/create-profile* 1)
file (import-svg-attrs-asset profile)
shape (fn [component-id shape-id]
(-> file
(get-in [:data :components (uuid/uuid component-id) :objects])
(get (uuid/uuid shape-id))
:svg-attrs))]
(t/is (= {:fillRule "evenodd"}
(shape "fae4bc76-0cc2-8057-8008-b540912cdf78"
"fae4bc76-0cc2-8057-8008-b540912774cb")))
(t/is (= {:fillRule "nonzero"}
(shape "fae4bc76-0cc2-8057-8008-b540912c917b"
"fae4bc76-0cc2-8057-8008-b540912774c8")))))
(t/deftest export-binfile-v3 (t/deftest export-binfile-v3
(let [profile (th/create-profile* 1) (let [profile (th/create-profile* 1)
file (prepare-simple-file profile) file (prepare-simple-file profile)

View File

@ -550,46 +550,83 @@
(t/is (= {} (:props row))) (t/is (= {} (:props row)))
(t/is (= {} (:context row)))))) (t/is (= {} (:context row))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PREPARE-RPC-EVENT PROFILE-ID CONVERSION ;; PREPARE-RPC-EVENT PROFILE-ID RESOLUTION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest prepare-rpc-event-converts-string-profile-id-to-uuid (defn- prepare-event
;; When result contains a string :profile-id (e.g. from error reports), "Call prepare-rpc-event with a bare request, returning the built event."
;; prepare-rpc-event must convert it to a UUID for audit schema compliance. [caller result]
(let [prof (th/create-profile* 1 {:is-active true}) (let [mdata {::sv/name "test-cmd"}
string-pid "33601240-a00b-11ea-ba1b-c554cc60e361" params {::rpc/profile-id caller
expected #uuid "33601240-a00b-11ea-ba1b-c554cc60e361" ::rpc/request-id (uuid/next)
mdata {::sv/name "test-cmd"} ::rpc/request-at (ct/now)}
params {::rpc/profile-id (:id prof)
::rpc/request-id (uuid/next)
::rpc/request-at (ct/now)}
mock-req (reify
yetti.request/IRequest
(get-header [_ _] nil)
(remote-addr [_] "127.0.0.1"))
params (with-meta params {:app.http/request mock-req})
result {:profile-id string-pid :some-data "value"}
event (audit/prepare-rpc-event th/*system* mdata params result)]
;; profile-id must be a UUID, not a string
(t/is (uuid? (:profile-id event)))
(t/is (= expected (:profile-id event)))))
(t/deftest prepare-rpc-event-handles-invalid-string-profile-id
;; When result contains an invalid string :profile-id, it should fall back
;; to the RPC params profile-id (which is always a valid UUID).
(let [prof (th/create-profile* 1 {:is-active true})
mdata {::sv/name "test-cmd"}
params {::rpc/profile-id (:id prof)
::rpc/request-id (uuid/next)
::rpc/request-at (ct/now)}
mock-req (reify mock-req (reify
yetti.request/IRequest yetti.request/IRequest
(get-header [_ _] nil) (get-header [_ _] nil)
(remote-addr [_] "127.0.0.1")) (remote-addr [_] "127.0.0.1"))
params (with-meta params {:app.http/request mock-req}) params (with-meta params {:app.http/request mock-req})]
result {:profile-id "not-a-valid-uuid"} (audit/prepare-rpc-event th/*system* mdata params result)))
event (audit/prepare-rpc-event th/*system* mdata params result)]
;; profile-id must fall back to the RPC params profile-id (t/deftest prepare-rpc-event-ignores-profile-id-from-result
(t/is (uuid? (:profile-id event))) ;; An audit event belongs to the caller, never to whatever `:profile-id`
(t/is (= (:id prof) (:profile-id event))))) ;; the response carries. `get-error-report` returns the report with its
;; decoded content merged in, and that content can hold the profile that
;; owned the report, so honoring it attributed the call to somebody who
;; never made it.
(let [caller (th/create-profile* 1 {:is-active true})]
(t/is (= (:id caller)
(:profile-id (prepare-event (:id caller)
{:profile-id "33601240-a00b-11ea-ba1b-c554cc60e361"
:some-data "value"}))))
;; an unparseable one must not break the event either
(t/is (= (:id caller)
(:profile-id (prepare-event (:id caller)
{:profile-id "not-a-valid-uuid"}))))))
(t/deftest prepare-rpc-event-uses-metadata-profile-id
;; `::audit/profile-id` metadata is the only sanctioned override: it is
;; how commands that authenticate somebody else (login, verify-token)
;; attribute the event to the right profile.
(let [caller (th/create-profile* 1 {:is-active true})
target (th/create-profile* 2 {:is-active true})
result (with-meta {:some-data "value"}
{::audit/profile-id (:id target)})]
(t/is (= (:id target)
(:profile-id (prepare-event (:id caller) result))))))
(t/deftest prepare-rpc-event-falls-back-to-zero-for-anonymous-callers
;; With no metadata and no authenticated caller there is nobody to
;; attribute the event to, so it lands on the zero uuid.
(t/is (= uuid/zero
(:profile-id (prepare-event nil {:some-data "value"})))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PREPARE-RPC-EVENT PROFILE-ID COERCION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest prepare-rpc-event-coerces-string-metadata-profile-id
;; `::audit/profile-id` is set by hand in a dozen commands, and some of
;; them read the value from token claims or other stringly-typed sources.
;; The audit schema demands a uuid, and `submit*` swallows the validation
;; error, so an unconverted string would drop the event on the floor.
(let [caller (th/create-profile* 1 {:is-active true})
target "33601240-a00b-11ea-ba1b-c554cc60e361"
result (with-meta {:some-data "value"}
{::audit/profile-id target})]
(t/is (= #uuid "33601240-a00b-11ea-ba1b-c554cc60e361"
(:profile-id (prepare-event (:id caller) result))))
(t/is (uuid? (:profile-id (prepare-event (:id caller) result))))))
(t/deftest prepare-rpc-event-discards-unusable-metadata-profile-id
;; An override that cannot be turned into a uuid is dropped, not honoured
;; and not propagated: the event falls back to the caller, which is always
;; a valid uuid, instead of failing the schema check and losing the row.
(let [caller (th/create-profile* 1 {:is-active true})]
(doseq [bad ["not-a-valid-uuid" "" " " 42 {} [] :whatever nil false]]
(let [result (with-meta {:some-data "value"}
(cond-> {::audit/profile-id bad}
(nil? bad) (dissoc ::audit/profile-id)))]
(t/is (= (:id caller)
(:profile-id (prepare-event (:id caller) result)))
(str "override " (pr-str bad) " must fall back to the caller"))))))

View File

@ -256,31 +256,41 @@
;; --- Audit event tests ;; --- Audit event tests
(t/deftest get-error-report-audit-event-has-uuid-profile-id (t/deftest get-error-report-audit-event-attributes-the-caller
;; When get-error-report returns a report with string profile-id in content, ;; The report content carries the profile that owned the report and the
;; the audit event must have a proper UUID profile-id (not a string). ;; handler merges that content into the response, so the response holds a
;; This tests the prepare-rpc-event function directly since the test RPC ;; `:profile-id` that does not belong to the caller. The audit event must
;; flow doesn't include the audit middleware wrapper. ;; still belong to the caller: deriving it from the response attributed
(let [profile (th/create-profile* 1 {:is-active true}) ;; privileged reads to the users whose crashes were being inspected.
id (uuid/next) (let [caller (th/create-profile* 1 {:is-active true})
orig-pid "33601240-a00b-11ea-ba1b-c554cc60e361" owner "33601240-a00b-11ea-ba1b-c554cc60e361"
;; Simulate the result from get-error-report with string profile-id id (uuid/next)]
result {:id id (insert-report! th/*system*
:source "logging" {:id id
:hint "test error" :source 4
:profile-id orig-pid} :content {:profile-id owner
mdata {::sv/name "get-error-report"} :hint "test error"}})
params {::rpc/profile-id (:id profile)
::rpc/request-id (uuid/next) (let [out (token-cmd caller {::th/type :get-error-report :id id})]
::rpc/request-at (ct/now)} (t/is (th/success? out))
mock-req (reify yetti.request/IRequest
(get-header [_ _] nil) (let [result (:result out)
(remote-addr [_] "127.0.0.1")) event (audit/prepare-rpc-event
params (with-meta params {:app.http/request mock-req}) th/*system*
event (audit/prepare-rpc-event th/*system* mdata params result)] {::sv/name "get-error-report"}
;; profile-id must be a UUID, not a string (with-meta {::rpc/profile-id (:id caller)
(t/is (uuid? (:profile-id event))) ::rpc/request-id (uuid/next)
(t/is (= #uuid "33601240-a00b-11ea-ba1b-c554cc60e361" (:profile-id event))))) ::rpc/request-at (ct/now)
:id id}
{:app.http/request (reify
yetti.request/IRequest
(get-header [_ _] nil)
(remote-addr [_] "127.0.0.1"))})
result)]
;; the response does expose the report owner's profile...
(t/is (= owner (get result :profile-id)))
;; ...but the audit event belongs to whoever called the command
(t/is (= (:id caller) (:profile-id event)))))))
;; Note: The integration of access token middleware with audit context is tested ;; Note: The integration of access token middleware with audit context is tested
;; via unit tests in rpc_audit_test.clj and http_middleware_test.clj. ;; via unit tests in rpc_audit_test.clj and http_middleware_test.clj.

View File

@ -1594,7 +1594,6 @@
(t/is (th/ex-of-type? (:error out) :validation)) (t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :weak-password)))) (t/is (th/ex-of-code? (:error out) :weak-password))))
(t/deftest update-profile-password-sends-notification (t/deftest update-profile-password-sends-notification
(with-mocks [mock {:target 'app.email/send! :return nil}] (with-mocks [mock {:target 'app.email/send! :return nil}]
(let [profile (th/create-profile* 1) (let [profile (th/create-profile* 1)
@ -1645,3 +1644,56 @@
(t/is (= eml/password-changed factory)) (t/is (= eml/password-changed factory))
(t/is (= (:email profile) to)) (t/is (= (:email profile) to))
(t/is (= (:fullname profile) name)))))) (t/is (= (:fullname profile) name))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; VERIFY-TOKEN AUDIT ATTRIBUTION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest verify-token-auth-audit-event-attributes-the-authenticated-profile
;; `verify-token` is anonymous, so the audit event cannot infer the profile
;; from the caller: the handler must declare it in the result metadata.
(let [profile (th/create-profile* 1 {:is-active true})
token (tokens/generate th/*system*
{:iss :auth
:exp (ct/in-future "1h")
:profile-id (:id profile)})
out (th/command! {::th/type :verify-token
:token token})]
(t/is (th/success? out))
(t/is (= (:id profile)
(get-in (meta (:result out)) [:app.loggers.audit/profile-id])))))
(t/deftest verify-token-invitation-audit-event-attributes-the-accepting-profile
;; Both the invitation claims and the response carry the inviter's
;; profile-id, so the event must not end up on the inviter: the member who
;; clicked the link is the one who accepted the invitation.
(with-redefs [app.config/flags #{:login-with-password}]
(let [owner (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})
member (th/create-profile* 2 {:is-active true
:email "invited@example.com"})
email (:email member)
token (tokens/generate th/*system*
{:iss :team-invitation
:exp (ct/in-future "48h")
:role :editor
:profile-id (:id owner)
:team-id (:id team)
:member-email email
:member-id (:id member)})]
(th/db-insert! :team-invitation
{:id (uuid/random)
:team-id (:id team)
:email-to email
:created-by (:id owner)
:role "editor"
:valid-until (ct/in-future "48h")})
(let [out (th/command! {::th/type :verify-token
:token token
::rpc/profile-id (:id member)
::rpc/auth-type :session})
event-pid (get-in (meta (:result out)) [:app.loggers.audit/profile-id])]
(t/is (th/success? out))
(t/is (= (:id member) event-pid))
(t/is (not= (:id owner) event-pid))))))

View File

@ -492,13 +492,14 @@
(th/command! {::th/type :verify-token (th/command! {::th/type :verify-token
::rpc/profile-id (:id invitee) ::rpc/profile-id (:id invitee)
:token token})) :token token}))
organization-event emitted-events
(fn [] (fn [event-name]
(->> (:call-args-list @audit-mock) (->> (:call-args-list @audit-mock)
(map second) (map second)
(filter #(= "accept-organization-invitation" (:name %))) (filter #(= event-name (:name %)))))
first)) organization-event
token-result (atom nil)] (fn []
(first (emitted-events "accept-organization-invitation")))]
(db/insert! (:app.db/pool th/*system*) (db/insert! (:app.db/pool th/*system*)
:team-invitation :team-invitation
@ -520,29 +521,22 @@
(fn [& _] default-team-id)] (fn [& _] default-team-id)]
(let [out (verify! direct-token)] (let [out (verify! direct-token)]
(t/is (th/success? out)) (t/is (th/success? out))
(reset! token-result (:result out)))) (t/is (not (contains? (:result out) :organization-invitation-audit)))))
(let [event (organization-event)] (let [event (organization-event)]
(t/is (= organization-id (get-in event [:props :organization-id]))) (t/is (= organization-id (get-in event [:props :organization-id])))
(t/is (= (:id invitee) (get-in event [:props :user-id]))) (t/is (= (:id invitee) (get-in event [:props :profile-id])))
(t/is (= (:id inviter) (t/is (= (:id inviter) (get-in event [:props :invited-by])))
(get-in event [:props :user-who-send-invitation]))) (t/is (= (:email invitee) (get-in event [:props :profile-email])))
(t/is (not (contains? (:props event) :organization-member-add-source)))
(t/is (not (contains? (:props event) :belongs-to-team-on-add)))
(t/is (not (contains? (:props event) :organization-member-count-before)))
(t/is (= :editor (get-in event [:props :role]))) (t/is (= :editor (get-in event [:props :role])))
(t/is (uuid? (get-in event [:props :invitation-id]))) (t/is (uuid? (get-in event [:props :invitation-id])))
(t/is (= organization-id (:organization-id @token-result))) (t/is (= "direct-organization-invitation"
(t/is (= :editor (:role @token-result))) (get-in event [:props :organization-member-add-source])))
(t/is (not (contains? @token-result :organization-invitation-audit))) (t/is (false? (get-in event [:props :belongs-to-team-on-add])))
(t/is (= (get-in event [:props :invitation-id]) (t/is (= 3 (get-in event [:props :organization-member-count-before])))
(:invitation-id @token-result))) (t/is (not (contains? (:props event) :invitation-origin)))
(t/is (= (:id invitee) (t/is (not (contains? (:context event) :event-origin)))
(:member-id @token-result))) (t/is (= 1 (count (emitted-events "accept-organization-invitation"))))
(t/is (= (:id inviter)
(:profile-id @token-result)))
(t/is (= 3
(:organization-member-count-before @token-result)))
(t/is (not-any? #(contains? #{"accept-team-invitation" (t/is (not-any? #(contains? #{"accept-team-invitation"
"accept-team-invitation-from"} "accept-team-invitation-from"}
(:name (second %))) (:name (second %)))
@ -569,7 +563,7 @@
teams/add-profile-to-team! (fn [& _] nil)] teams/add-profile-to-team! (fn [& _] nil)]
(let [out (verify! team-token)] (let [out (verify! team-token)]
(t/is (th/success? out)) (t/is (th/success? out))
(reset! token-result (:result out)))) (t/is (not (contains? (:result out) :organization-invitation-audit)))))
(let [events (mapv second (:call-args-list @audit-mock)) (let [events (mapv second (:call-args-list @audit-mock))
event (organization-event)] event (organization-event)]
@ -577,25 +571,27 @@
(t/is (some #(= "accept-team-invitation-from" (:name %)) events)) (t/is (some #(= "accept-team-invitation-from" (:name %)) events))
(t/is (= (:id team) (get-in event [:props :team-id]))) (t/is (= (:id team) (get-in event [:props :team-id])))
(t/is (= organization-id (get-in event [:props :organization-id]))) (t/is (= organization-id (get-in event [:props :organization-id])))
(t/is (= (:id invitee) (get-in event [:props :user-id]))) (t/is (= (:id invitee) (get-in event [:props :profile-id])))
(t/is (= (:id inviter) (t/is (= (:id inviter) (get-in event [:props :invited-by])))
(get-in event [:props :user-who-send-invitation]))) (t/is (= "team-invitation"
(t/is (not (contains? (:props event) :organization-member-add-source))) (get-in event [:props :organization-member-add-source])))
(t/is (not (contains? (:props event) :belongs-to-team-on-add))) (t/is (true? (get-in event [:props :belongs-to-team-on-add])))
(t/is (not (contains? (:props event) :organization-member-count-before))) (t/is (= 5 (get-in event [:props :organization-member-count-before])))
(t/is (= organization-id (t/is (not (contains? (:props event) :invitation-origin)))
(:organization-id @token-result))) (t/is (not (contains? (:context event) :event-origin)))
(t/is (= (:id team) (:team-id @token-result))) (t/is (= 1 (count (emitted-events "accept-organization-invitation")))))
(t/is (= :editor (:role @token-result)))
(t/is (not (contains? @token-result :organization-invitation-audit))) (let [from-event (first (emitted-events "accept-team-invitation-from"))]
(t/is (= (get-in event [:props :invitation-id]) (t/is (= (:id team) (get-in from-event [:props :team-id])))
(:invitation-id @token-result))) (t/is (= :editor (get-in from-event [:props :role])))
(t/is (= (:id invitee) (t/is (uuid? (get-in from-event [:props :invitation-id])))
(:member-id @token-result))) (t/is (= (:id inviter) (get-in from-event [:props :invited-by])))
(t/is (= (:id inviter) (t/is (= (:id invitee) (get-in from-event [:props :profile-id])))
(:profile-id @token-result))) (t/is (= (:email invitee) (get-in from-event [:props :profile-email])))
(t/is (= 5 (t/is (not (contains? (get-in from-event [:props]) :email)))
(:organization-member-count-before @token-result)))) (t/is (not (contains? (get-in from-event [:props]) :user-id)))
(t/is (not (contains? (get-in from-event [:props])
:user-who-send-invitation))))
(th/reset-mock! audit-mock) (th/reset-mock! audit-mock)
(db/insert! (:app.db/pool th/*system*) (db/insert! (:app.db/pool th/*system*)
@ -616,57 +612,11 @@
teams/add-profile-to-team! (fn [& _] nil)] teams/add-profile-to-team! (fn [& _] nil)]
(let [out (verify! team-token)] (let [out (verify! team-token)]
(t/is (th/success? out)) (t/is (th/success? out))
(reset! token-result (:result out)))) (t/is (not (contains? (:result out) :organization-invitation-audit)))))
(let [events (mapv second (:call-args-list @audit-mock))] (let [events (mapv second (:call-args-list @audit-mock))]
(t/is (some #(= "accept-team-invitation" (:name %)) events)) (t/is (some #(= "accept-team-invitation" (:name %)) events))
(t/is (not-any? #(= "accept-organization-invitation" (:name %)) events)) (t/is (not-any? #(= "accept-organization-invitation" (:name %)) events))))))
(t/is (not (contains? @token-result :organization-invitation-audit)))
(t/is (not (contains? @token-result :organization-member-count-before)))))))
(t/deftest accept-organization-invitation-response-ids-match-database
(with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
(let [inviter (th/create-profile* 211 {:is-active true})
invitee (th/create-profile* 212 {:is-active true})
organization-id (uuid/random)
default-team-id (uuid/random)
;; Token minted with a stale :profile-id (e.g. a re-sent link
;; requested by someone else) and no :member-id (invitee was
;; unregistered when invited); the invitation row says inviter.
stale-token (tokens/generate
th/*system*
{:iss :team-invitation
:exp (ct/in-future "1h")
:profile-id (uuid/random)
:role :editor
:organization-id organization-id
:member-email (:email invitee)})]
(db/insert! (:app.db/pool th/*system*)
:team-invitation
{:org-id organization-id
:email-to (:email invitee)
:created-by (:id inviter)
:role "editor"
:valid-until (ct/in-future "48h")})
(with-redefs [cf/flags (conj cf/flags :admin-console)
nitrate/call
(fn [_cfg method _params]
(case method
:get-organization-membership {:organization-id organization-id
:is-member false}
:get-organization-members [(:id inviter)]
nil))
teams/initialize-user-in-organization
(fn [& _] default-team-id)]
(let [out (th/command! {::th/type :verify-token
::rpc/profile-id (:id invitee)
:token stale-token})]
(t/is (th/success? out))
(t/is (= (:id inviter) (:profile-id (:result out))))
(t/is (= (:id invitee) (:member-id (:result out))))
(t/is (not (contains? (:result out) :user-who-send-invitation)))
(t/is (not (contains? (:result out) :user-id))))))))
(t/deftest create-team-invitations-with-email-verification-disabled (t/deftest create-team-invitations-with-email-verification-disabled
(with-mocks [mock {:target 'app.email/send! :return nil}] (with-mocks [mock {:target 'app.email/send! :return nil}]

View File

@ -754,6 +754,34 @@
(t/is (not (contains? (:props result) :route))) (t/is (not (contains? (:props result) :route)))
(t/is (not (contains? (:props result) :label))))) (t/is (not (contains? (:props result) :label)))))
(t/deftest test-filter-telemetry-props-accept-organization-invitation
;; The shadow row of accept-organization-invitation keeps the ids, the boolean
;; and the count, and drops every text prop, the raw email among them. The
;; full row and the Nexus archive still carry all of them, and there is no
;; context key to fall back on: `safe-backend-context-keys` has no
;; `:event-origin`, that one belongs to the browser.
(let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props)
profile-id (uuid/next)
organization-id (uuid/next)
result (ftp {:source "backend"
:name "accept-organization-invitation"
:type "action"
:props {:profile-id profile-id
:invited-by profile-id
:organization-id organization-id
:team-id (uuid/next)
:belongs-to-team-on-add true
:organization-member-count-before 3
:organization-member-add-source "team-invitation"
:profile-email "invitee@example.com"}})]
(t/is (= profile-id (get-in result [:props :profile-id])))
(t/is (= profile-id (get-in result [:props :invited-by])))
(t/is (= organization-id (get-in result [:props :organization-id])))
(t/is (true? (get-in result [:props :belongs-to-team-on-add])))
(t/is (= 3 (get-in result [:props :organization-member-count-before])))
(t/is (not (contains? (:props result) :profile-email)))
(t/is (not (contains? (:props result) :organization-member-add-source)))))
(t/deftest test-filter-telemetry-props-organization-sso-failure-keeps-reason (t/deftest test-filter-telemetry-props-organization-sso-failure-keeps-reason
(let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props) (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props)
organization-id (uuid/next) organization-id (uuid/next)

View File

@ -6,11 +6,9 @@
(ns app.main.ui.auth.verify-token (ns app.main.ui.auth.verify-token
(:require (:require
[app.common.data :as d]
[app.config :as cf] [app.config :as cf]
[app.main.data.auth :as da] [app.main.data.auth :as da]
[app.main.data.common :as dcm] [app.main.data.common :as dcm]
[app.main.data.event :as ev]
[app.main.data.notifications :as ntf] [app.main.data.notifications :as ntf]
[app.main.data.profile :as du] [app.main.data.profile :as du]
[app.main.repo :as rp] [app.main.repo :as rp]
@ -45,33 +43,8 @@
(st/emit! (da/login-from-token tdata))) (st/emit! (da/login-from-token tdata)))
(defmethod handle-token :team-invitation (defmethod handle-token :team-invitation
[{:keys [state team-id organization-team-id organization-name invitation-token] :as tdata}] [{:keys [state team-id organization-team-id organization-name invitation-token
(when (and (= state :created) redirect-to]}]
(contains? tdata :organization-member-count-before))
(let [direct-invitation? (some? organization-team-id)]
(st/emit!
(ev/event
(-> (select-keys tdata [:team-id :organization-id :role
:invitation-id :member-id :profile-id
:organization-member-count-before])
;; The audit event keeps its own names: :user-id is the
;; invitee (:member-id, backfilled by the backend with the
;; accepting profile) and :user-who-send-invitation is the
;; inviter (:profile-id, backfilled from `created-by`).
(assoc :user-id (:member-id tdata)
:user-who-send-invitation (:profile-id tdata))
(dissoc :member-id :profile-id)
(d/without-nils)
(assoc :organization-member-add-source
(if direct-invitation?
"direct-organization-invitation"
"team-invitation")
:belongs-to-team-on-add (boolean team-id)
::ev/name "accept-organization-invitation"
::ev/origin (if direct-invitation?
"organization-invitation-acceptance"
"team-invitation-acceptance")))))))
(case state (case state
:created :created
(if organization-team-id (if organization-team-id
@ -85,7 +58,7 @@
(ntf/success (tr "auth.notifications.team-invitation-accepted")))) (ntf/success (tr "auth.notifications.team-invitation-accepted"))))
:pending :pending
(let [route-id (:redirect-to tdata :auth-register)] (let [route-id (or redirect-to :auth-register)]
(st/emit! (rt/nav route-id {:invitation-token invitation-token}))))) (st/emit! (rt/nav route-id {:invitation-token invitation-token})))))
(defmethod handle-token :default (defmethod handle-token :default

View File

@ -140,74 +140,6 @@
(t/is (= :auth-register (:id event))) (t/is (= :auth-register (:id event)))
(t/is (= invitation-token (get-in event [:params :invitation-token]))))))) (t/is (= invitation-token (get-in event [:params :invitation-token])))))))
(t/deftest accept-organization-invitation-audit-event-test
(let [emitted (atom [])]
(with-redefs [st/emit! (fn
([event]
(swap! emitted conj event))
([event & events]
(swap! emitted into (cons event events))))]
(t/testing "accepting a team invitation that adds an organization member"
(verify-token/handle-token
{:iss :team-invitation
:state :created
:team-id "team-1"
:organization-id "organization-1"
:role :editor
:invitation-id "invitation-1"
:member-id "invitee-1"
:profile-id "inviter-1"
:organization-member-count-before 4})
(t/is (= {::ev/name "accept-organization-invitation"
::ev/origin "team-invitation-acceptance"
:team-id "team-1"
:organization-id "organization-1"
:role :editor
:invitation-id "invitation-1"
:user-id "invitee-1"
:user-who-send-invitation "inviter-1"
:organization-member-add-source "team-invitation"
:belongs-to-team-on-add true
:organization-member-count-before 4}
@(first @emitted))))
(reset! emitted [])
(t/testing "accepting an invitation directly to the organization"
(verify-token/handle-token
{:iss :team-invitation
:state :created
:organization-id "organization-2"
:organization-team-id "team-default"
:role :viewer
:invitation-id "invitation-2"
:member-id "invitee-2"
:profile-id "inviter-2"
:organization-member-count-before 0})
(t/is (= {::ev/name "accept-organization-invitation"
::ev/origin "organization-invitation-acceptance"
:organization-id "organization-2"
:role :viewer
:invitation-id "invitation-2"
:user-id "invitee-2"
:user-who-send-invitation "inviter-2"
:organization-member-add-source "direct-organization-invitation"
:belongs-to-team-on-add false
:organization-member-count-before 0}
@(first @emitted))))
(reset! emitted [])
(t/testing "does not audit a team invitation for an existing organization member"
(verify-token/handle-token
{:iss :team-invitation
:state :created
:team-id "team-3"
:organization-id "organization-3"
:role :editor})
(t/is (= 3 (count @emitted)))))))
(t/deftest build-admin-console-url-preserves-public-uri-subpath (t/deftest build-admin-console-url-preserves-public-uri-subpath
(t/testing "builds admin console routes below the configured Penpot subpath" (t/testing "builds admin console routes below the configured Penpot subpath"
(let [public-uri (u/uri "https://example.com/penpot/")] (let [public-uri (u/uri "https://example.com/penpot/")]