This is an automated email from the ASF dual-hosted git repository. jsinovassin pushed a commit to branch UNOMI-v2compat-default-tenant in repository https://gitbox.apache.org/repos/asf/unomi.git
commit 81ad9fac3715a0289998b41979dd56cb9d347fcb Author: jsinovassin <[email protected]> AuthorDate: Tue Sep 15 12:14:05 2026 +0200 UNOMI-980: create the compatibility tenant, and name the mode for what it does The compatibility mode lets a client written before Unomi 3.1 keep working with no change on its side. It refused every request until an operator created a tenant by hand, which is a step no earlier version asked for: ERROR AuthenticationFilter - V2 compatibility mode: configured default tenant 'default' does not exist Create the tenant the mode runs on. The new TenantService.getOrCreateTenant is synchronized, and it re-reads the tenant when a concurrent creation wins the race. Several nodes starting together all get the tenant, and only one creation happens. Drop the tenant identifier setting. The mode read the setting in two places, and both sit inside the method that runs only when the mode is on. A client from before 3.1 knows no tenant, so it never names one, and nothing outside the filter read the value. The identifier is now a constant. Two unreachable branches go with the setting. Each call site tested the value for blankness, while modified() already fell back to "default". The two branches also disagreed: a blank value denied every public request on one path, and switched to the system context on the other. Rename the mode from V2 compatibility to single-tenant compatibility. Unomi 3.0.0 and 3.0.1 carry no tenant either, so a 3.0.1 client meets the same wall as a V2 client, and the old name named the wrong thing. The setting becomes singletenantcompatibility.enabled. V2CompatibilityModeIT becomes SingleTenantCompatibilityModeIT. Its two tests that existed only to exercise the removed setting are gone. testV2ModeBehavior fetched the profile setUp had created through profileService, which belongs to the tenant BaseIT works in, so it now reads back the profile its own V2-style request created. The write and the read both go through the compatibility path. --- .../apache/unomi/api/tenants/TenantService.java | 12 ++ .../test/java/org/apache/unomi/itests/AllITs.java | 2 +- .../apache/unomi/itests/CorePersistenceITs.java | 2 +- ...T.java => SingleTenantCompatibilityModeIT.java} | 164 +++++++-------------- .../asciidoc/migrations/migrate-3.0-to-3.1.adoc | 16 +- .../asciidoc/migrations/v2-compatibility-mode.adoc | 68 ++++----- manual/src/main/asciidoc/whats-new.adoc | 2 +- .../main/resources/etc/custom.system.properties | 3 +- .../rest/authentication/AuthenticationFilter.java | 93 ++++++------ .../authentication/RestAuthenticationConfig.java | 13 +- .../impl/DefaultRestAuthenticationConfig.java | 52 ++----- .../rest/service/impl/RestServiceUtilsImpl.java | 16 +- .../org.apache.unomi.rest.authentication.cfg | 7 +- .../AuthenticationFilterBlankPasswordTest.java | 17 +-- .../services/impl/tenants/TenantServiceImpl.java | 19 +++ .../unomi/services/impl/TestTenantService.java | 6 + 16 files changed, 217 insertions(+), 275 deletions(-) diff --git a/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java b/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java index a5fdb0ab5..dff0128b2 100644 --- a/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java +++ b/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java @@ -61,6 +61,18 @@ public interface TenantService { * @param tenantId the ID of the tenant to retrieve * @return the Tenant object if found, null otherwise */ + /** + * Returns the tenant with the given identifier, and creates it when it does not exist yet. + * <p> + * Two callers that race on the same identifier both get the tenant, and only one creation + * happens. + * + * @param tenantId the identifier of the tenant + * @param properties the properties to set when the tenant has to be created, may be {@code null} + * @return the tenant, never {@code null} + */ + Tenant getOrCreateTenant(String tenantId, Map<String, Object> properties); + Tenant getTenant(String tenantId); /** 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 89609d825..d867decb5 100644 --- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java +++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java @@ -72,7 +72,7 @@ import org.junit.runners.Suite.SuiteClasses; GraphQLProfileAliasesIT.class, SendEventActionIT.class, ScopeIT.class, - V2CompatibilityModeIT.class, + SingleTenantCompatibilityModeIT.class, CrudCommandsIT.class, CacheCommandsIT.class, TailCommandsIT.class, diff --git a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java index c279eeef7..2c8622805 100644 --- a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java +++ b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java @@ -73,7 +73,7 @@ import org.junit.runners.Suite.SuiteClasses; GraphQLProfileAliasesIT.class, SendEventActionIT.class, ScopeIT.class, - V2CompatibilityModeIT.class, + SingleTenantCompatibilityModeIT.class, CrudCommandsIT.class, CacheCommandsIT.class, TailCommandsIT.class, diff --git a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java b/itests/src/test/java/org/apache/unomi/itests/SingleTenantCompatibilityModeIT.java similarity index 76% rename from itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java rename to itests/src/test/java/org/apache/unomi/itests/SingleTenantCompatibilityModeIT.java index f27089368..8291e60c8 100644 --- a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/SingleTenantCompatibilityModeIT.java @@ -54,25 +54,27 @@ import java.util.Objects; import static org.junit.Assert.*; /** - * Integration tests for V2 compatibility mode authentication. + * Integration tests for single-tenant 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 { +public class SingleTenantCompatibilityModeIT extends BaseIT { - private final static Logger LOGGER = LoggerFactory.getLogger(V2CompatibilityModeIT.class); + private final static Logger LOGGER = LoggerFactory.getLogger(SingleTenantCompatibilityModeIT.class); private final static String CONTEXT_URL = "/cxs/context.json"; private static final String TEST_SCOPE = "testScope"; private String TEST_SESSION_ID; + /** The tenant the single-tenant compatibility mode runs on, as AuthenticationFilter names it. */ + private static final String COMPATIBILITY_TENANT_ID = "default"; + 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 @@ -82,26 +84,30 @@ public class V2CompatibilityModeIT extends BaseIT { v2ThirdPartyConfigService = getService(V2ThirdPartyConfigService.class); TestUtils.createScope(TEST_SCOPE, "Test scope", scopeService); + + // The compatibility mode runs on its own tenant, not the one BaseIT works in, so an event it + // carries is validated against the scopes of that tenant. Create the scope there too, then + // put the context back where BaseIT left it. + executionContextManager.setCurrentContext(executionContextManager.createContext(COMPATIBILITY_TENANT_ID)); + try { + TestUtils.createScope(TEST_SCOPE, "Test scope", scopeService); + } finally { + executionContextManager.setCurrentContext(executionContextManager.createContext(testTenant.getItemId())); + } 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(); + originalV2Mode = restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled(); - // Configure V2 compatibility mode to use the BaseIT test tenant as default + // Configure single-tenant compatibility mode to use the BaseIT test tenant as default Map<String, Object> v2Config = new HashMap<>(); - v2Config.put("v2.compatibilitymode.enabled", false); // Start in V3 mode - v2Config.put("v2.compatibilitymode.defaultTenantId", TEST_TENANT_ID); // Use BaseIT tenant + v2Config.put("singletenantcompatibility.enabled", false); // Start in V3 mode 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); @@ -118,10 +124,7 @@ public class V2CompatibilityModeIT extends BaseIT { try { // Restore original V2 mode setting and default tenant ID Map<String, Object> originalConfig = new HashMap<>(); - originalConfig.put("v2.compatibilitymode.enabled", originalV2Mode); - if (originalDefaultTenantId != null) { - originalConfig.put("v2.compatibilitymode.defaultTenantId", originalDefaultTenantId); - } + originalConfig.put("singletenantcompatibility.enabled", originalV2Mode); updateConfiguration(null, "org.apache.unomi.rest.authentication", @@ -149,45 +152,45 @@ public class V2CompatibilityModeIT extends BaseIT { @Test public void testV2CompatibilityModeSwitch() throws Exception { - LOGGER.info("Starting V2 compatibility mode switch test"); + LOGGER.info("Starting single-tenant 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"); + // STEP 2: Switch to single-tenant compatibility mode + LOGGER.info("STEP 2: Switching to single-tenant compatibility mode"); updateConfiguration(null, "org.apache.unomi.rest.authentication", - "v2.compatibilitymode.enabled", + "singletenantcompatibility.enabled", true); // Wait for configuration to take effect - keepTrying("V2 compatibility mode not enabled in the required time", - () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(), + keepTrying("single-tenant compatibility mode not enabled in the required time", + () -> restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled(), 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"); + LOGGER.info("STEP 3: Testing single-tenant 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", + "singletenantcompatibility.enabled", false); // Wait for configuration to take effect - keepTrying("V2 compatibility mode not disabled in the required time", - () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(), + keepTrying("single-tenant compatibility mode not disabled in the required time", + () -> restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled(), 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"); + LOGGER.info("single-tenant compatibility mode switch test completed successfully"); } /** @@ -242,7 +245,7 @@ public class V2CompatibilityModeIT extends BaseIT { } /** - * Test behavior in V2 compatibility mode: + * Test behavior in single-tenant compatibility mode: * - V2 requests (no auth for public endpoints) should work * - V3 requests should be rejected */ @@ -254,21 +257,26 @@ public class V2CompatibilityModeIT extends BaseIT { HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL)); request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); TestUtils.RequestResponse response = executeContextJSONRequest(request, TEST_SESSION_ID); - assertEquals("V2-style request should work in V2 compatibility mode", 200, response.getStatusCode()); + assertEquals("V2-style request should work in single-tenant compatibility mode", 200, response.getStatusCode()); + // The profile this request created belongs to the tenant the compatibility mode runs on, which + // is not the tenant BaseIT works in. Read that profile back below, so the write and the read + // both go through the compatibility path. + String compatibilityProfileId = response.getContextResponse().getProfileId(); + assertNotNull("V2-style request should have created a profile", compatibilityProfileId); // 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(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); response = executeContextJSONRequest(request, TEST_SESSION_ID); - assertEquals("V2-style request with X-Unomi-Peer should work in V2 compatibility mode", 200, response.getStatusCode()); + assertEquals("V2-style request with X-Unomi-Peer should work in single-tenant compatibility mode", 200, response.getStatusCode()); // Test V3-style request with public API key - in V2 mode, V3 API keys are ignored (request succeeds but no events processed) request = new HttpPost(getFullUrl(CONTEXT_URL)); request.addHeader(UNOMI_API_KEY_HEADER, testPublicKeyValue); request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); response = 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 return 200 in single-tenant compatibility mode", 200, response.getStatusCode()); assertEquals("V3-style request with public API key should have 0 processed events in V2 mode", 0, response.getContextResponse().getProcessedEvents()); // Test V3-style request with private API key - in V2 mode, V3 API keys are ignored (request succeeds but no events processed) @@ -276,11 +284,11 @@ public class V2CompatibilityModeIT extends BaseIT { addPrivateTenantAuth(request, testTenant, testPrivateKeyValue); request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); response = 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 return 200 in single-tenant 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)); + HttpGet getRequest = new HttpGet(getFullUrl("/cxs/profiles/" + compatibilityProfileId)); BasicCredentialsProvider credsProvider = new BasicCredentialsProvider(); credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)); @@ -295,26 +303,26 @@ public class V2CompatibilityModeIT extends BaseIT { .setDefaultRequestConfig(requestConfig) .build()) { try (CloseableHttpResponse jaasResponse = adminClient.execute(getRequest)) { - assertEquals("Private endpoint with JAAS auth should work in V2 compatibility mode", 200, jaasResponse.getStatusLine().getStatusCode()); + assertEquals("Private endpoint with JAAS auth should work in single-tenant compatibility mode", 200, jaasResponse.getStatusLine().getStatusCode()); } try (CloseableHttpResponse privacyResponse = adminClient.execute(new HttpGet(getFullUrl("/cxs/privacy/info")))) { - assertEquals("GET /cxs/privacy/info with Karaf auth should work in V2 compatibility mode", 200, privacyResponse.getStatusLine().getStatusCode()); + assertEquals("GET /cxs/privacy/info with Karaf auth should work in single-tenant compatibility mode", 200, privacyResponse.getStatusLine().getStatusCode()); } } } @Test public void testV2CompatibilityModeWithProtectedEvents() throws Exception { - LOGGER.info("Testing V2 compatibility mode with protected events"); + LOGGER.info("Testing single-tenant compatibility mode with protected events"); - // Switch to V2 compatibility mode + // Switch to single-tenant compatibility mode updateConfiguration(null, "org.apache.unomi.rest.authentication", - "v2.compatibilitymode.enabled", + "singletenantcompatibility.enabled", true); - keepTrying("V2 compatibility mode not enabled in the required time", - () -> restAuthenticationConfig.isV2CompatibilityModeEnabled(), + keepTrying("single-tenant compatibility mode not enabled in the required time", + () -> restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled(), enabled -> enabled, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); // Test protected event (login) without V2 third-party authentication - should be rejected @@ -363,79 +371,13 @@ public class V2CompatibilityModeIT extends BaseIT { 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(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); - TestUtils.RequestResponse response = 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(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); - TestUtils.RequestResponse response = executeContextJSONRequest(request, TEST_SESSION_ID); - assertEquals("V2-style request should still work after service update", 200, response.getStatusCode()); - } - @Test public void testV2CompatibilityProtectedEventNegativeCases() throws Exception { - LOGGER.info("Testing V2 compatibility mode - protected event negative cases"); + LOGGER.info("Testing single-tenant 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(), + updateConfiguration(null, "org.apache.unomi.rest.authentication", "singletenantcompatibility.enabled", true); + keepTrying("single-tenant compatibility mode not enabled in the required time", + () -> restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled(), enabled -> enabled, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); Event loginEvent = new Event(); diff --git a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc index 59350e8ec..0b2e6e165 100644 --- a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc +++ b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc @@ -350,7 +350,7 @@ Before starting the migration, please ensure that: - You did practice the migration in a staging environment, NEVER migrate a production environment without prior validation - You are currently running Apache Unomi 3.0 (or a later 3.0.x version) - You understand the multi-tenancy impact on your data model -- You have a plan to update client applications to tenant API keys (or temporary <<_v2_compatibility_mode,V2 compatibility mode>> only if coming from 2.x) +- You have a plan to update client applications to tenant API keys (or temporary <<_v2_compatibility_mode,single-tenant compatibility mode>> only if coming from 2.x) - You have reviewed the <<_client_facing_hardening_3_1,client-facing hardening>> notes (cookie-only public profile binding, HttpOnly default, required passwords, privileged REST roles) - You know how to obtain plaintext API keys after upgrade (regenerate via `/cxs/tenants/{id}/apikeys`; create responses expose masked keys only) @@ -376,7 +376,7 @@ Scripts applied for the 3.1 line include (under `tools/shell-commands/.../META-I * `migrate-3.1.0-05-fixSystemItemIds` * `migrate-3.1.0-10-tenantInitialization` (creates the default tenant used for isolation) * `migrate-3.1.0-15-updateLegacyQueryBuilder` -4. **Update client applications** to the 3.1 authentication model (public key header / tenant private key), or enable <<_v2_compatibility_mode,V2 compatibility mode>> only when migrating **from Unomi 2.x**. +4. **Update client applications** to the 3.1 authentication model (public key header / tenant private key), or enable <<_v2_compatibility_mode,single-tenant compatibility mode>> only when migrating **from Unomi 2.x**. 5. **Start your Apache Unomi 3.1 cluster**. 6. **Regenerate and store tenant API keys** (plaintext is returned only from the key-creation endpoint), then **test** applications end to end. @@ -388,20 +388,20 @@ Unomi 3.1 does **not** provide a separate "3.0 compatibility mode" system proper Adopt tenant public API keys for public endpoints and tenant private keys or JAAS for administrative work. See <<_multitenancy,Multi-tenancy>> and the authentication examples in this guide. -==== Option 2: V2 compatibility mode (2.x clients only) +==== Option 2: single-tenant compatibility mode (2.x clients only) -If you are migrating from **Unomi 2.x** (not 3.0), you can enable <<_v2_compatibility_mode,V2 compatibility mode>> so legacy clients work without API keys during a phased rollout: +If you are migrating from **Unomi 2.x** (not 3.0), you can enable <<_v2_compatibility_mode,single-tenant compatibility mode>> so legacy clients work without API keys during a phased rollout: [source,properties] ---- # etc/org.apache.unomi.rest.authentication.cfg -v2.compatibilitymode.enabled = true -v2.compatibilitymode.defaultTenantId = default +singletenantcompatibility.enabled = true +v2.compatibilitymode.tenantId = default ---- -Environment variable equivalent: `UNOMI_REST_AUTHENTICATION_V2COMPATIBILITYMODEENABLED=true` +Environment variable equivalent: `UNOMI_REST_AUTHENTICATION_SINGLETENANTCOMPATIBILITYMODEENABLED=true` -WARNING: V2 compatibility mode is intended for migration from 2.x only. It bypasses tenant API key requirements on public endpoints. Disable it once all clients use 3.1 authentication. +WARNING: single-tenant compatibility mode is intended for migration from 2.x only. It bypasses tenant API key requirements on public endpoints. Disable it once all clients use 3.1 authentication. === Post Migration diff --git a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc index 623820939..f0468f570 100644 --- a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc +++ b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc @@ -13,17 +13,17 @@ // [#_v2_compatibility_mode] -=== V2 compatibility mode +=== single-tenant compatibility mode -This document explains how to use the V2 compatibility mode in Apache Unomi V3, which allows V2 client applications to work with Unomi V3 without requiring API keys. +This document explains how to use the single-tenant compatibility mode in Apache Unomi V3, which allows V2 client applications to work with Unomi V3 without requiring API keys. ==== Overview -The V2 compatibility mode is designed to ease the migration from Unomi V2 to V3 by allowing V2 clients to continue working without immediate changes to their authentication logic. This mode provides backward compatibility while still leveraging the multi-tenant architecture of V3. +The single-tenant compatibility mode is designed to ease the migration from Unomi V2 to V3 by allowing V2 clients to continue working without immediate changes to their authentication logic. This mode provides backward compatibility while still leveraging the multi-tenant architecture of V3. ===== How It Works -When V2 compatibility mode is enabled: +When single-tenant compatibility mode is enabled: - **Public endpoints** (like `/context.json`) require no authentication (like V2) - **Protected events** (like `login`, `updateProperties`) require IP + X-Unomi-Peer (like V2) @@ -35,7 +35,7 @@ This allows V2 clients to work with Unomi V3 immediately after migration, giving ==== Prerequisites -Before enabling V2 compatibility mode, ensure that: +Before enabling single-tenant compatibility mode, ensure that: 1. **Data Migration Completed**: Your V2 data has been migrated to a tenant using the migration scripts 2. **Default Tenant Exists**: A default tenant exists that will be used for all operations @@ -45,7 +45,7 @@ Before enabling V2 compatibility mode, ensure that: ===== Enable V2 Compatibility Mode -You can also set the environment variable `UNOMI_REST_AUTHENTICATION_V2COMPATIBILITYMODEENABLED=true` (maps to `org.apache.unomi.rest.authentication.v2CompatibilityModeEnabled` in `custom.system.properties`). +You can also set the environment variable `UNOMI_REST_AUTHENTICATION_SINGLETENANTCOMPATIBILITYMODEENABLED=true` (maps to `org.apache.unomi.rest.authentication.singleTenantCompatibilityModeEnabled` in `custom.system.properties`). 1. **Edit the configuration file**: ```bash @@ -53,13 +53,13 @@ You can also set the environment variable `UNOMI_REST_AUTHENTICATION_V2COMPATIBI vi etc/org.apache.unomi.rest.authentication.cfg ``` -2. **Enable V2 compatibility mode**: +2. **Enable single-tenant compatibility mode**: ```properties - # Enable V2 compatibility mode - v2.compatibilitymode.enabled = true + # Enable single-tenant compatibility mode + singletenantcompatibility.enabled = true # Set the default tenant ID (should match the tenant ID used during migration) - v2.compatibilitymode.defaultTenantId = your-migration-tenant-id + v2.compatibilitymode.tenantId = your-migration-tenant-id ``` 3. **Restart the server** to apply the configuration changes: @@ -73,7 +73,7 @@ You can also set the environment variable `UNOMI_REST_AUTHENTICATION_V2COMPATIBI ===== Configuration Management -V2 compatibility mode is managed through configuration files only. This approach is safer and prevents accidental changes to authentication settings. +single-tenant compatibility mode is managed through configuration files only. This approach is safer and prevents accidental changes to authentication settings. ==== Migration Workflow @@ -86,18 +86,18 @@ First, migrate your data with Apache Unomi stopped using the single shell comman unomi:migrate 2.0.0 ``` -The `migrate-3.1.0-10-tenantInitialization` script (run as part of that chain when migrating through 3.1) creates a default tenant used for V2 compatibility mode. See <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> and <<_shell_commands,Shell commands>>. +The `migrate-3.1.0-10-tenantInitialization` script (run as part of that chain when migrating through 3.1) creates a default tenant used for single-tenant compatibility mode. See <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> and <<_shell_commands,Shell commands>>. ===== Step 2: Enable V2 Compatibility Mode -Enable V2 compatibility mode by updating the configuration file: +Enable single-tenant compatibility mode by updating the configuration file: ```bash # Edit the configuration file vi etc/org.apache.unomi.rest.authentication.cfg -# Set v2.compatibilitymode.enabled = true -# Set v2.compatibilitymode.defaultTenantId = your-tenant-id +# Set singletenantcompatibility.enabled = true +# Set v2.compatibilitymode.tenantId = your-tenant-id # Restart the server to apply changes ./bin/stop @@ -126,8 +126,8 @@ RestAssured.given() Over time, gradually update your clients to use V3 authentication: 1. **Update client applications** to use API keys -2. **Test with V3 authentication** while keeping V2 compatibility mode enabled -3. **Disable V2 compatibility mode** once all clients are updated +2. **Test with V3 authentication** while keeping single-tenant compatibility mode enabled +3. **Disable single-tenant compatibility mode** once all clients are updated ==== Client Migration Examples @@ -135,7 +135,7 @@ Over time, gradually update your clients to use V3 authentication: **V2 Client (continues to work)**: ```java -// This continues to work in V2 compatibility mode +// This continues to work in single-tenant compatibility mode RestAssured.authentication = RestAssured.preemptive() .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")); @@ -158,16 +158,16 @@ given() ===== Gradual Migration Strategy -1. **Phase 1**: Enable V2 compatibility mode, V2 clients continue working +1. **Phase 1**: Enable single-tenant compatibility mode, V2 clients continue working 2. **Phase 2**: Develop and test V3 clients alongside V2 clients 3. **Phase 3**: Migrate clients one by one to V3 authentication -4. **Phase 4**: Disable V2 compatibility mode once all clients are migrated +4. **Phase 4**: Disable single-tenant compatibility mode once all clients are migrated ==== Security Considerations ===== V2 Compatibility Mode Security -When V2 compatibility mode is enabled: +When single-tenant compatibility mode is enabled: - **Public endpoints** are accessible without authentication (same as V2) - **Protected events** require IP + X-Unomi-Peer authentication (same as V2) @@ -177,7 +177,7 @@ When V2 compatibility mode is enabled: ===== Protected Events in V2 Compatibility Mode -In V2 compatibility mode, protected event types are configured dynamically using the V2 third-party configuration file. By default, the following event types are protected: +In single-tenant compatibility mode, protected event types are configured dynamically using the V2 third-party configuration file. By default, the following event types are protected: - `login` - User authentication events - `updateProperties` - Profile property updates @@ -244,10 +244,10 @@ The V2 third-party configuration supports dynamic updates: ===== Recommendations -1. **Use V2 compatibility mode temporarily** during migration +1. **Use single-tenant compatibility mode temporarily** during migration 2. **Plan for gradual migration** to V3 authentication 3. **Monitor access patterns** during the transition -4. **Disable V2 compatibility mode** once migration is complete +4. **Disable single-tenant compatibility mode** once migration is complete ==== Troubleshooting @@ -255,8 +255,8 @@ The V2 third-party configuration supports dynamic updates: **V2 clients still not working**: - Check configuration file: `etc/org.apache.unomi.rest.authentication.cfg` -- Verify `v2.compatibilitymode.enabled = true` -- Ensure `v2.compatibilitymode.defaultTenantId` matches the tenant ID used during migration +- Verify `singletenantcompatibility.enabled = true` +- Ensure `v2.compatibilitymode.tenantId` matches the tenant ID used during migration - Ensure the tenant exists and is accessible **Authentication errors**: @@ -291,7 +291,7 @@ Once all clients are migrated to V3 authentication: 1. **Update configuration**: ```properties - v2.compatibilitymode.enabled = false + singletenantcompatibility.enabled = false ``` 2. **Restart the server**: @@ -306,14 +306,14 @@ Once all clients are migrated to V3 authentication: ==== Testing V2 Compatibility Mode -The existing test framework supports testing V2 compatibility mode using system properties. +The existing test framework supports testing single-tenant compatibility mode using system properties. ===== Running Tests in V2 Compatibility Mode -To run tests with V2 compatibility mode enabled: +To run tests with single-tenant compatibility mode enabled: ```bash -# Enable V2 compatibility mode for tests +# Enable single-tenant compatibility mode for tests mvn test -Dunomi.v2.compatibility.mode=true # Or set the property in your test environment @@ -323,17 +323,17 @@ mvn test ===== Test Framework Integration -The test framework automatically detects V2 compatibility mode and uses the appropriate client: +The test framework automatically detects single-tenant compatibility mode and uses the appropriate client: - **V2 Compatibility Mode Enabled**: Uses `UnomiV2Client` for all tests - **V2 Compatibility Mode Disabled**: Uses normal V2/V3 detection logic -This allows you to test both V2 compatibility mode and normal V3 mode using the same test suite. +This allows you to test both single-tenant compatibility mode and normal V3 mode using the same test suite. ===== Example Test Execution ```bash -# Test with V2 compatibility mode (server should be configured for V2 compatibility) +# Test with single-tenant compatibility mode (server should be configured for V2 compatibility) mvn test -Dunomi.v2.compatibility.mode=true -Dunomi.url=http://localhost:8181 # Test with normal V3 mode @@ -342,7 +342,7 @@ mvn test -Dunomi.url=http://localhost:8181 ==== Conclusion -The V2 compatibility mode provides a smooth migration path from Unomi V2 to V3, allowing you to: +The single-tenant compatibility mode provides a smooth migration path from Unomi V2 to V3, allowing you to: - **Maintain existing V2 clients** during migration - **Gradually migrate** to V3 authentication diff --git a/manual/src/main/asciidoc/whats-new.adoc b/manual/src/main/asciidoc/whats-new.adoc index d49febed1..2433bcef3 100644 --- a/manual/src/main/asciidoc/whats-new.adoc +++ b/manual/src/main/asciidoc/whats-new.adoc @@ -22,7 +22,7 @@ Complete tenant isolation for profiles, events, segments, rules, and schemas. Pu * Operator guide: <<_multitenancy,Multi-tenancy>> * Migration: <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> (includes <<_client_facing_hardening_3_1,client-facing hardening>>) -* Migrating from Unomi 2.x: <<_v2_compatibility_mode,V2 compatibility mode>> (`v2.compatibilitymode.enabled` in `org.apache.unomi.rest.authentication.cfg`) +* Migrating from Unomi 2.x: <<_v2_compatibility_mode,single-tenant compatibility mode>> (`singletenantcompatibility.enabled` in `org.apache.unomi.rest.authentication.cfg`) ==== Security hardening (credentials, profile binding, privileged APIs) diff --git a/package/src/main/resources/etc/custom.system.properties b/package/src/main/resources/etc/custom.system.properties index 4dd435a78..9cb7b8caf 100644 --- a/package/src/main/resources/etc/custom.system.properties +++ b/package/src/main/resources/etc/custom.system.properties @@ -524,5 +524,4 @@ org.apache.unomi.campaigns.refresh.interval=${env:UNOMI_CAMPAIGNS_REFRESH_INTERV ####################################################################################################################### ## 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} +org.apache.unomi.rest.authentication.singleTenantCompatibilityModeEnabled=${env:UNOMI_REST_AUTHENTICATION_SINGLETENANTCOMPATIBILITYMODEENABLED:-false} 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 0e990ffaa..6f547f935 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,7 +41,6 @@ import javax.ws.rs.container.ContainerRequestFilter; import javax.ws.rs.container.PreMatching; import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.Response; -import org.apache.commons.lang3.StringUtils; import java.io.IOException; import java.util.Base64; @@ -60,6 +59,13 @@ import java.util.Set; @Priority(Priorities.AUTHENTICATION) public class AuthenticationFilter implements ContainerRequestFilter { + /** + * The tenant that single-tenant compatibility mode runs on. A client from before Unomi 3.1 knows + * no tenant, so it never names one, and nothing outside this class reads this value. It is a + * constant rather than a setting for that reason. + */ + private static final String COMPATIBILITY_TENANT_ID = "default"; + private static final String UNOMI_API_KEY_HEADER = "X-Unomi-Api-Key"; private static final String UNOMI_TENANT_ID_HEADER = "X-Unomi-Tenant-Id"; private static final Logger logger = LoggerFactory.getLogger(AuthenticationFilter.class); @@ -135,9 +141,9 @@ public class AuthenticationFilter implements ContainerRequestFilter { try { String path = requestContext.getUriInfo().getPath(); - // Check if V2 compatibility mode is enabled - if (restAuthenticationConfig.isV2CompatibilityModeEnabled()) { - handleV2CompatibilityMode(requestContext, path); + // Check if single-tenant compatibility mode is enabled + if (restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled()) { + handleSingleTenantCompatibilityMode(requestContext, path); return; } @@ -280,42 +286,39 @@ public class AuthenticationFilter implements ContainerRequestFilter { } /** - * Handle authentication in V2 compatibility mode. + * Handle authentication in single-tenant 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 { + private void handleSingleTenantCompatibilityMode(ContainerRequestContext requestContext, String path) throws IOException { // For public paths, allow access without authentication (like V2) if (isPublicPath(requestContext)) { - String defaultTenantId = restAuthenticationConfig.getV2CompatibilityDefaultTenantId(); - if (StringUtils.isNotBlank(defaultTenantId)) { - Tenant defaultTenant = tenantService.getTenant(defaultTenantId); - if (defaultTenant == null) { - logger.error("V2 compatibility mode: configured default tenant '{}' does not exist", defaultTenantId); - unauthorized(requestContext); - return; - } - // Create a guest subject for public endpoints - Subject subject = securityService.createSubject(defaultTenantId, false); - - // Set CXF security context - JAXRSUtils.getCurrentMessage().put(SecurityContext.class, - new RolePrefixSecurityContextImpl(subject, ROLE_CLASSIFIER, ROLE_CLASSIFIER_TYPE)); - - // Set the security service subject - securityService.setCurrentSubject(subject); - - // Set the execution context for the default tenant - executionContextManager.setCurrentContext(executionContextManager.createContext(defaultTenantId)); - return; - } else { - logger.warn("V2 compatibility mode: public path request denied because v2CompatibilityDefaultTenantId is not configured"); + String tenantId = COMPATIBILITY_TENANT_ID; + // V2 knew no tenant, so a V2 client cannot name one and cannot create one. Create the + // tenant this mode runs on, rather than refuse every request until an operator creates + // it by hand. + try { + tenantService.getOrCreateTenant(tenantId, Collections.singletonMap("name", tenantId)); + } catch (RuntimeException e) { + logger.error("single-tenant compatibility mode: could not obtain tenant '{}'", tenantId, e); unauthorized(requestContext); return; } + // Create a guest subject for public endpoints + Subject subject = securityService.createSubject(tenantId, 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); + + executionContextManager.setCurrentContext(executionContextManager.createContext(tenantId)); + return; } // For private endpoints, require system administrator authentication (like V2) @@ -330,47 +333,43 @@ public class AuthenticationFilter implements ContainerRequestFilter { // A null security context here means auth was rejected or an unexpected state occurred — deny either way. SecurityContext securityContext = JAXRSUtils.getCurrentMessage().get(SecurityContext.class); if (securityContext == null) { - logger.debug("V2 compatibility mode: no security context after JAAS filter, denying access"); + logger.debug("single-tenant compatibility mode: no security context after JAAS filter, denying access"); unauthorized(requestContext); return; } Subject jaasSubject = ((RolePrefixSecurityContextImpl) securityContext).getSubject(); - // Private endpoints in V2 compatibility mode require system administrator + // Private endpoints in single-tenant compatibility mode require system administrator // authentication (like V2) — a JAAS login alone isn't enough, since any Karaf // user (not just admins) can authenticate against the realm. if (!securityService.extractRolesFromSubject(jaasSubject).contains(UnomiRoles.ADMINISTRATOR)) { - logger.debug("V2 compatibility mode: authenticated user lacks administrator role, denying access to private endpoint"); + logger.debug("single-tenant compatibility mode: authenticated user lacks administrator role, denying access to private endpoint"); unauthorized(requestContext); return; } - // Build a merged subject that combines the JAAS principals with tenant admin - // principals for the default tenant, so that resolveTenantId() can find it downstream. - String defaultTenantId = restAuthenticationConfig.getV2CompatibilityDefaultTenantId(); + // Build a merged subject that combines the JAAS principals with the tenant admin + // principals, so that resolveTenantId() can find the tenant downstream. + String tenantId = COMPATIBILITY_TENANT_ID; Subject mergedSubject = new Subject(); mergedSubject.getPrincipals().addAll(jaasSubject.getPrincipals()); - if (StringUtils.isNotBlank(defaultTenantId)) { - mergedSubject.getPrincipals().addAll(securityService.createSubject(defaultTenantId, true).getPrincipals()); - Set<String> roles = securityService.extractRolesFromSubject(mergedSubject); - Set<String> permissions = new HashSet<>(); - for (String role : roles) { - permissions.addAll(securityService.getPermissionsForRole(role)); - } - executionContextManager.setCurrentContext(new ExecutionContext(defaultTenantId, roles, permissions)); - } else { - executionContextManager.setCurrentContext(ExecutionContext.systemContext()); + mergedSubject.getPrincipals().addAll(securityService.createSubject(tenantId, true).getPrincipals()); + Set<String> roles = securityService.extractRolesFromSubject(mergedSubject); + Set<String> permissions = new HashSet<>(); + for (String role : roles) { + permissions.addAll(securityService.getPermissionsForRole(role)); } + executionContextManager.setCurrentContext(new ExecutionContext(tenantId, roles, permissions)); JAXRSUtils.getCurrentMessage().put(SecurityContext.class, new RolePrefixSecurityContextImpl(mergedSubject, ROLE_CLASSIFIER, ROLE_CLASSIFIER_TYPE)); securityService.setCurrentSubject(mergedSubject); return; } catch (Exception e) { // Only fires for unexpected exceptions — credential failures are handled inside JAASAuthenticationFilter. - logger.debug("V2 compatibility mode: unexpected exception during JAAS processing", e); + logger.debug("single-tenant compatibility mode: unexpected exception during JAAS processing", e); } } else { - logger.debug("V2 compatibility mode: Missing Basic Auth header for private endpoint"); + logger.debug("single-tenant compatibility mode: Missing Basic Auth header for private endpoint"); } // If we get here, no valid authentication was provided 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 ed7022dc0..fe3eed098 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 @@ -61,21 +61,14 @@ public interface RestAuthenticationConfig { String getGlobalRoles(); /** - * Check if V2 compatibility mode is enabled. + * Check if single-tenant 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 + * @return true if single-tenant compatibility mode is enabled, false otherwise */ - boolean isV2CompatibilityModeEnabled(); + boolean isSingleTenantCompatibilityModeEnabled(); - /** - * 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/impl/DefaultRestAuthenticationConfig.java b/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java index 2d3bf5fa1..a9529e1c7 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 @@ -33,7 +33,7 @@ import java.util.regex.Pattern; /** * Default OSGi-backed implementation of {@link RestAuthenticationConfig} for REST endpoint - * authentication, role mappings, and V2 compatibility mode settings. + * authentication, role mappings, and single-tenant compatibility mode settings. */ @Component(service = { RestAuthenticationConfig.class}, configurationPid = "org.apache.unomi.rest.authentication", immediate = true) @Designate(ocd = DefaultRestAuthenticationConfig.Config.class) @@ -76,8 +76,7 @@ public class DefaultRestAuthenticationConfig implements RestAuthenticationConfig ROLES_MAPPING = Collections.unmodifiableMap(roles); } - private volatile boolean v2CompatibilityModeEnabled = false; - private volatile String v2CompatibilityDefaultTenantId = "default"; + private volatile boolean singleTenantCompatibilityModeEnabled = false; /** * Updates authentication settings from OSGi configuration. @@ -91,19 +90,9 @@ public class DefaultRestAuthenticationConfig implements RestAuthenticationConfig LOGGER.warn("Config is null in modified method"); return; } - boolean v2Mode = config.v2_compatibilitymode_enabled(); - String defaultTenant = config.v2_compatibilitymode_defaultTenantId(); - if (defaultTenant != null) { - defaultTenant = defaultTenant.trim(); - } - if (StringUtils.isBlank(defaultTenant)) { - LOGGER.warn("v2CompatibilityDefaultTenantId is blank, falling back to 'default'"); - defaultTenant = "default"; - } - LOGGER.info("Configuration updated - v2CompatibilityModeEnabled: {}, v2CompatibilityDefaultTenantId: {}", - v2Mode, defaultTenant); - this.v2CompatibilityModeEnabled = v2Mode; - this.v2CompatibilityDefaultTenantId = defaultTenant; + boolean singleTenantMode = config.singletenantcompatibility_enabled(); + LOGGER.info("Configuration updated - singleTenantCompatibilityModeEnabled: {}", singleTenantMode); + this.singleTenantCompatibilityModeEnabled = singleTenantMode; } @@ -123,44 +112,29 @@ public class DefaultRestAuthenticationConfig implements RestAuthenticationConfig } @Override - public boolean isV2CompatibilityModeEnabled() { - return v2CompatibilityModeEnabled; + public boolean isSingleTenantCompatibilityModeEnabled() { + return singleTenantCompatibilityModeEnabled; } - @Override - public String getV2CompatibilityDefaultTenantId() { - return v2CompatibilityDefaultTenantId; - } /** * OSGi configuration for REST authentication. */ @ObjectClassDefinition( name = "Unomi REST Authentication Configuration", - description = "Configuration for Unomi REST authentication including V2 compatibility mode" + description = "Configuration for Unomi REST authentication including single-tenant compatibility mode" ) public @interface Config { /** - * Whether V2 compatibility mode is enabled. - * - * @return {@code true} when V2 compatibility mode is enabled - */ - @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; - - /** - * Default tenant identifier used in V2 compatibility mode. + * Whether single-tenant compatibility mode is enabled. * - * @return the default tenant identifier + * @return {@code true} when single-tenant compatibility mode is enabled */ @AttributeDefinition( - name = "V2 Compatibility Default Tenant ID", - description = "Default tenant ID to use in V2 compatibility mode" + name = "Single-tenant compatibility mode enabled", + description = "Serve clients from before Unomi 3.1, which send no tenant API key, and run them all on one tenant" ) - String v2_compatibilitymode_defaultTenantId() default "default"; + boolean singletenantcompatibility_enabled() default false; } } 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 7ed3a0987..9bee93127 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 @@ -388,10 +388,10 @@ public class RestServiceUtilsImpl implements RestServiceUtils { Event eventToSend = new Event(event.getEventType(), eventsRequestContext.getSession(), eventsRequestContext.getProfile(), event.getScope(), event.getSource(), event.getTarget(), event.getProperties(), eventsRequestContext.getTimestamp(), event.isPersistent()); eventToSend.setFlattenedProperties(event.getFlattenedProperties()); - // Check if V2 compatibility mode is enabled and handle V2-style event authorization - if (restAuthenticationConfig.isV2CompatibilityModeEnabled()) { + // Check if single-tenant compatibility mode is enabled and handle V2-style event authorization + if (restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled()) { if (!isEventAllowedInV2CompatibilityMode(event, eventsRequestContext.getRequest())) { - LOGGER.debug("Event {} not authorized in V2 compatibility mode from IP {}", event.getEventType(), eventsRequestContext.getRequest().getRemoteAddr()); + LOGGER.debug("Event {} not authorized in single-tenant compatibility mode from IP {}", event.getEventType(), eventsRequestContext.getRequest().getRemoteAddr()); //Don't count the event that failed eventsRequestContext.setProcessedItems(eventsRequestContext.getProcessedItems() - 1); continue; @@ -540,7 +540,7 @@ public class RestServiceUtilsImpl implements RestServiceUtils { } /** - * Check if an event is allowed in V2 compatibility mode. + * Check if an event is allowed in single-tenant compatibility mode. * In V2, protected events required IP + X-Unomi-Peer (third-party key) authentication. * * @param event the event to check @@ -550,7 +550,7 @@ public class RestServiceUtilsImpl implements RestServiceUtils { 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 + // Non-protected events are always allowed in single-tenant compatibility mode return true; } @@ -559,18 +559,18 @@ public class RestServiceUtilsImpl implements RestServiceUtils { 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()); + LOGGER.debug("single-tenant 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: {}", + LOGGER.debug("single-tenant compatibility mode: Protected event {} rejected - invalid third-party provider key: {} from IP: {}", event.getEventType(), SecurityUtils.maskSecret(thirdPartyKey), sourceIP); return false; } - LOGGER.debug("V2 compatibility mode: Protected event {} allowed for provider key: {} from IP: {}", + LOGGER.debug("single-tenant compatibility mode: Protected event {} allowed for provider key: {} from IP: {}", event.getEventType(), SecurityUtils.maskSecret(thirdPartyKey), sourceIP); return true; } diff --git a/rest/src/main/resources/org.apache.unomi.rest.authentication.cfg b/rest/src/main/resources/org.apache.unomi.rest.authentication.cfg index db79d2627..58a9475f4 100644 --- a/rest/src/main/resources/org.apache.unomi.rest.authentication.cfg +++ b/rest/src/main/resources/org.apache.unomi.rest.authentication.cfg @@ -22,10 +22,9 @@ # - 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} +singletenantcompatibility.enabled = ${org.apache.unomi.rest.authentication.singleTenantCompatibilityModeEnabled:-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 +# Default tenant ID to use in single-tenant compatibility mode +# This tenant will be used for all operations when single-tenant compatibility mode is enabled # Should match the tenant ID used during migration (e.g., "default" or "system") -v2.compatibilitymode.defaultTenantId = ${org.apache.unomi.rest.authentication.v2CompatibilityDefaultTenantId:-default} diff --git a/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java index 016a45756..1b4baac8e 100644 --- a/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java +++ b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java @@ -171,15 +171,15 @@ class AuthenticationFilterBlankPasswordTest { } /** - * V2 compatibility mode routes every request through {@link AuthenticationFilter}'s own + * single-tenant compatibility mode routes every request through {@link AuthenticationFilter}'s own * private-endpoint branch, which consumes the Basic credential at a third, separate call site. * Without this test that call site is unreachable from the suite: the other tests leave - * {@code isV2CompatibilityModeEnabled()} at the unstubbed Mockito {@code false}, so deleting + * {@code isSingleTenantCompatibilityModeEnabled()} at the unstubbed Mockito {@code false}, so deleting * the guard there would leave every test green. */ @Test void filterRejectsBlankPasswordOnAPrivatePathInV2CompatibilityMode() throws IOException { - when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true); + when(restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled()).thenReturn(true); when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList()); ContainerRequestContext requestContext = request("profiles", basic("karaf:")); @@ -191,7 +191,7 @@ class AuthenticationFilterBlankPasswordTest { /** Control for the V2 branch: a non-blank credential must still reach JAAS there too. */ @Test void filterPassesNonBlankPasswordToJaasOnAPrivatePathInV2CompatibilityMode() throws IOException { - when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true); + when(restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled()).thenReturn(true); when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList()); ContainerRequestContext requestContext = request("profiles", basic("karaf:a-strong-password")); @@ -201,20 +201,19 @@ class AuthenticationFilterBlankPasswordTest { } /** - * A public path in V2 compatibility mode authenticates by default tenant, ignoring + * A public path in single-tenant compatibility mode authenticates by default tenant, ignoring * {@code Authorization} entirely — so a stray blank Basic header must not turn it into a 401. */ @Test - void filterDoesNotRejectAStrayBlankBasicHeaderOnAPublicPathInV2CompatibilityMode() throws IOException { - when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true); + void filterDoesNotRejectAStrayBlankBasicHeaderOnAPublicPathInSingleTenantCompatibilityMode() throws IOException { + when(restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled()).thenReturn(true); when(restAuthenticationConfig.getPublicPathPatterns()) .thenReturn(Collections.singletonList(Pattern.compile("POST context\\.json"))); - when(restAuthenticationConfig.getV2CompatibilityDefaultTenantId()).thenReturn("default"); ContainerRequestContext requestContext = request("context.json", basic("someone:")); filter.filter(requestContext); - verify(tenantService).getTenant("default"); + verify(tenantService).getOrCreateTenant(eq("default"), any()); } private void assertUnauthorizedWithoutReachingJaas(ContainerRequestContext requestContext) throws IOException { diff --git a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantServiceImpl.java index 0359d9dbf..c2c6e474d 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantServiceImpl.java @@ -113,6 +113,25 @@ public class TenantServiceImpl implements TenantService { } } + @Override + public synchronized Tenant getOrCreateTenant(String tenantId, Map<String, Object> properties) { + Tenant tenant = getTenant(tenantId); + if (tenant != null) { + return tenant; + } + try { + return createTenant(tenantId, properties); + } catch (IllegalArgumentException e) { + // Another node created the tenant between the read and the write, which is the outcome + // this method is asked for. Re-read rather than fail. + Tenant concurrent = getTenant(tenantId); + if (concurrent == null) { + throw e; + } + return concurrent; + } + } + @Override public Tenant createTenant(String requestedId, Map<String, Object> properties) { validateTenantId(requestedId); diff --git a/services/src/test/java/org/apache/unomi/services/impl/TestTenantService.java b/services/src/test/java/org/apache/unomi/services/impl/TestTenantService.java index 59723a95f..b33839b28 100644 --- a/services/src/test/java/org/apache/unomi/services/impl/TestTenantService.java +++ b/services/src/test/java/org/apache/unomi/services/impl/TestTenantService.java @@ -105,6 +105,12 @@ public class TestTenantService implements TenantService { && secretHashService.verify(plainTextKey, apiKey.getKeyHash()); } + @Override + public Tenant getOrCreateTenant(String tenantId, Map<String, Object> properties) { + Tenant tenant = getTenant(tenantId); + return tenant != null ? tenant : createTenant(tenantId, properties); + } + @Override public Tenant createTenant(String tenantId, Map<String, Object> properties) { Tenant tenant = new Tenant();
