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"));
+    }
+}

Reply via email to