From dc0ea3a69c9ce7f1b51d1d246cf95a89507db732 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 29 Sep 2026 09:47:07 +0200 Subject: [PATCH 1/3] :bug: Attribute audit events to the caller, not the response (#11952) * :bug: Attribute audit events to the caller, not the response prepare-rpc-event took the event profile-id from the result map whenever it carried one, before falling back to the caller. Any command returning a response with a :profile-id key silently credited the action to somebody else. get-error-report returns the report with its decoded content merged in, and that content holds the profile that owned the report, so privileged reads were logged against the users whose crashes were being inspected. verify-token on a team invitation returns the inviter's profile-id, so accepting an invitation was logged against the inviter. Resolution is now ::audit/profile-id metadata, then ::rpc/profile-id, then the zero uuid; the response is never consulted. The two verify-token branches that relied on it now declare the profile in the result metadata. Every other command either already declared it or returns no :profile-id; all 30 registered command namespaces were checked. The tests that pinned the old behavior are replaced by ones covering the new contract. AI-assisted-by: space-bunny-free * :bug: Coerce the audit profile-id override to a uuid The only sanctioned way for a command to override the profile of an audit event is the ::audit/profile-id metadata, and the value is set by hand in a dozen commands, some of them reading it from token claims or other sources we do not type. schema:event requires a uuid and submit* swallows the validation error, so a string did not fail loudly: the row was dropped silently. Values that cannot become a uuid are now discarded with a warning and the event falls back to the caller, which is always a valid uuid. A uuid, the common case, exits on the first check. AI-assisted-by: space-bunny-free --- .serena/memories/backend/audit-log.md | 2 + backend/src/app/loggers/audit.clj | 38 +++++- backend/src/app/rpc/commands/verify_token.clj | 46 ++++--- backend/test/backend_tests/rpc_audit_test.clj | 113 ++++++++++++------ .../rpc_commands_error_reports_test.clj | 60 ++++++---- .../test/backend_tests/rpc_profile_test.clj | 53 ++++++++ 6 files changed, 226 insertions(+), 86 deletions(-) diff --git a/.serena/memories/backend/audit-log.md b/.serena/memories/backend/audit-log.md index fe401cef0c..825d9a2162 100644 --- a/.serena/memories/backend/audit-log.md +++ b/.serena/memories/backend/audit-log.md @@ -16,6 +16,8 @@ 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 `-` 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. +- 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. ## Consumers I: webhooks (`app.loggers.webhooks`) diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index 28d14c3daa..01f2ae3a94 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -350,14 +350,44 @@ {})] (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 [cfg mdata params result] (let [resultm (meta result) request (-> params meta ::http/request) - profile-id (or (::profile-id resultm) - (some-> (:profile-id result) - (cond-> (string? (:profile-id result)) - uuid/parse*)) + ;; SECURITY: the event belongs to whoever made the request. The only + ;; sanctioned override is the `::audit/profile-id` metadata, set + ;; explicitly by the command. Never derive it from the response: + ;; 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) uuid/zero) diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index d46c7a8057..3c893dc6b2 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -97,7 +97,11 @@ (profile/strip-private-attrs) (update :props profile/filter-props) (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 @@ -313,25 +317,29 @@ :user-who-send-invitation (:created-by invitation)) (audit/clean-props)))))) - (cond-> (assoc claims :state :created) - ;; when the invitation is to an organization, instead of a team, add the - ;; accepted-team-id as :organization-team-id - (:organization-id claims) - (assoc :organization-team-id accepted-team-id) + (-> (cond-> (assoc claims :state :created) + ;; when the invitation is to an organization, instead of a team, add the + ;; accepted-team-id as :organization-team-id + (:organization-id claims) + (assoc :organization-team-id accepted-team-id) - organization-id-on-add - (assoc :organization-invitation-audit - {:origin organization-event-origin - :props - (-> props - (assoc :organization-id organization-id-on-add - :organization-member-add-source organization-add-source - :belongs-to-team-on-add (boolean team-id) - :user-id (:id profile) - :user-who-send-invitation (:created-by invitation) - :organization-member-count-before - organization-member-count-before) - (audit/clean-props))})))))) + organization-id-on-add + (assoc :organization-invitation-audit + {:origin organization-event-origin + :props + (-> props + (assoc :organization-id organization-id-on-add + :organization-member-add-source organization-add-source + :belongs-to-team-on-add (boolean team-id) + :user-id (:id profile) + :user-who-send-invitation (:created-by invitation) + :organization-member-count-before + organization-member-count-before) + (audit/clean-props))})) + ;; The response carries the inviter's profile-id, so the + ;; audit event has to name the accepting profile explicitly + ;; or the invitation gets logged against the wrong user. + (rph/with-meta {::audit/profile-id (:id profile)})))))) (do ;; If the user is not logged-in and the invitation has been canceled diff --git a/backend/test/backend_tests/rpc_audit_test.clj b/backend/test/backend_tests/rpc_audit_test.clj index 6148df5831..2fd3a43c62 100644 --- a/backend/test/backend_tests/rpc_audit_test.clj +++ b/backend/test/backend_tests/rpc_audit_test.clj @@ -515,46 +515,83 @@ (t/is (= {} (:props 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 - ;; When result contains a string :profile-id (e.g. from error reports), - ;; prepare-rpc-event must convert it to a UUID for audit schema compliance. - (let [prof (th/create-profile* 1 {:is-active true}) - string-pid "33601240-a00b-11ea-ba1b-c554cc60e361" - expected #uuid "33601240-a00b-11ea-ba1b-c554cc60e361" - mdata {::sv/name "test-cmd"} - 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)} +(defn- prepare-event + "Call prepare-rpc-event with a bare request, returning the built event." + [caller result] + (let [mdata {::sv/name "test-cmd"} + params {::rpc/profile-id caller + ::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 "not-a-valid-uuid"} - event (audit/prepare-rpc-event th/*system* mdata params result)] - ;; profile-id must fall back to the RPC params profile-id - (t/is (uuid? (:profile-id event))) - (t/is (= (:id prof) (:profile-id event))))) + params (with-meta params {:app.http/request mock-req})] + (audit/prepare-rpc-event th/*system* mdata params result))) + +(t/deftest prepare-rpc-event-ignores-profile-id-from-result + ;; An audit event belongs to the caller, never to whatever `:profile-id` + ;; 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")))))) diff --git a/backend/test/backend_tests/rpc_commands_error_reports_test.clj b/backend/test/backend_tests/rpc_commands_error_reports_test.clj index c317fe0cbd..6a40db62b7 100644 --- a/backend/test/backend_tests/rpc_commands_error_reports_test.clj +++ b/backend/test/backend_tests/rpc_commands_error_reports_test.clj @@ -256,31 +256,41 @@ ;; --- Audit event tests -(t/deftest get-error-report-audit-event-has-uuid-profile-id - ;; When get-error-report returns a report with string profile-id in content, - ;; the audit event must have a proper UUID profile-id (not a string). - ;; This tests the prepare-rpc-event function directly since the test RPC - ;; flow doesn't include the audit middleware wrapper. - (let [profile (th/create-profile* 1 {:is-active true}) - id (uuid/next) - orig-pid "33601240-a00b-11ea-ba1b-c554cc60e361" - ;; Simulate the result from get-error-report with string profile-id - result {:id id - :source "logging" - :hint "test error" - :profile-id orig-pid} - mdata {::sv/name "get-error-report"} - params {::rpc/profile-id (:id profile) - ::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}) - 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 (= #uuid "33601240-a00b-11ea-ba1b-c554cc60e361" (:profile-id event))))) +(t/deftest get-error-report-audit-event-attributes-the-caller + ;; The report content carries the profile that owned the report and the + ;; handler merges that content into the response, so the response holds a + ;; `:profile-id` that does not belong to the caller. The audit event must + ;; still belong to the caller: deriving it from the response attributed + ;; privileged reads to the users whose crashes were being inspected. + (let [caller (th/create-profile* 1 {:is-active true}) + owner "33601240-a00b-11ea-ba1b-c554cc60e361" + id (uuid/next)] + (insert-report! th/*system* + {:id id + :source 4 + :content {:profile-id owner + :hint "test error"}}) + + (let [out (token-cmd caller {::th/type :get-error-report :id id})] + (t/is (th/success? out)) + + (let [result (:result out) + event (audit/prepare-rpc-event + th/*system* + {::sv/name "get-error-report"} + (with-meta {::rpc/profile-id (:id caller) + ::rpc/request-id (uuid/next) + ::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 ;; via unit tests in rpc_audit_test.clj and http_middleware_test.clj. diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index ab10c91f4c..94f08a21b5 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -1385,3 +1385,56 @@ (t/is (th/ex-info? (:error out))) (t/is (th/ex-of-type? (:error out) :validation)) (t/is (th/ex-of-code? (:error out) :weak-password)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; 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)))))) From 0de865748d8dd9fd9849bc3b93b62958097e29c1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 29 Sep 2026 14:15:21 +0200 Subject: [PATCH 2/3] :bug: Keep camelCase svg attribute names when importing a penpot file (#11974) The json reader of the binfile v3 import rewrites every key of every entry to kebab-case, nested maps included. Shape `:svg-attrs` is the one map whose keys are camelCase react prop names, because the svg import path runs them through `attrs->props`, so an attribute exported as `fillRule` came back as `:fill-rule` and was stored that way. The renderer looks the attribute up by its camelCase name, does not find it and falls back to the default fill rule, so a shape exported with `fillRule: evenodd` was painted without its hole, and the attributes panel showed `fill-rule`. `clean-shape-post-decode` already runs on every shape right after the schema decode, for page shapes and component objects alike, so the repair goes there: run `:svg-attrs` back through `attrs->props`, the same transform that built the keys. It is idempotent, so shapes that arrive correct are left untouched. The new tests import a real export that carries the attribute, for page shapes and component shapes. Closes #11954 AI-assisted-by: space-bunny-free --- .../common/data-model-change-checklist.md | 1 + backend/src/app/binfile/cleaner.clj | 13 ++++- backend/test/backend_tests/binfile_test.clj | 52 ++++++++++++++++++ .../test_files/svg-attrs-camel-case.penpot | Bin 0 -> 42017 bytes 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 backend/test/backend_tests/test_files/svg-attrs-camel-case.penpot diff --git a/.serena/memories/common/data-model-change-checklist.md b/.serena/memories/common/data-model-change-checklist.md index 91f00d1e6e..f4540f9ea3 100644 --- a/.serena/memories/common/data-model-change-checklist.md +++ b/.serena/memories/common/data-model-change-checklist.md @@ -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. - 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. +- 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 diff --git a/backend/src/app/binfile/cleaner.clj b/backend/src/app/binfile/cleaner.clj index 66964b5358..035c0453cf 100644 --- a/backend/src/app/binfile/cleaner.clj +++ b/backend/src/app/binfile/cleaner.clj @@ -9,6 +9,7 @@ for recently imported shapes." (:require [app.common.data :as d] + [app.common.svg :as csvg] [app.common.types.shape :as cts] [app.common.uuid :as uuid])) @@ -105,6 +106,15 @@ :reverse-column :column-reverse 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 "A shape procesor that expected to be executed after schema decoding process but before validation." @@ -112,7 +122,8 @@ (-> shape (fix-shape-shadow-color) (fix-root-shape) - (fix-legacy-flex-dir))) + (fix-legacy-flex-dir) + (fix-svg-attrs))) (defn- fix-container [container] diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index a57624fa00..42edaaf4b5 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -24,6 +24,7 @@ [app.storage.tmp :as tmp] [backend-tests.helpers :as th] [backend-tests.storage-test :as stt] + [clojure.java.io :as jio] [clojure.test :as t] [cuerdas.core :as str] [datoteka.fs :as fs] @@ -188,6 +189,57 @@ ;; of failing with :child-not-found on the next update-file. (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 (let [profile (th/create-profile* 1) file (prepare-simple-file profile) diff --git a/backend/test/backend_tests/test_files/svg-attrs-camel-case.penpot b/backend/test/backend_tests/test_files/svg-attrs-camel-case.penpot new file mode 100644 index 0000000000000000000000000000000000000000..dd9513c343dc3be8df9627a8c416aaaaef1d8377 GIT binary patch literal 42017 zcmdSB1#BeBcCKq?W@ct)X67+7GqcCc%*@Qp%*@O@X7(7y_Sn9jy-!|ppOlm2>0RZf zrK)b(Qp@#wt$$U0y}A^nfk99JARr(BfUS9S0RGK`{_C}=g|&$jy{QoggMlHdDJ`R+ zDHAORBfAkT2Ll5KtsyH51BWRCGbe+oDV?R0ovpX3Y~m&xO!tY}@B#y4^?Fn8=KH?V?YU>d^fFaXsT!^b=e8aQPP-^e<+Uf>^kko)oO- zbBL?p?TET6rv|$54x$TQja6fB0;?QF;8HNDaAmo!Jg|ufWzDSngQT>)bf|L`h0*{n z)FlhK#L3+=*~Cc;N6w>oWxT&8&~M#&Lr_vxe@_vpNfv_XXn&@ZBn!zu(@TvRxMpXDp&Zl~)(qJI;*dBy^73O16uQfKDaSkT zVyG!*4Ou>+h<|pAautf|G;tQ>g_N^j8AT|DKXT&DS~!VKAAN=*AaZHenw7KTi*3m| z?%JM~U7C8$EN>IN?A5{Z027xxc7@fX@CiDkWsT^TN{!JnaCZBmr_J*=PP;9Ku-3`o zom>)tR0%HzdZbI{32E(BHx_xzqH5TvnPbw2+n?`6=F_geHd?O@@{ERh8d=Vj`9$T`Z3|h-9D;Qe z-meo`8(s8F8uA~;Vw>xkdd7-act|vInzljz+|6}%e{sLW`W~b5W{#1aO`VhD0j?;m zC>WXh0jKAVLr?sj9aZK^&;d}R@aasr!3vj8*Y{BbS-#5?>9_Lu1r7SHE56SMxPmk| zgl>6A)o)+`fJ-O<0EK_oSTz4j#zJpzVD_K!AVvm;zdWe&SV=))LPoJcNkK}wg+W4w zNp_J{Mv8f%nSn)qrI~@DZcdhom9b8OnW2S+g+XBfuzXf}N*44CLPAFV8$(c-$#HR! zDgY>Kn3Fb6kgQ4nB}*qO--rZw$Wi zekX|VGLHbnwW;pX-G`!-*wfMC{;giP?kh^%p$6(#nak+()@Sn=1p-M)wbGj=Q4MR3 zq}cMJ!TCd0aB!~BZqo25_*6GnWfwOA+eNiot84v6r=rw!Hb-mEc^s@Ntt@=2<(pW8 z0I`LFw6BR6?bk$*vA2CBmk)Y>+j2*`huW^9Xi21wTNuod{FzElQK`D2>%s9+K`Q6YXr0c3tCr zRgD*wExP#FMpR&#a)cZET}Tcw(=UpY|JID&kvTK zy!|Dyft|A9LADoTr6>+FJF-Io#kRMLM}o9+yC&DZk*+jVLqf=!Qcgs2OiLWsB(}Iw zB~TJFrwHzCvya;S2^JIOp8|e>0031W|2@HCWWvnG!f0qr%feyA@l~W*{KJGcldRV>e%yHQ zE;1){-5&@P1nPI_sdZO)e%sE~)7F_*eY`xW)L8z7aoLaqUT#c>LiIa-Diz0a_8Y2J z{<_PE9okLC_k-0(&*p7*HOI$8{7{|emKTdP{_h)4Z=1vW4(cyqv(s;n9iP9PU+os_J@Z{87w+$TBU9hVZd+Y30I!h+fW`EHf7jg*y;{2A}H?caX`*bY%%G)si zrk_adRp@j9E82jR;F1#<)WGjhKU3C-QUwa|=FoII&v&xHclcH~NuJzjX11R`1je5; zmANy&w_Myu>1b^2bH-eErJ6a2m)7g~J1*n|`L0^!Ayco0eyx4#s<*|7a?B3HldsMW zW8gDWFu8Nsbl0`hO?;ci(E>KPh`zC}r1t!>l24e;%xV=B{hK z6T#*_(Fr99vhh+`;TWr%QkTOiPJlxkRy=FE1_bN->+;~ zC~&w>#n!b(*)dTH)D%Z0>lOl&2vbF529@9!{LoC`hLxDo)Oa?|2>HmGp-`j3P_z}$ z7xiD*B0^2X>3}o@4DI)DhQP#@{X33M!4yd9R#Z}KnZgpOjJ!!m$RvL75Spt`5D2iW za}3jm;5NEA6yQdi0Z~V`A3&37=&zxr+>(tba{MeI+fRB{w8@)dqQVp-Iy4rSwWou4 zLlRdp+f#OOYa$VbD1@vwhnIszvQ0tq;pBMoCt3lk)cEF>(e#m#5swJfG1 z%^lx1ZkX}pA{$7=f`Np?p+W6QfLSOc35$F}4d46_FN1#)RE^q6dDTL%S!Y2kxdR0;sDI&6+>uMbF zLdnKSRaaCGv?z}eF=+@KV$N0>#C`}OO8kYt97vqj+yZe(2Cr|ghQE#~wD;c3l4F>4 zHHYivo=>gzAnGH#9H&QWY*M}jbfXw+H~sp1a7-7YYA)& zd+x&g#2f5c&*mS_Tjz;@G%u=uO#4Y9fdz;p29Oao|c-cHECn zPPOZBNe#?-7f7lAovq2e?6KU~RVB?$QIZV{(ZWUuyT^#On+K5-8Eg;UofURB^cYL9 z$!zqL;o}5jQOusS!Z(> z8$(+I3u`BOBRd;=J6jW5=l}RH^RpmDa78J8l*?WN6ev))uaf2V8L z-F_|2YvR6XqsvIo=j1?-;+)Gq<<8~r8I z`9IyR{NE&`!QVnoGyV}0bdI+aB8NH-m)p@Z5KlOz8o@k4sUdl%8?MCps^oLuySt-@ zy<=;VHq)w`gs3!6Kande1%${j2!%&%*g)u;6X$f`iEp&1wODSK4 z>w%A^nd-6Le0ut>PAJ++x9Zz06jhrH;)! zj9qHOft4JAxWfJOc!dRE;+nePjZBFol>d_atAAhJL6%c7rC z+VcZTpl@3)H#JuZo2AE@o6Cs~8l#C348X9J-eZ+YNBY%~kY!Pp{}%F+7}4W@mXId@ zNNriDKn8@6{qEAZDsG1Gd6I6ySVrdbFa=+V>ms*WB&pVI@9(!=;)^XMGVKofbT=0fgXTfZC-i@ z)G)KzaoVuzw!)(*YaxQaxJT$>*&oV0Va+|3p1!q5*>0Ti{Fh0(!$hzA$=rXQ+Y$et zP14lSz{ceNcY_p!8el*O-uISQY;e_2Aj=MCzr{$DEJ2|OYeT-VwL)mU#^e8f!x>fB z__#s0oc&xJrIg@LkLJfq$v`A5iMVb*VSX$Z+CF|?dl$C7rqe>7fEKv`Q`SK&^B}wq zUQp&F4zIztSJ!P_U9(`PNs&*I;n%JjrW_I1NKw|RO44Xf%-*&0C!<}3-U33u#5w+_ z*7$#meE+b;ssByB5?QAIP(H|F5MU}cK_XR&Fi5G}G?*nGuVG5pI8R)If3L1^eX}8% zQJ9YFeyvxM=vM?>!!!s^fpnxzx((an-=#T;^*7xA9R2=bg42J+AL!iQ`YDl*4`I7U zjgu%tp^0os{++&j_&cAq1~}QYt*+JA4df$tnF4*(a)IE%IzajIgkfF#QTyUDJm>r zcUqCQ>;8_vrG^Ec{|WlBeQg|BjsInT_`m6wK$gDrrC+*a=|Akx^2`4CK7W2VVThY; zy0ojf*-An;kO0%8<+&?Sgn^QX(u6GjmA}}vpIEVL(c2!MM7jkOCIZVL4D;~Q?Xcy? zF?d}*eFk2Q^e%@!fl|n%xdg8ogkr{VmSBP;M2NBz{jjy{f9x+u{$%q%wm-f9TJ`vU z_SIjX68!T$vk@mFyWwAj^-5F2Zc_}wXSSAMNzJ1E8Ib_qKa)r-kt{5-Np=oALVLs7 zTq)0dR4VQ>M=e1e0sh9$qKn#JR@r*VLSYJ@RH)(4hQvIn$t{AqvH9^pj@`FTN|YsRR#* zy7EsbI!?g8&T9d|G6y;uKcQ#iu1J)jv(1wMNH+>0eXzWAWQ!4$QU zscao>Q=LsT)7?B$$ax1DZA}hfVCGk4z52f6{i30%E{pLW-;3DyX+aanY!)WsISf#b zl&c?!q7w!z?5vK(-t%Euw8|uNOEyioEw}uw8$%t|3A4Eymi0c_2opWVnPeI(YruKk zNg-OTIo)h;6n^88o=UFc$*U`Q(v_ofO50kuH0^5T2B)r*>=xokU?2!mb4OlsOEJWt z`39;x;*R1T@MjK9Qq=W|I{YT`aO{*|PG!tTr?*M0Y|W-|`E53SG1k+nQF)&n5@mzL zBOh4cNU{e&Z>!RA*Dxl1&tDPXgy&rsOTaGg6PvI%U#t66^X>bB)n@}mm0jj7RG}7j z)|&>_s5h>KxHx~eW)bAt?{68$nNoE0i_fJN34``_Iie|C*a9Y=DX%xLgFB)}ut=Jm z=zuvd>INsUCrDFfBZ+8-2+UhgVk2xzfpGC2HMU)@iAQIKOf0{aU`N=dw3y>%%|O`B z&eG|4_$PEm5_)8xETwx0Eq7?3v5Idl8!qBpYcIrs)@pC}*nr)VCf&`n2!{?OBwYqe zEwVa(W&TD&n>Fqjzbjw^stJ7+rPgv$tqFKYOR9v0SyyS1(=)Q88^BBtC6G(n4YPtk z4Aim|R`-P7+bEN7wM|UK@gkaR6FW zq~Yv$M3$ux3Cqu-2rOoija#zOC6rJj`F0C75+x8% z+i|ntga7`#DHv!drb5W=Oo<3LEWtF0Dnr_2a79YhHHdf^;Y3P$B!uLE@_SyLPzF?a zD`^%Wk1$NQ470xpA%bzf=n|tRA4!pc@CXJ-1$#rB27@>)rXQ^gB>}2^cZi$yc!;2| zm;pzc6l5Jf&YwJ9Daj9SKtEtv5DGt_O&_g_fGUYa-D%K%v;C(I4BY3G7-xWv@)oJ~ zW_!AW&imKPebc8A&+f#v9`#y!b%So(Qk}qVJ0N>D#Oa0SjnHvfnMTGg&|#Q>7+`L9YnoQ0`-G_%XF)j~>cHKJW zpiN;!8T8NFsx|WGAq=TacADU-vxX_DT4QD)R!>$c%hF~Lk|rB-n<_7FVwDiSM=pmN zV6Fov3TPWtF6P6rL}U*TvOtv@Bq^;!<;Ipmp5*_Ls&|FP_%ElGlJog(Ksj_y_Yz{Nj#wY>QAR5(c+WgKOjE%8RcfzMdn zfwOhqyr0>=tSF5^tQa~xT8ukzBn-pVHLy?E zuNm>I)& zCms>HNQSo4nMd-A61?0!;OTn-bot7J%uaLDR`eY80))^|EIo=w(yi(v%v=^;eLN0_ zS2sW8^L4xHMt@Mj7BDpQ9}djKUCsebMV<3QdXGH_Qz0a^F`04iPU#8N$v?fw!I+dL z?LcF&=U&JD%6Q*fH2YK5ie7G8de4a(#Cb7j z!Eg^gpd&;S3Zo^vpf*T#ARdLIMPt6Ce*rGF|EHH(>W>5zQZ3iTVHVq8DfZax z{Z+HZ_3x<5sXx&hUw*fHNm<)kWdh{a)Krt}h-x@!S6!5aUDbOr%M(wg)+a#Yj5_Dn zE8OC*L>BL@AylBm7K-V#|DGIq#0u&eIJ^{?kHP-cIEs5}5^*}gRnAd)u=Vud*?9lZ zJo)tD><1fC_G?d72tPe{cSw?<}4h ze%rfBS-j+ROysY+F1#=jpJqsVM5o?O)i?ltWu$*Sy?Z2ZBSf&xyGzf_bf`53hJT&9 zZShl~<^5uo&!nPEz0|~oH6~A5G2j+pvKaa;3`w41Tx3GdeIbMiQj+0ETU-6|hL4?> zs-j8ctDh^uez1hGo^8)-4oR9><*r~=+sseIyI7k8la#gGMjl_St{$D_ z`S0~lh%~i_(z_b`PcO-)t!$i;l}xjxVhi!{4j#=^&;ak=8Y`QsHT09S;wFlN7vVWP z8%e8=4mPzre*PK``}woBUW=;V&h!J0U6e+0$)Hr{MX?zYG}}=ib%)dq&>7HKr_jj~ z&<;_Ud0^CiZ$o*()vPB)+e|CQSaacHTItk&>d^1DrMAn0pQ;*j7lhN2D z8An_Qx@lz;D2c2hZL%|_@QAV_SE`#pL7my z5HMhCd#8SAPPqW8Q9>Z6vK471AsPs7D(o*p&F%UcS8m98qkZ42RRy2;eFCL1OOCPD z$=tz;d$AFJ_It~F6TxEoh`d8#yy+4m`(}`B561KK*QAKgy+$Fz*sr8eO@i+1HH8Qw zIo{3^Aw^hj%w2+45U;q!Py=BSfZc5c0Yo;2IKslLqD)zz0Vn#rCWOaSlAT6EBz16Y zR#cFl%W$w{UIoc0gFTVUA$P^o!@Qa9b!o+@%hy?<&iUly> z(9bu`N|x7O(jOu*$)I+CSwbwzk6&dk4o5jZnGK^Uhr%e%04VTy1tA#aP(?vFdeO0f zQ8Z0y5SFy#s1KMs1}6{zH46Di)K8R1m~=S94bE~(0O~hthT+N@kPH-(4!9F0kUS$H z6^;ldsg%FqlrRw@>kx=AOM)0epobBaIfEbOD9pqc#v!htAZ=iUr4dtSB5ps14FsU3 z_!YYSPL<6cMBEKZBRm9dFv^`D&yeSbETyT}I*R~ANlJ-cZ6!XVFacXqWs)~0A(Z!N ze;M14BiRNbl1reB7SP0!Wf|bNL(D_Jlkdrd!qtem1^apSZ zXE3dZ&b25((kK@&jpO`U%AKV-9gYPi1g2Xwt_xw3L5}O#n7ahmxT%8*h?3ITRs64b z2;jjT#lU&skQp!=*inM_AgE&digAUe`q6=!S6-;K1tWW&zIRyiR?Jeu8-!6W7P9e% z`4KZ{gekD=Gn^JFV8T`@!;P8ENs7DFyTGB~7m2J&@1caDDG%~E5Qc0FgjB{++xrfT zh@e8xFCyg)CN(whWdxHn#Fkbn7=Z+bi@PJp;mwr%{Mqxl(>YaRN2j~&(u}t&ZM)*@ zyB)RlG=gT5Fh#C5&aca8bo%J?ypute?!PVqj0FG+z=t!tMNp?NC8!ljs@#A2BTQKotrYIkwm3ih6)tg=X)!o#qO%2D)TjIIZBebG&?A4X9N;;2=+k zpGAF#l4 zzgAlu6fweD^qygY@8FbUCg$*gBF9t@_=f*S_f={4@4-#~?Hh4J<>$b+-9I^V$R)~D z3IqT^1^M5WGk>$(Kip`G?JsWBQrih~Lo9#?L@XM`C5wP=k(mdt8Q!owwje}}pGol9 zEi75-HOnO%pb6i?iUW7DH}$>f_sjVBxbgmxT4E`A;`mZGC|gTAtY4fNdkcY%m!e|| zjT8OdRXTQhP|lH)-Z+{#klmtE02=+{28??aP0M+4hjlhQ8%&1+Sw!7%|au? z%OibrTSE?^zsz#oQ9++hC}Q-; z>95f(j+w|F4vK9YAA1suV?Jp-(AT*$o2m59#@5LCD_JP0#DM^B55$VK=p(yGKAWjH z(C5kDU>tw`NTq3+P{Y$or%tBvS*&0p6saJCDC=9fNnFC)4tJ1t#}WC2X=m0j$r zJ-nyt=9W#w33(Haq;bLX>u}C_#RspX>6)ZMBWZG^1LFBvKR|~@N02rfMNT_HXxfGw z8THr{_z~w(W7*|2aPQ2BiT(Wy%n19KR( z@pYcA^UmC@W1kP;6BY2%qt4Y{ygkZo3d(@pma@$D7fZBq>l+z=@Gx`w6Q}N~T;Jnd zov}&f%)~MpFWJ5&xG-IE^y2DTW~9B?F`*3kgk6c%8WC9QQi#qOt@Y}uH1;|t8hHS&`FwZjfZ+*;VeIf2vTzOr}!X&AdHD{CiRI94_4fN z#YRRHe_QZ4mBkuIucXyeY|MY0TqT}*%z4jD3-86I_3ZiLKGv}2HQba6(T6OqOAUvr4YCy z%V+^CVCcW7QSM$g6!RW~Ce&cPiV#9Ke%b;Qr6}YnAq<&1ZqY5F1T=9l>Lny8?8+9y zR?O9R^uR&h#%i2?Iz)KLMOUmw9W~$7FmM)AyC`-J|EtKm5M=78)vJ1|xYqKYd=yP* zM(M>j&G7NwEQ{DXetKg2BmQv(BM8w!hnm)YskyT zh6qSk0B$HT7H1F$1SBy4g`u*Ymy==a)eniF97++d^};C$C(mWcvwpE7%DKAAkx@ng zp6L7vedRislrMJF2NS}m=wM&OQK-_lLch1UWTQ>N2~Y=`+Ofw7p)Evt?AN|6G=ZV6 z2`qF{Oc_Gt5155LC+7EhGJGBC%+#;|vg43>*rYztrcqBN!DjMXrPzf@#>75QYHA<}@J~g7TN{L#;R_ z2n+CUT5;zZC;|mK0fwg^FcecgAANm)Go7^U-VLaEKsIW(9xVB za3wrw>kkTh>6QSk*X%$oH%>+VQ9muu+0FRl5=S2MbQ%DRH{$3O-nUC zigMG4!ul&PP+tsd?(5L)HP_JX%p*R-%}>CVch|ZTaq)QoI?}-ogzvg!STxOIoi4hN z`6P-%-_>rPD8^p!^CbZyM9gboe`5hB2DNoMRHTDU;`RR^PTRtl;VXBYSop9kdnrSZ8fkU=9W5LPk+5Dg>Rw-lSiZ z=aSMOR5ut8iMRj~9bkp=1Gsm*Q(oHr2?h$ptua#xdLtl^iEVGj`QzixyJ}d_HG<1q zkTvOUthFDp3gqZxpI7Gf*lk{ezaPx*Vc9%5l4xCQ1ucXb1BQ3bJ}y}y_<7%^*|r}1 z#18Se%r#c;&$cMnBvk_NtJQblYyA6t;f!C}*%?}z7&$x9n|$@-a&WS-&~h@HFw(NH zf3=qx8Zt7|8XFrL8Zfc37;rLi{-rPJlwiyN15EHfZ)sUaP8gZPLYmW*Bb)2xoB*Xn z(C}1PXJOgqvyb;@r~wALC*7e1hDUKF7vDOESG~_f94DnT;aCj`M1L{@qO=Gk;IKH! zJWb5iPzqsbk_bhVp3ae4kL87Jd1VH-9t-lZ%ph} z*Y3C13X6asL>SLzuwzav(~D>b=5~l6xaSXz&ko?m*wDY5Qp^Y_jmp1Px%D;veH|G7 zt2)@*nnkOBQ$dOPsiT>quKA--!_&W*yVzAx^Gr7m>`bAE+Z7N!E*3>WAY4@N3<(X; z;w(YT5z?SMclYA+?301{WQX}=YH(^WJJYzVRv6S6@Xr=Dt)}E|H{)J!i}*b=8n0~^ z5P$_Bt)#;OqCW@LqvcFx=c;-=Q54V=8vE{}$=^RYZb6s+x@cMj%G;WfR9i-X!lIgc zVWO*jC@XkrY{QK(JFQ4Vd)?ped7j=QpIvVu>Nn|5kU9S`vbX%!^a0F7)Zscz@m53xciO_@1TjBFS1;A1zWwjeUiinnkoOb34h+ zd<%kMxRpNxb8D>8rjM>JbE+80?$dVUw#AwOMBdrC?q|>l`C+gA@_>0Ff zp$g`g4;q8!lN!o~h6yu^nxniZi#V?@)Gtu0chvsq4dYVmqKY1`O6Kvr_#RH+2lr|y zHdaf68!&OA0$8W{+p%J#V8((mgH;Qo4{9ZC9F%El+!D~YGkEJ4Kof>+H7zp@-w6z*?SJS-?)2MXDi-wOL}{ z`o36F$6M>+bg;&#AZ(X)1}l~PTHepWCGOH>S$w)BHKpzRpu@p75V11O`35AHRR6)a zL^}5lEyuV%@ws%|pYc0&7U)dr#h7U$%oTyzalfqFyP~cD=v2TaPs#R=U~l-#Pw73oJ6&uS zyXK0`O{`p+&`dj4dTN_;KKmcwiiy9gz2qWp-An9mZR<5ytK>SK|8gxGn3)3z$})W$ zybg|9;|GfM8Ba3|C%3ZG`rT)-L^GoYYM_+aQPQF!icG1~U-!@oLe_|4C7)DYctx2E zM?CX>1KrHapM3r-TeA_|H)CGh!$A6N;3@k$a|Z{sGm)suD>KL^D4z9n1v(&c-8N$X@9mbSJ%0$gkpHn9>G-W`JVV*;jej_$8=IgdT} zbV7Z7W9;Z`Jq{YvF(%x6GgWPw#VVwVa9m}xI2N_v-O;i19Uz8ml92o&U*FJC1r3c8 z-Hj*0tLlCc-?<1+e8s$TQ>Ij|f84}hDozDXRPM<9Rv9QC6O#P!u5r5g^_{s%Y!(}^ zR{iZ4>aOn}_QCM5MbWkIfRT4FgFn`RJiHo}pmQv}?a1-iSG>`I+wQ*EIz|UGGNnsx zZorpz`>6wd)|~atSJp0^PEqCYI1g@RpIs%-K-NJG=5OrWz+**^yzZ|HZ2!mOW=OM^nMq zOmusr%Z&I*6ALd`!NDP~SGNwYq9qF*j62T5#8fruGQ@|A)^iIs9JzM5YwrYbxkaA1eG zzO|^5u$_fE1wmf*c|L#_zQV3ez_QwepKFZ0n^zjtrSQ4riM!*6_R!5UjyrZBaD7Oml^K>tgCRe=&s$zJo4}@4|U$GN4$;oDaPd2;0C09`HZXl{7 z-unnGoggP)n$a#=h)(ciN3dX2E*%64`d!lBBy+JMlAhV&Twli^w1sW{LkNBSE>okB z1%>P2Krp?t)q}VwGO(-+y3Y074`;_YJ5I`d4|h(41PXCUx(^6<0w1IqIuf%VCG1zMycqfi&+qf`S%HP94kn|r+fd(i6hI26F>pf zp(E?P8}hl$V?)?|PTZ!fEM<-lKQ!SPLp0KH*PZ z6=_(n?yG2`T1;o|s(mw8LlCet35*IjmJwEA4 zhojQpxHRtyuaqtwu*E%*`IZMGt!^!>wuWdvK0u*W{1lvAnw$@o4vq0L{KOQUFf1EN zOI&cH@Yt>)C*&K#}?INO+S$9u$YjIoygzOoMziF(4wS%8K z5dv?%Rc;43`<1A3h>$V^quhG*nNNVP4b;PlZGU; z>3!)=52K@&L1{6h<)pTp4v-YeoZlKMuh7D(H44W|EDNp_>hNk0R4TErGY%TJ$7IB>xfzqBuV%rvL z=Cvl}eqtkf@MurYCwEHorvZtv5p0S#W>TTRpPlEc%chQ+G@aV(^Era)T_QF=f2grS zwm86F1m|+##au9EQ*=*ZY6`O95IxPQ1PtUygN7M#4L_SqMiqKnqPHH9>}Ja#0#?BV zW3yefy`4o_J!-^(R0Q{IMW0D#h@Ng6*m_!rgcnHj|G@0Aq#f@Hq%zYH72pJv@ENW& zbvNSv?Z%qAIVo;rQ#nFNjSh*lb5D}+f_3?0DEiS5S%d6;i9MZ60W&UbP;z)8#WhtT z?V*X!6gz!Iu)y1@L@HyAd|)tr4dwe57a_i$8~#msVeLXF;`a^$W!dJ@Or<6Phydnw zQP6qkd9jt(CR_}rqN0Q1U8G-$D~(nOgWm23swkb2C+_vIoCqmo$8#)u(OG0+NT)DQ zEk(SiUSo#g8@&Uwgcp}0-e%fw*3b$36-SAjVH$fm7x~c5Q1v`-=%TNhzqRFKLKmCo zv=W=|f5apX6zxYbzKK_Jjy5Z+?i22b1_!VWr3Ipc=RCPEvpSM4O{5Br=e{m^5IE!w z)F_h)1ue=sg}|RAy_t*k4|Q$07E$6ex!*xd-7I<&qMc00A`JvSNHI~&;L`!P1UJOk z55S(h#(Yaa#LdIl+sqI7F0l+;Z!zEx*JJ@^aHDIi@m$Lu@2;6jxt^*b{@V(r&B37Rb={LONv4~cyhzHu|pk=#+8f}y{(_zIgWYjl5 zqwsXaZI6}KtxeSqkbTU)bK7CkWp~vFYM~Ux85syWrq3oUmXDCAOXi^uCCs+-6jESMBzd}FVV|w<8lWv_CV`$N zD?|rtOxea1H6U`8wYuver6|8a=!w@^kp}pWqwx{uOw9G&j8~U7Y$~8F!UCNkB}ou7 z2zU$a*x!7hI&4)<9B6qbx+oLmBX9a-&XwZ2ftsV|KCuyltbXz6M^Z8a-|<68fIn(8 z-1T1gQe@k4lew;fd?(W~XT}@TM)*NwwOENJ;r;uEP5q2zV6VGEWnEaw;rVJTykxK$g_2u1;G{O^a<+O7hS(1cl8C)yaI3AI({V-wXw z{W?jxLp+;(vfP*7(w!akzg-LbT=j5>ZS=DVnXQKC4!A(ze$Uot0eezG72P(2R@^lKY6j!*hO4+!VGJQjpD}8KrY}al z0>Me+j4ehx5Jc{)2T>YV6Ik#$fv-p0UR|OpOd`aZVVE{|&^J-IT!3?{D{m({BS^cT zDiva_n>CMzJv!AL&!Ve3^M_alU^sVEHRrI+Jc)hDSTww9;kU+oCKX&kD%+aILl2ao z1<%oO2VWSgMEq#tYr_IK!^JHf9VeC@YslyZvPexNamscsbejJCvBvIQ5bugymlvN^ z@SM3!aBWqZs})(>?0$6I4%_!1>h0Nn{Pet1ZB*C}J=QZ!&AmDJoQ+wvA_9Ct4GNOG zCP$aZ-BK~lVJ%N7Vd+>b@F}|rA=q;y2yW|WCGKSsD1Oww2DWXyCyX-Op3>3WFR9(k zZ((m})Yi{gLdP1YB3?}V0p!_z#yI@<6k8G0%Ht|PvlJCJZ{1JxgPd%I^MHc}aqQ?w zOV%84+YAxuP$}lTK#@J05cg^O7pk8AS98{;FLJ9AT?1WIw}x2E*1GNx;NX#udb2i! z(6L>;1%}xnzXt2m-B7YpCqcL)8C);4z7eH(eX1x~{{#XE3i$K@1cAMK*E)2W+>n`k z2Z!c44pEDnK2t3OC^qC4VlKsR&R4|j)AC%yRW{Yukk>Z0^f;%|nrKIyhw5RizaK^G zU%4BOoN^hx!JgaX|GGti2IZUR)}85?>!I7ZJZLH+0W=IEE4`XP_}E|{Ts8(N6%FoM zYR5_jsWeYQaRErLiLK|;x%3|Wyq{U%1LP%_8`N^}hfe+MdF1wbnok8u`bP_hR(dt} zY}NN3m5tT~sf4pW>Ba1DYbSSG<-`x>*`@Hj<~TpI3~x$UBkK!zK^SJg#s|58v#m%; zM~qui`hqq%X9dJJ?Frk@G}BC7Uiqh=&LA3l*`*#^a80;_q#6~vq85^G_SE7$N$5&7 z4g(|tbd^8JSZ--ll&roHr}5Fp4ZBV5!#Z%{vee8#mUKt3mL$&1@5_f;b3B-pI93vX zB%)Nk92R}}e0D^z;x9;O&>rtR*D*$k-=8{M`Sy?1{UX}hQ3A(4M=BOR;H&n|b-EHs zE~TU?b5a~Pz!-UCcI?p^E)kBwzS1{}NV8rXJ`5y8xmjqh(@%rrYLs#)7r-(AQY>aK&795@bxOB=L$rS#KdoRas9-n8>M9guy-RbyWIbxQWBrsSVVn` zaoEVj&i+q&U;v8?t5x%Lu^;$Al+0KlK|@6)Y+OZga?*_atw7``scVKic+ zWie!6rv2i2jI`{mY|I?SOeUP{|3dkqI^n)3AAnm=!MThGo-OiE+$b6c!^J<04-nxK zPI0w!(VI_L!5+I8o&7m`iXUc>kSDGmcy3S>r8 zfMYMt|J9~*fmx@mr8X&jIeI3mqGIur9@JR-Y6%Pk^wQH}HQn$bRnQF8)$#^FK}a{!7~IibN_rY(;ec!;}*QbOY9{Z zV~+I*=+aGG%@KZsoUi#Ak<3rXO*Uuv(bCur#e*6HvteQuk-xXsx`>R6Y>t@g44cVv zNLo#C^PEkImj28b`s5GjFW`2SLD>OjO28xzio&x@4dd@;ZZK*~v1m4#``L7DVXmPM z0fNVP-3~It%7I?VfCzi$3E3J1~#vR|n~%otsf*w%DmWD`9PC%ZS1-lsBU4~qJT)mKw5G^avhlJ4qK zRn=~oxTCPe26HMuM-F8*BLg0DZ@Z2vo_L>>7~PKi z`Ni@-Kkr)QJifFA`y(naz&&{=VQ$ge~g2M3Y_6bhYuY01t8hBU?Oqss&;O~2)NT;~JLLWhNKIfZAd41Su z41ZN%f~hGFSqKS%0hGWcu7<^KC)cjd>LW5=>Brh5%b~1ajIKKID1!*JVQ7DwzWQ1O zxpZDN_UI`_9I4QXu`?P=co!OmvSZIKd$R8L#EmU&Q$@E9D79~;t3uQ%9+AI#R5dQn z-s4nLKVuY{MCcg0*TD}3_~Co?cN)|XA}aTZvP$pbZ#Fq zrC-YY7x4CfMuo57xB2G5l4*gCzVHKeSSx;mH9rL@NV9^g3z7_Gxvcw&qTXd>Ax*Of zF*$6_yBX%ZY@;HjWyONOS~F1Y)}R9Ao0iH|Qhid{%fa{08Qv2QpI)!)_ie9P-dRq0 zreS}Ym;`Z1NXYQb4Rsw`ZsF>#X;z=g0nj^ctpn7^%Ce?y9SmYeMohPRs448Q59`ggMJu1QUaI zO~bu26Mn8ymd(umSzFI1s-iWtg%dSU z*L22+hWXxSi@!5yb%A}J?#TNh54!7vaDm6HnGSl)Y6ag6(O59X*wp;;GUS2o%g_YWvx5wadniirZAI{DkEMNA~&8!x9L)@W zoaa!LzAZhQz>V$K^dzg5%rKT=X(Gz60zb9LAO3#qNB-bO^ zxcAt{gG_q9ksg`g)Zia|RE=v$<5@F${*i8RZ!=IBnzhf-L%`g(X;#UXJ>CohzYv`; zRq%Z3T9&Cb)M`Gtg(8*&pI__?xK3(4_UEX`K@G$8!ro4#Sn{3?v|QZ(Qiho8+k5cH z6C*Z`IZeM(?5_CYCv?orWIx;YYLU;+Ed$$FSg#CAd)22)W29^q=~{i8sw`9W&b?*t zm1lDfXg%XF(4vCiZ8+hM18>TQ-I5#=jUk~eh%Pcst+V{G2*>9 zL}AqMp$DGBW-k0`_?R2lMwq=6o)+bQD44@gbt{%~3ndK+n+Sdbm*nIuFP4P0m%sIT zk*h4?u!^y+Y3YMwlY1VicIF7yOnRf7cXqm!#u?a(aHZq1D1IeRWib8Mi>*T_lb=`=8z!{{_*)*V_EEKmx|f3J?l6q9_bSc}Um zj_u4gILKjIL6(B!P-r|iua$?MBY`#`e{UJsUzMw@Syt8&`SQU7lvVdSsbf)}Eha|@ zzQfn58GX|`3%S=aP`jkn8C~`>#v6L`rO56?%GLt%KF>aHy<4!?-6^4d}l32;3M1-U<@#@KLSpLIr`&WUnDyyNnrr%6r;;gNb@~njN^uvIQ(Es{K9ORvdk-Au}8PvV1rya$0jA-F>E? z=H4d5XWsZK;rH(3uP6R9Kl5>On@zw=FiLZQwOtg2c~hI+wV%=W^@kSWa}k|noEB$9 z&XIo>t+J6szqjbchO?6(LfV=?30^Upxy~`|y57VXl}vC1I1d$0VVuNt$o!8W;SEi6 zCxyFY=2E3AJVu0*Jzoq+k;aij8fE&hWVCj8OUL{24M=s%99`u{`4ZLpxg}G3kK|VVUJPH8*|6ernz3+9F-A80-|{S-Yp* zrdVFW^QE00v*M4ifzEt|lD|n#IU$;jKkSS30@n)ZMeQT?*(%(q*d`GX#@emUYx%JJ zwPIYCOS-rc0xW3YlLgw)OBcu9n$BTwM<9EB-(5n{J|EkkBjnc$)ZbF0vD?(8Zw#Ne zckGaf$W}RK`4tj>{Wj^x=;k~4M9Fd#VS{?iKmJpAU(63v>SjDoEJ3ZM;Ly&}8t>fL zMJQ4u>d{D36~0=wH~{u-CoX-!d{D*6F?W5bP_MFVMbc|O>!6siYP}Q&k?poOoe2p# zJ$f{qH?~ZS+}(uwE*2(pLz7b1;X?DHFIBA{=X2IEA3aC+*~H zpVT^66+$t37j%X)#K~~8u|F{$>5*f5uUe*wo;~F{F(*Fj2lyZUa9d(Ld&{>YYqrv zs!NC;06;$fpGf;uS1wFv5VZ=o(hv%6hz#Z=2~*>AWY=JmwTGDJD^aT8sS3VR*k_mo zV{kYpqVDocU)TxqIKd$HVopjPvQS8GKKM_B>lGqX=SDEpC^g7%_d*7Ng{408FJ|dK zI;;GuU&dF{_4F=I+tqb-@9rMT%3h+y=|==`0s)XfL-4MVJkfNfbSNQMlOD+K#G8DO{=T^Pn8?*_Kim``9;7ug5x$;e+F{HB--_{C%S^C#_@X&y`!gK( z`cR9!Z|-ljL}`Y`1DG-}8Fq-Y{7SrqrW?e+y5zw^REAklS|pY^fIcu!gR53@yT5XYNF&tbT8E&Vb<(idJXPhof)zx8 zf9=lL%y?=c@1Zg0(Ll=fsl`tSd1-!2{$B}PEx4Q8k1j^g`uN>Y#7@*L|&F? za$;&PR{)B-OretFKrUkdReU&z>D3IXjwmmzD8nS4%%TWkJBU{s*VUm+HCSYGQ0ydw zi=Wya!?2+RN^=7&x7_0Apl+~TCAnY^!iN-kDk6ZZlaXNu7 zh{jyyqDq(MGVC|UD2|Fm_2OA5A_u}5;wa+Jw+AQ%swqlyK~&n$4^sBh_<8Ra^TmSt zpQKcbJV9N zflM7J{k8#<@(o1M&vum2k~3aKZauK@tf3)ii|btCpEJLQ7lue0%PDAZH73lnG@^;S zI|W|gX6#MzRuM0g{F(wnS)OWG@HvB&mRhhhnW4IUKm^bFoM^bSqiLs z-Gt$qjvGq~8X>^C%VKZ`J)6TV@6YB zyn5PF`_ko6PYZwi^`h=OWG_7~itZRKAo90Z>NFL^m@KMhShkxpDp*oMF!7%lkV#Pi zP+v7^FuP<)qu6scX_3SI_tEQg6r`~$XXwRquP3Mb!RH`xL~+pF(he_V4+(wXy@FoS zxKB6MUYH@(r_2b$7#4iLr+cN`k>nw|^)1(p4g4NE8~N2a4BPvb5DJW_v1jT^M4b&7^_5hcyQAYncS zOp)x!dofZ&DHTn#gpkQu7y{8BaG;0jj0C&xuRV?^>h zHwBGh#MNqpGPHW60s39CtE(op{JP9T2L>40qrZg&<#_uD}WDZO>F4YGYw6^5kacqrJ; zM+@m!y_K&c&2fgEdytXdSW|*CcEp0%#~lDyg-TDd{Bon-UANX458F_3SEw2yMvmCC z=C6X2aW^2AD|Vd&#LY*8W*g(nLG>k>Exw}qU_}dT2{wG{J1`w_IKuM|uX?eUHoMS~ z>!GDFBt(0&o+0}CePD-+?E6AVK5m9(+X$1HWOpkK=_X`>tT_n6U3iA_QXe@&xF8?N z)c-4jk^acvL~+}7AeS^!RV?5s8c36e7NTWBBHP@2fRt{UHS8Pa=IyOc$BCiPNS`-o zvV$i6xX*m{BzFdjjfMFziu*a4DW!>a^k*Z4jT9<7_}hbv%f-*u!Q zY%Zg#?3cA}bv*W2o8DUI90ohOBE~nDpzWm21$3qKn-g`h%hJ;>m*$I7M&U_$Y<5h% zo5KF6$J{M9hV@`J-Iu0aCJRPQ>5Og#`WcPm3J?C;XUT_!Ghr0Gg zgsk8GtsPe+dbD4pK>NPp!s$-@qJhUgrlBcPU^7om{raaJuk$44@lw**=70x!%W&m) zZCvxgpf_Qc+M+9%63U#&JwLCWsZx8*sPDunrY&OgJ)?&}Ey*ekd@+}&NU;f;Z=Q7# zDT#^vo+s!w%^|SmLnjXi{i5pi?=M7WCE^lbP$;yZqe2N~a+$T&2*t6?`0YsWL6-r?v zUT8CIo0`HTex?$m!3FJ`VB#V`B4YS}uOq6v`~g5huF3ai7v$Gqg)`i;&?A6S=)d~Z zr8sO9L$`ugdPw=4QAbWJD=#r$B#WQ}Cn>_6fzpux#5}@YNkE|}CjH)|Gh0|6XA^pB zw$Bk4k;kiZ3j*DlU<<96n6K>@_CBU}m)5B4q(5h|GX5Zz5q=dqUDuiCsT89`+#7GR z)KK8lFCe0~`v&^eIXZULIXPvV-QN#`OGyci)=P*^2*TYYKI8-BoRTk%RnAhoFm@3k zG_zY9-b-G74d^SuK>!w%3rXiI`v6_K!P`7gLtYX_Il@hj?|mr6mwVi=Q)*LMhdwOR z(VlT)XHn8=OGhPudvbD!E&_`175Ofpnr!Z@;B2A4jeO@>vP4)+L!MHrWA3hqmp!4Rb6FmM?{D$V01 z^KK26Sc_mdDG3pSApFxVj6ak^W?n3BJGkgP!pD#_TxXCORW6Sa&js8+wN(z|_2b~e zV^F?uHMH2;^Ugrlm6%S$8cRuFUoxCG88S6m^dD&Q;QWKc#GQfv z3fGM>3E9W8ZQ4rTw1-Saf(Xty-wHqz2E4iI2&xhVcbuSY zMR7&k#Hv7vbp|QbtlVZt_HTvGg%nt2e-^ zE$H_FlcqVU=L7*NY^Gta6Z6OC9po&9(0Eamk{jB6?3+2Ya3*22-!OqS6#+(Qm}B)Z zWMX^?Xt2?|5|C{m^(IA8*2_6`XMiu&O*xK}TcR!qc z_B@z{izS=g&to;z;U=j=g-&#=AQYOGc7At+6drle^8Vw^0I#d%f9&M_)?NgZeYIpI zw2a+)BR%JaEv{SP)>tRv{=HNhc`2L9NS&*+VP;H2su;oa&4pDKE%=~v^bCP#6fJ|6)L$4gi6rVd zf+{G_RaYuODldReidHNQUo0h<0DX;4EDg@Puo|T!)m>$$?-;mzO#LSEyXk^67#KFPAVr&Tf z&wdAHJ5cY}zxy4Sc-2cpr++3oyPOIun^X)qf{Ws;+#u5RPeDCZ*ftZuXMmW{?VV+Moi2P={IC*?JWFaxr@ z3PkUZ3W4?Rq?zrDe;}Gw9dXMNkZArrMDwr2@E4*%M!EcN5Y69%2m1bjXuy!5gFp~X z-+v&QAO1FErZ|H+?p5Sq`501j0!&RFsrN+8%;G!oB5hNIhXQ>lv64zF_=oh;J*Z)D z>NB=fkfMC=C{3`bJW1Kpak<^17jCg4!w)Li>pGIOFBrba#7b zI&*V3cW=6}d6C~wfB5mR`1_*xqW!|%Y@_w2WSIWtrYDuQ-YlE?l|PYz&UD7}^C*3! z%z-mnh(p>+%c;xu-k6IY?~Ckq+sJe1RVcU@tM9SO;LH2fWjGIxJ%21Nz}5{MYs40Z84l?f`fjQivCfw9%>4kxSbnoDv2%s zX-x$|R4rG+^!jS~gJ9T$1d43PHduash)EsuP;R*Lg7{{nw$HIckSHjuH+PH(WU@3m=r0?Gukv=Yb7!GJOvHH?fy%(pFby; zfkWyyQCDf_X*O7mT(%uu)o+lXtFcK`n(BGqfX5+}2Q^xNfIfCB$6I(EHo2J@KWI&# z)O=JAeENWdynpnXjC)GR3X}Zt1!M6{4h5FNDG_MGe=^L{<(I@v2Lx2rUqUj)|EY4a zu>W{4Q3iH)Mf*P*>BLw;@)Eyr!m3BQ7;eLEF&E!XIi%|xA46W&6hw{%!6@93F|ZbnhF~3;}^dc{0%$T@Dd1Xdy9nrOZGAm ze6AoQ;Z4{*pTpC41!lS7H2LWH^>pABlQNU!^_ST$L1LmDDma@-g>nz}v55+#f#!x= zE;bl^=not4kk4boMW46SMptm^EI%S2rXM3)yUZr0-_sLW`EPiM+qKFuf{&8q&?Vg9 zTbxbCz(`1YQKP(uroNJ_h`@fy;Zl&K1k-Z+Q3uE86t0gYsqq?dS4@2s`2=@u8I|@a zlDIuGh4Zijfy#FS{VJmQf5YZ&eV02^O1K;KDHAS_TX?LHrB!;cTVSo$X%C{II zrgk!?74+;{X=@SGg~M6Xho_yQbzfHQZNam+$UL@B?@du>W?1GZxa~pLw&tauD(y~F zXYxMls6^B5vG_EVzM`4{+f2{}2pWblOjR5fF5^od#d5ghm~AVh*$7vM}t@k93l0t90AB3!f*>{CYffl@8~*LUu3Hr6mWR`t%r|b$gi0 z!6MMYJ^dVNks#OnIa5F~@;&I$k%xMJ&*Eb&Xz3^5GD`4s9J^etIq)K1;=)7v+8}nk zVYrA;rbm$xY857!8PzWsDlrB?QA;AZqv)m1|Hx^!7H+Gb^^gABmf6i_67t|bmz&)x&5Hi2ij zFmAbcNA6_|(Dw68lHl;XEF19eu;ZXf!#pa=OhIB{=Q?WP zv}7@^WmTzsvCc#}dhm*|a}yQvXxP^u96YNoVb4rtz zIOLP&_)5`yw9o~<4uKARFbp-eOu1NuEn+wd^iO}8VJbnH@B98v)7gvqxKZVDVp+G*Li zi4u&%%AU`0HC)fqLbc};`tL&Z1`Oq58+bq=Yh0dwcC-Rfz@1^LcQezc$LQyswBB>yr1Nu z{L2O3Wzp~cnwLdb3Z0i};%8yvlWh|fNrSrto@Lco#UT&MOkr?q*49M1=maYwT|5&N zh|V^lM9yW5t1k<~MPY#;9bi@j=>Rmyq4f#M^n)%q32NXfP8}TMxq*i(zEJ+ETCm-+ z3E0Gt^)3!W=M|Z!YgB*JV`%NARjaN^2Az7fjj%!=t#KZPVegAO_Gh*&{BmiOV#Esh z<>2o~hv5L2fNt79{4X>()k%&Udv~Zi(gw-5UqRC1t8W+r<3j9GL1V}f6bJ3;$S zm)8XKJPgI2>ahguA|w$`q~Ov=Mj8O~t{0-3M@UG*}T%rqx-hl{G>R%23;QOJ8| zSY<4g8$Q%GF!=WLjGW~w+ zI$-gD)~W+7CsUtX_c3My6oyS#k8{L9KfY0nd4G{S(+CoEUV%1IPKk~7x06P+O+>6v zL1%>oyVy#_Bm69B=8o}Hihhhfdt;&I6$VI!g4Z~H+C;?ZC4)Rl8F*nb5oM;?UDvu&=&TwgdG_z*Ur9R><=D1Z(f3SLSmvyD9se$wf&SSlL@Sz? zfH>31{i;ZvP=&)`K1?FuCtQSI+`##tw(V@+7&#=&{!}z-vJGpxqL)LEm#9nvr$$?;f{L9}Vh7RNM( zVoo;+5LF#xAy29_qyM0M%aIfKo#+R2O)Jt7uuY+$V9j+^b2Pyr_J|zJ zg&BZ!hTT%005G!+Ca$sp-El{_Q(mo1RD=n!Q)%XhtHhV*hIvO_)rm7l8jl=eahK<| z@on#1hbgoRC-|}H>q_gH^-6rL>yCuECe+Dt#n~}WX0KursEc!dAGDdl`J$#K+S|;)8r=>cWY;_3Es|n<&$w6R_uxp00S|G-O*2i(xwZ zC!4qLhw62Lofc@$EMN1R62(ZIvi;>T1*t%TnU>+usJLQ}It8uDLy&%g` z&u`-+11_1>jQT)?0bvB=!_z7Emk*mj=`QmqUQh;rj@Pn&X0uW;=>jQW5cvE)c$S%mYa(MvD%Saklve> zbaT3{><0cA$5H0JUZd<_H8}U}x3dpo5kBBetXm>SZF+a*y4y!E^L{fZIrIKnqzQ!q zw|jnsHw5UcpNI50_JfJ~I*6hM6%jnNm02~owK*1Ih@8q<(YIZ+yYL|Wo_wAm81JyS zF_{yL(ECG`;>RQ?4U}U;gzP0O4({ep5BN=EH{L-pHIfxF#^%H@DaQrXva{*nO~J=_ zroBN&L6{uZaCCg03U|nMwYY`{9RcPstiLiKDgYGKFQLQiV5bF=&>rUk@!ys4b+XiP zd+HL9?MyjeVMnV}bi@VCW;`u%`I$$I)49qF&UPXX(Jfx4*42MoLmcDdLPt0`NHo%N zUG{G8-oH40;>noU6zpqeP9IG>6uwfxae6;*^TH8LG32!x!Bpcm<*YjnM^qUr=u2mlnkiR1Patc7Rd;% zwawre@`1r{(dsz`p|D^N9l-Gchqjz@o_5}x1MyE(tS2ubDZPiGOsJH>%xA98O-4pR z)M1J!A_3%Yu>q#meAY;4)X)y$N`rI-IQX$IdWGh{9Zqyh5z6u2ob1ZRCuu7+QsqXP z8?RPks(IY&^{&Q>b6&hQ&b7GjaywkRMr6sU2eUESP*kt^Em_4=(>OkqHaj4QPk;OW zr6cc8yHx!JUj97`{a=aUFYqD+{`>8}aAQC{0s+|n054nae2*X&`u+a^FJo#5@*ud% z7w!*!O=1lJ1O+6|V8}wa z*qwA;lKl{e4(tvd*e=Ot>b?%^SGvh+f`8hXGjjk@_&;Ira1N(7X1RXpqyk4#hH{%E z#o@bHK4RJp3T8!rSu*3vZZoMWUB-@BtH59CC0VwSn=QI6u$bmg`N26r9LsLP`YPU<%`V0{1b%!IH492!Y| z3YqOz5McuqfIb2IJWXxQfJ5AFQVueH@+)AHF#035m=BoMFPuORlpzwT5~ z{e5o(Z$Uw2xFxnFt+dvrz$3kG{2C6I6 zg;!T@3(}MC2v3knJWI>@ha#P%38-LYu_i{A258Ae^!P)!`duI3rB;8E>hG5|wHeGI zsSLiPK)Xoh{p>}-OD4A7SV2sxjph)IaS{zsVFZ!e3&42D!b2XuwZ6iHYzgG%Nr%?W zDB(o{Tow|{GOzewPz}DE=^y%l=iMV z-twr014c-hCgWUGmhE5AMPMb`XlKedqW0^pGTfqKYu7pG6|Ri5HxeTPprb^d ztMT#_@S)j53K|I7B}`4=O7qE1srvvt%H$ye=fB)^%U_cslUL+Cf$o3mr5sIt^gLrc zwa8k>`}dtZ)+;lfa24Fv)(&Qa>KCxUftuAZ;6WlpF2P^~_X(mr7tt^M{=4pH9z+_x z|JdnCOr87xKMotyzmdUTJLQBNeEBbS3Tj>ip!mm5OZbke004-y{{W;Zx?!Kyum}Ck z%{JU;G4u0mXyeEaN!s9OA$~^XVTdqWtuoTnkuWO5Gp_koif^uWENPJ#CwA1jt;Q}@ zL{AM*Cd=3=?wJ@9-#@Xuy(=4F`q z3YFq+SQe#*Gde1$_aW0h&@)Y9`=&nq{*^!*+Ull`CG%qqN2WU|%z_ZMMcP~+sbr<_N7(1kz6Dq-GHw2k+R z`13Qo=glt60)M+?cur56u5^=Du3z6ZLmEc_KhuTb60)e*M_wDSb8bU~vqkYDi~LIs zM$({wjF~M-N{H0htV~O- zEgK^x8ZWAyvoP3oHGR_k$tlyf5M|~cc5S?QxGbl<@52vpMr=tAk}^KJ@7aN-ldJ-( z$LGvn{;1&3Asa@OJFbAH2mb)xrGwU3WJ5$0YcP=`OK(46Nf@|ku@I`XKWX%3pI;Bj zj&R!^{PW6~0k%aKIL5&=ZCDtmpqpbhd1Ef=F?~)}4ud~`{6SQMjvewDZiHR>#v?mkL0g{@(_FloMRvI(0T1O-4|%OFj-$GkJ{W;ktjRSf@%|j23EA;4 zf8WJ2mlKXDnVU=Er$g3{FY|}DHAHOVW!s0~(%-DRc}s^aI9B+D|wiLaiQn}(*k5PJKWP*2|?VAt#BrdZ)18z(%# zWh$uq;Dh9rD-TDg*p5rys{2-Npw-w*2;)X|B;TIl*M!vByKI1s*C{=MA^sux3t^{H zcFE;tYh^9qDNw$!Nlyr2Mhj7bsR?T=6B`{&hN%K$DzuQgp5yRGjKDCoZ3ZFt;+Ors zy94+9D9r>Uy0@bh&dPX3$)!)jA_g2RY|A2XF2?Oh(sj~Jn3(-leO}Q#d&qyk{5gbU-z8(JNJ+e6nSxi#=mpVrp8Q$>_84s3F~h-#R_Br?PvmR zZlX74GvVN5F);%@`47c7<{$Sg`j30=(fn|j5R$6@r58pFC&>&&bS__!#By`Xx~V(c z0Kp)#%0Um}80Yf;jMc5NKv$HQcHW}WtC!gs785T34s- zcw{K_V)Vqg%guC9GWC#SH74W;`{Eqmnzf-&YLb0PbI|Mj+s;|O5p&^?nA5Zp-L$)g zyNYtoiwa+^t%BI@H9=tBSm(gKQ{ZnPCAQ`Ktr#Sle_z4+S5o-vpxco|uK$aJ{(niv zNRI!ff`#O?+J+jC>g9J(;+()ecsOffl|yRoU7Ag)|PkTThO@S*sAv-|OSNmih(K6nu7jr^Hu*wf|) zY9ABD-4FslU`xe2*~b&F6(gaA8@Kh|01N{KD!@W}zjYh|n<1&02>HhyiY6S^SLmex zDgXu@>bXroQK-$%Z6I76Q%|cRVFIyQOEEp5=LfNR4X<}y_RRJy6h#0U(Kfi8(#;Si z9rC3CSTU7(fow6%*krD#n+w`<01N^roWl&OlU5fK_ct-AlM7a9+i%WBjuV!E-iGA# zPx;QcscWDxl6b-}B4)3lw9$fo78;s7PqwI1Y@W$&&o1J9@Htc)?-|%cHn@_*z@&kB z;g+8n#6Ali62GS#*8;^s{j2f%^s8l# zScXLN2Sb#dGAReiNi6?W`rOC^L+s?qh~zSoMO;5}R-K%sb#aNrG{jhhC_Xw`j$^#k-|$$qROH?? z#kv?xtxmI`0z4ReXH2VBeHSIh{Jv1K%1rE!*9@U`D|=l8cAw^$kC&ex3fe8Do> zwd$l}A)>yJ^EM=r|Drt|KmaSqdGev*IYI4AeuH9dQ4s*3WrAk}UbrE{MCmt<)zGCm>L}*f zQEDPui`XPWjBEXVwC6{h>oyFyVj&r3>Tb#1Q&0c%IoD9I;5ODH#_6i_<#Nuo+*|R-Q5dn?3)~K` zE8x$wXUXP^64OvkI8Xv1+h(j{mk*ke;?q}W#ve~faQ@n zr}ruu^V9!?4X#_{Yn7fU;m;_kYbOpB5VEXdlDc7Pyl{ISaQ0;{KnfV!wR7IFbbrNa z4Ur*kH`ntg10K4|G~mi?I$grm{zG80v|bB26MRitGMx(*U|&|VkqlO3`(E`qiD!cM zb1nnt4LG$B)7JrHE${Uu2$9o-YbcL%&eeyDTkG4ZQdbsAQ+4=$;C)vELWk>>LJ2jd zll*drwbRv)qDc)L0S~tCF$aw>Me){3w(_X+m|_-msnv#zJ4;( z$TX^W$*rpZ@)v&^k2j(V{8m$7QLy8sHH_@6j@9$_dGh6*s#<);vSol}(=&hK5A8Y# z@n84XCwW!JZ6`U8`97kJtk7t9ML%P`neQ*gtns*cL=SAf@IxU#r z-Z?i?h;75(WpHiuo!BpYT>Iv1v5OJm$M@+8qlbp}C1>9E8uqQ|>ehYU0wlA9z|> zJ_f9_Y;vjs4HWXV9d4D*QY#+y5|mF}7Dd(>CBG$>yexc>b|F~&zD%ftpYf4H$n8Po z*W%(@n@qJXt6yCw5cFBOCLu>P^=E9#L#$}V3dQl#F^QK)yWv)|S+doDiEG=?cDh{+ zb>_R5`@E%91vXNBp_Q_yNg9JGLS3JeKW5K>ZkW9!6uJpKD!twMi3;BM?Kt7Kd!-<$j5w3IU!FizHEk<9l6yE1(?CIaG_ z`$Xj#X3<6A@Jz{$pJ`)^?W%`~1~GedX*TDEi?;_6Zf_q#gnniK;?E7`M;X*6Yp(N| zxB@pWSpMsZkMs)gt|A9y%|D8 zVGY1x(t_vb4=~rRtc(}?AuCunwOB!n*`<=iEOpA$=1r%YSh{{*q4E>*bnCL{3?E!2 zEKA@|jwl~z>yrti*{EMPtTMoaw1K4S$;1V0~3-droWjex~FzR8oE z_bR8z>8xBRX3iD{5y?^?P4u8#TiCf)>O=o5O5eu-0r9K&OQ&C%cEmUGJ$<+%`gbKw zo4}u3u9vj)^iq9AeuhouD%MxJ@x}u8T^8Rqk5giFN#j*dcIe-)j@h_Q1BE0jspt5| zDvq{G56A~(Y8eK)?G*F*nC5I+6h&RlGdtR5EpDBq0a64OENc`+>K8snjYg;w>6xg{OKRZMj*YWUTYjeAsmw%`Bzlm4 zFPhf9v+uMGEImJztFO(e87Z|JYM$mD0aOif?D;K&i43xx53w;EoX2QfDMN zoj1nZaxR*5W4rGIGYqain$m(TrRP16yJeg!cyK2Ci)zwYd3X9y^6R%m^ZC zDA}SZ2kS(U1}h8o5k7pU9Ao{vMn{kcsMosmt{C*QsK1kOGRG4>-5%D#M#kz}5;CIQ z&tC?=8ZUTyPN}Fc5CWCOYDiKO>Rp{Gf}xni+3bjl`E8e{iru%YX_r&KaIiAaNv0r2 zZ4mGw1jGeEQdgjFJu020EVp`eK`cp~;ZtJ}!7T*yPrt_-O&4okV;MZ%V(S(e4EPv` zR*BYY(y$ciu0Uj;c~Am^p>~1>=R^pvm7zIdyP7NWhombw44zz>M{F$g)vp5#=bVJ@ z=lT2p4B-JtQ2J*aw|Qi4$p|5_9P0h~_Q9>M<5GcypTwe)3t*aqUTmL!-maThT6lI< zm|iaWS@B^1=!7_$ne2PT4Tspd({Q0@$MM`X-ojkd=)MU+ZB1xfwgg~_wqM(N8mpJu z${lrO<$(^A{NZX#+YN=}vdE?O=wh(H8>8H4t~}9CgB6Jk+#)!@ubmCV=_N3%ySm`w z%a+!B4tgQ}TI)$+5tm&E#!|p@9f8SV>S~ReX7kEJs?14I-2DB0DmDC94*y;en92$P zR3UhX`sww_uNTTFNmyz;Nq4?Ec95EhG#T7aV^OtiQ-vA=yR_cbQ!udm!G&@%x-S_TFU%(EQ_;Orv;(RB*^c&{>wxbCQ~Se(qzA7E;xE|afWq%ApDp(sw-A;AiQi= zwpCR$DDS}&o72q#Jvw@owX)-ublG4ixjTX$`;IJ^bkOZ8!CAH=S;VH_|0y?|;%X%Q z-z0gYF;oqr@A_|!{J${FCeupdZcv+$cF_2jNixs~XvD_BLeI%*0&4rg!UpPa&1Gmz zZ_I3FYGla8X=rT9@jpDznA*SP22tsyHeDuetyEj|dj4fiI+{H3eB-J=`J$t2Es%rn zjX%a&WMXh>U_{1jR1fTr7j-;nzP8B@xVn7*=d^WX>6G>|kZ}He!NK(32;y&^Tnw)9 zZwe0JI{&BOP-}-Sgz{sV42s|uqY1LE7t0|Y^>=V~5ONo)4@$DneEPT0tlY#Y}mC341 z_l4YML|@&A8iA+nF-j9I7~-88JRDUI^)Qmf92yoYBbfncI?`(SM{{C3Hf)#@3fD&P zGvA_Pv2G6r10IscOX7J$a=554Bnk&DtN`jbn^kaNrza|`2xd)k0@X;*>hy{3`Dt)q z80xjVd=rRQYv11bwtwjEG9U&90t7nzh`nbYBKhl)|AYwwSXzdmCqn!{2Ss4JHao{k z5Cv@Fzfp)ItQZ)r%idn2aLXBJXfI%8EnT7D!*cR?1_~2PW%5G6V&UC2Xp-)T?!xfV zqo6cjr%R!_H(y|Rb2~oq=qTO6u%Luhr}78Zh0+>qV>--t&CA}t1i+}01PW%OZgW{6 z?zG|$A%J$aJKNLJ=zf3beS+-<9sqV*OdBCQ)abn-zeEXkrElPTO>F3hEMGDp=zH-A zBr_)9|3FccVY-2AB^+|!iMF0Zg&{@RVm41EIz7lv+bR3{J24~TJktVP`P)U8q>j?C z{aT~t|JU64hBOg}ar{&yO;JWBLE0ZQUzBS%x7%&t3o9aNO2O!b1BLCpsijl$PZ{LG zpe)p+vJfGtun??32z@a@dtC(m5kiq(h(TD8QD#~6oNM0i_v-=gq@(?0n<9Sq@wsQe zyZha9-)DAqA^ywVxdXXvn|dyVp9l~7w+^_cJ;%l-vQEt8Pwji|&FZb18Lb%a3n#vf zj~ltOaJFM<;!(S6GFb0SY9!BVQ4vl^qi3AC3IE`u3OySSyn zSEYr+%JVA|70V%eJ{O&Xg~lOx*AT^FhA!U?p5G&x z|C`$(i4LI>pUVe`F4{`3Y;}Bvx(^-sy}I*(!ycb(_rrUdc}ar99$paD!ycElKyq2d zNHb7&f+M?>(lF1q8wIo4#+!v9+dtAU-=XlrkjSWQQdtuMAyr+I2ki02 z`XDP$`i|1@mSUaq2t4WMNyFzIR@G%K9-bIH>0(C1=eu>vBk-g@7!BVGwz<(hW12j{ zFnID7UmCv8qf;J%C-0o4;e|?7UDMsN7(7`fOT!mJNo_PXn&c69vig*Uf7q<5YpOWK z;K{;48vb#MPI&~LEXkwc%i!#PH8fhv@Gy9?tcHd!KciC~fhWsVX!vh!s=B5U6%3wS z?xf*=g>=dz@Z=gI4WA8XFww|sS~tYt$%PpjKJSW7`4#v?#d4d=g|8_S$MIK{pMC?r CONm?n literal 0 HcmV?d00001 From 3b886995badb2237fe47a01d7a54072080eb1af1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 29 Sep 2026 15:53:24 +0000 Subject: [PATCH 3/3] :bug: Emit the accept-organization-invitation audit event once The event was written twice per accepted organization invitation. The backend submitted it, and the browser then re-submitted a copy of the props that the backend had already put in the response under `:organization-invitation-audit` (`handle-token :team-invitation` in `verify-token.cljs`). Both rows carried the same name with different prop vocabularies, and the browser copy only existed when the browser finished the flow. Emit the event from the backend only. It now also carries the three props that lived in the browser copy: the organization member count before the add, the add source, and whether the invitee also joined a team. The origin moves to the event context as `:event-origin`. The response no longer includes `:organization-invitation-audit`, so the browser stops emitting the event and `verify-token.cljs` drops its `app.main.data.event` require. The `accept-*` events of this command now share one prop vocabulary: `:profile-id` for the accepting profile, `:invited-by` for the inviter and `:profile-email` for the email, replacing the mix of `:user-id`/`:user-who-send-invitation` and `:email`. Audit consumers of `accept-organization-invitation` now see one row per acceptance instead of two, and must read the new prop names. AI-assisted-by: space-bunny-free --- .serena/memories/backend/audit-log.md | 3 +- backend/src/app/rpc/commands/verify_token.clj | 77 ++++++++-------- backend/test/backend_tests/rpc_team_test.clj | 89 ++++++++----------- .../backend_tests/tasks_telemetry_test.clj | 28 ++++++ .../src/app/main/ui/auth/verify_token.cljs | 13 +-- .../frontend_tests/data/nitrate_test.cljs | 29 ------ 6 files changed, 107 insertions(+), 132 deletions(-) diff --git a/.serena/memories/backend/audit-log.md b/.serena/memories/backend/audit-log.md index 825d9a2162..ccd4153655 100644 --- a/.serena/memories/backend/audit-log.md +++ b/.serena/memories/backend/audit-log.md @@ -19,6 +19,7 @@ Penpot records what users do as events in the Postgres `audit_log` table. There - 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. +- `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`) @@ -50,4 +51,4 @@ Penpot records what users do as events in the Postgres `audit_log` table. There ## 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. -- 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. diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index 3c893dc6b2..d98c16dc00 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -254,12 +254,6 @@ "direct-organization-invitation" "team-invitation")) - organization-event-origin - (when organization-id-on-add - (if organization-id - "organization-invitation-acceptance" - "team-invitation-acceptance")) - organization-member-count-before (when organization-id-on-add (count @@ -300,46 +294,45 @@ (-> (audit/event-from-rpc-params params) (assoc :profile-id created-by) (assoc :name "accept-team-invitation-from") - (assoc :props (assoc props - :profile-id (:id profile) - :email (:email profile))))))) + (assoc :props (-> props + (assoc :invited-by (:created-by invitation)) + (assoc :profile-id (:id profile)) + (assoc :profile-email (:email profile)) + (audit/clean-props))))))) (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 - (audit/submit - cfg - (-> (audit/event-from-rpc-params params) - (assoc :name "accept-organization-invitation") - (assoc :props - (-> props - (assoc :organization-id organization-id-on-add - :user-id (:id profile) - :user-who-send-invitation (:created-by invitation)) - (audit/clean-props)))))) + (audit/submit cfg (-> (audit/event-from-rpc-params params) + (assoc :name "accept-organization-invitation") + (assoc :props + (-> props + (assoc :organization-id organization-id-on-add) + (assoc :invited-by (:created-by invitation)) + (assoc :profile-id (:id profile)) + (assoc :profile-email (:email profile)) + (assoc :organization-member-add-source + 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 :state :created) - ;; when the invitation is to an organization, instead of a team, add the - ;; accepted-team-id as :organization-team-id - (:organization-id claims) - (assoc :organization-team-id accepted-team-id) - - organization-id-on-add - (assoc :organization-invitation-audit - {:origin organization-event-origin - :props - (-> props - (assoc :organization-id organization-id-on-add - :organization-member-add-source organization-add-source - :belongs-to-team-on-add (boolean team-id) - :user-id (:id profile) - :user-who-send-invitation (:created-by invitation) - :organization-member-count-before - organization-member-count-before) - (audit/clean-props))})) - ;; The response carries the inviter's profile-id, so the - ;; audit event has to name the accepting profile explicitly - ;; or the invitation gets logged against the wrong user. - (rph/with-meta {::audit/profile-id (:id profile)})))))) + (cond-> (assoc claims :state :created) + ;; when the invitation is to an organization, instead of a team, add the + ;; accepted-team-id as :organization-team-id + (:organization-id claims) + (assoc :organization-team-id accepted-team-id) + ;; The response carries the inviter's profile-id, so the + ;; audit event has to name the accepting profile explicitly + ;; or the invitation gets logged against the wrong user. + :always + (rph/with-meta {::audit/profile-id (:id profile)})))))) (do ;; If the user is not logged-in and the invitation has been canceled diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 61acf23cb8..1c30415db2 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -492,13 +492,14 @@ (th/command! {::th/type :verify-token ::rpc/profile-id (:id invitee) :token token})) - organization-event - (fn [] + emitted-events + (fn [event-name] (->> (:call-args-list @audit-mock) (map second) - (filter #(= "accept-organization-invitation" (:name %))) - first)) - frontend-event (atom nil)] + (filter #(= event-name (:name %))))) + organization-event + (fn [] + (first (emitted-events "accept-organization-invitation")))] (db/insert! (:app.db/pool th/*system*) :team-invitation @@ -520,32 +521,22 @@ (fn [& _] default-team-id)] (let [out (verify! direct-token)] (t/is (th/success? out)) - (reset! frontend-event - (get-in out [:result :organization-invitation-audit])))) + (t/is (not (contains? (:result out) :organization-invitation-audit))))) (let [event (organization-event)] (t/is (= organization-id (get-in event [:props :organization-id]))) - (t/is (= (:id invitee) (get-in event [:props :user-id]))) - (t/is (= (:id inviter) - (get-in event [:props :user-who-send-invitation]))) - (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 (= (:id invitee) (get-in event [:props :profile-id]))) + (t/is (= (:id inviter) (get-in event [:props :invited-by]))) + (t/is (= (:email invitee) (get-in event [:props :profile-email]))) (t/is (= :editor (get-in event [:props :role]))) (t/is (uuid? (get-in event [:props :invitation-id]))) - (t/is (= "organization-invitation-acceptance" - (:origin @frontend-event))) - (t/is (= organization-id - (get-in @frontend-event [:props :organization-id]))) - (t/is (= (:id invitee) - (get-in @frontend-event [:props :user-id]))) - (t/is (= (:id inviter) - (get-in @frontend-event [:props :user-who-send-invitation]))) (t/is (= "direct-organization-invitation" - (get-in @frontend-event [:props :organization-member-add-source]))) - (t/is (false? (get-in @frontend-event [:props :belongs-to-team-on-add]))) - (t/is (= 3 - (get-in @frontend-event [:props :organization-member-count-before]))) + (get-in event [:props :organization-member-add-source]))) + (t/is (false? (get-in event [:props :belongs-to-team-on-add]))) + (t/is (= 3 (get-in event [:props :organization-member-count-before]))) + (t/is (not (contains? (:props event) :invitation-origin))) + (t/is (not (contains? (:context event) :event-origin))) + (t/is (= 1 (count (emitted-events "accept-organization-invitation")))) (t/is (not-any? #(contains? #{"accept-team-invitation" "accept-team-invitation-from"} (:name (second %))) @@ -572,8 +563,7 @@ teams/add-profile-to-team! (fn [& _] nil)] (let [out (verify! team-token)] (t/is (th/success? out)) - (reset! frontend-event - (get-in out [:result :organization-invitation-audit])))) + (t/is (not (contains? (:result out) :organization-invitation-audit))))) (let [events (mapv second (:call-args-list @audit-mock)) event (organization-event)] @@ -581,26 +571,27 @@ (t/is (some #(= "accept-team-invitation-from" (:name %)) events)) (t/is (= (:id team) (get-in event [:props :team-id]))) (t/is (= organization-id (get-in event [:props :organization-id]))) - (t/is (= (:id invitee) (get-in event [:props :user-id]))) - (t/is (= (:id inviter) - (get-in event [:props :user-who-send-invitation]))) - (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 (= "team-invitation-acceptance" - (:origin @frontend-event))) - (t/is (= (:id team) (get-in @frontend-event [:props :team-id]))) - (t/is (= organization-id - (get-in @frontend-event [:props :organization-id]))) - (t/is (= (:id invitee) - (get-in @frontend-event [:props :user-id]))) - (t/is (= (:id inviter) - (get-in @frontend-event [:props :user-who-send-invitation]))) + (t/is (= (:id invitee) (get-in event [:props :profile-id]))) + (t/is (= (:id inviter) (get-in event [:props :invited-by]))) (t/is (= "team-invitation" - (get-in @frontend-event [:props :organization-member-add-source]))) - (t/is (true? (get-in @frontend-event [:props :belongs-to-team-on-add]))) - (t/is (= 5 - (get-in @frontend-event [:props :organization-member-count-before])))) + (get-in event [:props :organization-member-add-source]))) + (t/is (true? (get-in event [:props :belongs-to-team-on-add]))) + (t/is (= 5 (get-in event [:props :organization-member-count-before]))) + (t/is (not (contains? (:props event) :invitation-origin))) + (t/is (not (contains? (:context event) :event-origin))) + (t/is (= 1 (count (emitted-events "accept-organization-invitation"))))) + + (let [from-event (first (emitted-events "accept-team-invitation-from"))] + (t/is (= (:id team) (get-in from-event [:props :team-id]))) + (t/is (= :editor (get-in from-event [:props :role]))) + (t/is (uuid? (get-in from-event [:props :invitation-id]))) + (t/is (= (:id inviter) (get-in from-event [:props :invited-by]))) + (t/is (= (:id invitee) (get-in from-event [:props :profile-id]))) + (t/is (= (:email invitee) (get-in from-event [:props :profile-email]))) + (t/is (not (contains? (get-in from-event [:props]) :email))) + (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) (db/insert! (:app.db/pool th/*system*) @@ -621,13 +612,11 @@ teams/add-profile-to-team! (fn [& _] nil)] (let [out (verify! team-token)] (t/is (th/success? out)) - (reset! frontend-event - (get-in out [:result :organization-invitation-audit])))) + (t/is (not (contains? (:result out) :organization-invitation-audit))))) (let [events (mapv second (:call-args-list @audit-mock))] (t/is (some #(= "accept-team-invitation" (:name %)) events)) - (t/is (not-any? #(= "accept-organization-invitation" (:name %)) events)) - (t/is (nil? @frontend-event)))))) + (t/is (not-any? #(= "accept-organization-invitation" (:name %)) events)))))) (t/deftest create-team-invitations-with-email-verification-disabled (with-mocks [mock {:target 'app.email/send! :return nil}] diff --git a/backend/test/backend_tests/tasks_telemetry_test.clj b/backend/test/backend_tests/tasks_telemetry_test.clj index 01f72977ed..e3a8570832 100644 --- a/backend/test/backend_tests/tasks_telemetry_test.clj +++ b/backend/test/backend_tests/tasks_telemetry_test.clj @@ -754,6 +754,34 @@ (t/is (not (contains? (:props result) :route))) (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 (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props) organization-id (uuid/next) diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs index 52a17761c6..3c1e7902e4 100644 --- a/frontend/src/app/main/ui/auth/verify_token.cljs +++ b/frontend/src/app/main/ui/auth/verify_token.cljs @@ -9,7 +9,6 @@ [app.config :as cf] [app.main.data.auth :as da] [app.main.data.common :as dcm] - [app.main.data.event :as ev] [app.main.data.notifications :as ntf] [app.main.data.profile :as du] [app.main.repo :as rp] @@ -44,14 +43,8 @@ (st/emit! (da/login-from-token tdata))) (defmethod handle-token :team-invitation - [{:keys [state team-id organization-team-id organization-name invitation-token] :as tdata}] - (when-let [{:keys [origin props]} (:organization-invitation-audit tdata)] - (st/emit! - (ev/event - (assoc props - ::ev/name "accept-organization-invitation" - ::ev/origin origin)))) - + [{:keys [state team-id organization-team-id organization-name invitation-token + redirect-to]}] (case state :created (if organization-team-id @@ -65,7 +58,7 @@ (ntf/success (tr "auth.notifications.team-invitation-accepted")))) :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}))))) (defmethod handle-token :default diff --git a/frontend/test/frontend_tests/data/nitrate_test.cljs b/frontend/test/frontend_tests/data/nitrate_test.cljs index 2c59912c70..de5c56d92f 100644 --- a/frontend/test/frontend_tests/data/nitrate_test.cljs +++ b/frontend/test/frontend_tests/data/nitrate_test.cljs @@ -11,8 +11,6 @@ [app.main.data.event :as ev] [app.main.data.nitrate :as dnt] [app.main.data.nitrate-audit :as nitrate-audit] - [app.main.store :as st] - [app.main.ui.auth.verify-token :as verify-token] [cljs.test :as t :include-macros true])) (t/deftest account-age-days-test @@ -115,33 +113,6 @@ (t/is (contains? event :days-since-member-added)) (t/is (nil? (:days-since-member-added event))))))) -(t/deftest accept-organization-invitation-audit-event-test - (let [emitted (atom []) - props {:team-id "team-1" - :organization-id "organization-1" - :role :editor - :invitation-id "invitation-1" - :organization-member-add-source "team-invitation" - :belongs-to-team-on-add true - :organization-member-count-before 4}] - (with-redefs [st/emit! (fn - ([event] - (swap! emitted conj event)) - ([event & events] - (swap! emitted into (cons event events))))] - (verify-token/handle-token - {:iss :team-invitation - :state :created - :team-id "team-1" - :organization-invitation-audit - {:origin "team-invitation-acceptance" - :props props}})) - - (let [event @(first @emitted)] - (t/is (= "accept-organization-invitation" (::ev/name event))) - (t/is (= "team-invitation-acceptance" (::ev/origin event))) - (t/is (= props (dissoc event ::ev/name ::ev/origin)))))) - (t/deftest build-admin-console-url-preserves-public-uri-subpath (t/testing "builds admin console routes below the configured Penpot subpath" (let [public-uri (u/uri "https://example.com/penpot/")]