Copilot commented on code in PR #762:
URL: https://github.com/apache/unomi/pull/762#discussion_r3366829673


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

Review Comment:
   Provider properties are only stored when the `key` property happens to be 
encountered first. Because Configuration Admin property iteration order is 
undefined, `ipAddresses` / `allowedEvents` can be silently dropped, which can 
unintentionally make protected events unprotected or remove IP restrictions 
depending on ordering.



##########
rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java:
##########
@@ -63,6 +70,26 @@ public class DefaultRestAuthenticationConfig implements 
RestAuthenticationConfig
         ROLES_MAPPING = Collections.unmodifiableMap(roles);
     }
 
+    private volatile boolean v2CompatibilityModeEnabled = false;
+    private volatile String v2CompatibilityDefaultTenantId = "default";
+
+
+    @Activate
+    @Modified
+    public void modified(Config config) {
+        if (config == null) {
+            LOGGER.warn("Config is null in modified method");
+            return;
+        }
+        boolean v2Mode = config.v2_compatibilitymode_enabled();
+        String defaultTenant = config.v2_compatibilitymode_defaultTenantId();
+        LOGGER.info("Configuration updated - v2CompatibilityModeEnabled: {}, 
v2CompatibilityDefaultTenantId: {}",
+                    v2Mode, defaultTenant);
+            this.v2CompatibilityModeEnabled = v2Mode;
+            this.v2CompatibilityDefaultTenantId = defaultTenant;
+    }

Review Comment:
   `v2CompatibilityDefaultTenantId` is set from configuration without 
trimming/validating. If the property is accidentally set to blank/whitespace, 
subsequent `createContext(defaultTenantId)` calls will use an empty tenant ID.



