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