This is an automated email from the ASF dual-hosted git repository.

sergehuber pushed a commit to branch UNOMI-1013-tenant-cors-origins
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit 323dc4461e7a6a7cbc82ffa68bdb28789c1927ee
Author: Serge Huber <[email protected]>
AuthorDate: Wed Oct 21 11:27:11 2026 +0200

    UNOMI-1013: Let each tenant list the websites that can use its tracker
    
    Tenants are created and deleted at runtime, so the operator's website
    list cannot keep up with them. Each tenant can now list its own origins
    in its publicCorsAllowedOrigins property, set by a system administrator
    through /cxs/tenants, with the same matching rules. A tenant's origins
    only apply to requests made with that tenant's public key; the operator
    list still applies to everyone, and tenants cannot change it.
    
    A preflight carries no API key, so it is answered for an origin the
    operator or any tenant lists; the actual request is then checked
    against its own tenant, for both the visitor cookie and the credentialed
    CORS response. The tenant is read from the request message, which a
    response filter reaches through the exchange.
    
    Tenant origins live in an in-memory snapshot: requests never load
    tenants. It is loaded at activation, reloaded at once when a tenant is
    changed through this node or removed, refreshed every 30 seconds, and
    reloaded (at most every 5 seconds) when an unknown tenant or origin
    shows up, so a tenant created on another node works on its first
    request. Invalid origins are refused with 400 when the tenant is saved.
    org.apache.unomi.public.cors.tenantOrigins 
(UNOMI_PUBLIC_CORS_TENANT_ORIGINS,
    default true) turns tenant lists off.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../test/java/org/apache/unomi/itests/AllITs.java  |   1 +
 .../unomi/itests/TenantTrackerOriginsIT.java       | 233 ++++++++++++++++
 manual/src/main/asciidoc/configuration.adoc        |  32 ++-
 manual/src/main/asciidoc/whats-new.adoc            |   1 +
 .../main/resources/etc/custom.system.properties    |   4 +
 .../unomi/rest/cors/PublicTrackerCorsFilter.java   |  17 +-
 .../unomi/rest/cors/PublicTrackerCorsPolicy.java   |  80 +++++-
 .../unomi/rest/cors/PublicTrackerSettings.java     | 295 +++++++++++++++++++--
 .../rest/service/impl/RestServiceUtilsImpl.java    |   2 +-
 .../apache/unomi/rest/tenants/TenantEndpoint.java  |  27 ++
 .../config/ShippedProfileCookieConfigTest.java     |   5 +
 .../rest/cors/PublicTrackerCorsFilterTest.java     |  38 +++
 .../rest/cors/PublicTrackerCorsPolicyTest.java     |  31 +++
 .../rest/cors/PublicTrackerSettingsTenantTest.java | 234 ++++++++++++++++
 .../unomi/rest/cors/PublicTrackerSettingsTest.java |   5 +-
 .../tenants/TenantEndpointTrackerOriginsTest.java  | 122 +++++++++
 .../org/apache/unomi/web/servlets/WebConfig.java   |   6 +
 .../src/main/resources/org.apache.unomi.web.cfg    |   2 +
 .../apache/unomi/web/servlets/WebConfigTest.java   |   3 +
 19 files changed, 1110 insertions(+), 28 deletions(-)

diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java 
b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
index 10247e2c8..dfe8b3a9c 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -60,6 +60,7 @@ import org.junit.runners.Suite.SuiteClasses;
         IncrementPropertyIT.class,
         InputValidationIT.class,
         PublicTrackerCorsIT.class,
