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}

Reply via email to