This is an automated email from the ASF dual-hosted git repository.

shuber pushed a commit to branch unomi-3-dev
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/unomi-3-dev by this push:
     new 1e1386983 UNOMI-904 Implement V2 Compatibility Mode for Unomi REST API
1e1386983 is described below

commit 1e1386983a9215fd41ce94f5d214c72a32b76148
Author: Serge Huber <[email protected]>
AuthorDate: Mon Sep 1 09:58:38 2025 +0200

    UNOMI-904 Implement V2 Compatibility Mode for Unomi REST API
    
    - Added support for V2 compatibility mode, allowing V2 clients to interact 
with Unomi V3 without requiring API keys.
    - Updated the AuthenticationFilter to handle authentication differently 
based on the compatibility mode, enabling public endpoints to be accessed 
without authentication.
    - Introduced configuration options for enabling V2 compatibility mode and 
specifying a default tenant ID.
    - Added integration tests to validate both the V2 and V3 modes are working 
properly.
    - Enhanced event authorization checks to accommodate V2-style protected 
events using third-party provider keys.
    - Created a new utility class for IP address validation to streamline 
authorization processes.
    - Added integration tests to validate the functionality of the new 
compatibility mode and ensure proper behavior across various scenarios.
---
 itests/pom.xml                                     |   6 +
 .../test/java/org/apache/unomi/itests/AllITs.java  |   1 +
 .../test/java/org/apache/unomi/itests/BaseIT.java  |  27 +-
 .../org/apache/unomi/itests/ProfileServiceIT.java  |   4 +-
 .../org/apache/unomi/itests/RuleServiceIT.java     |   4 +-
 .../apache/unomi/itests/V2CompatibilityModeIT.java | 435 +++++++++++++++++++++
 itests/src/test/resources/events/viewEvent.json    |  37 ++
 kar/src/main/feature/feature.xml                   |   2 +
 .../asciidoc/migrations/migrate-2.0-to-3.0.adoc    | 319 +++++++++++++++
 .../src/main/asciidoc/migrations/migrations.adoc   |   4 +
 .../asciidoc/migrations/v2-compatibility-mode.adoc | 351 +++++++++++++++++
 .../main/resources/etc/custom.system.properties    |   6 +
 .../META-INF/cxs/mappings/clusterNode.json         |  67 ++++
 .../META-INF/cxs/mappings/clusterNode.json         |  67 ++++
 rest/pom.xml                                       |  36 ++
 .../rest/authentication/AuthenticationFilter.java  |  67 ++++
 .../authentication/RestAuthenticationConfig.java   |  19 +
 .../authentication/V2ThirdPartyConfigService.java  | 259 ++++++++++++
 .../impl/DefaultRestAuthenticationConfig.java      |  61 ++-
 .../rest/service/impl/RestServiceUtilsImpl.java    |  64 ++-
 .../org.apache.unomi.rest.authentication.cfg       |  31 ++
 services-common/pom.xml                            |  18 +-
 .../common/security/IPValidationUtils.java         |  83 ++++
 .../common/security/IPValidationUtilsTest.java     | 144 +++++++
 .../services/impl/events/EventServiceImpl.java     |  35 +-
 25 files changed, 2095 insertions(+), 52 deletions(-)

diff --git a/itests/pom.xml b/itests/pom.xml
index 99b19f3be..a1c3d1ffc 100644
--- a/itests/pom.xml
+++ b/itests/pom.xml
@@ -163,6 +163,12 @@
             <version>${project.version}</version>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.apache.unomi</groupId>
+            <artifactId>unomi-api</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <build>
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 e39873a3d..cf0637b57 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -66,6 +66,7 @@ import org.junit.runners.Suite.SuiteClasses;
         SendEventActionIT.class,
         ScopeIT.class,
         HealthCheckIT.class,
+        V2CompatibilityModeIT.class
 })
 public class AllITs {
 }
diff --git a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java 
b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
index f42ecc7a0..c7205d5cf 100644
--- a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
@@ -98,6 +98,7 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.security.KeyManagementException;
+import java.util.Hashtable;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
 import java.security.cert.X509Certificate;