##########
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("v2.compatibilitymode.enabled", false); // Start in V3 
mode
+        v2Config.put("v2.compatibilitymode.defaultTenantId", TEST_TENANT_ID); 
// Use BaseIT tenant
+
+        updateConfiguration(null,
+                "org.apache.unomi.rest.authentication",
+                v2Config);
+
+        // Wait for configuration to be applied
+        keepTrying("V2 compatibility configuration not applied in the required 
time",
+                () -> 
restAuthenticationConfig.getV2CompatibilityDefaultTenantId(),
+                tenantId -> TEST_TENANT_ID.equals(tenantId), 
DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        // Create test profile
+        Profile profile = new Profile(TEST_PROFILE_ID);
+        profileService.save(profile);
+
+        keepTrying("Profile " + TEST_PROFILE_ID + " not found in the required 
time",
+                () -> profileService.load(TEST_PROFILE_ID),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+    }
+
+    @After
+    public void tearDown() throws InterruptedException, IOException {
+        try {
+            // Restore original V2 mode setting and default tenant ID
+            Map<String, Object> originalConfig = new HashMap<>();
+            originalConfig.put("v2.compatibilitymode.enabled", originalV2Mode);
+            if (originalDefaultTenantId != null) {
+                originalConfig.put("v2.compatibilitymode.defaultTenantId", 
originalDefaultTenantId);
+            }
+
+            updateConfiguration(null,
+                    "org.apache.unomi.rest.authentication",
+                    originalConfig);
+        } catch (Exception e) {
+            LOGGER.warn("Failed to restore original V2 mode setting", e);
+        }
+
+        // Clean up test data
+        try {
+            TestUtils.removeAllEvents(definitionsService, persistenceService, 
true, tenantService, executionContextManager);
+            TestUtils.removeAllSessions(definitionsService, 
persistenceService, true, tenantService, executionContextManager);
+            TestUtils.removeAllProfiles(definitionsService, 
persistenceService, true, tenantService, executionContextManager);
+
+            profileService.delete(TEST_PROFILE_ID, false);
+            removeItems(Session.class);
+
+            scopeService.delete(TEST_SCOPE);
+        } catch (Exception e) {
+            LOGGER.warn("Failed to clean up test data", e);
+        }
+
+
+    }
+
+    @Test
+    public void testV2CompatibilityModeSwitch() throws Exception {
+        LOGGER.info("Starting V2 compatibility mode switch test");
+
+        // STEP 1: Test V3 mode (default) - V2 requests should be rejected, V3 
requests should work
+        LOGGER.info("STEP 1: Testing V3 mode (default)");
+        testV3ModeBehavior();
+
+        // STEP 2: Switch to V2 compatibility mode
+        LOGGER.info("STEP 2: Switching to V2 compatibility mode");
+        updateConfiguration(null,
+                "org.apache.unomi.rest.authentication",
+                "v2.compatibilitymode.enabled",
+                true);
+
+        // Wait for configuration to take effect
+        keepTrying("V2 compatibility mode not enabled in the required time",
+                () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(),
+                enabled -> enabled, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        // STEP 3: Test V2 mode - V2 requests should work, V3 requests should 
be rejected
+        LOGGER.info("STEP 3: Testing V2 compatibility mode");
+        testV2ModeBehavior();
+
+        // STEP 4: Switch back to V3 mode
+        LOGGER.info("STEP 4: Switching back to V3 mode");
+        updateConfiguration(null,
+                "org.apache.unomi.rest.authentication",
+                "v2.compatibilitymode.enabled",
+                false);
+
+        // Wait for configuration to take effect
+        keepTrying("V2 compatibility mode not disabled in the required time",
+                () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(),
+                enabled -> !enabled, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        // STEP 5: Test V3 mode again - V2 requests should be rejected, V3 
requests should work
+        LOGGER.info("STEP 5: Testing V3 mode again");
+        testV3ModeBehavior();
+
+        LOGGER.info("V2 compatibility mode switch test completed 
successfully");
+    }
+
+    /**
+     * Test behavior in V3 mode (default):
+     * - V2 requests (no auth) should be rejected
+     * - V3 requests with proper authentication should work
+     */
+    private void testV3ModeBehavior() throws Exception {
+        // Test V2-style request (no authentication) - should be rejected
+        ContextRequest contextRequest = new ContextRequest();
+        contextRequest.setSessionId(TEST_SESSION_ID);
+
+        HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        TestUtils.RequestResponse response = 
TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID, 401, false);
+        assertEquals("V2-style request should be rejected in V3 mode", 401, 
response.getStatusCode());
+
+        // Test V3-style request with public API key - should work
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.addHeader(UNOMI_API_KEY_HEADER, testPublicKey.getKey());
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        response = TestUtils.executeContextJSONRequest(request, 
TEST_SESSION_ID);
+        assertEquals("V3-style request with public API key should work in V3 
mode", 200, response.getStatusCode());
+
+        // Test V3-style request with private API key - should work
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(request, testTenant, testPrivateKey);
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        response = TestUtils.executeContextJSONRequest(request, 
TEST_SESSION_ID);
+        assertEquals("V3-style request with private API key should work in V3 
mode", 200, response.getStatusCode());
+
+        // Test V3-style request with JAAS authentication - should work
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.addHeader(UNOMI_TENANT_ID_HEADER, testTenant.getItemId());
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+
+        BasicCredentialsProvider credsProvider = new 
BasicCredentialsProvider();
+        credsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials("karaf", "karaf"));
+
+        RequestConfig requestConfig = RequestConfig.custom()
+                .setAuthenticationEnabled(true)
+                
.setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.BASIC))
+                .build();
+
+        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());

Review Comment:
   These comments say the V3 API-key requests “should be rejected in V2 mode”, 
but the assertions expect HTTP 200 with `processedEvents == 0` (i.e., request 
accepted but events ignored). Updating the comments would avoid confusion about 
the intended behavior.



##########
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("v2.compatibilitymode.enabled", false); // Start in V3 
mode
+        v2Config.put("v2.compatibilitymode.defaultTenantId", TEST_TENANT_ID); 
// Use BaseIT tenant
+
+        updateConfiguration(null,
+                "org.apache.unomi.rest.authentication",
+                v2Config);
+
+        // Wait for configuration to be applied
+        keepTrying("V2 compatibility configuration not applied in the required 
time",
+                () -> 
restAuthenticationConfig.getV2CompatibilityDefaultTenantId(),
+                tenantId -> TEST_TENANT_ID.equals(tenantId), 
DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        // Create test profile
+        Profile profile = new Profile(TEST_PROFILE_ID);
+        profileService.save(profile);
+
+        keepTrying("Profile " + TEST_PROFILE_ID + " not found in the required 
time",
+                () -> profileService.load(TEST_PROFILE_ID),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+    }
+
+    @After
+    public void tearDown() throws InterruptedException, IOException {
+        try {
+            // Restore original V2 mode setting and default tenant ID
+            Map<String, Object> originalConfig = new HashMap<>();
+            originalConfig.put("v2.compatibilitymode.enabled", originalV2Mode);
+            if (originalDefaultTenantId != null) {
+                originalConfig.put("v2.compatibilitymode.defaultTenantId", 
originalDefaultTenantId);
+            }
+
+            updateConfiguration(null,
+                    "org.apache.unomi.rest.authentication",
+                    originalConfig);
+        } catch (Exception e) {
+            LOGGER.warn("Failed to restore original V2 mode setting", e);
+        }
+
+        // Clean up test data
+        try {
+            TestUtils.removeAllEvents(definitionsService, persistenceService, 
true, tenantService, executionContextManager);
+            TestUtils.removeAllSessions(definitionsService, 
persistenceService, true, tenantService, executionContextManager);
+            TestUtils.removeAllProfiles(definitionsService, 
persistenceService, true, tenantService, executionContextManager);
+
+            profileService.delete(TEST_PROFILE_ID, false);
+            removeItems(Session.class);
+
+            scopeService.delete(TEST_SCOPE);
+        } catch (Exception e) {
+            LOGGER.warn("Failed to clean up test data", e);
+        }
+
+
+    }
+
+    @Test
+    public void testV2CompatibilityModeSwitch() throws Exception {
+        LOGGER.info("Starting V2 compatibility mode switch test");
+
+        // STEP 1: Test V3 mode (default) - V2 requests should be rejected, V3 
requests should work
+        LOGGER.info("STEP 1: Testing V3 mode (default)");
+        testV3ModeBehavior();
+
+        // STEP 2: Switch to V2 compatibility mode
+        LOGGER.info("STEP 2: Switching to V2 compatibility mode");
+        updateConfiguration(null,
+                "org.apache.unomi.rest.authentication",
+                "v2.compatibilitymode.enabled",
+                true);
+
+        // Wait for configuration to take effect
+        keepTrying("V2 compatibility mode not enabled in the required time",
+                () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(),
+                enabled -> enabled, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        // STEP 3: Test V2 mode - V2 requests should work, V3 requests should 
be rejected
+        LOGGER.info("STEP 3: Testing V2 compatibility mode");
+        testV2ModeBehavior();
+
+        // STEP 4: Switch back to V3 mode
+        LOGGER.info("STEP 4: Switching back to V3 mode");
+        updateConfiguration(null,
+                "org.apache.unomi.rest.authentication",
+                "v2.compatibilitymode.enabled",
+                false);
+
+        // Wait for configuration to take effect
+        keepTrying("V2 compatibility mode not disabled in the required time",
+                () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(),
+                enabled -> !enabled, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        // STEP 5: Test V3 mode again - V2 requests should be rejected, V3 
requests should work
+        LOGGER.info("STEP 5: Testing V3 mode again");
+        testV3ModeBehavior();
+
+        LOGGER.info("V2 compatibility mode switch test completed 
successfully");
+    }
+
+    /**
+     * Test behavior in V3 mode (default):
+     * - V2 requests (no auth) should be rejected
+     * - V3 requests with proper authentication should work
+     */
+    private void testV3ModeBehavior() throws Exception {
+        // Test V2-style request (no authentication) - should be rejected
+        ContextRequest contextRequest = new ContextRequest();
+        contextRequest.setSessionId(TEST_SESSION_ID);
+
+        HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        TestUtils.RequestResponse response = 
TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID, 401, false);
+        assertEquals("V2-style request should be rejected in V3 mode", 401, 
response.getStatusCode());
+
+        // Test V3-style request with public API key - should work
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.addHeader(UNOMI_API_KEY_HEADER, testPublicKey.getKey());
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        response = TestUtils.executeContextJSONRequest(request, 
TEST_SESSION_ID);
+        assertEquals("V3-style request with public API key should work in V3 
mode", 200, response.getStatusCode());
+
+        // Test V3-style request with private API key - should work
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(request, testTenant, testPrivateKey);
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        response = TestUtils.executeContextJSONRequest(request, 
TEST_SESSION_ID);
+        assertEquals("V3-style request with private API key should work in V3 
mode", 200, response.getStatusCode());
+
+        // Test V3-style request with JAAS authentication - should work
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.addHeader(UNOMI_TENANT_ID_HEADER, testTenant.getItemId());
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+
+        BasicCredentialsProvider credsProvider = new 
BasicCredentialsProvider();
+        credsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials("karaf", "karaf"));
+
+        RequestConfig requestConfig = RequestConfig.custom()
+                .setAuthenticationEnabled(true)
+                
.setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.BASIC))
+                .build();
+
+        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();
+    }

Review Comment:
   Same resource-leak issue here: the `CloseableHttpResponse` returned by 
`adminClient.execute(getRequest)` is never closed.



##########
rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java:
##########
@@ -19,16 +19,23 @@
 import org.apache.unomi.api.security.UnomiRoles;
 import org.apache.unomi.rest.authentication.RestAuthenticationConfig;
 import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Modified;
+import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.ObjectClassDefinition;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.util.*;
 import java.util.regex.Pattern;
 
 /**
  * Default implementation for the unomi authentication on Rest endpoints
  */
-@Component(service = RestAuthenticationConfig.class)
+@Component(service = { RestAuthenticationConfig.class}, configurationPid = 
"org.apache.unomi.rest.authentication", immediate = true)
 public class DefaultRestAuthenticationConfig implements 
RestAuthenticationConfig {

Review Comment:
   The REST auth config component defines an OSGi metatype `Config` interface, 
but the component isn’t designated to it. In this codebase, config-driven 
components use `@Designate` (e.g. 
`wab/src/main/java/org/apache/unomi/web/WebConfig.java`). Without designation, 
the metatype metadata/config binding may not be generated or wired as expected.



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

Review Comment:
   Debug logging currently prints third-party provider keys (`X-Unomi-Peer`) 
back into logs. These keys act as shared secrets, so logging them (even at 
debug) increases the blast radius if logs are exposed.



##########
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

Review Comment:
   The cfg comment refers to “/context.json”, but the Unomi endpoint path is 
typically `/cxs/context.json`. Updating this helps avoid misconfiguration when 
operators look at this file.



##########
rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java:
##########
@@ -239,6 +245,67 @@ public void filter(ContainerRequestContext requestContext) 
throws IOException {
         }
     }
 
+    /**
+     * Handle authentication in V2 compatibility mode.
+     * In this mode:
+     * - Public endpoints (like /context.json) require no authentication (like 
V2)
+     * - Protected events require IP + X-Unomi-Peer (like V2)
+     * - Private endpoints require system administrator authentication (like 
V2)
+     * - A default tenant is automatically used for all operations
+     */
+    private void handleV2CompatibilityMode(ContainerRequestContext 
requestContext, String path) throws IOException {
+        // For public paths, allow access without authentication (like V2)
+        if (isPublicPath(requestContext)) {
+            String defaultTenantId = 
restAuthenticationConfig.getV2CompatibilityDefaultTenantId();
+            if (defaultTenantId != null) {
+                // Create a guest subject for public endpoints
+                Subject subject = 
securityService.createSubject(defaultTenantId, false);
+

Review Comment:
   In V2 compatibility mode, a blank/whitespace `defaultTenantId` will 
currently be treated as a valid tenant ID (only `null` is checked). That can 
result in an execution context being created for an empty tenant ID instead of 
failing fast.



##########
rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java:
##########
@@ -376,4 +395,40 @@ private Profile createNewProfile(String existingProfileId, 
Date timestamp) {
         profile.setProperty("firstVisit", timestamp);
         return profile;
     }
+
+    /**
+     * Check if an event is allowed in V2 compatibility mode.
+     * In V2, protected events required IP + X-Unomi-Peer (third-party key) 
authentication.
+     *
+     * @param event the event to check
+     * @param request the HTTP request
+     * @return true if the event is allowed, false otherwise
+     */
+    private boolean isEventAllowedInV2CompatibilityMode(Event event, 
HttpServletRequest request) {
+        // Check if this is a protected event type using the V2 third-party 
configuration
+        if 
(!v2ThirdPartyConfigService.isProtectedEventType(event.getEventType())) {
+            // Non-protected events are always allowed in V2 compatibility mode
+            return true;
+        }
+
+        // For protected events, check IP + third-party key (V2-style)
+        String sourceIP = request.getRemoteAddr();
+        String thirdPartyKey = request.getHeader("X-Unomi-Peer");
+
+        if (StringUtils.isBlank(thirdPartyKey)) {
+            LOGGER.debug("V2 compatibility mode: Protected event {} rejected - 
missing X-Unomi-Peer header", event.getEventType());
+            return false;
+        }
+
+        // Validate the third-party provider using the V2 configuration
+        if (!v2ThirdPartyConfigService.validateProviderByKey(thirdPartyKey, 
event.getEventType(), sourceIP)) {
+            LOGGER.debug("V2 compatibility mode: Protected event {} rejected - 
invalid third-party provider key: {} from IP: {}",
+                        event.getEventType(), thirdPartyKey, sourceIP);
+            return false;
+        }
+
+        LOGGER.debug("V2 compatibility mode: Protected event {} allowed for 
provider key: {} from IP: {}",
+                    event.getEventType(), thirdPartyKey, sourceIP);

Review Comment:
   V2 compatibility mode debug logs currently include the raw `X-Unomi-Peer` 
value (`thirdPartyKey`). Since this header is effectively a shared secret, it’s 
safer to avoid logging it verbatim.



##########
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("v2.compatibilitymode.enabled", false); // Start in V3 
mode
+        v2Config.put("v2.compatibilitymode.defaultTenantId", TEST_TENANT_ID); 
// Use BaseIT tenant
+
+        updateConfiguration(null,
+                "org.apache.unomi.rest.authentication",
+                v2Config);
+
+        // Wait for configuration to be applied
+        keepTrying("V2 compatibility configuration not applied in the required 
time",
+                () -> 
restAuthenticationConfig.getV2CompatibilityDefaultTenantId(),
+                tenantId -> TEST_TENANT_ID.equals(tenantId), 
DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        // Create test profile
+        Profile profile = new Profile(TEST_PROFILE_ID);
+        profileService.save(profile);
+
+        keepTrying("Profile " + TEST_PROFILE_ID + " not found in the required 
time",
+                () -> profileService.load(TEST_PROFILE_ID),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+    }
+
+    @After
+    public void tearDown() throws InterruptedException, IOException {
+        try {
+            // Restore original V2 mode setting and default tenant ID
+            Map<String, Object> originalConfig = new HashMap<>();
+            originalConfig.put("v2.compatibilitymode.enabled", originalV2Mode);
+            if (originalDefaultTenantId != null) {
+                originalConfig.put("v2.compatibilitymode.defaultTenantId", 
originalDefaultTenantId);
+            }
+
+            updateConfiguration(null,
+                    "org.apache.unomi.rest.authentication",
+                    originalConfig);
+        } catch (Exception e) {
+            LOGGER.warn("Failed to restore original V2 mode setting", e);
+        }
+
+        // Clean up test data
+        try {
+            TestUtils.removeAllEvents(definitionsService, persistenceService, 
true, tenantService, executionContextManager);
+            TestUtils.removeAllSessions(definitionsService, 
persistenceService, true, tenantService, executionContextManager);
+            TestUtils.removeAllProfiles(definitionsService, 
persistenceService, true, tenantService, executionContextManager);
+
+            profileService.delete(TEST_PROFILE_ID, false);
+            removeItems(Session.class);
+
+            scopeService.delete(TEST_SCOPE);
+        } catch (Exception e) {
+            LOGGER.warn("Failed to clean up test data", e);
+        }
+
+
+    }
+
+    @Test
+    public void testV2CompatibilityModeSwitch() throws Exception {
+        LOGGER.info("Starting V2 compatibility mode switch test");
+
+        // STEP 1: Test V3 mode (default) - V2 requests should be rejected, V3 
requests should work
+        LOGGER.info("STEP 1: Testing V3 mode (default)");
+        testV3ModeBehavior();
+
+        // STEP 2: Switch to V2 compatibility mode
+        LOGGER.info("STEP 2: Switching to V2 compatibility mode");
+        updateConfiguration(null,
+                "org.apache.unomi.rest.authentication",
+                "v2.compatibilitymode.enabled",
+                true);
+
+        // Wait for configuration to take effect
+        keepTrying("V2 compatibility mode not enabled in the required time",
+                () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(),
+                enabled -> enabled, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        // STEP 3: Test V2 mode - V2 requests should work, V3 requests should 
be rejected
+        LOGGER.info("STEP 3: Testing V2 compatibility mode");
+        testV2ModeBehavior();
+
+        // STEP 4: Switch back to V3 mode
+        LOGGER.info("STEP 4: Switching back to V3 mode");
+        updateConfiguration(null,
+                "org.apache.unomi.rest.authentication",
+                "v2.compatibilitymode.enabled",
+                false);
+
+        // Wait for configuration to take effect
+        keepTrying("V2 compatibility mode not disabled in the required time",
+                () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(),
+                enabled -> !enabled, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        // STEP 5: Test V3 mode again - V2 requests should be rejected, V3 
requests should work
+        LOGGER.info("STEP 5: Testing V3 mode again");
+        testV3ModeBehavior();
+
+        LOGGER.info("V2 compatibility mode switch test completed 
successfully");
+    }
+
+    /**
+     * Test behavior in V3 mode (default):
+     * - V2 requests (no auth) should be rejected
+     * - V3 requests with proper authentication should work
+     */
+    private void testV3ModeBehavior() throws Exception {
+        // Test V2-style request (no authentication) - should be rejected
+        ContextRequest contextRequest = new ContextRequest();
+        contextRequest.setSessionId(TEST_SESSION_ID);
+
+        HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        TestUtils.RequestResponse response = 
TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID, 401, false);
+        assertEquals("V2-style request should be rejected in V3 mode", 401, 
response.getStatusCode());
+
+        // Test V3-style request with public API key - should work
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.addHeader(UNOMI_API_KEY_HEADER, testPublicKey.getKey());
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        response = TestUtils.executeContextJSONRequest(request, 
TEST_SESSION_ID);
+        assertEquals("V3-style request with public API key should work in V3 
mode", 200, response.getStatusCode());
+
+        // Test V3-style request with private API key - should work
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(request, testTenant, testPrivateKey);
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        response = TestUtils.executeContextJSONRequest(request, 
TEST_SESSION_ID);
+        assertEquals("V3-style request with private API key should work in V3 
mode", 200, response.getStatusCode());
+
+        // Test V3-style request with JAAS authentication - should work
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        request.addHeader(UNOMI_TENANT_ID_HEADER, testTenant.getItemId());
+        request.setEntity(new 
StringEntity(objectMapper.writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+
+        BasicCredentialsProvider credsProvider = new 
BasicCredentialsProvider();
+        credsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials("karaf", "karaf"));
+
+        RequestConfig requestConfig = RequestConfig.custom()
+                .setAuthenticationEnabled(true)
+                
.setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.BASIC))
+                .build();
+
+        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();
+    }

Review Comment:
   The `CloseableHttpResponse` from the JAAS-authenticated request isn’t 
closed. Over a full PaxExam suite this can leak connections/file descriptors 
and make tests flaky.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to