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());
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/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
new file mode 100644
index 000000000..691514eff
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
@@ -0,0 +1,509 @@
+/*
+ * 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.apache.unomi.rest.authentication.V2ThirdPartyConfigService;
+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 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),
+ 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();
+
+ 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());
+ }
+ }
+
+ /**
+ * 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 - 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));
+ 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 - 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));
+ 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();
+
+ 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());
+ }
+ }
+
+ @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());
+ }
+
+ @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()));
+ }
+
+ @Override
+ 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/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": {}
+ }
+ ]
+}
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..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;
@@ -41,6 +42,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;
@@ -107,6 +110,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 +248,87 @@ 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 (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);
+
+ // 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;
+ } else {
+ logger.warn("V2 compatibility mode: public path request denied because v2CompatibilityDefaultTenantId is not configured");
+ unauthorized(requestContext);
+ 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);
+ // 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) {
+ 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) {
+ // 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");
+ }
+
+ // 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..836cf8ef2
--- /dev/null
+++ b/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java
@@ -0,0 +1,258 @@
+/*
+ * 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.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;
+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) {
+ // Phase 1: collect raw property values per provider, order-independent
+ Map> rawProviders = new HashMap<>();
+ for (Map.Entry entry : properties.entrySet()) {
+ String propKey = entry.getKey();
+ String value = entry.getValue() != null ? entry.getValue().toString() : "";
+
+ // Look for provider configuration patterns: thirdparty.{providerName}.{property}
+ if (propKey.startsWith("thirdparty.") && propKey.contains(".")) {
+ String[] parts = propKey.split("\\.");
+ if (parts.length >= 3) {
+ String providerName = parts[1];
+ String property = parts[2];
+ 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));
+ }
+ }
+ }
+
+ 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")),
+ 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: {}", SecurityUtils.maskSecret(providerKey));
+ return false;
+ }
+
+ if (!config.getAllowedEvents().contains(eventType)) {
+ 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, SecurityUtils.maskSecret(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..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,9 +16,17 @@
*/
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.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.*;
import java.util.regex.Pattern;
@@ -26,9 +34,11 @@
/**
* 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)
+@Designate(ocd = DefaultRestAuthenticationConfig.Config.class)
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 +73,33 @@ 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();
+ 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;
+ }
+
+
@Override
public List getPublicPathPatterns() {
return PUBLIC_PATH_PATTERNS;
@@ -77,4 +114,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..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
@@ -29,6 +29,9 @@
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.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;
@@ -80,6 +83,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 +278,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 +396,41 @@ 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(), SecurityUtils.maskSecret(thirdPartyKey), sourceIP);
+ return false;
+ }
+
+ LOGGER.debug("V2 compatibility mode: Protected event {} allowed for provider key: {} from IP: {}",
+ event.getEventType(), SecurityUtils.maskSecret(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}
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"));
+ }
+}