This is an automated email from the ASF dual-hosted git repository. sergehuber pushed a commit to branch UNOMI-1001-compat-peer-abilities in repository https://gitbox.apache.org/repos/asf/unomi.git
commit 06e2b04e415283df325333eff7473c893cbe393c Author: Serge Huber <[email protected]> AuthorDate: Fri Oct 9 15:19:26 2026 +0200 UNOMI-1001: Restore 3.0 peer abilities in compatibility mode A valid X-Unomi-Peer key from an allowed IP again grants setEventId, chooseProfileId, mergeOnLogin and updateOtherProfiles on public endpoints only, without administrator roles. Harden provider keys (length, example key, constant-time compare) and honor X-Forwarded-For only behind trusted proxies. --- .../unomi/api/security/CompatPeerPrincipal.java | 107 ++++++++ .../apache/unomi/api/security/SecurityService.java | 13 + .../test/java/org/apache/unomi/itests/BaseIT.java | 2 + .../itests/SingleTenantCompatibilityModeIT.java | 60 +++++ .../asciidoc/migrations/v2-compatibility-mode.adoc | 45 +++- .../main/resources/etc/custom.system.properties | 3 + .../plugins/baseplugin/actions/IdentityTrust.java | 19 +- .../actions/MergeProfilesOnPropertyAction.java | 3 +- .../baseplugin/actions/UpdatePropertiesAction.java | 4 +- .../authentication/V2ThirdPartyConfigService.java | 285 ++++++++++++++++----- .../rest/service/impl/RestServiceUtilsImpl.java | 48 +++- .../V2ThirdPartyConfigServiceTest.java | 137 ++++++++++ .../common/security/KarafSecurityService.java | 20 ++ .../services/common/security/SecurityUtils.java | 25 ++ .../common/security/SecurityUtilsTest.java | 11 + .../main/resources/org.apache.unomi.thirdparty.cfg | 22 +- 16 files changed, 703 insertions(+), 101 deletions(-) diff --git a/api/src/main/java/org/apache/unomi/api/security/CompatPeerPrincipal.java b/api/src/main/java/org/apache/unomi/api/security/CompatPeerPrincipal.java new file mode 100644 index 000000000..f460b03ac --- /dev/null +++ b/api/src/main/java/org/apache/unomi/api/security/CompatPeerPrincipal.java @@ -0,0 +1,107 @@ +/* + * 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.api.security; + +import java.security.Principal; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; + +/** + * Marks a request subject as a validated third-party peer in single-tenant compatibility mode. + * <p> + * This is not an administrator role: it only carries the abilities that Unomi 3.0 gave a server + * that passed the {@code X-Unomi-Peer} check (choose a profile id, set an event id, merge on login, + * update another profile). Private / admin endpoints ignore it. + */ +public class CompatPeerPrincipal implements Principal { + + /** Ability to keep a client-supplied event item id. */ + public static final String ABILITY_SET_EVENT_ID = "setEventId"; + /** Ability to bind the request to a body-supplied profile id. */ + public static final String ABILITY_CHOOSE_PROFILE_ID = "chooseProfileId"; + /** Ability to claim a merge identifier and merge profiles on login. */ + public static final String ABILITY_MERGE_ON_LOGIN = "mergeOnLogin"; + /** Ability to update another profile and write segments, scores, consents and systemProperties. */ + public static final String ABILITY_UPDATE_OTHER_PROFILES = "updateOtherProfiles"; + + /** The 3.0 peer set: every ability above. Used when a provider lists no abilities of its own. */ + public static final Set<String> DEFAULT_ABILITIES = Set.of( + ABILITY_SET_EVENT_ID, + ABILITY_CHOOSE_PROFILE_ID, + ABILITY_MERGE_ON_LOGIN, + ABILITY_UPDATE_OTHER_PROFILES + ); + + private final String providerId; + private final Set<String> abilities; + + /** + * @param providerId the configured provider name + * @param abilities abilities granted to this peer; empty or null means {@link #DEFAULT_ABILITIES} + */ + public CompatPeerPrincipal(String providerId, Set<String> abilities) { + if (providerId == null || providerId.isBlank()) { + throw new IllegalArgumentException("providerId cannot be blank"); + } + this.providerId = providerId; + if (abilities == null || abilities.isEmpty()) { + this.abilities = DEFAULT_ABILITIES; + } else { + this.abilities = Collections.unmodifiableSet(Set.copyOf(abilities)); + } + } + + public String getProviderId() { + return providerId; + } + + public Set<String> getAbilities() { + return abilities; + } + + public boolean hasAbility(String ability) { + return ability != null && abilities.contains(ability); + } + + @Override + public String getName() { + return providerId; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CompatPeerPrincipal that = (CompatPeerPrincipal) o; + return Objects.equals(providerId, that.providerId) && Objects.equals(abilities, that.abilities); + } + + @Override + public int hashCode() { + return Objects.hash(providerId, abilities); + } + + @Override + public String toString() { + return "CompatPeerPrincipal[" + providerId + ", abilities=" + abilities + "]"; + } +} diff --git a/api/src/main/java/org/apache/unomi/api/security/SecurityService.java b/api/src/main/java/org/apache/unomi/api/security/SecurityService.java index 656f88f88..c4289b3a0 100644 --- a/api/src/main/java/org/apache/unomi/api/security/SecurityService.java +++ b/api/src/main/java/org/apache/unomi/api/security/SecurityService.java @@ -206,6 +206,19 @@ public interface SecurityService { */ boolean hasSystemAccess(); + /** + * Checks whether the current subject is a validated single-tenant-compatibility peer + * that was granted the given ability (see {@link CompatPeerPrincipal}). + * <p> + * This is never true for an administrator role on its own: only a subject that carries a + * {@link CompatPeerPrincipal} with that ability returns {@code true}. Callers that also accept + * admin trust should check {@link #hasSystemAccess()} separately. + * + * @param ability one of the ability constants on {@link CompatPeerPrincipal} + * @return {@code true} when the request subject is a compat peer with that ability + */ + boolean hasCompatPeerAbility(String ability); + /** * System subject with administrative privileges. * diff --git a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java index 8484ee4d0..7f326360a 100644 --- a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java @@ -690,6 +690,8 @@ public abstract class BaseIT extends KarafTestSupport { // they are checked. Purge ITs call the purge methods directly, so switch the schedule off. editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.profile.purge.inactiveTime", "-1"), editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.monthly.index.purge.existTime", "-1"), + // Compatibility-mode ITs use the shipped example X-Unomi-Peer key on localhost. + editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.thirdparty.allowExampleKey", "true"), // The router's base directories have to be set here rather than in // etc/org.apache.unomi.router.cfg: Karaf's configuration plugin overrides every diff --git a/itests/src/test/java/org/apache/unomi/itests/SingleTenantCompatibilityModeIT.java b/itests/src/test/java/org/apache/unomi/itests/SingleTenantCompatibilityModeIT.java index 8291e60c8..ed2394f5d 100644 --- a/itests/src/test/java/org/apache/unomi/itests/SingleTenantCompatibilityModeIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/SingleTenantCompatibilityModeIT.java @@ -371,6 +371,66 @@ public class SingleTenantCompatibilityModeIT extends BaseIT { assertEquals("Non-protected event without auth should have 1 processed event", 1, response.getContextResponse().getProcessedEvents()); } + @Test + public void testCompatPeerCanChooseProfileIdAndSetEventItemId() throws Exception { + LOGGER.info("Testing compat peer abilities: chooseProfileId and setEventId"); + + 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); + + String chosenProfileId = "compat-peer-profile-" + UUID.randomUUID(); + String chosenEventId = "compat-peer-event-" + UUID.randomUUID(); + + Event loginEvent = new Event(); + loginEvent.setItemId(chosenEventId); + loginEvent.setEventType("login"); + loginEvent.setScope(TEST_SCOPE); + + ContextRequest contextRequest = new ContextRequest(); + contextRequest.setSessionId(TEST_SESSION_ID); + contextRequest.setProfileId(chosenProfileId); + contextRequest.setEvents(Collections.singletonList(loginEvent)); + + // Without peer: body profileId and event itemId must not be honoured (975 / event-id gate). + 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(200, response.getStatusCode()); + assertNotEquals("Visitor must not bind a body profileId", chosenProfileId, response.getContextResponse().getProfileId()); + assertEquals("Protected login without peer must not be processed", 0, response.getContextResponse().getProcessedEvents()); + + // With valid peer: profile id and event item id are accepted. + request = new HttpPost(getFullUrl(CONTEXT_URL)); + request.addHeader(UNOMI_PEER_HEADER, V2ThirdPartyConfigService.EXAMPLE_PROVIDER_KEY); + request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); + response = executeContextJSONRequest(request, TEST_SESSION_ID); + assertEquals(200, response.getStatusCode()); + assertEquals("Compat peer must bind the body profileId", chosenProfileId, response.getContextResponse().getProfileId()); + assertEquals(1, response.getContextResponse().getProcessedEvents()); + + keepTrying("Peer-created profile not found", + () -> { + executionContextManager.setCurrentContext(executionContextManager.createContext(COMPATIBILITY_TENANT_ID)); + try { + return profileService.load(chosenProfileId); + } finally { + executionContextManager.setCurrentContext(executionContextManager.createContext(testTenant.getItemId())); + } + }, + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + executionContextManager.setCurrentContext(executionContextManager.createContext(COMPATIBILITY_TENANT_ID)); + try { + Event stored = persistenceService.load(chosenEventId, Event.class); + assertNotNull("Compat peer must keep the client-supplied event item id", stored); + assertEquals("login", stored.getEventType()); + } finally { + executionContextManager.setCurrentContext(executionContextManager.createContext(testTenant.getItemId())); + } + } + @Test public void testV2CompatibilityProtectedEventNegativeCases() throws Exception { LOGGER.info("Testing single-tenant compatibility mode - protected event negative cases"); diff --git a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc index e97b359af..7364c308d 100644 --- a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc +++ b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc @@ -172,38 +172,55 @@ When single-tenant compatibility mode is enabled: - **All operations** use tenant `default` - **Non-protected events** require no authentication (same as V2) -===== Protected Events in V2 Compatibility Mode +===== Protected Events and Peer Abilities -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: +In single-tenant compatibility mode, a server that sends a valid `X-Unomi-Peer` key from an allowed IP address keeps the same abilities it had in Unomi 3.0 on *public* endpoints only: + +- send protected events listed for that provider (for example `login`, `updateProperties`) +- set the event item id +- choose the profile id in the request body +- merge profiles on login / claim a merge identifier +- update another profile's properties, segments, scores, consents and `systemProperties` + +The peer header does **not** open private / admin endpoints, and it does nothing when compatibility mode is off. Visitors without a valid peer key still get none of these abilities (UNOMI-975 / UNOMI-978 stay in force for them). + +By default the following event types are protected for `provider1`: - `login` - User authentication events - `updateProperties` - Profile property updates -Additional event types can be configured as protected by editing the V2 third-party configuration file. - For protected events, clients must: -1. Send the request from an authorized IP address (configured in the V2 third-party configuration) -2. Include the `X-Unomi-Peer` header with the third-party ID (e.g., "provider1") -All other event types are considered non-protected and require no authentication. +1. Send the request from an authorized IP address (or from behind a configured trusted reverse proxy) +2. Include the `X-Unomi-Peer` header with the provider *key* (the secret, not the provider name) + +All other event types are non-protected and require no peer header. ===== V2 Third-Party Configuration -The protected events and third-party providers are configured in the original V2 configuration file `etc/org.apache.unomi.thirdparty.cfg`. The system dynamically detects any number of providers using the pattern `thirdparty.{providerName}.{property}`: +Providers are configured in `etc/org.apache.unomi.thirdparty.cfg` with the pattern `thirdparty.{providerName}.{property}`: ```properties -# Provider 1 Configuration (default provider) +# Global (optional) +# allowExampleKey=true only during a transition; logs a warning at every start +allowExampleKey=${org.apache.unomi.thirdparty.allowExampleKey:-false} +# When the direct connection is in this list, the first X-Forwarded-For hop is used for IP checks +trustedProxies=${org.apache.unomi.thirdparty.trustedProxies:-} + +# Provider 1 — replace the key before production use thirdparty.provider1.key=${org.apache.unomi.thirdparty.provider1.key:-670c26d1cc413346c3b2fd9ce65dab41} thirdparty.provider1.ipAddresses=${org.apache.unomi.thirdparty.provider1.ipAddresses:-127.0.0.1,::1} thirdparty.provider1.allowedEvents=${org.apache.unomi.thirdparty.provider1.allowedEvents:-login,updateProperties} +thirdparty.provider1.abilities=${org.apache.unomi.thirdparty.provider1.abilities:-setEventId,chooseProfileId,mergeOnLogin,updateOtherProfiles} -# Additional providers can be added dynamically -thirdparty.myapp.key=${org.apache.unomi.thirdparty.myapp.key:-my-secret-key} -thirdparty.myapp.ipAddresses=${org.apache.unomi.thirdparty.myapp.ipAddresses:-192.168.1.0/24} -thirdparty.myapp.allowedEvents=${org.apache.unomi.thirdparty.myapp.allowedEvents:-login,updateProperties,sessionCreated} +# Additional providers +thirdparty.myapp.key=replace-with-a-long-unique-secret +thirdparty.myapp.ipAddresses=192.168.1.0/24 +thirdparty.myapp.allowedEvents=login,updateProperties,sessionCreated +# abilities omitted → all four Unomi 3.0 peer abilities ``` -This uses the exact same configuration format as V2, ensuring complete compatibility with existing V2 setups. The system automatically detects and configures any provider that has a valid key. +A provider whose key is empty, shorter than 16 characters, or equal to the well-known example value is **disabled** at startup with a clear error, unless `allowExampleKey=true`. Keys are compared in constant time and never written in full to the logs. ===== Configuration Management diff --git a/package/src/main/resources/etc/custom.system.properties b/package/src/main/resources/etc/custom.system.properties index a0ef8c6da..5ca5f1731 100644 --- a/package/src/main/resources/etc/custom.system.properties +++ b/package/src/main/resources/etc/custom.system.properties @@ -297,9 +297,12 @@ org.apache.unomi.scheduler.thread.poolSize=${env:UNOMI_SCHEDULER_THREAD_POOL_SIZ # Example : provider1 is allowed to send login and download events from # localhost , with key provided in X-Unomi-Api-Key # +org.apache.unomi.thirdparty.allowExampleKey=${env:UNOMI_THIRDPARTY_ALLOW_EXAMPLE_KEY:-false} +org.apache.unomi.thirdparty.trustedProxies=${env:UNOMI_THIRDPARTY_TRUSTED_PROXIES:-} org.apache.unomi.thirdparty.provider1.key=${env:UNOMI_THIRDPARTY_PROVIDER1_KEY:-670c26d1cc413346c3b2fd9ce65dab41} org.apache.unomi.thirdparty.provider1.ipAddresses=${env:UNOMI_THIRDPARTY_PROVIDER1_IPADDRESSES:-127.0.0.1,::1} org.apache.unomi.thirdparty.provider1.allowedEvents=${env:UNOMI_THIRDPARTY_PROVIDER1_ALLOWEDEVENTS:-login,updateProperties} +org.apache.unomi.thirdparty.provider1.abilities=${env:UNOMI_THIRDPARTY_PROVIDER1_ABILITIES:-setEventId,chooseProfileId,mergeOnLogin,updateOtherProfiles} ####################################################################################################################### ## Profile cookie & download settings ## diff --git a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/IdentityTrust.java b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/IdentityTrust.java index c4052042e..f629a9175 100644 --- a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/IdentityTrust.java +++ b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/IdentityTrust.java @@ -16,6 +16,7 @@ */ package org.apache.unomi.plugins.baseplugin.actions; +import org.apache.unomi.api.security.CompatPeerPrincipal; import org.apache.unomi.api.security.SecurityService; import org.apache.unomi.api.security.UnomiRoles; @@ -24,10 +25,10 @@ import org.apache.unomi.api.security.UnomiRoles; * profiles, recording a merge identifier, updating another profile, or writing * {@code systemProperties}. * <p> - * This is a role check, not a check of the credential that produced it: a tenant private key - * authenticates as {@link UnomiRoles#TENANT_ADMINISTRATOR} and therefore passes, while a tenant - * public API key or an unauthenticated context event does not. A missing - * {@link SecurityService} fails closed. + * A tenant private key authenticates as {@link UnomiRoles#TENANT_ADMINISTRATOR} and therefore + * passes. A validated single-tenant-compatibility peer with the matching ability also passes + * (see {@link CompatPeerPrincipal}). A tenant public API key or an unauthenticated context event + * does not. A missing {@link SecurityService} fails closed. */ final class IdentityTrust { @@ -36,9 +37,13 @@ final class IdentityTrust { /** * @param securityService the security service, possibly null while OSGi wiring is in flux - * @return whether the caller holds system access (administrator or tenant administrator) + * @param ability a {@link CompatPeerPrincipal} ability required of a compat peer + * @return whether the caller holds system access or the given compat-peer ability */ - static boolean isTrustedIdentityCaller(SecurityService securityService) { - return securityService != null && securityService.hasSystemAccess(); + static boolean isTrustedIdentityCaller(SecurityService securityService, String ability) { + if (securityService == null) { + return false; + } + return securityService.hasSystemAccess() || securityService.hasCompatPeerAbility(ability); } } diff --git a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyAction.java b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyAction.java index 7ef891b24..29695703b 100644 --- a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyAction.java +++ b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyAction.java @@ -25,6 +25,7 @@ import org.apache.unomi.api.Session; import org.apache.unomi.api.actions.Action; import org.apache.unomi.api.actions.ActionExecutor; import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.security.CompatPeerPrincipal; import org.apache.unomi.api.security.SecurityService; import org.apache.unomi.api.services.*; import org.apache.unomi.persistence.spi.PersistenceService; @@ -354,7 +355,7 @@ public class MergeProfilesOnPropertyAction implements ActionExecutor { * no candidate profile exists yet: the switch still rebinds the session to a new profile. */ private boolean isTrustedIdentityCaller() { - return IdentityTrust.isTrustedIdentityCaller(securityService); + return IdentityTrust.isTrustedIdentityCaller(securityService, CompatPeerPrincipal.ABILITY_MERGE_ON_LOGIN); } public void setProfileService(ProfileService profileService) { diff --git a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java index 005a258c7..c6fc152f1 100644 --- a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java +++ b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java @@ -24,6 +24,7 @@ import org.apache.unomi.api.Profile; import org.apache.unomi.api.PropertyType; import org.apache.unomi.api.actions.Action; import org.apache.unomi.api.actions.ActionExecutor; +import org.apache.unomi.api.security.CompatPeerPrincipal; import org.apache.unomi.api.security.SecurityService; import org.apache.unomi.api.utils.LogSanitizer; import org.apache.unomi.api.services.EventService; @@ -226,7 +227,8 @@ public class UpdatePropertiesAction implements ActionExecutor { } private boolean isTrustedIdentityCaller() { - return IdentityTrust.isTrustedIdentityCaller(securityService); + return IdentityTrust.isTrustedIdentityCaller(securityService, + CompatPeerPrincipal.ABILITY_UPDATE_OTHER_PROFILES); } public void setProfileService(ProfileService profileService) { 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 56dff48df..53ecc5f65 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 @@ -18,27 +18,29 @@ package org.apache.unomi.rest.authentication; import org.apache.commons.lang3.StringUtils; +import org.apache.unomi.api.security.CompatPeerPrincipal; import org.apache.unomi.services.common.security.IPValidationUtils; import org.apache.unomi.services.common.security.SecurityUtils; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Modified; -import org.osgi.service.metatype.annotations.AttributeDefinition; import org.osgi.service.metatype.annotations.Designate; import org.osgi.service.metatype.annotations.ObjectClassDefinition; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.Arrays; +import javax.servlet.http.HttpServletRequest; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.Optional; import java.util.Set; /** - * OSGi service that loads V2 third-party provider configuration from {@code org.apache.unomi.thirdparty.cfg} - * and validates protected events and provider keys in V2 compatibility mode. + * Loads third-party provider configuration from {@code org.apache.unomi.thirdparty.cfg} + * and validates {@code X-Unomi-Peer} keys in single-tenant compatibility mode. */ @Component(service = V2ThirdPartyConfigService.class, configurationPid = "org.apache.unomi.thirdparty") @Designate(ocd = V2ThirdPartyConfigService.Config.class) @@ -46,40 +48,65 @@ public class V2ThirdPartyConfigService { private static final Logger LOGGER = LoggerFactory.getLogger(V2ThirdPartyConfigService.class); + /** Well-known key shipped in examples and old defaults; never safe for production. */ + public static final String EXAMPLE_PROVIDER_KEY = "670c26d1cc413346c3b2fd9ce65dab41"; + + /** Minimum accepted key length (UTF-16 code units). Shorter keys disable the provider. */ + public static final int MIN_KEY_LENGTH = 16; + + private static final String PROP_ALLOW_EXAMPLE_KEY = "allowExampleKey"; + private static final String PROP_TRUSTED_PROXIES = "trustedProxies"; + /** * OSGi configuration for V2 third-party providers. */ @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" + description = "Configuration for third-party providers (single-tenant compatibility mode). " + + "Providers use thirdparty.{name}.key, .ipAddresses, .allowedEvents and optional .abilities. " + + "Global: allowExampleKey, trustedProxies." ) public @interface Config { - // No hardcoded attributes - all providers are configured dynamically - // using the pattern: thirdparty.{providerName}.{property} } /** - * Provider configuration entry parsed from OSGi properties. + * A configured provider that passed key hygiene checks. */ - private static class ProviderConfig { + public static final class ProviderConfig { private final String key; private final Set<String> ipAddresses; private final Set<String> allowedEvents; + private final Set<String> abilities; - public ProviderConfig(String key, Set<String> ipAddresses, Set<String> allowedEvents) { + public ProviderConfig(String key, Set<String> ipAddresses, Set<String> allowedEvents, Set<String> abilities) { this.key = key; this.ipAddresses = ipAddresses; this.allowedEvents = allowedEvents; + this.abilities = abilities == null || abilities.isEmpty() + ? CompatPeerPrincipal.DEFAULT_ABILITIES + : Collections.unmodifiableSet(abilities); + } + + public String getKey() { + return key; + } + + public Set<String> getIpAddresses() { + return ipAddresses; + } + + public Set<String> getAllowedEvents() { + return allowedEvents; } - public String getKey() { return key; } - public Set<String> getIpAddresses() { return ipAddresses; } - public Set<String> getAllowedEvents() { return allowedEvents; } + public Set<String> getAbilities() { + return abilities; + } } private volatile Map<String, ProviderConfig> providers = new HashMap<>(); + private volatile Set<String> trustedProxies = Collections.emptySet(); + private volatile boolean allowExampleKey; /** * Activates the service and loads third-party provider configuration. @@ -99,61 +126,83 @@ public class V2ThirdPartyConfigService { @Modified public void modified(Map<String, Object> properties) { Map<String, ProviderConfig> newProviders = new HashMap<>(); + boolean allowExample = false; + Set<String> proxies = new LinkedHashSet<>(); if (properties != null) { - // Phase 1: collect raw property values per provider, order-independent + allowExample = Boolean.parseBoolean(String.valueOf(properties.getOrDefault(PROP_ALLOW_EXAMPLE_KEY, "false"))); + proxies.addAll(parseCommaSeparatedList(stringValue(properties.get(PROP_TRUSTED_PROXIES)))); + Map<String, Map<String, String>> rawProviders = new HashMap<>(); for (Map.Entry<String, Object> entry : properties.entrySet()) { String propKey = entry.getKey(); - String value = entry.getValue() != null ? entry.getValue().toString() : ""; - - // Look for provider configuration patterns: thirdparty.{providerName}.{property} - if (propKey.startsWith("thirdparty.") && propKey.contains(".")) { - String[] parts = propKey.split("\\."); - if (parts.length >= 3) { - String providerName = parts[1]; - String property = parts[2]; - rawProviders.computeIfAbsent(providerName, k -> new HashMap<>()).put(property, value); - } + if (!propKey.startsWith("thirdparty.") || !propKey.contains(".")) { + continue; + } + String[] parts = propKey.split("\\."); + if (parts.length >= 3) { + String providerName = parts[1]; + String property = parts[2]; + rawProviders.computeIfAbsent(providerName, k -> new HashMap<>()) + .put(property, stringValue(entry.getValue())); } } - // Phase 2: build ProviderConfig objects — only for providers that have a key for (Map.Entry<String, Map<String, String>> entry : rawProviders.entrySet()) { String providerName = entry.getKey(); Map<String, String> props = entry.getValue(); String configKey = props.get("key"); - if (StringUtils.isNotBlank(configKey)) { - Set<String> configIpAddresses = parseCommaSeparatedList(props.getOrDefault("ipAddresses", "")); - Set<String> configAllowedEvents = parseCommaSeparatedList(props.getOrDefault("allowedEvents", "")); - newProviders.put(providerName, new ProviderConfig(configKey, configIpAddresses, configAllowedEvents)); + if (StringUtils.isBlank(configKey)) { + LOGGER.error("Third-party provider '{}' is disabled: its key is empty. " + + "Set thirdparty.{}.key to a secret of at least {} characters.", + providerName, providerName, MIN_KEY_LENGTH); + continue; + } + if (configKey.length() < MIN_KEY_LENGTH) { + LOGGER.error("Third-party provider '{}' is disabled: its key is shorter than {} characters. " + + "Choose a longer secret for thirdparty.{}.key.", + providerName, MIN_KEY_LENGTH, providerName); + continue; + } + if (EXAMPLE_PROVIDER_KEY.equals(configKey) && !allowExample) { + LOGGER.error("Third-party provider '{}' is disabled: it uses the well-known example key. " + + "Set a unique secret for thirdparty.{}.key, or set allowExampleKey=true only " + + "during a transition (a warning is logged at every start).", + providerName, providerName); + continue; } + if (EXAMPLE_PROVIDER_KEY.equals(configKey) && allowExample) { + LOGGER.warn("Third-party provider '{}' uses the well-known example key with allowExampleKey=true. " + + "Replace thirdparty.{}.key before production use.", + providerName, providerName); + } + + Set<String> configIpAddresses = parseCommaSeparatedList(props.getOrDefault("ipAddresses", "")); + Set<String> configAllowedEvents = parseCommaSeparatedList(props.getOrDefault("allowedEvents", "")); + Set<String> abilities = parseAbilities(props.get("abilities")); + newProviders.put(providerName, + new ProviderConfig(configKey, configIpAddresses, configAllowedEvents, abilities)); } } if (newProviders.isEmpty()) { - // The fallback key below is the well-known Unomi V2 default key, publicly documented - // in Apache Unomi changelogs and issue trackers. It provides no confidentiality on its own - // and is restricted to localhost only as a partial mitigation. - // Configure org.apache.unomi.thirdparty.cfg with a custom key before production use. - LOGGER.warn("V2 compatibility mode: no third-party providers configured in org.apache.unomi.thirdparty.cfg — " + - "falling back to the well-known default key restricted to localhost. " + - "Configure a custom provider key before using V2 compatibility mode in production."); - newProviders.put("provider1", new ProviderConfig( - "670c26d1cc413346c3b2fd9ce65dab41", - new HashSet<>(Arrays.asList("127.0.0.1", "::1")), - new HashSet<>(Arrays.asList("login", "updateProperties")) - )); + LOGGER.error("No usable third-party providers are configured in org.apache.unomi.thirdparty.cfg. " + + "Protected events and peer abilities in single-tenant compatibility mode will be refused. " + + "Add thirdparty.{{name}}.key (at least {} characters, not the example key unless " + + "allowExampleKey=true), .ipAddresses and .allowedEvents.", + MIN_KEY_LENGTH); } + this.allowExampleKey = allowExample; + this.trustedProxies = Collections.unmodifiableSet(proxies); 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); + .mapToInt(config -> config.getAllowedEvents().size()) + .sum(); + LOGGER.info("V2 Third-Party Configuration updated - {} providers with {} total protected events" + + " (allowExampleKey={}, trustedProxies={})", + newProviders.size(), totalEvents, allowExample, proxies.size()); } /** @@ -166,9 +215,8 @@ public class V2ThirdPartyConfigService { if (StringUtils.isBlank(eventType)) { return false; } - return providers.values().stream() - .anyMatch(config -> config.getAllowedEvents().contains(eventType)); + .anyMatch(config -> config.getAllowedEvents().contains(eventType)); } /** @@ -184,6 +232,30 @@ public class V2ThirdPartyConfigService { return Collections.unmodifiableSet(allProtectedEvents); } + /** + * Authenticates the {@code X-Unomi-Peer} header for key and client IP only (no event-type check). + * Used to attach peer abilities for the whole public request in compatibility mode. + * + * @param request the HTTP request + * @return a principal when key and IP match a provider, otherwise empty + */ + public Optional<CompatPeerPrincipal> authenticatePeer(HttpServletRequest request) { + if (request == null) { + return Optional.empty(); + } + String providerKey = request.getHeader("X-Unomi-Peer"); + if (StringUtils.isBlank(providerKey)) { + return Optional.empty(); + } + String sourceIP = resolveClientIp(request); + return findProviderByKey(providerKey) + .filter(match -> IPValidationUtils.isIpAuthorized(sourceIP, match.config().getIpAddresses())) + .map(match -> { + LOGGER.debug("Compat peer authenticated: provider={} from IP={}", match.providerId(), sourceIP); + return new CompatPeerPrincipal(match.providerId(), match.config().getAbilities()); + }); + } + /** * Validates a provider key from the {@code X-Unomi-Peer} header for an event and source IP. * @@ -197,35 +269,50 @@ public class V2ThirdPartyConfigService { return false; } - // Find the provider that has the matching key - ProviderConfig config = null; - String foundProviderId = null; - for (Map.Entry<String, ProviderConfig> entry : providers.entrySet()) { - if (providerKey.equals(entry.getValue().getKey())) { - config = entry.getValue(); - foundProviderId = entry.getKey(); - break; - } - } - - if (config == null) { + Optional<ProviderMatch> match = findProviderByKey(providerKey); + if (match.isEmpty()) { LOGGER.debug("V2 compatibility mode: Unknown provider key: {}", SecurityUtils.maskSecret(providerKey)); return false; } + ProviderConfig config = match.get().config(); + String foundProviderId = match.get().providerId(); + if (!config.getAllowedEvents().contains(eventType)) { - LOGGER.debug("V2 compatibility mode: Event type {} not allowed for provider {} (key: {})", eventType, foundProviderId, SecurityUtils.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, SecurityUtils.maskSecret(providerKey)); + LOGGER.debug("V2 compatibility mode: IP {} not authorized for provider {} (key: {})", + sourceIP, foundProviderId, SecurityUtils.maskSecret(providerKey)); } - return ipAuthorized; } + /** + * Resolves the client IP for peer checks: the direct remote address, or the first + * {@code X-Forwarded-For} hop only when that direct address is a configured trusted proxy. + * + * @param request the HTTP request + * @return the IP to match against provider allow-lists + */ + public String resolveClientIp(HttpServletRequest request) { + String remoteAddr = request.getRemoteAddr(); + if (!trustedProxies.isEmpty() && IPValidationUtils.isIpAuthorized(remoteAddr, trustedProxies)) { + String xff = request.getHeader("X-Forwarded-For"); + if (StringUtils.isNotBlank(xff)) { + String candidate = xff.split(",")[0].trim(); + if (StringUtils.isNotBlank(candidate)) { + return candidate; + } + } + } + return remoteAddr; + } + /** * Returns the authentication key for the given provider ID. * @@ -247,14 +334,74 @@ public class V2ThirdPartyConfigService { return providers.containsKey(providerId); } + /** @return whether the example key is currently allowed */ + public boolean isAllowExampleKey() { + return allowExampleKey; + } + + /** @return configured trusted proxy CIDRs / addresses */ + public Set<String> getTrustedProxies() { + return trustedProxies; + } + + /** @return a snapshot of active providers (for tests) */ + public Map<String, ProviderConfig> getProviders() { + return Collections.unmodifiableMap(providers); + } + + private Optional<ProviderMatch> findProviderByKey(String providerKey) { + for (Map.Entry<String, ProviderConfig> entry : providers.entrySet()) { + if (SecurityUtils.constantTimeEquals(providerKey, entry.getValue().getKey())) { + return Optional.of(new ProviderMatch(entry.getKey(), entry.getValue())); + } + } + return Optional.empty(); + } + + private static final class ProviderMatch { + private final String providerId; + private final ProviderConfig config; + + private ProviderMatch(String providerId, ProviderConfig config) { + this.providerId = providerId; + this.config = config; + } + + private String providerId() { + return providerId; + } + + private ProviderConfig config() { + return config; + } + } + + private Set<String> parseAbilities(String value) { + if (StringUtils.isBlank(value)) { + return new HashSet<>(CompatPeerPrincipal.DEFAULT_ABILITIES); + } + Set<String> parsed = parseCommaSeparatedList(value); + Set<String> known = new HashSet<>(); + for (String ability : parsed) { + if (CompatPeerPrincipal.DEFAULT_ABILITIES.contains(ability)) { + known.add(ability); + } else { + LOGGER.warn("Ignoring unknown third-party ability '{}'", ability); + } + } + return known.isEmpty() ? new HashSet<>(CompatPeerPrincipal.DEFAULT_ABILITIES) : known; + } + + private static String stringValue(Object value) { + return value != null ? value.toString() : ""; + } + private Set<String> parseCommaSeparatedList(String value) { if (StringUtils.isBlank(value)) { return new HashSet<>(); } - Set<String> result = new HashSet<>(); - String[] parts = value.split(","); - for (String part : parts) { + for (String part : value.split(",")) { String trimmed = part.trim(); if (StringUtils.isNotBlank(trimmed)) { result.add(trimmed); @@ -262,6 +409,4 @@ public class V2ThirdPartyConfigService { } return result; } - - } 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 9bee93127..438ae8e20 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 @@ -21,6 +21,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.cxf.interceptor.security.RolePrefixSecurityContextImpl; import org.apache.cxf.jaxrs.utils.JAXRSUtils; import org.apache.unomi.api.*; +import org.apache.unomi.api.security.CompatPeerPrincipal; import org.apache.unomi.api.security.SecurityService; import org.apache.unomi.api.utils.LogSanitizer; import org.apache.unomi.api.security.TenantPrincipal; @@ -123,6 +124,9 @@ public class RestServiceUtilsImpl implements RestServiceUtils { boolean invalidateProfile, boolean invalidateSession, HttpServletRequest request, HttpServletResponse response, Date timestamp) { + // Attach 3.0 peer abilities before profile binding so chooseProfileId can apply. + applyCompatPeerTrust(request); + // Build context EventsRequestContext eventsRequestContext = new EventsRequestContext(timestamp, null, null, request, response); @@ -405,7 +409,7 @@ public class RestServiceUtilsImpl implements RestServiceUtils { continue; } } - if (securityContext.isUserInRole(UnomiRoles.TENANT_ADMINISTRATOR) && event.getItemId() != null) { + if (event.getItemId() != null && canSetEventItemId(securityContext)) { eventToSend = new Event(event.getItemId(), event.getEventType(), eventsRequestContext.getSession(), eventsRequestContext.getProfile(), event.getScope(), event.getSource(), event.getTarget(), event.getProperties(), eventsRequestContext.getTimestamp(), event.isPersistent()); eventToSend.setFlattenedProperties(event.getFlattenedProperties()); @@ -519,7 +523,45 @@ public class RestServiceUtilsImpl implements RestServiceUtils { * in the log to explain it, which is far harder to diagnose than the NPE that says so outright. */ private boolean isTrustedProfileCaller() { - return securityService.hasSystemAccess(); + return securityService.hasSystemAccess() + || securityService.hasCompatPeerAbility(CompatPeerPrincipal.ABILITY_CHOOSE_PROFILE_ID); + } + + private boolean canSetEventItemId(SecurityContext securityContext) { + return securityContext.isUserInRole(UnomiRoles.TENANT_ADMINISTRATOR) + || securityService.hasCompatPeerAbility(CompatPeerPrincipal.ABILITY_SET_EVENT_ID); + } + + /** + * When single-tenant compatibility mode is on and the request carries a valid {@code X-Unomi-Peer} + * key from an allowed IP, attach a {@link CompatPeerPrincipal} to the current guest subject. + * Does not grant administrator roles or open private endpoints. + */ + private void applyCompatPeerTrust(HttpServletRequest request) { + if (request == null || !restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled()) { + return; + } + Optional<CompatPeerPrincipal> peer = v2ThirdPartyConfigService.authenticatePeer(request); + if (peer.isEmpty()) { + return; + } + Subject current = securityService.getRequestSubject(); + if (current == null) { + current = securityService.getCurrentSubject(); + } + if (current == null) { + return; + } + if (!current.getPrincipals(CompatPeerPrincipal.class).isEmpty()) { + return; + } + Subject elevated = new Subject(); + elevated.getPrincipals().addAll(current.getPrincipals()); + elevated.getPrincipals().add(peer.get()); + elevated.getPublicCredentials().addAll(current.getPublicCredentials()); + elevated.getPrivateCredentials().addAll(current.getPrivateCredentials()); + securityService.setCurrentSubject(elevated); + LOGGER.debug("Attached compat peer principal for provider {}", peer.get().getProviderId()); } /** @@ -555,7 +597,7 @@ public class RestServiceUtilsImpl implements RestServiceUtils { } // For protected events, check IP + third-party key (V2-style) - String sourceIP = request.getRemoteAddr(); + String sourceIP = v2ThirdPartyConfigService.resolveClientIp(request); String thirdPartyKey = request.getHeader("X-Unomi-Peer"); if (StringUtils.isBlank(thirdPartyKey)) { diff --git a/rest/src/test/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigServiceTest.java b/rest/src/test/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigServiceTest.java new file mode 100644 index 000000000..6b6641bed --- /dev/null +++ b/rest/src/test/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigServiceTest.java @@ -0,0 +1,137 @@ +/* + * 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.unomi.api.security.CompatPeerPrincipal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.servlet.http.HttpServletRequest; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class V2ThirdPartyConfigServiceTest { + + private V2ThirdPartyConfigService service; + + @Mock + private HttpServletRequest request; + + @BeforeEach + void setUp() { + service = new V2ThirdPartyConfigService(); + } + + @Test + void exampleKeyIsDisabledUnlessAllowed() { + Map<String, Object> props = baseProvider(V2ThirdPartyConfigService.EXAMPLE_PROVIDER_KEY); + props.put("allowExampleKey", "false"); + service.modified(props); + assertTrue(service.getProviders().isEmpty()); + + props.put("allowExampleKey", "true"); + service.modified(props); + assertTrue(service.isValidProvider("provider1")); + assertTrue(service.isAllowExampleKey()); + } + + @Test + void shortAndEmptyKeysAreDisabled() { + Map<String, Object> props = new HashMap<>(); + props.put("thirdparty.short.key", "tooshort"); + props.put("thirdparty.empty.key", ""); + props.put("thirdparty.ok.key", "long-enough-secret-01"); + props.put("thirdparty.ok.ipAddresses", "127.0.0.1"); + props.put("thirdparty.ok.allowedEvents", "login"); + service.modified(props); + assertFalse(service.isValidProvider("short")); + assertFalse(service.isValidProvider("empty")); + assertTrue(service.isValidProvider("ok")); + } + + @Test + void abilitiesDefaultToFullSetAndCanBeLimited() { + Map<String, Object> props = baseProvider("long-enough-secret-01"); + service.modified(props); + assertEquals(CompatPeerPrincipal.DEFAULT_ABILITIES, service.getProviders().get("provider1").getAbilities()); + + props.put("thirdparty.provider1.abilities", "setEventId,chooseProfileId"); + service.modified(props); + assertEquals(2, service.getProviders().get("provider1").getAbilities().size()); + assertTrue(service.getProviders().get("provider1").getAbilities() + .contains(CompatPeerPrincipal.ABILITY_SET_EVENT_ID)); + } + + @Test + void authenticatePeerRequiresKeyAndIp() { + Map<String, Object> props = baseProvider("long-enough-secret-01"); + service.modified(props); + + when(request.getHeader("X-Unomi-Peer")).thenReturn("long-enough-secret-01"); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + Optional<CompatPeerPrincipal> peer = service.authenticatePeer(request); + assertTrue(peer.isPresent()); + assertEquals("provider1", peer.get().getProviderId()); + + when(request.getRemoteAddr()).thenReturn("10.0.0.1"); + assertTrue(service.authenticatePeer(request).isEmpty()); + + when(request.getHeader("X-Unomi-Peer")).thenReturn("wrong-key-wrong-key"); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + assertTrue(service.authenticatePeer(request).isEmpty()); + } + + @Test + void resolveClientIpUsesXffOnlyBehindTrustedProxy() { + Map<String, Object> props = baseProvider("long-enough-secret-01"); + props.put("trustedProxies", "10.0.0.1"); + service.modified(props); + + when(request.getRemoteAddr()).thenReturn("10.0.0.1"); + when(request.getHeader("X-Forwarded-For")).thenReturn("203.0.113.9, 10.0.0.1"); + assertEquals("203.0.113.9", service.resolveClientIp(request)); + + when(request.getRemoteAddr()).thenReturn("203.0.113.1"); + assertEquals("203.0.113.1", service.resolveClientIp(request)); + } + + @Test + void validateProviderByKeyChecksEventType() { + Map<String, Object> props = baseProvider("long-enough-secret-01"); + service.modified(props); + assertTrue(service.validateProviderByKey("long-enough-secret-01", "login", "127.0.0.1")); + assertFalse(service.validateProviderByKey("long-enough-secret-01", "view", "127.0.0.1")); + } + + private static Map<String, Object> baseProvider(String key) { + Map<String, Object> props = new HashMap<>(); + props.put("thirdparty.provider1.key", key); + props.put("thirdparty.provider1.ipAddresses", "127.0.0.1,::1"); + props.put("thirdparty.provider1.allowedEvents", "login,updateProperties"); + return props; + } +} diff --git a/services-common/src/main/java/org/apache/unomi/services/common/security/KarafSecurityService.java b/services-common/src/main/java/org/apache/unomi/services/common/security/KarafSecurityService.java index 24e79f375..8747afd3f 100644 --- a/services-common/src/main/java/org/apache/unomi/services/common/security/KarafSecurityService.java +++ b/services-common/src/main/java/org/apache/unomi/services/common/security/KarafSecurityService.java @@ -228,6 +228,26 @@ public class KarafSecurityService implements SecurityService { return hasRole(UnomiRoles.ADMINISTRATOR) || hasRole(UnomiRoles.TENANT_ADMINISTRATOR); } + @Override + public boolean hasCompatPeerAbility(String ability) { + if (ability == null || ability.isBlank()) { + return false; + } + Subject subject = getRequestSubject(); + if (subject == null) { + subject = currentSubject.get(); + } + if (subject == null) { + return false; + } + for (CompatPeerPrincipal peer : subject.getPrincipals(CompatPeerPrincipal.class)) { + if (peer.hasAbility(ability)) { + return true; + } + } + return false; + } + @Override public boolean hasTenantAccess(String tenantId) { if (hasRole(UnomiRoles.ADMINISTRATOR)) { 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 index 08175aa30..01fdf24da 100644 --- 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 @@ -17,6 +17,9 @@ package org.apache.unomi.services.common.security; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + /** * Utility class for security-related helpers, such as safe logging of sensitive values. */ @@ -39,4 +42,26 @@ public class SecurityUtils { } return secret.substring(0, VISIBLE_PREFIX_LENGTH) + MASK_SUFFIX; } + + /** + * Compares two strings in constant time relative to their UTF-8 byte lengths. + * Different lengths still return {@code false}; a dummy compare runs so the call cost does not + * collapse to a single length check. + * + * @param left first value, may be {@code null} + * @param right second value, may be {@code null} + * @return {@code true} only when both are non-null and their UTF-8 bytes are equal + */ + public static boolean constantTimeEquals(String left, String right) { + byte[] leftBytes = left == null ? new byte[0] : left.getBytes(StandardCharsets.UTF_8); + byte[] rightBytes = right == null ? new byte[0] : right.getBytes(StandardCharsets.UTF_8); + if (leftBytes.length != rightBytes.length) { + MessageDigest.isEqual(leftBytes, leftBytes); + return false; + } + if (left == null || right == null) { + return left == right; + } + return MessageDigest.isEqual(leftBytes, rightBytes); + } } 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 index a100fe684..62380be42 100644 --- 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 @@ -20,6 +20,8 @@ package org.apache.unomi.services.common.security; import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; public class SecurityUtilsTest { @@ -60,4 +62,13 @@ public class SecurityUtilsTest { // One char beyond threshold — prefix visible, rest masked assertEquals("abcd****", SecurityUtils.maskSecret("abcde")); } + + @Test + public void constantTimeEqualsMatchesEqualStrings() { + assertTrue(SecurityUtils.constantTimeEquals("same-secret", "same-secret")); + assertFalse(SecurityUtils.constantTimeEquals("same-secret", "other-secret")); + assertFalse(SecurityUtils.constantTimeEquals("short", "longer-value")); + assertFalse(SecurityUtils.constantTimeEquals(null, "x")); + assertTrue(SecurityUtils.constantTimeEquals(null, null)); + } } diff --git a/services/src/main/resources/org.apache.unomi.thirdparty.cfg b/services/src/main/resources/org.apache.unomi.thirdparty.cfg index e0bb043eb..01cc839b4 100644 --- a/services/src/main/resources/org.apache.unomi.thirdparty.cfg +++ b/services/src/main/resources/org.apache.unomi.thirdparty.cfg @@ -15,12 +15,24 @@ # limitations under the License. # -# Define secured events and the thirdparty servers which are allowed to send -# them. - -# Example : provider1 is allowed to send login and download events from -# localhost , with key provided in X-Unomi-Peer +# Third-party servers for single-tenant compatibility mode (X-Unomi-Peer). +# +# A provider whose key is empty, shorter than 16 characters, or equal to the +# well-known example value below is disabled at startup with an error in the log +# unless allowExampleKey=true (transition only; logs a warning every start). +# +# abilities (optional, comma-separated): setEventId, chooseProfileId, mergeOnLogin, +# updateOtherProfiles. Default is all four (Unomi 3.0 peer behaviour). # +# trustedProxies: when the direct connection address is in this list, the first +# X-Forwarded-For hop is used for the provider IP check. Leave empty to always +# use the direct address. + +allowExampleKey=${org.apache.unomi.thirdparty.allowExampleKey:-false} +trustedProxies=${org.apache.unomi.thirdparty.trustedProxies:-} + +# Example provider (localhost only). Replace the key before production use. thirdparty.provider1.key=${org.apache.unomi.thirdparty.provider1.key:-670c26d1cc413346c3b2fd9ce65dab41} thirdparty.provider1.ipAddresses=${org.apache.unomi.thirdparty.provider1.ipAddresses:-127.0.0.1,::1} thirdparty.provider1.allowedEvents=${org.apache.unomi.thirdparty.provider1.allowedEvents:-login,updateProperties} +thirdparty.provider1.abilities=${org.apache.unomi.thirdparty.provider1.abilities:-setEventId,chooseProfileId,mergeOnLogin,updateOtherProfiles}
