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


The following commit(s) were added to 
refs/heads/UNOMI-972-credentials-profile-binding-privileged-rest by this push:
     new 8a40ed2b9 UNOMI-972: fix stale public-caller docs and cover the 
V2-compat blank-password guard
8a40ed2b9 is described below

commit 8a40ed2b976b6f5907e651329283949e396156e5
Author: Serge Huber <[email protected]>
AuthorDate: Sat Aug 8 16:08:21 2026 +0200

    UNOMI-972: fix stale public-caller docs and cover the V2-compat 
blank-password guard
    
    Two review follow-ups on the client-facing hardening.
    
    Docs: the birthday-personalization walkthrough in request-examples.adoc and 
the
    custom-event examples in multitenancy.adoc still called /cxs/context.json 
with a
    public API key and a body "profileId". Public callers now have that field
    ignored, so those examples would silently resolve to a fresh anonymous 
profile
    and no longer demonstrate what they claim. They now carry the profile in the
    context-profile-id cookie, with a note pointing at the 3.0-to-3.1 
client-facing
    hardening section. Swept the rest of the manual for the same pattern; the
    remaining profileId occurrences are response bodies or already cookie-based.
    
    Tests: rejectBlankBasicAuthPassword has three call sites, but the V2
    compatibility private-endpoint one was unreachable from the suite, since 
every
    test left isV2CompatibilityModeEnabled() at the unstubbed Mockito false.
    Asserting on the response status alone could not have covered it either: 
every
    refusal path in the filter answers 401, so with the guard removed JAAS 
throws,
    is caught, and the filter still answers 401. AuthenticationFilter therefore 
gets
    a package-private constructor overload taking the JAAS filter, letting the 
tests
    assert the credential never reached JAAS. The public constructor and the 
only
    production call site are unchanged.
    
    Verified by mutation: deleting either guard fails exactly one test, in both
    cases on the never().filter() assertion that the status-only check would 
have
    missed.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
 manual/src/main/asciidoc/multitenancy.adoc         | 12 +++-
 manual/src/main/asciidoc/request-examples.adoc     | 14 ++++-
 .../rest/authentication/AuthenticationFilter.java  | 28 +++++++--
 .../AuthenticationFilterBlankPasswordTest.java     | 71 +++++++++++++++++++++-
 4 files changed, 113 insertions(+), 12 deletions(-)

diff --git a/manual/src/main/asciidoc/multitenancy.adoc 
b/manual/src/main/asciidoc/multitenancy.adoc
index 665390c3e..904ca0399 100644
--- a/manual/src/main/asciidoc/multitenancy.adoc
+++ b/manual/src/main/asciidoc/multitenancy.adoc
@@ -683,14 +683,22 @@ curl -X POST 
http://localhost:8181/cxs/jsonSchema/validateEvent \
 
 Once the event type is defined, you can send events:
 
+[NOTE]
+====
+`/cxs/context.json` is a public endpoint, so the profile is carried by the 
`context-profile-id`
+cookie: since Unomi 3.1 a public caller's body `profileId` is ignored. Only a 
trusted caller
+(tenant private key or system administrator) may bind a profile explicitly 
through the body. See
+<<_client_facing_hardening_3_1,client-facing hardening>>.
+====
+
 [source,bash]
 ----
 curl -X POST http://localhost:8181/cxs/context.json \
   -H "X-Unomi-Api-Key: <PUBLIC_KEY>" \
   -H "Content-Type: application/json" \
+  -b "context-profile-id=profile-456" \
   -d '{
     "sessionId": "session-123",
