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

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


The following commit(s) were added to refs/heads/master by this push:
     new df5f313de UNOMI-884: Platform polish audit (Phase 2 PR 3) (#786)
df5f313de is described below

commit df5f313de936be97826731b04f505bb61c6ed87d
Author: Serge Huber <[email protected]>
AuthorDate: Mon Jun 29 09:23:15 2026 +0200

    UNOMI-884: Platform polish audit (Phase 2 PR 3) (#786)
    
    Merge platform polish PR
---
 graphql/cxs-impl/pom.xml                           |  16 ++
 .../auth/GraphQLServletSecurityValidator.java      |   6 +-
 .../auth/GraphQLServletSecurityValidatorTest.java  | 199 +++++++++++++++++++++
 3 files changed, 220 insertions(+), 1 deletion(-)

diff --git a/graphql/cxs-impl/pom.xml b/graphql/cxs-impl/pom.xml
index 03ea7b058..828d7f4bc 100644
--- a/graphql/cxs-impl/pom.xml
+++ b/graphql/cxs-impl/pom.xml
@@ -147,6 +147,22 @@
             <artifactId>websocket-server</artifactId>
             <scope>provided</scope>
         </dependency>
+
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-junit-jupiter</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <build>
diff --git 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
index a295e6ebf..441449e42 100644
--- 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
+++ 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
@@ -198,7 +198,11 @@ public class GraphQLServletSecurityValidator {
                     if (tenant != null) {
                         
executionContextManager.setCurrentContext(executionContextManager.createContext(tenantId));
                     } else {
-                        LOG.warn("Invalid tenant ID provided in header: {} — 
leaving context derived from JAAS login", tenantId);
+                        LOG.warn("Invalid tenant ID provided in header: {}", 
tenantId);
+                        // Same fallback as the "no tenant header" branch 
below: the thread-local
+                        // execution context must always be set explicitly 
here, otherwise a stale
+                        // context from a previous request on this pooled 
thread could leak in.
+                        
executionContextManager.setCurrentContext(ExecutionContext.systemContext());
                     }
                 } else {
                     
executionContextManager.setCurrentContext(ExecutionContext.systemContext());
diff --git 
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
 
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
new file mode 100644
index 000000000..e64acd008
--- /dev/null
+++ 
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
@@ -0,0 +1,199 @@
+/*
+ * 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.graphql.servlet.auth;
+
+import org.apache.unomi.api.ExecutionContext;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.services.ExecutionContextManager;
+import org.apache.unomi.api.tenants.ApiKey;
+import org.apache.unomi.api.tenants.Tenant;
+import org.apache.unomi.api.tenants.TenantService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import javax.security.auth.Subject;
+import javax.security.auth.callback.Callback;
+import javax.security.auth.callback.CallbackHandler;
+import javax.security.auth.callback.NameCallback;
+import javax.security.auth.callback.PasswordCallback;
+import javax.security.auth.callback.UnsupportedCallbackException;
+import javax.security.auth.login.AppConfigurationEntry;
+import javax.security.auth.login.Configuration;
+import javax.security.auth.login.LoginException;
+import javax.security.auth.spi.LoginModule;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.refEq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Covers the JAAS-authenticated tenant resolution branch of {@link 
GraphQLServletSecurityValidator},
+ * in particular the fallback to {@link ExecutionContext#systemContext()} when 
the
+ * {@code X-Unomi-Tenant-Id} header does not resolve to a known tenant 
(UNOMI-884).
+ */
+@ExtendWith(MockitoExtension.class)
+class GraphQLServletSecurityValidatorTest {
+
+    private static final String TENANT_HEADER = "X-Unomi-Tenant-Id";
+    private static final String BASIC_AUTH = "Basic " + 
Base64.getEncoder().encodeToString("user:pass".getBytes());
+
+    private Configuration previousConfiguration;
+
+    @Mock
+    private TenantService tenantService;
+    @Mock
+    private SecurityService securityService;
+    @Mock
+    private ExecutionContextManager executionContextManager;
+    @Mock
+    private HttpServletRequest request;
+    @Mock
+    private HttpServletResponse response;
+
+    private GraphQLServletSecurityValidator validator;
+
+    @BeforeEach
+    void setUp() {
+        previousConfiguration = Configuration.getConfiguration();
+        Configuration.setConfiguration(new 
AlwaysSucceedingKarafConfiguration());
+        validator = new GraphQLServletSecurityValidator(tenantService, 
securityService, executionContextManager);
+    }
+
+    @AfterEach
+    void tearDown() {
+        Configuration.setConfiguration(previousConfiguration);
+    }
+
+    @Test
+    void validate_withInvalidTenantHeader_fallsBackToSystemContext() throws 
IOException {
+        when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
+        when(request.getHeader(TENANT_HEADER)).thenReturn("not-a-real-tenant");
+        when(tenantService.getTenantByApiKey(any(), 
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
+        when(tenantService.getTenant("not-a-real-tenant")).thenReturn(null);
+
+        boolean authenticated = validator.validate(null, null, request, 
response);
+
+        assertTrue(authenticated);
+        
verify(executionContextManager).setCurrentContext(refEq(ExecutionContext.systemContext()));
+        verify(response, never()).sendError(any(Integer.class));
+    }
+
+    @Test
+    void validate_withValidTenantHeader_createsTenantContext() throws 
IOException {
+        Tenant tenant = new Tenant();
+        tenant.setItemId("known-tenant");
+
+        when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
+        when(request.getHeader(TENANT_HEADER)).thenReturn("known-tenant");
+        when(tenantService.getTenantByApiKey(any(), 
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
+        when(tenantService.getTenant("known-tenant")).thenReturn(tenant);
+        ExecutionContext tenantContext = new ExecutionContext("known-tenant", 
null, null);
+        
when(executionContextManager.createContext("known-tenant")).thenReturn(tenantContext);
+
+        boolean authenticated = validator.validate(null, null, request, 
response);
+
+        assertTrue(authenticated);
+        verify(executionContextManager).createContext("known-tenant");
+        verify(executionContextManager).setCurrentContext(tenantContext);
+        verify(executionContextManager, 
never()).setCurrentContext(refEq(ExecutionContext.systemContext()));
+    }
+
+    @Test
+    void validate_withoutAuthorizationHeader_isRejected() throws IOException {
+        when(request.getHeader("Authorization")).thenReturn(null);
+
+        boolean authenticated = validator.validate(null, null, request, 
response);
+
+        assertFalse(authenticated);
+        verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
+    }
+
+    /**
+     * Minimal JAAS configuration that makes {@code new LoginContext("karaf", 
...)} succeed
+     * without requiring a real Karaf realm, so the post-login branches under 
test can run
+     * as a plain unit test.
+     */
+    private static class AlwaysSucceedingKarafConfiguration extends 
Configuration {
+        @Override
+        public AppConfigurationEntry[] getAppConfigurationEntry(String name) {
+            if (!"karaf".equals(name)) {
+                return null;
+            }
+            return new AppConfigurationEntry[]{
+                    new AppConfigurationEntry(
+                            AlwaysSucceedingLoginModule.class.getName(),
+                            
AppConfigurationEntry.LoginModuleControlFlag.REQUIRED,
+                            new HashMap<>())
+            };
+        }
+    }
+
+    public static class AlwaysSucceedingLoginModule implements LoginModule {
+        private Subject subject;
+        private CallbackHandler callbackHandler;
+
+        @Override
+        public void initialize(Subject subject, CallbackHandler 
callbackHandler, Map<String, ?> sharedState, Map<String, ?> options) {
+            this.subject = subject;
+            this.callbackHandler = callbackHandler;
+        }
+
+        @Override
+        public boolean login() throws LoginException {
+            try {
+                NameCallback nameCallback = new NameCallback("name");
+                PasswordCallback passwordCallback = new 
PasswordCallback("password", false);
+                callbackHandler.handle(new Callback[]{nameCallback, 
passwordCallback});
+            } catch (IOException | UnsupportedCallbackException e) {
+                throw new LoginException(e.getMessage());
+            }
+            return true;
+        }
+
+        @Override
+        public boolean commit() {
+            return true;
+        }
+
+        @Override
+        public boolean abort() {
+            return true;
+        }
+
+        @Override
+        public boolean logout() {
+            subject.getPrincipals().clear();
+            return true;
+        }
+    }
+}

Reply via email to