This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new b0fd9cea1 UNOMI-904: V2 API compatibility mode (#762)
b0fd9cea1 is described below
commit b0fd9cea181c86541e0de7f20b828ab90b578f25
Author: Serge Huber <[email protected]>
AuthorDate: Mon Jun 8 09:43:13 2026 +0200
UNOMI-904: V2 API compatibility mode (#762)
---
.../router/core/context/RouterCamelContext.java | 8 -
.../test/java/org/apache/unomi/itests/AllITs.java | 1 +
.../test/java/org/apache/unomi/itests/PatchIT.java | 5 +-
.../apache/unomi/itests/V2CompatibilityModeIT.java | 509 +++++++++++++++++++++
.../apache/unomi/itests/graphql/GraphQLListIT.java | 1 +
itests/src/test/resources/events/viewEvent.json | 37 ++
kar/src/main/feature/feature.xml | 1 +
.../main/resources/etc/custom.system.properties | 6 +
rest/pom.xml | 24 +
.../rest/authentication/AuthenticationFilter.java | 90 ++++
.../authentication/RestAuthenticationConfig.java | 19 +
.../authentication/V2ThirdPartyConfigService.java | 258 +++++++++++
.../impl/DefaultRestAuthenticationConfig.java | 68 ++-
.../rest/service/impl/RestServiceUtilsImpl.java | 67 ++-
.../org.apache.unomi.rest.authentication.cfg | 31 ++
.../services/common/security/SecurityUtils.java | 42 ++
.../common/security/SecurityUtilsTest.java | 63 +++
17 files changed, 1215 insertions(+), 15 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 @@ import java.util.concurrent.TimeUnit;
* </ul>
* </p>
*
- * <p>Dependency-injection setters on this class are intended for
OSGi/Blueprint wiring and are not part of the
- * {@link IRouterCamelContext} API surface.</p>
- *
* @since 1.0
*/
public class RouterCamelContext implements IRouterCamelContext {
@@ -144,11 +141,6 @@ public class RouterCamelContext implements
IRouterCamelContext {
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 @@ import org.junit.runners.Suite.SuiteClasses;
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.PropertyType;
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 class PatchIT extends BaseIT {
@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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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 class GraphQLListIT extends BaseGraphQLIT {
}
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 @@
<feature>unomi-services</feature>
<feature>unomi-cxs-privacy-extension-services</feature>
<configfile
finalname="/etc/org.apache.unomi.schema.cfg">mvn:org.apache.unomi/unomi-json-schema-services/${project.version}/cfg/schemacfg</configfile>
+ <configfile
finalname="/etc/org.apache.unomi.rest.authentication.cfg">mvn:org.apache.unomi/unomi-rest/${project.version}/cfg/restauth</configfile>
<bundle
start="false">mvn:org.apache.unomi/unomi-json-schema-services/${project.version}</bundle>
<bundle
start="false">mvn:org.apache.unomi/unomi-rest/${project.version}</bundle>
<bundle
start="false">mvn:org.apache.unomi/unomi-json-schema-rest/${project.version}</bundle>
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 @@
<build>
<plugins>
+ <plugin>
+ <groupId>org.codehaus.mojo</groupId>
+ <artifactId>build-helper-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>attach-artifacts</id>
+ <phase>package</phase>
+ <goals>
+ <goal>attach-artifact</goal>
+ </goals>
+ <configuration>
+ <artifacts>
+ <artifact>
+ <file>
+
src/main/resources/org.apache.unomi.rest.authentication.cfg
+ </file>
+ <type>cfg</type>
+ <classifier>restauth</classifier>
+ </artifact>
+ </artifacts>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
<plugin>
<groupId>org.apache.felix</groupId>
<artifactId>maven-bundle-plugin</artifactId>
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.RolePrincipal;
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.ContainerRequestFilter;
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 class AuthenticationFilter implements
ContainerRequestFilter {
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 class AuthenticationFilter implements
ContainerRequestFilter {
}
}
+ /**
+ * 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<String> ipAddresses;
+ private final Set<String> allowedEvents;
+
+ public ProviderConfig(String key, Set<String> ipAddresses, Set<String>
allowedEvents) {
+ this.key = key;
+ this.ipAddresses = ipAddresses;
+ this.allowedEvents = allowedEvents;
+ }
+
+ public String getKey() { return key; }
+ public Set<String> getIpAddresses() { return ipAddresses; }
+ public Set<String> getAllowedEvents() { return allowedEvents; }
+ }
+
+ private volatile Map<String, ProviderConfig> providers = new HashMap<>();
+
+ @Activate
+ public void activate(Map<String, Object> properties) {
+ modified(properties);
+ }
+
+ @Modified
+ public void modified(Map<String, Object> properties) {
+ Map<String, ProviderConfig> newProviders = new HashMap<>();
+
+ if (properties != null) {
+ // Phase 1: collect raw property values per provider,
order-independent
+ Map<String, Map<String, String>> rawProviders = new HashMap<>();
+ for (Map.Entry<String, Object> 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<String, Map<String, String>> entry :
rawProviders.entrySet()) {
+ String providerName = entry.getKey();
+ Map<String, String> props = entry.getValue();
+ String configKey = props.get("key");
+ if (StringUtils.isNotBlank(configKey)) {
+ Set<String> configIpAddresses =
parseCommaSeparatedList(props.getOrDefault("ipAddresses", ""));
+ Set<String> 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<String> getAllProtectedEventTypes() {
+ Set<String> 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<String, ProviderConfig> 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<String> parseCommaSeparatedList(String value) {
+ if (StringUtils.isBlank(value)) {
+ return new HashSet<>();
+ }
+
+ Set<String> 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 @@ import java.util.regex.Pattern;
/**
* 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<Pattern> getPublicPathPatterns() {
return PUBLIC_PATH_PATTERNS;
@@ -77,4 +114,33 @@ public class DefaultRestAuthenticationConfig implements
RestAuthenticationConfig
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.PrivacyService;
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 class RestServiceUtilsImpl implements
RestServiceUtils {
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 @@ public class RestServiceUtilsImpl implements
RestServiceUtils {
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"));
+ }
+}