This is an automated email from the ASF dual-hosted git repository.

sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/master by this push:
     new facb23e7b UNOMI-1001: Restore 3.0 peer abilities in compatibility mode 
(#914)
facb23e7b is described below

commit facb23e7b0c24937f8828f4ccdbdf1f5651fa859
Author: Serge Huber <[email protected]>
AuthorDate: Fri Oct 9 21:46:03 2026 +0200

    UNOMI-1001: Restore 3.0 peer abilities in compatibility mode (#914)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../unomi/api/security/CompatPeerPrincipal.java    | 111 +++++++
 .../api/security/InsecureTransportPrincipal.java   |  45 +++
 .../apache/unomi/api/security/SecurityService.java |  42 +++
 .../auth/GraphQLServletSecurityValidator.java      |  15 +
 .../auth/GraphQLServletSecurityValidatorTest.java  |   4 +-
 .../test/java/org/apache/unomi/itests/BaseIT.java  |   2 +
 .../itests/SingleTenantCompatibilityModeIT.java    | 205 +++++++++++-
 manual/src/main/asciidoc/configuration.adoc        |  13 +
 .../asciidoc/migrations/v2-compatibility-mode.adoc |  61 +++-
 .../main/resources/etc/custom.system.properties    |   5 +
 .../plugins/baseplugin/actions/IdentityTrust.java  |  51 ++-
 .../actions/MergeProfilesOnPropertyAction.java     |  58 ++--
 .../baseplugin/actions/UpdatePropertiesAction.java |   9 +-
 .../resources/OSGI-INF/blueprint/blueprint.xml     |   3 +
 .../resources/org.apache.unomi.plugins.base.cfg    |   7 +
 .../baseplugin/actions/IdentityTrustTest.java      | 106 +++++++
 .../actions/MergeProfilesOnPropertyActionTest.java |  57 ++++
 .../actions/UpdatePropertiesActionTest.java        |  37 +++
 .../rest/authentication/AuthenticationFilter.java  |  28 ++
 .../authentication/V2ThirdPartyConfigService.java  | 347 ++++++++++++++++-----
 .../rest/service/impl/RestServiceUtilsImpl.java    |  48 ++-
 .../V2ThirdPartyConfigServiceTest.java             | 288 +++++++++++++++++
 .../RestServiceUtilsImplProfileBindingTest.java    |  92 ++++++
 .../common/security/IPValidationUtils.java         |  18 ++
 .../common/security/KarafSecurityService.java      |  53 ++++
 .../services/common/security/SecurityUtils.java    |  18 ++
 .../common/security/KarafSecurityServiceTest.java  |  78 +++++
 .../common/security/SecurityUtilsTest.java         |  11 +
 .../main/resources/org.apache.unomi.thirdparty.cfg |  32 +-
 29 files changed, 1717 insertions(+), 127 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..d35bacd79
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/security/CompatPeerPrincipal.java
@@ -0,0 +1,111 @@
+/*
+ * 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.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 final 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 does not 
configure abilities at all. */
+    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, each one of the 
{@code ABILITY_*} constants.
+     *                   An empty set grants nothing; pass {@link 
#DEFAULT_ABILITIES} for the 3.0 set.
+     * @throws IllegalArgumentException when the provider id is blank, or the 
abilities are
+     *                                  {@code null} or contain a name that is 
not a known ability
+     */
+    public CompatPeerPrincipal(String providerId, Set<String> abilities) {
+        if (providerId == null || providerId.isBlank()) {
+            throw new IllegalArgumentException("providerId cannot be blank");
+        }
+        if (abilities == null) {
+            throw new IllegalArgumentException("abilities cannot be null");
+        }
+        if (!DEFAULT_ABILITIES.containsAll(abilities)) {
+            throw new IllegalArgumentException("Unknown compat peer ability in 
" + abilities);
+        }
+        this.providerId = providerId;
+        this.abilities = 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/InsecureTransportPrincipal.java
 
b/api/src/main/java/org/apache/unomi/api/security/InsecureTransportPrincipal.java
new file mode 100644
index 000000000..f792396c0
--- /dev/null
+++ 
b/api/src/main/java/org/apache/unomi/api/security/InsecureTransportPrincipal.java
@@ -0,0 +1,45 @@
+/*
+ * 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;
+
+/**
+ * Marks a request subject whose credentials arrived over a connection that 
was not encrypted.
+ * <p>
+ * It grants nothing. It is recorded by {@link 
SecurityService#recordRequestTransport} so that code
+ * guarding a sensitive operation, such as a profile merge, can refuse a 
caller that is otherwise
+ * trusted when the deployment requires a secure transport.
+ */
+public final class InsecureTransportPrincipal implements Principal {
+
+    /** The single instance: the marker carries no state. */
+    public static final InsecureTransportPrincipal INSTANCE = new 
InsecureTransportPrincipal();
+
+    private InsecureTransportPrincipal() {
+    }
+
+    @Override
+    public String getName() {
+        return "insecure-transport";
+    }
+
+    @Override
+    public String toString() {
+        return "InsecureTransportPrincipal";
+    }
+}
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..138d25543 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,48 @@ 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.
+     *
+     * <p>
+     * Only the request subject is consulted, so a privileged or system 
execution scope neither
+     * grants nor hides a peer ability. The answer is {@code false} when there 
is no request
+     * subject, when the ability is {@code null} or unknown, and whenever 
single-tenant
+     * compatibility mode is off, since no peer principal is attached in that 
case.
+     * <p>
+     * The default implementation answers {@code false}, so an implementation 
that knows nothing
+     * about compat peers grants none.
+     *
+     * @param ability one of the ability constants on {@link 
CompatPeerPrincipal}
+     * @return {@code true} when the request subject is a compat peer with 
that ability
+     */
+    default boolean hasCompatPeerAbility(String ability) {
+        return false;
+    }
+
+    /**
+     * Records how the current HTTP request reached Unomi, once its subject 
has been bound with
+     * {@link #setCurrentSubject(Subject)}. When the request was not 
encrypted, the request subject
+     * gains an {@link InsecureTransportPrincipal}.
+     * <p>
+     * A request counts as encrypted when the container says so, or when it 
comes from a trusted
+     * proxy that reports {@code https} in {@code X-Forwarded-Proto}. Trusted 
proxies are the
+     * addresses in the {@code org.apache.unomi.ip.trustedProxies} system 
property.
+     * <p>
+     * The default implementation records nothing.
+     *
+     * @param secure         whether the container received the request over 
an encrypted connection
+     * @param remoteAddr     the address the request was received from, may be 
{@code null}
+     * @param forwardedProto the {@code X-Forwarded-Proto} header value, may 
be {@code null}
+     */
+    default void recordRequestTransport(boolean secure, String remoteAddr, 
String forwardedProto) {
+    }
+
     /**
      * System subject with administrative privileges.
      *
diff --git 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
index 97624d7f1..c21a0c47c 100644
--- 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
+++ 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
@@ -94,6 +94,21 @@ public class GraphQLServletSecurityValidator {
     }
 
     public boolean validate(String query, String operationName, 
HttpServletRequest req, HttpServletResponse res) throws IOException {
+        boolean authenticated = authenticate(query, operationName, req, res);
+        if (authenticated) {
+            // Tell the security service whether the request was encrypted, so 
that operations which
+            // require a secure transport can refuse a credential that 
travelled in clear.
+            securityService.recordRequestTransport(req.isSecure(), 
req.getRemoteAddr(),
+                    req.getHeader("X-Forwarded-Proto"));
+        }
+        return authenticated;
+    }
+
+    private boolean authenticate(String query, String operationName, 
HttpServletRequest req, HttpServletResponse res) throws IOException {
+        // Request threads are pooled. Start from no subject at all, so a 
subject or privileged
+        // subject left behind by earlier work on this thread can never make 
this request trusted.
+        securityService.clearCurrentSubject();
+
         if (isPublicOperation(query, operationName)) {
             // For public operations, check API key
             String apiKey = req.getHeader("X-Unomi-Api-Key");
diff --git 
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
 
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
index a05d09193..2a8a9e694 100644
--- 
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
+++ 
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
@@ -55,6 +55,7 @@ import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.refEq;
 import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -128,7 +129,8 @@ class GraphQLServletSecurityValidatorTest {
 
         assertFalse(authenticated);
         verify(executionContextManager, never()).setCurrentContext(any());
-        verify(securityService).clearCurrentSubject();
+        // Once before authenticating, and once more to drop the role-less 
subject it logged in.
+        verify(securityService, times(2)).clearCurrentSubject();
         verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
     }
 
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..7bf63c4d1 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,60 @@ 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);
+
+        Event stored = waitForCompatTenantEvent("Compat peer must keep the 
client-supplied event item id", chosenEventId);
+        assertEquals("login", stored.getEventType());
+    }
+
     @Test
     public void testV2CompatibilityProtectedEventNegativeCases() throws 
Exception {
         LOGGER.info("Testing single-tenant compatibility mode - protected 
event negative cases");
@@ -396,6 +450,7 @@ public class SingleTenantCompatibilityModeIT extends BaseIT 
{
         assertEquals("Protected event with unknown provider key should return 
200", 200, response.getStatusCode());
         assertEquals("Protected event with unknown provider key should have 0 
processed events", 0, response.getContextResponse().getProcessedEvents());
 
+        Dictionary<String, Object> originalThirdPartyConfig = 
snapshotThirdPartyConfig();
         try {
             // Case 2: valid key but source IP not in provider's allowed list.
             // Configure a provider whose IP allowlist only contains a 
non-loopback address so
@@ -436,11 +491,157 @@ public class SingleTenantCompatibilityModeIT extends 
BaseIT {
             assertEquals("Protected login event with key that only allows 
updateProperties should return 200", 200, response.getStatusCode());
             assertEquals("Protected login event with key that only allows 
updateProperties should have 0 processed events", 0, 
response.getContextResponse().getProcessedEvents());
         } finally {
-            // Restore thirdparty config to defaults by deleting the test 
entries
-            configurationAdmin.getConfiguration("org.apache.unomi.thirdparty", 
null).delete();
+            restoreThirdPartyConfig(originalThirdPartyConfig, "testprovider", 
"limitedprovider");
+        }
+    }
+
+    @Test
+    public void testPeerHeaderDoesNotElevateWhenCompatOff() throws Exception {
+        LOGGER.info("Testing that X-Unomi-Peer does not elevate when 
compatibility mode is off");
+
+        updateConfiguration(null, "org.apache.unomi.rest.authentication", 
"singletenantcompatibility.enabled", false);
+        keepTrying("single-tenant compatibility mode not disabled in the 
required time",
+                () -> 
restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled(),
+                enabled -> !enabled, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        String chosenProfileId = "non-compat-peer-profile-" + 
UUID.randomUUID();
+        ContextRequest contextRequest = new ContextRequest();
+        contextRequest.setSessionId(TEST_SESSION_ID);
+        contextRequest.setProfileId(chosenProfileId);
+
+        HttpPost 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));
+        // withAuth=true adds the public API key (V3 path); peer must still 
not grant chooseProfileId.
+        TestUtils.RequestResponse response = 
executeContextJSONRequest(request, TEST_SESSION_ID);
+        assertEquals(200, response.getStatusCode());
+        assertNotEquals("Peer header must not bind body profileId when compat 
mode is off",
+                chosenProfileId, response.getContextResponse().getProfileId());
+    }
+
+    @Test
+    public void testCompatPeerAbilitySubset() throws Exception {
+        LOGGER.info("Testing compat peer with ability subset: setEventId 
without chooseProfileId");
+
+        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 subsetKey = "testprovidersubset000000000000000";
+        String chosenProfileId = "compat-subset-profile-" + UUID.randomUUID();
+        String chosenEventId = "compat-subset-event-" + UUID.randomUUID();
+
+        Dictionary<String, Object> originalThirdPartyConfig = 
snapshotThirdPartyConfig();
+        try {
+            Map<String, Object> subsetConfig = new HashMap<>();
+            subsetConfig.put("thirdparty.subsetprovider.key", subsetKey);
+            subsetConfig.put("thirdparty.subsetprovider.ipAddresses", 
"127.0.0.1,::1");
+            subsetConfig.put("thirdparty.subsetprovider.allowedEvents", 
"login");
+            subsetConfig.put("thirdparty.subsetprovider.abilities", 
"setEventId");
+            updateConfiguration(null, "org.apache.unomi.thirdparty", 
subsetConfig);
+            keepTrying("Third-party subset-abilities config not applied",
+                    () -> 
v2ThirdPartyConfigService.getProviderKey("subsetprovider"),
+                    subsetKey::equals, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+            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));
+
+            HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+            request.addHeader(UNOMI_PEER_HEADER, subsetKey);
+            request.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+            TestUtils.RequestResponse response = 
executeContextJSONRequest(request, TEST_SESSION_ID);
+            assertEquals(200, response.getStatusCode());
+            assertNotEquals("Peer without chooseProfileId must not bind body 
profileId",
+                    chosenProfileId, 
response.getContextResponse().getProfileId());
+            assertEquals("Peer with setEventId must still process allowed 
login", 1,
+                    response.getContextResponse().getProcessedEvents());
+
+            waitForCompatTenantEvent("Peer with setEventId must keep the 
client-supplied event item id", chosenEventId);
+        } finally {
+            restoreThirdPartyConfig(originalThirdPartyConfig, 
"subsetprovider");
+        }
+    }
+
+    @Test
+    public void testCompatPeerDoesNotOpenPrivateEndpoint() throws Exception {
+        LOGGER.info("Testing that a valid peer key does not open private 
endpoints");
+
+        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);
+
+        // First show the key is a valid peer here, or the 401 below would 
prove nothing about it.
+        String chosenProfileId = "compat-peer-private-" + UUID.randomUUID();
+        ContextRequest contextRequest = new ContextRequest();
+        contextRequest.setSessionId(TEST_SESSION_ID);
+        contextRequest.setProfileId(chosenProfileId);
+        HttpPost publicRequest = new HttpPost(getFullUrl(CONTEXT_URL));
+        publicRequest.addHeader(UNOMI_PEER_HEADER, 
V2ThirdPartyConfigService.EXAMPLE_PROVIDER_KEY);
+        publicRequest.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        TestUtils.RequestResponse publicResponse = 
executeContextJSONRequest(publicRequest, TEST_SESSION_ID);
+        assertEquals(200, publicResponse.getStatusCode());
+        assertEquals("The key must be accepted as a peer on the public 
endpoint",
+                chosenProfileId, 
publicResponse.getContextResponse().getProfileId());
+
+        HttpGet getRequest = new HttpGet(getFullUrl("/cxs/profiles/" + 
chosenProfileId));
+        getRequest.addHeader(UNOMI_PEER_HEADER, 
V2ThirdPartyConfigService.EXAMPLE_PROVIDER_KEY);
+
+        try (CloseableHttpClient client = HttpClients.createDefault();
+             CloseableHttpResponse response = client.execute(getRequest)) {
+            assertEquals("Peer key alone must not authorize private endpoints",
+                    401, response.getStatusLine().getStatusCode());
         }
     }
 
+    private static final String THIRDPARTY_PID = "org.apache.unomi.thirdparty";
+
+    /** The event may not be readable yet when the response comes back, so 
poll for it. */
+    private Event waitForCompatTenantEvent(String failMessage, String eventId) 
throws InterruptedException {
+        return keepTrying(failMessage,
+                () -> {
+                    
executionContextManager.setCurrentContext(executionContextManager.createContext(COMPATIBILITY_TENANT_ID));
+                    try {
+                        return persistenceService.load(eventId, Event.class);
+                    } finally {
+                        
executionContextManager.setCurrentContext(executionContextManager.createContext(testTenant.getItemId()));
+                    }
+                },
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+    }
+
+    private Dictionary<String, Object> snapshotThirdPartyConfig() throws 
IOException {
+        Dictionary<String, Object> current = 
configurationAdmin.getConfiguration(THIRDPARTY_PID, null).getProperties();
+        Dictionary<String, Object> snapshot = new Hashtable<>();
+        if (current != null) {
+            for (String key : Collections.list(current.keys())) {
+                snapshot.put(key, current.get(key));
+            }
+        }
+        return snapshot;
+    }
+
+    /**
+     * Puts the third-party configuration back as it was. Deleting it instead 
would leave the rest of
+     * the suite, which shares this Karaf instance, without any provider.
+     */
+    private void restoreThirdPartyConfig(Dictionary<String, Object> original, 
String... addedProviders)
+            throws IOException, InterruptedException {
+        configurationAdmin.getConfiguration(THIRDPARTY_PID, 
null).update(original);
+        keepTrying("Third-party config not restored",
+                () -> 
Arrays.stream(addedProviders).noneMatch(v2ThirdPartyConfigService::isValidProvider)
+                        && 
v2ThirdPartyConfigService.isValidProvider("provider1"),
+                restored -> restored, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+    }
+
     private static void addPrivateTenantAuth(HttpPost request, Tenant tenant, 
String privateKeyValue) {
         request.setHeader("Authorization", "Basic " + 
Base64.getEncoder().encodeToString(
             (tenant.getItemId() + ":" + privateKeyValue).getBytes()));
diff --git a/manual/src/main/asciidoc/configuration.adoc 
b/manual/src/main/asciidoc/configuration.adoc
index 143ffd023..c3b037ce1 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -1153,6 +1153,19 @@ Key security recommendations:
 * *Implement rate limiting* for merge operations to prevent abuse
 * *Consider additional verification* before merging profiles with sensitive 
data
 
+===== Requiring HTTPS for merges
+
+A tenant private key or an administrator password sent over plain HTTP can be 
read by anyone on the network path, and whoever holds it can merge profiles. 
Set `org.apache.unomi.plugins.base.requireSecureTransport=true` (environment 
variable `UNOMI_REQUIRE_SECURE_TRANSPORT`) in production. It is `false` by 
default so that a local development install keeps working over HTTP.
+
+When it is on, a caller trusted for its private key or administrator login may 
only merge profiles, record a merge identifier, update another profile or write 
`systemProperties` if the request arrived over HTTPS. The same request over 
HTTP is still processed, but those steps are refused and a warning is logged.
+
+If a reverse proxy terminates TLS in front of Unomi, the request Unomi sees is 
plain HTTP. Unomi then accepts the proxy's word only when both of these hold:
+
+* the proxy's address is listed in `org.apache.unomi.ip.trustedProxies` 
(`UNOMI_IP_TRUSTED_PROXIES`). The default list covers every private address 
range; narrow it to your proxies, or any host on those networks can claim HTTPS.
+* the proxy sends `X-Forwarded-Proto: https`. Configure it to overwrite the 
header: if any listed protocol is not `https`, the request is treated as plain 
HTTP.
+
+Changing `org.apache.unomi.ip.trustedProxies` in `custom.system.properties` 
needs a restart. The setting does not apply to `X-Unomi-Peer` keys in 
<<_single_tenant_compatibility_mode,single-tenant compatibility mode>>, which 
keep their Unomi 3.0 behaviour, nor to merges Unomi runs itself outside an HTTP 
request.
+
 ==== Troubleshooting profile merges
 
 If profiles aren't merging as expected, check:
diff --git a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc 
b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
index e97b359af..dfded3512 100644
--- a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
+++ b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
@@ -172,38 +172,59 @@ 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 logs a warning at every start. Never use it in 
production: the example key is public
+allowExampleKey=${org.apache.unomi.thirdparty.allowExampleKey:-false}
+# When the direct connection is in this list, the last X-Forwarded-For entry 
that is not a trusted proxy 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.
+Ability names are case-sensitive. A provider with no `abilities` property gets 
all four. An empty value (`thirdparty.myapp.abilities=`) grants no peer 
ability, so the provider can only send its protected events. A value that names 
no known ability, for example a misspelt one, disables the provider and logs an 
error, so a typo never widens what a provider may do.
+
+When an `X-Unomi-Peer` header is present but not accepted, Unomi logs a 
warning that says whether the key is unknown or the caller's IP address is not 
in the provider's allowlist. The request then continues as an ordinary visitor.
+
+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
 
@@ -239,12 +260,24 @@ The V2 third-party configuration supports dynamic updates:
    ./bin/start
    ```
 
+===== IP allowlists and reverse proxies
+
+Peer checks use the TCP peer Unomi sees (`remoteAddr`). They only read 
`X-Forwarded-For` when that peer is listed in `trustedProxies`. That stops a 
client on the open network from inventing a client IP in a header.
+
+Operators should still treat IP allowlists as a second factor next to the peer 
key, not as the only control:
+
+* Always set an explicit `thirdparty.{name}.ipAddresses` list (single 
addresses or CIDR ranges). The list is required: a provider with an empty list 
is refused from every address, as in Unomi 3.0, and Unomi logs an error for it 
at startup. The key alone is never enough. An entry that is not an address or 
CIDR range is reported as an error at startup and never matches.
+* Keep `trustedProxies` limited to reverse proxies that sit in front of Unomi, 
and list every proxy in the chain. Unomi reads `X-Forwarded-For` from the last 
entry backwards and uses the first entry that is not a trusted proxy, so an 
entry a client puts at the front of the header is ignored whether the proxy 
appends to the header or overwrites it.
+* Prefer placing Unomi (or the peer callers) on a private network or behind a 
firewall so only known hosts can reach the public endpoints.
+* Rotate peer keys and keep them long and unique. For stronger assurance than 
key + IP, put mutual TLS or equivalent at the edge in front of Unomi.
+
 ===== Recommendations
 
 1. **Use single-tenant compatibility mode temporarily** during migration
 2. **Plan for gradual migration** to V3 authentication
 3. **Monitor access patterns** during the transition
 4. **Disable single-tenant compatibility mode** once migration is complete
+5. **Configure non-empty IP allowlists** and minimal `trustedProxies` before 
production use
 
 ==== Troubleshooting
 
diff --git a/package/src/main/resources/etc/custom.system.properties 
b/package/src/main/resources/etc/custom.system.properties
index a0ef8c6da..126afb1a4 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                                          
                                      ##
@@ -422,6 +425,8 @@ 
org.apache.unomi.mail.server.sslOnConnect=${env:UNOMI_MAIL_SSLONCONNECT:-true}
 ## baseplugin settings                                                         
                                        ##
 
#######################################################################################################################
 
org.apache.unomi.plugins.base.maxProfilesInOneMerge=${env:UNOMI_MAX_PROFILES_IN_ONE_MERGE:-50}
+# Refuse profile merges and other identity claims made with a private key or 
administrator login over plain HTTP
+org.apache.unomi.plugins.base.requireSecureTransport=${env:UNOMI_REQUIRE_SECURE_TRANSPORT:-false}
 
 
#######################################################################################################################
 ## Security 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..b169e2f43 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,29 +16,64 @@
  */
 package org.apache.unomi.plugins.baseplugin.actions;
 
+import org.apache.unomi.api.security.CompatPeerPrincipal;
+import org.apache.unomi.api.security.InsecureTransportPrincipal;
 import org.apache.unomi.api.security.SecurityService;
 import org.apache.unomi.api.security.UnomiRoles;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.security.auth.Subject;
 
 /**
  * Single definition of the trust policy for identity claims made through 
actions: merging
  * 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.
+ * <p>
+ * When a secure transport is required, a caller trusted for its system access 
is refused if its
+ * request was not encrypted (see {@link InsecureTransportPrincipal}). Work 
that does not come from
+ * an HTTP request carries no such marker and is unaffected, and so is a 
compat peer.
  */
 final class IdentityTrust {
 
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(IdentityTrust.class);
+
     private 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 securityService        the security service, possibly null while 
OSGi wiring is in flux
+     * @param ability                a {@link CompatPeerPrincipal} ability 
required of a compat peer
+     * @param requireSecureTransport whether system access only counts on an 
encrypted request
+     * @return whether the caller holds the given compat-peer ability, or 
system access over an
+     *         acceptable transport
      */
-    static boolean isTrustedIdentityCaller(SecurityService securityService) {
-        return securityService != null && securityService.hasSystemAccess();
+    static boolean isTrustedIdentityCaller(SecurityService securityService, 
String ability,
+            boolean requireSecureTransport) {
+        if (securityService == null) {
+            return false;
+        }
+        if (securityService.hasCompatPeerAbility(ability)) {
+            return true;
+        }
+        if (!securityService.hasSystemAccess()) {
+            return false;
+        }
+        if (requireSecureTransport && isInsecureTransport(securityService)) {
+            LOGGER.warn("Refusing identity claim from a trusted caller: the 
request was not sent over HTTPS "
+                    + "and requireSecureTransport is enabled");
+            return false;
+        }
+        return true;
+    }
+
+    private static boolean isInsecureTransport(SecurityService 
securityService) {
+        Subject subject = securityService.getRequestSubject();
+        return subject != null && 
!subject.getPrincipals(InsecureTransportPrincipal.class).isEmpty();
     }
 }
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..a709e1342 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;
@@ -36,6 +37,7 @@ import org.apache.unomi.tracing.api.TracerService;
 import org.apache.unomi.tracing.api.RequestTracer;
 import org.apache.unomi.api.services.ExecutionContextManager;
 
+import javax.security.auth.Subject;
 import java.util.*;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
@@ -52,6 +54,7 @@ public class MergeProfilesOnPropertyAction implements 
ActionExecutor {
     private TracerService tracerService;
     private ExecutionContextManager executionContextManager;
     private SecurityService securityService;
+    private boolean requireSecureTransport;
     // TODO we can remove this limit after dealing with: UNOMI-776 (50 is 
completely arbitrary and it's used to bypass the auto-scroll done by the 
persistence Service)
     private int maxProfilesInOneMerge = 50;
 
@@ -267,28 +270,38 @@ public class MergeProfilesOnPropertyAction implements 
ActionExecutor {
                     String masterProfile = (String) 
parameters.get("masterProfileId");
                     String tenantId = (String) parameters.get("tenantId");
 
+                    // The scheduler thread is pooled: put its subject back 
afterwards, so the
+                    // tenant-administrator subject does not stay on it for 
whatever runs next.
+                    Subject previousSubject = 
securityService.getRequestSubject();
                     
securityService.setCurrentSubject(securityService.createSubject(tenantId, 
true));
-
-                    // Execute the merge operation in the correct tenant 
context
-                    executionContextManager.executeAsTenant(tenantId, () -> {
-                        if (!isAnonymousBrowsing) {
-                            Condition profileIdsCondition = new 
Condition(definitionsService.getConditionType("eventPropertyCondition"));
-                            
profileIdsCondition.setParameter("propertyName","profileId");
-                            
profileIdsCondition.setParameter("comparisonOperator","in");
-                            profileIdsCondition.setParameter("propertyValues", 
profilesIds);
-
-                            String[] scripts = new String[]{"updateProfileId"};
-                            Map<String, Object>[] scriptParams = new 
Map[]{Collections.singletonMap("profileId", masterProfile)};
-                            Condition[] conditions = new 
Condition[]{profileIdsCondition};
-
-                            
persistenceService.updateWithQueryAndStoredScript(new Class[]{Session.class, 
Event.class}, scripts, scriptParams, conditions, false);
-                        } else {
-                            for (String mergedProfileId : profilesIds) {
-                                
privacyService.anonymizeBrowsingData(mergedProfileId);
+                    try {
+                        // Execute the merge operation in the correct tenant 
context
+                        executionContextManager.executeAsTenant(tenantId, () 
-> {
+                            if (!isAnonymousBrowsing) {
+                                Condition profileIdsCondition = new 
Condition(definitionsService.getConditionType("eventPropertyCondition"));
+                                
profileIdsCondition.setParameter("propertyName","profileId");
+                                
profileIdsCondition.setParameter("comparisonOperator","in");
+                                
profileIdsCondition.setParameter("propertyValues", profilesIds);
+
+                                String[] scripts = new 
String[]{"updateProfileId"};
+                                Map<String, Object>[] scriptParams = new 
Map[]{Collections.singletonMap("profileId", masterProfile)};
+                                Condition[] conditions = new 
Condition[]{profileIdsCondition};
+
+                                
persistenceService.updateWithQueryAndStoredScript(new Class[]{Session.class, 
Event.class}, scripts, scriptParams, conditions, false);
+                            } else {
+                                for (String mergedProfileId : profilesIds) {
+                                    
privacyService.anonymizeBrowsingData(mergedProfileId);
+                                }
                             }
+                            return null;
+                        });
+                    } finally {
+                        if (previousSubject == null) {
+                            securityService.clearRequestSubject();
+                        } else {
+                            securityService.setCurrentSubject(previousSubject);
                         }
-                        return null;
-                    });
+                    }
 
                     callback.complete();
                 } catch (Exception e) {
@@ -354,7 +367,12 @@ 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,
+                requireSecureTransport);
+    }
+
+    public void setRequireSecureTransport(boolean requireSecureTransport) {
+        this.requireSecureTransport = requireSecureTransport;
     }
 
     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..af715698f 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;
@@ -71,6 +72,7 @@ public class UpdatePropertiesAction implements ActionExecutor 
{
     private EventService eventService;
     private TracerService tracerService;
     private SecurityService securityService;
+    private boolean requireSecureTransport;
 
     public int execute(Action action, Event event) {
         RequestTracer tracer = null;
@@ -226,7 +228,12 @@ public class UpdatePropertiesAction implements 
ActionExecutor {
     }
 
     private boolean isTrustedIdentityCaller() {
-        return IdentityTrust.isTrustedIdentityCaller(securityService);
+        return IdentityTrust.isTrustedIdentityCaller(securityService,
+                CompatPeerPrincipal.ABILITY_UPDATE_OTHER_PROFILES, 
requireSecureTransport);
+    }
+
+    public void setRequireSecureTransport(boolean requireSecureTransport) {
+        this.requireSecureTransport = requireSecureTransport;
     }
 
     public void setProfileService(ProfileService profileService) {
diff --git 
a/plugins/baseplugin/src/main/resources/OSGI-INF/blueprint/blueprint.xml 
b/plugins/baseplugin/src/main/resources/OSGI-INF/blueprint/blueprint.xml
index c317dc34d..56fbadc84 100644
--- a/plugins/baseplugin/src/main/resources/OSGI-INF/blueprint/blueprint.xml
+++ b/plugins/baseplugin/src/main/resources/OSGI-INF/blueprint/blueprint.xml
@@ -28,6 +28,7 @@
             <cm:property name="useEventToUpdateProfile" value="false"/>
             <cm:property name="usePropertyConditionOptimizations" 
value="true"/>
             <cm:property name="maxProfilesInOneMerge" value="50"/>
+            <cm:property name="requireSecureTransport" value="false"/>
         </cm:default-properties>
     </cm:property-placeholder>
 
@@ -182,6 +183,7 @@
             <property name="eventService" ref="eventService"/>
             <property name="tracerService" ref="tracerService"/>
             <property name="securityService" ref="securityService"/>
+            <property name="requireSecureTransport" 
value="${base.requireSecureTransport}"/>
         </bean>
     </service>
 
@@ -245,6 +247,7 @@
         <property name="schedulerService" ref="schedulerService"/>
         <property name="tracerService" ref="tracerService"/>
         <property name="maxProfilesInOneMerge" 
value="${base.maxProfilesInOneMerge}"/>
+        <property name="requireSecureTransport" 
value="${base.requireSecureTransport}"/>
     </bean>
 
     <service interface="org.apache.unomi.api.actions.ActionExecutor" 
ref="mergeProfilesOnPropertyAction">
diff --git 
a/plugins/baseplugin/src/main/resources/org.apache.unomi.plugins.base.cfg 
b/plugins/baseplugin/src/main/resources/org.apache.unomi.plugins.base.cfg
index b0bad3b5c..ba11fffb9 100644
--- a/plugins/baseplugin/src/main/resources/org.apache.unomi.plugins.base.cfg
+++ b/plugins/baseplugin/src/main/resources/org.apache.unomi.plugins.base.cfg
@@ -16,3 +16,10 @@
 #
 
 
maxProfilesInOneMerge=${org.apache.unomi.plugins.base.maxProfilesInOneMerge:-50}
+
+# When true, a tenant private key or an administrator login may only merge 
profiles, record a merge
+# identifier, update another profile or write systemProperties on a request 
received over HTTPS.
+# Behind a proxy that terminates TLS, the proxy must be listed in 
org.apache.unomi.ip.trustedProxies
+# and send X-Forwarded-Proto: https. Leave false for development; set true in 
production.
+# X-Unomi-Peer keys in single-tenant compatibility mode are not affected.
+requireSecureTransport=${org.apache.unomi.plugins.base.requireSecureTransport:-false}
diff --git 
a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/IdentityTrustTest.java
 
b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/IdentityTrustTest.java
new file mode 100644
index 000000000..8e9f7a6c7
--- /dev/null
+++ 
b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/IdentityTrustTest.java
@@ -0,0 +1,106 @@
+/*
+ * 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.plugins.baseplugin.actions;
+
+import org.apache.unomi.api.security.CompatPeerPrincipal;
+import org.apache.unomi.api.security.InsecureTransportPrincipal;
+import org.apache.unomi.api.security.SecurityService;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import javax.security.auth.Subject;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.when;
+
+@RunWith(MockitoJUnitRunner.class)
+public class IdentityTrustTest {
+
+    @Mock private SecurityService securityService;
+
+    @Test
+    public void missingSecurityService_isNotTrusted() {
+        assertFalse(IdentityTrust.isTrustedIdentityCaller(null, 
CompatPeerPrincipal.ABILITY_MERGE_ON_LOGIN, false));
+    }
+
+    @Test
+    public void systemAccess_isTrustedWithoutAnyPeerAbility() {
+        when(securityService.hasSystemAccess()).thenReturn(true);
+
+        assertTrue(IdentityTrust.isTrustedIdentityCaller(securityService, 
CompatPeerPrincipal.ABILITY_MERGE_ON_LOGIN, false));
+    }
+
+    @Test
+    public void peerWithTheRequiredAbility_isTrusted() {
+        
when(securityService.hasCompatPeerAbility(CompatPeerPrincipal.ABILITY_MERGE_ON_LOGIN)).thenReturn(true);
+
+        assertTrue(IdentityTrust.isTrustedIdentityCaller(securityService, 
CompatPeerPrincipal.ABILITY_MERGE_ON_LOGIN, false));
+    }
+
+    @Test
+    public void peerWithAnotherAbility_isNotTrusted() {
+        
when(securityService.hasCompatPeerAbility(CompatPeerPrincipal.ABILITY_UPDATE_OTHER_PROFILES)).thenReturn(true);
+
+        assertFalse(IdentityTrust.isTrustedIdentityCaller(securityService, 
CompatPeerPrincipal.ABILITY_MERGE_ON_LOGIN, false));
+        assertTrue(IdentityTrust.isTrustedIdentityCaller(securityService,
+                CompatPeerPrincipal.ABILITY_UPDATE_OTHER_PROFILES, false));
+    }
+
+    @Test
+    public void 
systemAccessOverPlainHttp_isRefusedOnlyWhenSecureTransportIsRequired() {
+        when(securityService.hasSystemAccess()).thenReturn(true);
+        Subject overHttp = new Subject();
+        overHttp.getPrincipals().add(InsecureTransportPrincipal.INSTANCE);
+        when(securityService.getRequestSubject()).thenReturn(overHttp);
+
+        assertFalse(IdentityTrust.isTrustedIdentityCaller(securityService, 
CompatPeerPrincipal.ABILITY_MERGE_ON_LOGIN, true));
+        assertTrue(IdentityTrust.isTrustedIdentityCaller(securityService, 
CompatPeerPrincipal.ABILITY_MERGE_ON_LOGIN, false));
+    }
+
+    @Test
+    public void systemAccessOverHttps_isTrustedWhenSecureTransportIsRequired() 
{
+        when(securityService.hasSystemAccess()).thenReturn(true);
+        when(securityService.getRequestSubject()).thenReturn(new Subject());
+
+        assertTrue(IdentityTrust.isTrustedIdentityCaller(securityService, 
CompatPeerPrincipal.ABILITY_MERGE_ON_LOGIN, true));
+    }
+
+    /** Scheduled and other internal work has no request, so no transport to 
judge. */
+    @Test
+    public void 
systemAccessWithoutARequestSubject_isTrustedWhenSecureTransportIsRequired() {
+        when(securityService.hasSystemAccess()).thenReturn(true);
+        when(securityService.getRequestSubject()).thenReturn(null);
+
+        assertTrue(IdentityTrust.isTrustedIdentityCaller(securityService, 
CompatPeerPrincipal.ABILITY_MERGE_ON_LOGIN, true));
+    }
+
+    /** The compatibility-mode peer keeps its Unomi 3.0 behaviour whatever the 
transport. */
+    @Test
+    public void 
compatPeerOverPlainHttp_isTrustedWhenSecureTransportIsRequired() {
+        
when(securityService.hasCompatPeerAbility(CompatPeerPrincipal.ABILITY_MERGE_ON_LOGIN)).thenReturn(true);
+
+        assertTrue(IdentityTrust.isTrustedIdentityCaller(securityService, 
CompatPeerPrincipal.ABILITY_MERGE_ON_LOGIN, true));
+    }
+
+    @Test
+    public void visitorWithoutSystemAccessOrPeerAbility_isNotTrusted() {
+        assertFalse(IdentityTrust.isTrustedIdentityCaller(securityService, 
CompatPeerPrincipal.ABILITY_MERGE_ON_LOGIN, false));
+    }
+}
diff --git 
a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java
 
b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java
index 95a51a680..066ed07f1 100644
--- 
a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java
+++ 
b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java
@@ -21,6 +21,8 @@ import org.apache.unomi.api.PartialList;
 import org.apache.unomi.api.Profile;
 import org.apache.unomi.api.actions.Action;
 import org.apache.unomi.api.conditions.ConditionType;
+import org.apache.unomi.api.security.CompatPeerPrincipal;
+import org.apache.unomi.api.security.InsecureTransportPrincipal;
 import org.apache.unomi.api.security.SecurityService;
 import org.apache.unomi.api.security.UnomiRoles;
 import org.apache.unomi.api.services.DefinitionsService;
@@ -34,6 +36,7 @@ import org.junit.runner.RunWith;
 import org.mockito.Mock;
 import org.mockito.junit.MockitoJUnitRunner;
 
+import javax.security.auth.Subject;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Date;
@@ -138,6 +141,60 @@ public class MergeProfilesOnPropertyActionTest {
         assertEquals("[email protected]", 
caller.getSystemProperties().get("mergeIdentifier"));
     }
 
+    @Test
+    public void compatPeerWithMergeOnLogin_recordsMergeIdentifier() {
+        
when(securityService.hasCompatPeerAbility(CompatPeerPrincipal.ABILITY_MERGE_ON_LOGIN)).thenReturn(true);
+        Profile caller = new Profile("caller");
+
+        when(persistenceService.query(any(), anyString(), eq(Profile.class), 
anyInt(), anyInt()))
+                .thenReturn(new PartialList<>(new ArrayList<>(), 0, 0, 0, 
PartialList.Relation.EQUAL));
+
+        Event event = new Event("login", null, caller, "systemscope", null, 
null, null, new Date(), true);
+
+        int changes = actionExecutor.execute(mergeAction("[email protected]"), 
event);
+
+        assertEquals(EventService.PROFILE_UPDATED, changes);
+        assertEquals("[email protected]", 
caller.getSystemProperties().get("mergeIdentifier"));
+    }
+
+    /** Each action asks for its own ability: holding the update ability must 
not unlock merging. */
+    @Test
+    public void 
compatPeerWithOnlyUpdateOtherProfiles_cannotRecordMergeIdentifier() {
+        
when(securityService.hasCompatPeerAbility(anyString())).thenAnswer(invocation ->
+                
CompatPeerPrincipal.ABILITY_UPDATE_OTHER_PROFILES.equals(invocation.getArgument(0)));
+        Profile caller = new Profile("caller");
+
+        when(persistenceService.query(any(), anyString(), eq(Profile.class), 
anyInt(), anyInt()))
+                .thenReturn(new PartialList<>(new ArrayList<>(), 0, 0, 0, 
PartialList.Relation.EQUAL));
+
+        Event event = new Event("login", null, caller, "systemscope", null, 
null, null, new Date(), true);
+
+        int changes = 
actionExecutor.execute(mergeAction("[email protected]"), event);
+
+        assertEquals(EventService.NO_CHANGE, changes);
+        assertNull(caller.getSystemProperties().get("mergeIdentifier"));
+    }
+
+    @Test
+    public void 
trustedTenantAdminOverPlainHttp_cannotRecordMergeIdentifierWhenSecureTransportIsRequired()
 {
+        actionExecutor.setRequireSecureTransport(true);
+        when(securityService.hasSystemAccess()).thenReturn(true);
+        Subject overHttp = new Subject();
+        overHttp.getPrincipals().add(InsecureTransportPrincipal.INSTANCE);
+        when(securityService.getRequestSubject()).thenReturn(overHttp);
+        Profile caller = new Profile("caller");
+
+        when(persistenceService.query(any(), anyString(), eq(Profile.class), 
anyInt(), anyInt()))
+                .thenReturn(new PartialList<>(new ArrayList<>(), 0, 0, 0, 
PartialList.Relation.EQUAL));
+
+        Event event = new Event("login", null, caller, "systemscope", null, 
null, null, new Date(), true);
+
+        int changes = actionExecutor.execute(mergeAction("[email protected]"), 
event);
+
+        assertEquals(EventService.NO_CHANGE, changes);
+        assertNull(caller.getSystemProperties().get("mergeIdentifier"));
+    }
+
     @Test
     public void trustedTenantAdmin_canMergeIntoExistingProfile() {
         when(securityService.hasSystemAccess()).thenReturn(true);
diff --git 
a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java
 
b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java
index b0e9a3fe7..14504c91c 100644
--- 
a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java
+++ 
b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java
@@ -19,6 +19,7 @@ package org.apache.unomi.plugins.baseplugin.actions;
 import org.apache.unomi.api.Event;
 import org.apache.unomi.api.Profile;
 import org.apache.unomi.api.actions.Action;
+import org.apache.unomi.api.security.CompatPeerPrincipal;
 import org.apache.unomi.api.security.SecurityService;
 import org.apache.unomi.api.security.UnomiRoles;
 import org.apache.unomi.api.services.EventService;
@@ -168,6 +169,42 @@ public class UpdatePropertiesActionTest {
         assertEquals("[email protected]", 
caller.getSystemProperties().get("mergeIdentifier"));
     }
 
+    @Test
+    public void compatPeerWithUpdateOtherProfiles_canUpdateAnotherProfile() {
+        
when(securityService.hasCompatPeerAbility(CompatPeerPrincipal.ABILITY_UPDATE_OTHER_PROFILES)).thenReturn(true);
+
+        Profile other = new Profile("other");
+        when(profileService.load("other")).thenReturn(other);
+
+        actionExecutor.execute(new Action(), updateOtherProfileEvent());
+
+        verify(profileService).save(other);
+    }
+
+    /** Each action asks for its own ability: holding the merge ability must 
not unlock this one. */
+    @Test
+    public void compatPeerWithOnlyMergeOnLogin_cannotUpdateAnotherProfile() {
+        
when(securityService.hasCompatPeerAbility(any(String.class))).thenAnswer(invocation
 ->
+                
CompatPeerPrincipal.ABILITY_MERGE_ON_LOGIN.equals(invocation.getArgument(0)));
+
+        int changes = actionExecutor.execute(new Action(), 
updateOtherProfileEvent());
+
+        assertEquals(EventService.NO_CHANGE, changes);
+        verify(profileService, never()).load(any(String.class));
+        verify(profileService, never()).save(any(Profile.class));
+    }
+
+    private static Event updateOtherProfileEvent() {
+        Map<String, Object> updateMap = new HashMap<>();
+        updateMap.put("properties.email", "peer-set");
+        Map<String, Object> eventProps = new HashMap<>();
+        eventProps.put(UpdatePropertiesAction.TARGET_ID_KEY, "other");
+        eventProps.put(UpdatePropertiesAction.TARGET_TYPE_KEY, 
UpdatePropertiesAction.TARGET_TYPE_PROFILE);
+        eventProps.put(UpdatePropertiesAction.PROPS_TO_UPDATE, updateMap);
+        return new Event("updateProperties", null, new Profile("caller"), 
"systemscope", null, null, eventProps,
+                new Date(), true);
+    }
+
     @Test
     public void trustedAdmin_canUpdateAnotherProfile() {
         when(securityService.hasSystemAccess()).thenReturn(true);
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
 
b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
index 66e92a15d..14816719e 100644
--- 
a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
+++ 
b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
@@ -34,6 +34,8 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import jakarta.annotation.Priority;
+import org.apache.cxf.message.Message;
+import javax.servlet.http.HttpServletRequest;
 import javax.security.auth.Subject;
 import javax.ws.rs.Priorities;
 import javax.ws.rs.container.ContainerRequestContext;
@@ -138,7 +140,33 @@ public class AuthenticationFilter implements 
ContainerRequestFilter {
 
     @Override
     public void filter(ContainerRequestContext requestContext) throws 
IOException {
+        authenticate(requestContext);
+        recordRequestTransport(requestContext);
+    }
+
+    /**
+     * Tells the security service whether the request was encrypted, so that 
operations which
+     * require a secure transport can refuse a credential that travelled in 
clear.
+     */
+    private void recordRequestTransport(ContainerRequestContext 
requestContext) {
+        Message message = JAXRSUtils.getCurrentMessage();
+        Object request = message != null ? message.get("HTTP.REQUEST") : null;
+        if (request instanceof HttpServletRequest) {
+            HttpServletRequest httpRequest = (HttpServletRequest) request;
+            securityService.recordRequestTransport(httpRequest.isSecure(), 
httpRequest.getRemoteAddr(),
+                    httpRequest.getHeader("X-Forwarded-Proto"));
+        } else {
+            // No servlet request to ask: without proof of encryption the 
request is not secure.
+            securityService.recordRequestTransport(false, null, null);
+        }
+    }
+
+    private void authenticate(ContainerRequestContext requestContext) throws 
IOException {
         try {
+            // Request threads are pooled. Start from no subject at all, so a 
subject or privileged
+            // subject left behind by earlier work on this thread can never 
make this request trusted.
+            securityService.clearCurrentSubject();
+
             String path = requestContext.getUriInfo().getPath();
 
             // Check if single-tenant compatibility mode is enabled
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..84d87d4a7 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.api.utils.LogSanitizer;
 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.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,82 @@ 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 providerId;
         private final String key;
         private final Set<String> ipAddresses;
         private final Set<String> allowedEvents;
-
-        public ProviderConfig(String key, Set<String> ipAddresses, Set<String> 
allowedEvents) {
+        private final CompatPeerPrincipal principal;
+
+        /**
+         * @param providerId    the provider name from the configuration
+         * @param key           the provider key
+         * @param ipAddresses   allowed addresses or CIDR ranges; empty means 
no address is allowed
+         * @param allowedEvents protected event types this provider may send
+         * @param abilities     peer abilities of this provider; empty means 
none
+         */
+        public ProviderConfig(String providerId, String key, Set<String> 
ipAddresses, Set<String> allowedEvents,
+                Set<String> abilities) {
+            this.providerId = providerId;
             this.key = key;
             this.ipAddresses = ipAddresses;
             this.allowedEvents = allowedEvents;
+            this.principal = new CompatPeerPrincipal(providerId, abilities);
+        }
+
+        public String getProviderId() {
+            return providerId;
         }
 
-        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 principal.getAbilities();
+        }
+
+        /** @return the principal attached to a request this provider 
authenticated */
+        public CompatPeerPrincipal getPrincipal() {
+            return principal;
+        }
     }
 
     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 +143,98 @@ public class V2ThirdPartyConfigService {
     @Modified
     public void modified(Map<String, Object> properties) {
         Map<String, ProviderConfig> newProviders = new HashMap<>();
+        boolean allowExample = false;
+        Set<String> proxies = new HashSet<>();
 
         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))));
+            reportInvalidAddresses(proxies, 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 && StringUtils.isNotBlank(parts[1])) {
+                    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);
+                }
+
+                String configAbilities = props.get("abilities");
+                Set<String> abilities = parseAbilities(providerName, 
configAbilities);
+                if (abilities.isEmpty() && 
StringUtils.isNotBlank(configAbilities)) {
+                    LOGGER.error("Third-party provider '{}' is disabled: 
thirdparty.{}.abilities names no known "
+                            + "ability. Use any of {}, or leave the value 
empty to grant no peer ability.",
+                            providerName, providerName, 
CompatPeerPrincipal.DEFAULT_ABILITIES);
+                    continue;
+                }
+
+                Set<String> configIpAddresses = 
parseCommaSeparatedList(props.getOrDefault("ipAddresses", ""));
+                if (configIpAddresses.isEmpty()) {
+                    LOGGER.error("Third-party provider '{}' has no IP 
allowlist, so its key is refused from every "
+                            + "address. Set thirdparty.{}.ipAddresses to the 
addresses allowed to use it.",
+                            providerName, providerName);
+                }
+                reportInvalidAddresses(configIpAddresses, "thirdparty." + 
providerName + ".ipAddresses");
+                Set<String> configAllowedEvents = 
parseCommaSeparatedList(props.getOrDefault("allowedEvents", ""));
+                newProviders.put(providerName, new 
ProviderConfig(providerName, 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 +247,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 +264,38 @@ 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);
+        Optional<ProviderConfig> provider = findProviderByKey(providerKey);
+        if (provider.isEmpty()) {
+            LOGGER.warn("X-Unomi-Peer header ignored: no enabled third-party 
provider has key {} (request from IP {})",
+                    SecurityUtils.maskSecret(providerKey), 
LogSanitizer.forLogging(sourceIP));
+            return Optional.empty();
+        }
+        ProviderConfig config = provider.get();
+        if (!isIpAllowed(config, sourceIP)) {
+            LOGGER.warn("X-Unomi-Peer header ignored: IP {} is not in 
thirdparty.{}.ipAddresses",
+                    LogSanitizer.forLogging(sourceIP), config.getProviderId());
+            return Optional.empty();
+        }
+        LOGGER.debug("Compat peer authenticated: provider={} from IP={}", 
config.getProviderId(), sourceIP);
+        return Optional.of(config.getPrincipal());
+    }
+
     /**
      * Validates a provider key from the {@code X-Unomi-Peer} header for an 
event and source IP.
      *
@@ -197,35 +309,72 @@ 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<ProviderConfig> match = findProviderByKey(providerKey);
+        if (match.isEmpty()) {
             LOGGER.debug("V2 compatibility mode: Unknown provider key: {}", 
SecurityUtils.maskSecret(providerKey));
             return false;
         }
 
+        ProviderConfig config = match.get();
+        String foundProviderId = config.getProviderId();
+
         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());
+        boolean ipAuthorized = isIpAllowed(config, sourceIP);
         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, 
unless that address is a
+     * configured trusted proxy. In that case {@code X-Forwarded-For} is read 
from the last entry
+     * backwards and the first entry that is not itself a trusted proxy is 
used, because entries to
+     * the left of it were supplied by the client.
+     *
+     * @param request the HTTP request
+     * @return the IP to match against provider allow-lists
+     */
+    public String resolveClientIp(HttpServletRequest request) {
+        String remoteAddr = request.getRemoteAddr();
+        if (!isTrustedProxy(remoteAddr)) {
+            return remoteAddr;
+        }
+        String xff = request.getHeader("X-Forwarded-For");
+        if (StringUtils.isBlank(xff)) {
+            return remoteAddr;
+        }
+        String[] hops = xff.split(",");
+        for (int i = hops.length - 1; i >= 0; i--) {
+            String candidate = hops[i].trim();
+            if (!candidate.isEmpty() && !isTrustedProxy(candidate)) {
+                return candidate;
+            }
+        }
+        return remoteAddr;
+    }
+
+    /**
+     * A provider without an IP allowlist is refused from every address, as in 
Unomi 3.0. The key
+     * alone must never be enough; {@link IPValidationUtils} would read an 
empty list as "any address".
+     */
+    private static boolean isIpAllowed(ProviderConfig config, String sourceIP) 
{
+        Set<String> allowed = config.getIpAddresses();
+        return !allowed.isEmpty() && 
IPValidationUtils.isIpAuthorized(sourceIP, allowed);
+    }
+
+    /** An empty proxy list trusts nothing; {@link IPValidationUtils} would 
read it as "any address". */
+    private boolean isTrustedProxy(String address) {
+        Set<String> proxies = trustedProxies;
+        return !proxies.isEmpty() && IPValidationUtils.isIpAuthorized(address, 
proxies);
+    }
+
     /**
      * Returns the authentication key for the given provider ID.
      *
@@ -247,14 +396,68 @@ public class V2ThirdPartyConfigService {
         return providers.containsKey(providerId);
     }
 
-    private Set<String> parseCommaSeparatedList(String value) {
+    /** @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<ProviderConfig> findProviderByKey(String providerKey) {
+        for (ProviderConfig config : providers.values()) {
+            if (SecurityUtils.constantTimeEquals(providerKey, 
config.getKey())) {
+                return Optional.of(config);
+            }
+        }
+        return Optional.empty();
+    }
+
+    /**
+     * @return the configured abilities: the 3.0 set when the property is 
absent, otherwise the known
+     *         names it lists, which is empty when it is blank or lists only 
unknown names
+     */
+    private Set<String> parseAbilities(String providerName, String value) {
+        if (value == null) {
+            return CompatPeerPrincipal.DEFAULT_ABILITIES;
+        }
+        Set<String> known = new HashSet<>();
+        for (String ability : parseCommaSeparatedList(value)) {
+            if (CompatPeerPrincipal.DEFAULT_ABILITIES.contains(ability)) {
+                known.add(ability);
+            } else {
+                LOGGER.warn("Ignoring unknown ability '{}' in 
thirdparty.{}.abilities", ability, providerName);
+            }
+        }
+        return known;
+    }
+
+    private static void reportInvalidAddresses(Set<String> addresses, String 
propertyName) {
+        for (String address : addresses) {
+            if (!IPValidationUtils.isValidAddressOrRange(address)) {
+                LOGGER.error("'{}' in {} is not an IP address or CIDR range 
and will never match", address,
+                        propertyName);
+            }
+        }
+    }
+
+    private static String stringValue(Object value) {
+        return value != null ? value.toString() : "";
+    }
+
+    private static 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 +465,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..85b7a0c67
--- /dev/null
+++ 
b/rest/src/test/java/org/apache/unomi/rest/authentication/V2ThirdPartyConfigServiceTest.java
@@ -0,0 +1,288 @@
+/*
+ * 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 java.util.Set;
+
+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.lenient;
+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 keyLengthBoundary() {
+        Map<String, Object> props = new HashMap<>();
+        props.put("thirdparty.fifteen.key", "123456789012345");
+        props.put("thirdparty.sixteen.key", "1234567890123456");
+        service.modified(props);
+        assertFalse(service.isValidProvider("fifteen"));
+        assertTrue(service.isValidProvider("sixteen"));
+    }
+
+    @Test
+    void misspeltAbilitiesDisableTheProviderInsteadOfGrantingEverything() {
+        Map<String, Object> props = baseProvider("long-enough-secret-01");
+        props.put("thirdparty.provider1.abilities", "setEventID,merge");
+        service.modified(props);
+        assertFalse(service.isValidProvider("provider1"));
+
+        
when(request.getHeader("X-Unomi-Peer")).thenReturn("long-enough-secret-01");
+        when(request.getRemoteAddr()).thenReturn("127.0.0.1");
+        assertTrue(service.authenticatePeer(request).isEmpty());
+    }
+
+    @Test
+    void unknownAbilityNextToAKnownOneIsDropped() {
+        Map<String, Object> props = baseProvider("long-enough-secret-01");
+        props.put("thirdparty.provider1.abilities", 
"setEventId,chooseProfileID");
+        service.modified(props);
+        assertEquals(Set.of(CompatPeerPrincipal.ABILITY_SET_EVENT_ID),
+                service.getProviders().get("provider1").getAbilities());
+    }
+
+    @Test
+    void emptyAbilitiesGrantNoAbilityButKeepProtectedEvents() {
+        Map<String, Object> props = baseProvider("long-enough-secret-01");
+        props.put("thirdparty.provider1.abilities", "");
+        service.modified(props);
+        
assertTrue(service.getProviders().get("provider1").getAbilities().isEmpty());
+        assertTrue(service.validateProviderByKey("long-enough-secret-01", 
"login", "127.0.0.1"));
+
+        
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());
+        for (String ability : CompatPeerPrincipal.DEFAULT_ABILITIES) {
+            assertFalse(peer.get().hasAbility(ability));
+        }
+    }
+
+    @Test
+    void providerWithoutIpAllowlistIsRefusedFromEveryAddress() {
+        Map<String, Object> props = baseProvider("long-enough-secret-01");
+        for (String ipAddresses : new String[]{null, "", " , "}) {
+            if (ipAddresses == null) {
+                props.remove("thirdparty.provider1.ipAddresses");
+            } else {
+                props.put("thirdparty.provider1.ipAddresses", ipAddresses);
+            }
+            service.modified(props);
+
+            
when(request.getHeader("X-Unomi-Peer")).thenReturn("long-enough-secret-01");
+            when(request.getRemoteAddr()).thenReturn("127.0.0.1");
+            assertTrue(service.authenticatePeer(request).isEmpty(), 
"ipAddresses=" + ipAddresses);
+            assertFalse(service.validateProviderByKey("long-enough-secret-01", 
"login", "127.0.0.1"),
+                    "ipAddresses=" + ipAddresses);
+            // The provider's events stay protected, so they are not 
downgraded to public events.
+            assertTrue(service.isProtectedEventType("login"));
+        }
+    }
+
+    @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 resolveClientIpIgnoresClientSuppliedLeadingXffEntries() {
+        Map<String, Object> props = baseProvider("long-enough-secret-01");
+        props.put("trustedProxies", "10.0.0.0/24");
+        service.modified(props);
+        when(request.getRemoteAddr()).thenReturn("10.0.0.1");
+
+        // The client sent "127.0.0.1"; the proxy appended the address it 
really saw.
+        when(request.getHeader("X-Forwarded-For")).thenReturn("127.0.0.1, 
198.51.100.7");
+        assertEquals("198.51.100.7", service.resolveClientIp(request));
+
+        // A second trusted proxy in the chain is skipped as well.
+        when(request.getHeader("X-Forwarded-For")).thenReturn("127.0.0.1, 
198.51.100.7, 10.0.0.2");
+        assertEquals("198.51.100.7", service.resolveClientIp(request));
+
+        
when(request.getHeader("X-Unomi-Peer")).thenReturn("long-enough-secret-01");
+        when(request.getHeader("X-Forwarded-For")).thenReturn("127.0.0.1, 
198.51.100.7");
+        assertTrue(service.authenticatePeer(request).isEmpty());
+    }
+
+    @Test
+    void resolveClientIpFallsBackToRemoteAddrWhenXffHasNoUsableEntry() {
+        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");
+
+        for (String header : new String[]{",", ",,", " , ", "10.0.0.1", "", 
null}) {
+            when(request.getHeader("X-Forwarded-For")).thenReturn(header);
+            assertEquals("10.0.0.1", service.resolveClientIp(request), 
"X-Forwarded-For: " + header);
+        }
+    }
+
+    @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"));
+    }
+
+    @Test
+    void validateProviderByKeyRejectsWrongIp() {
+        Map<String, Object> props = baseProvider("long-enough-secret-01");
+        service.modified(props);
+        assertFalse(service.validateProviderByKey("long-enough-secret-01", 
"login", "10.0.0.1"));
+    }
+
+    @Test
+    void authenticatePeerCarriesConfiguredAbilitySubset() {
+        Map<String, Object> props = baseProvider("long-enough-secret-01");
+        props.put("thirdparty.provider1.abilities", "setEventId");
+        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(1, peer.get().getAbilities().size());
+        
assertTrue(peer.get().getAbilities().contains(CompatPeerPrincipal.ABILITY_SET_EVENT_ID));
+        
assertFalse(peer.get().getAbilities().contains(CompatPeerPrincipal.ABILITY_CHOOSE_PROFILE_ID));
+    }
+
+    @Test
+    void authenticatePeerUsesXffIpAgainstAllowlistBehindTrustedProxy() {
+        Map<String, Object> props = baseProvider("long-enough-secret-01");
+        props.put("trustedProxies", "10.0.0.1");
+        props.put("thirdparty.provider1.ipAddresses", "203.0.113.9");
+        service.modified(props);
+
+        
when(request.getHeader("X-Unomi-Peer")).thenReturn("long-enough-secret-01");
+        when(request.getRemoteAddr()).thenReturn("10.0.0.1");
+        when(request.getHeader("X-Forwarded-For")).thenReturn("203.0.113.9, 
10.0.0.1");
+        assertTrue(service.authenticatePeer(request).isPresent());
+
+        when(request.getHeader("X-Forwarded-For")).thenReturn("198.51.100.1, 
10.0.0.1");
+        assertTrue(service.authenticatePeer(request).isEmpty());
+
+        // Direct client that is not a trusted proxy: allowlist matches 
remoteAddr (XFF unused).
+        when(request.getRemoteAddr()).thenReturn("203.0.113.9");
+        assertTrue(service.authenticatePeer(request).isPresent());
+
+        // Forged XFF from a non-proxy peer must not satisfy the allowlist 
(header is unused).
+        when(request.getRemoteAddr()).thenReturn("198.51.100.1");
+        
lenient().when(request.getHeader("X-Forwarded-For")).thenReturn("203.0.113.9");
+        assertTrue(service.authenticatePeer(request).isEmpty());
+    }
+
+    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/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
 
b/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
index 2a20a245f..293e09c95 100644
--- 
a/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
+++ 
b/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
@@ -21,6 +21,7 @@ import org.apache.unomi.api.PersonaSession;
 import org.apache.unomi.api.PersonaWithSessions;
 import org.apache.unomi.api.Profile;
 import org.apache.unomi.api.Session;
+import org.apache.unomi.api.security.CompatPeerPrincipal;
 import org.apache.unomi.api.security.SecurityService;
 import org.apache.unomi.api.security.UnomiRoles;
 import org.apache.unomi.api.services.ConfigSharingService;
@@ -37,18 +38,24 @@ import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
+import javax.security.auth.Subject;
 import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import javax.ws.rs.core.SecurityContext;
 import java.lang.reflect.Field;
+import java.lang.reflect.Method;
 import java.util.Collections;
 import java.util.Date;
+import java.util.Optional;
+import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.never;
@@ -91,9 +98,94 @@ class RestServiceUtilsImplProfileBindingTest {
         
lenient().when(configSharingService.getProperty("profileIdCookieName")).thenReturn(COOKIE_NAME);
         lenient().when(schemaService.isValid(anyString(), 
anyString())).thenReturn(true);
         lenient().when(securityService.hasSystemAccess()).thenReturn(false);
+        
lenient().when(securityService.hasCompatPeerAbility(anyString())).thenReturn(false);
+        
lenient().when(restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled()).thenReturn(false);
         
lenient().when(privacyService.isRequireAnonymousBrowsing(org.mockito.ArgumentMatchers.any(Profile.class))).thenReturn(false);
     }
 
+    @Test
+    void initEventsRequest_doesNotAuthenticatePeerWhenCompatOff() {
+        Profile cookieProfile = new Profile("cookie-profile");
+        when(request.getCookies()).thenReturn(new Cookie[]{new 
Cookie(COOKIE_NAME, "cookie-profile")});
+        when(profileService.load("cookie-profile")).thenReturn(cookieProfile);
+
+        restServiceUtils.initEventsRequest(
+                "systemscope", null, null, null,
+                false, false, request, response, new Date());
+
+        verify(v2ThirdPartyConfigService, never()).authenticatePeer(any());
+        verify(securityService, never()).setCurrentSubject(any());
+    }
+
+    @Test
+    void initEventsRequest_compatPeerWithChooseProfileIdBindsBodyProfile() {
+        
when(restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled()).thenReturn(true);
+        CompatPeerPrincipal peer = new CompatPeerPrincipal("provider1",
+                Set.of(CompatPeerPrincipal.ABILITY_CHOOSE_PROFILE_ID));
+        
when(v2ThirdPartyConfigService.authenticatePeer(request)).thenReturn(Optional.of(peer));
+        when(securityService.getRequestSubject()).thenReturn(new Subject());
+        
when(securityService.hasCompatPeerAbility(CompatPeerPrincipal.ABILITY_CHOOSE_PROFILE_ID)).thenReturn(true);
+
+        Profile bodyProfile = new Profile("peer-chosen");
+        when(request.getCookies()).thenReturn(null);
+        when(profileService.load("peer-chosen")).thenReturn(bodyProfile);
+
+        EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+                "systemscope", null, "peer-chosen", null,
+                false, false, request, response, new Date());
+
+        assertEquals("peer-chosen", ctx.getProfile().getItemId());
+        verify(securityService).setCurrentSubject(any(Subject.class));
+    }
+
+    @Test
+    void 
initEventsRequest_compatPeerWithoutChooseProfileIdIgnoresBodyProfile() {
+        
when(restAuthenticationConfig.isSingleTenantCompatibilityModeEnabled()).thenReturn(true);
+        CompatPeerPrincipal peer = new CompatPeerPrincipal("provider1",
+                Set.of(CompatPeerPrincipal.ABILITY_SET_EVENT_ID));
+        
when(v2ThirdPartyConfigService.authenticatePeer(request)).thenReturn(Optional.of(peer));
+        when(securityService.getRequestSubject()).thenReturn(new Subject());
+        
when(securityService.hasCompatPeerAbility(CompatPeerPrincipal.ABILITY_CHOOSE_PROFILE_ID)).thenReturn(false);
+
+        Profile cookieProfile = new Profile("cookie-profile");
+        when(request.getCookies()).thenReturn(new Cookie[]{new 
Cookie(COOKIE_NAME, "cookie-profile")});
+        when(profileService.load("cookie-profile")).thenReturn(cookieProfile);
+
+        EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+                "systemscope", null, "peer-chosen", null,
+                false, false, request, response, new Date());
+
+        assertEquals("cookie-profile", ctx.getProfile().getItemId());
+        verify(profileService, never()).load("peer-chosen");
+    }
+
+    @Test
+    void canSetEventItemId_onlyForTenantAdminOrPeerWithSetEventId() throws 
Exception {
+        SecurityContext securityContext = 
org.mockito.Mockito.mock(SecurityContext.class);
+        
when(securityContext.isUserInRole(UnomiRoles.TENANT_ADMINISTRATOR)).thenReturn(false);
+
+        assertFalse(canSetEventItemId(securityContext), "a visitor must not 
choose the event id");
+
+        // A peer holding every ability except setEventId.
+        
when(securityService.hasCompatPeerAbility(anyString())).thenAnswer(invocation ->
+                
!CompatPeerPrincipal.ABILITY_SET_EVENT_ID.equals(invocation.getArgument(0)));
+        assertFalse(canSetEventItemId(securityContext), "a peer without 
setEventId must not choose the event id");
+
+        
when(securityService.hasCompatPeerAbility(anyString())).thenAnswer(invocation ->
+                
CompatPeerPrincipal.ABILITY_SET_EVENT_ID.equals(invocation.getArgument(0)));
+        assertTrue(canSetEventItemId(securityContext));
+
+        
lenient().when(securityService.hasCompatPeerAbility(anyString())).thenReturn(false);
+        
when(securityContext.isUserInRole(UnomiRoles.TENANT_ADMINISTRATOR)).thenReturn(true);
+        assertTrue(canSetEventItemId(securityContext));
+    }
+
+    private boolean canSetEventItemId(SecurityContext securityContext) throws 
Exception {
+        Method method = 
RestServiceUtilsImpl.class.getDeclaredMethod("canSetEventItemId", 
SecurityContext.class);
+        method.setAccessible(true);
+        return (boolean) method.invoke(restServiceUtils, securityContext);
+    }
+
     @Test
     void initEventsRequest_ignoresMismatchedBodyProfileIdForPublicCaller() {
         Profile cookieProfile = new Profile("cookie-profile");
diff --git 
a/services-common/src/main/java/org/apache/unomi/services/common/security/IPValidationUtils.java
 
b/services-common/src/main/java/org/apache/unomi/services/common/security/IPValidationUtils.java
index 160f058a7..af741f0b3 100644
--- 
a/services-common/src/main/java/org/apache/unomi/services/common/security/IPValidationUtils.java
+++ 
b/services-common/src/main/java/org/apache/unomi/services/common/security/IPValidationUtils.java
@@ -43,6 +43,24 @@ public class IPValidationUtils {
     private static final boolean SUPPRESS_STACK_TRACES = Boolean.parseBoolean(
         System.getProperty(SUPPRESS_STACK_TRACES_PROPERTY, "false"));
     
+    /**
+     * Check whether a configured value is an IP address or CIDR range that
+     * {@link #isIpAuthorized(String, Set)} is able to match against.
+     *
+     * @param value the configured address or range
+     * @return true if the value can be parsed, false otherwise
+     */
+    public static boolean isValidAddressOrRange(String value) {
+        if (StringUtils.isBlank(value)) {
+            return false;
+        }
+        try {
+            return new IPAddressString(value.trim()).toAddress() != null;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
     /**
      * Check if a source IP address is authorized against a set of allowed IP 
addresses.
      * 
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..1d9f7561a 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
@@ -54,6 +54,8 @@ public class KarafSecurityService implements SecurityService {
     private EncryptionService encryptionService;
     private AuditService tenantAuditService;
 
+    private static final String TRUSTED_PROXIES_PROPERTY = 
"org.apache.unomi.ip.trustedProxies";
+
     private final ThreadLocal<Subject> currentSubject = new ThreadLocal<>();
     private final ThreadLocal<Subject> privilegedSubject = new ThreadLocal<>();
 
@@ -228,6 +230,57 @@ public class KarafSecurityService implements 
SecurityService {
         return hasRole(UnomiRoles.ADMINISTRATOR) || 
hasRole(UnomiRoles.TENANT_ADMINISTRATOR);
     }
 
+    @Override
+    public void recordRequestTransport(boolean secure, String remoteAddr, 
String forwardedProto) {
+        if (secure || isForwardedAsSecure(remoteAddr, forwardedProto)) {
+            return;
+        }
+        Subject current = currentSubject.get();
+        if (current == null) {
+            return;
+        }
+        // Work on a copy: the bound subject may be shared or read-only.
+        Subject marked = new Subject();
+        marked.getPrincipals().addAll(current.getPrincipals());
+        marked.getPrincipals().add(InsecureTransportPrincipal.INSTANCE);
+        marked.getPublicCredentials().addAll(current.getPublicCredentials());
+        marked.getPrivateCredentials().addAll(current.getPrivateCredentials());
+        currentSubject.set(marked);
+    }
+
+    /**
+     * Whether a trusted proxy vouches that the client connection was 
encrypted. Every protocol the
+     * header lists must be {@code https}, because a proxy that appends to the 
header leaves a
+     * client-supplied value in front.
+     */
+    private boolean isForwardedAsSecure(String remoteAddr, String 
forwardedProto) {
+        if (forwardedProto == null || forwardedProto.isBlank()) {
+            return false;
+        }
+        Set<String> trustedProxies = 
Arrays.stream(System.getProperty(TRUSTED_PROXIES_PROPERTY, "").split(","))
+                .map(String::trim)
+                .filter(proxy -> !proxy.isEmpty())
+                .collect(Collectors.toSet());
+        if (trustedProxies.isEmpty() || 
!IPValidationUtils.isIpAuthorized(remoteAddr, trustedProxies)) {
+            return false;
+        }
+        return Arrays.stream(forwardedProto.split(",")).allMatch(proto -> 
"https".equalsIgnoreCase(proto.trim()));
+    }
+
+    @Override
+    public boolean hasCompatPeerAbility(String ability) {
+        Subject subject = getRequestSubject();
+        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..85c25710c 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,19 @@ public class SecurityUtils {
         }
         return secret.substring(0, VISIBLE_PREFIX_LENGTH) + MASK_SUFFIX;
     }
+
+    /**
+     * Compares two strings without stopping at the first differing byte, so 
the time taken does
+     * not reveal how much of a secret matched. The lengths of the values are 
not hidden.
+     *
+     * @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) {
+        if (left == null || right == null) {
+            return false;
+        }
+        return MessageDigest.isEqual(left.getBytes(StandardCharsets.UTF_8), 
right.getBytes(StandardCharsets.UTF_8));
+    }
 }
diff --git 
a/services-common/src/test/java/org/apache/unomi/services/common/security/KarafSecurityServiceTest.java
 
b/services-common/src/test/java/org/apache/unomi/services/common/security/KarafSecurityServiceTest.java
index 6841adf85..95a596ba9 100644
--- 
a/services-common/src/test/java/org/apache/unomi/services/common/security/KarafSecurityServiceTest.java
+++ 
b/services-common/src/test/java/org/apache/unomi/services/common/security/KarafSecurityServiceTest.java
@@ -18,7 +18,9 @@ package org.apache.unomi.services.common.security;
 
 import org.apache.karaf.jaas.boot.principal.RolePrincipal;
 import org.apache.karaf.jaas.boot.principal.UserPrincipal;
+import org.apache.unomi.api.security.CompatPeerPrincipal;
 import org.apache.unomi.api.security.EncryptionService;
+import org.apache.unomi.api.security.InsecureTransportPrincipal;
 import org.apache.unomi.api.security.SecurityServiceConfiguration;
 import org.apache.unomi.api.security.TenantPrincipal;
 import org.apache.unomi.api.security.UnomiRoles;
@@ -74,6 +76,82 @@ public class KarafSecurityServiceTest {
         securityService.clearPrivilegedSubject();
     }
 
+    @Test
+    public void testHasCompatPeerAbilityOnlyForAbilitiesThePeerHolds() {
+        Subject subject = new Subject();
+        subject.getPrincipals().add(new CompatPeerPrincipal("provider1",
+                
Collections.singleton(CompatPeerPrincipal.ABILITY_SET_EVENT_ID)));
+        securityService.setCurrentSubject(subject);
+
+        
assertTrue(securityService.hasCompatPeerAbility(CompatPeerPrincipal.ABILITY_SET_EVENT_ID));
+        
assertFalse(securityService.hasCompatPeerAbility(CompatPeerPrincipal.ABILITY_CHOOSE_PROFILE_ID));
+        
assertFalse(securityService.hasCompatPeerAbility(CompatPeerPrincipal.ABILITY_MERGE_ON_LOGIN));
+        
assertFalse(securityService.hasCompatPeerAbility(CompatPeerPrincipal.ABILITY_UPDATE_OTHER_PROFILES));
+        assertFalse(securityService.hasCompatPeerAbility(null));
+        assertFalse(securityService.hasCompatPeerAbility("unknown"));
+        assertFalse("A compat peer must not gain system access", 
securityService.hasSystemAccess());
+    }
+
+    @Test
+    public void testHasCompatPeerAbilityIsFalseWithoutPeerPrincipal() {
+        assertFalse("No subject", 
securityService.hasCompatPeerAbility(CompatPeerPrincipal.ABILITY_SET_EVENT_ID));
+
+        Subject admin = new Subject();
+        admin.getPrincipals().add(new RolePrincipal(UnomiRoles.ADMINISTRATOR));
+        securityService.setCurrentSubject(admin);
+        assertFalse("An administrator role is not a peer ability",
+                
securityService.hasCompatPeerAbility(CompatPeerPrincipal.ABILITY_SET_EVENT_ID));
+    }
+
+    @Test
+    public void testHasCompatPeerAbilityIgnoresPrivilegedSubject() {
+        Subject privileged = new Subject();
+        privileged.getPrincipals().add(new CompatPeerPrincipal("provider1", 
CompatPeerPrincipal.DEFAULT_ABILITIES));
+        securityService.setPrivilegedSubject(privileged);
+
+        
assertFalse(securityService.hasCompatPeerAbility(CompatPeerPrincipal.ABILITY_SET_EVENT_ID));
+    }
+
+    @Test
+    public void testRecordRequestTransportMarksOnlyUnencryptedRequests() {
+        String previous = 
System.getProperty("org.apache.unomi.ip.trustedProxies");
+        System.setProperty("org.apache.unomi.ip.trustedProxies", "10.0.0.1");
+        try {
+            assertFalse("HTTPS seen by the container", isMarkedInsecure(true, 
"203.0.113.9", null));
+            assertTrue("plain HTTP", isMarkedInsecure(false, "203.0.113.9", 
null));
+            assertFalse("trusted proxy reports https", isMarkedInsecure(false, 
"10.0.0.1", "https"));
+            assertTrue("trusted proxy reports http", isMarkedInsecure(false, 
"10.0.0.1", "http"));
+            assertTrue("client-supplied value in front of the proxy's", 
isMarkedInsecure(false, "10.0.0.1", "https, http"));
+            assertTrue("header from an untrusted address", 
isMarkedInsecure(false, "203.0.113.9", "https"));
+
+            System.clearProperty("org.apache.unomi.ip.trustedProxies");
+            assertTrue("no trusted proxy configured", isMarkedInsecure(false, 
"10.0.0.1", "https"));
+        } finally {
+            if (previous == null) {
+                System.clearProperty("org.apache.unomi.ip.trustedProxies");
+            } else {
+                System.setProperty("org.apache.unomi.ip.trustedProxies", 
previous);
+            }
+        }
+    }
+
+    @Test
+    public void testRecordRequestTransportKeepsRolesAndIgnoresMissingSubject() 
{
+        securityService.recordRequestTransport(false, "203.0.113.9", null);
+        assertNull(securityService.getRequestSubject());
+
+        
securityService.setCurrentSubject(securityService.createSubject("tenant1", 
true));
+        securityService.recordRequestTransport(false, "203.0.113.9", null);
+        assertTrue(securityService.hasSystemAccess());
+        assertEquals("tenant1", securityService.getCurrentSubjectTenantId());
+    }
+
+    private boolean isMarkedInsecure(boolean secure, String remoteAddr, String 
forwardedProto) {
+        
securityService.setCurrentSubject(securityService.createSubject("tenant1", 
true));
+        securityService.recordRequestTransport(secure, remoteAddr, 
forwardedProto);
+        return 
!securityService.getRequestSubject().getPrincipals(InsecureTransportPrincipal.class).isEmpty();
+    }
+
     @Test
     public void testGetSystemSubject() {
         Subject systemSubject = securityService.getSystemSubject();
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..4d8b913e1 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"));
+        assertFalse(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..af1b4a54b 100644
--- a/services/src/main/resources/org.apache.unomi.thirdparty.cfg
+++ b/services/src/main/resources/org.apache.unomi.thirdparty.cfg
@@ -15,12 +15,34 @@
 # 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 (logs a warning every start).
+# Never set allowExampleKey=true in production: the example key is public, so
+# anyone who can reach Unomi from an allowed address holds it.
+#
+# abilities (optional, comma-separated): setEventId, chooseProfileId, 
mergeOnLogin,
+# updateOtherProfiles. Names are case-sensitive. A provider with no abilities
+# property gets all four (Unomi 3.0 peer behaviour). An empty value grants no
+# ability (protected events only). A value that names no known ability disables
+# the provider with an error in the log.
 #
+# ipAddresses: addresses or CIDR ranges the key is accepted from. Required: a
+# provider with an empty list is refused from every address, with an error in
+# the log at startup. The key alone is never enough.
+#
+# trustedProxies: when the direct connection address is in this list, the
+# X-Forwarded-For header is read from its last entry backwards and the first
+# entry that is not itself a trusted proxy 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