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

asf-gitbox-commits pushed a commit to branch 
UNOMI-972-credentials-profile-binding-privileged-rest
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit cc7728a53a9b20cb6c16a4ba0dd4c34076b3b533
Author: Serge Huber <[email protected]>
AuthorDate: Mon Aug 10 09:27:28 2026 +0200

    UNOMI-972: prove the hardening end to end, and pin what it must not break
    
    Adds the integration coverage for the fixes above, plus a before/after 
baseline for the two
    public client endpoints, which is the part that answers whether existing 
clients still work.
    
    ContextEndpointBaselineIT is written to compile and run against both master 
and this branch
    and is split into two groups with opposite expectations. Run on both, it 
gives:
    
      compat_* (7)                               master pass / branch pass
      hardened_publicBodyProfileIdIsIgnored      master FAIL / branch pass
      hardened_publicCallerCannotAdoptAForeign*  master FAIL / branch pass
    
    On master the first hardened test returns the victim's profile identifier 
and properties to
    a caller holding only the public API key - the reported issue reproduced 
end to end - and the
    branch answers 400. The compat group deliberately covers the client entry 
points that had no
    coverage at all: the GET forms carrying ?payload=, which is how a script 
tag or image beacon
    tracks and which route through the same binding code as POST.
    
    Also pins the areas a future ownership check is most likely to break, none 
of which was
    covered anywhere: all four branches of the anonymous-browsing handling, and 
persona binding.
    Personas short-circuit binding entirely and profileOverrides only apply to 
a Persona, so both
    are structurally isolated from these changes - now asserted rather than 
assumed.
    
    RestEndpointRoleSecurityIT covers the Groovy and router role gates over 
real HTTP; ProfileMergeIT
    and PropertiesUpdateActionIT cover the action gates. Every new IT is 
registered in AllITs and
    CorePersistenceITs, without which failsafe silently never runs them.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
 .../test/java/org/apache/unomi/itests/AllITs.java  |   3 +
 .../unomi/itests/ContextEndpointBaselineIT.java    | 289 ++++++++++
 .../org/apache/unomi/itests/ContextServletIT.java  | 606 ++++++++++++++++++++-
 .../apache/unomi/itests/CorePersistenceITs.java    |   3 +
 .../org/apache/unomi/itests/ProfileMergeIT.java    |  65 +++
 .../unomi/itests/PropertiesUpdateActionIT.java     |  76 +++
 .../unomi/itests/RestEndpointRoleSecurityIT.java   | 177 ++++++
 7 files changed, 1199 insertions(+), 20 deletions(-)

diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java 
b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
index 41351e5b0..a17afe29a 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -56,11 +56,14 @@ import org.junit.runners.Suite.SuiteClasses;
         ModifyConsentIT.class,
         PatchIT.class,
         ContextServletIT.class,
+        ContextEndpointBaselineIT.class,
         SecurityIT.class,
         RuleServiceIT.class,
         PrivacyServiceIT.class,
         GroovyActionsServiceIT.class,
+        RestEndpointRoleSecurityIT.class,
         GraphQLEventIT.class,