+        TenantTrackerOriginsIT.class,
         RestCreateValidationIT.class,
         ModifyConsentIT.class,
         PatchIT.class,
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/TenantTrackerOriginsIT.java 
b/itests/src/test/java/org/apache/unomi/itests/TenantTrackerOriginsIT.java
new file mode 100644
index 000000000..e507ecbe6
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/TenantTrackerOriginsIT.java
@@ -0,0 +1,233 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.itests;
+
+import org.apache.http.Header;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpOptions;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpUriRequest;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.util.EntityUtils;
+import org.apache.unomi.api.tenants.ApiKey;
+import org.apache.unomi.api.tenants.Tenant;
+import org.apache.unomi.itests.tools.httpclient.HttpClientThatWaitsForUnomi;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
+import org.ops4j.pax.exam.spi.reactors.PerSuite;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * UNOMI-1013: each tenant lists its own website origins in its {@code 
publicCorsAllowedOrigins} property,
+ * set through the tenant API when the tenant is provisioned, with no change 
to the operator configuration.
+ * The origin only applies to requests made with that tenant's key.
+ */
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class TenantTrackerOriginsIT extends BaseIT {
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(TenantTrackerOriginsIT.class);
+
+    private static final String CONTEXT_JSON = "/cxs/context.json";
+    private static final String TENANT_ORIGINS_KEY = 
"publicCorsAllowedOrigins";
+
+    private final List<String> createdTenants = new ArrayList<>();
+    private String sessionId;
+
+    @Before
+    public void newSession() {
+        // Session ids are limited to 60 characters by the request schema.
+        sessionId = "tto-it-" + UUID.randomUUID();
+    }
+
+    @After
+    public void tearDown() {
+        for (String tenantId : createdTenants) {
+            try {
+                tenantService.deleteTenant(tenantId);
+            } catch (RuntimeException e) {
+                LOGGER.warn("Could not delete test tenant {}", tenantId, e);
+            }
+        }
+        persistenceService.refresh();
+        TestUtils.removeAllEvents(definitionsService, persistenceService, 
true, tenantService, executionContextManager);
+        TestUtils.removeAllSessions(definitionsService, persistenceService, 
true, tenantService, executionContextManager);
+        TestUtils.removeAllProfiles(definitionsService, persistenceService, 
true, tenantService, executionContextManager);
+    }
+
+    @Test
+    public void aTenantsOwnOriginGetsCredentialsAndKeepsItsVisitor() throws 
Exception {
+        String origin = "https://tenant-a-"; + shortId() + ".example";
+        String key = createTenantThroughApi(origin);
+
+        try (CloseableHttpResponse preflight = execute(preflight(origin))) {
+            assertEquals(origin, header(preflight, 
"Access-Control-Allow-Origin"));
+            assertEquals("true", header(preflight, 
"Access-Control-Allow-Credentials"));
+        }
+
+        String[] visitor = newVisitor(key);
+        HttpPost request = contextRequest(key);
+        request.setHeader("Origin", origin);
+        request.setHeader("Sec-Fetch-Site", "cross-site");
+        request.setHeader("Cookie", visitor[1]);
+        try (CloseableHttpResponse response = execute(request)) {
+            assertEquals(origin, header(response, 
"Access-Control-Allow-Origin"));
+            assertEquals("true", header(response, 
"Access-Control-Allow-Credentials"));
+            assertEquals(visitor[0], profileId(response));
+        }
+    }
+
+    @Test
+    public void anotherTenantsOriginDoesNotUseTheVisitorCookie() throws 
Exception {
+        String origin = "https://tenant-a-"; + shortId() + ".example";
+        createTenantThroughApi(origin);
+        String otherKey = createTenantThroughApi(null);
+
+        String[] visitor = newVisitor(otherKey);
+        HttpPost request = contextRequest(otherKey);
+        request.setHeader("Origin", origin);
+        request.setHeader("Sec-Fetch-Site", "cross-site");
+        request.setHeader("Cookie", visitor[1]);
+        try (CloseableHttpResponse response = execute(request)) {
+            assertEquals("*", header(response, "Access-Control-Allow-Origin"));
+            assertNull(header(response, "Access-Control-Allow-Credentials"));
+            assertNull("The visitor's cookie must not be replaced", 
header(response, "Set-Cookie"));
+            assertNotEquals(visitor[0], profileId(response));
+        }
+    }
+
+    @Test
+    public void invalidOriginsAreRejectedWhenTheTenantIsCreated() throws 
Exception {
+        String tenantId = "it-cors-" + shortId();
+        HttpPost create = new HttpPost(getFullUrl("/cxs/tenants"));
+        create.setEntity(new StringEntity("{\"requestedId\":\"" + tenantId + 
"\",\"properties\":{\"" + TENANT_ORIGINS_KEY
+                + "\":[\"https://*.example\"]}}";, 
ContentType.APPLICATION_JSON));
+        try (CloseableHttpResponse response = executeHttpRequest(create, 
AuthType.JAAS_ADMIN)) {
+            assertEquals(400, response.getStatusLine().getStatusCode());
+        }
+        assertNull(tenantService.getTenant(tenantId));
+    }
+
+    @Test
+    public void aTenantCreatedElsewhereWorksWithoutRestart() throws Exception {
+        // Saved directly, as another node would, so this node only learns 
about it from the request.
+        String origin = "https://tenant-c-"; + shortId() + ".example";
+        String tenantId = "it-cors-" + shortId();
+        Tenant tenant = tenantService.createTenant(tenantId, 
Collections.singletonMap(TENANT_ORIGINS_KEY, origin));
+        createdTenants.add(tenant.getItemId());
+        String key = tenantService.generateApiKeyWithType(tenantId, 
ApiKey.ApiKeyType.PUBLIC, null).getPlainTextKey();
+        persistenceService.refresh();
+
+        // Reloads for unknown origins are rate-limited, so allow a few 
seconds.
+        keepTrying("A tenant created elsewhere should be picked up without a 
restart",
+                () -> preflightAllowsCredentials(origin), allowed -> allowed, 
DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        HttpPost request = contextRequest(key);
+        request.setHeader("Origin", origin);
+        request.setHeader("Sec-Fetch-Site", "cross-site");
+        try (CloseableHttpResponse response = execute(request)) {
+            assertEquals(origin, header(response, 
"Access-Control-Allow-Origin"));
+            assertEquals("true", header(response, 
"Access-Control-Allow-Credentials"));
+        }
+    }
+
+    /** Creates a tenant through {@code /cxs/tenants}, as provisioning would, 
and returns its public key. */
+    private String createTenantThroughApi(String origin) throws Exception {
+        String tenantId = "it-cors-" + shortId();
+        String properties = origin == null ? "{}" : "{\"" + TENANT_ORIGINS_KEY 
+ "\":[\"" + origin + "\"]}";
+        HttpPost create = new HttpPost(getFullUrl("/cxs/tenants"));
+        create.setEntity(new StringEntity("{\"requestedId\":\"" + tenantId + 
"\",\"properties\":" + properties + "}",
+                ContentType.APPLICATION_JSON));
+        try (CloseableHttpResponse response = executeHttpRequest(create, 
AuthType.JAAS_ADMIN)) {
+            assertEquals(EntityUtils.toString(response.getEntity()), 200, 
response.getStatusLine().getStatusCode());
+        }
+        createdTenants.add(tenantId);
+        String key = tenantService.generateApiKeyWithType(tenantId, 
ApiKey.ApiKeyType.PUBLIC, null).getPlainTextKey();
+        persistenceService.refresh();
+        return key;
+    }
+
+    private boolean preflightAllowsCredentials(String origin) {
+        try (CloseableHttpResponse response = execute(preflight(origin))) {
+            return origin.equals(header(response, 
"Access-Control-Allow-Origin"))
+                    && "true".equals(header(response, 
"Access-Control-Allow-Credentials"));
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    /** Returns the profile id and the cookie of a visitor of this tenant 
created without any Origin header. */
+    private String[] newVisitor(String key) throws Exception {
+        try (CloseableHttpResponse response = execute(contextRequest(key))) {
+            Header setCookie = response.getFirstHeader("Set-Cookie");
+            assertNotNull("A new visitor gets a profile cookie", setCookie);
+            return new String[]{profileId(response), 
setCookie.getValue().split(";")[0]};
+        }
+    }
+
+    private HttpPost contextRequest(String key) throws Exception {
+        HttpPost request = new HttpPost(getFullUrl(CONTEXT_JSON));
+        request.setHeader("X-Unomi-Api-Key", key);
+        request.setEntity(new StringEntity("{\"sessionId\":\"" + sessionId + 
"\"}", ContentType.APPLICATION_JSON));
+        return request;
+    }
+
+    private HttpOptions preflight(String origin) throws Exception {
+        HttpOptions request = new HttpOptions(getFullUrl(CONTEXT_JSON));
+        request.setHeader("Origin", origin);
+        request.setHeader("Access-Control-Request-Method", "POST");
+        request.setHeader("Access-Control-Request-Headers", 
"content-type,x-unomi-api-key");
+        return request;
+    }
+
+    private static CloseableHttpResponse execute(HttpUriRequest request) 
throws IOException {
+        // The key, when any, is already on the request; no default key is 
added.
+        return HttpClientThatWaitsForUnomi.doRequest(request, -1, false, 
false);
+    }
+
+    private String profileId(CloseableHttpResponse response) throws 
IOException {
+        assertEquals(200, response.getStatusLine().getStatusCode());
+        return 
getObjectMapper().readTree(EntityUtils.toString(response.getEntity())).get("profileId").asText();
+    }
+
+    private static String header(CloseableHttpResponse response, String name) {
+        Header header = response.getFirstHeader(name);
+        return header == null ? null : header.getValue();
+    }
+
+    private static String shortId() {
+        return UUID.randomUUID().toString().substring(0, 8);
+    }
+}
diff --git a/manual/src/main/asciidoc/configuration.adoc 
b/manual/src/main/asciidoc/configuration.adoc
index 17ed1cf3c..42af3ef0b 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -293,6 +293,7 @@ Changing the Karaf admin password is done by setting 
`UNOMI_ROOT_PASSWORD` or by
 
 Apache Unomi supports multi-tenancy, allowing multiple organizations to use 
the same Unomi instance while keeping their data completely isolated. Each 
tenant has its own set of API keys for authentication.
 
+[[_creating_and_managing_tenants]]
 ==== Creating and Managing Tenants
 
 IMPORTANT: All tenant management operations (create, list, update, delete, API 
key management) are restricted to administrators only and require JAAS 
authentication. These endpoints cannot be accessed using tenant API keys.
@@ -312,7 +313,8 @@ curl -X POST "http://localhost:8181/cxs/tenants"; \
       "name": "My Company",
       "description": "My Company tenant",
       "address": "123 Main St",
-      "country": "USA"
+      "country": "USA",
+      "publicCorsAllowedOrigins": ["https://www.mycompany.com";, 
"https://shop.mycompany.com";]
     }
   }'
 
@@ -553,7 +555,7 @@ curl -X POST "http://localhost:8181/cxs/segments"; \
 The public tracker endpoints (`/cxs/context.js`, `/cxs/context.json`, 
`/cxs/eventcollector` and `/cxs/client/*`) accept requests from any origin. The 
visitor cookie (`context-profile-id` by default), which identifies the visitor, 
is only used and set for:
 
 * pages served from Unomi's own origin;
-* origins the operator lists in `org.apache.unomi.public.cors.allowedOrigins`;
+* origins the operator lists in `org.apache.unomi.public.cors.allowedOrigins`, 
or that the tenant of the request lists in its own settings;
 * requests that carry no sign of coming from a page on another site, such as 
server-to-server calls.
 
 For any other page, Unomi answers without credentialed CORS headers and 
ignores the cookie: the request gets a new anonymous profile, and the visitor's 
cookie is not replaced.
@@ -587,6 +589,7 @@ A response can only set the cookie on Unomi's own host, or 
on a parent domain se
 
org.apache.unomi.public.cors.allowedOrigins=${env:UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS:-}
 
org.apache.unomi.profile.cookie.sameSite=${env:UNOMI_PROFILE_COOKIE_SAMESITE:-Lax}
 
org.apache.unomi.public.cors.matchReferer=${env:UNOMI_PUBLIC_CORS_MATCH_REFERER:-false}
+org.apache.unomi.public.cors.tenantOrigins=${env:UNOMI_PUBLIC_CORS_TENANT_ORIGINS:-true}
 ----
 
 Example:
@@ -609,6 +612,7 @@ export UNOMI_PROFILE_COOKIE_SAMESITE=None
 * The list is empty by default: no other origin may use the cookie.
 * A listed origin is named exactly in the response, with 
`Access-Control-Allow-Credentials: true` and `Vary: Origin`, preflight requests 
included. An unlisted origin gets `Access-Control-Allow-Origin: *` without 
credentials.
 * `SameSite` accepts `Lax` (default), `Strict` and `None`. `None` is used only 
when the cookie is `Secure` and the list is not empty; otherwise Unomi uses 
`Lax` and logs a warning.
+* `tenantOrigins` (default `true`) lets each tenant list its own websites; see 
<<_public_tracker_tenant_origins,Websites listed by each tenant>>. Only `false` 
turns it off.
 * `matchReferer` (default `false`) lets listed websites that embed the tracker 
with a `<script>` tag keep their visitors; see 
<<_public_tracker_script_tag,Embedding `context.js` with a script tag>>. Only 
`true` turns it on; any other value, and `true` with no origin listed, is 
logged as a warning.
 * Changes are applied without a restart and are parsed once, not on every 
request.
 
@@ -620,6 +624,28 @@ Do not:
 * turn on `matchReferer` "to make it work": it trusts everything the listed 
websites frame (see <<_public_tracker_script_tag,Embedding `context.js` with a 
script tag>>);
 * set `SameSite=None` for a same-site setup: `Lax` already works there.
 
+[[_public_tracker_tenant_origins]]
+===== Websites listed by each tenant
+
+On a server with many tenants, each tenant lists its own websites, so tenants 
can be created and deleted without changing the server configuration. The list 
is the tenant's `publicCorsAllowedOrigins` property, a JSON array of origins or 
a comma-separated string, set by a system administrator through `/cxs/tenants` 
when the tenant is created or updated (see 
<<_creating_and_managing_tenants,Creating and Managing Tenants>>):
+
+[source,bash]
+----
+# PUT replaces the whole tenant, API keys included: read it, change the 
property, send it back.
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" 
"http://localhost:8181/cxs/tenants/mytenant"; \
+  | jq '.properties.publicCorsAllowedOrigins = ["https://www.mycompany.com";]' \
+  | curl -X PUT "http://localhost:8181/cxs/tenants/mytenant"; \
+      -u "karaf:$UNOMI_ROOT_PASSWORD" -H "Content-Type: application/json" 
--data-binary @-
+----
+
+* The origins follow the same rules as the server list. An invalid entry is 
refused with `400` when the tenant is saved, so a provisioning script fails 
instead of a website silently losing its visitors.
+* A tenant's origins only apply to requests made with that tenant's public API 
key. The same website calling with another tenant's key is served without the 
visitor cookie.
+* The server list still applies to every tenant. Tenants can only add origins 
for themselves; they cannot change the server settings.
+* A browser's preflight request carries no API key, so it is answered for any 
origin the server or a tenant lists. That only lets the browser send the real 
request, which is then checked against its own tenant.
+* A change made through `/cxs/tenants` applies at once on the node that 
handled it. Other nodes pick up a new tenant or origin on its first request, 
and other changes, such as a removed origin, within 30 seconds.
+* `SameSite`, `HttpOnly` and the cookie domain stay server settings: all 
tenants share one visitor cookie on a Unomi host.
+* `org.apache.unomi.public.cors.tenantOrigins=false` 
(`UNOMI_PUBLIC_CORS_TENANT_ORIGINS`) turns tenant lists off; only the server 
list then counts.
+
 ===== How Unomi recognises the page
 
 Current browsers send `Origin` on every cross-origin request and on every 
`POST`, same-origin ones included, so its presence alone does not mean another 
site. Unomi decides as follows:
@@ -662,7 +688,7 @@ In 2.x and 3.x the public endpoints allowed credentialed 
CORS for every origin,
 |Nothing.
 
 |Website on another subdomain using the cookie
-|List the website origin. `SameSite=Lax` is enough.
+|List the website origin, in the server list or in the tenant's own list. 
`SameSite=Lax` is enough.
 
 |Website on another site sending body `profileId`
 |List the website origin, serve Unomi over HTTPS and set `SameSite=None`, or 
serve Unomi from the website's origin. Expect the cookie to be blocked in 
Safari.
diff --git a/manual/src/main/asciidoc/whats-new.adoc 
b/manual/src/main/asciidoc/whats-new.adoc
index edbea6213..1074100ee 100644
--- a/manual/src/main/asciidoc/whats-new.adoc
+++ b/manual/src/main/asciidoc/whats-new.adoc
@@ -38,6 +38,7 @@ The public tracker endpoints answer any origin, but only the 
operator's list of
 
 * Setup guide: <<_public_tracker_origins,Websites on other origins>>
 * Upgrading from 2.x or 3.x: <<_public_tracker_compatibility,compatibility 
table>>
+* Each tenant can list its own websites in its `publicCorsAllowedOrigins` 
property, so tenants created at runtime need no server change: 
<<_public_tracker_tenant_origins,Websites listed by each tenant>> 
(https://issues.apache.org/jira/browse/UNOMI-1013[UNOMI-1013])
 * Jira: https://issues.apache.org/jira/browse/UNOMI-1005[UNOMI-1005]
 
 ==== Cluster-aware task scheduler
diff --git a/package/src/main/resources/etc/custom.system.properties 
b/package/src/main/resources/etc/custom.system.properties
index 83eef22fe..da24317f7 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -333,6 +333,10 @@ 
org.apache.unomi.public.cors.allowedOrigins=${env:UNOMI_PUBLIC_CORS_ALLOWED_ORIG
 # Recognise a listed website that loads context.js with a <script> tag (no 
Origin header) by its Referer.
 # Keep false unless you need that: it also trusts anything the listed website 
frames, such as sandboxed ads.
 
org.apache.unomi.public.cors.matchReferer=${env:UNOMI_PUBLIC_CORS_MATCH_REFERER:-false}
+# Let each tenant add its own website origins, in its publicCorsAllowedOrigins 
tenant property (set by a system
+# administrator through /cxs/tenants). Tenant origins only apply to requests 
made with that tenant's key.
+# Only false turns this off.
+org.apache.unomi.public.cors.tenantOrigins=${env:UNOMI_PUBLIC_CORS_TENANT_ORIGINS:-true}
 #Allowed profile download formats, actually only csv (horizontal and 
vertical), json, text and yaml are allowed.
 
org.apache.unomi.profile.download.formats=${env:UNOMI_PROFILE_DOWNLOAD_FORMATS:-csv,yaml,json,text}
 # This setting allow for request size (Content-length) protection. Checking 
that the requests do not exceed the limit.
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilter.java 
b/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilter.java
index 5dcddfe3b..4d5d1062a 100644
--- a/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilter.java
+++ b/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilter.java
@@ -62,7 +62,7 @@ public class PublicTrackerCorsFilter extends 
CrossOriginResourceSharingFilter {
         if (origin == null || !headers.containsKey(ALLOW_ORIGIN)) {
             return;
         }
-        if (settings.policy().allows(origin)) {
+        if (allowsCredentials(requestContext, origin)) {
             headers.putSingle(ALLOW_ORIGIN, origin);
             headers.putSingle(ALLOW_CREDENTIALS, "true");
             if (!variesOnOrigin(headers)) {
@@ -75,6 +75,21 @@ public class PublicTrackerCorsFilter extends 
CrossOriginResourceSharingFilter {
         headers.remove(ALLOW_CREDENTIALS);
     }
 
+    /**
+     * A preflight carries no API key, so any origin the operator or a tenant 
lists is accepted there; the
+     * actual request, which carries the key, is checked against its own 
tenant.
+     */
+    private boolean allowsCredentials(ContainerRequestContext requestContext, 
String origin) {
+        boolean preflight = "OPTIONS".equals(requestContext.getMethod())
+                && 
requestContext.getHeaderString("Access-Control-Request-Method") != null;
+        return preflight ? settings.allowsPreflight(origin) : 
settings.policyForTenant(requestTenantId()).allows(origin);
+    }
+
+    /** The tenant of the current request; replaced in tests. */
+    String requestTenantId() {
+        return PublicTrackerSettings.requestTenantId();
+    }
+
     private static boolean variesOnOrigin(MultivaluedMap<String, Object> 
headers) {
         List<Object> vary = headers.get("Vary");
         if (vary != null) {
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicy.java 
b/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicy.java
index da1d364d3..fa1961a4d 100644
--- a/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicy.java
+++ b/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicy.java
@@ -17,6 +17,7 @@
 package org.apache.unomi.rest.cors;
 
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.LinkedHashSet;
 import java.util.List;
@@ -82,6 +83,75 @@ public final class PublicTrackerCorsPolicy {
         return new 
PublicTrackerCorsPolicy(Collections.unmodifiableSet(origins), matchReferer);
     }
 
+    /**
+     * The origins in a tenant's {@code publicCorsAllowedOrigins} property: a 
comma-separated string or a list
+     * of strings. Entries that are not valid origins are left out; {@link 
#tenantOriginProblems} reports them
+     * when the tenant is saved.
+     */
+    static Set<String> parseTenantOrigins(Object value) {
+        Set<String> origins = new LinkedHashSet<>();
+        for (String entry : tenantOriginEntries(value)) {
+            String origin = normalize(entry.endsWith("/") ? entry.substring(0, 
entry.length() - 1) : entry);
+            if (origin != null) {
+                origins.add(origin);
+            }
+        }
+        return Collections.unmodifiableSet(origins);
+    }
+
+    /**
+     * The entries of a tenant's {@code publicCorsAllowedOrigins} property 
that are not valid origins, or a
+     * message for a value that is neither a string nor a list of strings. 
Empty when the value is valid.
+     */
+    public static List<String> tenantOriginProblems(Object value) {
+        List<String> problems = new ArrayList<>();
+        if (value != null && !(value instanceof String) && !(value instanceof 
Collection)) {
+            problems.add("expected a comma-separated string or a list of 
origins");
+            return problems;
+        }
+        if (value instanceof Collection) {
+            for (Object item : (Collection<?>) value) {
+                if (!(item instanceof String)) {
+                    problems.add("expected origins as strings, got " + item);
+                }
+            }
+        }
+        for (String entry : tenantOriginEntries(value)) {
+            if (normalize(entry.endsWith("/") ? entry.substring(0, 
entry.length() - 1) : entry) == null) {
+                problems.add("'" + entry + "' is not scheme://host[:port] with 
http or https, without path or wildcard");
+            }
+        }
+        return problems;
+    }
+
+    private static List<String> tenantOriginEntries(Object value) {
+        List<String> entries = new ArrayList<>();
+        if (value instanceof String) {
+            for (String part : ((String) value).split(",")) {
+                if (!part.trim().isEmpty()) {
+                    entries.add(part.trim());
+                }
+            }
+        } else if (value instanceof Collection) {
+            for (Object item : (Collection<?>) value) {
+                if (item instanceof String && !((String) 
item).trim().isEmpty()) {
+                    entries.add(((String) item).trim());
+                }
+            }
+        }
+        return entries;
+    }
+
+    /** This policy with a tenant's origins added; the Referer setting stays 
the operator's. */
+    PublicTrackerCorsPolicy withOrigins(Set<String> tenantOrigins) {
+        if (tenantOrigins.isEmpty()) {
+            return this;
+        }
+        Set<String> merged = new LinkedHashSet<>(allowedOrigins);
+        merged.addAll(tenantOrigins);
+        return new 
PublicTrackerCorsPolicy(Collections.unmodifiableSet(merged), matchReferer);
+    }
+
     /**
      * Returns the origin as a browser serializes it (lowercase, no default 
port), or {@code null} when it is
      * not an {@code http} or {@code https} origin with a host and an optional 
port only.
@@ -220,12 +290,20 @@ public final class PublicTrackerCorsPolicy {
      * Anything else falls back to {@code Lax}.
      */
     String resolveSameSite(String requested, boolean secure) {
+        return resolveSameSite(requested, secure, !allowedOrigins.isEmpty());
+    }
+
+    /**
+     * @param originsListed whether any origin may be listed: the operator's 
list is not empty, or tenants may
+     *                      list their own
+     */
+    String resolveSameSite(String requested, boolean secure, boolean 
originsListed) {
         if (requested == null || requested.isBlank()) {
             return "Lax";
         }
         String value = requested.trim();
         if ("None".equalsIgnoreCase(value)) {
-            if (secure && !allowedOrigins.isEmpty()) {
+            if (secure && originsListed) {
                 return "None";
             }
             return "Lax";
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerSettings.java 
b/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerSettings.java
index 92e47a846..216c6a976 100644
--- a/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerSettings.java
+++ b/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerSettings.java
@@ -16,28 +16,48 @@
  */
 package org.apache.unomi.rest.cors;
 
+import org.apache.cxf.interceptor.security.RolePrefixSecurityContextImpl;
+import org.apache.cxf.jaxrs.utils.JAXRSUtils;
+import org.apache.cxf.message.Message;
+import org.apache.cxf.security.SecurityContext;
+import org.apache.unomi.api.security.TenantPrincipal;
 import org.apache.unomi.api.services.ConfigSharingService;
 import org.apache.unomi.api.services.ConfigSharingService.ConfigChangeEvent;
 import org.apache.unomi.api.services.ConfigSharingService.ConfigChangeListener;
+import org.apache.unomi.api.services.TenantLifecycleListener;
+import org.apache.unomi.api.tenants.Tenant;
+import org.apache.unomi.api.tenants.TenantService;
 import org.osgi.service.component.annotations.Activate;
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Reference;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import javax.security.auth.Subject;
+import java.security.Principal;
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.LongSupplier;
 
 /**
- * The public tracker origin list and profile cookie settings, parsed and 
resolved when they are set
- * rather than on every request. The web configuration publishes them through 
{@link ConfigSharingService};
- * this component reads them at activation and again whenever one of them 
changes. Requests only read
- * the results.
+ * The public tracker origin lists and profile cookie settings, parsed and 
resolved when they are set
+ * rather than on every request. The web configuration publishes the 
operator's settings through
+ * {@link ConfigSharingService}; this component reads them at activation and 
again whenever one of them
+ * changes. Each tenant may add its own website origins in its {@value 
#TENANT_ORIGINS_KEY} property; those
+ * are kept in an in-memory snapshot, reloaded in the background of requests 
every
+ * {@value #TENANT_REFRESH_SECONDS} seconds, and at once when a tenant is 
changed through this node or an
+ * unknown tenant or origin shows up. Requests only read the results.
  */
-@Component(service = {PublicTrackerSettings.class, ConfigChangeListener.class})
-public class PublicTrackerSettings implements ConfigChangeListener {
+@Component(service = {PublicTrackerSettings.class, ConfigChangeListener.class, 
TenantLifecycleListener.class})
+public class PublicTrackerSettings implements ConfigChangeListener, 
TenantLifecycleListener {
 
     private static final Logger LOGGER = 
LoggerFactory.getLogger(PublicTrackerSettings.class);
 
@@ -45,21 +65,31 @@ public class PublicTrackerSettings implements 
ConfigChangeListener {
     static final String ALLOWED_ORIGINS_PROPERTY = "publicCorsAllowedOrigins";
     static final String SAME_SITE_PROPERTY = "profileIdCookieSameSite";
     static final String MATCH_REFERER_PROPERTY = "publicCorsMatchReferer";
+    static final String TENANT_LISTS_PROPERTY = "publicCorsTenantOrigins";
     static final String COOKIE_NAME_PROPERTY = "profileIdCookieName";
     static final String COOKIE_DOMAIN_PROPERTY = "profileIdCookieDomain";
     static final String COOKIE_MAX_AGE_PROPERTY = 
"profileIdCookieMaxAgeInSeconds";
     static final String COOKIE_HTTP_ONLY_PROPERTY = "profileIdCookieHttpOnly";
 
+    /** The tenant property, set through the tenant API, that lists the 
tenant's own website origins. */
+    public static final String TENANT_ORIGINS_KEY = "publicCorsAllowedOrigins";
+
+    static final int TENANT_REFRESH_SECONDS = 30;
+    /** At most one reload for an unknown tenant or origin in this time, so 
requests cannot force reloads. */
+    static final int TENANT_RELOAD_MIN_SECONDS = 5;
+
     static final String DEFAULT_COOKIE_NAME = "context-profile-id";
     static final int DEFAULT_COOKIE_MAX_AGE = 31536000;
 
     private static final Set<String> WATCHED = Set.of(
-            ALLOWED_ORIGINS_PROPERTY, SAME_SITE_PROPERTY, 
MATCH_REFERER_PROPERTY,
+            ALLOWED_ORIGINS_PROPERTY, SAME_SITE_PROPERTY, 
MATCH_REFERER_PROPERTY, TENANT_LISTS_PROPERTY,
             COOKIE_NAME_PROPERTY, COOKIE_DOMAIN_PROPERTY, 
COOKIE_MAX_AGE_PROPERTY, COOKIE_HTTP_ONLY_PROPERTY);
 
     /** Everything a request needs, replaced as a whole so a request never 
sees half of a change. */
     private static final class Resolved {
         final PublicTrackerCorsPolicy policy;
+        /** Whether tenants may add their own origins. */
+        final boolean tenantLists;
         final String sameSiteWhenSecure;
         final String cookieName;
         /** {@code name=}, then the attributes after the value, for secure and 
plain HTTP requests. */
@@ -73,12 +103,14 @@ public class PublicTrackerSettings implements 
ConfigChangeListener {
         /** Problems found in this configuration, logged when it is applied. */
         final List<String> warnings;
 
-        Resolved(PublicTrackerCorsPolicy policy, String requestedSameSite, 
String cookieName, String cookieDomain,
-                 int cookieMaxAge, boolean cookieHttpOnly, List<String> 
warnings) {
+        Resolved(PublicTrackerCorsPolicy policy, boolean tenantLists, String 
requestedSameSite, String cookieName,
+                 String cookieDomain, int cookieMaxAge, boolean 
cookieHttpOnly, List<String> warnings) {
             this.policy = policy;
+            this.tenantLists = tenantLists;
             this.warnings = List.copyOf(warnings);
-            this.sameSiteWhenSecure = 
policy.resolveSameSite(requestedSameSite, true);
-            String sameSiteWhenInsecure = 
policy.resolveSameSite(requestedSameSite, false);
+            boolean originsListed = !policy.isEmpty() || tenantLists;
+            this.sameSiteWhenSecure = 
policy.resolveSameSite(requestedSameSite, true, originsListed);
+            String sameSiteWhenInsecure = 
policy.resolveSameSite(requestedSameSite, false, originsListed);
             this.cookieName = cookieName;
             this.cookiePrefix = cookieName + "=";
             String common = "; Path=/; Max-Age=" + cookieMaxAge
@@ -87,16 +119,61 @@ public class PublicTrackerSettings implements 
ConfigChangeListener {
             this.cookieAttributesWhenSecure = common + "; SameSite=" + 
sameSiteWhenSecure + "; Secure" + httpOnly;
             this.cookieAttributesWhenInsecure = common + "; SameSite=" + 
sameSiteWhenInsecure + httpOnly;
             this.insecureRefusalToReport = new 
AtomicBoolean("None".equals(sameSiteWhenSecure) && 
!"None".equals(sameSiteWhenInsecure));
-            // Only worth reporting once the operator has listed websites.
-            this.refusedEmbedToReport = new AtomicBoolean(!policy.isEmpty());
+            // Only worth reporting once websites can be listed.
+            this.refusedEmbedToReport = new AtomicBoolean(originsListed);
+        }
+    }
+
+    /** The origins each tenant lists, as last loaded. Only tenants with a 
list have an entry in {@code lists}. */
+    private static final class TenantOrigins {
+        static final TenantOrigins NONE = new 
TenantOrigins(Collections.emptySet(), Collections.emptyMap(), Long.MIN_VALUE);
+
+        final Set<String> tenantIds;
+        final Map<String, Set<String>> lists;
+        final long loadedAt;
+
+        TenantOrigins(Set<String> tenantIds, Map<String, Set<String>> lists, 
long loadedAt) {
+            this.tenantIds = tenantIds;
+            this.lists = lists;
+            this.loadedAt = loadedAt;
         }
     }
 
-    private volatile Resolved resolved = resolve(null, null, null, null, null, 
null, null);
+    /** The operator's policy merged with each tenant's list, and every origin 
any tenant lists (for preflights). */
+    private static final class Merged {
+        final Resolved base;
+        final TenantOrigins origins;
+        final Map<String, PublicTrackerCorsPolicy> byTenant;
+        final Set<String> anyTenant;
+
+        Merged(Resolved base, TenantOrigins origins) {
+            this.base = base;
+            this.origins = origins;
+            Map<String, PublicTrackerCorsPolicy> policies = new HashMap<>();
+            Set<String> all = new HashSet<>();
+            for (Map.Entry<String, Set<String>> entry : 
origins.lists.entrySet()) {
+                policies.put(entry.getKey(), 
base.policy.withOrigins(entry.getValue()));
+                all.addAll(entry.getValue());
+            }
+            this.byTenant = Collections.unmodifiableMap(policies);
+            this.anyTenant = Collections.unmodifiableSet(all);
+        }
+    }
+
+    private volatile Resolved resolved = resolve(null, null, null, null, null, 
null, null, null);
+    private volatile TenantOrigins tenantOrigins = TenantOrigins.NONE;
+    private volatile Merged merged;
+    private volatile long lastTenantLoad = Long.MIN_VALUE;
+    private final ReentrantLock tenantLoadLock = new ReentrantLock();
+    /** Replaced in tests. */
+    LongSupplier clock = System::nanoTime;
 
     @Reference
     private ConfigSharingService configSharingService;
 
+    @Reference
+    private TenantService tenantService;
+
     /** Used by OSGi Declarative Services; the settings are read at 
activation. */
     public PublicTrackerSettings() {
     }
@@ -108,12 +185,13 @@ public class PublicTrackerSettings implements 
ConfigChangeListener {
 
     /** For tests; cookie name, domain, max age and HttpOnly take their 
defaults. */
     public PublicTrackerSettings(String allowedOrigins, String sameSite, 
boolean matchReferer) {
-        this.resolved = resolve(allowedOrigins, sameSite, matchReferer, null, 
null, null, null);
+        this.resolved = resolve(allowedOrigins, sameSite, matchReferer, 
"false", null, null, null, null);
     }
 
     @Activate
     void activate() {
         reload();
+        loadTenantOrigins();
     }
 
     @Override
@@ -130,6 +208,7 @@ public class PublicTrackerSettings implements 
ConfigChangeListener {
                 configSharingService.getProperty(ALLOWED_ORIGINS_PROPERTY),
                 configSharingService.getProperty(SAME_SITE_PROPERTY),
                 configSharingService.getProperty(MATCH_REFERER_PROPERTY),
+                configSharingService.getProperty(TENANT_LISTS_PROPERTY),
                 configSharingService.getProperty(COOKIE_NAME_PROPERTY),
                 configSharingService.getProperty(COOKIE_DOMAIN_PROPERTY),
                 configSharingService.getProperty(COOKIE_MAX_AGE_PROPERTY),
@@ -159,8 +238,8 @@ public class PublicTrackerSettings implements 
ConfigChangeListener {
         return resolved.warnings;
     }
 
-    private static Resolved resolve(Object allowedOrigins, Object sameSite, 
Object matchReferer, Object cookieName,
-                                    Object cookieDomain, Object cookieMaxAge, 
Object cookieHttpOnly) {
+    private static Resolved resolve(Object allowedOrigins, Object sameSite, 
Object matchReferer, Object tenantListsSetting,
+                                    Object cookieName, Object cookieDomain, 
Object cookieMaxAge, Object cookieHttpOnly) {
         List<String> warnings = new ArrayList<>();
         String requested = sameSite == null ? null : 
sameSite.toString().trim();
         String name = cookieName == null || cookieName.toString().isBlank() ? 
DEFAULT_COOKIE_NAME : cookieName.toString().trim();
@@ -172,17 +251,24 @@ public class PublicTrackerSettings implements 
ConfigChangeListener {
             warnings.add("Unrecognised value '" + refererSetting + "' for 
org.apache.unomi.public.cors.matchReferer; "
                     + "only true turns it on, so Referer matching is off.");
         }
+        // On unless explicitly turned off: an empty tenant list adds nothing.
+        String tenantSetting = tenantListsSetting == null ? "" : 
tenantListsSetting.toString().trim();
+        boolean tenantLists = !"false".equalsIgnoreCase(tenantSetting);
+        if (!tenantSetting.isEmpty() && tenantLists && 
!"true".equalsIgnoreCase(tenantSetting)) {
+            warnings.add("Unrecognised value '" + tenantSetting + "' for 
org.apache.unomi.public.cors.tenantOrigins; "
+                    + "only false turns it off, so tenants may list their own 
origins.");
+        }
         PublicTrackerCorsPolicy policy = 
PublicTrackerCorsPolicy.parse(origins, refererMatch, warnings);
         if (origins != null && !origins.isBlank() && policy.isEmpty()) {
             warnings.add("None of the public CORS allowed origins '" + origins 
+ "' is valid, so no other website may "
                     + "use the visitor cookie. Expected scheme://host[:port], 
comma-separated.");
         }
-        if (refererMatch && policy.isEmpty()) {
+        if (refererMatch && policy.isEmpty() && !tenantLists) {
             warnings.add("org.apache.unomi.public.cors.matchReferer is on but 
no website origin is listed, so it has no effect.");
         }
-        if ("None".equalsIgnoreCase(requested) && 
!"None".equals(policy.resolveSameSite(requested, true))) {
+        if ("None".equalsIgnoreCase(requested) && 
!"None".equals(policy.resolveSameSite(requested, true, !policy.isEmpty() || 
tenantLists))) {
             warnings.add("Refusing SameSite=None for the profile cookie 
because the public CORS allowed origins list "
-                    + "has no valid entry; using SameSite=Lax.");
+                    + "has no valid entry and tenant lists are off; using 
SameSite=Lax.");
         }
         int maxAge = toInt(cookieMaxAge, warnings);
         String httpOnlySetting = cookieHttpOnly == null ? "" : 
cookieHttpOnly.toString().trim();
@@ -192,6 +278,7 @@ public class PublicTrackerSettings implements 
ConfigChangeListener {
         }
         return new Resolved(
                 policy,
+                tenantLists,
                 requested,
                 name,
                 cookieDomain == null ? null : cookieDomain.toString(),
@@ -219,11 +306,177 @@ public class PublicTrackerSettings implements 
ConfigChangeListener {
         return DEFAULT_COOKIE_MAX_AGE;
     }
 
-    /** @return the website origins that may use the visitor cookie, as 
currently configured */
+    /** @return the operator's website origins that may use the visitor 
cookie, as currently configured */
     public PublicTrackerCorsPolicy policy() {
         return resolved.policy;
     }
 
+    /**
+     * The website origins that may use the visitor cookie for a tenant: the 
operator's, plus the tenant's own
+     * when tenant lists are on.
+     *
+     * @param tenantId the tenant the request authenticated as, may be {@code 
null}
+     * @return the policy to apply to that tenant's request
+     */
+    public PublicTrackerCorsPolicy policyForTenant(String tenantId) {
+        Resolved base = resolved;
+        if (!base.tenantLists || tenantId == null) {
+            return base.policy;
+        }
+        refreshTenantOriginsIfStale();
+        Merged current = merged(base);
+        PublicTrackerCorsPolicy policy = current.byTenant.get(tenantId);
+        if (policy == null && !current.origins.tenantIds.contains(tenantId) && 
reloadTenantOriginsSoon()) {
+            // A tenant created since the last load, possibly on another node.
+            policy = merged(resolved).byTenant.get(tenantId);
+        }
+        return policy != null ? policy : base.policy;
+    }
+
+    /**
+     * Whether a preflight from this origin may be answered with credentials. 
A preflight carries no API key,
+     * so the tenant is not known yet: an origin the operator or any tenant 
lists is accepted, and the actual
+     * request is then checked against its own tenant.
+     */
+    public boolean allowsPreflight(String origin) {
+        Resolved base = resolved;
+        if (origin == null) {
+            return false;
+        }
+        if (base.policy.allows(origin)) {
+            return true;
+        }
+        if (!base.tenantLists) {
+            return false;
+        }
+        refreshTenantOriginsIfStale();
+        if (merged(base).anyTenant.contains(origin)) {
+            return true;
+        }
+        // Perhaps a tenant that listed it since the last load, possibly on 
another node.
+        return reloadTenantOriginsSoon() && 
merged(resolved).anyTenant.contains(origin);
+    }
+
+    /** Reloads the tenant origins now, after a tenant was created, changed or 
deleted through this node. */
+    public void tenantsChanged() {
+        loadTenantOrigins();
+    }
+
+    @Override
+    public void onTenantRemoved(String tenantId) {
+        loadTenantOrigins();
+    }
+
+    /**
+     * The tenant the current REST request authenticated as, read from the CXF 
security context, which stays
+     * in place until the response has been written. {@code null} outside a 
request or for an anonymous one.
+     */
+    public static String requestTenantId() {
+        return tenantIdOf(JAXRSUtils.getCurrentMessage());
+    }
+
+    /**
+     * The authentication filter stores the security context on the request 
message. In a response filter the
+     * current message is the response, so the request message is read from 
the exchange.
+     */
+    static String tenantIdOf(Message message) {
+        if (message == null) {
+            return null;
+        }
+        Object context = message.get(SecurityContext.class);
+        if (context == null && message.getExchange() != null && 
message.getExchange().getInMessage() != null) {
+            context = 
message.getExchange().getInMessage().get(SecurityContext.class);
+        }
+        if (!(context instanceof RolePrefixSecurityContextImpl)) {
+            return null;
+        }
+        Subject subject = ((RolePrefixSecurityContextImpl) 
context).getSubject();
+        if (subject == null) {
+            return null;
+        }
+        for (Principal principal : subject.getPrincipals()) {
+            if (principal instanceof TenantPrincipal) {
+                return ((TenantPrincipal) principal).getTenantId();
+            }
+        }
+        return null;
+    }
+
+    private Merged merged(Resolved base) {
+        Merged current = merged;
+        TenantOrigins origins = tenantOrigins;
+        if (current != null && current.base == base && current.origins == 
origins) {
+            return current;
+        }
+        Merged next = new Merged(base, origins);
+        merged = next;
+        return next;
+    }
+
+    private void refreshTenantOriginsIfStale() {
+        if (clock.getAsLong() - tenantOrigins.loadedAt < 
TimeUnit.SECONDS.toNanos(TENANT_REFRESH_SECONDS)) {
+            return;
+        }
+        // One request reloads; the others keep using the previous snapshot 
meanwhile.
+        if (tenantLoadLock.tryLock()) {
+            try {
+                if (clock.getAsLong() - tenantOrigins.loadedAt >= 
TimeUnit.SECONDS.toNanos(TENANT_REFRESH_SECONDS)) {
+                    doLoadTenantOrigins();
+                }
+            } finally {
+                tenantLoadLock.unlock();
+            }
+        }
+    }
+
+    /** Reloads unless a load happened in the last few seconds; returns 
whether it reloaded. */
+    private boolean reloadTenantOriginsSoon() {
+        tenantLoadLock.lock();
+        try {
+            if (clock.getAsLong() - lastTenantLoad < 
TimeUnit.SECONDS.toNanos(TENANT_RELOAD_MIN_SECONDS)) {
+                return false;
+            }
+            doLoadTenantOrigins();
+            return true;
+        } finally {
+            tenantLoadLock.unlock();
+        }
+    }
+
+    private void loadTenantOrigins() {
+        tenantLoadLock.lock();
+        try {
+            doLoadTenantOrigins();
+        } finally {
+            tenantLoadLock.unlock();
+        }
+    }
+
+    private void doLoadTenantOrigins() {
+        long now = clock.getAsLong();
+        lastTenantLoad = now;
+        if (tenantService == null) {
+            return;
+        }
+        try {
+            Set<String> ids = new HashSet<>();
+            Map<String, Set<String>> lists = new HashMap<>();
+            for (Tenant tenant : tenantService.getAllTenants()) {
+                ids.add(tenant.getItemId());
+                Object value = tenant.getProperties() == null ? null : 
tenant.getProperties().get(TENANT_ORIGINS_KEY);
+                Set<String> origins = 
PublicTrackerCorsPolicy.parseTenantOrigins(value);
+                if (!origins.isEmpty()) {
+                    lists.put(tenant.getItemId(), origins);
+                }
+            }
+            tenantOrigins = new 
TenantOrigins(Collections.unmodifiableSet(ids), 
Collections.unmodifiableMap(lists), now);
+        } catch (RuntimeException e) {
+            // Keep the previous snapshot; the next refresh tries again.
+            LOGGER.warn("Could not load the tenants' public CORS allowed 
origins; keeping the previous ones: {}", e.getMessage());
+            LOGGER.debug("Tenant origins load failure", e);
+        }
+    }
+
     /** @return the name of the visitor cookie, as currently configured */
     public String cookieName() {
         return resolved.cookieName;
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
 
b/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
index c49f8835a..7f711fff6 100644
--- 
a/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
+++ 
b/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
@@ -154,7 +154,7 @@ public class RestServiceUtilsImpl implements 
RestServiceUtils {
         String origin = request.getHeader("Origin");
         String referer = request.getHeader("Referer");
         String secFetchSite = request.getHeader("Sec-Fetch-Site");
-        boolean allowed = publicTrackerSettings.policy().allowsVisitorCookie(
+        boolean allowed = 
publicTrackerSettings.policyForTenant(PublicTrackerSettings.requestTenantId()).allowsVisitorCookie(
                 origin, referer, secFetchSite, request.getServerName(), 
request.getHeader("X-Forwarded-Host"));
         if (!allowed) {
             // Any site can cause this, so do not pay for sanitising the 
headers unless they are logged.
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/tenants/TenantEndpoint.java 
b/rest/src/main/java/org/apache/unomi/rest/tenants/TenantEndpoint.java
index 26ada07f1..5596a2466 100644
--- a/rest/src/main/java/org/apache/unomi/rest/tenants/TenantEndpoint.java
+++ b/rest/src/main/java/org/apache/unomi/rest/tenants/TenantEndpoint.java
@@ -25,6 +25,8 @@ import org.apache.unomi.api.tenants.TenantService;
 import org.apache.unomi.api.tenants.TenantEventPurgeResult;
 import org.apache.unomi.api.tenants.TenantUsage;
 import org.apache.unomi.api.tenants.TenantUsageService;
+import org.apache.unomi.rest.cors.PublicTrackerCorsPolicy;
+import org.apache.unomi.rest.cors.PublicTrackerSettings;
 import org.apache.unomi.rest.security.RequiresRole;
 import org.apache.unomi.rest.security.RequiresTenant;
 import org.osgi.service.component.annotations.Component;
@@ -34,6 +36,7 @@ import javax.ws.rs.*;
 import javax.ws.rs.core.MediaType;
 import javax.ws.rs.core.Response;
 import java.util.List;
+import java.util.Map;
 
 /**
  * REST endpoint for managing tenants in the Apache Unomi system.
@@ -57,6 +60,9 @@ public class TenantEndpoint {
     @Reference
     private TenantUsageService tenantUsageService;
 
+    @Reference
+    private PublicTrackerSettings publicTrackerSettings;
+
     /**
      * Retrieves all tenants in the system.
      *
@@ -113,8 +119,10 @@ public class TenantEndpoint {
             throw new WebApplicationException("Tenant ID is required", 
Response.Status.BAD_REQUEST);
         }
 
+        validateTrackerOrigins(request.getProperties());
         Tenant tenant = tenantService.createTenant(request.getRequestedId(), 
request.getProperties());
         // Note: createTenant already generates both API keys via 
generateApiKeyWithType
+        publicTrackerSettings.tenantsChanged();
         return tenant;
     }
 
@@ -145,7 +153,9 @@ public class TenantEndpoint {
             throw new WebApplicationException("Tenant not found", 
Response.Status.NOT_FOUND);
         }
 
+        validateTrackerOrigins(tenant.getProperties());
         tenantService.saveTenant(tenant);
+        publicTrackerSettings.tenantsChanged();
         return tenant;
     }
 
@@ -168,6 +178,7 @@ public class TenantEndpoint {
         }
 
         tenantService.deleteTenant(tenantId);
+        publicTrackerSettings.tenantsChanged();
         return Response.noContent().build();
     }
 
@@ -303,4 +314,20 @@ public class TenantEndpoint {
             throw new WebApplicationException(e.getMessage(), 
Response.Status.BAD_REQUEST);
         }
     }
+
+    /**
+     * Rejects a {@value PublicTrackerSettings#TENANT_ORIGINS_KEY} property 
that would not be applied as written,
+     * so the provisioning call fails rather than the tenant's websites 
silently losing their visitors.
+     */
+    private static void validateTrackerOrigins(Map<String, Object> properties) 
{
+        if (properties == null || 
!properties.containsKey(PublicTrackerSettings.TENANT_ORIGINS_KEY)) {
+            return;
+        }
+        List<String> problems = 
PublicTrackerCorsPolicy.tenantOriginProblems(properties.get(PublicTrackerSettings.TENANT_ORIGINS_KEY));
+        if (!problems.isEmpty()) {
+            throw new 
WebApplicationException(Response.status(Response.Status.BAD_REQUEST)
+                    .entity("Invalid " + 
PublicTrackerSettings.TENANT_ORIGINS_KEY + ": " + String.join("; ", problems))
+                    .type(MediaType.TEXT_PLAIN).build());
+        }
+    }
 }
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/config/ShippedProfileCookieConfigTest.java
 
b/rest/src/test/java/org/apache/unomi/rest/config/ShippedProfileCookieConfigTest.java
index 82755d2f5..8fde84c3f 100644
--- 
a/rest/src/test/java/org/apache/unomi/rest/config/ShippedProfileCookieConfigTest.java
+++ 
b/rest/src/test/java/org/apache/unomi/rest/config/ShippedProfileCookieConfigTest.java
@@ -62,6 +62,11 @@ class ShippedProfileCookieConfigTest {
                 "Referer matching should default to off in " + WEB_CFG_PATH);
         
assertTrue(systemProperties.contains("org.apache.unomi.public.cors.matchReferer=${env:UNOMI_PUBLIC_CORS_MATCH_REFERER:-false}"),
                 "Referer matching should default to off and be reachable from 
the environment");
+        // Tenants created at runtime list their own websites, so tenant lists 
ship on.
+        
assertTrue(webCfg.contains("public.cors.tenantOrigins=${org.apache.unomi.public.cors.tenantOrigins:-true}"),
+                "tenant origin lists should default to on in " + WEB_CFG_PATH);
+        
assertTrue(systemProperties.contains("org.apache.unomi.public.cors.tenantOrigins=${env:UNOMI_PUBLIC_CORS_TENANT_ORIGINS:-true}"),
+                "tenant origin lists should default to on and be reachable 
from the environment");
     }
 
     private static Path repoFile(String relativePath) throws IOException {
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilterTest.java
 
b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilterTest.java
index b109ccae2..74aac4ed1 100644
--- 
a/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilterTest.java
+++ 
b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilterTest.java
@@ -172,6 +172,44 @@ class PublicTrackerCorsFilterTest {
         assertEquals(Boolean.FALSE, 
headers.getFirst("Access-Control-Allow-Credentials"));
     }
 
+    @Test
+    void preflightIsAnsweredForAnOriginAnyTenantLists() {
+        PublicTrackerSettings settings = 
org.mockito.Mockito.mock(PublicTrackerSettings.class);
+        
when(settings.allowsPreflight("https://shop.example";)).thenReturn(true);
+        filter = new PublicTrackerCorsFilter(settings);
+        when(request.getMethod()).thenReturn("OPTIONS");
+        
when(request.getHeaderString("Access-Control-Request-Method")).thenReturn("POST");
+        
when(request.getHeaderString("Origin")).thenReturn("https://shop.example";);
+        grantedByCxf();
+
+        filter.restrictCredentials(request, response);
+
+        assertEquals("https://shop.example";, 
headers.getFirst("Access-Control-Allow-Origin"));
+        assertEquals("true", 
headers.getFirst("Access-Control-Allow-Credentials"));
+    }
+
+    @Test
+    void actualRequestIsCheckedAgainstItsOwnTenant() {
+        // The preflight passed for any tenant; with another tenant's key, the 
actual request gets no credentials.
+        PublicTrackerSettings settings = 
org.mockito.Mockito.mock(PublicTrackerSettings.class);
+        
when(settings.policyForTenant("globex")).thenReturn(PublicTrackerCorsPolicy.parse(""));
+        filter = new PublicTrackerCorsFilter(settings) {
+            @Override
+            String requestTenantId() {
+                return "globex";
+            }
+        };
+        when(request.getMethod()).thenReturn("POST");
+        
when(request.getHeaderString("Origin")).thenReturn("https://shop.example";);
+        grantedByCxf();
+
+        filter.restrictCredentials(request, response);
+
+        assertEquals("*", headers.getFirst("Access-Control-Allow-Origin"));
+        assertNull(headers.getFirst("Access-Control-Allow-Credentials"));
+        org.mockito.Mockito.verify(settings, 
org.mockito.Mockito.never()).allowsPreflight(org.mockito.ArgumentMatchers.anyString());
+    }
+
     @Test
     void inheritsCxfPreMatchingAndPriority() {
         // CXF reads both through the class hierarchy; the rewrite then needs 
no ordering of its own.
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicyTest.java
 
b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicyTest.java
index d5101db1a..02f21ff99 100644
--- 
a/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicyTest.java
+++ 
b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicyTest.java
@@ -22,6 +22,7 @@ import java.util.ArrayList;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class PublicTrackerCorsPolicyTest {
@@ -154,6 +155,36 @@ class PublicTrackerCorsPolicyTest {
         assertFalse(listed.allowsVisitorCookie("https://other.example";, null, 
"cross-site", "cdp.example", null));
     }
 
+    @Test
+    void tenantOriginsAcceptAStringOrAList() {
+        assertEquals(java.util.Set.of("https://shop.example";, 
"https://blog.example:8443";),
+                PublicTrackerCorsPolicy.parseTenantOrigins(" 
https://SHOP.example/ , https://blog.example:8443";));
+        assertEquals(java.util.Set.of("https://shop.example";),
+                
PublicTrackerCorsPolicy.parseTenantOrigins(java.util.List.of("https://shop.example:443";,
 "")));
+        assertTrue(PublicTrackerCorsPolicy.parseTenantOrigins(null).isEmpty());
+        assertTrue(PublicTrackerCorsPolicy.parseTenantOrigins(42).isEmpty());
+    }
+
+    @Test
+    void invalidTenantOriginsAreReported() {
+        
assertTrue(PublicTrackerCorsPolicy.tenantOriginProblems("https://shop.example, 
https://blog.example";).isEmpty());
+        
assertTrue(PublicTrackerCorsPolicy.tenantOriginProblems(java.util.List.of("https://shop.example";)).isEmpty());
+        
assertTrue(PublicTrackerCorsPolicy.tenantOriginProblems(null).isEmpty());
+        assertEquals(2, PublicTrackerCorsPolicy.tenantOriginProblems("*, 
https://shop.example/path";).size());
+        assertEquals(1, 
PublicTrackerCorsPolicy.tenantOriginProblems(42).size());
+        assertEquals(1, 
PublicTrackerCorsPolicy.tenantOriginProblems(java.util.List.of(42)).size());
+    }
+
+    @Test
+    void aTenantsOriginsAreAddedToTheOperatorList() {
+        PublicTrackerCorsPolicy operator = 
PublicTrackerCorsPolicy.parse("https://shop.example";);
+        PublicTrackerCorsPolicy merged = 
operator.withOrigins(java.util.Set.of("https://blog.example";));
+        assertTrue(merged.allows("https://shop.example";));
+        assertTrue(merged.allows("https://blog.example";));
+        assertFalse(operator.allows("https://blog.example";));
+        assertSame(operator, operator.withOrigins(java.util.Set.of()));
+    }
+
     @Test
     void wildcardIsNeverAnAllowedOrigin() {
         PublicTrackerCorsPolicy policy = PublicTrackerCorsPolicy.parse("*");
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerSettingsTenantTest.java
 
b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerSettingsTenantTest.java
new file mode 100644
index 000000000..5cb944e2b
--- /dev/null
+++ 
b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerSettingsTenantTest.java
@@ -0,0 +1,234 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.cors;
+
+import org.apache.unomi.api.services.ConfigSharingService;
+import org.apache.unomi.api.tenants.Tenant;
+import org.apache.unomi.api.tenants.TenantService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tenants list their own website origins (UNOMI-1013): applied only to 
requests made with that tenant's key,
+ * picked up without restarting or changing the operator configuration when 
tenants come and go.
+ */
+@ExtendWith(MockitoExtension.class)
+class PublicTrackerSettingsTenantTest {
+
+    private static final String SHOP = "https://shop.example";;
+    private static final String BLOG = "https://blog.example";;
+
+    @Mock private ConfigSharingService configSharingService;
+    @Mock private TenantService tenantService;
+
+    private final List<Tenant> tenants = new ArrayList<>();
+    private final long[] now = {TimeUnit.HOURS.toNanos(1)};
+    private PublicTrackerSettings settings;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        settings = new PublicTrackerSettings();
+        set("configSharingService", configSharingService);
+        set("tenantService", tenantService);
+        settings.clock = () -> now[0];
+        lenient().when(tenantService.getAllTenants()).thenAnswer(call -> new 
ArrayList<>(tenants));
+        tenants.add(tenant("acme", SHOP));
+        tenants.add(tenant("globex", null));
+    }
+
+    @Test
+    void aTenantsOriginsApplyOnlyToThatTenant() {
+        settings.activate();
+
+        assertTrue(settings.policyForTenant("acme").allows(SHOP));
+        assertFalse(settings.policyForTenant("globex").allows(SHOP));
+        assertFalse(settings.policyForTenant(null).allows(SHOP));
+        // A preflight carries no key: an origin any tenant lists passes, the 
actual request decides.
+        assertTrue(settings.allowsPreflight(SHOP));
+        assertFalse(settings.allowsPreflight("https://evil.example";));
+    }
+
+    @Test
+    void theOperatorListStillAppliesToEveryTenant() {
+        
lenient().when(configSharingService.getProperty(PublicTrackerSettings.ALLOWED_ORIGINS_PROPERTY)).thenReturn(BLOG);
+        settings.activate();
+
+        assertTrue(settings.policyForTenant("acme").allows(BLOG));
+        assertTrue(settings.policyForTenant("acme").allows(SHOP));
+        assertTrue(settings.policyForTenant("globex").allows(BLOG));
+        assertTrue(settings.allowsPreflight(BLOG));
+    }
+
+    @Test
+    void theOperatorCanTurnTenantListsOff() {
+        
lenient().when(configSharingService.getProperty(PublicTrackerSettings.TENANT_LISTS_PROPERTY)).thenReturn("false");
+        settings.activate();
+
+        assertFalse(settings.policyForTenant("acme").allows(SHOP));
+        assertFalse(settings.allowsPreflight(SHOP));
+    }
+
+    @Test
+    void aNewTenantWorksAtOnceWithoutRestart() {
+        settings.activate();
+        now[0] += 
TimeUnit.SECONDS.toNanos(PublicTrackerSettings.TENANT_RELOAD_MIN_SECONDS);
+        // Created on another node: this node has not seen it yet.
+        tenants.add(tenant("initech", BLOG));
+
+        assertTrue(settings.allowsPreflight(BLOG));
+        assertTrue(settings.policyForTenant("initech").allows(BLOG));
+    }
+
+    @Test
+    void unknownOriginsCannotForceReloads() {
+        settings.activate();
+        clearInvocations(tenantService);
+        now[0] += 
TimeUnit.SECONDS.toNanos(PublicTrackerSettings.TENANT_RELOAD_MIN_SECONDS);
+
+        assertFalse(settings.allowsPreflight("https://random-1.example";));
+        assertFalse(settings.allowsPreflight("https://random-2.example";));
+        assertFalse(settings.policyForTenant("unknown-tenant").allows(SHOP));
+
+        verify(tenantService, times(1)).getAllTenants();
+    }
+
+    @Test
+    void theSnapshotIsRefreshedPeriodically() {
+        settings.activate();
+        tenants.clear();
+        tenants.add(tenant("acme", null));
+
+        // Still within the refresh interval: the known tenant keeps its 
origins for now.
+        assertTrue(settings.policyForTenant("acme").allows(SHOP));
+
+        now[0] += 
TimeUnit.SECONDS.toNanos(PublicTrackerSettings.TENANT_REFRESH_SECONDS);
+        assertFalse(settings.policyForTenant("acme").allows(SHOP));
+    }
+
+    @Test
+    void changesThroughThisNodeApplyImmediately() {
+        settings.activate();
+        tenants.clear();
+
+        settings.tenantsChanged();
+        assertFalse(settings.policyForTenant("acme").allows(SHOP));
+
+        tenants.add(tenant("acme", SHOP));
+        settings.onTenantRemoved("globex");
+        assertTrue(settings.policyForTenant("acme").allows(SHOP));
+    }
+
+    @Test
+    void aFailedLoadKeepsThePreviousOrigins() {
+        settings.activate();
+        when(tenantService.getAllTenants()).thenThrow(new 
IllegalStateException("search engine unavailable"));
+
+        settings.tenantsChanged();
+
+        assertTrue(settings.policyForTenant("acme").allows(SHOP));
+    }
+
+    @Test
+    void sameSiteNoneIsAvailableOnceTenantsMayListOrigins() {
+        
lenient().when(configSharingService.getProperty(PublicTrackerSettings.SAME_SITE_PROPERTY)).thenReturn("None");
+        settings.activate();
+
+        assertTrue(settings.profileCookie("p1", true).contains("SameSite=None; 
Secure"));
+        assertTrue(settings.configurationWarnings().isEmpty(), 
String.valueOf(settings.configurationWarnings()));
+    }
+
+    @Test
+    void anUnrecognisedSwitchValueKeepsTenantListsOnAndSaysSo() {
+        
lenient().when(configSharingService.getProperty(PublicTrackerSettings.TENANT_LISTS_PROPERTY)).thenReturn("off");
+        settings.activate();
+
+        assertTrue(settings.policyForTenant("acme").allows(SHOP));
+        assertTrue(settings.configurationWarnings().stream()
+                .anyMatch(warning -> warning.contains("Unrecognised value 
'off' for org.apache.unomi.public.cors.tenantOrigins")));
+    }
+
+    @Test
+    void tenantsAreNotLoadedForEachRequest() {
+        settings.activate();
+        clearInvocations(tenantService);
+
+        for (int i = 0; i < 100; i++) {
+            settings.policyForTenant("acme");
+            settings.allowsPreflight(SHOP);
+        }
+
+        verify(tenantService, never()).getAllTenants();
+    }
+
+    @Test
+    void theTenantIsFoundFromTheResponseMessageToo() {
+        // The authentication filter stores the security context on the 
request; a response filter's current
+        // message is the response, which only reaches the request through the 
exchange.
+        javax.security.auth.Subject subject = new 
javax.security.auth.Subject();
+        subject.getPrincipals().add(new 
org.apache.unomi.api.security.TenantPrincipal("acme"));
+        org.apache.cxf.message.Message request = new 
org.apache.cxf.message.MessageImpl();
+        request.put(org.apache.cxf.security.SecurityContext.class,
+                new 
org.apache.cxf.interceptor.security.RolePrefixSecurityContextImpl(subject, 
"ROLE_", "prefix"));
+        org.apache.cxf.message.Message response = new 
org.apache.cxf.message.MessageImpl();
+        org.apache.cxf.message.Exchange exchange = new 
org.apache.cxf.message.ExchangeImpl();
+        exchange.setInMessage(request);
+        exchange.setOutMessage(response);
+        request.setExchange(exchange);
+        response.setExchange(exchange);
+
+        org.junit.jupiter.api.Assertions.assertEquals("acme", 
PublicTrackerSettings.tenantIdOf(request));
+        org.junit.jupiter.api.Assertions.assertEquals("acme", 
PublicTrackerSettings.tenantIdOf(response));
+        
org.junit.jupiter.api.Assertions.assertNull(PublicTrackerSettings.tenantIdOf(new
 org.apache.cxf.message.MessageImpl()));
+        
org.junit.jupiter.api.Assertions.assertNull(PublicTrackerSettings.tenantIdOf(null));
+    }
+
+    private static Tenant tenant(String id, Object origins) {
+        Tenant tenant = new Tenant();
+        tenant.setItemId(id);
+        Map<String, Object> properties = new HashMap<>();
+        if (origins != null) {
+            properties.put(PublicTrackerSettings.TENANT_ORIGINS_KEY, origins);
+        }
+        tenant.setProperties(properties);
+        return tenant;
+    }
+
+    private void set(String name, Object value) throws Exception {
+        Field field = PublicTrackerSettings.class.getDeclaredField(name);
+        field.setAccessible(true);
+        field.set(settings, value);
+    }
+}
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerSettingsTest.java 
b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerSettingsTest.java
index 925d7dee4..b604a510f 100644
--- 
a/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerSettingsTest.java
+++ 
b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerSettingsTest.java
@@ -153,7 +153,7 @@ class PublicTrackerSettingsTest {
     @Test
     void everyWatchedPropertyTriggersAReload() {
         for (String property : new 
String[]{PublicTrackerSettings.ALLOWED_ORIGINS_PROPERTY, 
PublicTrackerSettings.SAME_SITE_PROPERTY,
-                PublicTrackerSettings.MATCH_REFERER_PROPERTY,
+                PublicTrackerSettings.MATCH_REFERER_PROPERTY, 
PublicTrackerSettings.TENANT_LISTS_PROPERTY,
                 PublicTrackerSettings.COOKIE_NAME_PROPERTY, 
PublicTrackerSettings.COOKIE_DOMAIN_PROPERTY,
                 PublicTrackerSettings.COOKIE_MAX_AGE_PROPERTY, 
PublicTrackerSettings.COOKIE_HTTP_ONLY_PROPERTY}) {
             clearInvocations(configSharingService);
@@ -189,6 +189,8 @@ class PublicTrackerSettingsTest {
 
     @Test
     void misconfiguredSettingsAreReported() {
+        // With tenant lists off, so that an empty operator list leaves 
nothing to apply matchReferer to.
+        
lenient().when(configSharingService.getProperty(PublicTrackerSettings.TENANT_LISTS_PROPERTY)).thenReturn("false");
         
lenient().when(configSharingService.getProperty(PublicTrackerSettings.MATCH_REFERER_PROPERTY)).thenReturn("yes");
         
lenient().when(configSharingService.getProperty(PublicTrackerSettings.COOKIE_MAX_AGE_PROPERTY)).thenReturn(0);
         settings.activate();
@@ -256,6 +258,7 @@ class PublicTrackerSettingsTest {
 
     @Test
     void refusedSameSiteNoneIsReported() {
+        
lenient().when(configSharingService.getProperty(PublicTrackerSettings.TENANT_LISTS_PROPERTY)).thenReturn("false");
         
lenient().when(configSharingService.getProperty(PublicTrackerSettings.SAME_SITE_PROPERTY)).thenReturn("None");
         settings.activate();
         assertTrue(hasWarning("Refusing SameSite=None"));
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/tenants/TenantEndpointTrackerOriginsTest.java
 
b/rest/src/test/java/org/apache/unomi/rest/tenants/TenantEndpointTrackerOriginsTest.java
new file mode 100644
index 000000000..dd2ee3d68
--- /dev/null
+++ 
b/rest/src/test/java/org/apache/unomi/rest/tenants/TenantEndpointTrackerOriginsTest.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.tenants;
+
+import org.apache.unomi.api.tenants.Tenant;
+import org.apache.unomi.api.tenants.TenantService;
+import org.apache.unomi.rest.cors.PublicTrackerSettings;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import javax.ws.rs.WebApplicationException;
+import java.lang.reflect.Field;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * A tenant's website origins are set through the tenant API when tenants are 
provisioned (UNOMI-1013).
+ */
+@ExtendWith(MockitoExtension.class)
+class TenantEndpointTrackerOriginsTest {
+
+    @Mock private TenantService tenantService;
+    @Mock private PublicTrackerSettings publicTrackerSettings;
+
+    private TenantEndpoint endpoint;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        endpoint = new TenantEndpoint();
+        set("tenantService", tenantService);
+        set("publicTrackerSettings", publicTrackerSettings);
+    }
+
+    @Test
+    void creatingATenantWithItsOriginsAppliesThemAtOnce() {
+        TenantRequest request = 
request(Map.of(PublicTrackerSettings.TENANT_ORIGINS_KEY, 
List.of("https://shop.example";)));
+        when(tenantService.createTenant(anyString(), any())).thenReturn(new 
Tenant());
+
+        endpoint.createTenant(request);
+
+        verify(publicTrackerSettings).tenantsChanged();
+    }
+
+    @Test
+    void invalidOriginsAreRejectedBeforeTheTenantIsCreated() {
+        TenantRequest request = 
request(Map.of(PublicTrackerSettings.TENANT_ORIGINS_KEY, 
"https://shop.example/path, *"));
+
+        WebApplicationException error = 
assertThrows(WebApplicationException.class, () -> 
endpoint.createTenant(request));
+
+        assertEquals(400, error.getResponse().getStatus());
+        
assertTrue(String.valueOf(error.getResponse().getEntity()).contains("'*'"));
+        verify(tenantService, never()).createTenant(anyString(), any());
+        verify(publicTrackerSettings, never()).tenantsChanged();
+    }
+
+    @Test
+    void updatingOrDeletingATenantAppliesAtOnce() {
+        Tenant tenant = new Tenant();
+        tenant.setItemId("acme");
+        tenant.setProperties(Map.of(PublicTrackerSettings.TENANT_ORIGINS_KEY, 
"https://blog.example";));
+        when(tenantService.getTenant("acme")).thenReturn(tenant);
+
+        endpoint.updateTenant("acme", tenant);
+        endpoint.deleteTenant("acme");
+
+        verify(tenantService).saveTenant(tenant);
+        verify(tenantService).deleteTenant("acme");
+        verify(publicTrackerSettings, 
org.mockito.Mockito.times(2)).tenantsChanged();
+    }
+
+    @Test
+    void invalidOriginsAreRejectedOnUpdate() {
+        Tenant tenant = new Tenant();
+        tenant.setItemId("acme");
+        tenant.setProperties(Map.of(PublicTrackerSettings.TENANT_ORIGINS_KEY, 
42));
+        when(tenantService.getTenant("acme")).thenReturn(tenant);
+
+        WebApplicationException error = 
assertThrows(WebApplicationException.class, () -> endpoint.updateTenant("acme", 
tenant));
+
+        assertEquals(400, error.getResponse().getStatus());
+        verify(tenantService, never()).saveTenant(any());
+    }
+
+    private static TenantRequest request(Map<String, Object> properties) {
+        TenantRequest request = new TenantRequest();
+        request.setRequestedId("acme");
+        request.setProperties(properties);
+        return request;
+    }
+
+    private void set(String name, Object value) throws Exception {
+        Field field = TenantEndpoint.class.getDeclaredField(name);
+        field.setAccessible(true);
+        field.set(endpoint, value);
+    }
+}
diff --git 
a/web-servlets/src/main/java/org/apache/unomi/web/servlets/WebConfig.java 
b/web-servlets/src/main/java/org/apache/unomi/web/servlets/WebConfig.java
index cf4a88567..247a2662e 100644
--- a/web-servlets/src/main/java/org/apache/unomi/web/servlets/WebConfig.java
+++ b/web-servlets/src/main/java/org/apache/unomi/web/servlets/WebConfig.java
@@ -42,6 +42,7 @@ public class WebConfig {
     private String contextserverProfileIdCookieSameSite;
     private String publicCorsAllowedOrigins;
     private String publicCorsMatchReferer;
+    private String publicCorsTenantOrigins;
     private String allowedProfileDownloadFormats;
     private int publicPostRequestBytesLimit;
 
@@ -75,6 +76,9 @@ public class WebConfig {
         @AttributeDefinition
         String public_cors_matchReferer() default "false";
 
+        @AttributeDefinition
+        String public_cors_tenantOrigins() default "true";
+
         @AttributeDefinition
         String allowed_profile_download_formats() default "csv,yaml,json,text";
 
@@ -96,6 +100,7 @@ public class WebConfig {
         this.contextserverProfileIdCookieSameSite = 
config.contextserver_profileIdCookieSameSite();
         this.publicCorsAllowedOrigins = config.public_cors_allowedOrigins();
         this.publicCorsMatchReferer = config.public_cors_matchReferer();
+        this.publicCorsTenantOrigins = config.public_cors_tenantOrigins();
         this.allowedProfileDownloadFormats = 
config.allowed_profile_download_formats();
         this.publicPostRequestBytesLimit = 
config.public_post_request_bytes_limit();
 
@@ -107,6 +112,7 @@ public class WebConfig {
         // with no origins yet would be refused, with a warning, on the way.
         configSharingService.setProperty("publicCorsAllowedOrigins", 
publicCorsAllowedOrigins);
         configSharingService.setProperty("publicCorsMatchReferer", 
publicCorsMatchReferer);
+        configSharingService.setProperty("publicCorsTenantOrigins", 
publicCorsTenantOrigins);
         configSharingService.setProperty("profileIdCookieSameSite", 
contextserverProfileIdCookieSameSite);
         configSharingService.setProperty("publicPostRequestBytesLimit", 
publicPostRequestBytesLimit);
         configSharingService.setProperty("allowedProfileDownloadFormats", 
allowedProfileDownloadFormats);
diff --git a/web-servlets/src/main/resources/org.apache.unomi.web.cfg 
b/web-servlets/src/main/resources/org.apache.unomi.web.cfg
index 675a4b6e8..55c629333 100644
--- a/web-servlets/src/main/resources/org.apache.unomi.web.cfg
+++ b/web-servlets/src/main/resources/org.apache.unomi.web.cfg
@@ -34,6 +34,8 @@ 
public.cors.allowedOrigins=${org.apache.unomi.public.cors.allowedOrigins:-}
 # Let a listed website that embeds context.js with a <script> tag (no Origin 
header) be recognised by its
 # Referer. Off by default: it also trusts anything that website frames, such 
as sandboxed ads.
 public.cors.matchReferer=${org.apache.unomi.public.cors.matchReferer:-false}
+# Let each tenant add its own website origins (publicCorsAllowedOrigins tenant 
property). Only false turns it off.
+public.cors.tenantOrigins=${org.apache.unomi.public.cors.tenantOrigins:-true}
 #Allowed profile download formats, actually only csv (horizontal and 
vertical), json, text and yaml are allowed.
 
allowed.profile.download.formats=${org.apache.unomi.profile.download.formats:-csv,yaml,json,text}
 # This setting allow for request size (Content-length) protection. Checking 
that the requests do not exceed the limit.
diff --git 
a/web-servlets/src/test/java/org/apache/unomi/web/servlets/WebConfigTest.java 
b/web-servlets/src/test/java/org/apache/unomi/web/servlets/WebConfigTest.java
index f243ca8b3..899d81ce2 100644
--- 
a/web-servlets/src/test/java/org/apache/unomi/web/servlets/WebConfigTest.java
+++ 
b/web-servlets/src/test/java/org/apache/unomi/web/servlets/WebConfigTest.java
@@ -35,6 +35,7 @@ class WebConfigTest {
         WebConfig.Config config = mock(WebConfig.Config.class);
         
when(config.contextserver_profileIdCookieHttpOnly()).thenReturn("flase");
         when(config.public_cors_matchReferer()).thenReturn("yes");
+        when(config.public_cors_tenantOrigins()).thenReturn("off");
         
when(config.public_cors_allowedOrigins()).thenReturn("https://shop.example";);
         
when(config.contextserver_profileIdCookieSameSite()).thenReturn("None");
         ConfigSharingService configSharingService = 
mock(ConfigSharingService.class);
@@ -47,9 +48,11 @@ class WebConfigTest {
 
         verify(configSharingService).setProperty("profileIdCookieHttpOnly", 
"flase");
         verify(configSharingService).setProperty("publicCorsMatchReferer", 
"yes");
+        verify(configSharingService).setProperty("publicCorsTenantOrigins", 
"off");
         // Origins before SameSite, so SameSite=None is never resolved against 
an empty list on the way.
         InOrder order = inOrder(configSharingService);
         
order.verify(configSharingService).setProperty("publicCorsAllowedOrigins", 
"https://shop.example";);
+        
order.verify(configSharingService).setProperty("publicCorsTenantOrigins", 
"off");
         
order.verify(configSharingService).setProperty("profileIdCookieSameSite", 
"None");
     }
 }

Reply via email to