-    "profileId": "profile-456",
     "source": {
       "itemId": "checkout-page",
       "itemType": "page",
@@ -768,9 +776,9 @@ To test that everything works:
 curl -X POST http://localhost:8181/cxs/context.json \
   -H "X-Unomi-Api-Key: <PUBLIC_KEY>" \
   -H "Content-Type: application/json" \
+  -b "context-profile-id=profile-456" \
   -d '{
     "sessionId": "session-123",
-    "profileId": "profile-456",
     "source": {
       "itemId": "checkout-page",
       "itemType": "page",
diff --git a/manual/src/main/asciidoc/request-examples.adoc 
b/manual/src/main/asciidoc/request-examples.adoc
index b1396c4b3..26edb46e6 100644
--- a/manual/src/main/asciidoc/request-examples.adoc
+++ b/manual/src/main/asciidoc/request-examples.adoc
@@ -549,14 +549,22 @@ The format is always `MM-DD` where:
 
 You can also update the personalization example to use the birthday property:
 
+[NOTE]
+====
+The profile is selected with the `context-profile-id` cookie, not with a 
`profileId` in the request
+body. `/cxs/context.json` is a public endpoint, and since Unomi 3.1 a public 
caller's body `profileId`
+is ignored — the cookie is the only profile identity bearer. See
+<<_client_facing_hardening_3_1,Client-facing hardening>> in the 3.0 to 3.1 
migration guide.
+====
+
 [source]
 ----
 curl -X POST http://localhost:8181/cxs/context.json \
 -H "Content-Type: application/json" \
 -H "X-Unomi-Api-Key: YOUR_PUBLIC_API_KEY" \
+-b "context-profile-id=profile-1" \
 -d '{
     "sessionId": "birthday-session",
-    "profileId": "profile-1",
     "source": {
         "itemId": "homepage",
         "itemType": "page",
@@ -702,9 +710,9 @@ For the birthday profile (should show birthday message):
 curl -X POST http://localhost:8181/cxs/context.json \
 -H "Content-Type: application/json" \
 -H "X-Unomi-Api-Key: YOUR_PUBLIC_API_KEY" \
+-b "context-profile-id=profile-1" \
 -d '{
     "sessionId": "birthday-session",
-    "profileId": "profile-1",
     "source": {
         "itemId": "homepage",
         "itemType": "page",
@@ -748,9 +756,9 @@ For the non-birthday profile (should show welcome message):
 curl -X POST http://localhost:8181/cxs/context.json \
 -H "Content-Type: application/json" \
 -H "X-Unomi-Api-Key: YOUR_PUBLIC_API_KEY" \
+-b "context-profile-id=profile-2" \
 -d '{
     "sessionId": "regular-session",
-    "profileId": "profile-2",
     "source": {
         "itemId": "homepage",
         "itemType": "page",
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 effe4b850..0e990ffaa 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
@@ -101,17 +101,33 @@ public class AuthenticationFilter implements 
ContainerRequestFilter {
                               TenantService tenantService,
                               SecurityService securityService,
                               ExecutionContextManager executionContextManager) 
{
+        this(restAuthenticationConfig, tenantService, securityService, 
executionContextManager, buildJaasFilter());
+    }
+
+    /**
+     * Test seam: lets a test supply a stub JAAS filter so it can assert that 
a credential was
+     * refused <em>before</em> it reached JAAS. Asserting on the response 
status alone proves
+     * nothing here — every refusal path in this class ends in the same 401.
+     */
+    AuthenticationFilter(RestAuthenticationConfig restAuthenticationConfig,
+                              TenantService tenantService,
+                              SecurityService securityService,
+                              ExecutionContextManager executionContextManager,
+                              JAASAuthenticationFilter 
jaasAuthenticationFilter) {
         this.restAuthenticationConfig = restAuthenticationConfig;
         this.tenantService = tenantService;
         this.securityService = securityService;
         this.executionContextManager = executionContextManager;
+        this.jaasAuthenticationFilter = jaasAuthenticationFilter;
+    }
 
-        // Build wrapped jaas filter
-        jaasAuthenticationFilter = new JAASAuthenticationFilter();
-        jaasAuthenticationFilter.setRoleClassifier(ROLE_CLASSIFIER);
-        jaasAuthenticationFilter.setRoleClassifierType(ROLE_CLASSIFIER_TYPE);
-        jaasAuthenticationFilter.setContextName(CONTEXT_NAME);
-        jaasAuthenticationFilter.setRealmName(REALM_NAME);
+    private static JAASAuthenticationFilter buildJaasFilter() {
+        JAASAuthenticationFilter jaasFilter = new JAASAuthenticationFilter();
+        jaasFilter.setRoleClassifier(ROLE_CLASSIFIER);
+        jaasFilter.setRoleClassifierType(ROLE_CLASSIFIER_TYPE);
+        jaasFilter.setContextName(CONTEXT_NAME);
+        jaasFilter.setRealmName(REALM_NAME);
+        return jaasFilter;
     }
 
     @Override
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java
 
b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java
index ef761f84a..a219885c3 100644
--- 
a/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java
+++ 
b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.unomi.rest.authentication;
 
+import org.apache.cxf.jaxrs.security.JAASAuthenticationFilter;
 import org.apache.unomi.api.security.SecurityService;
 import org.apache.unomi.api.services.ExecutionContextManager;
 import org.apache.unomi.api.tenants.ApiKey;
@@ -58,17 +59,23 @@ class AuthenticationFilterBlankPasswordTest {
 
     private RestAuthenticationConfig restAuthenticationConfig;
     private TenantService tenantService;
+    private JAASAuthenticationFilter jaasAuthenticationFilter;
     private AuthenticationFilter filter;
 
     @BeforeEach
     void setUp() {
         restAuthenticationConfig = mock(RestAuthenticationConfig.class);
         tenantService = mock(TenantService.class);
+        // Stubbed so the tests below can assert the credential never reached 
JAAS. Every refusal
+        // path in the filter answers 401, so the status alone cannot tell 
"refused for a blank
+        // password" apart from "JAAS rejected it" — only this can.
+        jaasAuthenticationFilter = mock(JAASAuthenticationFilter.class);
         filter = new AuthenticationFilter(
                 restAuthenticationConfig,
                 tenantService,
                 mock(SecurityService.class),
-                mock(ExecutionContextManager.class));
+                mock(ExecutionContextManager.class),
+                jaasAuthenticationFilter);
     }
 
     @Test
@@ -120,9 +127,71 @@ class AuthenticationFilterBlankPasswordTest {
 
         filter.filter(requestContext);
 
+        assertUnauthorizedWithoutReachingJaas(requestContext);
+    }
+
+    /** Control: a non-blank credential on the same path must still be handed 
to JAAS to judge. */
+    @Test
+    void filterPassesNonBlankPasswordToJaasOnAnAuthenticatedPath() throws 
IOException {
+        ContainerRequestContext requestContext = request("tenants", 
basic("karaf:a-strong-password"));
+
+        filter.filter(requestContext);
+
+        verify(jaasAuthenticationFilter).filter(requestContext);
+    }
+
+    /**
+     * V2 compatibility mode routes every request through {@link 
AuthenticationFilter}'s own
+     * private-endpoint branch, which consumes the Basic credential at a 
third, separate call site.
+     * Without this test that call site is unreachable from the suite: the 
other tests leave
+     * {@code isV2CompatibilityModeEnabled()} at the unstubbed Mockito {@code 
false}, so deleting
+     * the guard there would leave every test green.
+     */
+    @Test
+    void filterRejectsBlankPasswordOnAPrivatePathInV2CompatibilityMode() 
throws IOException {
+        
when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true);
+        
when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList());
+        ContainerRequestContext requestContext = request("profiles", 
basic("karaf:"));
+
+        filter.filter(requestContext);
+
+        assertUnauthorizedWithoutReachingJaas(requestContext);
+    }
+
+    /** Control for the V2 branch: a non-blank credential must still reach 
JAAS there too. */
+    @Test
+    void 
filterPassesNonBlankPasswordToJaasOnAPrivatePathInV2CompatibilityMode() throws 
IOException {
+        
when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true);
+        
when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList());
+        ContainerRequestContext requestContext = request("profiles", 
basic("karaf:a-strong-password"));
+
+        filter.filter(requestContext);
+
+        verify(jaasAuthenticationFilter).filter(requestContext);
+    }
+
+    /**
+     * A public path in V2 compatibility mode authenticates by default tenant, 
ignoring
+     * {@code Authorization} entirely — so a stray blank Basic header must not 
turn it into a 401.
+     */
+    @Test
+    void 
filterDoesNotRejectAStrayBlankBasicHeaderOnAPublicPathInV2CompatibilityMode() 
throws IOException {
+        
when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true);
+        when(restAuthenticationConfig.getPublicPathPatterns())
+                .thenReturn(Collections.singletonList(Pattern.compile("POST 
context\\.json")));
+        
when(restAuthenticationConfig.getV2CompatibilityDefaultTenantId()).thenReturn("default");
+        ContainerRequestContext requestContext = request("context.json", 
basic("someone:"));
+
+        filter.filter(requestContext);
+
+        verify(tenantService).getTenant("default");
+    }
+
+    private void assertUnauthorizedWithoutReachingJaas(ContainerRequestContext 
requestContext) throws IOException {
         ArgumentCaptor<Response> aborted = 
ArgumentCaptor.forClass(Response.class);
         verify(requestContext).abortWith(aborted.capture());
         assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), 
aborted.getValue().getStatus());
+        verify(jaasAuthenticationFilter, never()).filter(any());
     }
 
     /**

Reply via email to