+        GraphQLServletSecurityIT.class,
         GraphQLListIT.class,
         GraphQLProfileIT.class,
         GraphQLProfilePropertiesIT.class,
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/ContextEndpointBaselineIT.java 
b/itests/src/test/java/org/apache/unomi/itests/ContextEndpointBaselineIT.java
new file mode 100644
index 000000000..52da5035f
--- /dev/null
+++ 
b/itests/src/test/java/org/apache/unomi/itests/ContextEndpointBaselineIT.java
@@ -0,0 +1,289 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.itests;
+
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.util.EntityUtils;
+import org.apache.unomi.api.ContextRequest;
+import org.apache.unomi.api.Event;
+import org.apache.unomi.api.EventsCollectorRequest;
+import org.apache.unomi.api.CustomItem;
+import org.apache.unomi.api.Profile;
+import org.apache.unomi.itests.tools.httpclient.HttpClientThatWaitsForUnomi;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
+import org.ops4j.pax.exam.spi.reactors.PerSuite;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Objects;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Before/after behavioural baseline for the two public client endpoints, 
{@code /cxs/context.json}
+ * (plus its {@code /cxs/context.js} sibling) and {@code /cxs/eventcollector}.
+ * <p>
+ * This class is deliberately written to compile and run against <em>both</em> 
the pre-hardening
+ * baseline and the hardened branch, so the same suite can be executed on each 
and the results
+ * diffed. It is split into two groups with opposite expectations:
+ * <ul>
+ *   <li><b>compat_*</b> — legacy client behaviour that MUST be identical 
before and after. A
+ *       failure here on the hardened branch is a compatibility regression, 
full stop.</li>
+ *   <li><b>hardened_*</b> — behaviour the hardening intentionally changes. 
These are expected to
+ *       FAIL on the pre-hardening baseline and PASS after; that contrast is 
the evidence the
+ *       security fix actually does something.</li>
+ * </ul>
+ * The compat group covers the client entry points that had no coverage at 
all: the {@code GET}
+ * forms carrying a {@code ?payload=} query parameter, which is how a script 
tag or image beacon
+ * tracks, and which route through exactly the same binding code as the POST 
forms.
+ */
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class ContextEndpointBaselineIT extends BaseIT {
+
+    private static final String UNOMI_API_KEY_HTTP_HEADER_KEY = 
"X-Unomi-Api-Key";
+    private static final String CONTEXT_JSON_URL = "/cxs/context.json";
+    private static final String CONTEXT_JS_URL = "/cxs/context.js";
+    private static final String EVENT_COLLECTOR_URL = "/cxs/eventcollector";
+    private static final String TEST_SCOPE = "baseline-scope";
+
+    // ------------------------------------------------------------------ 
compatibility group
+
+    /** A brand new visitor with no cookie and no session must still be issued 
a profile. */
+    @Test
+    public void compat_firstVisitIssuesAProfileAndCookie() throws Exception {
+        String sessionId = "baseline-first-" + System.currentTimeMillis();
+        TestUtils.RequestResponse response = 
postContextJson(newContextRequest(sessionId), null, sessionId);
+
+        assertEquals(200, response.getStatusCode());
+        assertNotNull("a first visit must be issued a profile id", 
response.getContextResponse().getProfileId());
+        assertNotNull("a first visit must be issued the profile cookie", 
response.getCookieHeaderValue());
+    }
+
+    /** A returning visitor presenting the cookie must be recognised as the 
same profile. */
+    @Test
+    public void compat_returningVisitorKeepsItsProfile() throws Exception {
+        String sessionId = "baseline-returning-" + System.currentTimeMillis();
+        TestUtils.RequestResponse first = 
postContextJson(newContextRequest(sessionId), null, sessionId);
+        String profileId = first.getContextResponse().getProfileId();
+
+        TestUtils.RequestResponse second = 
postContextJson(newContextRequest(sessionId), first.getCookieHeaderValue(), 
sessionId);
+
+        assertEquals(200, second.getStatusCode());
+        assertEquals("a returning visitor must keep its profile", profileId, 
second.getContextResponse().getProfileId());
+        assertEquals("and its session", sessionId, 
second.getContextResponse().getSessionId());
+    }
+
+    /** The GET form with ?payload= must behave like the POST form. This entry 
point had no coverage. */
+    @Test
+    public void compat_getWithPayloadBehavesLikePost() throws Exception {
+        String sessionId = "baseline-get-" + System.currentTimeMillis();
+        TestUtils.RequestResponse established = 
postContextJson(newContextRequest(sessionId), null, sessionId);
+        String profileId = established.getContextResponse().getProfileId();
+
+        HttpGet get = new HttpGet(getFullUrl(CONTEXT_JSON_URL) + "?payload=" + 
encode(newContextRequest(sessionId)));
+        get.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+        get.addHeader("Cookie", established.getCookieHeaderValue());
+        TestUtils.RequestResponse response = 
TestUtils.executeContextJSONRequest(get, sessionId, getObjectMapper());
+
+        assertEquals(200, response.getStatusCode());
+        assertEquals("GET ?payload= must resolve the same profile as POST", 
profileId,
+                response.getContextResponse().getProfileId());
+    }
+
+    /** /cxs/context.js must keep serving JavaScript to script-tag clients. */
+    @Test
+    public void compat_contextJsServesJavaScript() throws Exception {
+        String sessionId = "baseline-js-" + System.currentTimeMillis();
+        HttpGet get = new HttpGet(getFullUrl(CONTEXT_JS_URL) + "?sessionId=" + 
sessionId);
+        get.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+
+        try (CloseableHttpResponse response = 
HttpClientThatWaitsForUnomi.doRequest(get)) {
+            assertEquals(200, response.getStatusLine().getStatusCode());
+            String body = EntityUtils.toString(response.getEntity());
+            // Same marker BasicIT asserts on: context.js emits the 
digitalData bootstrap that
+            // script-tag clients rely on. Asserting the marker, not just a 
200, so an empty or
+            // error body cannot pass as success.
+            assertTrue("context.js must return the tracker javascript, got: "
+                            + body.substring(0, Math.min(200, body.length())),
+                    body.contains("window.digitalData"));
+        }
+    }
+
+    /** Event collection over POST must keep working and report the event as 
processed. */
+    @Test
+    public void compat_eventCollectorAcceptsEvents() throws Exception {
+        String sessionId = "baseline-ec-" + System.currentTimeMillis();
+        TestUtils.RequestResponse established = 
postContextJson(newContextRequest(sessionId), null, sessionId);
+
+        HttpPost post = new HttpPost(getFullUrl(EVENT_COLLECTOR_URL));
+        post.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+        post.addHeader("Cookie", established.getCookieHeaderValue());
+        post.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(newEventsRequest(sessionId)),
+                ContentType.APPLICATION_JSON));
+
+        try (CloseableHttpResponse response = 
HttpClientThatWaitsForUnomi.doRequest(post)) {
+            assertEquals("the eventcollector must keep accepting events from a 
cookie-bearing client",
+                    200, response.getStatusLine().getStatusCode());
+        }
+    }
+
+    /** The eventcollector GET form with ?payload= — another entry point that 
had no coverage. */
+    @Test
+    public void compat_eventCollectorGetWithPayload() throws Exception {
+        String sessionId = "baseline-ecget-" + System.currentTimeMillis();
+        TestUtils.RequestResponse established = 
postContextJson(newContextRequest(sessionId), null, sessionId);
+
+        HttpGet get = new HttpGet(getFullUrl(EVENT_COLLECTOR_URL) + 
"?payload=" + encode(newEventsRequest(sessionId)));
+        get.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+        get.addHeader("Cookie", established.getCookieHeaderValue());
+
+        try (CloseableHttpResponse response = 
HttpClientThatWaitsForUnomi.doRequest(get)) {
+            assertEquals(200, response.getStatusLine().getStatusCode());
+        }
+    }
+
+    /** A client may continue its own session across requests without 
re-establishing it. */
+    @Test
+    public void compat_sessionContinuityAcrossRequests() throws Exception {
+        String sessionId = "baseline-cont-" + System.currentTimeMillis();
+        TestUtils.RequestResponse first = 
postContextJson(newContextRequest(sessionId), null, sessionId);
+
+        for (int i = 0; i < 3; i++) {
+            TestUtils.RequestResponse next = 
postContextJson(newContextRequest(sessionId), first.getCookieHeaderValue(), 
sessionId);
+            assertEquals(200, next.getStatusCode());
+            assertEquals("the client's own session must never be refused", 
sessionId,
+                    next.getContextResponse().getSessionId());
+        }
+    }
+
+    // ------------------------------------------------------------------ 
hardened group
+    // Expected to FAIL on the pre-hardening baseline and PASS after. That 
contrast is the point.
+
+    /**
+     * A public caller must not be able to read another visitor's profile by 
naming it in the body.
+     * <p>
+     * The attack request carries ONLY the body profileId - no cookie and no 
session - because that is
+     * what makes this discriminating. An earlier version of this test also 
sent a session owned by the
+     * caller, and on the pre-hardening baseline the session-recovery logic 
switched the profile back
+     * to the session owner, masking the body profileId entirely and making 
the test pass on both
+     * sides. Asserting on the victim's actual data rather than on an echoed 
id keeps it honest.
+     */
+    @Test
+    public void hardened_publicBodyProfileIdIsIgnored() throws Exception {
+        String victimProfileId = "baseline-victim-" + 
System.currentTimeMillis();
+        String victimSecret = "baseline-secret-" + System.currentTimeMillis();
+        Profile victim = new Profile(victimProfileId);
+        victim.setProperty("baselineSecret", victimSecret);
+        profileService.save(victim);
+        keepTrying("Victim profile should be saved", () -> 
profileService.load(victimProfileId),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        try {
+            ContextRequest claim = new ContextRequest();
+            claim.setProfileId(victimProfileId);
+            claim.setRequiredProfileProperties(Collections.singletonList("*"));
+            CustomItem source = new CustomItem("baseline-page", "page");
+            source.setScope(TEST_SCOPE);
+            claim.setSource(source);
+
+            HttpPost post = new HttpPost(getFullUrl(CONTEXT_JSON_URL));
+            post.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+            post.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(claim), 
ContentType.APPLICATION_JSON));
+
+            // Plain client, not HttpClientThatWaitsForUnomi: the hardened 
branch answers 400 here
+            // (nothing left to bind once the body profileId is ignored), and 
that helper retries
+            // non-2xx and then throws, which would mask the very behaviour 
under test.
+            try (CloseableHttpResponse response = httpClient.execute(post)) {
+                String body = response.getEntity() == null ? "" : 
EntityUtils.toString(response.getEntity());
+                assertFalse("a public caller must not receive the victim's 
profile properties, got: "
+                                + body.substring(0, Math.min(300, 
body.length())),
+                        body.contains(victimSecret));
+                assertFalse("a public caller must not be bound to the victim's 
profile id",
+                        body.contains(victimProfileId));
+            }
+        } finally {
+            profileService.delete(victimProfileId, false);
+        }
+    }
+
+    /** A public caller must not be able to adopt a session belonging to 
someone else. */
+    @Test
+    public void hardened_publicCallerCannotAdoptAForeignSession() throws 
Exception {
+        String victimSessionId = "baseline-victim-sess-" + 
System.currentTimeMillis();
+        TestUtils.RequestResponse victim = 
postContextJson(newContextRequest(victimSessionId), null, victimSessionId);
+        String victimProfileId = victim.getContextResponse().getProfileId();
+
+        String attackerSessionId = "baseline-attacker-sess-" + 
System.currentTimeMillis();
+        TestUtils.RequestResponse attacker = 
postContextJson(newContextRequest(attackerSessionId), null, attackerSessionId);
+
+        // Attacker presents the victim's session id with its own cookie.
+        TestUtils.RequestResponse hijack = 
postContextJson(newContextRequest(victimSessionId),
+                attacker.getCookieHeaderValue(), victimSessionId);
+
+        assertEquals(200, hijack.getStatusCode());
+        assertTrue("the attacker must not end up on the victim's profile",
+                
!victimProfileId.equals(hijack.getContextResponse().getProfileId()));
+    }
+
+    // ------------------------------------------------------------------ 
helpers
+
+    private ContextRequest newContextRequest(String sessionId) {
+        ContextRequest contextRequest = new ContextRequest();
+        contextRequest.setSessionId(sessionId);
+        CustomItem source = new CustomItem("baseline-page", "page");
+        source.setScope(TEST_SCOPE);
+        contextRequest.setSource(source);
+        return contextRequest;
+    }
+
+    private EventsCollectorRequest newEventsRequest(String sessionId) {
+        Event event = new Event();
+        event.setEventType("view");
+        event.setScope(TEST_SCOPE);
+        EventsCollectorRequest eventsRequest = new EventsCollectorRequest();
+        eventsRequest.setSessionId(sessionId);
+        eventsRequest.setEvents(Collections.singletonList(event));
+        return eventsRequest;
+    }
+
+    private TestUtils.RequestResponse postContextJson(ContextRequest 
contextRequest, String cookie, String sessionId)
+            throws Exception {
+        HttpPost post = new HttpPost(getFullUrl(CONTEXT_JSON_URL));
+        post.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+        if (cookie != null) {
+            post.addHeader("Cookie", cookie);
+        }
+        post.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        return TestUtils.executeContextJSONRequest(post, sessionId, 
getObjectMapper());
+    }
+
+    private String encode(Object payload) throws Exception {
+        return 
URLEncoder.encode(getObjectMapper().writeValueAsString(payload), 
StandardCharsets.UTF_8.name());
+    }
+}
diff --git a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java 
b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
index e714ac218..3e8d6e8f9 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
@@ -11,7 +11,7 @@
  * 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 
gtestCreateEventWithPropertiesValidation_Successoverning permissions and
+ * See the License for the specific language governing permissions and
  * limitations under the License
  */
 
@@ -34,6 +34,8 @@ import 
org.apache.http.impl.client.TargetAuthenticationStrategy;
 import org.apache.http.client.config.RequestConfig;
 import org.apache.unomi.api.*;
 import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.conditions.ConditionType;
+import org.apache.unomi.api.rules.Rule;
 import org.apache.unomi.api.segments.Scoring;
 import org.apache.unomi.api.segments.Segment;
 import org.apache.unomi.api.tenants.ApiKey;
@@ -48,6 +50,8 @@ import org.junit.runner.RunWith;
 import org.ops4j.pax.exam.junit.PaxExam;
 import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
 import org.ops4j.pax.exam.spi.reactors.PerSuite;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.File;
 import java.net.URI;
