From 3085fe304978e29359c86b51ca54b58b48e55d4e Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Mon, 18 May 2026 20:53:13 +0200 Subject: [PATCH 1/6] UNOMI-139/880/878/884: Multi-tenant platform core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the Unomi 3.1 platform core: tenant-scoped execution, unified multi-type caching, cluster-aware scheduling, and 3.1.0 migration tooling. REST layers use V3 tenant resolution and role-based auth. UNOMI-139 — Multi-tenancy - Tenant model and APIs (Tenant, TenantService, API keys, security and audit hooks, quotas, lifecycle listeners) - ExecutionContextManager and tenant propagation across services, persistence (Elasticsearch/OpenSearch), GraphQL, and REST - Tenant-scoped items and definitions (system tenant vs tenant overrides) UNOMI-880 — Unified multi-tenant caching - MultiTypeCacheService, CacheableTypeConfig, and shared cache lifecycle (refresh, statistics, predefined item bundling) - Migrate DefinitionsService and SegmentService onto the unified cache; remove superseded ad-hoc cache listeners UNOMI-878 — Cluster-aware task scheduling - SchedulerService with persistent and in-memory tasks, TaskExecutor registration, and cluster-aware execution - Integrate periodic work (cache refresh, cluster heartbeat, router jobs, rule refresh) via the new scheduler API UNOMI-884 — Migration to V3 - 3.1.0 migration Groovy scripts and Elasticsearch request bodies (tenant document IDs, system item ID fixes, tenant initialization, legacy queryBuilder updates) - MigrationUtils extensions and MigrationUtilsTest Integration tests updated for platform behaviour (including migration and ScopeIT). Verified locally with OpenSearch integration tests. --- .../unomi/router/core/context/RouterCamelContext.java | 8 -------- 1 file changed, 8 deletions(-) diff --git a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java index 0f3c682ad..d8c59857a 100644 --- a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java +++ b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java @@ -67,9 +67,6 @@ * *

* - *

Dependency-injection setters on this class are intended for OSGi/Blueprint wiring and are not part of the - * {@link IRouterCamelContext} API surface.

