From 3085fe304978e29359c86b51ca54b58b48e55d4e Mon Sep 17 00:00:00 2001
From: Serge Huber
Date: Mon, 18 May 2026 20:53:13 +0200
Subject: [PATCH 1/6] UNOMI-139/880/878/884: Multi-tenant platform core
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Introduce the Unomi 3.1 platform core: tenant-scoped execution, unified
multi-type caching, cluster-aware scheduling, and 3.1.0 migration
tooling. REST layers use V3 tenant resolution and role-based auth.
UNOMI-139 — Multi-tenancy
- Tenant model and APIs (Tenant, TenantService, API keys, security and
audit hooks, quotas, lifecycle listeners)
- ExecutionContextManager and tenant propagation across services,
persistence (Elasticsearch/OpenSearch), GraphQL, and REST
- Tenant-scoped items and definitions (system tenant vs tenant overrides)
UNOMI-880 — Unified multi-tenant caching
- MultiTypeCacheService, CacheableTypeConfig, and shared cache lifecycle
(refresh, statistics, predefined item bundling)
- Migrate DefinitionsService and SegmentService onto the unified cache;
remove superseded ad-hoc cache listeners
UNOMI-878 — Cluster-aware task scheduling
- SchedulerService with persistent and in-memory tasks, TaskExecutor
registration, and cluster-aware execution
- Integrate periodic work (cache refresh, cluster heartbeat, router
jobs, rule refresh) via the new scheduler API
UNOMI-884 — Migration to V3
- 3.1.0 migration Groovy scripts and Elasticsearch request bodies
(tenant document IDs, system item ID fixes, tenant initialization,
legacy queryBuilder updates)
- MigrationUtils extensions and MigrationUtilsTest
Integration tests updated for platform behaviour (including migration
and ScopeIT). Verified locally with OpenSearch integration tests.
---
.../unomi/router/core/context/RouterCamelContext.java | 8 --------
1 file changed, 8 deletions(-)
diff --git a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java
index 0f3c682ad..d8c59857a 100644
--- a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java
+++ b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java
@@ -67,9 +67,6 @@
*
*
*
- * Dependency-injection setters on this class are intended for OSGi/Blueprint wiring and are not part of the
- * {@link IRouterCamelContext} API surface.
- *
* @since 1.0
*/
public class RouterCamelContext implements IRouterCamelContext {
@@ -144,11 +141,6 @@ public void init() throws Exception {
LOGGER.info("Camel Context initialized successfully.");
}
- /**
- * Stops the configuration refresh scheduler and shuts down the Camel context (all routes and components).
- *
- * @throws Exception if Camel shutdown fails
- */
public void destroy() throws Exception {
if (scheduledTask != null) {
schedulerService.cancelTask(scheduledTask.getItemId());
From 2e6553e48e0469bca483bca0681e353d7cc2da5a Mon Sep 17 00:00:00 2001
From: Serge Huber
Date: Tue, 19 May 2026 06:22:59 +0200
Subject: [PATCH 2/6] UNOMI-904: V2 API compatibility mode
Let V2 client applications run on the V3 multi-tenant platform without
rewriting authentication first: compatibility mode can be toggled at runtime,
legacy third-party API keys and IP/X-Unomi-Peer checks apply for protected
events, and public endpoints such as /context.json stay usable without tenant
API keys while V3 tenant auth remains the default when the mode is off.
- V2ThirdPartyConfigService wired to org.apache.unomi.thirdparty.cfg
- REST authentication filter and config for V2 compatibility routing
- Event collector allowlist checks in RestServiceUtilsImpl
- Karaf packaging for org.apache.unomi.rest.authentication.cfg
- V2CompatibilityModeIT in the integration test suite
---
.../java/org/apache/unomi/itests/AllITs.java | 1 +
.../unomi/itests/V2CompatibilityModeIT.java | 435 ++++++++++++++++++
kar/src/main/feature/feature.xml | 1 +
.../resources/etc/custom.system.properties | 6 +
rest/pom.xml | 24 +
.../authentication/AuthenticationFilter.java | 67 +++
.../RestAuthenticationConfig.java | 19 +
.../V2ThirdPartyConfigService.java | 259 +++++++++++
.../impl/DefaultRestAuthenticationConfig.java | 58 ++-
.../service/impl/RestServiceUtilsImpl.java | 65 ++-
.../org.apache.unomi.rest.authentication.cfg | 31 ++
11 files changed, 960 insertions(+), 6 deletions(-)
create mode 100644 itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
create mode 100644 rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java
create mode 100644 rest/src/main/resources/org.apache.unomi.rest.authentication.cfg
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 2be7f0641..2e835cf0a 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -64,6 +64,7 @@
GraphQLProfileAliasesIT.class,
SendEventActionIT.class,
ScopeIT.class,
+ V2CompatibilityModeIT.class,
HealthCheckIT.class,
LegacyQueryBuilderMappingIT.class,
})
diff --git a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
new file mode 100644
index 000000000..a41d959d9
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
@@ -0,0 +1,435 @@
+/*
+ * 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.auth.AuthScope;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.config.AuthSchemes;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.BasicCredentialsProvider;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.unomi.api.*;
+import org.apache.unomi.api.tenants.ApiKey;
+import org.apache.unomi.api.tenants.Tenant;
+import org.apache.unomi.itests.TestUtils.RequestResponse;
+import org.apache.unomi.rest.authentication.RestAuthenticationConfig;
+import org.junit.After;
+import org.junit.Assert;
+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.*;
+import java.util.Base64;
+import java.util.Objects;
+
+import static org.junit.Assert.*;
+
+/**
+ * Integration tests for V2 compatibility mode authentication.
+ * Tests the behavior when switching between V2 and V3 authentication modes
+ * using OSGi configuration admin without restarting bundles.
+ */
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class V2CompatibilityModeIT extends BaseIT {
+
+ private final static Logger LOGGER = LoggerFactory.getLogger(V2CompatibilityModeIT.class);
+ private final static String CONTEXT_URL = "/cxs/context.json";
+ private static final String TEST_SCOPE = "testScope";
+ private final static String TEST_SESSION_ID = "v2-compat-test-session-" + System.currentTimeMillis();
+ private final static String TEST_PROFILE_ID = "v2-compat-test-profile-" + System.currentTimeMillis();
+ private final static String UNOMI_API_KEY_HEADER = "X-Unomi-Api-Key";
+ private final static String UNOMI_TENANT_ID_HEADER = "X-Unomi-Tenant-Id";
+ private final static String UNOMI_PEER_HEADER = "X-Unomi-Peer";
+
+ private boolean originalV2Mode;
+ private String originalDefaultTenantId;
+
+ @Before
+ public void setUp() throws InterruptedException, IOException {
+
+ TestUtils.createScope(TEST_SCOPE, "Test scope", scopeService);
+ keepTrying("Scope "+ TEST_SCOPE +" not found in the required time", () -> scopeService.getScope(TEST_SCOPE),
+ Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ // Store original V2 mode setting and default tenant ID
+ originalV2Mode = restAuthenticationConfig.isV2CompatibilityModeEnabled();
+ originalDefaultTenantId = restAuthenticationConfig.getV2CompatibilityDefaultTenantId();
+
+ // Configure V2 compatibility mode to use the BaseIT test tenant as default
+ Map v2Config = new HashMap<>();
+ v2Config.put("v2.compatibilitymode.enabled", false); // Start in V3 mode
+ v2Config.put("v2.compatibilitymode.defaultTenantId", TEST_TENANT_ID); // Use BaseIT tenant
+
+ updateConfiguration(null,
+ "org.apache.unomi.rest.authentication",
+ v2Config);
+
+ // Wait for configuration to be applied
+ keepTrying("V2 compatibility configuration not applied in the required time",
+ () -> restAuthenticationConfig.getV2CompatibilityDefaultTenantId(),
+ tenantId -> TEST_TENANT_ID.equals(tenantId), DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ // Create test profile
+ Profile profile = new Profile(TEST_PROFILE_ID);
+ profileService.save(profile);
+
+ keepTrying("Profile " + TEST_PROFILE_ID + " not found in the required time",
+ () -> profileService.load(TEST_PROFILE_ID),
+ Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ }
+
+ @After
+ public void tearDown() throws InterruptedException, IOException {
+ try {
+ // Restore original V2 mode setting and default tenant ID
+ Map originalConfig = new HashMap<>();
+ originalConfig.put("v2.compatibilitymode.enabled", originalV2Mode);
+ if (originalDefaultTenantId != null) {
+ originalConfig.put("v2.compatibilitymode.defaultTenantId", originalDefaultTenantId);
+ }
+
+ updateConfiguration(null,
+ "org.apache.unomi.rest.authentication",
+ originalConfig);
+ } catch (Exception e) {
+ LOGGER.warn("Failed to restore original V2 mode setting", e);
+ }
+
+ // Clean up test data
+ try {
+ TestUtils.removeAllEvents(definitionsService, persistenceService, true, tenantService, executionContextManager);
+ TestUtils.removeAllSessions(definitionsService, persistenceService, true, tenantService, executionContextManager);
+ TestUtils.removeAllProfiles(definitionsService, persistenceService, true, tenantService, executionContextManager);
+
+ profileService.delete(TEST_PROFILE_ID, false);
+ removeItems(Session.class);
+
+ scopeService.delete(TEST_SCOPE);
+ } catch (Exception e) {
+ LOGGER.warn("Failed to clean up test data", e);
+ }
+
+
+ }
+
+ @Test
+ public void testV2CompatibilityModeSwitch() throws Exception {
+ LOGGER.info("Starting V2 compatibility mode switch test");
+
+ // STEP 1: Test V3 mode (default) - V2 requests should be rejected, V3 requests should work
+ LOGGER.info("STEP 1: Testing V3 mode (default)");
+ testV3ModeBehavior();
+
+ // STEP 2: Switch to V2 compatibility mode
+ LOGGER.info("STEP 2: Switching to V2 compatibility mode");
+ updateConfiguration(null,
+ "org.apache.unomi.rest.authentication",
+ "v2.compatibilitymode.enabled",
+ true);
+
+ // Wait for configuration to take effect
+ keepTrying("V2 compatibility mode not enabled in the required time",
+ () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(),
+ enabled -> enabled, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ // STEP 3: Test V2 mode - V2 requests should work, V3 requests should be rejected
+ LOGGER.info("STEP 3: Testing V2 compatibility mode");
+ testV2ModeBehavior();
+
+ // STEP 4: Switch back to V3 mode
+ LOGGER.info("STEP 4: Switching back to V3 mode");
+ updateConfiguration(null,
+ "org.apache.unomi.rest.authentication",
+ "v2.compatibilitymode.enabled",
+ false);
+
+ // Wait for configuration to take effect
+ keepTrying("V2 compatibility mode not disabled in the required time",
+ () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(),
+ enabled -> !enabled, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ // STEP 5: Test V3 mode again - V2 requests should be rejected, V3 requests should work
+ LOGGER.info("STEP 5: Testing V3 mode again");
+ testV3ModeBehavior();
+
+ LOGGER.info("V2 compatibility mode switch test completed successfully");
+ }
+
+ /**
+ * Test behavior in V3 mode (default):
+ * - V2 requests (no auth) should be rejected
+ * - V3 requests with proper authentication should work
+ */
+ private void testV3ModeBehavior() throws Exception {
+ // Test V2-style request (no authentication) - should be rejected
+ ContextRequest contextRequest = new ContextRequest();
+ contextRequest.setSessionId(TEST_SESSION_ID);
+
+ HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+ request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
+ TestUtils.RequestResponse response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID, 401, false);
+ assertEquals("V2-style request should be rejected in V3 mode", 401, response.getStatusCode());
+
+ // Test V3-style request with public API key - should work
+ request = new HttpPost(getFullUrl(CONTEXT_URL));
+ request.addHeader(UNOMI_API_KEY_HEADER, testPublicKey.getKey());
+ request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
+ response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+ assertEquals("V3-style request with public API key should work in V3 mode", 200, response.getStatusCode());
+
+ // Test V3-style request with private API key - should work
+ request = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPrivateTenantAuth(request, testTenant, testPrivateKey);
+ request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
+ response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+ assertEquals("V3-style request with private API key should work in V3 mode", 200, response.getStatusCode());
+
+ // Test V3-style request with JAAS authentication - should work
+ request = new HttpPost(getFullUrl(CONTEXT_URL));
+ request.addHeader(UNOMI_TENANT_ID_HEADER, testTenant.getItemId());
+ request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
+
+ BasicCredentialsProvider credsProvider = new BasicCredentialsProvider();
+ credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf"));
+
+ RequestConfig requestConfig = RequestConfig.custom()
+ .setAuthenticationEnabled(true)
+ .setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.BASIC))
+ .build();
+
+ CloseableHttpClient adminClient = HttpClients.custom()
+ .setDefaultCredentialsProvider(credsProvider)
+ .setDefaultRequestConfig(requestConfig)
+ .build();
+
+ CloseableHttpResponse jaasResponse = adminClient.execute(request);
+ assertEquals("V3-style request with JAAS auth should work in V3 mode", 200, jaasResponse.getStatusLine().getStatusCode());
+ adminClient.close();
+ }
+
+ /**
+ * Test behavior in V2 compatibility mode:
+ * - V2 requests (no auth for public endpoints) should work
+ * - V3 requests should be rejected
+ */
+ private void testV2ModeBehavior() throws Exception {
+ // Test V2-style request (no authentication for public endpoint) - should work
+ ContextRequest contextRequest = new ContextRequest();
+ contextRequest.setSessionId(TEST_SESSION_ID);
+
+ HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+ request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
+ TestUtils.RequestResponse response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+ assertEquals("V2-style request should work in V2 compatibility mode", 200, response.getStatusCode());
+
+ // Test V2-style request with X-Unomi-Peer header (V2 third-party auth) - should work
+ request = new HttpPost(getFullUrl(CONTEXT_URL));
+ request.addHeader(UNOMI_PEER_HEADER, "670c26d1cc413346c3b2fd9ce65dab41");
+ request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
+ response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+ assertEquals("V2-style request with X-Unomi-Peer should work in V2 compatibility mode", 200, response.getStatusCode());
+
+ // Test V3-style request with public API key - should be rejected in V2 mode
+ request = new HttpPost(getFullUrl(CONTEXT_URL));
+ request.addHeader(UNOMI_API_KEY_HEADER, testPublicKey.getKey());
+ request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
+ response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+ assertEquals("V3-style request with public API key should return 200 in V2 compatibility mode", 200, response.getStatusCode());
+ assertEquals("V3-style request with public API key should have 0 processed events in V2 mode", 0, response.getContextResponse().getProcessedEvents());
+
+ // Test V3-style request with private API key - should be rejected in V2 mode
+ request = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPrivateTenantAuth(request, testTenant, testPrivateKey);
+ request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
+ response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+ assertEquals("V3-style request with private API key should return 200 in V2 compatibility mode", 200, response.getStatusCode());
+ assertEquals("V3-style request with private API key should have 0 processed events in V2 mode", 0, response.getContextResponse().getProcessedEvents());
+
+ // Test private endpoint with JAAS authentication - should work (like V2)
+ HttpGet getRequest = new HttpGet(getFullUrl("/cxs/profiles/" + TEST_PROFILE_ID));
+
+ BasicCredentialsProvider credsProvider = new BasicCredentialsProvider();
+ credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf"));
+
+ RequestConfig requestConfig = RequestConfig.custom()
+ .setAuthenticationEnabled(true)
+ .setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.BASIC))
+ .build();
+
+ CloseableHttpClient adminClient = HttpClients.custom()
+ .setDefaultCredentialsProvider(credsProvider)
+ .setDefaultRequestConfig(requestConfig)
+ .build();
+
+ CloseableHttpResponse jaasResponse = adminClient.execute(getRequest);
+ assertEquals("Private endpoint with JAAS auth should work in V2 compatibility mode", 200, jaasResponse.getStatusLine().getStatusCode());
+ adminClient.close();
+ }
+
+ @Test
+ public void testV2CompatibilityModeWithProtectedEvents() throws Exception {
+ LOGGER.info("Testing V2 compatibility mode with protected events");
+
+ // Switch to V2 compatibility mode
+ updateConfiguration(null,
+ "org.apache.unomi.rest.authentication",
+ "v2.compatibilitymode.enabled",
+ true);
+
+ keepTrying("V2 compatibility mode not enabled in the required time",
+ () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(),
+ enabled -> enabled, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ // Test protected event (login) without V2 third-party authentication - should be rejected
+ Event loginEvent = new Event();
+ loginEvent.setEventType("login");
+ loginEvent.setScope(TEST_SCOPE);
+
+ ContextRequest contextRequest = new ContextRequest();
+ contextRequest.setSessionId(TEST_SESSION_ID);
+ contextRequest.setEvents(Arrays.asList(loginEvent));
+
+ HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+ request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
+ TestUtils.RequestResponse response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID, 200, false);
+ assertEquals("Protected event without V2 auth should return 200", 200, response.getStatusCode());
+ assertEquals("Protected event without V2 auth should have 0 processed events", 0, response.getContextResponse().getProcessedEvents());
+
+ // Test protected event with V2 third-party authentication - should work
+ request = new HttpPost(getFullUrl(CONTEXT_URL));
+ request.addHeader(UNOMI_PEER_HEADER, "670c26d1cc413346c3b2fd9ce65dab41");
+ request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
+ response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+ assertEquals("Protected event with V2 auth should work", 200, response.getStatusCode());
+ assertEquals("Protected event with V2 auth should have 1 processed event", 1, response.getContextResponse().getProcessedEvents());
+
+ // Test protected event with empty X-Unomi-Peer header - should be rejected
+ request = new HttpPost(getFullUrl(CONTEXT_URL));
+ request.addHeader(UNOMI_PEER_HEADER, "");
+ request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
+ response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+ assertEquals("Protected event with empty X-Unomi-Peer should return 200", 200, response.getStatusCode());
+ assertEquals("Protected event with empty X-Unomi-Peer should have 0 processed events", 0, response.getContextResponse().getProcessedEvents());
+
+ // Test non-protected event (view) without authentication - should work
+ // Load the view event from JSON file
+ String contextRequestJson = resourceAsString("events/viewEvent.json");
+
+ // Replace the session ID with the test session ID
+ contextRequestJson = contextRequestJson.replace("test-session-id", TEST_SESSION_ID);
+ contextRequestJson = contextRequestJson.replace("testScope", TEST_SCOPE);
+
+ request = new HttpPost(getFullUrl(CONTEXT_URL));
+ request.setEntity(new StringEntity(contextRequestJson, ContentType.APPLICATION_JSON));
+ response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+ assertEquals("Non-protected event without auth should work in V2 mode", 200, response.getStatusCode());
+ assertEquals("Non-protected event without auth should have 1 processed event", 1, response.getContextResponse().getProcessedEvents());
+ }
+
+ @Test
+ public void testV2CompatibilityModeDefaultTenant() throws Exception {
+ LOGGER.info("Testing V2 compatibility mode default tenant behavior");
+
+ // Verify the configuration was applied correctly in setUp()
+ assertEquals("Default tenant should be set to BaseIT tenant", TEST_TENANT_ID, restAuthenticationConfig.getV2CompatibilityDefaultTenantId());
+
+ // Switch to V2 compatibility mode
+ updateConfiguration(null,
+ "org.apache.unomi.rest.authentication",
+ "v2.compatibilitymode.enabled",
+ true);
+
+ keepTrying("V2 compatibility mode not enabled in the required time",
+ () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(),
+ enabled -> enabled, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ // Verify the configuration was applied
+ assertTrue("V2 compatibility mode should be enabled", restAuthenticationConfig.isV2CompatibilityModeEnabled());
+ assertEquals("Default tenant should be set to BaseIT tenant", TEST_TENANT_ID, restAuthenticationConfig.getV2CompatibilityDefaultTenantId());
+
+ // Test that requests work with the BaseIT tenant as default
+ ContextRequest contextRequest = new ContextRequest();
+ contextRequest.setSessionId(TEST_SESSION_ID);
+
+ HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+ request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
+ TestUtils.RequestResponse response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+ assertEquals("V2-style request should work with BaseIT tenant as default", 200, response.getStatusCode());
+ }
+
+ @Test
+ public void testV2CompatibilityModeConfigurationPersistence() throws Exception {
+ LOGGER.info("Testing V2 compatibility mode configuration persistence");
+
+ // Test that configuration changes persist across service updates
+ updateConfiguration(null,
+ "org.apache.unomi.rest.authentication",
+ "v2.compatibilitymode.enabled",
+ true);
+
+ keepTrying("V2 compatibility mode not enabled in the required time",
+ () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(),
+ enabled -> enabled, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ // Verify configuration is applied
+ assertTrue("V2 compatibility mode should be enabled", restAuthenticationConfig.isV2CompatibilityModeEnabled());
+ assertEquals("Default tenant should persist", TEST_TENANT_ID, restAuthenticationConfig.getV2CompatibilityDefaultTenantId());
+
+ // Update services to simulate service restart
+ updateServices();
+
+ // Verify configuration persists
+ assertTrue("V2 compatibility mode should persist after service update", restAuthenticationConfig.isV2CompatibilityModeEnabled());
+ assertEquals("Default tenant should persist after service update", TEST_TENANT_ID, restAuthenticationConfig.getV2CompatibilityDefaultTenantId());
+
+ // Test that behavior is still correct
+ ContextRequest contextRequest = new ContextRequest();
+ contextRequest.setSessionId(TEST_SESSION_ID);
+
+ HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+ request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
+ TestUtils.RequestResponse response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+ assertEquals("V2-style request should still work after service update", 200, response.getStatusCode());
+ }
+
+ private static void addPrivateTenantAuth(HttpPost request, Tenant tenant, ApiKey privateKey) {
+ request.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString(
+ (tenant.getItemId() + ":" + privateKey.getKey()).getBytes()));
+ }
+
+ @Override
+ public void updateServices() throws InterruptedException {
+ super.updateServices();
+ restAuthenticationConfig = getService(RestAuthenticationConfig.class);
+ }
+}
diff --git a/kar/src/main/feature/feature.xml b/kar/src/main/feature/feature.xml
index d6768233b..2ded970f2 100644
--- a/kar/src/main/feature/feature.xml
+++ b/kar/src/main/feature/feature.xml
@@ -129,6 +129,7 @@
unomi-services
unomi-cxs-privacy-extension-services
mvn:org.apache.unomi/unomi-json-schema-services/${project.version}/cfg/schemacfg
+ mvn:org.apache.unomi/unomi-rest/${project.version}/cfg/restauth
mvn:org.apache.unomi/unomi-json-schema-services/${project.version}
mvn:org.apache.unomi/unomi-rest/${project.version}
mvn:org.apache.unomi/unomi-json-schema-rest/${project.version}
diff --git a/package/src/main/resources/etc/custom.system.properties b/package/src/main/resources/etc/custom.system.properties
index 0f9a70cf1..f6385658b 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -511,3 +511,9 @@ karaf.local.roles = admin,manager,viewer,systembundles,ROLE_UNOMI_ADMIN,ROLE_UNO
#######################################################################################################################
org.apache.unomi.goals.refresh.interval=${env:UNOMI_GOALS_REFRESH_INTERVAL:-5000}
org.apache.unomi.campaigns.refresh.interval=${env:UNOMI_CAMPAIGNS_REFRESH_INTERVAL:-5000}
+
+#######################################################################################################################
+## REST API Authorization Settings ##
+#######################################################################################################################
+org.apache.unomi.rest.authentication.v2CompatibilityModeEnabled=${env:UNOMI_REST_AUTHENTICATION_V2COMPATIBILITYMODEENABLED:-false}
+org.apache.unomi.rest.authentication.v2CompatibilityDefaultTenantId=${env:UNOMI_REST_AUTHENTICATION_V2COMPATIBILITYDEFAULTTENANTID:-default}
diff --git a/rest/pom.xml b/rest/pom.xml
index 2c973eabe..315b9bbac 100644
--- a/rest/pom.xml
+++ b/rest/pom.xml
@@ -191,6 +191,30 @@
+
+ org.codehaus.mojo
+ build-helper-maven-plugin
+
+
+ attach-artifacts
+ package
+
+ attach-artifact
+
+
+
+
+
+ src/main/resources/org.apache.unomi.rest.authentication.cfg
+
+ cfg
+ restauth
+
+
+
+
+
+
org.apache.felix
maven-bundle-plugin
diff --git a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
index 47b0486dc..bfc88f4b1 100644
--- a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
+++ b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
@@ -107,6 +107,12 @@ public void filter(ContainerRequestContext requestContext) throws IOException {
try {
String path = requestContext.getUriInfo().getPath();
+ // Check if V2 compatibility mode is enabled
+ if (restAuthenticationConfig.isV2CompatibilityModeEnabled()) {
+ handleV2CompatibilityMode(requestContext, path);
+ return;
+ }
+
// Tenant endpoints require JAAS authentication only
if (path.startsWith("tenants")) {
String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
@@ -239,6 +245,67 @@ public void filter(ContainerRequestContext requestContext) throws IOException {
}
}
+ /**
+ * Handle authentication in V2 compatibility mode.
+ * In this mode:
+ * - Public endpoints (like /context.json) require no authentication (like V2)
+ * - Protected events require IP + X-Unomi-Peer (like V2)
+ * - Private endpoints require system administrator authentication (like V2)
+ * - A default tenant is automatically used for all operations
+ */
+ private void handleV2CompatibilityMode(ContainerRequestContext requestContext, String path) throws IOException {
+ // For public paths, allow access without authentication (like V2)
+ if (isPublicPath(requestContext)) {
+ String defaultTenantId = restAuthenticationConfig.getV2CompatibilityDefaultTenantId();
+ if (defaultTenantId != null) {
+ // Create a guest subject for public endpoints
+ Subject subject = securityService.createSubject(defaultTenantId, false);
+
+ // Set CXF security context
+ JAXRSUtils.getCurrentMessage().put(SecurityContext.class,
+ new RolePrefixSecurityContextImpl(subject, ROLE_CLASSIFIER, ROLE_CLASSIFIER_TYPE));
+
+ // Set the security service subject
+ securityService.setCurrentSubject(subject);
+
+ // Set the execution context for the default tenant
+ executionContextManager.setCurrentContext(executionContextManager.createContext(defaultTenantId));
+ return;
+ }
+ }
+
+ // For private endpoints, require system administrator authentication (like V2)
+ String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
+ if (authHeader != null && authHeader.startsWith(BASIC_AUTH_PREFIX)) {
+ try {
+ jaasAuthenticationFilter.filter(requestContext);
+ // Get the subject from the security context after successful JAAS auth
+ SecurityContext securityContext = JAXRSUtils.getCurrentMessage().get(SecurityContext.class);
+ if (securityContext != null) {
+ Subject subject = ((RolePrefixSecurityContextImpl) securityContext).getSubject();
+ // Set the authenticated subject in Unomi's security service
+ securityService.setCurrentSubject(subject);
+
+ // In V2 compatibility mode, use the default tenant for all operations
+ String defaultTenantId = restAuthenticationConfig.getV2CompatibilityDefaultTenantId();
+ if (defaultTenantId != null) {
+ executionContextManager.setCurrentContext(executionContextManager.createContext(defaultTenantId));
+ } else {
+ executionContextManager.setCurrentContext(ExecutionContext.systemContext());
+ }
+ }
+ return;
+ } catch (Exception e) {
+ logger.debug("V2 compatibility mode: JAAS authentication failed");
+ }
+ } else {
+ logger.debug("V2 compatibility mode: Missing Basic Auth header for private endpoint");
+ }
+
+ // If we get here, no valid authentication was provided
+ unauthorized(requestContext);
+ }
+
private String[] extractBasicAuthCredentials(String authHeader) {
try {
String base64Credentials = authHeader.substring(BASIC_AUTH_PREFIX.length()).trim();
diff --git a/rest/src/main/java/org/apache/unomi/rest/authentication/RestAuthenticationConfig.java b/rest/src/main/java/org/apache/unomi/rest/authentication/RestAuthenticationConfig.java
index 991c580e0..ed7022dc0 100644
--- a/rest/src/main/java/org/apache/unomi/rest/authentication/RestAuthenticationConfig.java
+++ b/rest/src/main/java/org/apache/unomi/rest/authentication/RestAuthenticationConfig.java
@@ -59,4 +59,23 @@ public interface RestAuthenticationConfig {
* @return Global roles separated with single white spaces, like: "ROLE1 ROLE2 ROLE3"
*/
String getGlobalRoles();
+
+ /**
+ * Check if V2 compatibility mode is enabled.
+ * When enabled, V2 clients can use Unomi V3 without requiring API keys:
+ * - Public endpoints (like /context.json) require no authentication (like V2)
+ * - Private endpoints require system administrator authentication (like V2)
+ * - A default tenant is automatically used for all operations
+ *
+ * @return true if V2 compatibility mode is enabled, false otherwise
+ */
+ boolean isV2CompatibilityModeEnabled();
+
+ /**
+ * Get the default tenant ID to use in V2 compatibility mode.
+ * This tenant will be used for all operations when V2 compatibility mode is enabled.
+ *
+ * @return the default tenant ID
+ */
+ String getV2CompatibilityDefaultTenantId();
}
diff --git a/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java b/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java
new file mode 100644
index 000000000..954493f72
--- /dev/null
+++ b/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java
@@ -0,0 +1,259 @@
+/*
+ * 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.authentication;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.unomi.services.common.security.IPValidationUtils;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Modified;
+import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.Designate;
+import org.osgi.service.metatype.annotations.ObjectClassDefinition;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Service to handle V2 third-party configuration for V2 compatibility mode.
+ * This service reads the legacy V2 third-party configuration and provides
+ * methods to validate protected events and third-party providers.
+ * Uses the original V2 configuration file: org.apache.unomi.thirdparty.cfg
+ */
+@Component(service = V2ThirdPartyConfigService.class, configurationPid = "org.apache.unomi.thirdparty")
+@Designate(ocd = V2ThirdPartyConfigService.Config.class)
+public class V2ThirdPartyConfigService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(V2ThirdPartyConfigService.class);
+
+ @ObjectClassDefinition(
+ name = "Apache Unomi Third-Party Configuration",
+ description = "Configuration for third-party providers (V2 compatibility mode). " +
+ "Providers are configured using the pattern: thirdparty.{providerName}.{property}. " +
+ "Example: thirdparty.myapp.key, thirdparty.myapp.ipAddresses, thirdparty.myapp.allowedEvents"
+ )
+ public @interface Config {
+ // No hardcoded attributes - all providers are configured dynamically
+ // using the pattern: thirdparty.{providerName}.{property}
+ }
+
+ /**
+ * Provider configuration data structure
+ */
+ private static class ProviderConfig {
+ private final String key;
+ private final Set ipAddresses;
+ private final Set allowedEvents;
+
+ public ProviderConfig(String key, Set ipAddresses, Set allowedEvents) {
+ this.key = key;
+ this.ipAddresses = ipAddresses;
+ this.allowedEvents = allowedEvents;
+ }
+
+ public String getKey() { return key; }
+ public Set getIpAddresses() { return ipAddresses; }
+ public Set getAllowedEvents() { return allowedEvents; }
+ }
+
+ private volatile Map providers = new HashMap<>();
+
+ @Activate
+ public void activate(Map properties) {
+ modified(properties);
+ }
+
+ @Modified
+ public void modified(Map properties) {
+ Map newProviders = new HashMap<>();
+
+ if (properties != null) {
+ // Parse all provider configurations dynamically
+ for (Map.Entry entry : properties.entrySet()) {
+ String key = entry.getKey();
+ String value = entry.getValue() != null ? entry.getValue().toString() : "";
+
+ // Look for provider configuration patterns: thirdparty.{providerName}.{property}
+ if (key.startsWith("thirdparty.") && key.contains(".")) {
+ String[] parts = key.split("\\.");
+ if (parts.length >= 3) {
+ String providerName = parts[1];
+ String property = parts[2];
+
+ ProviderConfig existingConfig = newProviders.get(providerName);
+ String configKey = existingConfig != null ? existingConfig.getKey() : "";
+ Set configIpAddresses = existingConfig != null ? existingConfig.getIpAddresses() : new HashSet<>();
+ Set configAllowedEvents = existingConfig != null ? existingConfig.getAllowedEvents() : new HashSet<>();
+
+ switch (property) {
+ case "key":
+ configKey = value;
+ break;
+ case "ipAddresses":
+ configIpAddresses = parseCommaSeparatedList(value);
+ break;
+ case "allowedEvents":
+ configAllowedEvents = parseCommaSeparatedList(value);
+ break;
+ }
+
+ // Only add provider if it has a key (required for authentication)
+ if (StringUtils.isNotBlank(configKey)) {
+ newProviders.put(providerName, new ProviderConfig(configKey, configIpAddresses, configAllowedEvents));
+ }
+ }
+ }
+ }
+ }
+
+ // Set default provider1 if no providers configured
+ if (newProviders.isEmpty()) {
+ newProviders.put("provider1", new ProviderConfig(
+ "670c26d1cc413346c3b2fd9ce65dab41",
+ new HashSet<>(Arrays.asList("127.0.0.1", "::1")),
+ new HashSet<>(Arrays.asList("login", "updateProperties"))
+ ));
+ }
+
+ this.providers = newProviders;
+
+ int totalEvents = newProviders.values().stream()
+ .mapToInt(config -> config.getAllowedEvents().size())
+ .sum();
+
+ LOGGER.info("V2 Third-Party Configuration updated - {} providers with {} total protected events",
+ newProviders.size(), totalEvents);
+ }
+
+ /**
+ * Check if an event type is protected (requires third-party authentication).
+ *
+ * @param eventType the event type to check
+ * @return true if the event type is protected, false otherwise
+ */
+ public boolean isProtectedEventType(String eventType) {
+ if (StringUtils.isBlank(eventType)) {
+ return false;
+ }
+
+ return providers.values().stream()
+ .anyMatch(config -> config.getAllowedEvents().contains(eventType));
+ }
+
+ /**
+ * Get all protected event types from all providers.
+ *
+ * @return set of all protected event types
+ */
+ public Set getAllProtectedEventTypes() {
+ Set allProtectedEvents = new HashSet<>();
+ for (ProviderConfig config : providers.values()) {
+ allProtectedEvents.addAll(config.getAllowedEvents());
+ }
+ return Collections.unmodifiableSet(allProtectedEvents);
+ }
+
+
+ /**
+ * Validate a third-party provider by key for a given event type.
+ * This method is used when the X-Unomi-Peer header contains the provider key.
+ *
+ * @param providerKey the third-party provider key (from X-Unomi-Peer header)
+ * @param eventType the event type to validate
+ * @param sourceIP the source IP address
+ * @return true if the provider is authorized for this event type and IP, false otherwise
+ */
+ public boolean validateProviderByKey(String providerKey, String eventType, String sourceIP) {
+ if (StringUtils.isBlank(providerKey) || StringUtils.isBlank(eventType) || StringUtils.isBlank(sourceIP)) {
+ return false;
+ }
+
+ // Find the provider that has the matching key
+ ProviderConfig config = null;
+ String foundProviderId = null;
+ for (Map.Entry entry : providers.entrySet()) {
+ if (providerKey.equals(entry.getValue().getKey())) {
+ config = entry.getValue();
+ foundProviderId = entry.getKey();
+ break;
+ }
+ }
+
+ if (config == null) {
+ LOGGER.debug("V2 compatibility mode: Unknown provider key: {}", providerKey);
+ return false;
+ }
+
+ if (!config.getAllowedEvents().contains(eventType)) {
+ LOGGER.debug("V2 compatibility mode: Event type {} not allowed for provider {} (key: {})", eventType, foundProviderId, providerKey);
+ return false;
+ }
+
+ boolean ipAuthorized = IPValidationUtils.isIpAuthorized(sourceIP, config.getIpAddresses());
+ if (!ipAuthorized) {
+ LOGGER.debug("V2 compatibility mode: IP {} not authorized for provider {} (key: {})", sourceIP, foundProviderId, providerKey);
+ }
+
+ return ipAuthorized;
+ }
+
+ /**
+ * Get the key for a third-party provider.
+ *
+ * @param providerId the third-party provider ID
+ * @return the provider key, or null if not found
+ */
+ public String getProviderKey(String providerId) {
+ ProviderConfig config = providers.get(providerId);
+ return config != null ? config.getKey() : null;
+ }
+
+ /**
+ * Check if a provider ID is valid.
+ *
+ * @param providerId the third-party provider ID
+ * @return true if the provider ID is valid, false otherwise
+ */
+ public boolean isValidProvider(String providerId) {
+ return providers.containsKey(providerId);
+ }
+
+ private Set parseCommaSeparatedList(String value) {
+ if (StringUtils.isBlank(value)) {
+ return new HashSet<>();
+ }
+
+ Set result = new HashSet<>();
+ String[] parts = value.split(",");
+ for (String part : parts) {
+ String trimmed = part.trim();
+ if (StringUtils.isNotBlank(trimmed)) {
+ result.add(trimmed);
+ }
+ }
+ return result;
+ }
+
+
+}
diff --git a/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java b/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java
index d65cdfa0e..ea6690140 100644
--- a/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java
+++ b/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java
@@ -19,6 +19,12 @@
import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.rest.authentication.RestAuthenticationConfig;
import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Modified;
+import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.ObjectClassDefinition;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.regex.Pattern;
@@ -26,9 +32,10 @@
/**
* Default implementation for the unomi authentication on Rest endpoints
*/
-@Component(service = RestAuthenticationConfig.class)
+@Component(service = { RestAuthenticationConfig.class}, configurationPid = "org.apache.unomi.rest.authentication", immediate = true)
public class DefaultRestAuthenticationConfig implements RestAuthenticationConfig {
+ private static final Logger LOGGER = LoggerFactory.getLogger(DefaultRestAuthenticationConfig.class);
private static final String GUEST_ROLES = UnomiRoles.USER;
private static final String ADMIN_ROLES = UnomiRoles.ADMINISTRATOR;
private static final String TENANT_ADMIN_ROLES = UnomiRoles.ADMINISTRATOR + " " + UnomiRoles.TENANT_ADMINISTRATOR;
@@ -63,6 +70,26 @@ public class DefaultRestAuthenticationConfig implements RestAuthenticationConfig
ROLES_MAPPING = Collections.unmodifiableMap(roles);
}
+ private volatile boolean v2CompatibilityModeEnabled = false;
+ private volatile String v2CompatibilityDefaultTenantId = "default";
+
+
+ @Activate
+ @Modified
+ public void modified(Config config) {
+ if (config == null) {
+ LOGGER.warn("Config is null in modified method");
+ return;
+ }
+ boolean v2Mode = config.v2_compatibilitymode_enabled();
+ String defaultTenant = config.v2_compatibilitymode_defaultTenantId();
+ LOGGER.info("Configuration updated - v2CompatibilityModeEnabled: {}, v2CompatibilityDefaultTenantId: {}",
+ v2Mode, defaultTenant);
+ this.v2CompatibilityModeEnabled = v2Mode;
+ this.v2CompatibilityDefaultTenantId = defaultTenant;
+ }
+
+
@Override
public List getPublicPathPatterns() {
return PUBLIC_PATH_PATTERNS;
@@ -77,4 +104,33 @@ public Map getMethodRolesMap() {
public String getGlobalRoles() {
return TENANT_ADMIN_ROLES;
}
+
+ @Override
+ public boolean isV2CompatibilityModeEnabled() {
+ return v2CompatibilityModeEnabled;
+ }
+
+ @Override
+ public String getV2CompatibilityDefaultTenantId() {
+ return v2CompatibilityDefaultTenantId;
+ }
+
+ @ObjectClassDefinition(
+ name = "Unomi REST Authentication Configuration",
+ description = "Configuration for Unomi REST authentication including V2 compatibility mode"
+ )
+ public @interface Config {
+
+ @AttributeDefinition(
+ name = "V2 Compatibility Mode Enabled",
+ description = "Enable V2 compatibility mode to allow V2 clients to use Unomi V3 without API keys"
+ )
+ boolean v2_compatibilitymode_enabled() default false;
+
+ @AttributeDefinition(
+ name = "V2 Compatibility Default Tenant ID",
+ description = "Default tenant ID to use in V2 compatibility mode"
+ )
+ String v2_compatibilitymode_defaultTenantId() default "default";
+ }
}
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 a8ca7d9f3..f1dfb466b 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
@@ -29,6 +29,8 @@
import org.apache.unomi.api.services.ProfileService;
import org.apache.unomi.api.tenants.Tenant;
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.exception.InvalidRequestException;
import org.apache.unomi.rest.service.RestServiceUtils;
import org.apache.unomi.schema.api.SchemaService;
@@ -80,6 +82,12 @@ public class RestServiceUtilsImpl implements RestServiceUtils {
@Reference
private TenantService tenantService;
+ @Reference
+ private RestAuthenticationConfig restAuthenticationConfig;
+
+ @Reference
+ private V2ThirdPartyConfigService v2ThirdPartyConfigService;
+
@Override
public String getProfileIdCookieValue(HttpServletRequest httpServletRequest) {
String cookieProfileId = null;
@@ -269,11 +277,22 @@ public EventsRequestContext performEventsRequest(List events, EventsReque
Event eventToSend = new Event(event.getEventType(), eventsRequestContext.getSession(), eventsRequestContext.getProfile(), event.getScope(),
event.getSource(), event.getTarget(), event.getProperties(), eventsRequestContext.getTimestamp(), event.isPersistent());
eventToSend.setFlattenedProperties(event.getFlattenedProperties());
- if (!eventService.isEventAllowedForTenant(event, tenantId, eventsRequestContext.getRequest().getRemoteAddr())) {
- LOGGER.debug("Tenant is not authorized to send event {} from IP {}", event.getEventType(), eventsRequestContext.getRequest().getRemoteAddr());
- //Don't count the event that failed
- eventsRequestContext.setProcessedItems(eventsRequestContext.getProcessedItems() - 1);
- continue;
+ // Check if V2 compatibility mode is enabled and handle V2-style event authorization
+ if (restAuthenticationConfig.isV2CompatibilityModeEnabled()) {
+ if (!isEventAllowedInV2CompatibilityMode(event, eventsRequestContext.getRequest())) {
+ LOGGER.debug("Event {} not authorized in V2 compatibility mode from IP {}", event.getEventType(), eventsRequestContext.getRequest().getRemoteAddr());
+ //Don't count the event that failed
+ eventsRequestContext.setProcessedItems(eventsRequestContext.getProcessedItems() - 1);
+ continue;
+ }
+ } else {
+ // Normal V3 event authorization
+ if (!eventService.isEventAllowedForTenant(event, tenantId, eventsRequestContext.getRequest().getRemoteAddr())) {
+ LOGGER.debug("Tenant is not authorized to send event {} from IP {}", event.getEventType(), eventsRequestContext.getRequest().getRemoteAddr());
+ //Don't count the event that failed
+ eventsRequestContext.setProcessedItems(eventsRequestContext.getProcessedItems() - 1);
+ continue;
+ }
}
if (securityContext.isUserInRole(UnomiRoles.TENANT_ADMINISTRATOR) && event.getItemId() != null) {
eventToSend = new Event(event.getItemId(), event.getEventType(), eventsRequestContext.getSession(), eventsRequestContext.getProfile(), event.getScope(),
@@ -376,4 +395,40 @@ private Profile createNewProfile(String existingProfileId, Date timestamp) {
profile.setProperty("firstVisit", timestamp);
return profile;
}
+
+ /**
+ * Check if an event is allowed in V2 compatibility mode.
+ * In V2, protected events required IP + X-Unomi-Peer (third-party key) authentication.
+ *
+ * @param event the event to check
+ * @param request the HTTP request
+ * @return true if the event is allowed, false otherwise
+ */
+ private boolean isEventAllowedInV2CompatibilityMode(Event event, HttpServletRequest request) {
+ // Check if this is a protected event type using the V2 third-party configuration
+ if (!v2ThirdPartyConfigService.isProtectedEventType(event.getEventType())) {
+ // Non-protected events are always allowed in V2 compatibility mode
+ return true;
+ }
+
+ // For protected events, check IP + third-party key (V2-style)
+ String sourceIP = request.getRemoteAddr();
+ String thirdPartyKey = request.getHeader("X-Unomi-Peer");
+
+ if (StringUtils.isBlank(thirdPartyKey)) {
+ LOGGER.debug("V2 compatibility mode: Protected event {} rejected - missing X-Unomi-Peer header", event.getEventType());
+ return false;
+ }
+
+ // Validate the third-party provider using the V2 configuration
+ if (!v2ThirdPartyConfigService.validateProviderByKey(thirdPartyKey, event.getEventType(), sourceIP)) {
+ LOGGER.debug("V2 compatibility mode: Protected event {} rejected - invalid third-party provider key: {} from IP: {}",
+ event.getEventType(), thirdPartyKey, sourceIP);
+ return false;
+ }
+
+ LOGGER.debug("V2 compatibility mode: Protected event {} allowed for provider key: {} from IP: {}",
+ event.getEventType(), thirdPartyKey, sourceIP);
+ return true;
+ }
}
diff --git a/rest/src/main/resources/org.apache.unomi.rest.authentication.cfg b/rest/src/main/resources/org.apache.unomi.rest.authentication.cfg
new file mode 100644
index 000000000..db79d2627
--- /dev/null
+++ b/rest/src/main/resources/org.apache.unomi.rest.authentication.cfg
@@ -0,0 +1,31 @@
+#
+# 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.
+#
+# Unomi REST Authentication Configuration
+# This file configures authentication settings for Unomi REST endpoints
+
+# V2 Compatibility Mode
+# When enabled, allows V2 clients to use Unomi V3 without requiring API keys
+# - Public endpoints (like /context.json) require no authentication (like V2)
+# - Private endpoints require system administrator authentication (like V2)
+# - A default tenant is automatically used for all operations
+v2.compatibilitymode.enabled = ${org.apache.unomi.rest.authentication.v2CompatibilityModeEnabled:-false}
+
+# V2 Compatibility Default Tenant ID
+# Default tenant ID to use in V2 compatibility mode
+# This tenant will be used for all operations when V2 compatibility mode is enabled
+# Should match the tenant ID used during migration (e.g., "default" or "system")
+v2.compatibilitymode.defaultTenantId = ${org.apache.unomi.rest.authentication.v2CompatibilityDefaultTenantId:-default}
From e8c92473c4968c71aa88864c066236c13fa41049 Mon Sep 17 00:00:00 2001
From: Serge Huber
Date: Tue, 19 May 2026 08:21:22 +0200
Subject: [PATCH 3/6] UNOMI-904: Add viewEvent.json fixture for
V2CompatibilityModeIT
testV2CompatibilityModeWithProtectedEvents loads events/viewEvent.json;
resource was missing after V peel (Appendix J peel-fix).
---
.../src/test/resources/events/viewEvent.json | 37 +++++++++++++++++++
1 file changed, 37 insertions(+)
create mode 100644 itests/src/test/resources/events/viewEvent.json
diff --git a/itests/src/test/resources/events/viewEvent.json b/itests/src/test/resources/events/viewEvent.json
new file mode 100644
index 000000000..424621384
--- /dev/null
+++ b/itests/src/test/resources/events/viewEvent.json
@@ -0,0 +1,37 @@
+{
+ "sessionId": "test-session-id",
+ "events": [
+ {
+ "eventType": "view",
+ "scope": "testScope",
+ "source": {
+ "itemType": "site",
+ "scope": "testScope",
+ "itemId": "test-site"
+ },
+ "target": {
+ "itemType": "page",
+ "scope": "testScope",
+ "itemId": "test-page",
+ "properties": {
+ "pageInfo": {
+ "pageID": "test-page",
+ "nodeType": "jnt:page",
+ "pageName": "Test Page",
+ "pagePath": "/test-page",
+ "templateName": "test",
+ "destinationURL": "http://localhost:8080/test-page",
+ "destinationSearch": "",
+ "referringURL": "http://localhost:8080/",
+ "language": "en",
+ "categories": [],
+ "tags": [],
+ "sameDomainReferrer": false
+ },
+ "consentTypes": []
+ }
+ },
+ "flattenedProperties": {}
+ }
+ ]
+}
From 04954031fa58803ac81d32863000a4ceada8c615 Mon Sep 17 00:00:00 2001
From: Serge Huber
Date: Sat, 6 Jun 2026 10:36:51 +0200
Subject: [PATCH 4/6] UNOMI-904: Address Copilot review feedback on V2
compatibility mode
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Fix ordering-dependent data loss in V2ThirdPartyConfigService.modified()
by using a two-pass approach: collect raw properties first, then build
ProviderConfig objects (OSGi ConfigAdmin property order is undefined)
- Mask X-Unomi-Peer secret in debug logs (RestServiceUtilsImpl,
V2ThirdPartyConfigService) using a maskSecret() helper
- Add @Designate annotation to DefaultRestAuthenticationConfig so OSGi
metatype descriptors are generated correctly by bnd
- Trim and blank-check v2CompatibilityDefaultTenantId in
DefaultRestAuthenticationConfig, falling back to "default" if blank
- Replace null checks with StringUtils.isNotBlank() for defaultTenantId
in AuthenticationFilter.handleV2CompatibilityMode() (both public-path
and JAAS sections)
- Wrap CloseableHttpClient/CloseableHttpResponse pairs in try-with-resources
in V2CompatibilityModeIT to avoid connection pool leaks
- Fix misleading test comments: V3 API keys in V2 mode are ignored, not
rejected — requests return 200 with 0 processed events
---
.../unomi/itests/V2CompatibilityModeIT.java | 22 +++----
.../authentication/AuthenticationFilter.java | 6 +-
.../V2ThirdPartyConfigService.java | 63 +++++++++----------
.../impl/DefaultRestAuthenticationConfig.java | 16 ++++-
.../service/impl/RestServiceUtilsImpl.java | 9 ++-
5 files changed, 64 insertions(+), 52 deletions(-)
diff --git a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
index a41d959d9..b7931572b 100644
--- a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
@@ -227,14 +227,13 @@ private void testV3ModeBehavior() throws Exception {
.setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.BASIC))
.build();
- CloseableHttpClient adminClient = HttpClients.custom()
+ try (CloseableHttpClient adminClient = HttpClients.custom()
.setDefaultCredentialsProvider(credsProvider)
.setDefaultRequestConfig(requestConfig)
.build();
-
- CloseableHttpResponse jaasResponse = adminClient.execute(request);
- assertEquals("V3-style request with JAAS auth should work in V3 mode", 200, jaasResponse.getStatusLine().getStatusCode());
- adminClient.close();
+ CloseableHttpResponse jaasResponse = adminClient.execute(request)) {
+ assertEquals("V3-style request with JAAS auth should work in V3 mode", 200, jaasResponse.getStatusLine().getStatusCode());
+ }
}
/**
@@ -259,7 +258,7 @@ private void testV2ModeBehavior() throws Exception {
response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
assertEquals("V2-style request with X-Unomi-Peer should work in V2 compatibility mode", 200, response.getStatusCode());
- // Test V3-style request with public API key - should be rejected in V2 mode
+ // Test V3-style request with public API key - in V2 mode, V3 API keys are ignored (request succeeds but no events processed)
request = new HttpPost(getFullUrl(CONTEXT_URL));
request.addHeader(UNOMI_API_KEY_HEADER, testPublicKey.getKey());
request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
@@ -267,7 +266,7 @@ private void testV2ModeBehavior() throws Exception {
assertEquals("V3-style request with public API key should return 200 in V2 compatibility mode", 200, response.getStatusCode());
assertEquals("V3-style request with public API key should have 0 processed events in V2 mode", 0, response.getContextResponse().getProcessedEvents());
- // Test V3-style request with private API key - should be rejected in V2 mode
+ // Test V3-style request with private API key - in V2 mode, V3 API keys are ignored (request succeeds but no events processed)
request = new HttpPost(getFullUrl(CONTEXT_URL));
addPrivateTenantAuth(request, testTenant, testPrivateKey);
request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
@@ -286,14 +285,13 @@ private void testV2ModeBehavior() throws Exception {
.setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.BASIC))
.build();
- CloseableHttpClient adminClient = HttpClients.custom()
+ try (CloseableHttpClient adminClient = HttpClients.custom()
.setDefaultCredentialsProvider(credsProvider)
.setDefaultRequestConfig(requestConfig)
.build();
-
- CloseableHttpResponse jaasResponse = adminClient.execute(getRequest);
- assertEquals("Private endpoint with JAAS auth should work in V2 compatibility mode", 200, jaasResponse.getStatusLine().getStatusCode());
- adminClient.close();
+ CloseableHttpResponse jaasResponse = adminClient.execute(getRequest)) {
+ assertEquals("Private endpoint with JAAS auth should work in V2 compatibility mode", 200, jaasResponse.getStatusLine().getStatusCode());
+ }
}
@Test
diff --git a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
index bfc88f4b1..1fdeff342 100644
--- a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
+++ b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
@@ -41,6 +41,8 @@
import javax.ws.rs.container.PreMatching;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.Response;
+import org.apache.commons.lang3.StringUtils;
+
import java.io.IOException;
import java.util.Base64;
import java.util.Collections;
@@ -257,7 +259,7 @@ private void handleV2CompatibilityMode(ContainerRequestContext requestContext, S
// For public paths, allow access without authentication (like V2)
if (isPublicPath(requestContext)) {
String defaultTenantId = restAuthenticationConfig.getV2CompatibilityDefaultTenantId();
- if (defaultTenantId != null) {
+ if (StringUtils.isNotBlank(defaultTenantId)) {
// Create a guest subject for public endpoints
Subject subject = securityService.createSubject(defaultTenantId, false);
@@ -288,7 +290,7 @@ private void handleV2CompatibilityMode(ContainerRequestContext requestContext, S
// In V2 compatibility mode, use the default tenant for all operations
String defaultTenantId = restAuthenticationConfig.getV2CompatibilityDefaultTenantId();
- if (defaultTenantId != null) {
+ if (StringUtils.isNotBlank(defaultTenantId)) {
executionContextManager.setCurrentContext(executionContextManager.createContext(defaultTenantId));
} else {
executionContextManager.setCurrentContext(ExecutionContext.systemContext());
diff --git a/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java b/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java
index 954493f72..08d1cad8e 100644
--- a/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java
+++ b/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java
@@ -87,44 +87,36 @@ public void activate(Map properties) {
@Modified
public void modified(Map properties) {
Map newProviders = new HashMap<>();
-
+
if (properties != null) {
- // Parse all provider configurations dynamically
+ // Phase 1: collect raw property values per provider, order-independent
+ Map> rawProviders = new HashMap<>();
for (Map.Entry entry : properties.entrySet()) {
- String key = entry.getKey();
+ String propKey = entry.getKey();
String value = entry.getValue() != null ? entry.getValue().toString() : "";
-
+
// Look for provider configuration patterns: thirdparty.{providerName}.{property}
- if (key.startsWith("thirdparty.") && key.contains(".")) {
- String[] parts = key.split("\\.");
+ if (propKey.startsWith("thirdparty.") && propKey.contains(".")) {
+ String[] parts = propKey.split("\\.");
if (parts.length >= 3) {
String providerName = parts[1];
String property = parts[2];
-
- ProviderConfig existingConfig = newProviders.get(providerName);
- String configKey = existingConfig != null ? existingConfig.getKey() : "";
- Set configIpAddresses = existingConfig != null ? existingConfig.getIpAddresses() : new HashSet<>();
- Set configAllowedEvents = existingConfig != null ? existingConfig.getAllowedEvents() : new HashSet<>();
-
- switch (property) {
- case "key":
- configKey = value;
- break;
- case "ipAddresses":
- configIpAddresses = parseCommaSeparatedList(value);
- break;
- case "allowedEvents":
- configAllowedEvents = parseCommaSeparatedList(value);
- break;
- }
-
- // Only add provider if it has a key (required for authentication)
- if (StringUtils.isNotBlank(configKey)) {
- newProviders.put(providerName, new ProviderConfig(configKey, configIpAddresses, configAllowedEvents));
- }
+ rawProviders.computeIfAbsent(providerName, k -> new HashMap<>()).put(property, value);
}
}
}
+
+ // Phase 2: build ProviderConfig objects — only for providers that have a key
+ for (Map.Entry> entry : rawProviders.entrySet()) {
+ String providerName = entry.getKey();
+ Map props = entry.getValue();
+ String configKey = props.get("key");
+ if (StringUtils.isNotBlank(configKey)) {
+ Set configIpAddresses = parseCommaSeparatedList(props.getOrDefault("ipAddresses", ""));
+ Set configAllowedEvents = parseCommaSeparatedList(props.getOrDefault("allowedEvents", ""));
+ newProviders.put(providerName, new ProviderConfig(configKey, configIpAddresses, configAllowedEvents));
+ }
+ }
}
// Set default provider1 if no providers configured
@@ -201,18 +193,18 @@ public boolean validateProviderByKey(String providerKey, String eventType, Strin
}
if (config == null) {
- LOGGER.debug("V2 compatibility mode: Unknown provider key: {}", providerKey);
+ LOGGER.debug("V2 compatibility mode: Unknown provider key: {}", maskSecret(providerKey));
return false;
}
-
+
if (!config.getAllowedEvents().contains(eventType)) {
- LOGGER.debug("V2 compatibility mode: Event type {} not allowed for provider {} (key: {})", eventType, foundProviderId, providerKey);
+ LOGGER.debug("V2 compatibility mode: Event type {} not allowed for provider {} (key: {})", eventType, foundProviderId, maskSecret(providerKey));
return false;
}
-
+
boolean ipAuthorized = IPValidationUtils.isIpAuthorized(sourceIP, config.getIpAddresses());
if (!ipAuthorized) {
- LOGGER.debug("V2 compatibility mode: IP {} not authorized for provider {} (key: {})", sourceIP, foundProviderId, providerKey);
+ LOGGER.debug("V2 compatibility mode: IP {} not authorized for provider {} (key: {})", sourceIP, foundProviderId, maskSecret(providerKey));
}
return ipAuthorized;
@@ -239,6 +231,11 @@ public boolean isValidProvider(String providerId) {
return providers.containsKey(providerId);
}
+ private static String maskSecret(String secret) {
+ if (secret == null || secret.length() <= 4) return "****";
+ return secret.substring(0, 4) + "****";
+ }
+
private Set parseCommaSeparatedList(String value) {
if (StringUtils.isBlank(value)) {
return new HashSet<>();
diff --git a/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java b/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java
index ea6690140..801dce2bb 100644
--- a/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java
+++ b/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java
@@ -16,12 +16,14 @@
*/
package org.apache.unomi.rest.authentication.impl;
+import org.apache.commons.lang3.StringUtils;
import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.rest.authentication.RestAuthenticationConfig;
-import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Modified;
import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.Designate;
import org.osgi.service.metatype.annotations.ObjectClassDefinition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -33,6 +35,7 @@
* Default implementation for the unomi authentication on Rest endpoints
*/
@Component(service = { RestAuthenticationConfig.class}, configurationPid = "org.apache.unomi.rest.authentication", immediate = true)
+@Designate(ocd = DefaultRestAuthenticationConfig.Config.class)
public class DefaultRestAuthenticationConfig implements RestAuthenticationConfig {
private static final Logger LOGGER = LoggerFactory.getLogger(DefaultRestAuthenticationConfig.class);
@@ -83,10 +86,17 @@ public void modified(Config config) {
}
boolean v2Mode = config.v2_compatibilitymode_enabled();
String defaultTenant = config.v2_compatibilitymode_defaultTenantId();
+ if (defaultTenant != null) {
+ defaultTenant = defaultTenant.trim();
+ }
+ if (StringUtils.isBlank(defaultTenant)) {
+ LOGGER.warn("v2CompatibilityDefaultTenantId is blank, falling back to 'default'");
+ defaultTenant = "default";
+ }
LOGGER.info("Configuration updated - v2CompatibilityModeEnabled: {}, v2CompatibilityDefaultTenantId: {}",
v2Mode, defaultTenant);
- this.v2CompatibilityModeEnabled = v2Mode;
- this.v2CompatibilityDefaultTenantId = defaultTenant;
+ this.v2CompatibilityModeEnabled = v2Mode;
+ this.v2CompatibilityDefaultTenantId = defaultTenant;
}
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 f1dfb466b..7ee15162a 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
@@ -423,12 +423,17 @@ private boolean isEventAllowedInV2CompatibilityMode(Event event, HttpServletRequ
// Validate the third-party provider using the V2 configuration
if (!v2ThirdPartyConfigService.validateProviderByKey(thirdPartyKey, event.getEventType(), sourceIP)) {
LOGGER.debug("V2 compatibility mode: Protected event {} rejected - invalid third-party provider key: {} from IP: {}",
- event.getEventType(), thirdPartyKey, sourceIP);
+ event.getEventType(), maskSecret(thirdPartyKey), sourceIP);
return false;
}
LOGGER.debug("V2 compatibility mode: Protected event {} allowed for provider key: {} from IP: {}",
- event.getEventType(), thirdPartyKey, sourceIP);
+ event.getEventType(), maskSecret(thirdPartyKey), sourceIP);
return true;
}
+
+ private static String maskSecret(String secret) {
+ if (secret == null || secret.length() <= 4) return "****";
+ return secret.substring(0, 4) + "****";
+ }
}
From a84b3a0ac006dca8db39248abb9d6d06af45ccfb Mon Sep 17 00:00:00 2001
From: Serge Huber
Date: Sat, 6 Jun 2026 11:26:49 +0200
Subject: [PATCH 5/6] UNOMI-904: Extract maskSecret into shared SecurityUtils
to eliminate duplication
Move the maskSecret() helper from V2ThirdPartyConfigService and
RestServiceUtilsImpl into a new SecurityUtils class in services-common,
which is already the home for security utilities like IPValidationUtils.
Add unit tests covering null, empty, short, boundary, and typical
32-char shared-secret inputs.
---
.../V2ThirdPartyConfigService.java | 12 ++--
.../service/impl/RestServiceUtilsImpl.java | 9 +--
.../common/security/SecurityUtils.java | 42 +++++++++++++
.../common/security/SecurityUtilsTest.java | 63 +++++++++++++++++++
4 files changed, 112 insertions(+), 14 deletions(-)
create mode 100644 services-common/src/main/java/org/apache/unomi/services/common/security/SecurityUtils.java
create mode 100644 services-common/src/test/java/org/apache/unomi/services/common/security/SecurityUtilsTest.java
diff --git a/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java b/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java
index 08d1cad8e..b72f5c4dc 100644
--- a/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java
+++ b/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java
@@ -19,6 +19,7 @@
import org.apache.commons.lang3.StringUtils;
import org.apache.unomi.services.common.security.IPValidationUtils;
+import org.apache.unomi.services.common.security.SecurityUtils;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Modified;
@@ -193,18 +194,18 @@ public boolean validateProviderByKey(String providerKey, String eventType, Strin
}
if (config == null) {
- LOGGER.debug("V2 compatibility mode: Unknown provider key: {}", maskSecret(providerKey));
+ LOGGER.debug("V2 compatibility mode: Unknown provider key: {}", SecurityUtils.maskSecret(providerKey));
return false;
}
if (!config.getAllowedEvents().contains(eventType)) {
- LOGGER.debug("V2 compatibility mode: Event type {} not allowed for provider {} (key: {})", eventType, foundProviderId, maskSecret(providerKey));
+ LOGGER.debug("V2 compatibility mode: Event type {} not allowed for provider {} (key: {})", eventType, foundProviderId, SecurityUtils.maskSecret(providerKey));
return false;
}
boolean ipAuthorized = IPValidationUtils.isIpAuthorized(sourceIP, config.getIpAddresses());
if (!ipAuthorized) {
- LOGGER.debug("V2 compatibility mode: IP {} not authorized for provider {} (key: {})", sourceIP, foundProviderId, maskSecret(providerKey));
+ LOGGER.debug("V2 compatibility mode: IP {} not authorized for provider {} (key: {})", sourceIP, foundProviderId, SecurityUtils.maskSecret(providerKey));
}
return ipAuthorized;
@@ -231,11 +232,6 @@ public boolean isValidProvider(String providerId) {
return providers.containsKey(providerId);
}
- private static String maskSecret(String secret) {
- if (secret == null || secret.length() <= 4) return "****";
- return secret.substring(0, 4) + "****";
- }
-
private Set parseCommaSeparatedList(String value) {
if (StringUtils.isBlank(value)) {
return new HashSet<>();
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 7ee15162a..efe3159ef 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
@@ -31,6 +31,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.services.common.security.SecurityUtils;
import org.apache.unomi.rest.exception.InvalidRequestException;
import org.apache.unomi.rest.service.RestServiceUtils;
import org.apache.unomi.schema.api.SchemaService;
@@ -423,17 +424,13 @@ private boolean isEventAllowedInV2CompatibilityMode(Event event, HttpServletRequ
// Validate the third-party provider using the V2 configuration
if (!v2ThirdPartyConfigService.validateProviderByKey(thirdPartyKey, event.getEventType(), sourceIP)) {
LOGGER.debug("V2 compatibility mode: Protected event {} rejected - invalid third-party provider key: {} from IP: {}",
- event.getEventType(), maskSecret(thirdPartyKey), sourceIP);
+ event.getEventType(), SecurityUtils.maskSecret(thirdPartyKey), sourceIP);
return false;
}
LOGGER.debug("V2 compatibility mode: Protected event {} allowed for provider key: {} from IP: {}",
- event.getEventType(), maskSecret(thirdPartyKey), sourceIP);
+ event.getEventType(), SecurityUtils.maskSecret(thirdPartyKey), sourceIP);
return true;
}
- private static String maskSecret(String secret) {
- if (secret == null || secret.length() <= 4) return "****";
- return secret.substring(0, 4) + "****";
- }
}
diff --git a/services-common/src/main/java/org/apache/unomi/services/common/security/SecurityUtils.java b/services-common/src/main/java/org/apache/unomi/services/common/security/SecurityUtils.java
new file mode 100644
index 000000000..08175aa30
--- /dev/null
+++ b/services-common/src/main/java/org/apache/unomi/services/common/security/SecurityUtils.java
@@ -0,0 +1,42 @@
+/*
+ * 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.services.common.security;
+
+/**
+ * Utility class for security-related helpers, such as safe logging of sensitive values.
+ */
+public class SecurityUtils {
+
+ private static final String MASK_SUFFIX = "****";
+ private static final int VISIBLE_PREFIX_LENGTH = 4;
+
+ /**
+ * Masks a secret value for safe use in log statements.
+ * Shows the first {@value #VISIBLE_PREFIX_LENGTH} characters followed by {@code ****},
+ * or {@code ****} entirely if the secret is null or too short to reveal safely.
+ *
+ * @param secret the secret to mask (e.g. an API key or shared token)
+ * @return a masked representation safe for logging
+ */
+ public static String maskSecret(String secret) {
+ if (secret == null || secret.length() <= VISIBLE_PREFIX_LENGTH) {
+ return MASK_SUFFIX;
+ }
+ return secret.substring(0, VISIBLE_PREFIX_LENGTH) + MASK_SUFFIX;
+ }
+}
diff --git a/services-common/src/test/java/org/apache/unomi/services/common/security/SecurityUtilsTest.java b/services-common/src/test/java/org/apache/unomi/services/common/security/SecurityUtilsTest.java
new file mode 100644
index 000000000..a100fe684
--- /dev/null
+++ b/services-common/src/test/java/org/apache/unomi/services/common/security/SecurityUtilsTest.java
@@ -0,0 +1,63 @@
+/*
+ * 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.services.common.security;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class SecurityUtilsTest {
+
+ @Test
+ public void testNullSecret() {
+ assertEquals("****", SecurityUtils.maskSecret(null));
+ }
+
+ @Test
+ public void testEmptySecret() {
+ assertEquals("****", SecurityUtils.maskSecret(""));
+ }
+
+ @Test
+ public void testSecretShorterThanThreshold() {
+ assertEquals("****", SecurityUtils.maskSecret("abc"));
+ }
+
+ @Test
+ public void testSecretExactlyAtThreshold() {
+ // exactly 4 chars — still fully masked (not enough to reveal safely)
+ assertEquals("****", SecurityUtils.maskSecret("abcd"));
+ }
+
+ @Test
+ public void testSecretLongerThanThreshold() {
+ assertEquals("abcd****", SecurityUtils.maskSecret("abcdefgh"));
+ }
+
+ @Test
+ public void testRealWorldApiKey() {
+ // Typical 32-char shared secret (e.g. X-Unomi-Peer value)
+ assertEquals("670c****", SecurityUtils.maskSecret("670c26d1cc413346c3b2fd9ce65dab41"));
+ }
+
+ @Test
+ public void testFiveCharSecret() {
+ // One char beyond threshold — prefix visible, rest masked
+ assertEquals("abcd****", SecurityUtils.maskSecret("abcde"));
+ }
+}
From ad94a666f7477ad195f006e5e1917b1aa7315b3e Mon Sep 17 00:00:00 2001
From: Serge Huber
Date: Sun, 7 Jun 2026 09:34:37 +0200
Subject: [PATCH 6/6] UNOMI-904: Address review feedback on V2 compatibility
mode
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The main fix is that private endpoints with JAAS auth were always
returning 401 in V2 mode — the JAAS subject had no TenantPrincipal,
so resolveTenantId() couldn't find the tenant. We now build a merged
subject with a TenantPrincipal for the default tenant before handing
off to downstream processing. Two smaller filter bugs are fixed
alongside: a blank defaultTenantId on a public path would silently
fall through to the private-endpoint handler, and a null security
context after JAAS was ignored rather than denied. The default tenant
is now checked for existence before use, and V2ThirdPartyConfigService
logs a clear warning when falling back to the well-known default key.
On the test side, session and profile IDs are now per-test UUIDs,
three negative cases cover the main rejection branches (unknown key,
wrong IP, wrong event type), PatchIT polls for sendMailAction instead
of asserting immediately, and GraphQLListIT refreshes Profile
persistence before querying list membership.
---
.../java/org/apache/unomi/itests/PatchIT.java | 5 +-
.../unomi/itests/V2CompatibilityModeIT.java | 80 ++++++++++++++++++-
.../unomi/itests/graphql/GraphQLListIT.java | 1 +
.../authentication/AuthenticationFilter.java | 49 ++++++++----
.../V2ThirdPartyConfigService.java | 8 +-
5 files changed, 125 insertions(+), 18 deletions(-)
diff --git a/itests/src/test/java/org/apache/unomi/itests/PatchIT.java b/itests/src/test/java/org/apache/unomi/itests/PatchIT.java
index af43963fa..5a068ea4a 100644
--- a/itests/src/test/java/org/apache/unomi/itests/PatchIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/PatchIT.java
@@ -21,6 +21,7 @@
import org.apache.unomi.api.actions.ActionType;
import org.apache.unomi.api.conditions.ConditionType;
import org.apache.unomi.persistence.spi.CustomObjectMapper;
+import java.util.Objects;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -147,7 +148,9 @@ public void testPatchOnConditionType() throws IOException, InterruptedException
@Test
public void testPatchOnActionType() throws IOException, InterruptedException {
- ActionType mailAction = definitionsService.getActionType("sendMailAction");
+ ActionType mailAction = keepTrying("sendMailAction should exist",
+ () -> definitionsService.getActionType("sendMailAction"),
+ Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
Assert.assertNotNull("sendMailAction should exist", mailAction);
Assert.assertNotNull("ActionType metadata should not be null", mailAction.getMetadata());
Assert.assertNotNull("ActionType systemTags should not be null", mailAction.getMetadata().getSystemTags());
diff --git a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
index b7931572b..691514eff 100644
--- a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
@@ -34,6 +34,7 @@
import org.apache.unomi.api.tenants.Tenant;
import org.apache.unomi.itests.TestUtils.RequestResponse;
import org.apache.unomi.rest.authentication.RestAuthenticationConfig;
+import org.apache.unomi.rest.authentication.V2ThirdPartyConfigService;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
@@ -64,17 +65,21 @@ public class V2CompatibilityModeIT extends BaseIT {
private final static Logger LOGGER = LoggerFactory.getLogger(V2CompatibilityModeIT.class);
private final static String CONTEXT_URL = "/cxs/context.json";
private static final String TEST_SCOPE = "testScope";
- private final static String TEST_SESSION_ID = "v2-compat-test-session-" + System.currentTimeMillis();
- private final static String TEST_PROFILE_ID = "v2-compat-test-profile-" + System.currentTimeMillis();
+ private String TEST_SESSION_ID;
+ private String TEST_PROFILE_ID;
private final static String UNOMI_API_KEY_HEADER = "X-Unomi-Api-Key";
private final static String UNOMI_TENANT_ID_HEADER = "X-Unomi-Tenant-Id";
private final static String UNOMI_PEER_HEADER = "X-Unomi-Peer";
private boolean originalV2Mode;
private String originalDefaultTenantId;
+ private V2ThirdPartyConfigService v2ThirdPartyConfigService;
@Before
public void setUp() throws InterruptedException, IOException {
+ TEST_SESSION_ID = "v2-compat-test-session-" + UUID.randomUUID();
+ TEST_PROFILE_ID = "v2-compat-test-profile-" + UUID.randomUUID();
+ v2ThirdPartyConfigService = getService(V2ThirdPartyConfigService.class);
TestUtils.createScope(TEST_SCOPE, "Test scope", scopeService);
keepTrying("Scope "+ TEST_SCOPE +" not found in the required time", () -> scopeService.getScope(TEST_SCOPE),
@@ -420,6 +425,76 @@ public void testV2CompatibilityModeConfigurationPersistence() throws Exception {
assertEquals("V2-style request should still work after service update", 200, response.getStatusCode());
}
+ @Test
+ public void testV2CompatibilityProtectedEventNegativeCases() throws Exception {
+ LOGGER.info("Testing V2 compatibility mode - protected event negative cases");
+
+ updateConfiguration(null, "org.apache.unomi.rest.authentication", "v2.compatibilitymode.enabled", true);
+ keepTrying("V2 compatibility mode not enabled in the required time",
+ () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(),
+ enabled -> enabled, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ Event loginEvent = new Event();
+ loginEvent.setEventType("login");
+ loginEvent.setScope(TEST_SCOPE);
+ ContextRequest contextRequest = new ContextRequest();
+ contextRequest.setSessionId(TEST_SESSION_ID);
+ contextRequest.setEvents(Arrays.asList(loginEvent));
+ String requestBody = objectMapper.writeValueAsString(contextRequest);
+
+ // Case 1: protected event with an unknown provider key → rejected (0 processed events)
+ HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+ request.addHeader(UNOMI_PEER_HEADER, "unknownkey000000000000000000000000");
+ request.setEntity(new StringEntity(requestBody, ContentType.APPLICATION_JSON));
+ TestUtils.RequestResponse response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+ assertEquals("Protected event with unknown provider key should return 200", 200, response.getStatusCode());
+ assertEquals("Protected event with unknown provider key should have 0 processed events", 0, response.getContextResponse().getProcessedEvents());
+
+ try {
+ // Case 2: valid key but source IP not in provider's allowed list.
+ // Configure a provider whose IP allowlist only contains a non-loopback address so
+ // the test client (connecting from loopback) is rejected.
+ String testProviderKey = "testproviderip0000000000000000000";
+ Map wrongIpConfig = new HashMap<>();
+ wrongIpConfig.put("thirdparty.testprovider.key", testProviderKey);
+ wrongIpConfig.put("thirdparty.testprovider.ipAddresses", "10.0.0.1");
+ wrongIpConfig.put("thirdparty.testprovider.allowedEvents", "login,updateProperties");
+ updateConfiguration(null, "org.apache.unomi.thirdparty", wrongIpConfig);
+ keepTrying("Third-party wrong-IP config not applied",
+ () -> v2ThirdPartyConfigService.getProviderKey("testprovider"),
+ testProviderKey::equals, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ request = new HttpPost(getFullUrl(CONTEXT_URL));
+ request.addHeader(UNOMI_PEER_HEADER, testProviderKey);
+ request.setEntity(new StringEntity(requestBody, ContentType.APPLICATION_JSON));
+ response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+ assertEquals("Protected event with valid key but wrong source IP should return 200", 200, response.getStatusCode());
+ assertEquals("Protected event with valid key but wrong source IP should have 0 processed events", 0, response.getContextResponse().getProcessedEvents());
+
+ // Case 3: valid key but event type (login) not in provider's allowedEvents.
+ // Configure a provider that only allows updateProperties, not login.
+ String limitedKey = "testproviderlimited000000000000000";
+ Map limitedEventsConfig = new HashMap<>();
+ limitedEventsConfig.put("thirdparty.limitedprovider.key", limitedKey);
+ limitedEventsConfig.put("thirdparty.limitedprovider.ipAddresses", "127.0.0.1,::1");
+ limitedEventsConfig.put("thirdparty.limitedprovider.allowedEvents", "updateProperties");
+ updateConfiguration(null, "org.apache.unomi.thirdparty", limitedEventsConfig);
+ keepTrying("Third-party limited-events config not applied",
+ () -> v2ThirdPartyConfigService.isValidProvider("limitedprovider"),
+ valid -> valid, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ request = new HttpPost(getFullUrl(CONTEXT_URL));
+ request.addHeader(UNOMI_PEER_HEADER, limitedKey);
+ request.setEntity(new StringEntity(requestBody, ContentType.APPLICATION_JSON));
+ response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+ assertEquals("Protected login event with key that only allows updateProperties should return 200", 200, response.getStatusCode());
+ assertEquals("Protected login event with key that only allows updateProperties should have 0 processed events", 0, response.getContextResponse().getProcessedEvents());
+ } finally {
+ // Restore thirdparty config to defaults by deleting the test entries
+ configurationAdmin.getConfiguration("org.apache.unomi.thirdparty", null).delete();
+ }
+ }
+
private static void addPrivateTenantAuth(HttpPost request, Tenant tenant, ApiKey privateKey) {
request.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString(
(tenant.getItemId() + ":" + privateKey.getKey()).getBytes()));
@@ -429,5 +504,6 @@ private static void addPrivateTenantAuth(HttpPost request, Tenant tenant, ApiKey
public void updateServices() throws InterruptedException {
super.updateServices();
restAuthenticationConfig = getService(RestAuthenticationConfig.class);
+ v2ThirdPartyConfigService = getService(V2ThirdPartyConfigService.class);
}
}
diff --git a/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLListIT.java b/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLListIT.java
index 66553e742..319cb6a0c 100644
--- a/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLListIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLListIT.java
@@ -76,6 +76,7 @@ public void testCRUD() throws Exception {
}
refreshPersistence(UserList.class);
+ refreshPersistence(Profile.class);
final ResponseContext findListsContext = keepTrying("Failed waiting for profile in list query",
() -> {
diff --git a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
index 1fdeff342..df37dbf55 100644
--- a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
+++ b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
@@ -25,6 +25,7 @@
import org.apache.karaf.jaas.boot.principal.UserPrincipal;
import org.apache.unomi.api.ExecutionContext;
import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.security.TenantPrincipal;
import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.api.services.ExecutionContextManager;
import org.apache.unomi.api.tenants.ApiKey;
@@ -260,6 +261,12 @@ private void handleV2CompatibilityMode(ContainerRequestContext requestContext, S
if (isPublicPath(requestContext)) {
String defaultTenantId = restAuthenticationConfig.getV2CompatibilityDefaultTenantId();
if (StringUtils.isNotBlank(defaultTenantId)) {
+ Tenant defaultTenant = tenantService.getTenant(defaultTenantId);
+ if (defaultTenant == null) {
+ logger.error("V2 compatibility mode: configured default tenant '{}' does not exist", defaultTenantId);
+ unauthorized(requestContext);
+ return;
+ }
// Create a guest subject for public endpoints
Subject subject = securityService.createSubject(defaultTenantId, false);
@@ -273,6 +280,10 @@ private void handleV2CompatibilityMode(ContainerRequestContext requestContext, S
// Set the execution context for the default tenant
executionContextManager.setCurrentContext(executionContextManager.createContext(defaultTenantId));
return;
+ } else {
+ logger.warn("V2 compatibility mode: public path request denied because v2CompatibilityDefaultTenantId is not configured");
+ unauthorized(requestContext);
+ return;
}
}
@@ -281,24 +292,34 @@ private void handleV2CompatibilityMode(ContainerRequestContext requestContext, S
if (authHeader != null && authHeader.startsWith(BASIC_AUTH_PREFIX)) {
try {
jaasAuthenticationFilter.filter(requestContext);
- // Get the subject from the security context after successful JAAS auth
+ // JAASAuthenticationFilter handles credential failures internally (calls abortWith + returns normally).
+ // A null security context here means auth was rejected or an unexpected state occurred — deny either way.
SecurityContext securityContext = JAXRSUtils.getCurrentMessage().get(SecurityContext.class);
- if (securityContext != null) {
- Subject subject = ((RolePrefixSecurityContextImpl) securityContext).getSubject();
- // Set the authenticated subject in Unomi's security service
- securityService.setCurrentSubject(subject);
-
- // In V2 compatibility mode, use the default tenant for all operations
- String defaultTenantId = restAuthenticationConfig.getV2CompatibilityDefaultTenantId();
- if (StringUtils.isNotBlank(defaultTenantId)) {
- executionContextManager.setCurrentContext(executionContextManager.createContext(defaultTenantId));
- } else {
- executionContextManager.setCurrentContext(ExecutionContext.systemContext());
- }
+ if (securityContext == null) {
+ logger.debug("V2 compatibility mode: no security context after JAAS filter, denying access");
+ unauthorized(requestContext);
+ return;
+ }
+ Subject jaasSubject = ((RolePrefixSecurityContextImpl) securityContext).getSubject();
+
+ // Build a merged subject that combines the JAAS principals with a TenantPrincipal
+ // for the default tenant, so that resolveTenantId() can find it downstream.
+ String defaultTenantId = restAuthenticationConfig.getV2CompatibilityDefaultTenantId();
+ Subject mergedSubject = new Subject();
+ mergedSubject.getPrincipals().addAll(jaasSubject.getPrincipals());
+ if (StringUtils.isNotBlank(defaultTenantId)) {
+ mergedSubject.getPrincipals().add(new TenantPrincipal(defaultTenantId));
+ executionContextManager.setCurrentContext(executionContextManager.createContext(defaultTenantId));
+ } else {
+ executionContextManager.setCurrentContext(ExecutionContext.systemContext());
}
+ JAXRSUtils.getCurrentMessage().put(SecurityContext.class,
+ new RolePrefixSecurityContextImpl(mergedSubject, ROLE_CLASSIFIER, ROLE_CLASSIFIER_TYPE));
+ securityService.setCurrentSubject(mergedSubject);
return;
} catch (Exception e) {
- logger.debug("V2 compatibility mode: JAAS authentication failed");
+ // Only fires for unexpected exceptions — credential failures are handled inside JAASAuthenticationFilter.
+ logger.debug("V2 compatibility mode: unexpected exception during JAAS processing", e);
}
} else {
logger.debug("V2 compatibility mode: Missing Basic Auth header for private endpoint");
diff --git a/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java b/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java
index b72f5c4dc..836cf8ef2 100644
--- a/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java
+++ b/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java
@@ -120,8 +120,14 @@ public void modified(Map properties) {
}
}
- // Set default provider1 if no providers configured
if (newProviders.isEmpty()) {
+ // The fallback key below is the well-known Unomi V2 default key, publicly documented
+ // in Apache Unomi changelogs and issue trackers. It provides no confidentiality on its own
+ // and is restricted to localhost only as a partial mitigation.
+ // Configure org.apache.unomi.thirdparty.cfg with a custom key before production use.
+ LOGGER.warn("V2 compatibility mode: no third-party providers configured in org.apache.unomi.thirdparty.cfg — " +
+ "falling back to the well-known default key restricted to localhost. " +
+ "Configure a custom provider key before using V2 compatibility mode in production.");
newProviders.put("provider1", new ProviderConfig(
"670c26d1cc413346c3b2fd9ce65dab41",
new HashSet<>(Arrays.asList("127.0.0.1", "::1")),