@@ -66,6 +70,8 @@ import static org.junit.Assert.*;
 @RunWith(PaxExam.class)
 @ExamReactorStrategy(PerSuite.class)
 public class ContextServletIT extends BaseIT {
+    private final static Logger LOGGER = 
LoggerFactory.getLogger(ContextServletIT.class);
+
     private final static String CONTEXT_URL = "/cxs/context.json";
 
     private final static String UNOMI_API_KEY_HTTP_HEADER_KEY = 
"X-Unomi-Api-Key";
@@ -123,6 +129,22 @@ public class ContextServletIT extends BaseIT {
 
     @After
     public void tearDown() throws InterruptedException {
+        // The login-merge tests register this rule and remove it on their 
happy path, but an
+        // assertion failing earlier would leave it behind. The suite shares 
one Karaf container
+        // (PerSuite), so a stray rule reacting to every login event would 
leak into later tests.
+        //
+        // Guarded: if this threw, it would abort tearDown before the 
event/session cleanup below,
+        // silently polluting the shared container for every later test with a 
failure that looks
+        // unrelated. A rule that cannot be removed is worth reporting, not 
worth losing the rest
+        // of the cleanup over.
+        try {
+            if (rulesService.getRule("testLogin") != null) {
+                rulesService.removeRule("testLogin");
+            }
+        } catch (RuntimeException e) {
+            LOGGER.warn("Could not remove the testLogin rule during tearDown; 
later tests in this "
+                    + "suite may see it", e);
+        }
         persistenceService.refresh();
         TestUtils.removeAllEvents(definitionsService, persistenceService, 
true, tenantService, executionContextManager);
         TestUtils.removeAllSessions(definitionsService, persistenceService, 
true, tenantService, executionContextManager);
@@ -419,6 +441,511 @@ public class ContextServletIT extends BaseIT {
                 DEFAULT_SHOULDBETRUE_TRIES);
     }
 
+    @Test
+    public void testPublicCaller_mismatchedBodyProfileId_ignored() throws 
Exception {
+        String sessionId = "mismatch-session-" + System.currentTimeMillis();
+
+        ContextRequest firstRequest = new ContextRequest();
+        firstRequest.setSessionId(sessionId);
+        HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(establish);
+        establish.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(firstRequest), 
ContentType.APPLICATION_JSON));
+        RequestResponse established = executeContextJSONRequest(establish, 
sessionId);
+        assertEquals(200, established.getStatusCode());
+        String cookieProfileId = 
established.getContextResponse().getProfileId();
+        assertNotNull(cookieProfileId);
+        assertNotNull(established.getCookieHeaderValue());
+
+        String attackerProfileId = "attacker-body-profile-" + 
System.currentTimeMillis();
+        ContextRequest mismatchRequest = new ContextRequest();
+        mismatchRequest.setSessionId(sessionId);
+        mismatchRequest.setProfileId(attackerProfileId);
+        HttpPost mismatch = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(mismatch);
+        mismatch.addHeader("Cookie", established.getCookieHeaderValue());
+        mismatch.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(mismatchRequest), 
ContentType.APPLICATION_JSON));
+        RequestResponse mismatched = executeContextJSONRequest(mismatch, 
sessionId);
+
+        assertEquals(200, mismatched.getStatusCode());
+        assertEquals("Public caller must keep cookie profile when body 
profileId differs",
+                cookieProfileId, 
mismatched.getContextResponse().getProfileId());
+        assertNull("Attacker-supplied profileId must not be created", 
profileService.load(attackerProfileId));
+    }
+
+    /**
+     * End-to-end guard for anonymous browsing. The session-ownership check 
added for public callers
+     * deliberately skips anonymous profiles today; any future tightening of 
it must not detach the
+     * session of a visitor who is legitimately browsing anonymously. That 
failure would be invisible
+     * at unit level in the endpoint wiring, hence this IT: it asserts the 
visitor's own session id is
+     * still echoed back (a refused session is suppressed from the response) 
after anonymisation.
+     */
+    @Test
+    public void testAnonymousBrowsing_visitorKeepsItsOwnSession() throws 
Exception {
+        String sessionId = "anon-browsing-session-" + 
System.currentTimeMillis();
+
+        ContextRequest firstRequest = new ContextRequest();
+        firstRequest.setSessionId(sessionId);
+        HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(establish);
+        establish.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(firstRequest), 
ContentType.APPLICATION_JSON));
+        RequestResponse established = executeContextJSONRequest(establish, 
sessionId);
+        assertEquals(200, established.getStatusCode());
+        String profileId = established.getContextResponse().getProfileId();
+        assertNotNull(profileId);
+        assertNotNull(established.getCookieHeaderValue());
+
+        // Turn on anonymous browsing for this visitor, exactly as the privacy 
endpoint would.
+        privacyService.setRequireAnonymousBrowsing(profileId, true, 
TEST_SCOPE);
+        keepTrying("Anonymous browsing should be enabled for the profile",
+                () -> privacyService.isRequireAnonymousBrowsing(profileId),
+                Boolean.TRUE::equals, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        try {
+            // Same visitor, same cookie, same session: must still be served, 
and the session kept.
+            ContextRequest secondRequest = new ContextRequest();
+            secondRequest.setSessionId(sessionId);
+            HttpPost anonymous = new HttpPost(getFullUrl(CONTEXT_URL));
+            addPublicTenantAuth(anonymous);
+            anonymous.addHeader("Cookie", established.getCookieHeaderValue());
+            anonymous.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(secondRequest), 
ContentType.APPLICATION_JSON));
+            RequestResponse anonymousResponse = 
executeContextJSONRequest(anonymous, sessionId);
+
+            assertEquals(200, anonymousResponse.getStatusCode());
+            assertNotNull("An anonymous visitor's own session must not be 
refused",
+                    anonymousResponse.getContextResponse().getSessionId());
+            assertEquals(sessionId, 
anonymousResponse.getContextResponse().getSessionId());
+
+            // And turning anonymity back off must keep working too (the 
de-anonymising branch).
+            privacyService.setRequireAnonymousBrowsing(profileId, false, 
TEST_SCOPE);
+            keepTrying("Anonymous browsing should be disabled again",
+                    () -> privacyService.isRequireAnonymousBrowsing(profileId),
+                    Boolean.FALSE::equals, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+            ContextRequest thirdRequest = new ContextRequest();
+            thirdRequest.setSessionId(sessionId);
+            HttpPost deanonymised = new HttpPost(getFullUrl(CONTEXT_URL));
+            addPublicTenantAuth(deanonymised);
+            deanonymised.addHeader("Cookie", 
established.getCookieHeaderValue());
+            deanonymised.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(thirdRequest), 
ContentType.APPLICATION_JSON));
+            RequestResponse deanonymisedResponse = 
executeContextJSONRequest(deanonymised, sessionId);
+
+            assertEquals(200, deanonymisedResponse.getStatusCode());
+            assertNotNull("Leaving anonymous browsing must not refuse the 
visitor's own session",
+                    deanonymisedResponse.getContextResponse().getSessionId());
+        } finally {
+            privacyService.setRequireAnonymousBrowsing(profileId, false, 
TEST_SCOPE);
+        }
+    }
+
+    /**
+     * Personas short-circuit profile binding entirely: the profile and 
session both come from the
+     * persona and none of the cookie/body binding logic runs. Nothing covered 
that path end to end,
+     * so a change to the binding code could silently break persona preview.
+     */
+    @Test
+    public void testPersona_contextJsonBindsToThePersona() throws Exception {
+        String personaId = "it-persona-" + System.currentTimeMillis();
+        profileService.createPersona(personaId);
+        keepTrying("Persona should be created", () -> 
profileService.loadPersonaWithSessions(personaId),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        try {
+            ContextRequest contextRequest = new ContextRequest();
+            HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL) + 
"?personaId=" + personaId);
+            addPublicTenantAuth(request);
+            request.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+            RequestResponse response = executeContextJSONRequest(request, 
null);
+
+            assertEquals(200, response.getStatusCode());
+            assertEquals("The context response must be bound to the persona, 
not a live profile",
+                    personaId, response.getContextResponse().getProfileId());
+        } finally {
+            profileService.delete(personaId, true);
+        }
+    }
+
+    /**
+     * profileOverrides / sessionPropertiesOverrides are the preview-UI 
feature that lets a caller
+     * temporarily substitute segments, scores and properties. They had no 
coverage at all, and they
+     * are only honoured when the active profile is a Persona ({@code 
ContextJsonEndpoint#processOverrides}),
+     * which is exactly what keeps a public caller from overriding a real 
profile. Pin both halves:
+     * the override applies for a persona, and the persona path is unaffected 
by the binding rules.
+     */
+    @Test
+    public void testPersona_profileOverridesAreApplied() throws Exception {
+        String personaId = "it-persona-overrides-" + 
System.currentTimeMillis();
+        profileService.createPersona(personaId);
+        keepTrying("Persona should be created", () -> 
profileService.loadPersonaWithSessions(personaId),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        try {
+            Profile overrides = new Profile();
+            overrides.setSegments(new 
HashSet<>(Arrays.asList("override-segment-a", "override-segment-b")));
+
+            ContextRequest contextRequest = new ContextRequest();
+            contextRequest.setRequireSegments(true);
+            contextRequest.setProfileOverrides(overrides);
+
+            HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL) + 
"?personaId=" + personaId);
+            addPublicTenantAuth(request);
+            request.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+            RequestResponse response = executeContextJSONRequest(request, 
null);
+
+            assertEquals(200, response.getStatusCode());
+            assertEquals(personaId, 
response.getContextResponse().getProfileId());
+            assertNotNull("requireSegments must return the segment set", 
response.getContextResponse().getProfileSegments());
+            assertTrue("profileOverrides segments must be reflected for a 
persona",
+                    
response.getContextResponse().getProfileSegments().contains("override-segment-a"));
+        } finally {
+            profileService.delete(personaId, true);
+        }
+    }
+
+    @Test
+    public void 
testPublicCaller_sessionProfileSwitchWithoutMatchingCookie_refused() throws 
Exception {
+        String sessionOwnerId = "session-owner-" + System.currentTimeMillis();
+        String sessionId = "hijack-session-" + System.currentTimeMillis();
+        Profile sessionOwner = new Profile(sessionOwnerId);
+        profileService.save(sessionOwner);
+        Session foreignSession = new Session(sessionId, sessionOwner, new 
Date(), TEST_SCOPE);
+        profileService.saveSession(foreignSession);
+        keepTrying("Session owner not found", () -> 
profileService.load(sessionOwnerId), Objects::nonNull,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+        keepTrying("Foreign session not found", () -> 
profileService.loadSession(sessionId), Objects::nonNull,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        ContextRequest cookieEstablish = new ContextRequest();
+        cookieEstablish.setSessionId("cookie-session-" + 
System.currentTimeMillis());
+        HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(establish);
+        establish.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(cookieEstablish), 
ContentType.APPLICATION_JSON));
+        RequestResponse established = executeContextJSONRequest(establish, 
cookieEstablish.getSessionId());
+        String cookieProfileId = 
established.getContextResponse().getProfileId();
+        assertNotEquals(sessionOwnerId, cookieProfileId);
+
+        ContextRequest hijack = new ContextRequest();
+        hijack.setSessionId(sessionId);
+        hijack.setProfileId(cookieProfileId);
+        HttpPost hijackRequest = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(hijackRequest);
+        hijackRequest.addHeader("Cookie", established.getCookieHeaderValue());
+        hijackRequest.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(hijack), 
ContentType.APPLICATION_JSON));
+        RequestResponse hijacked = executeContextJSONRequest(hijackRequest, 
sessionId);
+
+        assertEquals("Public caller must not adopt a foreign session profile",
+                cookieProfileId, hijacked.getContextResponse().getProfileId());
+        Session reloaded = profileService.loadSession(sessionId);
+        assertEquals("Foreign session ownership must remain unchanged",
+                sessionOwnerId, reloaded.getProfileId());
+    }
+
+    @Test
+    public void testTrustedPrivateKey_mayOverrideBodyProfileId() throws 
Exception {
+        String cookieSessionId = "trusted-cookie-session-" + 
System.currentTimeMillis();
+        ContextRequest first = new ContextRequest();
+        first.setSessionId(cookieSessionId);
+        HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(establish);
+        establish.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(first), 
ContentType.APPLICATION_JSON));
+        RequestResponse established = executeContextJSONRequest(establish, 
cookieSessionId);
+        assertNotNull(established.getCookieHeaderValue());
+
+        String overrideProfileId = "admin-chosen-profile-" + 
System.currentTimeMillis();
+        Profile overrideProfile = new Profile(overrideProfileId);
+        profileService.save(overrideProfile);
+        keepTrying("Override profile not found", () -> 
profileService.load(overrideProfileId), Objects::nonNull,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        ContextRequest override = new ContextRequest();
+        override.setSessionId(cookieSessionId);
+        override.setProfileId(overrideProfileId);
+        HttpPost trusted = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(trusted, testTenant, testPrivateKeyValue);
+        trusted.addHeader("Cookie", established.getCookieHeaderValue());
+        trusted.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(override), 
ContentType.APPLICATION_JSON));
+        RequestResponse overridden = executeContextJSONRequest(trusted, 
cookieSessionId, -1, false);
+
+        assertEquals(200, overridden.getStatusCode());
+        assertEquals("Trusted private key may select body profileId over 
cookie",
+                overrideProfileId, 
overridden.getContextResponse().getProfileId());
+    }
+
+    @Test
+    public void testPublicHttp_updateProperties_cannotUpdateAnotherProfile() 
throws Exception {
+        String victimId = "update-victim-" + System.currentTimeMillis();
+        Profile victim = new Profile(victimId);
+        profileService.save(victim);
+        keepTrying("Victim profile not found", () -> 
profileService.load(victimId), Objects::nonNull,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        String sessionId = "update-attacker-session-" + 
System.currentTimeMillis();
+        ContextRequest establishReq = new ContextRequest();
+        establishReq.setSessionId(sessionId);
+        HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(establish);
+        establish.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(establishReq), 
ContentType.APPLICATION_JSON));
+        RequestResponse established = executeContextJSONRequest(establish, 
sessionId);
+
+        Event updateEvent = new Event();
+        updateEvent.setEventType("updateProperties");
+        updateEvent.setScope(TEST_SCOPE);
+        Map<String, Object> props = new HashMap<>();
+        props.put("targetId", victimId);
+        props.put("targetType", "profile");
+        Map<String, Object> toUpdate = new HashMap<>();
+        toUpdate.put("properties.firstName", "PWNED");
+        props.put("update", toUpdate);
+        updateEvent.setProperties(props);
+
+        ContextRequest attack = new ContextRequest();
+        attack.setSessionId(sessionId);
+        attack.setEvents(Collections.singletonList(updateEvent));
+        HttpPost attackRequest = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(attackRequest);
+        attackRequest.addHeader("Cookie", established.getCookieHeaderValue());
+        attackRequest.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(attack), 
ContentType.APPLICATION_JSON));
+        executeContextJSONRequest(attackRequest, sessionId);
+
+        shouldBeTrueUntilEnd("Victim profile must not be updated by public 
updateProperties",
+                () -> profileService.load(victimId),
+                p -> p.getProperty("firstName") == null,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_SHOULDBETRUE_TRIES);
+    }
+
+    @Test
+    public void testPrivateKeyHttp_updateProperties_canUpdateAnotherProfile() 
throws Exception {
+        String victimId = "trusted-update-victim-" + 
System.currentTimeMillis();
+        Profile victim = new Profile(victimId);
+        profileService.save(victim);
+        keepTrying("Victim profile not found", () -> 
profileService.load(victimId), Objects::nonNull,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        String sessionId = "trusted-update-session-" + 
System.currentTimeMillis();
+        ContextRequest establishReq = new ContextRequest();
+        establishReq.setSessionId(sessionId);
+        HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(establish);
+        establish.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(establishReq), 
ContentType.APPLICATION_JSON));
+        RequestResponse established = executeContextJSONRequest(establish, 
sessionId);
+
+        Event updateEvent = new Event();
+        updateEvent.setEventType("updateProperties");
+        updateEvent.setScope(TEST_SCOPE);
+        Map<String, Object> props = new HashMap<>();
+        props.put("targetId", victimId);
+        props.put("targetType", "profile");
+        Map<String, Object> toUpdate = new HashMap<>();
+        toUpdate.put("properties.firstName", "TRUSTED_HTTP");
+        props.put("update", toUpdate);
+        updateEvent.setProperties(props);
+
+        ContextRequest update = new ContextRequest();
+        update.setSessionId(sessionId);
+        update.setEvents(Collections.singletonList(updateEvent));
+        HttpPost trusted = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(trusted, testTenant, testPrivateKeyValue);
+        trusted.addHeader("Cookie", established.getCookieHeaderValue());
+        trusted.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(update), 
ContentType.APPLICATION_JSON));
+        executeContextJSONRequest(trusted, sessionId, -1, false);
+
+        waitForProfileProperty(victimId, "firstName", "TRUSTED_HTTP");
+    }
+
+    @Test
+    public void testPublicHttpLogin_cannotMergeIntoExistingVictimProfile() 
throws Exception {
+        ConditionType conditionType = getObjectMapper().readValue(
+                new 
File("data/tmp/testLoginEventCondition.json").toURI().toURL(), 
ConditionType.class);
+        definitionsService.setConditionType(conditionType);
+        keepTrying("loginEventCondition not registered",
+                () -> 
definitionsService.getConditionType("loginEventCondition"),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+        Rule rule = getObjectMapper().readValue(new 
File("data/tmp/testLogin.json").toURI().toURL(), Rule.class);
+        createAndWaitForRule(rule);
+
+        String victimEmail = "victim-takeover-" + System.currentTimeMillis() + 
"@example.com";
+        String victimId = "victim-merge-" + System.currentTimeMillis();
+        Profile victim = new Profile(victimId);
+        victim.setProperty("email", victimEmail);
+        victim.setSystemProperty("mergeIdentifier", victimEmail);
+        profileService.save(victim);
+        keepTrying("Victim not found", () -> profileService.load(victimId), 
Objects::nonNull,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        String sessionId = "attacker-merge-session-" + 
System.currentTimeMillis();
+        ContextRequest pageView = new ContextRequest();
+        pageView.setSessionId(sessionId);
+        HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(establish);
+        establish.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(pageView), 
ContentType.APPLICATION_JSON));
+        RequestResponse established = executeContextJSONRequest(establish, 
sessionId);
+        String attackerId = established.getContextResponse().getProfileId();
+        assertNotEquals(victimId, attackerId);
+
+        CustomItem loginTarget = new CustomItem(victimEmail, "visitor");
+        Map<String, Object> loginProps = new HashMap<>();
+        loginProps.put("email", victimEmail);
+        loginTarget.setProperties(loginProps);
+        Event login = new Event();
+        login.setEventType("login");
+        login.setScope(TEST_SCOPE);
+        login.setTarget(loginTarget);
+        login.setTimeStamp(new Date());
+
+        ContextRequest loginRequest = new ContextRequest();
+        loginRequest.setSessionId(sessionId);
+        loginRequest.setEvents(Collections.singletonList(login));
+        HttpPost attack = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(attack);
+        attack.addHeader("Cookie", established.getCookieHeaderValue());
+        attack.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(loginRequest), 
ContentType.APPLICATION_JSON));
+        RequestResponse afterLogin = executeContextJSONRequest(attack, 
sessionId);
+
+        assertEquals("Public login must not take over the victim profile",
+                attackerId, afterLogin.getContextResponse().getProfileId());
+        assertNotNull(profileService.load(victimId));
+        rulesService.removeRule("testLogin");
+    }
+
+    /**
+     * Counterpart to {@link 
#testPublicHttpLogin_cannotMergeIntoExistingVictimProfile()}: the merge
+     * must still work end to end for a trusted caller, over real HTTP through 
the auth filter and
+     * the rules engine, not just when the subject is set programmatically.
+     */
+    @Test
+    public void testPrivateKeyHttpLogin_canMergeIntoExistingProfile() throws 
Exception {
+        ConditionType conditionType = getObjectMapper().readValue(
+                new 
File("data/tmp/testLoginEventCondition.json").toURI().toURL(), 
ConditionType.class);
+        definitionsService.setConditionType(conditionType);
+        keepTrying("loginEventCondition not registered",
+                () -> 
definitionsService.getConditionType("loginEventCondition"),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+        Rule rule = getObjectMapper().readValue(new 
File("data/tmp/testLogin.json").toURI().toURL(), Rule.class);
+        createAndWaitForRule(rule);
+
+        String knownEmail = "trusted-merge-" + System.currentTimeMillis() + 
"@example.com";
+        String knownProfileId = "trusted-merge-known-" + 
System.currentTimeMillis();
+        Profile known = new Profile(knownProfileId);
+        known.setProperty("email", knownEmail);
+        known.setSystemProperty("mergeIdentifier", knownEmail);
+        profileService.save(known);
+        keepTrying("Known profile not found", () -> 
profileService.load(knownProfileId), Objects::nonNull,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        // Anonymous browsing first, exactly as a visitor would before logging 
in.
+        String sessionId = "trusted-merge-session-" + 
System.currentTimeMillis();
+        ContextRequest pageView = new ContextRequest();
+        pageView.setSessionId(sessionId);
+        HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(establish);
+        establish.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(pageView), 
ContentType.APPLICATION_JSON));
+        RequestResponse established = executeContextJSONRequest(establish, 
sessionId);
+        String anonymousId = established.getContextResponse().getProfileId();
+        assertNotEquals(knownProfileId, anonymousId);
+
+        // The login event is then emitted by a trusted server-side caller 
after authentication.
+        CustomItem loginTarget = new CustomItem(knownEmail, "visitor");
+        Map<String, Object> loginProps = new HashMap<>();
+        loginProps.put("email", knownEmail);
+        loginTarget.setProperties(loginProps);
+        Event login = new Event();
+        login.setEventType("login");
+        login.setScope(TEST_SCOPE);
+        login.setTarget(loginTarget);
+        login.setTimeStamp(new Date());
+
+        ContextRequest loginRequest = new ContextRequest();
+        loginRequest.setSessionId(sessionId);
+        loginRequest.setEvents(Collections.singletonList(login));
+        HttpPost trusted = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(trusted, testTenant, testPrivateKeyValue);
+        trusted.addHeader("Cookie", established.getCookieHeaderValue());
+        trusted.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(loginRequest), 
ContentType.APPLICATION_JSON));
+        executeContextJSONRequest(trusted, sessionId, -1, false);
+
+        keepTrying("Trusted login should merge the anonymous profile into the 
known one",
+                () -> profileService.load(anonymousId),
+                p -> p != null && knownEmail.equals(p.getProperty("email")),
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        rulesService.removeRule("testLogin");
+    }
+
+    /**
+     * invalidateSession replaces the session bound to the supplied id, so it 
must not be usable as a
+     * way around the cookie-ownership rule.
+     */
+    @Test
+    public void testPublicCaller_invalidateSessionCannotStealForeignSession() 
throws Exception {
+        String ownerId = "invalidate-owner-" + System.currentTimeMillis();
+        String foreignSessionId = "invalidate-foreign-session-" + 
System.currentTimeMillis();
+        Profile owner = new Profile(ownerId);
+        profileService.save(owner);
+        Session foreignSession = new Session(foreignSessionId, owner, new 
Date(), TEST_SCOPE);
+        profileService.saveSession(foreignSession);
+        keepTrying("Foreign session not found", () -> 
profileService.loadSession(foreignSessionId), Objects::nonNull,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        // The attacker establishes their own cookie against an unrelated 
session.
+        String ownSessionId = "invalidate-attacker-session-" + 
System.currentTimeMillis();
+        ContextRequest establishReq = new ContextRequest();
+        establishReq.setSessionId(ownSessionId);
+        HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(establish);
+        establish.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(establishReq), 
ContentType.APPLICATION_JSON));
+        RequestResponse established = executeContextJSONRequest(establish, 
ownSessionId);
+        String attackerId = established.getContextResponse().getProfileId();
+        assertNotEquals(ownerId, attackerId);
+
+        ContextRequest steal = new ContextRequest();
+        steal.setSessionId(foreignSessionId);
+        HttpPost attack = new HttpPost(getFullUrl(CONTEXT_URL) + 
"?invalidateSession=true");
+        addPublicTenantAuth(attack);
+        attack.addHeader("Cookie", established.getCookieHeaderValue());
+        attack.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(steal), 
ContentType.APPLICATION_JSON));
+        executeContextJSONRequest(attack, foreignSessionId);
+
+        shouldBeTrueUntilEnd("Foreign session ownership must survive 
invalidateSession from a public caller",
+                () -> profileService.loadSession(foreignSessionId),
+                s -> s != null && ownerId.equals(s.getProfileId()),
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_SHOULDBETRUE_TRIES);
+    }
+
+    /**
+     * A refused session is never created, so echoing the requested id back 
would tell the client its
+     * session is live and make it replay the same rejected id forever.
+     */
+    @Test
+    public void testPublicCaller_refusedSessionIsNotEchoedInResponse() throws 
Exception {
+        String ownerId = "echo-owner-" + System.currentTimeMillis();
+        String foreignSessionId = "echo-foreign-session-" + 
System.currentTimeMillis();
+        Profile owner = new Profile(ownerId);
+        profileService.save(owner);
+        Session foreignSession = new Session(foreignSessionId, owner, new 
Date(), TEST_SCOPE);
+        profileService.saveSession(foreignSession);
+        keepTrying("Foreign session not found", () -> 
profileService.loadSession(foreignSessionId), Objects::nonNull,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        String ownSessionId = "echo-attacker-session-" + 
System.currentTimeMillis();
+        ContextRequest establishReq = new ContextRequest();
+        establishReq.setSessionId(ownSessionId);
+        HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(establish);
+        establish.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(establishReq), 
ContentType.APPLICATION_JSON));
+        RequestResponse established = executeContextJSONRequest(establish, 
ownSessionId);
+
+        ContextRequest hijack = new ContextRequest();
+        hijack.setSessionId(foreignSessionId);
+        HttpPost attack = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(attack);
+        attack.addHeader("Cookie", established.getCookieHeaderValue());
+        attack.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(hijack), 
ContentType.APPLICATION_JSON));
+        RequestResponse refused = executeContextJSONRequest(attack, 
foreignSessionId);
+
+        assertEquals(200, refused.getStatusCode());
+        assertNull("A refused session id must not be echoed back to the 
client",
+                refused.getContextResponse().getSessionId());
+    }
+
     @Test
     public void testCreateEventWithProfileId_Success() throws Exception {
         //Arrange
@@ -432,16 +959,36 @@ public class ContextServletIT extends BaseIT {
         contextRequest.setProfileId(TEST_PROFILE_ID);
         contextRequest.setEvents(Arrays.asList(event));
 
-        //Act
+        //Act — body profileId binding for a chosen id requires a trusted 
caller
         HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
-        addPublicTenantAuth(request);
+        addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
         request.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
-        executeContextJSONRequest(request);
+        executeContextJSONRequest(request, null, -1, false);
 
         keepTrying("Profile " + TEST_PROFILE_ID + " not found in the required 
time", () -> profileService.load(TEST_PROFILE_ID),
                 Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
     }
 
+    @Test
+    public void testPublicCaller_bodyProfileIdWithoutCookie_rejected() throws 
Exception {
+        String victimId = "body-only-victim-" + System.currentTimeMillis();
+        Profile victim = new Profile(victimId);
+        victim.setProperty("email", "[email protected]");
+        profileService.save(victim);
+        keepTrying("Victim not found", () -> profileService.load(victimId), 
Objects::nonNull,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        ContextRequest attack = new ContextRequest();
+        attack.setProfileId(victimId);
+        attack.setRequiredProfileProperties(Collections.singletonList("*"));
+        HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(request);
+        request.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(attack), 
ContentType.APPLICATION_JSON));
+        // Body profileId is ignored for public callers; with no 
cookie/session → 400, not victim data
+        RequestResponse response = executeContextJSONRequest(request, null, 
400, false);
+        assertEquals(400, response.getStatusCode());
+    }
+
     @Test
     public void testCreateEventWithPropertiesValidation_Success() throws 
Exception {
         //Arrange
@@ -489,11 +1036,11 @@ public class ContextServletIT extends BaseIT {
         contextRequest.setProfileId(profileId);
         contextRequest.setEvents(Arrays.asList(event));
 
-        //Act
+        //Act — body profileId requires trusted auth; withAuth=false so the 
public key is not also attached
         HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
         addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
         request.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
-        executeContextJSONRequest(request);
+        executeContextJSONRequest(request, null, -1, false);
 
         //Assert
         shouldBeTrueUntilEnd("Event should be null", () -> 
eventService.getEvent(eventId), Objects::isNull, DEFAULT_TRYING_TIMEOUT,
@@ -516,11 +1063,11 @@ public class ContextServletIT extends BaseIT {
         contextRequest.setProfileId(profileId);
         contextRequest.setEvents(Arrays.asList(event));
 
-        //Act
+        //Act — body profileId requires trusted auth; withAuth=false so the 
public key is not also attached
         HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
         addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
         request.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
-        executeContextJSONRequest(request);
+        executeContextJSONRequest(request, null, -1, false);
 
         //Assert
         shouldBeTrueUntilEnd("Event should be null", () -> 
eventService.getEvent(eventId), Objects::isNull, DEFAULT_TRYING_TIMEOUT,
@@ -581,9 +1128,11 @@ public class ContextServletIT extends BaseIT {
     @Test
     public void testScorePersonalizationStrategy_Interests() throws Exception {
         // Test request before adding interests to current profile.
+        // JSON binds profileId in the body — requires trusted caller (public 
ignores body profileId).
         HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
         request.setEntity(new 
StringEntity(getValidatedBundleJSON("personalization-score-interests.json", 
null), ContentType.APPLICATION_JSON));
-        TestUtils.RequestResponse response = 
executeContextJSONRequest(request);
+        TestUtils.RequestResponse response = 
executeContextJSONRequest(request, null, -1, false);
         ContextResponse contextResponse = response.getContextResponse();
         List<String> variants = 
contextResponse.getPersonalizations().get("perso-by-interest");
         assertEquals("Invalid response code", 200, response.getStatusCode());
@@ -600,8 +1149,9 @@ public class ContextServletIT extends BaseIT {
 
         // check results of the perso now
         request = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
         request.setEntity(new 
StringEntity(getValidatedBundleJSON("personalization-score-interests.json", 
null), ContentType.APPLICATION_JSON));
-        response = executeContextJSONRequest(request);
+        response = executeContextJSONRequest(request, null, -1, false);
         contextResponse = response.getContextResponse();
         variants = 
contextResponse.getPersonalizations().get("perso-by-interest");
         assertEquals("Invalid response code", 200, response.getStatusCode());
@@ -637,8 +1187,9 @@ public class ContextServletIT extends BaseIT {
 
         // re test now that profiles has interests
         request = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
         request.setEntity(new 
StringEntity(getValidatedBundleJSON("personalization-score-interests.json", 
null), ContentType.APPLICATION_JSON));
-        response = executeContextJSONRequest(request);
+        response = executeContextJSONRequest(request, null, -1, false);
         contextResponse = response.getContextResponse();
         variants = 
contextResponse.getPersonalizations().get("perso-by-interest");
         assertEquals("Invalid response code", 200, response.getStatusCode());
@@ -675,8 +1226,9 @@ public class ContextServletIT extends BaseIT {
         // first let's make sure everything works without the requireScoring 
parameter
         parameters = new HashMap<>();
         HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
         request.setEntity(new 
StringEntity(getValidatedBundleJSON("withoutRequireScores.json", parameters), 
ContentType.APPLICATION_JSON));
-        TestUtils.RequestResponse response = 
executeContextJSONRequest(request);
+        TestUtils.RequestResponse response = 
executeContextJSONRequest(request, null, -1, false);
         assertEquals("Invalid response code", 200, response.getStatusCode());
 
         assertNotNull("Context response should not be null", 
response.getContextResponse());
@@ -686,8 +1238,9 @@ public class ContextServletIT extends BaseIT {
         // now let's test adding it.
         parameters = new HashMap<>();
         request = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
         request.setEntity(new 
StringEntity(getValidatedBundleJSON("withRequireScores.json", parameters), 
ContentType.APPLICATION_JSON));
-        response = executeContextJSONRequest(request);
+        response = executeContextJSONRequest(request, null, -1, false);
         assertEquals("Invalid response code", 200, response.getStatusCode());
 
         assertNotNull("Context response should not be null", 
response.getContextResponse());
@@ -874,7 +1427,8 @@ public class ContextServletIT extends BaseIT {
 
         // Test with JAAS authentication (should succeed)
         BasicCredentialsProvider credsProvider = new 
BasicCredentialsProvider();
-        credsProvider.setCredentials(AuthScope.ANY, new 
UsernamePasswordCredentials("karaf", "karaf"));
+        credsProvider.setCredentials(AuthScope.ANY,
+                new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, 
BASIC_AUTH_PASSWORD));
 
         RequestConfig requestConfig = RequestConfig.custom()
                 .setAuthenticationEnabled(true)
@@ -922,13 +1476,15 @@ public class ContextServletIT extends BaseIT {
         // Test normal personalization should not have control group info in 
response
 
         HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+        // JSON fixtures bind profileId in the body — requires trusted caller 
(public ignores body profileId).
+        addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
         if (controlGroupConfig != null) {
             request.setEntity(new 
StringEntity(getValidatedBundleJSON("personalization-control-group.json", 
controlGroupConfig), ContentType.APPLICATION_JSON));
         } else {
             request.setEntity(new 
StringEntity(getValidatedBundleJSON("personalization-no-control-group.json", 
null), ContentType.APPLICATION_JSON));
         }
 
-        TestUtils.RequestResponse response = 
executeContextJSONRequest(request);
+        TestUtils.RequestResponse response = 
executeContextJSONRequest(request, null, -1, false);
         ContextResponse contextResponse = response.getContextResponse();
 
         // Check variants
@@ -978,23 +1534,31 @@ public class ContextServletIT extends BaseIT {
         contextRequest.setProfileId(profile.getItemId());
         contextRequest.setSessionId(sessionId);
         HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+        // Body profileId requires trusted caller (public ignores body 
profileId).
+        addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
         request.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
-        assertEquals(executeContextJSONRequest(request, 
sessionId).getContextResponse().getProfileProperties().get("customProperty"), 
("concealedValue"));
+        assertEquals(executeContextJSONRequest(request, sessionId, -1, 
false).getContextResponse().getProfileProperties().get("customProperty"), 
("concealedValue"));
         // set the property as concealed
         customPropertyType.getMetadata().getSystemTags().add("concealed");
         profileService.deletePropertyType(customPropertyType.getItemId());
         profileService.setPropertyType(customPropertyType);
         // Not in all properties
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
         request.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
-        assertNull(executeContextJSONRequest(request, 
sessionId).getContextResponse().getProfileProperties().get("customProperty"));
+        assertNull(executeContextJSONRequest(request, sessionId, -1, 
false).getContextResponse().getProfileProperties().get("customProperty"));
         // Got it explicitly
         
contextRequest.setRequiredProfileProperties(Arrays.asList("customProperty"));
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
         request.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
-        assertEquals(executeContextJSONRequest(request, 
sessionId).getContextResponse().getProfileProperties().get("customProperty"), 
("concealedValue"));
+        assertEquals(executeContextJSONRequest(request, sessionId, -1, 
false).getContextResponse().getProfileProperties().get("customProperty"), 
("concealedValue"));
         // Got it with all
         contextRequest.setRequiredProfileProperties(Arrays.asList("*", 
"customProperty"));
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
         request.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
-        assertEquals(executeContextJSONRequest(request, 
sessionId).getContextResponse().getProfileProperties().get("customProperty"), 
("concealedValue"));
+        assertEquals(executeContextJSONRequest(request, sessionId, -1, 
false).getContextResponse().getProfileProperties().get("customProperty"), 
("concealedValue"));
 
         // remove the concealed tag on the property type
         customPropertyType.getMetadata().getSystemTags().remove("concealed");
@@ -1003,8 +1567,10 @@ public class ContextServletIT extends BaseIT {
 
         // Got it from all properties
         contextRequest.setRequiredProfileProperties(Arrays.asList("*"));
+        request = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
         request.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
-        assertEquals(executeContextJSONRequest(request, 
sessionId).getContextResponse().getProfileProperties().get("customProperty"), 
("concealedValue"));
+        assertEquals(executeContextJSONRequest(request, sessionId, -1, 
false).getContextResponse().getProfileProperties().get("customProperty"), 
("concealedValue"));
     }
 
     @Test
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java 
b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
index 6cc692a0d..23c7b219b 100644
--- a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
@@ -57,11 +57,14 @@ import org.junit.runners.Suite.SuiteClasses;
         ModifyConsentIT.class,
         PatchIT.class,
         ContextServletIT.class,
+        ContextEndpointBaselineIT.class,
         SecurityIT.class,
         RuleServiceIT.class,
         PrivacyServiceIT.class,
         GroovyActionsServiceIT.class,
+        RestEndpointRoleSecurityIT.class,
         GraphQLEventIT.class,
+        GraphQLServletSecurityIT.class,
         GraphQLListIT.class,
         GraphQLProfileIT.class,
         GraphQLProfilePropertiesIT.class,
diff --git a/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java 
b/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java
index 20011d21c..179984d61 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java
@@ -176,6 +176,71 @@ public class ProfileMergeIT extends BaseIT {
      * - a new one, if it's the first time we encounter his own mergeIdentifier
      * - a previous one, if we already have a profile in DB with the same 
mergeIdentifier. (TESTED in this scenario)
      */
+    /**
+     * Public / untrusted callers must not merge into an existing victim 
profile (identity takeover).
+     * Suite {@code @Before} installs a tenant-admin subject; this test 
temporarily downgrades it.
+     */
+    @Test
+    public void testUntrustedCaller_cannotMergeIntoExistingVictimProfile() 
throws InterruptedException {
+        createAndWaitForRule(createMergeOnPropertyRule(false, "email"));
+
+        Profile victim = new Profile("victimProfileID");
+        victim.setProperty("email", "[email protected]");
+        victim.setSystemProperty("mergeIdentifier", "[email protected]");
+        profileService.save(victim);
+
+        keepTrying("Victim profile not found", () -> 
profileService.load("victimProfileID"),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        Profile attacker = new Profile("attackerProfileID");
+        attacker.setProperty("email", "[email protected]");
+        Session session = new Session("untrustedMergeSession", attacker, new 
Date(), null);
+        Event event = new Event(TEST_EVENT_TYPE, session, attacker, null, 
null, attacker, new Date());
+
+        javax.security.auth.Subject previous = 
securityService.getCurrentSubject();
+        try {
+            
securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, 
false));
+            eventService.send(event);
+        } finally {
+            securityService.setCurrentSubject(previous);
+        }
+
+        Assert.assertEquals("attackerProfileID", 
event.getProfile().getItemId());
+        Assert.assertEquals("attackerProfileID", 
event.getSession().getProfile().getItemId());
+        Assert.assertNotNull(profileService.load("victimProfileID"));
+    }
+
+    @Test
+    public void testTrustedPrivateKeySubject_canMergeIntoExistingProfile() 
throws InterruptedException {
+        createAndWaitForRule(createMergeOnPropertyRule(false, "email"));
+
+        Profile victim = new Profile("trustedVictimProfileID");
+        victim.setProperty("email", "[email protected]");
+        victim.setSystemProperty("mergeIdentifier", 
"[email protected]");
+        victim.setProperty("firstVisit", new Date(0));
+        profileService.save(victim);
+
+        keepTrying("Victim profile not found", () -> 
profileService.load("trustedVictimProfileID"),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        Profile caller = new Profile("trustedCallerProfileID");
+        caller.setProperty("email", "[email protected]");
+        caller.setProperty("firstVisit", new Date());
+        Session session = new Session("trustedMergeSession", caller, new 
Date(), null);
+        Event event = new Event(TEST_EVENT_TYPE, session, caller, null, null, 
caller, new Date());
+
+        javax.security.auth.Subject previous = 
securityService.getCurrentSubject();
+        try {
+            
securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, 
true));
+            eventService.send(event);
+        } finally {
+            securityService.setCurrentSubject(previous);
+        }
+
+        Assert.assertEquals("trustedVictimProfileID", 
event.getProfile().getItemId());
+        Assert.assertEquals("trustedVictimProfileID", 
event.getSession().getProfile().getItemId());
+    }
+
     @Test
     public void 
testProfileMergeOnPropertyAction_sessionReassigned_existingProfile() throws 
InterruptedException {
         // create rule
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java 
b/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
index ad2206a6b..64d65ea4f 100644
--- a/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
@@ -20,6 +20,7 @@ package org.apache.unomi.itests;
 import org.apache.unomi.api.Event;
 import org.apache.unomi.api.Profile;
 import org.apache.unomi.api.rules.Rule;
+import org.apache.unomi.api.services.EventService;
 import org.apache.unomi.plugins.baseplugin.actions.UpdatePropertiesAction;
 import org.junit.Assert;
 import org.junit.Before;
@@ -118,6 +119,81 @@ public class PropertiesUpdateActionIT extends BaseIT {
         waitForProfileProperty(PROFILE_TEST_ID, "firstName", "UPDATED FIRST 
NAME");
     }
 
+    @Test
+    public void testUntrustedCaller_cannotUpdateAnotherProfile() throws 
InterruptedException {
+        Profile caller = profileService.load(PROFILE_TARGET_TEST_ID);
+        Profile other = profileService.load(PROFILE_TEST_ID);
+        Assert.assertNull(other.getProperty("firstName"));
+
+        Event updateProperties = new Event("updateProperties", null, caller, 
null, null, null, new Date());
+        updateProperties.setPersistent(false);
+        Map<String, Object> propertyToUpdate = new HashMap<>();
+        propertyToUpdate.put("properties.firstName", "SHOULD_NOT_APPLY");
+        updateProperties.setProperty(UpdatePropertiesAction.PROPS_TO_UPDATE, 
propertyToUpdate);
+        updateProperties.setProperty(UpdatePropertiesAction.TARGET_ID_KEY, 
PROFILE_TEST_ID);
+        updateProperties.setProperty(UpdatePropertiesAction.TARGET_TYPE_KEY, 
"profile");
+
+        javax.security.auth.Subject previous = 
securityService.getCurrentSubject();
+        try {
+            
securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, 
false));
+            int changes = eventService.send(updateProperties);
+            Assert.assertEquals(EventService.NO_CHANGE, changes);
+        } finally {
+            securityService.setCurrentSubject(previous);
+        }
+
+        shouldBeTrueUntilEnd("Other profile must remain unchanged",
+                () -> profileService.load(PROFILE_TEST_ID),
+                p -> p.getProperty("firstName") == null,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_SHOULDBETRUE_TRIES);
+    }
+
+    @Test
+    public void testUntrustedCaller_cannotWriteSystemProperties() throws 
InterruptedException {
+        Profile caller = profileService.load(PROFILE_TEST_ID);
+        Assert.assertNull(caller.getSystemProperties().get("mergeIdentifier"));
+
+        Event updateProperties = new Event("updateProperties", null, caller, 
null, null, null, new Date());
+        updateProperties.setPersistent(false);
+        Map<String, Object> propertyToUpdate = new HashMap<>();
+        propertyToUpdate.put("systemProperties.mergeIdentifier", "stolen");
+        updateProperties.setProperty(UpdatePropertiesAction.PROPS_TO_UPDATE, 
propertyToUpdate);
+
+        javax.security.auth.Subject previous = 
securityService.getCurrentSubject();
+        try {
+            
securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, 
false));
+            eventService.send(updateProperties);
+        } finally {
+            securityService.setCurrentSubject(previous);
+        }
+
+        
Assert.assertNull(profileService.load(PROFILE_TEST_ID).getSystemProperties().get("mergeIdentifier"));
+    }
+
+    @Test
+    public void testTrustedPrivateKeySubject_canUpdateAnotherProfile() throws 
InterruptedException {
+        Profile caller = profileService.load(PROFILE_TARGET_TEST_ID);
+        
Assert.assertNull(profileService.load(PROFILE_TEST_ID).getProperty("firstName"));
+
+        Event updateProperties = new Event("updateProperties", null, caller, 
null, null, null, new Date());
+        updateProperties.setPersistent(false);
+        Map<String, Object> propertyToUpdate = new HashMap<>();
+        propertyToUpdate.put("properties.firstName", "TRUSTED UPDATE");
+        updateProperties.setProperty(UpdatePropertiesAction.PROPS_TO_UPDATE, 
propertyToUpdate);
+        updateProperties.setProperty(UpdatePropertiesAction.TARGET_ID_KEY, 
PROFILE_TEST_ID);
+        updateProperties.setProperty(UpdatePropertiesAction.TARGET_TYPE_KEY, 
"profile");
+
+        javax.security.auth.Subject previous = 
securityService.getCurrentSubject();
+        try {
+            
securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, 
true));
+            eventService.send(updateProperties);
+        } finally {
+            securityService.setCurrentSubject(previous);
+        }
+
+        waitForProfileProperty(PROFILE_TEST_ID, "firstName", "TRUSTED UPDATE");
+    }
+
     @Test
     public void testUpdateProperties_CurrentProfile_PROPS_TO_ADD() throws 
InterruptedException {
         Profile profile = profileService.load(PROFILE_TEST_ID);
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/RestEndpointRoleSecurityIT.java 
b/itests/src/test/java/org/apache/unomi/itests/RestEndpointRoleSecurityIT.java
new file mode 100644
index 000000000..67329b98e
--- /dev/null
+++ 
b/itests/src/test/java/org/apache/unomi/itests/RestEndpointRoleSecurityIT.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.itests;
+
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpDelete;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ByteArrayEntity;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
+import org.ops4j.pax.exam.spi.reactors.PerSuite;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * HTTP-level checks that system-admin-only REST endpoints reject tenant 
private keys
+ * (including multipart upload / oneshot paths).
+ */
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class RestEndpointRoleSecurityIT extends BaseIT {
+
+    @Test
+    public void importConfiguration_requiresSystemAdministrator() throws 
Exception {
+        try (CloseableHttpResponse tenantAdmin = executeHttpRequest(
+                new HttpGet(getFullUrl("/cxs/importConfiguration")), 
AuthType.PRIVATE_KEY)) {
+            Assert.assertEquals("Tenant private key must not list import 
configurations",
+                    403, tenantAdmin.getStatusLine().getStatusCode());
+        }
+
+        try (CloseableHttpResponse jaasAdmin = executeHttpRequest(
+                new HttpGet(getFullUrl("/cxs/importConfiguration")), 
AuthType.JAAS_ADMIN)) {
+            Assert.assertEquals("JAAS admin should list import configurations",
+                    200, jaasAdmin.getStatusLine().getStatusCode());
+        }
+    }
+
+    @Test
+    public void exportConfiguration_requiresSystemAdministrator() throws 
Exception {
+        try (CloseableHttpResponse tenantAdmin = executeHttpRequest(
+                new HttpGet(getFullUrl("/cxs/exportConfiguration")), 
AuthType.PRIVATE_KEY)) {
+            Assert.assertEquals(403, 
tenantAdmin.getStatusLine().getStatusCode());
+        }
+
+        try (CloseableHttpResponse jaasAdmin = executeHttpRequest(
+                new HttpGet(getFullUrl("/cxs/exportConfiguration")), 
AuthType.JAAS_ADMIN)) {
+            Assert.assertEquals(200, 
jaasAdmin.getStatusLine().getStatusCode());
+        }
+    }
+
+    @Test
+    public void 
importConfiguration_oneshotUpload_requiresSystemAdministrator() throws 
Exception {
+        HttpPost oneshot = 
multipartPost(getFullUrl("/cxs/importConfiguration/oneshot"),
+                "----UnomiImportBoundary",
+                part("importConfigId", "text/plain", 
"rest-role-security-oneshot"),
+                filePart("file", "probe.csv", "text/csv", "col1\nvalue1\n"));
+
+        try (CloseableHttpResponse tenantAdmin = executeHttpRequest(oneshot, 
AuthType.PRIVATE_KEY)) {
+            Assert.assertEquals(403, 
tenantAdmin.getStatusLine().getStatusCode());
+        }
+
+        HttpPost oneshotJaas = 
multipartPost(getFullUrl("/cxs/importConfiguration/oneshot"),
+                "----UnomiImportBoundaryJaas",
+                part("importConfigId", "text/plain", 
"rest-role-security-oneshot"),
+                filePart("file", "probe.csv", "text/csv", "col1\nvalue1\n"));
+        try (CloseableHttpResponse jaasAdmin = executeHttpRequest(oneshotJaas, 
AuthType.JAAS_ADMIN)) {
+            // Role gate is what we care about; missing config may yield 500 
after auth succeeds.
+            Assert.assertNotEquals(403, 
jaasAdmin.getStatusLine().getStatusCode());
+            Assert.assertNotEquals(401, 
jaasAdmin.getStatusLine().getStatusCode());
+        }
+    }
+
+    @Test
+    public void exportConfiguration_oneshot_requiresSystemAdministrator() 
throws Exception {
+        String body = 
"{\"itemId\":\"rest-role-security-export\",\"itemType\":\"exportConfig\"}";
+        HttpPost oneshot = new 
HttpPost(getFullUrl("/cxs/exportConfiguration/oneshot"));
+        oneshot.setEntity(new StringEntity(body, 
ContentType.APPLICATION_JSON));
+
+        try (CloseableHttpResponse tenantAdmin = executeHttpRequest(oneshot, 
AuthType.PRIVATE_KEY)) {
+            Assert.assertEquals(403, 
tenantAdmin.getStatusLine().getStatusCode());
+        }
+
+        HttpPost oneshotJaas = new 
HttpPost(getFullUrl("/cxs/exportConfiguration/oneshot"));
+        oneshotJaas.setEntity(new StringEntity(body, 
ContentType.APPLICATION_JSON));
+        try (CloseableHttpResponse jaasAdmin = executeHttpRequest(oneshotJaas, 
AuthType.JAAS_ADMIN)) {
+            Assert.assertNotEquals(403, 
jaasAdmin.getStatusLine().getStatusCode());
+            Assert.assertNotEquals(401, 
jaasAdmin.getStatusLine().getStatusCode());
+        }
+    }
+
+    @Test
+    public void groovyActions_requiresSystemAdministrator() throws Exception {
+        String path = 
getFullUrl("/cxs/groovyActions/rest-role-security-it-missing-action");
+
+        try (CloseableHttpResponse tenantAdmin = executeHttpRequest(new 
HttpDelete(path), AuthType.PRIVATE_KEY)) {
+            Assert.assertEquals("Tenant private key must not delete groovy 
actions",
+                    403, tenantAdmin.getStatusLine().getStatusCode());
+        }
+
+        try (CloseableHttpResponse jaasAdmin = executeHttpRequest(new 
HttpDelete(path), AuthType.JAAS_ADMIN)) {
+            int status = jaasAdmin.getStatusLine().getStatusCode();
+            Assert.assertTrue("JAAS admin delete should be allowed (got " + 
status + ")",
+                    status == 200 || status == 204 || status == 404);
+        }
+    }
+
+    @Test
+    public void groovyActions_upload_requiresSystemAdministrator() throws 
Exception {
+        String script = "// RestEndpointRoleSecurityIT probe\nvoid execute() 
{}\n";
+        HttpPost upload = multipartPost(getFullUrl("/cxs/groovyActions/"),
+                "----UnomiGroovyBoundary",
+                filePart("file", "RestRoleSecurityITProbe.groovy", 
"text/plain", script));
+
+        try (CloseableHttpResponse tenantAdmin = executeHttpRequest(upload, 
AuthType.PRIVATE_KEY)) {
+            Assert.assertEquals("Tenant private key must not upload groovy 
actions",
+                    403, tenantAdmin.getStatusLine().getStatusCode());
+        }
+
+        HttpPost uploadJaas = multipartPost(getFullUrl("/cxs/groovyActions/"),
+                "----UnomiGroovyBoundaryJaas",
+                filePart("file", "RestRoleSecurityITProbe.groovy", 
"text/plain", script));
+        try (CloseableHttpResponse jaasAdmin = executeHttpRequest(uploadJaas, 
AuthType.JAAS_ADMIN)) {
+            Assert.assertEquals("JAAS admin should be allowed to upload groovy 
actions",
+                    200, jaasAdmin.getStatusLine().getStatusCode());
+        }
+
+        try (CloseableHttpResponse cleanup = executeHttpRequest(
+                new 
HttpDelete(getFullUrl("/cxs/groovyActions/RestRoleSecurityITProbe")), 
AuthType.JAAS_ADMIN)) {
+            int status = cleanup.getStatusLine().getStatusCode();
+            Assert.assertTrue(status == 200 || status == 204 || status == 404);
+        }
+    }
+
+    private static HttpPost multipartPost(String url, String boundary, 
String... parts) {
+        HttpPost post = new HttpPost(url);
+        StringBuilder body = new StringBuilder();
+        for (String part : parts) {
+            body.append("--").append(boundary).append("\r\n").append(part);
+        }
+        body.append("--").append(boundary).append("--\r\n");
+        post.setHeader("Content-Type", "multipart/form-data; boundary=" + 
boundary);
+        post.setEntity(new 
ByteArrayEntity(body.toString().getBytes(StandardCharsets.UTF_8)));
+        return post;
+    }
+
+    private static String part(String name, String contentType, String value) {
+        return "Content-Disposition: form-data; name=\"" + name + "\"\r\n"
+                + "Content-Type: " + contentType + "\r\n\r\n"
+                + value + "\r\n";
+    }
+
+    private static String filePart(String name, String filename, String 
contentType, String value) {
+        return "Content-Disposition: form-data; name=\"" + name + "\"; 
filename=\"" + filename + "\"\r\n"
+                + "Content-Type: " + contentType + "\r\n\r\n"
+                + value + "\r\n";
+    }
+}

Reply via email to