- * * @since 1.0 */ public class RouterCamelContext implements IRouterCamelContext { @@ -144,11 +141,6 @@ public void init() throws Exception { LOGGER.info("Camel Context initialized successfully."); } - /** - * Stops the configuration refresh scheduler and shuts down the Camel context (all routes and components). - * - * @throws Exception if Camel shutdown fails - */ public void destroy() throws Exception { if (scheduledTask != null) { schedulerService.cancelTask(scheduledTask.getItemId()); From 2e6553e48e0469bca483bca0681e353d7cc2da5a Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Tue, 19 May 2026 06:22:59 +0200 Subject: [PATCH 2/6] UNOMI-904: V2 API compatibility mode Let V2 client applications run on the V3 multi-tenant platform without rewriting authentication first: compatibility mode can be toggled at runtime, legacy third-party API keys and IP/X-Unomi-Peer checks apply for protected events, and public endpoints such as /context.json stay usable without tenant API keys while V3 tenant auth remains the default when the mode is off. - V2ThirdPartyConfigService wired to org.apache.unomi.thirdparty.cfg - REST authentication filter and config for V2 compatibility routing - Event collector allowlist checks in RestServiceUtilsImpl - Karaf packaging for org.apache.unomi.rest.authentication.cfg - V2CompatibilityModeIT in the integration test suite --- .../java/org/apache/unomi/itests/AllITs.java | 1 + .../unomi/itests/V2CompatibilityModeIT.java | 435 ++++++++++++++++++ kar/src/main/feature/feature.xml | 1 + .../resources/etc/custom.system.properties | 6 + rest/pom.xml | 24 + .../authentication/AuthenticationFilter.java | 67 +++ .../RestAuthenticationConfig.java | 19 + .../V2ThirdPartyConfigService.java | 259 +++++++++++ .../impl/DefaultRestAuthenticationConfig.java | 58 ++- .../service/impl/RestServiceUtilsImpl.java | 65 ++- .../org.apache.unomi.rest.authentication.cfg | 31 ++ 11 files changed, 960 insertions(+), 6 deletions(-) create mode 100644 itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java create mode 100644 rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java create mode 100644 rest/src/main/resources/org.apache.unomi.rest.authentication.cfg diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java b/itests/src/test/java/org/apache/unomi/itests/AllITs.java index 2be7f0641..2e835cf0a 100644 --- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java +++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java @@ -64,6 +64,7 @@ GraphQLProfileAliasesIT.class, SendEventActionIT.class, ScopeIT.class, + V2CompatibilityModeIT.class, HealthCheckIT.class, LegacyQueryBuilderMappingIT.class, }) diff --git a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java new file mode 100644 index 000000000..a41d959d9 --- /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 v2Config = new HashMap<>(); + v2Config.put("v2.compatibilitymode.enabled", false); // Start in V3 mode + v2Config.put("v2.compatibilitymode.defaultTenantId", TEST_TENANT_ID); // Use BaseIT tenant + + updateConfiguration(null, + "org.apache.unomi.rest.authentication", + v2Config); + + // Wait for configuration to be applied + keepTrying("V2 compatibility configuration not applied in the required time", + () -> restAuthenticationConfig.getV2CompatibilityDefaultTenantId(), + tenantId -> TEST_TENANT_ID.equals(tenantId), DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + // Create test profile + Profile profile = new Profile(TEST_PROFILE_ID); + profileService.save(profile); + + keepTrying("Profile " + TEST_PROFILE_ID + " not found in the required time", + () -> profileService.load(TEST_PROFILE_ID), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + } + + @After + public void tearDown() throws InterruptedException, IOException { + try { + // Restore original V2 mode setting and default tenant ID + Map originalConfig = new HashMap<>(); + originalConfig.put("v2.compatibilitymode.enabled", originalV2Mode); + if (originalDefaultTenantId != null) { + originalConfig.put("v2.compatibilitymode.defaultTenantId", originalDefaultTenantId); + } + + updateConfiguration(null, + "org.apache.unomi.rest.authentication", + originalConfig); + } catch (Exception e) { + LOGGER.warn("Failed to restore original V2 mode setting", e); + } + + // Clean up test data + try { + TestUtils.removeAllEvents(definitionsService, persistenceService, true, tenantService, executionContextManager); + TestUtils.removeAllSessions(definitionsService, persistenceService, true, tenantService, executionContextManager); + TestUtils.removeAllProfiles(definitionsService, persistenceService, true, tenantService, executionContextManager); + + profileService.delete(TEST_PROFILE_ID, false); + removeItems(Session.class); + + scopeService.delete(TEST_SCOPE); + } catch (Exception e) { + LOGGER.warn("Failed to clean up test data", e); + } + + + } + + @Test + public void testV2CompatibilityModeSwitch() throws Exception { + LOGGER.info("Starting V2 compatibility mode switch test"); + + // STEP 1: Test V3 mode (default) - V2 requests should be rejected, V3 requests should work + LOGGER.info("STEP 1: Testing V3 mode (default)"); + testV3ModeBehavior(); + + // STEP 2: Switch to V2 compatibility mode + LOGGER.info("STEP 2: Switching to V2 compatibility mode"); + updateConfiguration(null, + "org.apache.unomi.rest.authentication", + "v2.compatibilitymode.enabled", + true); + + // Wait for configuration to take effect + keepTrying("V2 compatibility mode not enabled in the required time", + () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(), + enabled -> enabled, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + // STEP 3: Test V2 mode - V2 requests should work, V3 requests should be rejected + LOGGER.info("STEP 3: Testing V2 compatibility mode"); + testV2ModeBehavior(); + + // STEP 4: Switch back to V3 mode + LOGGER.info("STEP 4: Switching back to V3 mode"); + updateConfiguration(null, + "org.apache.unomi.rest.authentication", + "v2.compatibilitymode.enabled", + false); + + // Wait for configuration to take effect + keepTrying("V2 compatibility mode not disabled in the required time", + () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(), + enabled -> !enabled, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + // STEP 5: Test V3 mode again - V2 requests should be rejected, V3 requests should work + LOGGER.info("STEP 5: Testing V3 mode again"); + testV3ModeBehavior(); + + LOGGER.info("V2 compatibility mode switch test completed successfully"); + } + + /** + * Test behavior in V3 mode (default): + * - V2 requests (no auth) should be rejected + * - V3 requests with proper authentication should work + */ + private void testV3ModeBehavior() throws Exception { + // Test V2-style request (no authentication) - should be rejected + ContextRequest contextRequest = new ContextRequest(); + contextRequest.setSessionId(TEST_SESSION_ID); + + HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL)); + request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); + TestUtils.RequestResponse response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID, 401, false); + assertEquals("V2-style request should be rejected in V3 mode", 401, response.getStatusCode()); + + // Test V3-style request with public API key - should work + request = new HttpPost(getFullUrl(CONTEXT_URL)); + request.addHeader(UNOMI_API_KEY_HEADER, testPublicKey.getKey()); + request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); + response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID); + assertEquals("V3-style request with public API key should work in V3 mode", 200, response.getStatusCode()); + + // Test V3-style request with private API key - should work + request = new HttpPost(getFullUrl(CONTEXT_URL)); + addPrivateTenantAuth(request, testTenant, testPrivateKey); + request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); + response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID); + assertEquals("V3-style request with private API key should work in V3 mode", 200, response.getStatusCode()); + + // Test V3-style request with JAAS authentication - should work + request = new HttpPost(getFullUrl(CONTEXT_URL)); + request.addHeader(UNOMI_TENANT_ID_HEADER, testTenant.getItemId()); + request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); + + BasicCredentialsProvider credsProvider = new BasicCredentialsProvider(); + credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf")); + + RequestConfig requestConfig = RequestConfig.custom() + .setAuthenticationEnabled(true) + .setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.BASIC)) + .build(); + + 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", + "v2.compatibilitymode.enabled", + true); + + keepTrying("V2 compatibility mode not enabled in the required time", + () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(), + enabled -> enabled, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + // Test protected event (login) without V2 third-party authentication - should be rejected + Event loginEvent = new Event(); + loginEvent.setEventType("login"); + loginEvent.setScope(TEST_SCOPE); + + ContextRequest contextRequest = new ContextRequest(); + contextRequest.setSessionId(TEST_SESSION_ID); + contextRequest.setEvents(Arrays.asList(loginEvent)); + + HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL)); + request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); + TestUtils.RequestResponse response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID, 200, false); + assertEquals("Protected event without V2 auth should return 200", 200, response.getStatusCode()); + assertEquals("Protected event without V2 auth should have 0 processed events", 0, response.getContextResponse().getProcessedEvents()); + + // Test protected event with V2 third-party authentication - should work + request = new HttpPost(getFullUrl(CONTEXT_URL)); + request.addHeader(UNOMI_PEER_HEADER, "670c26d1cc413346c3b2fd9ce65dab41"); + request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); + response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID); + assertEquals("Protected event with V2 auth should work", 200, response.getStatusCode()); + assertEquals("Protected event with V2 auth should have 1 processed event", 1, response.getContextResponse().getProcessedEvents()); + + // Test protected event with empty X-Unomi-Peer header - should be rejected + request = new HttpPost(getFullUrl(CONTEXT_URL)); + request.addHeader(UNOMI_PEER_HEADER, ""); + request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); + response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID); + assertEquals("Protected event with empty X-Unomi-Peer should return 200", 200, response.getStatusCode()); + assertEquals("Protected event with empty X-Unomi-Peer should have 0 processed events", 0, response.getContextResponse().getProcessedEvents()); + + // Test non-protected event (view) without authentication - should work + // Load the view event from JSON file + String contextRequestJson = resourceAsString("events/viewEvent.json"); + + // Replace the session ID with the test session ID + contextRequestJson = contextRequestJson.replace("test-session-id", TEST_SESSION_ID); + contextRequestJson = contextRequestJson.replace("testScope", TEST_SCOPE); + + request = new HttpPost(getFullUrl(CONTEXT_URL)); + request.setEntity(new StringEntity(contextRequestJson, ContentType.APPLICATION_JSON)); + response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID); + assertEquals("Non-protected event without auth should work in V2 mode", 200, response.getStatusCode()); + assertEquals("Non-protected event without auth should have 1 processed event", 1, response.getContextResponse().getProcessedEvents()); + } + + @Test + public void testV2CompatibilityModeDefaultTenant() throws Exception { + LOGGER.info("Testing V2 compatibility mode default tenant behavior"); + + // Verify the configuration was applied correctly in setUp() + assertEquals("Default tenant should be set to BaseIT tenant", TEST_TENANT_ID, restAuthenticationConfig.getV2CompatibilityDefaultTenantId()); + + // Switch to V2 compatibility mode + updateConfiguration(null, + "org.apache.unomi.rest.authentication", + "v2.compatibilitymode.enabled", + true); + + keepTrying("V2 compatibility mode not enabled in the required time", + () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(), + enabled -> enabled, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + // Verify the configuration was applied + assertTrue("V2 compatibility mode should be enabled", restAuthenticationConfig.isV2CompatibilityModeEnabled()); + assertEquals("Default tenant should be set to BaseIT tenant", TEST_TENANT_ID, restAuthenticationConfig.getV2CompatibilityDefaultTenantId()); + + // Test that requests work with the BaseIT tenant as default + ContextRequest contextRequest = new ContextRequest(); + contextRequest.setSessionId(TEST_SESSION_ID); + + HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL)); + request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); + TestUtils.RequestResponse response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID); + assertEquals("V2-style request should work with BaseIT tenant as default", 200, response.getStatusCode()); + } + + @Test + public void testV2CompatibilityModeConfigurationPersistence() throws Exception { + LOGGER.info("Testing V2 compatibility mode configuration persistence"); + + // Test that configuration changes persist across service updates + updateConfiguration(null, + "org.apache.unomi.rest.authentication", + "v2.compatibilitymode.enabled", + true); + + keepTrying("V2 compatibility mode not enabled in the required time", + () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(), + enabled -> enabled, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + // Verify configuration is applied + assertTrue("V2 compatibility mode should be enabled", restAuthenticationConfig.isV2CompatibilityModeEnabled()); + assertEquals("Default tenant should persist", TEST_TENANT_ID, restAuthenticationConfig.getV2CompatibilityDefaultTenantId()); + + // Update services to simulate service restart + updateServices(); + + // Verify configuration persists + assertTrue("V2 compatibility mode should persist after service update", restAuthenticationConfig.isV2CompatibilityModeEnabled()); + assertEquals("Default tenant should persist after service update", TEST_TENANT_ID, restAuthenticationConfig.getV2CompatibilityDefaultTenantId()); + + // Test that behavior is still correct + ContextRequest contextRequest = new ContextRequest(); + contextRequest.setSessionId(TEST_SESSION_ID); + + HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL)); + request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); + TestUtils.RequestResponse response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID); + assertEquals("V2-style request should still work after service update", 200, response.getStatusCode()); + } + + 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/kar/src/main/feature/feature.xml b/kar/src/main/feature/feature.xml index d6768233b..2ded970f2 100644 --- a/kar/src/main/feature/feature.xml +++ b/kar/src/main/feature/feature.xml @@ -129,6 +129,7 @@ unomi-services unomi-cxs-privacy-extension-services mvn:org.apache.unomi/unomi-json-schema-services/${project.version}/cfg/schemacfg + mvn:org.apache.unomi/unomi-rest/${project.version}/cfg/restauth mvn:org.apache.unomi/unomi-json-schema-services/${project.version} mvn:org.apache.unomi/unomi-rest/${project.version} mvn:org.apache.unomi/unomi-json-schema-rest/${project.version} diff --git a/package/src/main/resources/etc/custom.system.properties b/package/src/main/resources/etc/custom.system.properties index 0f9a70cf1..f6385658b 100644 --- a/package/src/main/resources/etc/custom.system.properties +++ b/package/src/main/resources/etc/custom.system.properties @@ -511,3 +511,9 @@ karaf.local.roles = admin,manager,viewer,systembundles,ROLE_UNOMI_ADMIN,ROLE_UNO ####################################################################################################################### org.apache.unomi.goals.refresh.interval=${env:UNOMI_GOALS_REFRESH_INTERVAL:-5000} org.apache.unomi.campaigns.refresh.interval=${env:UNOMI_CAMPAIGNS_REFRESH_INTERVAL:-5000} + +####################################################################################################################### +## REST API Authorization Settings ## +####################################################################################################################### +org.apache.unomi.rest.authentication.v2CompatibilityModeEnabled=${env:UNOMI_REST_AUTHENTICATION_V2COMPATIBILITYMODEENABLED:-false} +org.apache.unomi.rest.authentication.v2CompatibilityDefaultTenantId=${env:UNOMI_REST_AUTHENTICATION_V2COMPATIBILITYDEFAULTTENANTID:-default} diff --git a/rest/pom.xml b/rest/pom.xml index 2c973eabe..315b9bbac 100644 --- a/rest/pom.xml +++ b/rest/pom.xml @@ -191,6 +191,30 @@ + + org.codehaus.mojo + build-helper-maven-plugin + + + attach-artifacts + package + + attach-artifact + + + + + + src/main/resources/org.apache.unomi.rest.authentication.cfg + + cfg + restauth + + + + + + org.apache.felix maven-bundle-plugin diff --git a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java index 47b0486dc..bfc88f4b1 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 void filter(ContainerRequestContext requestContext) throws IOException { try { String path = requestContext.getUriInfo().getPath(); + // Check if V2 compatibility mode is enabled + if (restAuthenticationConfig.isV2CompatibilityModeEnabled()) { + handleV2CompatibilityMode(requestContext, path); + return; + } + // Tenant endpoints require JAAS authentication only if (path.startsWith("tenants")) { String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION); @@ -239,6 +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); + + // 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 ipAddresses; + private final Set allowedEvents; + + public ProviderConfig(String key, Set ipAddresses, Set allowedEvents) { + this.key = key; + this.ipAddresses = ipAddresses; + this.allowedEvents = allowedEvents; + } + + public String getKey() { return key; } + public Set getIpAddresses() { return ipAddresses; } + public Set getAllowedEvents() { return allowedEvents; } + } + + private volatile Map providers = new HashMap<>(); + + @Activate + public void activate(Map properties) { + modified(properties); + } + + @Modified + public void modified(Map properties) { + Map newProviders = new HashMap<>(); + + if (properties != null) { + // Parse all provider configurations dynamically + for (Map.Entry 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 configIpAddresses = existingConfig != null ? existingConfig.getIpAddresses() : new HashSet<>(); + Set 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 getAllProtectedEventTypes() { + Set allProtectedEvents = new HashSet<>(); + for (ProviderConfig config : providers.values()) { + allProtectedEvents.addAll(config.getAllowedEvents()); + } + return Collections.unmodifiableSet(allProtectedEvents); + } + + + /** + * Validate a third-party provider by key for a given event type. + * This method is used when the X-Unomi-Peer header contains the provider key. + * + * @param providerKey the third-party provider key (from X-Unomi-Peer header) + * @param eventType the event type to validate + * @param sourceIP the source IP address + * @return true if the provider is authorized for this event type and IP, false otherwise + */ + public boolean validateProviderByKey(String providerKey, String eventType, String sourceIP) { + if (StringUtils.isBlank(providerKey) || StringUtils.isBlank(eventType) || StringUtils.isBlank(sourceIP)) { + return false; + } + + // Find the provider that has the matching key + ProviderConfig config = null; + String foundProviderId = null; + for (Map.Entry entry : providers.entrySet()) { + if (providerKey.equals(entry.getValue().getKey())) { + config = entry.getValue(); + foundProviderId = entry.getKey(); + break; + } + } + + if (config == null) { + LOGGER.debug("V2 compatibility mode: Unknown provider key: {}", 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 parseCommaSeparatedList(String value) { + if (StringUtils.isBlank(value)) { + return new HashSet<>(); + } + + Set result = new HashSet<>(); + String[] parts = value.split(","); + for (String part : parts) { + String trimmed = part.trim(); + if (StringUtils.isNotBlank(trimmed)) { + result.add(trimmed); + } + } + return result; + } + + +} diff --git a/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java b/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java index d65cdfa0e..ea6690140 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,12 @@ 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; @@ -26,9 +32,10 @@ /** * 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 { + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultRestAuthenticationConfig.class); private static final String GUEST_ROLES = UnomiRoles.USER; private static final String ADMIN_ROLES = UnomiRoles.ADMINISTRATOR; private static final String TENANT_ADMIN_ROLES = UnomiRoles.ADMINISTRATOR + " " + UnomiRoles.TENANT_ADMINISTRATOR; @@ -63,6 +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; + } + + @Override public List getPublicPathPatterns() { return PUBLIC_PATH_PATTERNS; @@ -77,4 +104,33 @@ public Map getMethodRolesMap() { public String getGlobalRoles() { return TENANT_ADMIN_ROLES; } + + @Override + public boolean isV2CompatibilityModeEnabled() { + return v2CompatibilityModeEnabled; + } + + @Override + public String getV2CompatibilityDefaultTenantId() { + return v2CompatibilityDefaultTenantId; + } + + @ObjectClassDefinition( + name = "Unomi REST Authentication Configuration", + description = "Configuration for Unomi REST authentication including V2 compatibility mode" + ) + public @interface Config { + + @AttributeDefinition( + name = "V2 Compatibility Mode Enabled", + description = "Enable V2 compatibility mode to allow V2 clients to use Unomi V3 without API keys" + ) + boolean v2_compatibilitymode_enabled() default false; + + @AttributeDefinition( + name = "V2 Compatibility Default Tenant ID", + description = "Default tenant ID to use in V2 compatibility mode" + ) + String v2_compatibilitymode_defaultTenantId() default "default"; + } } diff --git a/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java b/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java index a8ca7d9f3..f1dfb466b 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.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; @@ -80,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; @@ -269,11 +277,22 @@ public EventsRequestContext performEventsRequest(List events, EventsReque Event eventToSend = new Event(event.getEventType(), eventsRequestContext.getSession(), eventsRequestContext.getProfile(), event.getScope(), event.getSource(), event.getTarget(), event.getProperties(), eventsRequestContext.getTimestamp(), event.isPersistent()); eventToSend.setFlattenedProperties(event.getFlattenedProperties()); - if (!eventService.isEventAllowedForTenant(event, tenantId, eventsRequestContext.getRequest().getRemoteAddr())) { - LOGGER.debug("Tenant is not authorized to send event {} from IP {}", event.getEventType(), eventsRequestContext.getRequest().getRemoteAddr()); - //Don't count the event that failed - eventsRequestContext.setProcessedItems(eventsRequestContext.getProcessedItems() - 1); - continue; + // Check if V2 compatibility mode is enabled and handle V2-style event authorization + if (restAuthenticationConfig.isV2CompatibilityModeEnabled()) { + if (!isEventAllowedInV2CompatibilityMode(event, eventsRequestContext.getRequest())) { + LOGGER.debug("Event {} not authorized in V2 compatibility mode from IP {}", event.getEventType(), eventsRequestContext.getRequest().getRemoteAddr()); + //Don't count the event that failed + eventsRequestContext.setProcessedItems(eventsRequestContext.getProcessedItems() - 1); + continue; + } + } else { + // Normal V3 event authorization + if (!eventService.isEventAllowedForTenant(event, tenantId, eventsRequestContext.getRequest().getRemoteAddr())) { + LOGGER.debug("Tenant is not authorized to send event {} from IP {}", event.getEventType(), eventsRequestContext.getRequest().getRemoteAddr()); + //Don't count the event that failed + eventsRequestContext.setProcessedItems(eventsRequestContext.getProcessedItems() - 1); + continue; + } } if (securityContext.isUserInRole(UnomiRoles.TENANT_ADMINISTRATOR) && event.getItemId() != null) { eventToSend = new Event(event.getItemId(), event.getEventType(), eventsRequestContext.getSession(), eventsRequestContext.getProfile(), event.getScope(), @@ -376,4 +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); + return true; + } } diff --git a/rest/src/main/resources/org.apache.unomi.rest.authentication.cfg b/rest/src/main/resources/org.apache.unomi.rest.authentication.cfg new file mode 100644 index 000000000..db79d2627 --- /dev/null +++ b/rest/src/main/resources/org.apache.unomi.rest.authentication.cfg @@ -0,0 +1,31 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Unomi REST Authentication Configuration +# This file configures authentication settings for Unomi REST endpoints + +# V2 Compatibility Mode +# When enabled, allows V2 clients to use Unomi V3 without requiring API keys +# - Public endpoints (like /context.json) require no authentication (like V2) +# - Private endpoints require system administrator authentication (like V2) +# - A default tenant is automatically used for all operations +v2.compatibilitymode.enabled = ${org.apache.unomi.rest.authentication.v2CompatibilityModeEnabled:-false} + +# V2 Compatibility Default Tenant ID +# Default tenant ID to use in V2 compatibility mode +# This tenant will be used for all operations when V2 compatibility mode is enabled +# Should match the tenant ID used during migration (e.g., "default" or "system") +v2.compatibilitymode.defaultTenantId = ${org.apache.unomi.rest.authentication.v2CompatibilityDefaultTenantId:-default} From e8c92473c4968c71aa88864c066236c13fa41049 Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Tue, 19 May 2026 08:21:22 +0200 Subject: [PATCH 3/6] UNOMI-904: Add viewEvent.json fixture for V2CompatibilityModeIT testV2CompatibilityModeWithProtectedEvents loads events/viewEvent.json; resource was missing after V peel (Appendix J peel-fix). --- .../src/test/resources/events/viewEvent.json | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 itests/src/test/resources/events/viewEvent.json 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": {} + } + ] +} From 04954031fa58803ac81d32863000a4ceada8c615 Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Sat, 6 Jun 2026 10:36:51 +0200 Subject: [PATCH 4/6] UNOMI-904: Address Copilot review feedback on V2 compatibility mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix ordering-dependent data loss in V2ThirdPartyConfigService.modified() by using a two-pass approach: collect raw properties first, then build ProviderConfig objects (OSGi ConfigAdmin property order is undefined) - Mask X-Unomi-Peer secret in debug logs (RestServiceUtilsImpl, V2ThirdPartyConfigService) using a maskSecret() helper - Add @Designate annotation to DefaultRestAuthenticationConfig so OSGi metatype descriptors are generated correctly by bnd - Trim and blank-check v2CompatibilityDefaultTenantId in DefaultRestAuthenticationConfig, falling back to "default" if blank - Replace null checks with StringUtils.isNotBlank() for defaultTenantId in AuthenticationFilter.handleV2CompatibilityMode() (both public-path and JAAS sections) - Wrap CloseableHttpClient/CloseableHttpResponse pairs in try-with-resources in V2CompatibilityModeIT to avoid connection pool leaks - Fix misleading test comments: V3 API keys in V2 mode are ignored, not rejected — requests return 200 with 0 processed events --- .../unomi/itests/V2CompatibilityModeIT.java | 22 +++---- .../authentication/AuthenticationFilter.java | 6 +- .../V2ThirdPartyConfigService.java | 63 +++++++++---------- .../impl/DefaultRestAuthenticationConfig.java | 16 ++++- .../service/impl/RestServiceUtilsImpl.java | 9 ++- 5 files changed, 64 insertions(+), 52 deletions(-) diff --git a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java index a41d959d9..b7931572b 100644 --- a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java @@ -227,14 +227,13 @@ private void testV3ModeBehavior() throws Exception { .setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.BASIC)) .build(); - CloseableHttpClient adminClient = HttpClients.custom() + try (CloseableHttpClient adminClient = HttpClients.custom() .setDefaultCredentialsProvider(credsProvider) .setDefaultRequestConfig(requestConfig) .build(); - - CloseableHttpResponse jaasResponse = adminClient.execute(request); - assertEquals("V3-style request with JAAS auth should work in V3 mode", 200, jaasResponse.getStatusLine().getStatusCode()); - adminClient.close(); + CloseableHttpResponse jaasResponse = adminClient.execute(request)) { + assertEquals("V3-style request with JAAS auth should work in V3 mode", 200, jaasResponse.getStatusLine().getStatusCode()); + } } /** @@ -259,7 +258,7 @@ private void testV2ModeBehavior() throws Exception { 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 + // Test V3-style request with public API key - in V2 mode, V3 API keys are ignored (request succeeds but no events processed) request = new HttpPost(getFullUrl(CONTEXT_URL)); request.addHeader(UNOMI_API_KEY_HEADER, testPublicKey.getKey()); request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); @@ -267,7 +266,7 @@ private void testV2ModeBehavior() throws Exception { 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 + // Test V3-style request with private API key - in V2 mode, V3 API keys are ignored (request succeeds but no events processed) request = new HttpPost(getFullUrl(CONTEXT_URL)); addPrivateTenantAuth(request, testTenant, testPrivateKey); request.setEntity(new StringEntity(objectMapper.writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); @@ -286,14 +285,13 @@ private void testV2ModeBehavior() throws Exception { .setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.BASIC)) .build(); - CloseableHttpClient adminClient = HttpClients.custom() + try (CloseableHttpClient adminClient = HttpClients.custom() .setDefaultCredentialsProvider(credsProvider) .setDefaultRequestConfig(requestConfig) .build(); - - CloseableHttpResponse jaasResponse = adminClient.execute(getRequest); - assertEquals("Private endpoint with JAAS auth should work in V2 compatibility mode", 200, jaasResponse.getStatusLine().getStatusCode()); - adminClient.close(); + CloseableHttpResponse jaasResponse = adminClient.execute(getRequest)) { + assertEquals("Private endpoint with JAAS auth should work in V2 compatibility mode", 200, jaasResponse.getStatusLine().getStatusCode()); + } } @Test 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 bfc88f4b1..1fdeff342 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 @@ -41,6 +41,8 @@ import javax.ws.rs.container.PreMatching; import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.Response; +import org.apache.commons.lang3.StringUtils; + import java.io.IOException; import java.util.Base64; import java.util.Collections; @@ -257,7 +259,7 @@ private void handleV2CompatibilityMode(ContainerRequestContext requestContext, S // For public paths, allow access without authentication (like V2) if (isPublicPath(requestContext)) { String defaultTenantId = restAuthenticationConfig.getV2CompatibilityDefaultTenantId(); - if (defaultTenantId != null) { + if (StringUtils.isNotBlank(defaultTenantId)) { // Create a guest subject for public endpoints Subject subject = securityService.createSubject(defaultTenantId, false); @@ -288,7 +290,7 @@ private void handleV2CompatibilityMode(ContainerRequestContext requestContext, S // In V2 compatibility mode, use the default tenant for all operations String defaultTenantId = restAuthenticationConfig.getV2CompatibilityDefaultTenantId(); - if (defaultTenantId != null) { + if (StringUtils.isNotBlank(defaultTenantId)) { executionContextManager.setCurrentContext(executionContextManager.createContext(defaultTenantId)); } else { executionContextManager.setCurrentContext(ExecutionContext.systemContext()); 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 index 954493f72..08d1cad8e 100644 --- a/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java +++ b/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java @@ -87,44 +87,36 @@ public void activate(Map properties) { @Modified public void modified(Map properties) { Map newProviders = new HashMap<>(); - + if (properties != null) { - // Parse all provider configurations dynamically + // Phase 1: collect raw property values per provider, order-independent + Map> rawProviders = new HashMap<>(); for (Map.Entry entry : properties.entrySet()) { - String key = entry.getKey(); + String propKey = 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 (propKey.startsWith("thirdparty.") && propKey.contains(".")) { + String[] parts = propKey.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 configIpAddresses = existingConfig != null ? existingConfig.getIpAddresses() : new HashSet<>(); - Set 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)); - } + rawProviders.computeIfAbsent(providerName, k -> new HashMap<>()).put(property, value); } } } + + // Phase 2: build ProviderConfig objects — only for providers that have a key + for (Map.Entry> entry : rawProviders.entrySet()) { + String providerName = entry.getKey(); + Map props = entry.getValue(); + String configKey = props.get("key"); + if (StringUtils.isNotBlank(configKey)) { + Set configIpAddresses = parseCommaSeparatedList(props.getOrDefault("ipAddresses", "")); + Set configAllowedEvents = parseCommaSeparatedList(props.getOrDefault("allowedEvents", "")); + newProviders.put(providerName, new ProviderConfig(configKey, configIpAddresses, configAllowedEvents)); + } + } } // Set default provider1 if no providers configured @@ -201,18 +193,18 @@ public boolean validateProviderByKey(String providerKey, String eventType, Strin } if (config == null) { - LOGGER.debug("V2 compatibility mode: Unknown provider key: {}", providerKey); + LOGGER.debug("V2 compatibility mode: Unknown provider key: {}", maskSecret(providerKey)); return false; } - + if (!config.getAllowedEvents().contains(eventType)) { - LOGGER.debug("V2 compatibility mode: Event type {} not allowed for provider {} (key: {})", eventType, foundProviderId, providerKey); + LOGGER.debug("V2 compatibility mode: Event type {} not allowed for provider {} (key: {})", eventType, foundProviderId, maskSecret(providerKey)); return false; } - + boolean ipAuthorized = IPValidationUtils.isIpAuthorized(sourceIP, config.getIpAddresses()); if (!ipAuthorized) { - LOGGER.debug("V2 compatibility mode: IP {} not authorized for provider {} (key: {})", sourceIP, foundProviderId, providerKey); + LOGGER.debug("V2 compatibility mode: IP {} not authorized for provider {} (key: {})", sourceIP, foundProviderId, maskSecret(providerKey)); } return ipAuthorized; @@ -239,6 +231,11 @@ public boolean isValidProvider(String providerId) { return providers.containsKey(providerId); } + private static String maskSecret(String secret) { + if (secret == null || secret.length() <= 4) return "****"; + return secret.substring(0, 4) + "****"; + } + private Set parseCommaSeparatedList(String value) { if (StringUtils.isBlank(value)) { return new HashSet<>(); 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 ea6690140..801dce2bb 100644 --- a/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java +++ b/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java @@ -16,12 +16,14 @@ */ package org.apache.unomi.rest.authentication.impl; +import org.apache.commons.lang3.StringUtils; import org.apache.unomi.api.security.UnomiRoles; import org.apache.unomi.rest.authentication.RestAuthenticationConfig; -import org.osgi.service.component.annotations.Component; 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; @@ -33,6 +35,7 @@ * Default implementation for the unomi authentication on Rest endpoints */ @Component(service = { RestAuthenticationConfig.class}, configurationPid = "org.apache.unomi.rest.authentication", immediate = true) +@Designate(ocd = DefaultRestAuthenticationConfig.Config.class) public class DefaultRestAuthenticationConfig implements RestAuthenticationConfig { private static final Logger LOGGER = LoggerFactory.getLogger(DefaultRestAuthenticationConfig.class); @@ -83,10 +86,17 @@ public void modified(Config config) { } boolean v2Mode = config.v2_compatibilitymode_enabled(); String defaultTenant = config.v2_compatibilitymode_defaultTenantId(); + if (defaultTenant != null) { + defaultTenant = defaultTenant.trim(); + } + if (StringUtils.isBlank(defaultTenant)) { + LOGGER.warn("v2CompatibilityDefaultTenantId is blank, falling back to 'default'"); + defaultTenant = "default"; + } LOGGER.info("Configuration updated - v2CompatibilityModeEnabled: {}, v2CompatibilityDefaultTenantId: {}", v2Mode, defaultTenant); - this.v2CompatibilityModeEnabled = v2Mode; - this.v2CompatibilityDefaultTenantId = defaultTenant; + this.v2CompatibilityModeEnabled = v2Mode; + this.v2CompatibilityDefaultTenantId = defaultTenant; } 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 f1dfb466b..7ee15162a 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 @@ -423,12 +423,17 @@ private boolean isEventAllowedInV2CompatibilityMode(Event event, HttpServletRequ // 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); + event.getEventType(), maskSecret(thirdPartyKey), sourceIP); return false; } LOGGER.debug("V2 compatibility mode: Protected event {} allowed for provider key: {} from IP: {}", - event.getEventType(), thirdPartyKey, sourceIP); + event.getEventType(), maskSecret(thirdPartyKey), sourceIP); return true; } + + private static String maskSecret(String secret) { + if (secret == null || secret.length() <= 4) return "****"; + return secret.substring(0, 4) + "****"; + } } From a84b3a0ac006dca8db39248abb9d6d06af45ccfb Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Sat, 6 Jun 2026 11:26:49 +0200 Subject: [PATCH 5/6] UNOMI-904: Extract maskSecret into shared SecurityUtils to eliminate duplication Move the maskSecret() helper from V2ThirdPartyConfigService and RestServiceUtilsImpl into a new SecurityUtils class in services-common, which is already the home for security utilities like IPValidationUtils. Add unit tests covering null, empty, short, boundary, and typical 32-char shared-secret inputs. --- .../V2ThirdPartyConfigService.java | 12 ++-- .../service/impl/RestServiceUtilsImpl.java | 9 +-- .../common/security/SecurityUtils.java | 42 +++++++++++++ .../common/security/SecurityUtilsTest.java | 63 +++++++++++++++++++ 4 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 services-common/src/main/java/org/apache/unomi/services/common/security/SecurityUtils.java create mode 100644 services-common/src/test/java/org/apache/unomi/services/common/security/SecurityUtilsTest.java 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 index 08d1cad8e..b72f5c4dc 100644 --- a/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java +++ b/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java @@ -19,6 +19,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.unomi.services.common.security.IPValidationUtils; +import org.apache.unomi.services.common.security.SecurityUtils; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Modified; @@ -193,18 +194,18 @@ public boolean validateProviderByKey(String providerKey, String eventType, Strin } if (config == null) { - LOGGER.debug("V2 compatibility mode: Unknown provider key: {}", maskSecret(providerKey)); + LOGGER.debug("V2 compatibility mode: Unknown provider key: {}", SecurityUtils.maskSecret(providerKey)); return false; } if (!config.getAllowedEvents().contains(eventType)) { - LOGGER.debug("V2 compatibility mode: Event type {} not allowed for provider {} (key: {})", eventType, foundProviderId, maskSecret(providerKey)); + LOGGER.debug("V2 compatibility mode: Event type {} not allowed for provider {} (key: {})", eventType, foundProviderId, SecurityUtils.maskSecret(providerKey)); return false; } boolean ipAuthorized = IPValidationUtils.isIpAuthorized(sourceIP, config.getIpAddresses()); if (!ipAuthorized) { - LOGGER.debug("V2 compatibility mode: IP {} not authorized for provider {} (key: {})", sourceIP, foundProviderId, maskSecret(providerKey)); + LOGGER.debug("V2 compatibility mode: IP {} not authorized for provider {} (key: {})", sourceIP, foundProviderId, SecurityUtils.maskSecret(providerKey)); } return ipAuthorized; @@ -231,11 +232,6 @@ public boolean isValidProvider(String providerId) { return providers.containsKey(providerId); } - private static String maskSecret(String secret) { - if (secret == null || secret.length() <= 4) return "****"; - return secret.substring(0, 4) + "****"; - } - private Set parseCommaSeparatedList(String value) { if (StringUtils.isBlank(value)) { return new HashSet<>(); 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 7ee15162a..efe3159ef 100644 --- a/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java +++ b/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java @@ -31,6 +31,7 @@ import org.apache.unomi.api.tenants.TenantService; import org.apache.unomi.rest.authentication.RestAuthenticationConfig; import org.apache.unomi.rest.authentication.V2ThirdPartyConfigService; +import org.apache.unomi.services.common.security.SecurityUtils; import org.apache.unomi.rest.exception.InvalidRequestException; import org.apache.unomi.rest.service.RestServiceUtils; import org.apache.unomi.schema.api.SchemaService; @@ -423,17 +424,13 @@ private boolean isEventAllowedInV2CompatibilityMode(Event event, HttpServletRequ // 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(), maskSecret(thirdPartyKey), sourceIP); + event.getEventType(), SecurityUtils.maskSecret(thirdPartyKey), sourceIP); return false; } LOGGER.debug("V2 compatibility mode: Protected event {} allowed for provider key: {} from IP: {}", - event.getEventType(), maskSecret(thirdPartyKey), sourceIP); + event.getEventType(), SecurityUtils.maskSecret(thirdPartyKey), sourceIP); return true; } - private static String maskSecret(String secret) { - if (secret == null || secret.length() <= 4) return "****"; - return secret.substring(0, 4) + "****"; - } } diff --git a/services-common/src/main/java/org/apache/unomi/services/common/security/SecurityUtils.java b/services-common/src/main/java/org/apache/unomi/services/common/security/SecurityUtils.java new file mode 100644 index 000000000..08175aa30 --- /dev/null +++ b/services-common/src/main/java/org/apache/unomi/services/common/security/SecurityUtils.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.unomi.services.common.security; + +/** + * Utility class for security-related helpers, such as safe logging of sensitive values. + */ +public class SecurityUtils { + + private static final String MASK_SUFFIX = "****"; + private static final int VISIBLE_PREFIX_LENGTH = 4; + + /** + * Masks a secret value for safe use in log statements. + * Shows the first {@value #VISIBLE_PREFIX_LENGTH} characters followed by {@code ****}, + * or {@code ****} entirely if the secret is null or too short to reveal safely. + * + * @param secret the secret to mask (e.g. an API key or shared token) + * @return a masked representation safe for logging + */ + public static String maskSecret(String secret) { + if (secret == null || secret.length() <= VISIBLE_PREFIX_LENGTH) { + return MASK_SUFFIX; + } + return secret.substring(0, VISIBLE_PREFIX_LENGTH) + MASK_SUFFIX; + } +} diff --git a/services-common/src/test/java/org/apache/unomi/services/common/security/SecurityUtilsTest.java b/services-common/src/test/java/org/apache/unomi/services/common/security/SecurityUtilsTest.java new file mode 100644 index 000000000..a100fe684 --- /dev/null +++ b/services-common/src/test/java/org/apache/unomi/services/common/security/SecurityUtilsTest.java @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.unomi.services.common.security; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class SecurityUtilsTest { + + @Test + public void testNullSecret() { + assertEquals("****", SecurityUtils.maskSecret(null)); + } + + @Test + public void testEmptySecret() { + assertEquals("****", SecurityUtils.maskSecret("")); + } + + @Test + public void testSecretShorterThanThreshold() { + assertEquals("****", SecurityUtils.maskSecret("abc")); + } + + @Test + public void testSecretExactlyAtThreshold() { + // exactly 4 chars — still fully masked (not enough to reveal safely) + assertEquals("****", SecurityUtils.maskSecret("abcd")); + } + + @Test + public void testSecretLongerThanThreshold() { + assertEquals("abcd****", SecurityUtils.maskSecret("abcdefgh")); + } + + @Test + public void testRealWorldApiKey() { + // Typical 32-char shared secret (e.g. X-Unomi-Peer value) + assertEquals("670c****", SecurityUtils.maskSecret("670c26d1cc413346c3b2fd9ce65dab41")); + } + + @Test + public void testFiveCharSecret() { + // One char beyond threshold — prefix visible, rest masked + assertEquals("abcd****", SecurityUtils.maskSecret("abcde")); + } +} From ad94a666f7477ad195f006e5e1917b1aa7315b3e Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Sun, 7 Jun 2026 09:34:37 +0200 Subject: [PATCH 6/6] UNOMI-904: Address review feedback on V2 compatibility mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main fix is that private endpoints with JAAS auth were always returning 401 in V2 mode — the JAAS subject had no TenantPrincipal, so resolveTenantId() couldn't find the tenant. We now build a merged subject with a TenantPrincipal for the default tenant before handing off to downstream processing. Two smaller filter bugs are fixed alongside: a blank defaultTenantId on a public path would silently fall through to the private-endpoint handler, and a null security context after JAAS was ignored rather than denied. The default tenant is now checked for existence before use, and V2ThirdPartyConfigService logs a clear warning when falling back to the well-known default key. On the test side, session and profile IDs are now per-test UUIDs, three negative cases cover the main rejection branches (unknown key, wrong IP, wrong event type), PatchIT polls for sendMailAction instead of asserting immediately, and GraphQLListIT refreshes Profile persistence before querying list membership. --- .../java/org/apache/unomi/itests/PatchIT.java | 5 +- .../unomi/itests/V2CompatibilityModeIT.java | 80 ++++++++++++++++++- .../unomi/itests/graphql/GraphQLListIT.java | 1 + .../authentication/AuthenticationFilter.java | 49 ++++++++---- .../V2ThirdPartyConfigService.java | 8 +- 5 files changed, 125 insertions(+), 18 deletions(-) diff --git a/itests/src/test/java/org/apache/unomi/itests/PatchIT.java b/itests/src/test/java/org/apache/unomi/itests/PatchIT.java index af43963fa..5a068ea4a 100644 --- a/itests/src/test/java/org/apache/unomi/itests/PatchIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/PatchIT.java @@ -21,6 +21,7 @@ import org.apache.unomi.api.actions.ActionType; import org.apache.unomi.api.conditions.ConditionType; import org.apache.unomi.persistence.spi.CustomObjectMapper; +import java.util.Objects; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; @@ -147,7 +148,9 @@ public void testPatchOnConditionType() throws IOException, InterruptedException @Test public void testPatchOnActionType() throws IOException, InterruptedException { - ActionType mailAction = definitionsService.getActionType("sendMailAction"); + ActionType mailAction = keepTrying("sendMailAction should exist", + () -> definitionsService.getActionType("sendMailAction"), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); Assert.assertNotNull("sendMailAction should exist", mailAction); Assert.assertNotNull("ActionType metadata should not be null", mailAction.getMetadata()); Assert.assertNotNull("ActionType systemTags should not be null", mailAction.getMetadata().getSystemTags()); diff --git a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java index b7931572b..691514eff 100644 --- a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java @@ -34,6 +34,7 @@ import org.apache.unomi.api.tenants.Tenant; import org.apache.unomi.itests.TestUtils.RequestResponse; import org.apache.unomi.rest.authentication.RestAuthenticationConfig; +import org.apache.unomi.rest.authentication.V2ThirdPartyConfigService; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -64,17 +65,21 @@ 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 String TEST_SESSION_ID; + private String TEST_PROFILE_ID; private final static String UNOMI_API_KEY_HEADER = "X-Unomi-Api-Key"; private final static String UNOMI_TENANT_ID_HEADER = "X-Unomi-Tenant-Id"; private final static String UNOMI_PEER_HEADER = "X-Unomi-Peer"; private boolean originalV2Mode; private String originalDefaultTenantId; + private V2ThirdPartyConfigService v2ThirdPartyConfigService; @Before public void setUp() throws InterruptedException, IOException { + TEST_SESSION_ID = "v2-compat-test-session-" + UUID.randomUUID(); + TEST_PROFILE_ID = "v2-compat-test-profile-" + UUID.randomUUID(); + v2ThirdPartyConfigService = getService(V2ThirdPartyConfigService.class); TestUtils.createScope(TEST_SCOPE, "Test scope", scopeService); keepTrying("Scope "+ TEST_SCOPE +" not found in the required time", () -> scopeService.getScope(TEST_SCOPE), @@ -420,6 +425,76 @@ public void testV2CompatibilityModeConfigurationPersistence() throws Exception { assertEquals("V2-style request should still work after service update", 200, response.getStatusCode()); } + @Test + public void testV2CompatibilityProtectedEventNegativeCases() throws Exception { + LOGGER.info("Testing V2 compatibility mode - protected event negative cases"); + + updateConfiguration(null, "org.apache.unomi.rest.authentication", "v2.compatibilitymode.enabled", true); + keepTrying("V2 compatibility mode not enabled in the required time", + () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(), + enabled -> enabled, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + Event loginEvent = new Event(); + loginEvent.setEventType("login"); + loginEvent.setScope(TEST_SCOPE); + ContextRequest contextRequest = new ContextRequest(); + contextRequest.setSessionId(TEST_SESSION_ID); + contextRequest.setEvents(Arrays.asList(loginEvent)); + String requestBody = objectMapper.writeValueAsString(contextRequest); + + // Case 1: protected event with an unknown provider key → rejected (0 processed events) + HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL)); + request.addHeader(UNOMI_PEER_HEADER, "unknownkey000000000000000000000000"); + request.setEntity(new StringEntity(requestBody, ContentType.APPLICATION_JSON)); + TestUtils.RequestResponse response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID); + assertEquals("Protected event with unknown provider key should return 200", 200, response.getStatusCode()); + assertEquals("Protected event with unknown provider key should have 0 processed events", 0, response.getContextResponse().getProcessedEvents()); + + try { + // Case 2: valid key but source IP not in provider's allowed list. + // Configure a provider whose IP allowlist only contains a non-loopback address so + // the test client (connecting from loopback) is rejected. + String testProviderKey = "testproviderip0000000000000000000"; + Map wrongIpConfig = new HashMap<>(); + wrongIpConfig.put("thirdparty.testprovider.key", testProviderKey); + wrongIpConfig.put("thirdparty.testprovider.ipAddresses", "10.0.0.1"); + wrongIpConfig.put("thirdparty.testprovider.allowedEvents", "login,updateProperties"); + updateConfiguration(null, "org.apache.unomi.thirdparty", wrongIpConfig); + keepTrying("Third-party wrong-IP config not applied", + () -> v2ThirdPartyConfigService.getProviderKey("testprovider"), + testProviderKey::equals, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + request = new HttpPost(getFullUrl(CONTEXT_URL)); + request.addHeader(UNOMI_PEER_HEADER, testProviderKey); + request.setEntity(new StringEntity(requestBody, ContentType.APPLICATION_JSON)); + response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID); + assertEquals("Protected event with valid key but wrong source IP should return 200", 200, response.getStatusCode()); + assertEquals("Protected event with valid key but wrong source IP should have 0 processed events", 0, response.getContextResponse().getProcessedEvents()); + + // Case 3: valid key but event type (login) not in provider's allowedEvents. + // Configure a provider that only allows updateProperties, not login. + String limitedKey = "testproviderlimited000000000000000"; + Map limitedEventsConfig = new HashMap<>(); + limitedEventsConfig.put("thirdparty.limitedprovider.key", limitedKey); + limitedEventsConfig.put("thirdparty.limitedprovider.ipAddresses", "127.0.0.1,::1"); + limitedEventsConfig.put("thirdparty.limitedprovider.allowedEvents", "updateProperties"); + updateConfiguration(null, "org.apache.unomi.thirdparty", limitedEventsConfig); + keepTrying("Third-party limited-events config not applied", + () -> v2ThirdPartyConfigService.isValidProvider("limitedprovider"), + valid -> valid, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + request = new HttpPost(getFullUrl(CONTEXT_URL)); + request.addHeader(UNOMI_PEER_HEADER, limitedKey); + request.setEntity(new StringEntity(requestBody, ContentType.APPLICATION_JSON)); + response = TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID); + assertEquals("Protected login event with key that only allows updateProperties should return 200", 200, response.getStatusCode()); + assertEquals("Protected login event with key that only allows updateProperties should have 0 processed events", 0, response.getContextResponse().getProcessedEvents()); + } finally { + // Restore thirdparty config to defaults by deleting the test entries + configurationAdmin.getConfiguration("org.apache.unomi.thirdparty", null).delete(); + } + } + private static void addPrivateTenantAuth(HttpPost request, Tenant tenant, ApiKey privateKey) { request.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString( (tenant.getItemId() + ":" + privateKey.getKey()).getBytes())); @@ -429,5 +504,6 @@ private static void addPrivateTenantAuth(HttpPost request, Tenant tenant, ApiKey public void updateServices() throws InterruptedException { super.updateServices(); restAuthenticationConfig = getService(RestAuthenticationConfig.class); + v2ThirdPartyConfigService = getService(V2ThirdPartyConfigService.class); } } diff --git a/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLListIT.java b/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLListIT.java index 66553e742..319cb6a0c 100644 --- a/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLListIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLListIT.java @@ -76,6 +76,7 @@ public void testCRUD() throws Exception { } refreshPersistence(UserList.class); + refreshPersistence(Profile.class); final ResponseContext findListsContext = keepTrying("Failed waiting for profile in list query", () -> { diff --git a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java index 1fdeff342..df37dbf55 100644 --- a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java +++ b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java @@ -25,6 +25,7 @@ import org.apache.karaf.jaas.boot.principal.UserPrincipal; import org.apache.unomi.api.ExecutionContext; import org.apache.unomi.api.security.SecurityService; +import org.apache.unomi.api.security.TenantPrincipal; import org.apache.unomi.api.security.UnomiRoles; import org.apache.unomi.api.services.ExecutionContextManager; import org.apache.unomi.api.tenants.ApiKey; @@ -260,6 +261,12 @@ private void handleV2CompatibilityMode(ContainerRequestContext requestContext, S if (isPublicPath(requestContext)) { String defaultTenantId = restAuthenticationConfig.getV2CompatibilityDefaultTenantId(); if (StringUtils.isNotBlank(defaultTenantId)) { + Tenant defaultTenant = tenantService.getTenant(defaultTenantId); + if (defaultTenant == null) { + logger.error("V2 compatibility mode: configured default tenant '{}' does not exist", defaultTenantId); + unauthorized(requestContext); + return; + } // Create a guest subject for public endpoints Subject subject = securityService.createSubject(defaultTenantId, false); @@ -273,6 +280,10 @@ private void handleV2CompatibilityMode(ContainerRequestContext requestContext, S // Set the execution context for the default tenant executionContextManager.setCurrentContext(executionContextManager.createContext(defaultTenantId)); return; + } else { + logger.warn("V2 compatibility mode: public path request denied because v2CompatibilityDefaultTenantId is not configured"); + unauthorized(requestContext); + return; } } @@ -281,24 +292,34 @@ private void handleV2CompatibilityMode(ContainerRequestContext requestContext, S if (authHeader != null && authHeader.startsWith(BASIC_AUTH_PREFIX)) { try { jaasAuthenticationFilter.filter(requestContext); - // Get the subject from the security context after successful JAAS auth + // JAASAuthenticationFilter handles credential failures internally (calls abortWith + returns normally). + // A null security context here means auth was rejected or an unexpected state occurred — deny either way. SecurityContext securityContext = JAXRSUtils.getCurrentMessage().get(SecurityContext.class); - if (securityContext != null) { - 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 (StringUtils.isNotBlank(defaultTenantId)) { - executionContextManager.setCurrentContext(executionContextManager.createContext(defaultTenantId)); - } else { - executionContextManager.setCurrentContext(ExecutionContext.systemContext()); - } + if (securityContext == null) { + logger.debug("V2 compatibility mode: no security context after JAAS filter, denying access"); + unauthorized(requestContext); + return; + } + Subject jaasSubject = ((RolePrefixSecurityContextImpl) securityContext).getSubject(); + + // Build a merged subject that combines the JAAS principals with a TenantPrincipal + // for the default tenant, so that resolveTenantId() can find it downstream. + String defaultTenantId = restAuthenticationConfig.getV2CompatibilityDefaultTenantId(); + Subject mergedSubject = new Subject(); + mergedSubject.getPrincipals().addAll(jaasSubject.getPrincipals()); + if (StringUtils.isNotBlank(defaultTenantId)) { + mergedSubject.getPrincipals().add(new TenantPrincipal(defaultTenantId)); + executionContextManager.setCurrentContext(executionContextManager.createContext(defaultTenantId)); + } else { + executionContextManager.setCurrentContext(ExecutionContext.systemContext()); } + JAXRSUtils.getCurrentMessage().put(SecurityContext.class, + new RolePrefixSecurityContextImpl(mergedSubject, ROLE_CLASSIFIER, ROLE_CLASSIFIER_TYPE)); + securityService.setCurrentSubject(mergedSubject); return; } catch (Exception e) { - logger.debug("V2 compatibility mode: JAAS authentication failed"); + // Only fires for unexpected exceptions — credential failures are handled inside JAASAuthenticationFilter. + logger.debug("V2 compatibility mode: unexpected exception during JAAS processing", e); } } else { logger.debug("V2 compatibility mode: Missing Basic Auth header for private endpoint"); 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 index b72f5c4dc..836cf8ef2 100644 --- a/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java +++ b/rest/src/main/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigService.java @@ -120,8 +120,14 @@ public void modified(Map properties) { } } - // Set default provider1 if no providers configured if (newProviders.isEmpty()) { + // The fallback key below is the well-known Unomi V2 default key, publicly documented + // in Apache Unomi changelogs and issue trackers. It provides no confidentiality on its own + // and is restricted to localhost only as a partial mitigation. + // Configure org.apache.unomi.thirdparty.cfg with a custom key before production use. + LOGGER.warn("V2 compatibility mode: no third-party providers configured in org.apache.unomi.thirdparty.cfg — " + + "falling back to the well-known default key restricted to localhost. " + + "Configure a custom provider key before using V2 compatibility mode in production."); newProviders.put("provider1", new ProviderConfig( "670c26d1cc413346c3b2fd9ce65dab41", new HashSet<>(Arrays.asList("127.0.0.1", "::1")),