From 9d898b8b31f8313b93d1f1f34b601f4d4e65ebf7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 5 Nov 2026 08:45:01 +0100 Subject: [PATCH] :wrench: Normalize the tenant to lowercase (#12843) An operator writing `PENPOT_TENANT=ACME` and one writing `acme` name the same tenant: the value is compared as an exact string in the keys and rows it lands in, and one uppercase letter would otherwise split one tenant into two. The value is normalized when the configuration is read, so nothing downstream has to care about case, and the validated form accepts only lowercase labels from then on. AI-assisted-by: deepseek-v4.1-flash --- backend/src/app/config.clj | 20 +++++++++++++++++--- backend/test/backend_tests/config_test.clj | 11 +++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 515f3bec49..99f47ea40c 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -111,8 +111,14 @@ :ssrf-extra-blocked-cidrs #{}}) (def schema:tenant - "Tenant identifier: hostname-label-style (letters, digits and - hyphens). It is interpolated into Redis keys separated by dots (msgbus + "Tenant identifier: hostname-label-style, lowercase (letters, digits + and hyphens). The value is normalized to lowercase when the + configuration is read, so an operator writing `PENPOT_TENANT=ACME` and + one writing `acme` name the same tenant: the value is compared as an + exact string in the keys and rows it lands in, and one uppercase + letter would otherwise split one tenant into two. + + It is interpolated into Redis keys separated by dots (msgbus topics, rate-limit buckets, exporter job keys, which are also matched with a glob pattern), so `.` and whitespace are rejected at startup because one tenant would then be able to read or overwrite another's @@ -125,7 +131,14 @@ tenant asking for it, and a tenant that has one would have to be renamed. Tenants using those characters must be renamed before upgrading." - [:re #"^[A-Za-z0-9-]+$"]) + [:re #"^[a-z0-9-]+$"]) + +(defn- normalize-tenant + "Lowercase the configured tenant before it is validated, so + `PENPOT_TENANT=ACME` and `acme` mean the same tenant everywhere." + [config] + (cond-> config + (string? (:tenant config)) (update :tenant str/lower))) (def schema:config (do #_sm/optional-keys @@ -401,6 +414,7 @@ [& {:keys [prefix default] :or {prefix "penpot"}}] (->> (read-env prefix) (merge default) + (normalize-tenant) (decode-config))) (def version diff --git a/backend/test/backend_tests/config_test.clj b/backend/test/backend_tests/config_test.clj index af06598962..74a159a703 100644 --- a/backend/test/backend_tests/config_test.clj +++ b/backend/test/backend_tests/config_test.clj @@ -6,10 +6,21 @@ (ns backend-tests.config-test (:require + [app.common.schema :as sm] [app.common.time :as ct] [app.config :as cf] [clojure.test :as t])) +(t/deftest tenant-is-normalized-to-lowercase + (t/testing "an uppercase tenant names the same tenant" + (t/is (= "acme" (:tenant (#'cf/normalize-tenant {:tenant "ACME"})))) + (t/is (= "acme" (:tenant (#'cf/normalize-tenant {:tenant "acme"}))))) + (t/testing "a config without a tenant is left untouched" + (t/is (= {:x 1} (#'cf/normalize-tenant {:x 1})))) + (t/testing "the validated form is lowercase-only" + (t/is (not (sm/validate cf/schema:tenant "ACME"))) + (t/is (sm/validate cf/schema:tenant "acme")))) + (t/deftest get-public-uri-normalizes-base (t/testing "trailing slash is ensured with and without subpath" (doseq [[base expected] [["http://localhost:3449" "http://localhost:3449/"]