You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@unomi.apache.org by sh...@apache.org on 2026/10/22 18:26:25 UTC
(unomi) branch master updated: UNOMI-1013: Let each tenant list the websites that can use its tracker (#921)
This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new b795eaa69 UNOMI-1013: Let each tenant list the websites that can use its tracker (#921)
b795eaa69 is described below
commit b795eaa693df0b398c683dad5d0aedb026c529af
Author: Serge Huber <sh...@apache.org>
AuthorDate: Thu Oct 22 20:26:19 2026 +0200
UNOMI-1013: Let each tenant list the websites that can use its tracker (#921)
Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
---
.../apache/unomi/api/tenants/TenantService.java | 12 +
docker/README.md | 8 +
.../test/java/org/apache/unomi/itests/AllITs.java | 1 +
.../unomi/itests/TenantTrackerOriginsIT.java | 286 +++++++++++++++++++++
manual/src/main/asciidoc/configuration.adoc | 41 ++-
.../main/asciidoc/how-profile-tracking-works.adoc | 7 +-
.../main/asciidoc/javascript-tracker-guide.adoc | 2 +-
.../asciidoc/migrations/migrate-3.0-to-4.0.adoc | 4 +-
.../asciidoc/migrations/v2-compatibility-mode.adoc | 2 +-
manual/src/main/asciidoc/multitenancy.adoc | 21 +-
manual/src/main/asciidoc/web-tracker.adoc | 2 +-
manual/src/main/asciidoc/whats-new.adoc | 3 +-
.../main/resources/etc/custom.system.properties | 7 +-
.../unomi/rest/cors/PublicTrackerCorsFilter.java | 17 +-
.../unomi/rest/cors/PublicTrackerCorsPolicy.java | 95 ++++++-
.../unomi/rest/cors/PublicTrackerSettings.java | 252 +++++++++++++++---
.../org/apache/unomi/rest/cors/RequestTenant.java | 72 ++++++
.../unomi/rest/cors/TenantOriginsSnapshot.java | 237 +++++++++++++++++
.../rest/service/impl/RestServiceUtilsImpl.java | 8 +-
.../apache/unomi/rest/tenants/TenantEndpoint.java | 53 +++-
.../config/ShippedProfileCookieConfigTest.java | 5 +
.../rest/cors/PublicTrackerCorsFilterTest.java | 38 +++
.../rest/cors/PublicTrackerCorsPolicyTest.java | 31 +++
.../rest/cors/PublicTrackerSettingsTenantTest.java | 206 +++++++++++++++
.../unomi/rest/cors/PublicTrackerSettingsTest.java | 18 +-
.../apache/unomi/rest/cors/RequestTenantTest.java | 62 +++++
.../unomi/rest/cors/TenantOriginsSnapshotTest.java | 198 ++++++++++++++
.../RestServiceUtilsImplProfileBindingTest.java | 27 ++
.../tenants/TenantEndpointTrackerOriginsTest.java | 144 +++++++++++
.../services/impl/tenants/TenantServiceImpl.java | 61 ++++-
.../unomi/services/impl/TestTenantService.java | 15 ++
.../impl/tenants/TenantServiceImplTest.java | 84 ++++++
.../org/apache/unomi/web/servlets/WebConfig.java | 6 +
.../src/main/resources/org.apache.unomi.web.cfg | 7 +-
.../apache/unomi/web/servlets/WebConfigTest.java | 3 +
35 files changed, 1951 insertions(+), 84 deletions(-)
diff --git a/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java b/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java
index 136e80e17..a5db6e557 100644
--- a/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java
+++ b/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java
@@ -95,6 +95,18 @@ public interface TenantService {
*/
void saveTenant(Tenant tenant);
+ /**
+ * Replaces a tenant's settings (name, description, status, properties, restricted event types and
+ * authorized IPs) with those of the given tenant, keeping its API keys, creation date and system metadata.
+ * The write is a compare-and-set against the stored tenant, retried if the tenant changed in between, so
+ * it never undoes a concurrent API key change.
+ *
+ * @param settings the tenant id and the new settings; its API keys and creation date are ignored
+ * @return the tenant as saved, or {@code null} if no tenant has that id
+ * @throws java.util.ConcurrentModificationException if the tenant kept changing while it was being updated
+ */
+ Tenant updateTenantSettings(Tenant settings);
+
/**
* Deletes a tenant and all associated data from the system.
*
diff --git a/docker/README.md b/docker/README.md
index 06e9fbe30..6f276c67d 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -131,6 +131,14 @@ Note: Linux doesn't support the host.docker.internal DNS lookup method yet, it s
- `UNOMI_AUTO_START`: Boolean to specify if unomi auto start with karaf (defaults to `true`)
- `UNOMI_DISTRIBUTION`: Specifies the Unomi Distribution Feature to use (`unomi-distribution-elasticsearch` or `unomi-distribution-opensearch`, defaults to `unomi-distribution-elasticsearch`)
+### Website origins and visitor cookie
+- `UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS`: Comma-separated website origins (`https://www.example.com`) that may use the visitor cookie with every tenant's key (default: none)
+- `UNOMI_PUBLIC_CORS_TENANT_ORIGINS`: Whether each tenant may list its own websites in its `publicCorsAllowedOrigins` property (default: `true`; only `false` turns it off)
+- `UNOMI_PUBLIC_CORS_MATCH_REFERER`: Recognise a listed website that embeds `context.js` with a `<script>` tag by its `Referer` (default: `false`)
+- `UNOMI_PROFILE_COOKIE_SAMESITE`: `Lax` (default), `Strict` or `None`; `None` needs HTTPS
+
+See "Websites on other origins" in the manual's configuration chapter.
+
### ElasticSearch-specific Variables
- `UNOMI_ELASTICSEARCH_ADDRESSES`: ElasticSearch host:port (default: localhost:9200)
- `UNOMI_ELASTICSEARCH_USERNAME`: Optional username for ElasticSearch
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..685d33ea1
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/TenantTrackerOriginsIT.java
@@ -0,0 +1,286 @@
+/*
+ * 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 {
+ if (tenantService.getTenant(tenantId) != null) {
+ 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"));
+ }
+ }
+
+ @Test
+ public void updatingATenantThroughTheApiKeepsItsKeysAndAppliesAtOnce() throws Exception {
+ String origin = "https://tenant-d-" + shortId() + ".example";
+ String key = createTenantThroughApi(null);
+ String tenantId = lastTenantId;
+
+ // Read the tenant as REST returns it (no key hashes), add the origin, send it back.
+ putTenant(tenantId, tenant -> ((com.fasterxml.jackson.databind.node.ObjectNode) tenant.get("properties"))
+ .putArray(TENANT_ORIGINS_KEY).add(origin));
+
+ // No index refresh: the node that handled the PUT applies it at once, and the key still works.
+ HttpPost request = contextRequest(key);
+ request.setHeader("Origin", origin);
+ request.setHeader("Sec-Fetch-Site", "cross-site");
+ try (CloseableHttpResponse response = execute(request)) {
+ assertEquals("The tenant's key must survive the update", 200, response.getStatusLine().getStatusCode());
+ assertEquals(origin, header(response, "Access-Control-Allow-Origin"));
+ assertEquals("true", header(response, "Access-Control-Allow-Credentials"));
+ }
+
+ putTenant(tenantId, tenant -> ((com.fasterxml.jackson.databind.node.ObjectNode) tenant.get("properties"))
+ .remove(TENANT_ORIGINS_KEY));
+ HttpPost after = contextRequest(key);
+ after.setHeader("Origin", origin);
+ after.setHeader("Sec-Fetch-Site", "cross-site");
+ try (CloseableHttpResponse response = execute(after)) {
+ assertEquals(200, response.getStatusLine().getStatusCode());
+ assertEquals("*", header(response, "Access-Control-Allow-Origin"));
+ assertNull(header(response, "Access-Control-Allow-Credentials"));
+ }
+ }
+
+ private void putTenant(String tenantId, java.util.function.Consumer<com.fasterxml.jackson.databind.JsonNode> change) throws Exception {
+ com.fasterxml.jackson.databind.JsonNode tenant;
+ try (CloseableHttpResponse response = executeHttpRequest(new org.apache.http.client.methods.HttpGet(
+ getFullUrl("/cxs/tenants/" + tenantId)), AuthType.JAAS_ADMIN)) {
+ assertEquals(200, response.getStatusLine().getStatusCode());
+ tenant = getObjectMapper().readTree(EntityUtils.toString(response.getEntity()));
+ }
+ change.accept(tenant);
+ org.apache.http.client.methods.HttpPut put = new org.apache.http.client.methods.HttpPut(getFullUrl("/cxs/tenants/" + tenantId));
+ put.setEntity(new StringEntity(getObjectMapper().writeValueAsString(tenant), ContentType.APPLICATION_JSON));
+ try (CloseableHttpResponse response = executeHttpRequest(put, AuthType.JAAS_ADMIN)) {
+ assertEquals(EntityUtils.toString(response.getEntity()), 200, response.getStatusLine().getStatusCode());
+ }
+ }
+
+ private String lastTenantId;
+
+ /** 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();
+ lastTenantId = tenantId;
+ 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));
+ // Recorded first, so the tenant is cleaned up even if the assertion below fails after the server created it.
+ createdTenants.add(tenantId);
+ try (CloseableHttpResponse response = executeHttpRequest(create, AuthType.JAAS_ADMIN)) {
+ assertEquals(EntityUtils.toString(response.getEntity()), 200, response.getStatusLine().getStatusCode());
+ }
+ 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..ddb1408fd 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"]
}
}'
@@ -329,7 +331,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"]
},
"creationDate": "2024-03-14T10:30:00Z",
"lastModificationDate": "2024-03-14T10:30:00Z"
@@ -553,7 +556,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 +590,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:
@@ -608,8 +612,9 @@ export UNOMI_PROFILE_COOKIE_SAMESITE=None
* Wildcards, paths and `null` are not accepted. A rejected entry is logged as a warning when the setting is applied, and so is a list in which no entry is valid.
* 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.
-* `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.
+* `SameSite` accepts `Lax` (default), `Strict` and `None`. `None` is used only when the cookie is `Secure` and origins can be listed: the server list is not empty, or tenant lists are on (the default). Otherwise Unomi uses `Lax` and logs a warning. Either way, only listed websites can use the cookie.
+* `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 is logged as a warning, and so is `true` when nothing can be listed (an empty server list with tenant lists off).
* Changes are applied without a restart and are parsed once, not on every request.
Do not:
@@ -620,6 +625,30 @@ 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 tenant's settings (but never its API keys): 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 tenant created, updated or deleted through `/cxs/tenants` applies at once on the node that handled it. Other nodes pick up a new tenant on its first request, and any other change, such as an origin added to or removed from an existing tenant, within 30 seconds.
+* `PUT /cxs/tenants/{tenantId}` replaces the tenant's settings but never its API keys, which are managed through `/cxs/tenants/{tenantId}/apikeys`, nor its creation date; those fields in the request body are ignored. A tenant read with `GET` can therefore be sent back as is. The write fails with `409` instead of undoing a concurrent API key change; retry it.
+* Tenants saved without going through `/cxs/tenants`, for example with the developer shell commands, are not validated: invalid entries are left out and logged as a warning.
+* `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 +691,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/how-profile-tracking-works.adoc b/manual/src/main/asciidoc/how-profile-tracking-works.adoc
index 4a470e1ad..e177f4743 100644
--- a/manual/src/main/asciidoc/how-profile-tracking-works.adoc
+++ b/manual/src/main/asciidoc/how-profile-tracking-works.adoc
@@ -820,12 +820,12 @@ Cookies are only set for regular profiles, not for Personas. If a `personaId` is
* **Cookie Attributes**:
* `Path=/` - Available for all paths on the domain
* `Max-Age=31536000` - Valid for 1 year by default (configurable via `org.apache.unomi.profile.cookie.maxAgeInSeconds`)
- * `SameSite=Lax` - CSRF protection. Configurable via `org.apache.unomi.profile.cookie.sameSite`; `None` is used only with `Secure` and a non-empty list of allowed origins
+ * `SameSite=Lax` - CSRF protection. Configurable via `org.apache.unomi.profile.cookie.sameSite`; `None` is used only with `Secure`, and only once origins can be listed (a non-empty server list, or tenant lists on, the default)
* `Secure` - Set if the request reached Unomi over HTTPS, directly or through a trusted proxy that sends `X-Forwarded-Proto: https`
* `HttpOnly` - Configurable via `org.apache.unomi.profile.cookie.httpOnly` (default: `true`; only an explicit `false` turns it off)
* `Domain` - Configurable via `org.apache.unomi.profile.cookie.domain`. Unomi's own host, or a parent domain of it; never the website's domain when the website is on another site
-The cookie is used and set only for pages on Unomi's own origin, for origins listed in `org.apache.unomi.public.cors.allowedOrigins`, and for requests that do not come from a page on another site. A request from any other page is served with a new anonymous profile, and the visitor's cookie is left untouched. See <<_public_tracker_origins,Websites on other origins>>.
+The cookie is used and set only for pages on Unomi's own origin, for origins listed in `org.apache.unomi.public.cors.allowedOrigins` or in the requesting tenant's `publicCorsAllowedOrigins` property, and for requests that do not come from a page on another site. A request from any other page is served with a new anonymous profile, and the visitor's cookie is left untouched. See <<_public_tracker_origins,Websites on other origins>>.
This cookie allows the browser to automatically send the profile ID on subsequent requests, enabling Apache Unomi to load the existing profile.
@@ -1202,8 +1202,9 @@ The following configuration properties control profile tracking behavior:
* `org.apache.unomi.profile.cookie.maxAgeInSeconds` - Cookie expiration time (default: `31536000` = 1 year)
* `org.apache.unomi.profile.cookie.httpOnly` - Whether cookie is HTTP-only (default: `true`; only `false` turns it off, any other value keeps it on and is logged as a warning). When `true`, browser JavaScript cannot read the cookie; the browser still sends it on requests. Prefer reading `profileId` from the context JSON response.
* `org.apache.unomi.profile.cookie.domain` - Cookie domain (optional)
-* `org.apache.unomi.profile.cookie.sameSite` - `Lax` (default), `Strict` or `None`; `None` needs `Secure` and a non-empty origin list
+* `org.apache.unomi.profile.cookie.sameSite` - `Lax` (default), `Strict` or `None`; `None` needs `Secure`, and a non-empty server list or tenant lists on (the default)
* `org.apache.unomi.public.cors.allowedOrigins` - Website origins, other than Unomi's own, that may use the cookie (default: none). See <<_public_tracker_origins,Websites on other origins>>
+* `org.apache.unomi.public.cors.tenantOrigins` - Whether each tenant may list its own websites in its `publicCorsAllowedOrigins` property (default: `true`). See <<_public_tracker_tenant_origins,Websites listed by each tenant>>
* `org.apache.unomi.public.cors.matchReferer` - Recognise a listed website that embeds `context.js` with a `<script>` tag by its `Referer` (default: `false`). See <<_public_tracker_script_tag,Embedding `context.js` with a script tag>>
These can be configured in `etc/custom.system.properties` / `org.apache.unomi.web.cfg`.
diff --git a/manual/src/main/asciidoc/javascript-tracker-guide.adoc b/manual/src/main/asciidoc/javascript-tracker-guide.adoc
index 4deec1519..04ecec077 100644
--- a/manual/src/main/asciidoc/javascript-tracker-guide.adoc
+++ b/manual/src/main/asciidoc/javascript-tracker-guide.adoc
@@ -922,7 +922,7 @@ The visitor cookie is set by Unomi, not by page script: it is `HttpOnly` by defa
When the website and Unomi are on different origins:
* send requests with credentials (`xhr.withCredentials = true` or `fetch(..., {credentials: 'include'})`);
-* have the operator list the website origin in `UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS`;
+* have the website origin listed, by the operator in `UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS` or in the tenant's `publicCorsAllowedOrigins` property;
* for a website on another site (another registrable domain), also serve Unomi over HTTPS with `SameSite=None`, knowing that browsers increasingly block such third-party cookies.
Serving Unomi from the website's own origin through a reverse proxy avoids all of this. See <<_public_tracker_origins,Websites on other origins>>.
diff --git a/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc b/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc
index 95de931c5..3508ff2b8 100644
--- a/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc
+++ b/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc
@@ -444,7 +444,7 @@ In addition to tenant API keys, Unomi 4.0 hardens several contracts that can bre
|Tracker on another origin
|Public endpoints answered any origin with credentialed CORS and never checked where a request came from. A page on another subdomain could use the visitor cookie. A website on another site never received it (the cookie was always `SameSite=Lax`), so it kept the profile id itself and sent it as body `profileId`.
-|Only origins listed in `org.apache.unomi.public.cors.allowedOrigins` (`UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS`) get credentialed CORS and have the cookie used and set; other pages get a new anonymous profile and their cookie is left untouched. The default is empty. A website served from Unomi's own origin through a reverse proxy needs no change. One on another subdomain must be listed. One on another site must be listed and, since body `profileId` is now ignored for public callers, also need [...]
+|Only origins listed in `org.apache.unomi.public.cors.allowedOrigins` (`UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS`), or in the requesting tenant's `publicCorsAllowedOrigins` property, get credentialed CORS and have the cookie used and set; other pages get a new anonymous profile and their cookie is left untouched. The default is empty. A website served from Unomi's own origin through a reverse proxy needs no change. One on another subdomain must be listed. One on another site must be listed and, [...]
|Profile cookie `HttpOnly`
|Often defaulted to `false` (JS could read `document.cookie`)
@@ -498,7 +498,7 @@ console (port 8102) is not covered by that check.
===== Migration checklist for client applications
-* Browser / tracker clients: ensure cookies are sent (`withCredentials` / same-site setup); list every website origin that calls Unomi from another origin in `UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS` (and, for another site, use HTTPS with `SameSite=None`, or serve Unomi from the website's origin); stop treating body `profileId` as authoritative for public calls.
+* Browser / tracker clients: ensure cookies are sent (`withCredentials` / same-site setup); list every website origin that calls Unomi from another origin in `UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS` or in its tenant's `publicCorsAllowedOrigins` property (and, for another site, use HTTPS with `SameSite=None`, or serve Unomi from the website's origin); stop treating body `profileId` as authoritative for public calls.
* Headless or mobile public clients that stored a UUID and posted it only in the body: switch to sending the profile cookie, or call with a **private key** when you intentionally bind a profile.
* Login / merge flows: emit merge-triggering events from a trusted server-side caller after real authentication — not from the public key alone.
* Ops scripts and Docker: export both password env vars; replace `karaf:karaf` with `karaf:$UNOMI_ROOT_PASSWORD`.
diff --git a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
index 52f551d42..c1d48143b 100644
--- a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
+++ b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
@@ -33,7 +33,7 @@ When single-tenant compatibility mode is enabled:
about 30 seconds, so public requests do not wait on each other or re-read it from the database
every time. A change made on another node is picked up after about that time.
- **No authentication** is required for non-protected events (like V2)
-- **The visitor cookie is limited to listed websites** (unlike V2). A website on another origin, another subdomain included, must be listed in `UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS` for its requests to use and receive the cookie, whether it calls `context.json` or embeds `context.js` with a `<script>` tag. A plain `<script>` tag (without a `crossorigin` attribute) sends no `Origin`, so the listed website is then recognised by its `Referer`, which must be turned on with `org.apache.unomi.pub [...]
+- **The visitor cookie is limited to listed websites** (unlike V2). A website on another origin, another subdomain included, must be listed, in `UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS` or in the `default` tenant's `publicCorsAllowedOrigins` property, for its requests to use and receive the cookie, whether it calls `context.json` or embeds `context.js` with a `<script>` tag. A plain `<script>` tag (without a `crossorigin` attribute) sends no `Origin`, so the listed website is then recognised b [...]
This allows V2 clients to work with Unomi V3 immediately after migration, giving you time to gradually update client applications to use the new V3 authentication model.
diff --git a/manual/src/main/asciidoc/multitenancy.adoc b/manual/src/main/asciidoc/multitenancy.adoc
index cd36baa02..89799171c 100644
--- a/manual/src/main/asciidoc/multitenancy.adoc
+++ b/manual/src/main/asciidoc/multitenancy.adoc
@@ -65,11 +65,14 @@ curl -X POST http://localhost:8181/cxs/tenants \
"requestedId": "my-tenant",
"properties": {
"name": "My Organization",
- "description": "My organization description"
+ "description": "My organization description",
+ "publicCorsAllowedOrigins": ["https://www.my-organization.com"]
}
}'
----
+`publicCorsAllowedOrigins` is optional: it lists the websites that may use the visitor cookie with this tenant's key (see <<_public_tracker_tenant_origins,Websites listed by each tenant>>). An invalid entry is refused with `400`.
+
The create response includes the tenant with **masked** API key metadata only (plaintext secrets are not returned and cannot be recovered from storage):
[source,json]
@@ -393,17 +396,19 @@ curl -X GET http://localhost:8181/cxs/tenants \
===== Updating a Tenant
+`PUT` replaces the tenant's settings with the body, which must carry the tenant's `itemId`. Read the tenant first, change what you need, and send it back. API keys and the creation date are never changed by `PUT`: keys are managed with the API key endpoints below, and those fields in the body are ignored. If the tenant changes while the update is being applied, for example because one of its keys is regenerated, the update answers `409` rather than undoing that change; send it again.
+
[source,bash]
----
-curl -X PUT http://localhost:8181/cxs/tenants/my-tenant \
- --user "karaf:$UNOMI_ROOT_PASSWORD" \
- -H "Content-Type: application/json" \
- -d '{
- "displayName": "Updated Organization Name",
- "description": "Updated description"
- }'
+curl -s --user "karaf:$UNOMI_ROOT_PASSWORD" http://localhost:8181/cxs/tenants/my-tenant \
+ | jq '.properties.name = "Updated Organization Name"' \
+ | curl -X PUT http://localhost:8181/cxs/tenants/my-tenant \
+ --user "karaf:$UNOMI_ROOT_PASSWORD" \
+ -H "Content-Type: application/json" --data-binary @-
----
+The websites that may use the visitor cookie with the tenant's key are listed in its `publicCorsAllowedOrigins` property; see <<_public_tracker_tenant_origins,Websites listed by each tenant>>.
+
===== Regenerating API Keys
Regenerating replaces any existing key of the same type. Store `plainTextKey` from the response immediately.
diff --git a/manual/src/main/asciidoc/web-tracker.adoc b/manual/src/main/asciidoc/web-tracker.adoc
index cd5341099..4c874ece6 100644
--- a/manual/src/main/asciidoc/web-tracker.adoc
+++ b/manual/src/main/asciidoc/web-tracker.adoc
@@ -213,7 +213,7 @@ It is possible to change the name of these cookie by passing the following prope
Please note however that the `contextServerCookieName` will also have to be changed in the server configuration in order for it to work. See the <<_configuration,Configuration>> section for details on how to do this.
-The tracker sends requests with cookies. When the website and Unomi are on different origins, another subdomain included (`www.example.com` and `cdp.example.com`), list the website origin on the server (`UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS`); otherwise the browser blocks the responses and Unomi does not use the visitor cookie. A website served from the same origin as Unomi, through a reverse proxy, needs no list. A website on another site also needs HTTPS and `SameSite=None`; see <<_public [...]
+The tracker sends requests with cookies. When the website and Unomi are on different origins, another subdomain included (`www.example.com` and `cdp.example.com`), list the website origin on the server (`UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS`) or in the tenant's `publicCorsAllowedOrigins` property; otherwise the browser blocks the responses and Unomi does not use the visitor cookie. A website served from the same origin as Unomi, through a reverse proxy, needs no list. A website on another s [...]
For session tracking, it is important to provide a value for the cookie otherwise the tracker will not initialize (a message is displayed in the console that explains that the session cookie is missing). Here is the code excerpt from the initialization code used in the tutorial that creates the initial cookie value.
diff --git a/manual/src/main/asciidoc/whats-new.adoc b/manual/src/main/asciidoc/whats-new.adoc
index edbea6213..480c9c4c3 100644
--- a/manual/src/main/asciidoc/whats-new.adoc
+++ b/manual/src/main/asciidoc/whats-new.adoc
@@ -34,10 +34,11 @@ Unomi 4.0 requires explicit admin and health-check passwords at startup, treats
==== Visitor cookie across websites
-The public tracker endpoints answer any origin, but only the operator's list of website origins (`UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS`) and Unomi's own origin may use the visitor cookie. The list is empty by default. `SameSite` is configurable (`UNOMI_PROFILE_COOKIE_SAMESITE`), and `None` requires HTTPS and a non-empty list. Websites on another subdomain that used the cookie with Unomi 2.x or 3.x must be listed; websites on another site must also use HTTPS with `SameSite=None`, or serve Un [...]
+The public tracker endpoints answer any origin, but only listed websites and Unomi's own origin may use the visitor cookie. Websites are listed by the operator for every tenant (`UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS`, empty by default) or by each tenant for itself (its `publicCorsAllowedOrigins` property). `SameSite` is configurable (`UNOMI_PROFILE_COOKIE_SAMESITE`), and `None` requires HTTPS. Websites on another subdomain that used the cookie with Unomi 2.x or 3.x must be listed; websites [...]
* 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..f18379dab 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -323,7 +323,8 @@ org.apache.unomi.profile.cookie.maxAgeInSeconds=${env:UNOMI_PROFILE_COOKIE_MAXAG
# value keeps it on and is logged as a warning.
org.apache.unomi.profile.cookie.httpOnly=${env:UNOMI_PROFILE_COOKIE_HTTPONLY:-true}
# SameSite for the visitor cookie. Keep Lax unless the sites really are different and the cookie is Secure.
-# None is refused unless Secure is also set and UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS is not empty.
+# None is refused unless Secure is also set and origins can be listed: UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS is not
+# empty, or UNOMI_PUBLIC_CORS_TENANT_ORIGINS is not false (tenants list their own websites).
org.apache.unomi.profile.cookie.sameSite=${env:UNOMI_PROFILE_COOKIE_SAMESITE:-Lax}
# Website origins that may send the visitor cookie to the public tracker endpoints.
# Example: https://www.example.com,https://shop.example.com:8443
@@ -333,6 +334,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..4af2e5d73 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 RequestTenant.id();
+ }
+
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..86d9f540b 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;
@@ -70,8 +71,7 @@ public final class PublicTrackerCorsPolicy {
if (entry.isEmpty()) {
continue;
}
- // An origin has no path, but a trailing slash is an easy mistake to make in the list.
- String origin = normalize(entry.endsWith("/") ? entry.substring(0, entry.length() - 1) : entry);
+ String origin = normalizeEntry(entry);
if (origin == null) {
warnings.add("Ignoring public CORS allowed origin '" + entry + "': expected scheme://host[:port] "
+ "with http or https, no path and no wildcard");
@@ -82,6 +82,84 @@ 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 = normalizeEntry(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 (normalizeEntry(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);
+ }
+
+ /**
+ * A configured list entry as an origin, or {@code null} when it is not one. The server list and tenant
+ * lists both go through here, so they follow the same rules. An origin has no path, but a trailing slash
+ * is an easy mistake to make in a list, so it is ignored.
+ */
+ static String normalizeEntry(String entry) {
+ return normalize(entry.endsWith("/") ? entry.substring(0, entry.length() - 1) : entry);
+ }
+
/**
* 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.
@@ -219,13 +297,24 @@ public final class PublicTrackerCorsPolicy {
* {@code SameSite=None} is only valid together with {@code Secure} and a non-empty origin list.
* Anything else falls back to {@code Lax}.
*/
+ /**
+ * {@link #resolveSameSite(String, boolean, boolean)} for this list alone, ignoring tenant lists.
+ */
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..cc85bc900 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
@@ -19,6 +19,9 @@ package org.apache.unomi.rest.cors;
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;
@@ -26,40 +29,55 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.atomic.AtomicBoolean;
+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_PROPERTY_ALLOWED_ORIGINS}
+ * property; those are kept in a {@link TenantOriginsSnapshot}. 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);
- // Published by WebConfig through ConfigSharingService.
+ // Operator settings, published by WebConfig through ConfigSharingService.
static final String ALLOWED_ORIGINS_PROPERTY = "publicCorsAllowedOrigins";
static final String SAME_SITE_PROPERTY = "profileIdCookieSameSite";
static final String MATCH_REFERER_PROPERTY = "publicCorsMatchReferer";
+ /** Whether tenants may list their own origins (org.apache.unomi.public.cors.tenantOrigins). */
+ static final String TENANT_ORIGINS_ENABLED_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. Not to be
+ * confused with {@link #ALLOWED_ORIGINS_PROPERTY}, the operator's list, which has the same name.
+ */
+ public static final String TENANT_PROPERTY_ALLOWED_ORIGINS = "publicCorsAllowedOrigins";
+
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,
+ private static final List<String> WATCHED = List.of(
+ ALLOWED_ORIGINS_PROPERTY, SAME_SITE_PROPERTY, MATCH_REFERER_PROPERTY, TENANT_ORIGINS_ENABLED_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;
+ final boolean tenantOriginsEnabled;
final String sameSiteWhenSecure;
final String cookieName;
/** {@code name=}, then the attributes after the value, for secure and plain HTTP requests. */
@@ -73,12 +91,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 tenantOriginsEnabled, String requestedSameSite, String cookieName,
+ String cookieDomain, int cookieMaxAge, boolean cookieHttpOnly, List<String> warnings) {
this.policy = policy;
+ this.tenantOriginsEnabled = tenantOriginsEnabled;
this.warnings = List.copyOf(warnings);
- this.sameSiteWhenSecure = policy.resolveSameSite(requestedSameSite, true);
- String sameSiteWhenInsecure = policy.resolveSameSite(requestedSameSite, false);
+ boolean originsListed = !policy.isEmpty() || tenantOriginsEnabled;
+ 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,33 +107,75 @@ 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 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 TenantOriginsSnapshot.State origins;
+ final Map<String, PublicTrackerCorsPolicy> byTenant;
+ final Set<String> anyTenant;
+
+ Merged(Resolved base, TenantOriginsSnapshot.State 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);
+ private volatile Resolved resolved = resolve(Collections.emptyMap());
+ /** {@code null} until activation, and in tests that do not set one: no tenant origins then. */
+ private volatile TenantOriginsSnapshot tenantOrigins;
+ private volatile Merged merged;
+ /** 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() {
}
- /** For tests; cookie name, domain, max age and HttpOnly take their defaults. */
+ /** For tests; tenant origins off, cookie name, domain, max age and HttpOnly take their defaults. */
public PublicTrackerSettings(String allowedOrigins, String sameSite) {
this(allowedOrigins, sameSite, false);
}
- /** For tests; cookie name, domain, max age and HttpOnly take their defaults. */
+ /** For tests; tenant origins off, 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);
+ Map<String, Object> raw = new HashMap<>();
+ raw.put(ALLOWED_ORIGINS_PROPERTY, allowedOrigins);
+ raw.put(SAME_SITE_PROPERTY, sameSite);
+ raw.put(MATCH_REFERER_PROPERTY, matchReferer);
+ raw.put(TENANT_ORIGINS_ENABLED_PROPERTY, "false");
+ this.resolved = resolve(raw);
}
@Activate
void activate() {
reload();
+ TenantOriginsSnapshot snapshot = new TenantOriginsSnapshot(tenantService::getAllTenants, clock);
+ snapshot.load();
+ this.tenantOrigins = snapshot;
+ }
+
+ /** For tests: the tenant origins to use. */
+ void useTenantOrigins(TenantOriginsSnapshot snapshot) {
+ this.tenantOrigins = snapshot;
}
@Override
@@ -126,14 +188,11 @@ public class PublicTrackerSettings implements ConfigChangeListener {
// Synchronized so that two reloads (activation and a configuration event) cannot interleave.
private synchronized void reload() {
Resolved previous = resolved;
- Resolved next = resolve(
- configSharingService.getProperty(ALLOWED_ORIGINS_PROPERTY),
- configSharingService.getProperty(SAME_SITE_PROPERTY),
- configSharingService.getProperty(MATCH_REFERER_PROPERTY),
- configSharingService.getProperty(COOKIE_NAME_PROPERTY),
- configSharingService.getProperty(COOKIE_DOMAIN_PROPERTY),
- configSharingService.getProperty(COOKIE_MAX_AGE_PROPERTY),
- configSharingService.getProperty(COOKIE_HTTP_ONLY_PROPERTY));
+ Map<String, Object> raw = new HashMap<>();
+ for (String property : WATCHED) {
+ raw.put(property, configSharingService.getProperty(property));
+ }
+ Resolved next = resolve(raw);
for (String warning : newWarnings(previous.warnings, next.warnings)) {
LOGGER.warn(warning);
}
@@ -159,49 +218,66 @@ 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) {
+ /** Resolves the raw settings, keyed by their {@code *_PROPERTY} names; a missing key takes the default. */
+ private static Resolved resolve(Map<String, Object> raw) {
List<String> warnings = new ArrayList<>();
+ Object sameSite = raw.get(SAME_SITE_PROPERTY);
+ Object cookieName = raw.get(COOKIE_NAME_PROPERTY);
+ Object cookieDomain = raw.get(COOKIE_DOMAIN_PROPERTY);
+ Object cookieHttpOnly = raw.get(COOKIE_HTTP_ONLY_PROPERTY);
String requested = sameSite == null ? null : sameSite.toString().trim();
String name = cookieName == null || cookieName.toString().isBlank() ? DEFAULT_COOKIE_NAME : cookieName.toString().trim();
- String origins = allowedOrigins == null ? null : allowedOrigins.toString();
+ String origins = raw.get(ALLOWED_ORIGINS_PROPERTY) == null ? null : raw.get(ALLOWED_ORIGINS_PROPERTY).toString();
// Off unless explicitly turned on: it trusts everything a listed page frames.
- String refererSetting = matchReferer == null ? "" : matchReferer.toString().trim();
+ String refererSetting = text(raw.get(MATCH_REFERER_PROPERTY));
boolean refererMatch = "true".equalsIgnoreCase(refererSetting);
if (!refererSetting.isEmpty() && !refererMatch && !"false".equalsIgnoreCase(refererSetting)) {
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 = text(raw.get(TENANT_ORIGINS_ENABLED_PROPERTY));
+ boolean tenantOriginsEnabled = !"false".equalsIgnoreCase(tenantSetting);
+ if (!tenantSetting.isEmpty() && tenantOriginsEnabled && !"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() && !tenantOriginsEnabled) {
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() || tenantOriginsEnabled))) {
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();
+ int maxAge = toInt(raw.get(COOKIE_MAX_AGE_PROPERTY), warnings);
+ String httpOnlySetting = text(cookieHttpOnly);
if (!httpOnlySetting.isEmpty() && !"true".equalsIgnoreCase(httpOnlySetting) && !"false".equalsIgnoreCase(httpOnlySetting)) {
warnings.add("Unrecognised value '" + httpOnlySetting + "' for org.apache.unomi.profile.cookie.httpOnly; "
+ "only false turns it off, so the cookie stays HttpOnly.");
}
return new Resolved(
policy,
+ tenantOriginsEnabled,
requested,
name,
cookieDomain == null ? null : cookieDomain.toString(),
maxAge,
// HttpOnly unless explicitly turned off: page script must not read the bearer cookie,
// so a typo keeps it on.
- cookieHttpOnly == null || !"false".equalsIgnoreCase(cookieHttpOnly.toString().trim()),
+ !"false".equalsIgnoreCase(httpOnlySetting),
warnings);
}
+ private static String text(Object value) {
+ return value == null ? "" : value.toString().trim();
+ }
+
private static int toInt(Object value, List<String> warnings) {
if (value == null) {
return DEFAULT_COOKIE_MAX_AGE;
@@ -219,17 +295,112 @@ 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;
+ TenantOriginsSnapshot snapshot = tenantOrigins;
+ if (!base.tenantOriginsEnabled || tenantId == null || snapshot == null) {
+ return base.policy;
+ }
+ Merged current = merged(base, snapshot.current());
+ PublicTrackerCorsPolicy policy = current.byTenant.get(tenantId);
+ if (policy == null && !current.origins.tenantIds.contains(tenantId)) {
+ // A tenant created since the last load, possibly on another node.
+ policy = merged(base, snapshot.afterMiss()).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.
+ *
+ * @param origin the {@code Origin} header of the preflight, may be {@code null}
+ * @return whether the preflight may allow credentials for that origin
+ */
+ public boolean allowsPreflight(String origin) {
+ Resolved base = resolved;
+ if (origin == null) {
+ return false;
+ }
+ if (base.policy.allows(origin)) {
+ return true;
+ }
+ TenantOriginsSnapshot snapshot = tenantOrigins;
+ if (!base.tenantOriginsEnabled || snapshot == null) {
+ return false;
+ }
+ if (merged(base, snapshot.current()).anyTenant.contains(origin)) {
+ return true;
+ }
+ // Perhaps listed since the last load, possibly on another node.
+ return merged(base, snapshot.afterMiss()).anyTenant.contains(origin);
+ }
+
+ /**
+ * Applies a tenant created or updated through this node at once, from the saved object.
+ *
+ * @param tenant the tenant as saved
+ */
+ public void tenantSaved(Tenant tenant) {
+ TenantOriginsSnapshot snapshot = tenantOrigins;
+ if (snapshot != null) {
+ snapshot.tenantSaved(tenant);
+ }
+ }
+
+ /**
+ * Drops a removed tenant's origins at once.
+ *
+ * @param tenantId the removed tenant
+ */
+ public void tenantRemoved(String tenantId) {
+ TenantOriginsSnapshot snapshot = tenantOrigins;
+ if (snapshot != null) {
+ snapshot.tenantRemoved(tenantId);
+ }
+ }
+
+ /** Called by the tenant service, just before it deletes the tenant. */
+ @Override
+ public void onTenantRemoved(String tenantId) {
+ tenantRemoved(tenantId);
+ }
+
+ private Merged merged(Resolved base, TenantOriginsSnapshot.State origins) {
+ Merged current = merged;
+ if (current != null && current.base == base && current.origins == origins) {
+ return current;
+ }
+ Merged next = new Merged(base, origins);
+ merged = next;
+ return next;
+ }
+
/** @return the name of the visitor cookie, as currently configured */
public String cookieName() {
return resolved.cookieName;
}
- /** The {@code Set-Cookie} value naming this profile; only the profile id is added per request. */
+ /**
+ * The {@code Set-Cookie} value naming this profile; only the profile id is added per request.
+ *
+ * @param profileId the profile the cookie names
+ * @param secure whether the request reached Unomi over HTTPS
+ * @return the {@code Set-Cookie} header value
+ */
public String profileCookie(String profileId, boolean secure) {
Resolved current = resolved;
if (!secure) {
@@ -250,9 +421,10 @@ public class PublicTrackerSettings implements ConfigChangeListener {
/**
* Reports, once per configuration, a refused request that carried no {@code Origin}: if it came from a
* listed website embedding {@code context.js} with a {@code <script>} tag, the operator needs to know why
- * that website keeps losing its visitors. Only once origins are listed: before that, every website is
+ * that website keeps losing its visitors. Only once origins can be listed: before that, every website is
* refused by design.
*
+ * @param hasReferer whether the refused request carried a {@code Referer}
* @return whether the warning was logged by this call
*/
public boolean reportRefusedRequestWithoutOrigin(boolean hasReferer) {
diff --git a/rest/src/main/java/org/apache/unomi/rest/cors/RequestTenant.java b/rest/src/main/java/org/apache/unomi/rest/cors/RequestTenant.java
new file mode 100644
index 000000000..381f4232a
--- /dev/null
+++ b/rest/src/main/java/org/apache/unomi/rest/cors/RequestTenant.java
@@ -0,0 +1,72 @@
+/*
+ * 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.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 javax.security.auth.Subject;
+import java.security.Principal;
+
+/**
+ * The tenant the current REST request authenticated as. It is read from the CXF security context that the
+ * authentication filter stores on the request message, which stays in place until the response has been
+ * written, unlike the thread-local security context that is cleared before the last response filters run.
+ */
+public final class RequestTenant {
+
+ private RequestTenant() {
+ }
+
+ /**
+ * @return the tenant id of the current request, or {@code null} outside a request or when the caller is
+ * not a tenant (anonymous, or a system administrator without a tenant)
+ */
+ public static String id() {
+ return idOf(JAXRSUtils.getCurrentMessage());
+ }
+
+ /**
+ * In a response filter the current message is the response; the request message is read from the
+ * exchange.
+ */
+ static String idOf(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;
+ }
+}
diff --git a/rest/src/main/java/org/apache/unomi/rest/cors/TenantOriginsSnapshot.java b/rest/src/main/java/org/apache/unomi/rest/cors/TenantOriginsSnapshot.java
new file mode 100644
index 000000000..4c2a70755
--- /dev/null
+++ b/rest/src/main/java/org/apache/unomi/rest/cors/TenantOriginsSnapshot.java
@@ -0,0 +1,237 @@
+/*
+ * 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.tenants.Tenant;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.LongSupplier;
+import java.util.function.Supplier;
+
+/**
+ * The website origins each tenant lists in its {@value PublicTrackerSettings#TENANT_PROPERTY_ALLOWED_ORIGINS}
+ * property, kept in memory so that requests never load tenants.
+ * <ul>
+ * <li>Loaded in full at activation.</li>
+ * <li>Updated in place from the tenant object when a tenant is saved or removed through this node, so the
+ * change does not wait for the search index and does not depend on when removal listeners are called.</li>
+ * <li>Reloaded in full every {@value #REFRESH_SECONDS} seconds, by one request while the others keep the
+ * current state, to pick up changes made on other nodes.</li>
+ * <li>Reloaded when an unknown tenant or origin shows up, at most every {@value #MISS_RELOAD_SECONDS}
+ * seconds, so a tenant created on another node works on its first request and requests cannot force
+ * reloads. No request ever waits for a reload made by another.</li>
+ * <li>After a failed load, the previous state is kept and the next attempt waits
+ * {@value #MISS_RELOAD_SECONDS} seconds.</li>
+ * <li>A tenant removed through this node is left out of full loads for {@value #REMOVED_SECONDS} seconds,
+ * until the store no longer returns it.</li>
+ * </ul>
+ */
+final class TenantOriginsSnapshot {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(TenantOriginsSnapshot.class);
+
+ static final int REFRESH_SECONDS = 30;
+ static final int MISS_RELOAD_SECONDS = 5;
+ /**
+ * How long a tenant removed through this node stays excluded from full loads. The tenant service tells
+ * listeners before it deletes, so a load in between would otherwise bring the tenant back.
+ */
+ static final int REMOVED_SECONDS = 60;
+
+ /** One consistent view; replaced as a whole. */
+ static final class State {
+ static final State EMPTY = new State(Collections.emptySet(), Collections.emptyMap(), Collections.emptyMap());
+
+ final Set<String> tenantIds;
+ /** Only tenants that list at least one valid origin. */
+ final Map<String, Set<String>> lists;
+ /** The property as configured, to report invalid entries once per change. */
+ final Map<String, Object> configured;
+
+ State(Set<String> tenantIds, Map<String, Set<String>> lists, Map<String, Object> configured) {
+ this.tenantIds = tenantIds;
+ this.lists = lists;
+ this.configured = configured;
+ }
+ }
+
+ private final Supplier<List<Tenant>> loader;
+ private final LongSupplier clock;
+ private final ReentrantLock lock = new ReentrantLock();
+
+ private volatile State state = State.EMPTY;
+ /** Tenants removed through this node, and until when loads skip them; guarded by {@link #lock}. */
+ private final Map<String, Long> removedUntil = new HashMap<>();
+ private volatile boolean attempted;
+ /** When the next periodic reload is due; meaningful once {@link #attempted}. */
+ private volatile long nextRefreshAt;
+ /** When the last full load was tried; meaningful once {@link #attempted}. */
+ private volatile long lastAttemptAt;
+
+ TenantOriginsSnapshot(Supplier<List<Tenant>> loader, LongSupplier clock) {
+ this.loader = loader;
+ this.clock = clock;
+ }
+
+ /** The current state, refreshed first if the periodic reload is due. */
+ State current() {
+ if (!attempted || clock.getAsLong() - nextRefreshAt >= 0) {
+ tryReload();
+ }
+ return state;
+ }
+
+ /**
+ * Reloads now unless a full load was tried in the last {@value #MISS_RELOAD_SECONDS} seconds or another
+ * reload is running, and returns the resulting state. For a tenant or origin the snapshot does not know.
+ */
+ State afterMiss() {
+ if (!attempted || clock.getAsLong() - lastAttemptAt >= TimeUnit.SECONDS.toNanos(MISS_RELOAD_SECONDS)) {
+ tryReload();
+ }
+ return state;
+ }
+
+ /** Full load, waiting for any reload in progress. At activation. */
+ void load() {
+ lock.lock();
+ try {
+ doLoad();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /** A tenant was created or updated through this node: apply its list from the saved object. */
+ void tenantSaved(Tenant tenant) {
+ if (tenant == null || tenant.getItemId() == null) {
+ return;
+ }
+ lock.lock();
+ try {
+ removedUntil.remove(tenant.getItemId());
+ State previous = state;
+ Set<String> ids = new HashSet<>(previous.tenantIds);
+ Map<String, Set<String>> lists = new HashMap<>(previous.lists);
+ Map<String, Object> configured = new HashMap<>(previous.configured);
+ put(tenant, ids, lists, configured, previous);
+ state = new State(Collections.unmodifiableSet(ids), Collections.unmodifiableMap(lists),
+ Collections.unmodifiableMap(configured));
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /** A tenant was removed: drop it, whether or not it is still in the store. */
+ void tenantRemoved(String tenantId) {
+ if (tenantId == null) {
+ return;
+ }
+ lock.lock();
+ try {
+ removedUntil.put(tenantId, clock.getAsLong() + TimeUnit.SECONDS.toNanos(REMOVED_SECONDS));
+ State previous = state;
+ if (!previous.tenantIds.contains(tenantId)) {
+ return;
+ }
+ Set<String> ids = new HashSet<>(previous.tenantIds);
+ Map<String, Set<String>> lists = new HashMap<>(previous.lists);
+ Map<String, Object> configured = new HashMap<>(previous.configured);
+ ids.remove(tenantId);
+ lists.remove(tenantId);
+ configured.remove(tenantId);
+ state = new State(Collections.unmodifiableSet(ids), Collections.unmodifiableMap(lists),
+ Collections.unmodifiableMap(configured));
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private void tryReload() {
+ // Never wait: a request either does the reload or keeps the current state.
+ if (lock.tryLock()) {
+ try {
+ doLoad();
+ } finally {
+ lock.unlock();
+ }
+ }
+ }
+
+ private void doLoad() {
+ long now = clock.getAsLong();
+ attempted = true;
+ lastAttemptAt = now;
+ try {
+ List<Tenant> tenants = loader.get();
+ State previous = state;
+ removedUntil.values().removeIf(until -> now - until >= 0);
+ Set<String> ids = new HashSet<>();
+ Map<String, Set<String>> lists = new HashMap<>();
+ Map<String, Object> configured = new HashMap<>();
+ for (Tenant tenant : tenants) {
+ if (!removedUntil.containsKey(tenant.getItemId())) {
+ put(tenant, ids, lists, configured, previous);
+ }
+ }
+ state = new State(Collections.unmodifiableSet(ids), Collections.unmodifiableMap(lists),
+ Collections.unmodifiableMap(configured));
+ nextRefreshAt = now + TimeUnit.SECONDS.toNanos(REFRESH_SECONDS);
+ } catch (RuntimeException e) {
+ // Keep the previous state and back off rather than retry on every request.
+ nextRefreshAt = now + TimeUnit.SECONDS.toNanos(MISS_RELOAD_SECONDS);
+ LOGGER.warn("Could not load the tenants' public CORS allowed origins; keeping the previous ones: {}", e.getMessage());
+ LOGGER.debug("Tenant origins load failure", e);
+ }
+ }
+
+ private static void put(Tenant tenant, Set<String> ids, Map<String, Set<String>> lists,
+ Map<String, Object> configured, State previous) {
+ String id = tenant.getItemId();
+ ids.add(id);
+ Object value = tenant.getProperties() == null ? null : tenant.getProperties().get(PublicTrackerSettings.TENANT_PROPERTY_ALLOWED_ORIGINS);
+ Set<String> origins = PublicTrackerCorsPolicy.parseTenantOrigins(value);
+ if (origins.isEmpty()) {
+ lists.remove(id);
+ } else {
+ lists.put(id, origins);
+ }
+ if (value == null) {
+ configured.remove(id);
+ } else {
+ configured.put(id, value);
+ }
+ // Tenants saved through /cxs/tenants are validated; others (shell, direct service calls) are not.
+ if (value != null && !Objects.equals(value, previous.configured.get(id))) {
+ List<String> problems = PublicTrackerCorsPolicy.tenantOriginProblems(value);
+ if (!problems.isEmpty()) {
+ LOGGER.warn("Ignoring invalid entries in the {} of tenant {}: {}",
+ PublicTrackerSettings.TENANT_PROPERTY_ALLOWED_ORIGINS, id, String.join("; ", problems));
+ }
+ }
+ }
+}
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..cc01aea51 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
@@ -35,6 +35,7 @@ import org.apache.unomi.api.tenants.TenantService;
import org.apache.unomi.rest.authentication.RestAuthenticationConfig;
import org.apache.unomi.rest.authentication.V2ThirdPartyConfigService;
import org.apache.unomi.rest.cors.PublicTrackerSettings;
+import org.apache.unomi.rest.cors.RequestTenant;
import org.apache.unomi.services.common.security.SecurityUtils;
import org.apache.unomi.rest.exception.InvalidRequestException;
import org.apache.unomi.rest.service.RestServiceUtils;
@@ -154,7 +155,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(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.
@@ -171,6 +172,11 @@ public class RestServiceUtilsImpl implements RestServiceUtils {
return allowed;
}
+ /** The tenant the current request authenticated as; replaced in tests. */
+ String requestTenantId() {
+ return RequestTenant.id();
+ }
+
/**
* Whether the visitor reached Unomi over HTTPS: the container says so, or a proxy reported it with
* {@code X-Forwarded-Proto} and {@link SecurityService#recordRequestTransport} accepted that proxy, so it
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..2dd4ad8e3 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;
@@ -33,7 +35,9 @@ import org.osgi.service.component.annotations.Reference;
import javax.ws.rs.*;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
+import java.util.ConcurrentModificationException;
import java.util.List;
+import java.util.Map;
/**
* REST endpoint for managing tenants in the Apache Unomi system.
@@ -57,6 +61,9 @@ public class TenantEndpoint {
@Reference
private TenantUsageService tenantUsageService;
+ @Reference
+ private PublicTrackerSettings publicTrackerSettings;
+
/**
* Retrieves all tenants in the system.
*
@@ -77,6 +84,7 @@ public class TenantEndpoint {
* @return the requested tenant with 200 status, or 404 if tenant is not found
* @api.status 200 org.apache.unomi.api.tenants.Tenant Tenant found.
* @api.status 404 empty Tenant not found.
+ * @api.status 409 empty The tenant kept changing concurrently; retry.
* @api.example {"itemId":"acme","itemType":"tenant","name":"Acme Corp","status":"ACTIVE","creationDate":"2026-01-15T10:00:00.000Z","lastModificationDate":"2026-01-15T10:00:00.000Z","apiKeys":[{"itemId":"key-1","itemType":"apiKey","maskedKey":"unomi_v1_****ab12","keyType":"PUBLIC","creationDate":"2026-01-15T10:00:00.000Z","revoked":false}],"properties":{},"restrictedEventTypes":[],"authorizedIPs":["10.0.0.0/8"]}
*/
@GET
@@ -97,12 +105,14 @@ public class TenantEndpoint {
* Plaintext keys are only returned in responses that explicitly expose
* {@link ApiKeyCreationResult} (not on this create response — the returned
* {@link Tenant} includes hashed/masked key metadata only).
+ * The optional {@value PublicTrackerSettings#TENANT_PROPERTY_ALLOWED_ORIGINS} property lists the websites that
+ * may use the visitor cookie with this tenant's key; it applies at once on this node.
*
* @param request the tenant creation request containing tenant details
* @return the created tenant with generated API key metadata
* @throws WebApplicationException with 400 status if request is invalid
* @api.status 200 org.apache.unomi.api.tenants.Tenant Tenant created (status ACTIVE, public and private keys generated).
- * @api.status 400 empty Missing or blank requestedId.
+ * @api.status 400 empty Missing or blank requestedId, or an invalid publicCorsAllowedOrigins entry.
* @api.example {"itemId":"acme","itemType":"tenant","status":"ACTIVE","creationDate":"2026-01-15T10:00:00.000Z","apiKeys":[{"maskedKey":"unomi_v1_****ab12","keyType":"PUBLIC","revoked":false},{"maskedKey":"unomi_v1_****cd34","keyType":"PRIVATE","revoked":false}],"properties":{"name":"Acme"},"restrictedEventTypes":[],"authorizedIPs":[]}
*/
@POST
@@ -113,21 +123,27 @@ 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.tenantSaved(tenant);
return tenant;
}
/**
* Updates an existing tenant.
- * The path {@code tenantId} must equal {@code tenant.itemId}.
+ * The path {@code tenantId} must equal {@code tenant.itemId}. The body replaces the tenant's settings (name,
+ * description, status, properties, restricted event types, authorized IPs), but never its API keys or creation
+ * date: keys are managed only through {@code /tenants/{tenantId}/apikeys}, and those fields in the body are
+ * ignored. REST responses never include key hashes, so sending back a tenant as read would otherwise
+ * invalidate every key. The write is a compare-and-set, so it never undoes a concurrent key change.
*
* @param tenantId the ID of the tenant to update
* @param tenant the updated tenant information
* @return the updated tenant
* @throws WebApplicationException with 400 if IDs mismatch, 404 if tenant is not found
* @api.status 200 org.apache.unomi.api.tenants.Tenant Tenant updated.
- * @api.status 400 empty Path tenantId does not match body itemId.
+ * @api.status 400 empty Path tenantId does not match body itemId, or an invalid publicCorsAllowedOrigins entry.
* @api.status 404 empty Tenant not found.
* @api.example {"itemId":"acme","itemType":"tenant","name":"Acme Corp","status":"ACTIVE","creationDate":"2026-01-15T10:00:00.000Z","lastModificationDate":"2026-01-15T10:00:00.000Z","apiKeys":[{"itemId":"key-1","itemType":"apiKey","maskedKey":"unomi_v1_****ab12","keyType":"PUBLIC","creationDate":"2026-01-15T10:00:00.000Z","revoked":false}],"properties":{},"restrictedEventTypes":[],"authorizedIPs":["10.0.0.0/8"]}
*/
@@ -141,12 +157,18 @@ public class TenantEndpoint {
throw new WebApplicationException("Tenant ID mismatch", Response.Status.BAD_REQUEST);
}
- if (tenantService.getTenant(tenantId) == null) {
+ validateTrackerOrigins(tenant.getProperties());
+ Tenant saved;
+ try {
+ saved = tenantService.updateTenantSettings(tenant);
+ } catch (ConcurrentModificationException e) {
+ throw new WebApplicationException("Tenant is being changed concurrently, retry", Response.Status.CONFLICT);
+ }
+ if (saved == null) {
throw new WebApplicationException("Tenant not found", Response.Status.NOT_FOUND);
}
-
- tenantService.saveTenant(tenant);
- return tenant;
+ publicTrackerSettings.tenantSaved(saved);
+ return saved;
}
/**
@@ -168,6 +190,7 @@ public class TenantEndpoint {
}
tenantService.deleteTenant(tenantId);
+ publicTrackerSettings.tenantRemoved(tenantId);
return Response.noContent().build();
}
@@ -303,4 +326,20 @@ public class TenantEndpoint {
throw new WebApplicationException(e.getMessage(), Response.Status.BAD_REQUEST);
}
}
+
+ /**
+ * Rejects a {@value PublicTrackerSettings#TENANT_PROPERTY_ALLOWED_ORIGINS} 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_PROPERTY_ALLOWED_ORIGINS)) {
+ return;
+ }
+ List<String> problems = PublicTrackerCorsPolicy.tenantOriginProblems(properties.get(PublicTrackerSettings.TENANT_PROPERTY_ALLOWED_ORIGINS));
+ if (!problems.isEmpty()) {
+ throw new WebApplicationException(Response.status(Response.Status.BAD_REQUEST)
+ .entity("Invalid " + PublicTrackerSettings.TENANT_PROPERTY_ALLOWED_ORIGINS + ": " + 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..0dad4c7fb
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerSettingsTenantTest.java
@@ -0,0 +1,206 @@
+/*
+ * 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_ORIGINS_ENABLED_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(TenantOriginsSnapshot.MISS_RELOAD_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(TenantOriginsSnapshot.MISS_RELOAD_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(TenantOriginsSnapshot.REFRESH_SECONDS);
+ assertFalse(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_ORIGINS_ENABLED_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 savesAndRemovalsThroughThisNodeApplyWithoutWaitingForTheIndex() {
+ settings.activate();
+ clearInvocations(tenantService);
+
+ // The store has not caught up yet (no index refresh): the saved object is what counts.
+ settings.tenantSaved(tenant("acme", BLOG));
+ assertFalse(settings.policyForTenant("acme").allows(SHOP));
+ assertTrue(settings.policyForTenant("acme").allows(BLOG));
+
+ // The tenant service calls the listener before deleting, so the store still has the tenant.
+ settings.onTenantRemoved("acme");
+ assertFalse(settings.allowsPreflight(BLOG));
+
+ verify(tenantService, never()).getAllTenants();
+ }
+
+ 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_PROPERTY_ALLOWED_ORIGINS, 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..ce9923faf 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
@@ -52,6 +52,19 @@ class PublicTrackerSettingsTest {
Field field = PublicTrackerSettings.class.getDeclaredField("configSharingService");
field.setAccessible(true);
field.set(settings, configSharingService);
+ // No tenants: these tests are about the operator settings.
+ org.apache.unomi.api.tenants.TenantService tenantService = org.mockito.Mockito.mock(org.apache.unomi.api.tenants.TenantService.class);
+ Field tenants = PublicTrackerSettings.class.getDeclaredField("tenantService");
+ tenants.setAccessible(true);
+ tenants.set(settings, tenantService);
+ }
+
+ @Test
+ void activationLoadsTheTenantOrigins() throws Exception {
+ settings.activate();
+ Field field = PublicTrackerSettings.class.getDeclaredField("tenantService");
+ field.setAccessible(true);
+ verify((org.apache.unomi.api.tenants.TenantService) field.get(settings)).getAllTenants();
}
@Test
@@ -153,7 +166,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_ORIGINS_ENABLED_PROPERTY,
PublicTrackerSettings.COOKIE_NAME_PROPERTY, PublicTrackerSettings.COOKIE_DOMAIN_PROPERTY,
PublicTrackerSettings.COOKIE_MAX_AGE_PROPERTY, PublicTrackerSettings.COOKIE_HTTP_ONLY_PROPERTY}) {
clearInvocations(configSharingService);
@@ -189,6 +202,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_ORIGINS_ENABLED_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 +271,7 @@ class PublicTrackerSettingsTest {
@Test
void refusedSameSiteNoneIsReported() {
+ lenient().when(configSharingService.getProperty(PublicTrackerSettings.TENANT_ORIGINS_ENABLED_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/cors/RequestTenantTest.java b/rest/src/test/java/org/apache/unomi/rest/cors/RequestTenantTest.java
new file mode 100644
index 000000000..9d6305cff
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/rest/cors/RequestTenantTest.java
@@ -0,0 +1,62 @@
+/*
+ * 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.cxf.interceptor.security.RolePrefixSecurityContextImpl;
+import org.apache.cxf.message.Exchange;
+import org.apache.cxf.message.ExchangeImpl;
+import org.apache.cxf.message.Message;
+import org.apache.cxf.message.MessageImpl;
+import org.apache.cxf.security.SecurityContext;
+import org.apache.unomi.api.security.TenantPrincipal;
+import org.junit.jupiter.api.Test;
+
+import javax.security.auth.Subject;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class RequestTenantTest {
+
+ @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.
+ Subject subject = new Subject();
+ subject.getPrincipals().add(new TenantPrincipal("acme"));
+ Message request = new MessageImpl();
+ request.put(SecurityContext.class, new RolePrefixSecurityContextImpl(subject, "ROLE_", "prefix"));
+ Message response = new MessageImpl();
+ Exchange exchange = new ExchangeImpl();
+ exchange.setInMessage(request);
+ exchange.setOutMessage(response);
+ request.setExchange(exchange);
+ response.setExchange(exchange);
+
+ assertEquals("acme", RequestTenant.idOf(request));
+ assertEquals("acme", RequestTenant.idOf(response));
+ }
+
+ @Test
+ void noTenantOutsideAnAuthenticatedTenantRequest() {
+ assertNull(RequestTenant.idOf(null));
+ assertNull(RequestTenant.idOf(new MessageImpl()));
+ Message adminRequest = new MessageImpl();
+ adminRequest.put(SecurityContext.class, new RolePrefixSecurityContextImpl(new Subject(), "ROLE_", "prefix"));
+ assertNull(RequestTenant.idOf(adminRequest));
+ }
+}
diff --git a/rest/src/test/java/org/apache/unomi/rest/cors/TenantOriginsSnapshotTest.java b/rest/src/test/java/org/apache/unomi/rest/cors/TenantOriginsSnapshotTest.java
new file mode 100644
index 000000000..41f10c707
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/rest/cors/TenantOriginsSnapshotTest.java
@@ -0,0 +1,198 @@
+/*
+ * 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.tenants.Tenant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+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 TenantOriginsSnapshotTest {
+
+ private static final long REFRESH = TimeUnit.SECONDS.toNanos(TenantOriginsSnapshot.REFRESH_SECONDS);
+ private static final long MISS = TimeUnit.SECONDS.toNanos(TenantOriginsSnapshot.MISS_RELOAD_SECONDS);
+
+ private final List<Tenant> tenants = new ArrayList<>();
+ private final AtomicInteger loads = new AtomicInteger();
+ private volatile RuntimeException failure;
+ // Positive, as System.nanoTime usually is, so an overflowing comparison would show.
+ private final long[] now = {TimeUnit.DAYS.toNanos(3)};
+ private TenantOriginsSnapshot snapshot;
+
+ @BeforeEach
+ void setUp() {
+ tenants.add(tenant("acme", "https://shop.example"));
+ snapshot = new TenantOriginsSnapshot(() -> {
+ loads.incrementAndGet();
+ if (failure != null) {
+ throw failure;
+ }
+ return new ArrayList<>(tenants);
+ }, () -> now[0]);
+ }
+
+ @Test
+ void refreshesExactlyWhenDue() {
+ snapshot.load();
+ now[0] += REFRESH - 1;
+ snapshot.current();
+ assertEquals(1, loads.get());
+
+ now[0] += 1;
+ snapshot.current();
+ assertEquals(2, loads.get());
+ }
+
+ @Test
+ void missReloadsAreLimited() {
+ snapshot.load();
+ now[0] += MISS - 1;
+ snapshot.afterMiss();
+ assertEquals(1, loads.get());
+
+ now[0] += 1;
+ snapshot.afterMiss();
+ snapshot.afterMiss();
+ assertEquals(2, loads.get());
+ }
+
+ @Test
+ void aFailedFirstLoadIsRetriedAfterTheBackoff() {
+ // For example the search engine is not ready when the bundle starts.
+ failure = new IllegalStateException("not ready");
+ snapshot.load();
+ assertTrue(snapshot.current().tenantIds.isEmpty());
+ assertEquals(1, loads.get());
+
+ failure = null;
+ now[0] += MISS - 1;
+ snapshot.current();
+ assertEquals(1, loads.get());
+
+ now[0] += 1;
+ assertTrue(snapshot.current().lists.containsKey("acme"));
+ }
+
+ @Test
+ void aFailedRefreshKeepsThePreviousStateAndBacksOff() {
+ snapshot.load();
+ TenantOriginsSnapshot.State loaded = snapshot.current();
+ failure = new IllegalStateException("search engine unavailable");
+
+ now[0] += REFRESH;
+ assertSame(loaded, snapshot.current());
+ int afterFailure = loads.get();
+ for (int i = 0; i < 50; i++) {
+ snapshot.current();
+ }
+ assertEquals(afterFailure, loads.get());
+ }
+
+ @Test
+ void savedAndRemovedTenantsApplyWithoutLoading() {
+ snapshot.load();
+
+ snapshot.tenantSaved(tenant("globex", "https://blog.example"));
+ snapshot.tenantSaved(tenant("acme", null));
+ assertTrue(snapshot.current().lists.containsKey("globex"));
+ assertFalse(snapshot.current().lists.containsKey("acme"));
+ assertTrue(snapshot.current().tenantIds.contains("acme"));
+
+ // Still in the store: the tenant service notifies before deleting.
+ snapshot.tenantRemoved("globex");
+ assertFalse(snapshot.current().tenantIds.contains("globex"));
+ assertEquals(1, loads.get());
+ }
+
+ @Test
+ void noRequestWaitsForAReloadInProgress() throws Exception {
+ CountDownLatch loading = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ TenantOriginsSnapshot slow = new TenantOriginsSnapshot(() -> {
+ loading.countDown();
+ try {
+ release.await(5, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ return new ArrayList<>(tenants);
+ }, () -> now[0]);
+ Thread reloader = new Thread(slow::load);
+ reloader.start();
+ assertTrue(loading.await(5, TimeUnit.SECONDS));
+
+ // Past the miss limit, so afterMiss() does try to reload rather than skip the attempt.
+ now[0] += MISS;
+ long started = System.nanoTime();
+ TenantOriginsSnapshot.State seen = slow.current();
+ TenantOriginsSnapshot.State afterMiss = slow.afterMiss();
+ assertTrue(System.nanoTime() - started < TimeUnit.SECONDS.toNanos(1), "a request waited for the reload");
+ // Both answered from the current state (empty here: the reload in progress has not finished).
+ assertSame(TenantOriginsSnapshot.State.EMPTY, seen);
+ assertSame(TenantOriginsSnapshot.State.EMPTY, afterMiss);
+
+ release.countDown();
+ reloader.join();
+ }
+
+ @Test
+ void aReloadDoesNotBringBackATenantJustRemoved() {
+ // The tenant service notifies before it deletes, and the index may still return the tenant.
+ snapshot.load();
+ snapshot.tenantRemoved("acme");
+
+ now[0] += REFRESH;
+ assertFalse(snapshot.current().tenantIds.contains("acme"));
+
+ // Once the marker has expired, a tenant the store still returns is taken again.
+ now[0] += TimeUnit.SECONDS.toNanos(TenantOriginsSnapshot.REMOVED_SECONDS);
+ assertTrue(snapshot.current().tenantIds.contains("acme"));
+ }
+
+ @Test
+ void savingATenantAgainClearsItsRemovalMarker() {
+ snapshot.load();
+ snapshot.tenantRemoved("acme");
+ snapshot.tenantSaved(tenant("acme", "https://shop.example"));
+
+ now[0] += REFRESH;
+ assertTrue(snapshot.current().lists.containsKey("acme"));
+ }
+
+ private static Tenant tenant(String id, String origins) {
+ Tenant tenant = new Tenant();
+ tenant.setItemId(id);
+ Map<String, Object> properties = new HashMap<>();
+ if (origins != null) {
+ properties.put(PublicTrackerSettings.TENANT_PROPERTY_ALLOWED_ORIGINS, origins);
+ }
+ tenant.setProperties(properties);
+ return tenant;
+ }
+}
diff --git a/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java b/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
index f98f7fa70..41a225b55 100644
--- a/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
+++ b/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
@@ -66,6 +66,8 @@ import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
@@ -385,6 +387,31 @@ class RestServiceUtilsImplProfileBindingTest {
assertNull(restServiceUtils.getProfileIdCookieValueForNavigation(request));
}
+ @Test
+ void aTenantsOwnOriginsDecideForItsRequests() throws Exception {
+ // UNOMI-1013: the decision uses the requesting tenant's list, not only the operator's.
+ PublicTrackerSettings settings = mock(PublicTrackerSettings.class);
+ when(settings.cookieName()).thenReturn(COOKIE_NAME);
+ when(settings.policyForTenant("acme")).thenReturn(policyListing("https://shop.example"));
+ lenient().when(settings.policyForTenant("globex")).thenReturn(policyListing(""));
+ setField(restServiceUtils, "publicTrackerSettings", settings);
+ RestServiceUtilsImpl forAcme = spy(restServiceUtils);
+ doReturn("acme").when(forAcme).requestTenantId();
+ lenient().when(request.getHeader("Origin")).thenReturn("https://shop.example");
+ lenient().when(request.getHeader("Sec-Fetch-Site")).thenReturn("cross-site");
+ when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+
+ assertEquals("cookie-profile", forAcme.getProfileIdCookieValue(request));
+
+ RestServiceUtilsImpl forGlobex = spy(restServiceUtils);
+ doReturn("globex").when(forGlobex).requestTenantId();
+ assertNull(forGlobex.getProfileIdCookieValue(request));
+ }
+
+ private static org.apache.unomi.rest.cors.PublicTrackerCorsPolicy policyListing(String origins) {
+ return new PublicTrackerSettings(origins, null).policy();
+ }
+
private void allowedOrigins(String origins) {
allowedOrigins(origins, false);
}
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..74b86d77f
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/rest/tenants/TenantEndpointTrackerOriginsTest.java
@@ -0,0 +1,144 @@
+/*
+ * 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.ApiKey;
+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_PROPERTY_ALLOWED_ORIGINS, List.of("https://shop.example")));
+ when(tenantService.createTenant(anyString(), any())).thenReturn(new Tenant());
+
+ endpoint.createTenant(request);
+
+ verify(publicTrackerSettings).tenantSaved(any());
+ }
+
+ @Test
+ void invalidOriginsAreRejectedBeforeTheTenantIsCreated() {
+ TenantRequest request = request(Map.of(PublicTrackerSettings.TENANT_PROPERTY_ALLOWED_ORIGINS, "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()).tenantSaved(any());
+ }
+
+ @Test
+ void updatingOrDeletingATenantAppliesAtOnce() {
+ Tenant tenant = new Tenant();
+ tenant.setItemId("acme");
+ tenant.setProperties(Map.of(PublicTrackerSettings.TENANT_PROPERTY_ALLOWED_ORIGINS, "https://blog.example"));
+ Tenant saved = new Tenant();
+ saved.setItemId("acme");
+ when(tenantService.updateTenantSettings(tenant)).thenReturn(saved);
+ when(tenantService.getTenant("acme")).thenReturn(saved);
+
+ assertEquals(saved, endpoint.updateTenant("acme", tenant));
+ endpoint.deleteTenant("acme");
+
+ verify(tenantService, never()).saveTenant(any());
+ verify(publicTrackerSettings).tenantSaved(saved);
+ verify(publicTrackerSettings).tenantRemoved("acme");
+ }
+
+ @Test
+ void invalidOriginsAreRejectedOnUpdate() {
+ Tenant tenant = new Tenant();
+ tenant.setItemId("acme");
+ tenant.setProperties(Map.of(PublicTrackerSettings.TENANT_PROPERTY_ALLOWED_ORIGINS, 42));
+
+ WebApplicationException error = assertThrows(WebApplicationException.class, () -> endpoint.updateTenant("acme", tenant));
+
+ assertEquals(400, error.getResponse().getStatus());
+ verify(tenantService, never()).updateTenantSettings(any());
+ }
+
+ @Test
+ void updatingGoesThroughTheKeyPreservingServiceMethod() {
+ // The service applies the settings onto the stored tenant with compare-and-set, keeping its keys.
+ Tenant body = new Tenant();
+ body.setItemId("acme");
+ body.setApiKeys(List.of(new ApiKey()));
+
+ when(tenantService.updateTenantSettings(body)).thenReturn(null);
+ WebApplicationException missing = assertThrows(WebApplicationException.class, () -> endpoint.updateTenant("acme", body));
+ assertEquals(404, missing.getResponse().getStatus());
+
+ when(tenantService.updateTenantSettings(body)).thenThrow(new java.util.ConcurrentModificationException());
+ WebApplicationException busy = assertThrows(WebApplicationException.class, () -> endpoint.updateTenant("acme", body));
+ assertEquals(409, busy.getResponse().getStatus());
+
+ verify(tenantService, never()).saveTenant(any());
+ verify(publicTrackerSettings, never()).tenantSaved(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/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantServiceImpl.java
index cc2951703..4841c85d3 100644
--- a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantServiceImpl.java
+++ b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantServiceImpl.java
@@ -43,6 +43,9 @@ public class TenantServiceImpl implements TenantService {
private static final int MAX_TENANT_ID_LENGTH = 32;
private static final String TENANT_ID_PATTERN = "^[a-zA-Z0-9][a-zA-Z0-9-_]*[a-zA-Z0-9]$";
+ /** Attempts of a compare-and-set tenant write before giving up on a tenant that keeps changing. */
+ private static final int WRITE_ATTEMPTS = 5;
+
private final List<TenantLifecycleListener> lifecycleListeners = new CopyOnWriteArrayList<>();
/**
* How long {@link #getOrCreateTenant} trusts a cached tenant before it reads persistence again.
@@ -258,16 +261,27 @@ public class TenantServiceImpl implements TenantService {
apiKey.setExpirationDate(new Date(System.currentTimeMillis() + validityPeriod));
}
- Tenant tenant = persistenceService.load(tenantId, Tenant.class);
- if (tenant != null) {
+ // Compare-and-set, so the new key cannot be lost to a concurrent settings update, and that update
+ // cannot be lost either: whichever write comes second reloads and reapplies.
+ for (int attempt = 1; attempt <= WRITE_ATTEMPTS; attempt++) {
+ Tenant tenant = persistenceService.load(tenantId, Tenant.class);
+ if (tenant == null) {
+ break;
+ }
// Remove any existing key of the same type
if (tenant.getApiKeys() == null) {
tenant.setApiKeys(new ArrayList<>());
}
tenant.getApiKeys().removeIf(existingKey -> existingKey.getKeyType() == keyType);
tenant.getApiKeys().add(apiKey);
- persistenceService.save(tenant);
- invalidateGetOrCreateCache(tenantId);
+ if (persistenceService.save(tenant, false, false)) {
+ persistenceService.refreshIndex(Tenant.class);
+ invalidateGetOrCreateCache(tenantId);
+ break;
+ }
+ if (attempt == WRITE_ATTEMPTS) {
+ throw new ConcurrentModificationException("Tenant " + tenantId + " kept changing while its API key was being replaced");
+ }
}
return new ApiKeyCreationResult(apiKey, plainTextKey);
@@ -286,12 +300,48 @@ public class TenantServiceImpl implements TenantService {
@Override
public void saveTenant(Tenant tenant) {
- executionContextManager.executeAsSystem(() -> persistenceService.save(tenant));
+ executionContextManager.executeAsSystem(() -> {
+ persistenceService.save(tenant);
+ // Searches over tenants (API key lookup, the tracker's tenant origins) see the change at once.
+ persistenceService.refreshIndex(Tenant.class);
+ });
if (tenant != null) {
invalidateGetOrCreateCache(tenant.getItemId());
}
}
+ @Override
+ public Tenant updateTenantSettings(Tenant settings) {
+ if (settings == null || settings.getItemId() == null) {
+ return null;
+ }
+ String tenantId = settings.getItemId();
+ Tenant saved = executionContextManager.executeAsSystem(() -> {
+ for (int attempt = 1; attempt <= WRITE_ATTEMPTS; attempt++) {
+ Tenant stored = persistenceService.load(tenantId, Tenant.class);
+ if (stored == null) {
+ return null;
+ }
+ stored.setName(settings.getName());
+ stored.setDescription(settings.getDescription());
+ stored.setStatus(settings.getStatus());
+ stored.setProperties(settings.getProperties());
+ stored.setRestrictedEventTypes(settings.getRestrictedEventTypes());
+ stored.setAuthorizedIPs(settings.getAuthorizedIPs());
+ stored.setLastModificationDate(new Date());
+ // Compare-and-set: if the tenant changed since it was loaded (an API key was replaced, say),
+ // nothing is written and the settings are applied again on the new version.
+ if (persistenceService.save(stored, false, false)) {
+ persistenceService.refreshIndex(Tenant.class);
+ return stored;
+ }
+ }
+ throw new ConcurrentModificationException("Tenant " + tenantId + " kept changing while its settings were being updated");
+ });
+ invalidateGetOrCreateCache(tenantId);
+ return saved;
+ }
+
@Override
public void deleteTenant(String tenantId) {
executionContextManager.executeAsSystem(() -> {
@@ -308,6 +358,7 @@ public class TenantServiceImpl implements TenantService {
}
}
persistenceService.remove(tenantId, Tenant.class);
+ persistenceService.refreshIndex(Tenant.class);
});
invalidateGetOrCreateCache(tenantId);
}
diff --git a/services/src/test/java/org/apache/unomi/services/impl/TestTenantService.java b/services/src/test/java/org/apache/unomi/services/impl/TestTenantService.java
index b33839b28..fa3ae39d9 100644
--- a/services/src/test/java/org/apache/unomi/services/impl/TestTenantService.java
+++ b/services/src/test/java/org/apache/unomi/services/impl/TestTenantService.java
@@ -74,6 +74,21 @@ public class TestTenantService implements TenantService {
}
}
+ @Override
+ public Tenant updateTenantSettings(Tenant settings) {
+ Tenant stored = settings == null ? null : tenants.get(settings.getItemId());
+ if (stored == null) {
+ return null;
+ }
+ stored.setName(settings.getName());
+ stored.setDescription(settings.getDescription());
+ stored.setStatus(settings.getStatus());
+ stored.setProperties(settings.getProperties());
+ stored.setRestrictedEventTypes(settings.getRestrictedEventTypes());
+ stored.setAuthorizedIPs(settings.getAuthorizedIPs());
+ return stored;
+ }
+
@Override
public void deleteTenant(String tenantId) {
tenants.remove(tenantId);
diff --git a/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantServiceImplTest.java b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantServiceImplTest.java
index 12ec5b3b0..2bdcb4a3f 100644
--- a/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantServiceImplTest.java
+++ b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantServiceImplTest.java
@@ -89,6 +89,8 @@ public class TenantServiceImplTest {
tenantService.setExecutionContextManager(executionContextManager);
tenantService.setSecretHashService(secretHashService);
+ // Compare-and-set writes succeed unless a test says otherwise.
+ when(persistenceService.save(any(Tenant.class), eq(false), eq(false))).thenReturn(true);
when(executionContextManager.executeAsSystem(any(Supplier.class))).thenAnswer(invocation -> {
Supplier<?> supplier = invocation.getArgument(0);
return supplier.get();
@@ -410,4 +412,86 @@ public class TenantServiceImplTest {
() -> tenantService.deleteTenant("missing-tenant"));
assertEquals("Tenant not found: missing-tenant", exception.getMessage());
}
+
+ @Test
+ public void updatingSettingsKeepsAKeyReplacedConcurrently() {
+ // UNOMI-1013: a tenant update must never undo an API key change made while it was in progress.
+ Tenant beforeRotation = tenantWithKey("old-hash");
+ Tenant afterRotation = tenantWithKey("new-hash");
+ when(persistenceService.load("acme", Tenant.class)).thenReturn(beforeRotation, afterRotation);
+ // Tenants are equal by id, so the conflict is stubbed as a sequence: the first write loses the race.
+ when(persistenceService.save(any(Tenant.class), eq(false), eq(false))).thenReturn(false, true);
+
+ Tenant settings = new Tenant();
+ settings.setItemId("acme");
+ settings.setProperties(Map.of("publicCorsAllowedOrigins", "https://shop.example"));
+ ApiKey fromBody = new ApiKey();
+ fromBody.setKeyHash(null);
+ settings.setApiKeys(List.of(fromBody));
+
+ Tenant saved = tenantService.updateTenantSettings(settings);
+
+ assertSame(afterRotation, saved);
+ assertEquals("new-hash", saved.getApiKeys().get(0).getKeyHash());
+ assertEquals("https://shop.example", saved.getProperties().get("publicCorsAllowedOrigins"));
+ verify(persistenceService).refreshIndex(Tenant.class);
+ }
+
+ @Test
+ public void updatingSettingsOfAnUnknownTenantReturnsNull() {
+ when(persistenceService.load("missing", Tenant.class)).thenReturn(null);
+ Tenant settings = new Tenant();
+ settings.setItemId("missing");
+
+ assertNull(tenantService.updateTenantSettings(settings));
+ verify(persistenceService, never()).save(any(Tenant.class), eq(false), eq(false));
+ }
+
+ @Test
+ public void updatingSettingsGivesUpOnATenantThatKeepsChanging() {
+ when(persistenceService.load("acme", Tenant.class)).thenAnswer(invocation -> tenantWithKey("hash"));
+ when(persistenceService.save(any(Tenant.class), eq(false), eq(false))).thenReturn(false);
+ Tenant settings = new Tenant();
+ settings.setItemId("acme");
+
+ assertThrows(java.util.ConcurrentModificationException.class, () -> tenantService.updateTenantSettings(settings));
+ }
+
+ @Test
+ public void replacingAKeyKeepsSettingsUpdatedConcurrently() {
+ Tenant before = tenantWithKey("old-hash");
+ Tenant updatedMeanwhile = tenantWithKey("old-hash");
+ updatedMeanwhile.setProperties(Map.of("publicCorsAllowedOrigins", "https://shop.example"));
+ when(persistenceService.load("acme", Tenant.class)).thenReturn(before, updatedMeanwhile);
+ when(persistenceService.save(any(Tenant.class), eq(false), eq(false))).thenReturn(false, true);
+ when(secretHashService.hash(anyString())).thenReturn("new-hash");
+
+ tenantService.generateApiKeyWithType("acme", ApiKey.ApiKeyType.PUBLIC, null);
+
+ assertEquals("https://shop.example", updatedMeanwhile.getProperties().get("publicCorsAllowedOrigins"));
+ assertEquals("new-hash", updatedMeanwhile.getApiKeys().get(0).getKeyHash());
+ verify(persistenceService).refreshIndex(Tenant.class);
+ }
+
+ @Test
+ public void savingOrDeletingATenantRefreshesTheIndex() {
+ Tenant tenant = tenantWithKey("hash");
+ tenantService.saveTenant(tenant);
+ when(persistenceService.load("acme", Tenant.class)).thenReturn(tenant);
+ tenantService.deleteTenant("acme");
+
+ verify(persistenceService, times(2)).refreshIndex(Tenant.class);
+ }
+
+ private static Tenant tenantWithKey(String hash) {
+ ApiKey key = new ApiKey();
+ key.setItemId("key-" + hash);
+ key.setKeyType(ApiKey.ApiKeyType.PUBLIC);
+ key.setKeyHash(hash);
+ Tenant tenant = new Tenant();
+ tenant.setItemId("acme");
+ tenant.setApiKeys(new ArrayList<>(List.of(key)));
+ tenant.setProperties(new HashMap<>());
+ return tenant;
+ }
}
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..2dd63cf31 100644
--- a/web-servlets/src/main/resources/org.apache.unomi.web.cfg
+++ b/web-servlets/src/main/resources/org.apache.unomi.web.cfg
@@ -25,8 +25,9 @@ contextserver.profileIdCookieMaxAgeInSeconds=${org.apache.unomi.profile.cookie.m
# This setting controls if the cookie should be flagged as HttpOnly or not. Only false turns it off; any other
# value keeps it on and is logged as a warning.
contextserver.profileIdCookieHttpOnly=${org.apache.unomi.profile.cookie.httpOnly:-true}
-# SameSite for the visitor cookie. Lax is the default. None is used only when the cookie is also Secure
-# and public.cors.allowedOrigins lists at least one origin; otherwise it falls back to Lax.
+# SameSite for the visitor cookie. Lax is the default. None is used only when the cookie is also Secure and
+# origins can be listed (public.cors.allowedOrigins is not empty, or public.cors.tenantOrigins is not false);
+# otherwise it falls back to Lax.
contextserver.profileIdCookieSameSite=${org.apache.unomi.profile.cookie.sameSite:-Lax}
# Comma-separated website origins (scheme, host and port) that may send the visitor cookie
# to context.js, context.json, eventcollector and client. Empty means no origin other than Unomi's own may send cookies.
@@ -34,6 +35,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");
}
}