@@ -651,9 +652,10 @@ public abstract class BaseIT extends KarafTestSupport {
     }
 
     /**
-     * Updates an OSGi configuration with a single property value and waits 
for the service to be reregistered.
+     * Updates an OSGi configuration with a single property value and 
optionally waits for the service to be reregistered.
+     * If serviceName is null, the method will not wait for service 
re-registration.
      *
-     * @param serviceName The fully qualified name of the service to wait for
+     * @param serviceName The fully qualified name of the service to wait for, 
or null to skip waiting
      * @param configPid   The persistent identifier of the configuration to 
update
      * @param propName    The name of the property to update
      * @param propValue   The new value for the property
@@ -668,10 +670,10 @@ public abstract class BaseIT extends KarafTestSupport {
     }
 
     /**
-     * Updates an OSGi configuration with multiple property values and waits 
for the service to be reregistered.
-     * For persistence configurations, this method handles updates without 
causing bundle restarts.
+     * Updates an OSGi configuration with multiple property values and 
optionally waits for the service to be reregistered.
+     * If serviceName is null, the method will not wait for service 
re-registration.
      *
-     * @param serviceName The fully qualified name of the service to wait for
+     * @param serviceName The fully qualified name of the service to wait for, 
or null to skip waiting
      * @param configPid   The persistent identifier of the configuration to 
update
      * @param propsToSet  A map of property names to their new values
      * @throws InterruptedException If the thread is interrupted while waiting 
for service reregistration
@@ -681,20 +683,25 @@ public abstract class BaseIT extends KarafTestSupport {
             throws InterruptedException, IOException {
         org.osgi.service.cm.Configuration cfg = 
configurationAdmin.getConfiguration(configPid);
         Dictionary<String, Object> props = cfg.getProperties();
+        
+        // Handle case where properties haven't been initialized yet
+        final Dictionary<String, Object> finalProps = (props != null) ? props 
: new Hashtable<>();
+        
+        // Add new properties to the dictionary
         for (Map.Entry<String, Object> propToSet : propsToSet.entrySet()) {
-            props.put(propToSet.getKey(), propToSet.getValue());
+            finalProps.put(propToSet.getKey(), propToSet.getValue());
         }
 
-        // For configurations that now handle changes without restarting, 
don't wait for service re-registration
-        if (configPid.contains("persistence") || 
configPid.contains("org.apache.unomi.services")) {
+        // If serviceName is null, don't wait for service re-registration
+        if (serviceName == null) {
             LOGGER.info("Updating configuration {} without waiting for service 
restart", configPid);
-            cfg.update(props);
+            cfg.update(finalProps);
             // Give the configuration change handler time to process
             Thread.sleep(1000);
         } else {
             waitForReRegistration(serviceName, () -> {
                 try {
-                    cfg.update(props);
+                    cfg.update(finalProps);
                 } catch (IOException ignored) {
                 }
             });
diff --git a/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java 
b/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java
index 3313a753d..55eebd57b 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java
@@ -168,7 +168,7 @@ public class ProfileServiceIT extends BaseIT {
             }
         }
 
-        updateConfiguration(PersistenceService.class.getName(), 
"org.apache.unomi.persistence." + searchEngine, "throwExceptions", true);
+        updateConfiguration(null, "org.apache.unomi.persistence." + 
searchEngine, "throwExceptions", true);
 
         Query query = new Query();
         query.setLimit(2);
@@ -181,7 +181,7 @@ public class ProfileServiceIT extends BaseIT {
         } catch (RuntimeException ex) {
             // Should get here since this scenario should throw exception
         } finally {
-            updateConfiguration(PersistenceService.class.getName(), 
"org.apache.unomi.persistence." + searchEngine, "throwExceptions",
+            updateConfiguration(null, "org.apache.unomi.persistence." + 
searchEngine, "throwExceptions",
                     throwExceptionCurrent);
         }
     }
diff --git a/itests/src/test/java/org/apache/unomi/itests/RuleServiceIT.java 
b/itests/src/test/java/org/apache/unomi/itests/RuleServiceIT.java
index df6d8a2ad..714a2afc4 100644
--- a/itests/src/test/java/org/apache/unomi/itests/RuleServiceIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/RuleServiceIT.java
@@ -184,14 +184,14 @@ public class RuleServiceIT extends BaseIT {
         Profile profile = new Profile(UUID.randomUUID().toString());
         Session session = new Session(UUID.randomUUID().toString(), profile, 
new Date(), TEST_SCOPE);
 
-        updateConfiguration(RulesService.class.getName(), 
"org.apache.unomi.services", "rules.optimizationActivated", "false");
+        updateConfiguration(null, "org.apache.unomi.services", 
"rules.optimizationActivated", "false");
         rulesService = getService(RulesService.class);
         eventService = getService(EventService.class);
 
         LOGGER.info("Running unoptimized rules performance test...");
         long unoptimizedRunTime = runEventTest(profile, session);
 
-        updateConfiguration(RulesService.class.getName(), 
"org.apache.unomi.services", "rules.optimizationActivated", "true");
+        updateConfiguration(null, "org.apache.unomi.services", 
"rules.optimizationActivated", "true");
         rulesService = getService(RulesService.class);
         eventService = getService(EventService.class);
 
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java 
b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
new file mode 100644
index 000000000..c987fefc8
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
@@ -0,0 +1,435 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.unomi.itests;
+
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.config.AuthSchemes;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.BasicCredentialsProvider;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.unomi.api.*;
+import org.apache.unomi.api.tenants.ApiKey;
+import org.apache.unomi.api.tenants.Tenant;
+import org.apache.unomi.itests.TestUtils.RequestResponse;
+import org.apache.unomi.rest.authentication.RestAuthenticationConfig;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
+import org.ops4j.pax.exam.spi.reactors.PerSuite;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.*;
+import java.util.Base64;
+import java.util.Objects;
+
+import static org.junit.Assert.*;
+
+/**
+ * Integration tests for V2 compatibility mode authentication.
+ * Tests the behavior when switching between V2 and V3 authentication modes
+ * using OSGi configuration admin without restarting bundles.
+ */
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class V2CompatibilityModeIT extends BaseIT {
+
+    private final static Logger LOGGER = 
LoggerFactory.getLogger(V2CompatibilityModeIT.class);
+    private final static String CONTEXT_URL = "/cxs/context.json";
+    private static final String TEST_SCOPE = "testScope";
+    private final static String TEST_SESSION_ID = "v2-compat-test-session-" + 
System.currentTimeMillis();
+    private final static String TEST_PROFILE_ID = "v2-compat-test-profile-" + 
System.currentTimeMillis();
+    private final static String UNOMI_API_KEY_HEADER = "X-Unomi-Api-Key";
+    private final static String UNOMI_TENANT_ID_HEADER = "X-Unomi-Tenant-Id";
+    private final static String UNOMI_PEER_HEADER = "X-Unomi-Peer";
+
+    private boolean originalV2Mode;
+    private String originalDefaultTenantId;
+
+    @Before
+    public void setUp() throws InterruptedException, IOException {
+
+        TestUtils.createScope(TEST_SCOPE, "Test scope", scopeService);
+        keepTrying("Scope "+ TEST_SCOPE +" not found in the required time", () 
-> scopeService.getScope(TEST_SCOPE),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        // Store original V2 mode setting and default tenant ID
+        originalV2Mode = 
restAuthenticationConfig.isV2CompatibilityModeEnabled();
+        originalDefaultTenantId = 
restAuthenticationConfig.getV2CompatibilityDefaultTenantId();
+
+        // Configure V2 compatibility mode to use the BaseIT test tenant as 
default
+        Map<String, Object> v2Config = new HashMap<>();
+        v2Config.put("v2CompatibilityModeEnabled", false); // Start in V3 mode
+        v2Config.put("v2CompatibilityDefaultTenantId", 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("v2CompatibilityModeEnabled", originalV2Mode);
+            if (originalDefaultTenantId != null) {
+                originalConfig.put("v2CompatibilityDefaultTenantId", 
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",
+                "v2CompatibilityModeEnabled",
+                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",
+                "v2CompatibilityModeEnabled",
+                false);
+
+        // Wait for configuration to take effect
+        keepTrying("V2 compatibility mode not disabled in the required time",
+                () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(),
+                enabled -> !enabled, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        // STEP 5: Test V3 mode again - V2 requests should be rejected, V3 
requests should work
+        LOGGER.info("STEP 5: Testing V3 mode again");
+        testV3ModeBehavior();
+
+        LOGGER.info("V2 compatibility mode switch test completed 
successfully");
+    }
+
+    /**
+     * Test behavior in V3 mode (default):
+     * - V2 requests (no auth) should be rejected
+     * - V3 requests with proper authentication should work
+     */
+    private void testV3ModeBehavior() throws Exception {
+        // Test V2-style request (no authentication) - should be rejected
+        ContextRequest contextRequest = new ContextRequest();
+        contextRequest.setSessionId(TEST_SESSION_ID);
+
+        HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        TestUtils.RequestResponse response = 
TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID, 401, false);
+        assertEquals("V2-style request should be rejected in V3 mode", 401, 
response.getStatusCode());
+
+        // Test V3-style request with public API key - should work
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.addHeader(UNOMI_API_KEY_HEADER, testPublicKey.getKey());
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        response = TestUtils.executeContextJSONRequest(request, 
TEST_SESSION_ID);
+        assertEquals("V3-style request with public API key should work in V3 
mode", 200, response.getStatusCode());
+
+        // Test V3-style request with private API key - should work
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(request, testTenant, testPrivateKey);
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        response = TestUtils.executeContextJSONRequest(request, 
TEST_SESSION_ID);
+        assertEquals("V3-style request with private API key should work in V3 
mode", 200, response.getStatusCode());
+
+        // Test V3-style request with JAAS authentication - should work
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.addHeader(UNOMI_TENANT_ID_HEADER, testTenant.getItemId());
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+
+        BasicCredentialsProvider credsProvider = new 
BasicCredentialsProvider();
+        credsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials("karaf", "karaf"));
+
+        RequestConfig requestConfig = RequestConfig.custom()
+                .setAuthenticationEnabled(true)
+                
.setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.BASIC))
+                .build();
+
+        CloseableHttpClient adminClient = HttpClients.custom()
+                .setDefaultCredentialsProvider(credsProvider)
+                .setDefaultRequestConfig(requestConfig)
+                .build();
+
+        CloseableHttpResponse jaasResponse = adminClient.execute(request);
+        assertEquals("V3-style request with JAAS auth should work in V3 mode", 
200, jaasResponse.getStatusLine().getStatusCode());
+        adminClient.close();
+    }
+
+    /**
+     * Test behavior in V2 compatibility mode:
+     * - V2 requests (no auth for public endpoints) should work
+     * - V3 requests should be rejected
+     */
+    private void testV2ModeBehavior() throws Exception {
+        // Test V2-style request (no authentication for public endpoint) - 
should work
+        ContextRequest contextRequest = new ContextRequest();
+        contextRequest.setSessionId(TEST_SESSION_ID);
+
+        HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        TestUtils.RequestResponse response = 
TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+        assertEquals("V2-style request should work in V2 compatibility mode", 
200, response.getStatusCode());
+
+        // Test V2-style request with X-Unomi-Peer header (V2 third-party 
auth) - should work
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.addHeader(UNOMI_PEER_HEADER, 
"670c26d1cc413346c3b2fd9ce65dab41");
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        response = TestUtils.executeContextJSONRequest(request, 
TEST_SESSION_ID);
+        assertEquals("V2-style request with X-Unomi-Peer should work in V2 
compatibility mode", 200, response.getStatusCode());
+
+        // Test V3-style request with public API key - should be rejected in 
V2 mode
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.addHeader(UNOMI_API_KEY_HEADER, testPublicKey.getKey());
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        response = TestUtils.executeContextJSONRequest(request, 
TEST_SESSION_ID);
+        assertEquals("V3-style request with public API key should return 200 
in V2 compatibility mode", 200, response.getStatusCode());
+        assertEquals("V3-style request with public API key should have 0 
processed events in V2 mode", 0, 
response.getContextResponse().getProcessedEvents());
+
+        // Test V3-style request with private API key - should be rejected in 
V2 mode
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(request, testTenant, testPrivateKey);
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        response = TestUtils.executeContextJSONRequest(request, 
TEST_SESSION_ID);
+        assertEquals("V3-style request with private API key should return 200 
in V2 compatibility mode", 200, response.getStatusCode());
+        assertEquals("V3-style request with private API key should have 0 
processed events in V2 mode", 0, 
response.getContextResponse().getProcessedEvents());
+
+        // Test private endpoint with JAAS authentication - should work (like 
V2)
+        HttpGet getRequest = new HttpGet(getFullUrl("/cxs/profiles/" + 
TEST_PROFILE_ID));
+
+        BasicCredentialsProvider credsProvider = new 
BasicCredentialsProvider();
+        credsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials("karaf", "karaf"));
+
+        RequestConfig requestConfig = RequestConfig.custom()
+                .setAuthenticationEnabled(true)
+                
.setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.BASIC))
+                .build();
+
+        CloseableHttpClient adminClient = HttpClients.custom()
+                .setDefaultCredentialsProvider(credsProvider)
+                .setDefaultRequestConfig(requestConfig)
+                .build();
+
+        CloseableHttpResponse jaasResponse = adminClient.execute(getRequest);
+        assertEquals("Private endpoint with JAAS auth should work in V2 
compatibility mode", 200, jaasResponse.getStatusLine().getStatusCode());
+        adminClient.close();
+    }
+
+    @Test
+    public void testV2CompatibilityModeWithProtectedEvents() throws Exception {
+        LOGGER.info("Testing V2 compatibility mode with protected events");
+
+        // Switch to V2 compatibility mode
+        updateConfiguration(null,
+                "org.apache.unomi.rest.authentication",
+                "v2CompatibilityModeEnabled",
+                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",
+                "v2CompatibilityModeEnabled",
+                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",
+                "v2CompatibilityModeEnabled",
+                true);
+
+        keepTrying("V2 compatibility mode not enabled in the required time",
+                () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(),
+                enabled -> enabled, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        // Verify configuration is applied
+        assertTrue("V2 compatibility mode should be enabled", 
restAuthenticationConfig.isV2CompatibilityModeEnabled());
+        assertEquals("Default tenant should persist", TEST_TENANT_ID, 
restAuthenticationConfig.getV2CompatibilityDefaultTenantId());
+
+        // Update services to simulate service restart
+        updateServices();
+
+        // Verify configuration persists
+        assertTrue("V2 compatibility mode should persist after service 
update", restAuthenticationConfig.isV2CompatibilityModeEnabled());
+        assertEquals("Default tenant should persist after service update", 
TEST_TENANT_ID, restAuthenticationConfig.getV2CompatibilityDefaultTenantId());
+
+        // Test that behavior is still correct
+        ContextRequest contextRequest = new ContextRequest();
+        contextRequest.setSessionId(TEST_SESSION_ID);
+
+        HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        TestUtils.RequestResponse response = 
TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+        assertEquals("V2-style request should still work after service 
update", 200, response.getStatusCode());
+    }
+
+    private static void addPrivateTenantAuth(HttpPost request, Tenant tenant, 
ApiKey privateKey) {
+        request.setHeader("Authorization", "Basic " + 
Base64.getEncoder().encodeToString(
+            (tenant.getItemId() + ":" + privateKey.getKey()).getBytes()));
+    }
+
+    @Override
+    public void updateServices() throws InterruptedException {
+        super.updateServices();
+        restAuthenticationConfig = getService(RestAuthenticationConfig.class);
+    }
+}
diff --git a/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 64de1110c..441c29f86 100644
--- a/kar/src/main/feature/feature.xml
+++ b/kar/src/main/feature/feature.xml
@@ -81,6 +81,7 @@
 
     <feature name="unomi-persistence-elasticsearch" description="ElasticSearch 
persistence implementation for Apache Unomi" version="${project.version}">
         <configfile 
finalname="/etc/org.apache.unomi.persistence.elasticsearch.cfg">mvn:org.apache.unomi/unomi-persistence-elasticsearch-core/${project.version}/cfg/elasticsearchcfg</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-services-common/${project.version}</bundle>
         <bundle 
start="false">mvn:org.apache.unomi/unomi-persistence-elasticsearch-core/${project.version}</bundle>
         <bundle 
start="false">mvn:org.apache.unomi/unomi-persistence-elasticsearch-conditions/${project.version}</bundle>
@@ -103,6 +104,7 @@
 
     <feature name="unomi-persistence-opensearch" description="OpenSearch 
persistence implementation for Apache Unomi" version="${project.version}">
         <configfile 
finalname="/etc/org.apache.unomi.persistence.opensearch.cfg">mvn:org.apache.unomi/unomi-persistence-opensearch-core/${project.version}/cfg/opensearchcfg</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-services-common/${project.version}</bundle>
         <bundle 
start="false">mvn:org.apache.unomi/unomi-persistence-opensearch-core/${project.version}</bundle>
         <bundle 
start="false">mvn:org.apache.unomi/unomi-persistence-opensearch-conditions/${project.version}</bundle>
diff --git a/manual/src/main/asciidoc/migrations/migrate-2.0-to-3.0.adoc 
b/manual/src/main/asciidoc/migrations/migrate-2.0-to-3.0.adoc
new file mode 100644
index 000000000..4d05057e1
--- /dev/null
+++ b/manual/src/main/asciidoc/migrations/migrate-2.0-to-3.0.adoc
@@ -0,0 +1,319 @@
+//
+// Licensed 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.
+//
+
+=== Migration Overview
+
+Apache Unomi 3.0 introduces comprehensive multi-tenancy support, enabling 
complete data isolation between different tenants. This fundamental 
architectural change requires a new tenant-based authentication model while 
keeping all API endpoints unchanged.
+
+The key innovation in V3 is the introduction of **tenant isolation** for all 
data:
+- **Profiles, events, segments, rules, and schemas** are now tenant-specific
+- **Complete data separation** between tenants - no cross-tenant data access
+- **Tenant-specific API keys** for secure access control
+- **Backward compatibility** with system administrator access for management 
operations
+
+=== Updating applications consuming Unomi
+
+==== Authentication Model Changes
+
+The main change in V3 is the introduction of tenant-based authentication. The 
system must now identify which tenant context to operate in for every request.
+
+[cols="1,1,1", options="header"]
+|===
+|Aspect |Unomi V2 |Unomi V3
+
+|Authentication Method
+|System Administrator Authentication (karaf/karaf)
+|Tenant-based API Keys + System Administrator Authentication
+
+|Public API Endpoints
+|No authentication required
+|Public API Key required (via X-Unomi-Api-Key header)
+
+|Private API Endpoints
+|System Administrator Authentication
+|Tenant Authentication (tenantId/privateKey) OR System Administrator 
Authentication
+
+|Tenant Administration
+|System Administrator Authentication (karaf/karaf)
+|System Administrator Authentication (karaf/karaf)
+|===
+
+==== API Key Types (V3 Only)
+
+V3 introduces two types of API keys per tenant:
+
+- **Public Key**: Used for public endpoints (event collection via 
`/context.json`)
+- **Private Key**: Used with tenantId for tenant-specific administrative 
operations
+
+==== Authentication Requirements by Endpoint Type
+
+[cols="1,1,1", options="header"]
+|===
+|Endpoint Category |V2 Authentication |V3 Authentication
+
+|Event Collection (`/context.json`)
+|None
+|Public API Key only
+
+|Administrative Operations
+|System Admin (karaf/karaf)
+|Tenant Auth (tenantId/privateKey) OR System Admin (karaf/karaf)
+
+|Tenant Administration (`/cxs/tenants`)
+|System Admin (karaf/karaf)
+|System Admin (karaf/karaf)
+|===
+
+==== Authentication Flow (V3)
+
+The AuthenticationFilter in V3 follows this resolution order:
+
+1. **Tenant endpoints** (`/cxs/tenants`): Requires system administrator 
authentication only
+2. **Public endpoints** (e.g., `/context.json`): Requires public API key via 
`X-Unomi-Api-Key` header
+3. **Private endpoints**: Tries tenant authentication first, then falls back 
to system administrator authentication:
+   - **Tenant Authentication**: Basic Auth with `tenantId:privateKey`
+   - **System Administrator Authentication**: Basic Auth with `karaf:karaf` 
(or configured admin credentials)
+
+==== Code Examples
+
+===== V2 Authentication
+
+[source,java]
+----
+// Global system administrator authentication for all endpoints
+RestAssured.authentication = RestAssured.preemptive()
+    .basic("karaf", "karaf");
+
+// Context requests require no authentication
+RestAssured.given()
+    .auth().none()
+    .contentType(ContentType.JSON)
+    .body(contextJson)
+    .post("/context.json");
+----
+
+===== V3 Authentication
+
+[source,java]
+----
+// For public endpoints (event collection)
+given()
+    .header("X-Unomi-Api-Key", publicKey)
+    .contentType(ContentType.JSON)
+    .body(contextJson)
+    .post("/context.json");
+
+// For private endpoints using tenant authentication
+given()
+    .auth().preemptive().basic(tenantId, privateKey)
+    .contentType(ContentType.JSON)
+    .body(payload)
+    .post("/cxs/profiles");
+
+// For private endpoints using system administrator authentication
+given()
+    .auth().preemptive().basic("karaf", "karaf")
+    .contentType(ContentType.JSON)
+    .body(payload)
+    .post("/cxs/profiles");
+
+// For tenant administration (system admin only)
+given()
+    .auth().preemptive().basic("karaf", "karaf")
+    .contentType(ContentType.JSON)
+    .body(tenantPayload)
+    .post("/cxs/tenants");
+----
+
+==== Implementation Strategy
+
+===== Client Factory Pattern
+
+[source,java]
+----
+public class UnomiConfiguration {
+    public UnomiClient createClient(String baseUrl) {
+        String version = System.getProperty("unomi.version", "3");
+        
+        if ("3".equals(version)) {
+            return new UnomiV3Client(baseUrl);
+        } else {
+            return new UnomiV2Client(baseUrl);
+        }
+    }
+}
+----
+
+===== Version-Specific Authentication
+
+[source,java]
+----
+// V2 Client
+public void init() {
+    RestAssured.baseURI = baseUrl;
+    RestAssured.authentication = RestAssured.preemptive()
+        .basic("karaf", "karaf");
+}
+
+// V3 Client
+public void init() {
+    RestAssured.baseURI = baseUrl;
+}
+
+public void updateKeys(String publicKey, String privateKey) {
+    this.publicKey = publicKey;
+    this.privateKey = privateKey;
+}
+----
+
+==== No API Contract Changes
+
+All API endpoints remain the same between V2 and V3. The only differences are 
in the authentication mechanism and tenant resolution. Request/response 
payloads are unchanged.
+
+=== Migrating your existing data
+
+==== Multi-Tenancy Impact
+
+When migrating to V3, you need to understand that:
+
+- All data (profiles, events, segments, rules, schemas) becomes tenant-specific
+- Each tenant operates in complete isolation with their own data space
+- Tenant context must be established for every API operation
+
+==== Migration Steps
+
+1. **Understand Multi-Tenancy Impact**
+   - All data (profiles, events, segments, rules, schemas) becomes 
tenant-specific
+   - Each tenant operates in complete isolation with their own data space
+   - Tenant context must be established for every API operation
+
+2. **Update Authentication Configuration**
+   - Remove global system administrator authentication
+   - Configure tenant-specific public and private API keys
+   - Implement endpoint-specific authentication logic
+
+3. **Endpoint-Specific Changes**
+   - Add `X-Unomi-Api-Key` header with public key for event collection
+   - Use tenant authentication (tenantId/privateKey) for tenant-specific 
administrative operations
+   - Keep system administrator authentication as fallback for administrative 
operations
+   - Continue using system administrator authentication for tenant 
administration
+
+4. **No API Contract Changes**
+   - All endpoints remain the same
+   - Request/response payloads are unchanged
+   - Only authentication mechanism differs
+
+==== Benefits of Multi-Tenancy in V3
+
+- **Data Isolation**: Complete separation ensures tenant data never crosses 
boundaries
+- **Scalability**: Support for multiple customers/organizations in a single 
Unomi instance
+- **Security**: Tenant-specific API keys prevent unauthorized cross-tenant 
access
+- **Compliance**: Easier to meet data privacy regulations with clear tenant 
boundaries
+- **Cost Efficiency**: Shared infrastructure with isolated data reduces 
operational costs
+
+=== Migration Checklist
+
+Before starting the migration, please ensure that:
+
+- You do have a backup of your data
+- You did practice the migration in a staging environment, NEVER migrate a 
production environment without prior validation
+- You verified your applications were operational with Apache Unomi 3.0 
(authentication updated, client applications updated, ...)
+- You are currently running Apache Unomi 2.0 (or a later 2.x version)
+- You understand the multi-tenancy impact on your data model
+- You have configured tenant-specific API keys for your applications
+
+=== Migration Process
+
+The migration from V2 to V3 is primarily a configuration and authentication 
update:
+
+1. **Shutdown your Apache Unomi 2.0 cluster**
+2. **Update your client applications** to use the new authentication model
+3. **Configure tenant-specific API keys** for your applications
+4. **Start your Apache Unomi 3.0 cluster**
+5. **Test your applications** with the new authentication model
+
+=== V2 Compatibility Mode
+
+To facilitate the migration process, Unomi V3 includes a **V2 compatibility 
mode** that allows V2 client applications to work with Unomi V3 without 
immediate code changes.
+
+==== Enabling V2 Compatibility Mode
+
+To enable V2 compatibility mode, set the following system property when 
starting Unomi V3:
+
+[source,bash]
+----
+# Enable V2 compatibility mode
+-Dunomi.v2.compatibility.mode=true
+----
+
+==== V2 Compatibility Mode Behavior
+
+When V2 compatibility mode is enabled:
+
+- **Public endpoints** (e.g., `/context.json`): No authentication required 
(same as V2)
+- **Private endpoints**: JAAS authentication required (same as V2)
+- **All API endpoints**: Identical behavior to V2
+- **Data isolation**: Still enforced through tenant context
+
+==== Migration Strategy with V2 Compatibility Mode
+
+1. **Phase 1: Enable V2 Compatibility Mode**
+   - Start Unomi V3 with `-Dunomi.v2.compatibility.mode=true`
+   - Verify all V2 client applications work without changes
+   - Migrate data to tenant structure
+
+2. **Phase 2: Gradual Migration**
+   - Update client applications one by one to use V3 authentication
+   - Test each application with V3 authentication
+   - Keep V2 compatibility mode enabled for remaining applications
+
+3. **Phase 3: Complete Migration**
+   - Update all client applications to V3 authentication
+   - Disable V2 compatibility mode: `-Dunomi.v2.compatibility.mode=false`
+   - Verify all applications work with full V3 multi-tenancy
+
+==== Security Considerations
+
+- **V2 compatibility mode** should only be used during migration
+- **Production environments** should use full V3 authentication for security
+- **V2 compatibility mode** bypasses tenant API key requirements
+- **Data isolation** is still enforced through tenant context
+
+==== Example: Starting Unomi V3 with V2 Compatibility Mode
+
+[source,bash]
+----
+# Start Unomi V3 with V2 compatibility mode
+./karaf -Dunomi.v2.compatibility.mode=true
+
+# Or set as environment variable
+export KARAF_OPTS="-Dunomi.v2.compatibility.mode=true"
+./karaf
+----
+
+=== Post Migration
+
+Once the migration has been completed, you will be able to start Apache Unomi 
3.0 with full multi-tenancy support.
+
+Remember that all data operations now require proper tenant context, either 
through tenant authentication or system administrator authentication.
+
+The fundamental difference between Unomi V2 and V3 is the introduction of 
**comprehensive multi-tenancy support**:
+
+- **V2**: Single-tenant architecture with system administrator authentication 
for all operations
+- **V3**: Multi-tenant architecture with complete data isolation and 
tenant-specific authentication
+- **API Endpoints**: Identical between versions - no breaking changes to 
existing integrations
+- **Data Model**: All entities (profiles, events, segments, rules, schemas) 
become tenant-specific in V3
+- **Authentication**: New tenant-based authentication model with system 
administrator authentication as fallback
+
+The authentication changes in V3 are driven by the need to establish tenant 
context for every operation, ensuring complete data isolation while maintaining 
backward compatibility for administrative operations.
diff --git a/manual/src/main/asciidoc/migrations/migrations.adoc 
b/manual/src/main/asciidoc/migrations/migrations.adoc
index e2142dc1d..ca2f4813f 100644
--- a/manual/src/main/asciidoc/migrations/migrations.adoc
+++ b/manual/src/main/asciidoc/migrations/migrations.adoc
@@ -18,6 +18,10 @@ This section contains information and steps to migrate 
between major Unomi versi
 
 include::v2-v3-compatibility.adoc[]
 
+=== From version 2.0 to 3.0
+
+include::migrate-2.0-to-3.0.adoc[]
+
 === From version 1.6 to 2.0
 
 include::migrate-1.6-to-2.0.adoc[]
diff --git a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc 
b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
new file mode 100644
index 000000000..d63af8189
--- /dev/null
+++ b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
@@ -0,0 +1,351 @@
+//
+// Licensed 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.
+//
+
+= Apache Unomi V2 Compatibility Mode
+
+This document explains how to use the V2 compatibility mode in Apache Unomi 
V3, which allows V2 client applications to work with Unomi V3 without requiring 
API keys.
+
+== Overview
+
+The V2 compatibility mode is designed to ease the migration from Unomi V2 to 
V3 by allowing V2 clients to continue working without immediate changes to 
their authentication logic. This mode provides backward compatibility while 
still leveraging the multi-tenant architecture of V3.
+
+=== How It Works
+
+When V2 compatibility mode is enabled:
+
+- **Public endpoints** (like `/context.json`) require no authentication (like 
V2)
+- **Protected events** (like `login`, `updateProperties`) require IP + 
X-Unomi-Peer (like V2)
+- **Private endpoints** require system administrator authentication (like V2)
+- **A default tenant** is automatically used for all operations
+- **No authentication** is required for non-protected events (like V2)
+
+This allows V2 clients to work with Unomi V3 immediately after migration, 
giving you time to gradually update client applications to use the new V3 
authentication model.
+
+== Prerequisites
+
+Before enabling V2 compatibility mode, ensure that:
+
+1. **Data Migration Completed**: Your V2 data has been migrated to a tenant 
using the migration scripts
+2. **Default Tenant Exists**: A default tenant exists that will be used for 
all operations
+3. **V3 Installation**: Unomi V3 is properly installed and configured
+
+== Configuration
+
+=== Enable V2 Compatibility Mode
+
+1. **Edit the configuration file**:
+   ```bash
+   # Edit the configuration file
+   vi etc/org.apache.unomi.rest.authentication.cfg
+   ```
+
+2. **Enable V2 compatibility mode**:
+   ```properties
+   # Enable V2 compatibility mode
+   v2CompatibilityModeEnabled = true
+   
+   # Set the default tenant ID (should match the tenant ID used during 
migration)
+   v2CompatibilityDefaultTenantId = your-migration-tenant-id
+   ```
+
+3. **Restart the server** to apply the configuration changes:
+   ```bash
+   # Stop the server
+   ./bin/stop
+   
+   # Start the server
+   ./bin/start
+   ```
+
+=== Configuration Management
+
+V2 compatibility mode is managed through configuration files only. This 
approach is safer and prevents accidental changes to authentication settings.
+
+== Migration Workflow
+
+=== Step 1: Migrate Data
+
+First, migrate your V2 data to V3 using the migration scripts:
+
+```bash
+# Run the migration scripts
+unomi:migrate-3.0.0-00-tenantDocumentIds
+unomi:migrate-3.0.0-10-tenantInitialization
+```
+
+The `migrate-3.0.0-10-tenantInitialization` script creates a default tenant 
that will be used for V2 compatibility mode.
+
+=== Step 2: Enable V2 Compatibility Mode
+
+Enable V2 compatibility mode by updating the configuration file:
+
+```bash
+# Edit the configuration file
+vi etc/org.apache.unomi.rest.authentication.cfg
+
+# Set v2CompatibilityModeEnabled = true
+# Set v2CompatibilityDefaultTenantId = your-tenant-id
+
+# Restart the server to apply changes
+./bin/stop
+./bin/start
+```
+
+=== Step 3: Test V2 Clients
+
+Your V2 clients should now work without any changes:
+
+```java
+// V2-style authentication still works
+RestAssured.authentication = RestAssured.preemptive()
+    .basic("karaf", "karaf");
+
+// Context requests work without API keys
+RestAssured.given()
+    .auth().none()
+    .contentType(ContentType.JSON)
+    .body(contextJson)
+    .post("/context.json");
+```
+
+=== Step 4: Gradual Migration
+
+Over time, gradually update your clients to use V3 authentication:
+
+1. **Update client applications** to use API keys
+2. **Test with V3 authentication** while keeping V2 compatibility mode enabled
+3. **Disable V2 compatibility mode** once all clients are updated
+
+== Client Migration Examples
+
+=== From V2 to V3 (with V2 Compatibility Mode)
+
+**V2 Client (continues to work)**:
+```java
+// This continues to work in V2 compatibility mode
+RestAssured.authentication = RestAssured.preemptive()
+    .basic("karaf", "karaf");
+
+RestAssured.given()
+    .auth().none()
+    .contentType(ContentType.JSON)
+    .body(contextJson)
+    .post("/context.json");
+```
+
+**V3 Client (new implementation)**:
+```java
+// New V3 client using API keys
+given()
+    .header("X-Unomi-Api-Key", publicKey)
+    .contentType(ContentType.JSON)
+    .body(contextJson)
+    .post("/context.json");
+```
+
+=== Gradual Migration Strategy
+
+1. **Phase 1**: Enable V2 compatibility mode, V2 clients continue working
+2. **Phase 2**: Develop and test V3 clients alongside V2 clients
+3. **Phase 3**: Migrate clients one by one to V3 authentication
+4. **Phase 4**: Disable V2 compatibility mode once all clients are migrated
+
+== Security Considerations
+
+=== V2 Compatibility Mode Security
+
+When V2 compatibility mode is enabled:
+
+- **Public endpoints** are accessible without authentication (same as V2)
+- **Protected events** require IP + X-Unomi-Peer authentication (same as V2)
+- **Private endpoints** require system administrator authentication (same as 
V2)
+- **All operations** use the default tenant context
+- **Non-protected events** require no authentication (same as V2)
+
+=== Protected Events in V2 Compatibility Mode
+
+In V2 compatibility mode, protected event types are configured dynamically 
using the V2 third-party configuration file. By default, the following event 
types are protected:
+
+- `login` - User authentication events
+- `updateProperties` - Profile property updates
+
+Additional event types can be configured as protected by editing the V2 
third-party configuration file.
+
+For protected events, clients must:
+1. Send the request from an authorized IP address (configured in the V2 
third-party configuration)
+2. Include the `X-Unomi-Peer` header with the third-party ID (e.g., 
"provider1")
+
+All other event types are considered non-protected and require no 
authentication.
+
+=== V2 Third-Party Configuration
+
+The protected events and third-party providers are configured in the original 
V2 configuration file `etc/org.apache.unomi.thirdparty.cfg`. The system 
dynamically detects any number of providers using the pattern 
`thirdparty.{providerName}.{property}`:
+
+```properties
+# Provider 1 Configuration (default provider)
+thirdparty.provider1.key=${org.apache.unomi.thirdparty.provider1.key:-670c26d1cc413346c3b2fd9ce65dab41}
+thirdparty.provider1.ipAddresses=${org.apache.unomi.thirdparty.provider1.ipAddresses:-127.0.0.1,::1}
+thirdparty.provider1.allowedEvents=${org.apache.unomi.thirdparty.provider1.allowedEvents:-login,updateProperties}
+
+# Additional providers can be added dynamically
+thirdparty.myapp.key=${org.apache.unomi.thirdparty.myapp.key:-my-secret-key}
+thirdparty.myapp.ipAddresses=${org.apache.unomi.thirdparty.myapp.ipAddresses:-192.168.1.0/24}
+thirdparty.myapp.allowedEvents=${org.apache.unomi.thirdparty.myapp.allowedEvents:-login,updateProperties,sessionCreated}
+```
+
+This uses the exact same configuration format as V2, ensuring complete 
compatibility with existing V2 setups. The system automatically detects and 
configures any provider that has a valid key.
+
+=== Configuration Management
+
+The V2 third-party configuration supports dynamic updates:
+
+1. **Edit the configuration file**:
+   ```bash
+   # Edit the V2 third-party configuration
+   vi etc/org.apache.unomi.thirdparty.cfg
+   ```
+
+2. **Update protected events**:
+   ```properties
+   # Add more protected event types
+   
thirdparty.provider1.allowedEvents=${org.apache.unomi.thirdparty.provider1.allowedEvents:-login,updateProperties,sessionCreated,profileUpdated}
+   ```
+
+3. **Add additional providers**:
+   ```properties
+   # Configure additional providers (any name is supported)
+   
thirdparty.myapp.key=${org.apache.unomi.thirdparty.myapp.key:-your-secret-key-here}
+   
thirdparty.myapp.ipAddresses=${org.apache.unomi.thirdparty.myapp.ipAddresses:-192.168.1.0/24,10.0.0.1}
+   
thirdparty.myapp.allowedEvents=${org.apache.unomi.thirdparty.myapp.allowedEvents:-login,updateProperties}
+   
+   
thirdparty.analytics.key=${org.apache.unomi.thirdparty.analytics.key:-analytics-secret}
+   
thirdparty.analytics.ipAddresses=${org.apache.unomi.thirdparty.analytics.ipAddresses:-10.0.0.0/8}
+   
thirdparty.analytics.allowedEvents=${org.apache.unomi.thirdparty.analytics.allowedEvents:-login,updateProperties,sessionCreated}
+   ```
+
+4. **Restart the server** to apply changes:
+   ```bash
+   ./bin/stop
+   ./bin/start
+   ```
+
+=== Recommendations
+
+1. **Use V2 compatibility mode temporarily** during migration
+2. **Plan for gradual migration** to V3 authentication
+3. **Monitor access patterns** during the transition
+4. **Disable V2 compatibility mode** once migration is complete
+
+== Troubleshooting
+
+=== Common Issues
+
+**V2 clients still not working**:
+- Check configuration file: `etc/org.apache.unomi.rest.authentication.cfg`
+- Verify `v2CompatibilityModeEnabled = true`
+- Ensure `v2CompatibilityDefaultTenantId` matches the tenant ID used during 
migration
+- Ensure the tenant exists and is accessible
+
+**Authentication errors**:
+- Verify system administrator credentials (karaf/karaf)
+- Check that the server is running properly
+- Review logs for authentication errors
+
+**Tenant context issues**:
+- Ensure the default tenant ID matches your migrated tenant
+- Verify tenant exists in the tenant index
+- Check tenant configuration in the migration scripts
+
+=== Debugging
+
+Enable debug logging for authentication:
+
+```bash
+# Enable debug logging
+log:set DEBUG org.apache.unomi.rest.authentication
+```
+
+Check authentication filter logs:
+
+```bash
+# View recent logs
+log:display | grep AuthenticationFilter
+```
+
+== Disabling V2 Compatibility Mode
+
+Once all clients are migrated to V3 authentication:
+
+1. **Update configuration**:
+   ```properties
+   v2CompatibilityModeEnabled = false
+   ```
+
+2. **Restart the server**:
+   ```bash
+   ./bin/stop
+   ./bin/start
+   ```
+
+3. **Verify all clients work** with V3 authentication
+
+4. **Monitor for any issues** and address them before final deployment
+
+== Testing V2 Compatibility Mode
+
+The existing test framework supports testing V2 compatibility mode using 
system properties.
+
+=== Running Tests in V2 Compatibility Mode
+
+To run tests with V2 compatibility mode enabled:
+
+```bash
+# Enable V2 compatibility mode for tests
+mvn test -Dunomi.v2.compatibility.mode=true
+
+# Or set the property in your test environment
+export UNOMI_V2_COMPATIBILITY_MODE=true
+mvn test
+```
+
+=== Test Framework Integration
+
+The test framework automatically detects V2 compatibility mode and uses the 
appropriate client:
+
+- **V2 Compatibility Mode Enabled**: Uses `UnomiV2Client` for all tests
+- **V2 Compatibility Mode Disabled**: Uses normal V2/V3 detection logic
+
+This allows you to test both V2 compatibility mode and normal V3 mode using 
the same test suite.
+
+=== Example Test Execution
+
+```bash
+# Test with V2 compatibility mode (server should be configured for V2 
compatibility)
+mvn test -Dunomi.v2.compatibility.mode=true -Dunomi.url=http://localhost:8181
+
+# Test with normal V3 mode
+mvn test -Dunomi.url=http://localhost:8181
+```
+
+== Conclusion
+
+The V2 compatibility mode provides a smooth migration path from Unomi V2 to 
V3, allowing you to:
+
+- **Maintain existing V2 clients** during migration
+- **Gradually migrate** to V3 authentication
+- **Leverage V3 features** while maintaining backward compatibility
+- **Minimize downtime** during the migration process
+- **Test both modes** using the existing test framework
+
+Use this mode as a temporary solution during your migration journey, and plan 
to disable it once all clients are updated to use V3 authentication.
diff --git a/package/src/main/resources/etc/custom.system.properties 
b/package/src/main/resources/etc/custom.system.properties
index 2c66b3c6d..cee6c8f79 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -546,3 +546,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}
\ No newline at end of file
diff --git 
a/persistence-elasticsearch/core/src/main/resources/META-INF/cxs/mappings/clusterNode.json
 
b/persistence-elasticsearch/core/src/main/resources/META-INF/cxs/mappings/clusterNode.json
new file mode 100644
index 000000000..e3cd2f76d
--- /dev/null
+++ 
b/persistence-elasticsearch/core/src/main/resources/META-INF/cxs/mappings/clusterNode.json
@@ -0,0 +1,67 @@
+{
+  "dynamic_templates": [
+    {
+      "all": {
+        "match": "*",
+        "match_mapping_type": "string",
+        "mapping": {
+          "type": "text",
+          "analyzer": "folding",
+          "fields": {
+            "keyword": {
+              "type": "keyword",
+              "ignore_above": 256
+            }
+          }
+        }
+      }
+    }
+  ],
+  "properties": {
+    "creationDate": {
+      "type": "date"
+    },
+    "lastModificationDate": {
+      "type": "date"
+    },
+    "lastSyncDate": {
+      "type": "date"
+    },
+    "cpuLoad": {
+      "type": "double"
+    },
+    "loadAverage": {
+      "type": "double"
+    },
+    "uptime": {
+      "type": "long"
+    },
+    "master": {
+      "type": "boolean"
+    },
+    "data": {
+      "type": "boolean"
+    },
+    "startTime": {
+      "type": "long"
+    },
+    "lastHeartbeat": {
+      "type": "long"
+    },
+    "serverInfo": {
+      "properties": {
+        "serverBuildDate": {
+          "type": "date"
+        },
+        "eventTypes": {
+          "type": "object",
+          "enabled": false
+        },
+        "capabilities": {
+          "type": "object",
+          "enabled": false
+        }
+      }
+    }
+  }
+}
diff --git 
a/persistence-opensearch/core/src/main/resources/META-INF/cxs/mappings/clusterNode.json
 
b/persistence-opensearch/core/src/main/resources/META-INF/cxs/mappings/clusterNode.json
new file mode 100644
index 000000000..e3cd2f76d
--- /dev/null
+++ 
b/persistence-opensearch/core/src/main/resources/META-INF/cxs/mappings/clusterNode.json
@@ -0,0 +1,67 @@
+{
+  "dynamic_templates": [
+    {
+      "all": {
+        "match": "*",
+        "match_mapping_type": "string",
+        "mapping": {
+          "type": "text",
+          "analyzer": "folding",
+          "fields": {
+            "keyword": {
+              "type": "keyword",
+              "ignore_above": 256
+            }
+          }
+        }
+      }
+    }
+  ],
+  "properties": {
+    "creationDate": {
+      "type": "date"
+    },
+    "lastModificationDate": {
+      "type": "date"
+    },
+    "lastSyncDate": {
+      "type": "date"
+    },
+    "cpuLoad": {
+      "type": "double"
+    },
+    "loadAverage": {
+      "type": "double"
+    },
+    "uptime": {
+      "type": "long"
+    },
+    "master": {
+      "type": "boolean"
+    },
+    "data": {
+      "type": "boolean"
+    },
+    "startTime": {
+      "type": "long"
+    },
+    "lastHeartbeat": {
+      "type": "long"
+    },
+    "serverInfo": {
+      "properties": {
+        "serverBuildDate": {
+          "type": "date"
+        },
+        "eventTypes": {
+          "type": "object",
+          "enabled": false
+        },
+        "capabilities": {
+          "type": "object",
+          "enabled": false
+        }
+      }
+    }
+  }
+}
diff --git a/rest/pom.xml b/rest/pom.xml
index 9cc6b8877..08bd709e0 100644
--- a/rest/pom.xml
+++ b/rest/pom.xml
@@ -117,6 +117,12 @@
             <version>${project.version}</version>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>org.apache.unomi</groupId>
+            <artifactId>unomi-services-common</artifactId>
+            <version>${project.version}</version>
+            <scope>provided</scope>
+        </dependency>
         <dependency>
             <groupId>commons-lang</groupId>
             <artifactId>commons-lang</artifactId>
@@ -177,4 +183,34 @@
             <scope>provided</scope>
         </dependency>
     </dependencies>
+
+    <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>
+        </plugins>
+    </build>
+
 </project>
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 ea9a42823..7bf4d3486 100644
--- 
a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
+++ 
b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
@@ -107,6 +107,12 @@ public 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 +245,67 @@ 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 (defaultTenantId != null) {
+                // Create a guest subject for public endpoints
+                Subject subject = 
securityService.createSubject(defaultTenantId, false);
+                
+                // Set CXF security context
+                JAXRSUtils.getCurrentMessage().put(SecurityContext.class,
+                    new RolePrefixSecurityContextImpl(subject, 
ROLE_CLASSIFIER, ROLE_CLASSIFIER_TYPE));
+                
+                // Set the security service subject
+                securityService.setCurrentSubject(subject);
+                
+                // Set the execution context for the default tenant
+                
executionContextManager.setCurrentContext(executionContextManager.createContext(defaultTenantId));
+                return;
+            }
+        }
+
+        // For private endpoints, require system administrator authentication 
(like V2)
+        String authHeader = 
requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
+        if (authHeader != null && authHeader.startsWith(BASIC_AUTH_PREFIX)) {
+            try {
+                jaasAuthenticationFilter.filter(requestContext);
+                // Get the subject from the security context after successful 
JAAS auth
+                SecurityContext securityContext = 
JAXRSUtils.getCurrentMessage().get(SecurityContext.class);
+                if (securityContext != null) {
+                    Subject subject = ((RolePrefixSecurityContextImpl) 
securityContext).getSubject();
+                    // Set the authenticated subject in Unomi's security 
service
+                    securityService.setCurrentSubject(subject);
+                    
+                    // In V2 compatibility mode, use the default tenant for 
all operations
+                    String defaultTenantId = 
restAuthenticationConfig.getV2CompatibilityDefaultTenantId();
+                    if (defaultTenantId != null) {
+                        
executionContextManager.setCurrentContext(executionContextManager.createContext(defaultTenantId));
+                    } else {
+                        
executionContextManager.setCurrentContext(ExecutionContext.systemContext());
+                    }
+                }
+                return;
+            } catch (Exception e) {
+                logger.debug("V2 compatibility mode: JAAS authentication 
failed");
+            }
+        } else {
+            logger.debug("V2 compatibility mode: Missing Basic Auth header for 
private endpoint");
+        }
+
+        // If we get here, no valid authentication was provided
+        unauthorized(requestContext);
+    }
+
     private String[] extractBasicAuthCredentials(String authHeader) {
         try {
             String base64Credentials = 
authHeader.substring(BASIC_AUTH_PREFIX.length()).trim();
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/authentication/RestAuthenticationConfig.java
 
b/rest/src/main/java/org/apache/unomi/rest/authentication/RestAuthenticationConfig.java
index 991c580e0..ed7022dc0 100644
--- 
a/rest/src/main/java/org/apache/unomi/rest/authentication/RestAuthenticationConfig.java
+++ 
b/rest/src/main/java/org/apache/unomi/rest/authentication/RestAuthenticationConfig.java
@@ -59,4 +59,23 @@ public interface RestAuthenticationConfig {
      * @return Global roles separated with single white spaces, like: "ROLE1 
ROLE2 ROLE3"
      */
     String getGlobalRoles();
+
+    /**
+     * Check if V2 compatibility mode is enabled.
+     * When enabled, V2 clients can use Unomi V3 without requiring API keys:
+     * - Public endpoints (like /context.json) require no authentication (like 
V2)
+     * - Private endpoints require system administrator authentication (like 
V2)
+     * - A default tenant is automatically used for all operations
+     *
+     * @return true if V2 compatibility mode is enabled, false otherwise
+     */
+    boolean isV2CompatibilityModeEnabled();
+
+    /**
+     * Get the default tenant ID to use in V2 compatibility mode.
+     * This tenant will be used for all operations when V2 compatibility mode 
is enabled.
+     *
+     * @return the default tenant ID
+     */
+    String getV2CompatibilityDefaultTenantId();
 }
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java
 
b/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java
new file mode 100644
index 000000000..954493f72
--- /dev/null
+++ 
b/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java
@@ -0,0 +1,259 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.unomi.rest.authentication;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.unomi.services.common.security.IPValidationUtils;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Modified;
+import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.Designate;
+import org.osgi.service.metatype.annotations.ObjectClassDefinition;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Service to handle V2 third-party configuration for V2 compatibility mode.
+ * This service reads the legacy V2 third-party configuration and provides
+ * methods to validate protected events and third-party providers.
+ * Uses the original V2 configuration file: org.apache.unomi.thirdparty.cfg
+ */
+@Component(service = V2ThirdPartyConfigService.class, configurationPid = 
"org.apache.unomi.thirdparty")
+@Designate(ocd = V2ThirdPartyConfigService.Config.class)
+public class V2ThirdPartyConfigService {
+    
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(V2ThirdPartyConfigService.class);
+    
+    @ObjectClassDefinition(
+        name = "Apache Unomi Third-Party Configuration",
+        description = "Configuration for third-party providers (V2 
compatibility mode). " +
+                     "Providers are configured using the pattern: 
thirdparty.{providerName}.{property}. " +
+                     "Example: thirdparty.myapp.key, 
thirdparty.myapp.ipAddresses, thirdparty.myapp.allowedEvents"
+    )
+    public @interface Config {
+        // No hardcoded attributes - all providers are configured dynamically
+        // using the pattern: thirdparty.{providerName}.{property}
+    }
+    
+    /**
+     * Provider configuration data structure
+     */
+    private static class ProviderConfig {
+        private final String key;
+        private final Set<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) {
+            // Parse all provider configurations dynamically
+            for (Map.Entry<String, Object> entry : properties.entrySet()) {
+                String key = entry.getKey();
+                String value = entry.getValue() != null ? 
entry.getValue().toString() : "";
+                
+                // Look for provider configuration patterns: 
thirdparty.{providerName}.{property}
+                if (key.startsWith("thirdparty.") && key.contains(".")) {
+                    String[] parts = key.split("\\.");
+                    if (parts.length >= 3) {
+                        String providerName = parts[1];
+                        String property = parts[2];
+                        
+                        ProviderConfig existingConfig = 
newProviders.get(providerName);
+                        String configKey = existingConfig != null ? 
existingConfig.getKey() : "";
+                        Set<String> configIpAddresses = existingConfig != null 
? existingConfig.getIpAddresses() : new HashSet<>();
+                        Set<String> configAllowedEvents = existingConfig != 
null ? existingConfig.getAllowedEvents() : new HashSet<>();
+                        
+                        switch (property) {
+                            case "key":
+                                configKey = value;
+                                break;
+                            case "ipAddresses":
+                                configIpAddresses = 
parseCommaSeparatedList(value);
+                                break;
+                            case "allowedEvents":
+                                configAllowedEvents = 
parseCommaSeparatedList(value);
+                                break;
+                        }
+                        
+                        // Only add provider if it has a key (required for 
authentication)
+                        if (StringUtils.isNotBlank(configKey)) {
+                            newProviders.put(providerName, new 
ProviderConfig(configKey, configIpAddresses, configAllowedEvents));
+                        }
+                    }
+                }
+            }
+        }
+        
+        // Set default provider1 if no providers configured
+        if (newProviders.isEmpty()) {
+            newProviders.put("provider1", new ProviderConfig(
+                "670c26d1cc413346c3b2fd9ce65dab41",
+                new HashSet<>(Arrays.asList("127.0.0.1", "::1")),
+                new HashSet<>(Arrays.asList("login", "updateProperties"))
+            ));
+        }
+        
+        this.providers = newProviders;
+        
+        int totalEvents = newProviders.values().stream()
+            .mapToInt(config -> config.getAllowedEvents().size())
+            .sum();
+        
+        LOGGER.info("V2 Third-Party Configuration updated - {} providers with 
{} total protected events", 
+                   newProviders.size(), totalEvents);
+    }
+    
+    /**
+     * Check if an event type is protected (requires third-party 
authentication).
+     * 
+     * @param eventType the event type to check
+     * @return true if the event type is protected, false otherwise
+     */
+    public boolean isProtectedEventType(String eventType) {
+        if (StringUtils.isBlank(eventType)) {
+            return false;
+        }
+        
+        return providers.values().stream()
+            .anyMatch(config -> config.getAllowedEvents().contains(eventType));
+    }
+    
+    /**
+     * Get all protected event types from all providers.
+     * 
+     * @return set of all protected event types
+     */
+    public Set<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: {}", 
providerKey);
+            return false;
+        }
+        
+        if (!config.getAllowedEvents().contains(eventType)) {
+            LOGGER.debug("V2 compatibility mode: Event type {} not allowed for 
provider {} (key: {})", eventType, foundProviderId, providerKey);
+            return false;
+        }
+        
+        boolean ipAuthorized = IPValidationUtils.isIpAuthorized(sourceIP, 
config.getIpAddresses());
+        if (!ipAuthorized) {
+            LOGGER.debug("V2 compatibility mode: IP {} not authorized for 
provider {} (key: {})", sourceIP, foundProviderId, providerKey);
+        }
+        
+        return ipAuthorized;
+    }
+    
+    /**
+     * Get the key for a third-party provider.
+     * 
+     * @param providerId the third-party provider ID
+     * @return the provider key, or null if not found
+     */
+    public String getProviderKey(String providerId) {
+        ProviderConfig config = providers.get(providerId);
+        return config != null ? config.getKey() : null;
+    }
+    
+    /**
+     * Check if a provider ID is valid.
+     * 
+     * @param providerId the third-party provider ID
+     * @return true if the provider ID is valid, false otherwise
+     */
+    public boolean isValidProvider(String providerId) {
+        return providers.containsKey(providerId);
+    }
+    
+    private Set<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 31b6fcd70..65a537dd9 100644
--- 
a/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java
+++ 
b/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java
@@ -19,6 +19,11 @@ package org.apache.unomi.rest.authentication.impl;
 import org.apache.unomi.api.security.UnomiRoles;
 import org.apache.unomi.rest.authentication.RestAuthenticationConfig;
 import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Modified;
+import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.Designate;
+import org.osgi.service.metatype.annotations.ObjectClassDefinition;
 
 import java.util.*;
 import java.util.regex.Pattern;
@@ -26,7 +31,8 @@ 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 String GUEST_ROLES = UnomiRoles.USER;
@@ -39,7 +45,6 @@ public class DefaultRestAuthenticationConfig implements 
RestAuthenticationConfig
             Pattern.compile("(GET|OPTIONS) client/.*")
     );
 
-
     private static final Map<String, String> ROLES_MAPPING;
 
     static {
@@ -64,6 +69,29 @@ public class DefaultRestAuthenticationConfig implements 
RestAuthenticationConfig
         ROLES_MAPPING = Collections.unmodifiableMap(roles);
     }
 
+    private volatile boolean v2CompatibilityModeEnabled = false;
+    private volatile String v2CompatibilityDefaultTenantId = "default";
+
+    @Activate
+    public void activate(Map<String, Object> properties) {
+        modified(properties);
+    }
+
+    @Modified
+    public void modified(Map<String, Object> properties) {
+        if (properties != null) {
+            Object v2Mode = properties.get("v2CompatibilityModeEnabled");
+            if (v2Mode != null) {
+                this.v2CompatibilityModeEnabled = 
Boolean.parseBoolean(v2Mode.toString());
+            }
+
+            Object defaultTenant = 
properties.get("v2CompatibilityDefaultTenantId");
+            if (defaultTenant != null) {
+                this.v2CompatibilityDefaultTenantId = defaultTenant.toString();
+            }
+        }
+    }
+
     @Override
     public List<Pattern> getPublicPathPatterns() {
         return PUBLIC_PATH_PATTERNS;
@@ -78,4 +106,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 v2CompatibilityModeEnabled() default false;
+
+        @AttributeDefinition(
+            name = "V2 Compatibility Default Tenant ID",
+            description = "Default tenant ID to use in V2 compatibility mode"
+        )
+        String v2CompatibilityDefaultTenantId() 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 83bb8e4a0..26eecae86 100644
--- 
a/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
+++ 
b/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
@@ -29,6 +29,8 @@ import org.apache.unomi.api.services.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.rest.exception.InvalidRequestException;
 import org.apache.unomi.rest.service.RestServiceUtils;
 import org.apache.unomi.schema.api.SchemaService;
@@ -51,6 +53,7 @@ import java.security.Principal;
 import java.util.Date;
 import java.util.List;
 import java.util.Optional;
+import java.util.Set;
 import java.util.UUID;
 
 @Component(service = RestServiceUtils.class)
@@ -79,6 +82,12 @@ public class RestServiceUtilsImpl implements 
RestServiceUtils {
     @Reference
     private TenantService tenantService;
 
+    @Reference
+    private RestAuthenticationConfig restAuthenticationConfig;
+
+    @Reference
+    private V2ThirdPartyConfigService v2ThirdPartyConfigService;
+
     @Override
     public String getProfileIdCookieValue(HttpServletRequest 
httpServletRequest) {
         String cookieProfileId = null;
@@ -268,8 +277,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())) {
-                        throw new WebApplicationException("Tenant is not 
authorized to send event " + event.getEventType() + " from IP " + 
eventsRequestContext.getRequest().getRemoteAddr(), 
Response.Status.UNAUTHORIZED);
+                    // 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(),
@@ -278,6 +301,7 @@ public class RestServiceUtilsImpl implements 
RestServiceUtils {
                     }
                     if (filteredEventTypes != null && 
filteredEventTypes.contains(event.getEventType())) {
                         LOGGER.debug("Profile is filtering event type {}", 
event.getEventType());
+                        
eventsRequestContext.setProcessedItems(eventsRequestContext.getProcessedItems() 
- 1);
                         continue;
                     }
                     if 
(eventsRequestContext.getProfile().isAnonymousProfile()) {
@@ -371,4 +395,40 @@ 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(), thirdPartyKey, sourceIP);
+            return false;
+        }
+        
+        LOGGER.debug("V2 compatibility mode: Protected event {} allowed for 
provider key: {} from IP: {}", 
+                    event.getEventType(), thirdPartyKey, sourceIP);
+        return true;
+    }
 }
diff --git a/rest/src/main/resources/org.apache.unomi.rest.authentication.cfg 
b/rest/src/main/resources/org.apache.unomi.rest.authentication.cfg
new file mode 100644
index 000000000..792f83b79
--- /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
+v2CompatibilityModeEnabled = 
${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")
+v2CompatibilityDefaultTenantId = 
${org.apache.unomi.rest.authentication.v2CompatibilityDefaultTenantId:-default}
diff --git a/services-common/pom.xml b/services-common/pom.xml
index 16df15c07..60527e7d6 100644
--- a/services-common/pom.xml
+++ b/services-common/pom.xml
@@ -79,6 +79,21 @@
             <scope>provided</scope>
         </dependency>
 
+        <!-- Apache Commons Lang3 for string utilities -->
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-lang3</artifactId>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- IP Address library for CIDR validation -->
+        <dependency>
+            <groupId>com.github.seancfoley</groupId>
+            <artifactId>ipaddress</artifactId>
+            <version>4.3.0</version>
+            <scope>compile</scope>
+        </dependency>
+
         <!-- Test dependencies -->
         <dependency>
             <groupId>junit</groupId>
@@ -113,7 +128,8 @@
                         <Export-Package>
                             org.apache.unomi.services.common,
                             org.apache.unomi.services.common.service,
-                            org.apache.unomi.services.common.cache
+                            org.apache.unomi.services.common.cache,
+                            org.apache.unomi.services.common.security
                         </Export-Package>
                         <Import-Package>
                             org.apache.unomi.api,
diff --git 
a/services-common/src/main/java/org/apache/unomi/services/common/security/IPValidationUtils.java
 
b/services-common/src/main/java/org/apache/unomi/services/common/security/IPValidationUtils.java
new file mode 100644
index 000000000..9a5812741
--- /dev/null
+++ 
b/services-common/src/main/java/org/apache/unomi/services/common/security/IPValidationUtils.java
@@ -0,0 +1,83 @@
+/*
+ * 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 inet.ipaddr.IPAddress;
+import inet.ipaddr.IPAddressString;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Set;
+
+/**
+ * Utility class for IP address validation and authorization.
+ * Provides shared functionality for checking if a source IP address is 
authorized
+ * against a set of allowed IP addresses or CIDR ranges.
+ */
+public class IPValidationUtils {
+    
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(IPValidationUtils.class);
+    
+    /**
+     * Check if a source IP address is authorized against a set of allowed IP 
addresses.
+     * 
+     * @param sourceIP the source IP address to validate
+     * @param authorizedIPs the set of authorized IP addresses or CIDR ranges
+     * @return true if the source IP is authorized, false otherwise
+     */
+    public static boolean isIpAuthorized(String sourceIP, Set<String> 
authorizedIPs) {
+        if (authorizedIPs == null || authorizedIPs.isEmpty()) {
+            return true; // No IP restrictions
+        }
+        
+        if (StringUtils.isBlank(sourceIP)) {
+            return false;
+        }
+        
+        try {
+            // Handle IPv6 addresses with brackets
+            if (sourceIP.startsWith("[") && sourceIP.endsWith("]")) {
+                // This can happen with IPv6 addresses, we must remove the 
markers since our IPAddress library doesn't support them.
+                sourceIP = sourceIP.substring(1, sourceIP.length() - 1);
+                // If the result is empty or only whitespace, it's invalid
+                if (StringUtils.isBlank(sourceIP)) {
+                    return false;
+                }
+            }
+            
+            IPAddress eventIP = new IPAddressString(sourceIP).toAddress();
+
+            for (String authorizedIP : authorizedIPs) {
+                try {
+                    IPAddress ip = new 
IPAddressString(authorizedIP.trim()).toAddress();
+                    if (ip.contains(eventIP)) {
+                        return true;
+                    }
+                } catch (Exception e) {
+                    // Log invalid IP in configuration but continue checking 
others
+                    LOGGER.warn("Invalid IP address in configuration: {}. 
Skipping.", authorizedIP);
+                }
+            }
+            return false;
+        } catch (Exception e) {
+            LOGGER.error("Invalid source IP address: {}", sourceIP, e);
+            return false;
+        }
+    }
+} 
\ No newline at end of file
diff --git 
a/services-common/src/test/java/org/apache/unomi/services/common/security/IPValidationUtilsTest.java
 
b/services-common/src/test/java/org/apache/unomi/services/common/security/IPValidationUtilsTest.java
new file mode 100644
index 000000000..1b02cf8c6
--- /dev/null
+++ 
b/services-common/src/test/java/org/apache/unomi/services/common/security/IPValidationUtilsTest.java
@@ -0,0 +1,144 @@
+/*
+ * 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 java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.Assert.*;
+
+/**
+ * Test class for IPValidationUtils
+ */
+public class IPValidationUtilsTest {
+
+    @Test
+    public void testNoRestrictions() {
+        // No IP restrictions should always return true
+        assertTrue(IPValidationUtils.isIpAuthorized("192.168.1.1", null));
+        assertTrue(IPValidationUtils.isIpAuthorized("192.168.1.1", 
Collections.emptySet()));
+    }
+
+    @Test
+    public void testBlankSourceIP() {
+        // Blank source IP should return false
+        Set<String> authorizedIPs = new 
HashSet<>(Arrays.asList("192.168.1.1"));
+        assertFalse(IPValidationUtils.isIpAuthorized("", authorizedIPs));
+        assertFalse(IPValidationUtils.isIpAuthorized(null, authorizedIPs));
+        assertFalse(IPValidationUtils.isIpAuthorized("   ", authorizedIPs));
+    }
+
+    @Test
+    public void testExactMatch() {
+        Set<String> authorizedIPs = new HashSet<>(Arrays.asList("192.168.1.1", 
"10.0.0.1"));
+        
+        assertTrue(IPValidationUtils.isIpAuthorized("192.168.1.1", 
authorizedIPs));
+        assertTrue(IPValidationUtils.isIpAuthorized("10.0.0.1", 
authorizedIPs));
+        assertFalse(IPValidationUtils.isIpAuthorized("192.168.1.2", 
authorizedIPs));
+    }
+
+    @Test
+    public void testIPv6Addresses() {
+        Set<String> authorizedIPs = new HashSet<>(Arrays.asList("::1", 
"2001:db8::1"));
+        
+        assertTrue(IPValidationUtils.isIpAuthorized("::1", authorizedIPs));
+        assertTrue(IPValidationUtils.isIpAuthorized("[::1]", authorizedIPs)); 
// With brackets
+        assertTrue(IPValidationUtils.isIpAuthorized("2001:db8::1", 
authorizedIPs));
+        assertTrue(IPValidationUtils.isIpAuthorized("[2001:db8::1]", 
authorizedIPs)); // With brackets
+        assertFalse(IPValidationUtils.isIpAuthorized("2001:db8::2", 
authorizedIPs));
+        assertFalse(IPValidationUtils.isIpAuthorized("[2001:db8::2]", 
authorizedIPs)); // With brackets
+    }
+
+    @Test
+    public void testCIDRRanges() {
+        Set<String> authorizedIPs = new HashSet<>(Arrays.asList("127.0.0.0/8", 
"192.168.0.0/16"));
+        
+        // Test localhost range
+        assertTrue(IPValidationUtils.isIpAuthorized("127.0.0.1", 
authorizedIPs));
+        assertTrue(IPValidationUtils.isIpAuthorized("127.255.255.255", 
authorizedIPs));
+        assertFalse(IPValidationUtils.isIpAuthorized("128.0.0.1", 
authorizedIPs));
+        
+        // Test private network range
+        assertTrue(IPValidationUtils.isIpAuthorized("192.168.1.1", 
authorizedIPs));
+        assertTrue(IPValidationUtils.isIpAuthorized("192.168.255.255", 
authorizedIPs));
+        assertFalse(IPValidationUtils.isIpAuthorized("192.169.1.1", 
authorizedIPs));
+    }
+
+    @Test
+    public void testInvalidIPs() {
+        Set<String> authorizedIPs = new 
HashSet<>(Arrays.asList("192.168.1.1"));
+        
+        // Invalid IPs should return false but not throw exceptions
+        assertFalse(IPValidationUtils.isIpAuthorized("invalid-ip", 
authorizedIPs));
+        assertFalse(IPValidationUtils.isIpAuthorized("256.256.256.256", 
authorizedIPs));
+        assertFalse(IPValidationUtils.isIpAuthorized("192.168.1", 
authorizedIPs));
+    }
+
+    @Test
+    public void testInvalidAuthorizedIPs() {
+        Set<String> authorizedIPs = new HashSet<>(Arrays.asList("invalid-ip", 
"192.168.1.1"));
+        
+        // Should still work with valid IPs even if some authorized IPs are 
invalid
+        assertTrue(IPValidationUtils.isIpAuthorized("192.168.1.1", 
authorizedIPs));
+        assertFalse(IPValidationUtils.isIpAuthorized("192.168.1.2", 
authorizedIPs));
+    }
+
+    @Test
+    public void testAllInvalidAuthorizedIPs() {
+        Set<String> authorizedIPs = new 
HashSet<>(Arrays.asList("invalid-ip-1", "invalid-ip-2", "256.256.256.256"));
+        
+        // Should return false when all authorized IPs are invalid
+        assertFalse(IPValidationUtils.isIpAuthorized("192.168.1.1", 
authorizedIPs));
+        assertFalse(IPValidationUtils.isIpAuthorized("10.0.0.1", 
authorizedIPs));
+    }
+
+    @Test
+    public void testMixedValidAndInvalidAuthorizedIPs() {
+        Set<String> authorizedIPs = new HashSet<>(Arrays.asList("invalid-ip", 
"192.168.1.0/24", "another-invalid"));
+        
+        // Should work with CIDR ranges even when some authorized IPs are 
invalid
+        assertTrue(IPValidationUtils.isIpAuthorized("192.168.1.1", 
authorizedIPs));
+        assertTrue(IPValidationUtils.isIpAuthorized("192.168.1.255", 
authorizedIPs));
+        assertFalse(IPValidationUtils.isIpAuthorized("192.168.2.1", 
authorizedIPs));
+    }
+
+    @Test
+    public void testEdgeCases() {
+        Set<String> authorizedIPs = new HashSet<>(Arrays.asList("127.0.0.1"));
+        
+        // Test edge cases for bracket handling
+        assertFalse(IPValidationUtils.isIpAuthorized("[", authorizedIPs)); // 
Only opening bracket
+        assertFalse(IPValidationUtils.isIpAuthorized("]", authorizedIPs)); // 
Only closing bracket
+        assertFalse(IPValidationUtils.isIpAuthorized("[]", authorizedIPs)); // 
Empty brackets
+        assertFalse(IPValidationUtils.isIpAuthorized("[invalid]", 
authorizedIPs)); // Invalid IP in brackets
+    }
+
+    @Test
+    public void testWhitespaceHandling() {
+        Set<String> authorizedIPs = new HashSet<>(Arrays.asList("  192.168.1.1 
 ", "  10.0.0.0/8  "));
+        
+        // Should handle whitespace in authorized IPs (trim() is called)
+        assertTrue(IPValidationUtils.isIpAuthorized("192.168.1.1", 
authorizedIPs));
+        assertTrue(IPValidationUtils.isIpAuthorized("10.0.0.1", 
authorizedIPs));
+        assertFalse(IPValidationUtils.isIpAuthorized("192.168.1.2", 
authorizedIPs));
+    }
+} 
\ No newline at end of file
diff --git 
a/services/src/main/java/org/apache/unomi/services/impl/events/EventServiceImpl.java
 
b/services/src/main/java/org/apache/unomi/services/impl/events/EventServiceImpl.java
index b6716f122..a143e40f2 100644
--- 
a/services/src/main/java/org/apache/unomi/services/impl/events/EventServiceImpl.java
+++ 
b/services/src/main/java/org/apache/unomi/services/impl/events/EventServiceImpl.java
@@ -17,10 +17,9 @@
 
 package org.apache.unomi.services.impl.events;
 
-import inet.ipaddr.IPAddress;
-import inet.ipaddr.IPAddressString;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.unomi.api.*;
+import org.apache.unomi.services.common.security.IPValidationUtils;
 import org.apache.unomi.api.actions.ActionPostExecutor;
 import org.apache.unomi.api.conditions.Condition;
 import org.apache.unomi.api.query.Query;
@@ -125,37 +124,7 @@ public class EventServiceImpl implements EventService {
 
     private boolean checkIPAuthorization(Tenant tenant, String sourceIP) {
         Set<String> authorizedIPs = tenant.getAuthorizedIPs();
-        if (authorizedIPs == null || authorizedIPs.isEmpty()) {
-            return true;  // No IP restrictions
-        }
-
-        if (StringUtils.isBlank(sourceIP)) {
-            return false;
-        }
-
-        try {
-            if (sourceIP.startsWith("[") && sourceIP.endsWith("]")) {
-                // This can happen with IPv6 addresses, we must remove the 
markers since our IPAddress library doesn't support them.
-                sourceIP = sourceIP.substring(1, sourceIP.length() - 1);
-            }
-            IPAddress eventIP = new IPAddressString(sourceIP).toAddress();
-
-            for (String authorizedIP : authorizedIPs) {
-                try {
-                    IPAddress ip = new 
IPAddressString(authorizedIP.trim()).toAddress();
-                    if (ip.contains(eventIP)) {
-                        return true;
-                    }
-                } catch (Exception e) {
-                    // Log invalid IP in tenant config but continue checking 
others
-                    LOGGER.warn("Invalid IP address in tenant configuration: 
{}. Skipping.", authorizedIP);
-                }
-            }
-            return false;
-        } catch (Exception e) {
-            LOGGER.error("Invalid source IP address: {}", sourceIP, e);
-            return false;
-        }
+        return IPValidationUtils.isIpAuthorized(sourceIP, authorizedIPs);
     }
 
     public int send(Event event) {

Reply via email to