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());
}
/**