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 503a0d690 Check roles and tenant authority after a realm login (#875)
503a0d690 is described below

commit 503a0d690f47d858ad135aff2dda612ef9068c63
Author: Serge Huber <[email protected]>
AuthorDate: Thu Sep 24 12:59:37 2026 +0200

    Check roles and tenant authority after a realm login (#875)
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../auth/GraphQLServletSecurityValidator.java      |  79 ++++++++++-----
 .../servlet/websocket/SubscriptionWebSocket.java   |   8 +-
 .../auth/GraphQLServletSecurityValidatorTest.java  | 107 ++++++++++++++++++++-
 .../websocket/SubscriptionWebSocketTest.java       |   4 +-
 4 files changed, 166 insertions(+), 32 deletions(-)

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 d6ef91881..97624d7f1 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
@@ -21,6 +21,7 @@ import graphql.language.*;
 import graphql.parser.Parser;
 import org.apache.unomi.api.ExecutionContext;
 import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.security.UnomiRoles;
 import org.apache.unomi.api.services.ExecutionContextManager;
 import org.apache.unomi.api.tenants.ApiKey;
 import org.apache.unomi.api.tenants.Tenant;
@@ -86,6 +87,8 @@ public class GraphQLServletSecurityValidator {
         if (isAuthenticatedUser(req)) {
             return true;
         }
+        // A 401 carries a challenge whether the header was missing or its 
credential was refused.
+        res.addHeader("WWW-Authenticate", "Basic realm=\"karaf\"");
         res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
         return false;
     }
@@ -289,7 +292,8 @@ public class GraphQLServletSecurityValidator {
      *            (WebSocket {@code connection_init}); when null, no tenant 
header is consulted.
      */
     private boolean authenticateBasic(String authHeader, HttpServletRequest 
req) {
-        if (authHeader == null || !authHeader.startsWith("Basic ")) {
+        // The scheme token is case-insensitive (RFC 7235).
+        if (authHeader == null || !authHeader.regionMatches(true, 0, "Basic ", 
0, 6)) {
             return false;
         }
 
@@ -353,33 +357,58 @@ public class GraphQLServletSecurityValidator {
             });
             loginContext.login();
             Subject loginSubject = loginContext.getSubject();
-            boolean success = loginSubject != null;
-            if (success) {
-                if (req != null) {
-                    req.setAttribute(REMOTE_USER, username);
+            if (loginSubject == null) {
+                return false;
+            }
+
+            // Set the security context for JAAS authentication
+            securityService.setCurrentSubject(loginSubject);
+
+            // A successful realm login is not by itself an authorization to 
use this API: the realm
+            // can carry accounts that hold no Unomi role at all. Require the 
same administrator roles
+            // the REST admin surface requires.
+            if (!securityService.hasRole(UnomiRoles.ADMINISTRATOR)
+                    && 
!securityService.hasRole(UnomiRoles.TENANT_ADMINISTRATOR)) {
+                LOG.warn("Refusing GraphQL access to '{}': the account holds 
no Unomi administrator role", username);
+                securityService.clearCurrentSubject();
+                return false;
+            }
+
+            // Check for tenant ID header (only present when the credential 
arrived on a request;
+            // the connection_init route carries none, so it can never select 
a tenant this way)
+            String tenantId = req != null ? 
req.getHeader(UNOMI_TENANT_ID_HEADER) : null;
+            if (tenantId != null && !tenantId.trim().isEmpty()) {
+                // Validate tenant exists
+                Tenant tenant = tenantService.getTenant(tenantId);
+                if (tenant == null) {
+                    LOG.warn("Invalid tenant ID provided in header: {}", 
tenantId);
+                    securityService.clearCurrentSubject();
+                    return false;
                 }
-                // Set the security context for JAAS authentication
-                securityService.setCurrentSubject(loginSubject);
-
-                // Check for tenant ID header (only meaningful when the 
credential arrived on a request)
-                String tenantId = req != null ? 
req.getHeader(UNOMI_TENANT_ID_HEADER) : null;
-                if (tenantId != null && !tenantId.trim().isEmpty()) {
-                    // Validate tenant exists
-                    Tenant tenant = tenantService.getTenant(tenantId);
-                    if (tenant != null) {
-                        
executionContextManager.setCurrentContext(executionContextManager.createContext(tenantId));
-                    } else {
-                        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());
+                // Naming a tenant is not the same as having authority over it.
+                if (!securityService.hasSystemAccess() && 
!securityService.hasTenantAccess(tenantId)) {
+                    LOG.warn("Refusing GraphQL access to '{}': no authority 
over tenant {}", username, tenantId);
+                    securityService.clearCurrentSubject();
+                    return false;
                 }
+                
executionContextManager.setCurrentContext(executionContextManager.createContext(tenantId));
+            } else {
+                // No tenant header. The system context is inherited by every 
tenant, so it is reserved
+                // for subjects that actually hold system access rather than 
being the default.
+                if (!securityService.hasSystemAccess()) {
+                    LOG.warn("Refusing GraphQL access to '{}': no tenant 
specified and no system access", username);
+                    securityService.clearCurrentSubject();
+                    return false;
+                }
+                // 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());
+            }
+
+            if (req != null) {
+                req.setAttribute(REMOTE_USER, username);
             }
-            return success;
+            return true;
         } catch (LoginException e) {
             LOG.debug("Login failed", e);
             return false;
diff --git 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocket.java
 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocket.java
index 71299eb19..cf0a0c852 100644
--- 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocket.java
+++ 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocket.java
@@ -69,6 +69,9 @@ public class SubscriptionWebSocket extends WebSocketAdapter {
 
     private volatile ScheduledFuture<?> deadlineTask;
 
+    /** The session's configured idle timeout, shortened while unauthenticated 
and restored on authentication. */
+    private volatile long configuredIdleTimeout;
+
     private boolean deadlineExpired;
 
     private final SecurityService securityService;
@@ -101,6 +104,7 @@ public class SubscriptionWebSocket extends WebSocketAdapter 
{
         LOGGER.info("Opening web socket");
         super.onWebSocketConnect(sess);
         if (!authenticated) {
+            configuredIdleTimeout = sess.getIdleTimeout();
             // Bound how long an unauthenticated socket may sit open. The idle 
timeout alone is not a
             // deadline: Jetty resets it on any frame, including ping/pong 
control frames that never reach
             // onWebSocketText, so a client could hold an unauthenticated 
socket open just by pinging.
@@ -265,8 +269,8 @@ public class SubscriptionWebSocket extends WebSocketAdapter 
{
         cancelAuthenticationDeadline();
         final Session session = getSession();
         if (session != null) {
-            // Authenticated: drop the short unauthenticated deadline.
-            session.setIdleTimeout(0);
+            // Authenticated: back to the idle timeout the session was 
configured with, not "none".
+            session.setIdleTimeout(configuredIdleTimeout);
         }
         return true;
     }
diff --git 
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
 
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
index 0f842f220..a05d09193 100644
--- 
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
+++ 
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
@@ -19,6 +19,7 @@ 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.security.UnomiRoles;
 import org.apache.unomi.api.services.ExecutionContextManager;
 import org.apache.unomi.api.tenants.ApiKey;
 import org.apache.unomi.api.tenants.Tenant;
@@ -94,8 +95,16 @@ class GraphQLServletSecurityValidatorTest {
         Configuration.setConfiguration(previousConfiguration);
     }
 
+    /** Grants the administrator role the JAAS branch now requires before it 
will authorize anything. */
+    private void givenAdministratorRole() {
+        
lenient().when(securityService.hasRole(UnomiRoles.ADMINISTRATOR)).thenReturn(true);
+    }
+
     @Test
-    void validate_withInvalidTenantHeader_fallsBackToSystemContext() throws 
IOException {
+    void validate_withInvalidTenantHeader_isRejected() throws IOException {
+        // A tenant header that names no known tenant is refused; it must not 
fall back to the system
+        // context, which is inherited by every tenant.
+        givenAdministratorRole();
         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);
@@ -103,9 +112,60 @@ class GraphQLServletSecurityValidatorTest {
 
         boolean authenticated = validator.validate(null, null, request, 
response);
 
-        assertTrue(authenticated);
-        
verify(executionContextManager).setCurrentContext(refEq(ExecutionContext.systemContext()));
-        verify(response, never()).sendError(any(Integer.class));
+        assertFalse(authenticated);
+        verify(executionContextManager, 
never()).setCurrentContext(refEq(ExecutionContext.systemContext()));
+        verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
+    }
+
+    @Test
+    void validate_withoutUnomiRole_isRejected() throws IOException {
+        // A realm account that carries no Unomi role (the shipped 
health-check account, for one) must
+        // not obtain access, even though the realm login itself succeeds.
+        when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
+        when(tenantService.getTenantByApiKey(any(), 
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
+
+        boolean authenticated = validator.validate(null, null, request, 
response);
+
+        assertFalse(authenticated);
+        verify(executionContextManager, never()).setCurrentContext(any());
+        verify(securityService).clearCurrentSubject();
+        verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
+    }
+
+    @Test
+    void validate_withTenantHeaderButNoAuthorityOverIt_isRejected() throws 
IOException {
+        Tenant tenant = new Tenant();
+        tenant.setItemId("someone-elses-tenant");
+
+        givenAdministratorRole();
+        when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
+        
when(request.getHeader(TENANT_HEADER)).thenReturn("someone-elses-tenant");
+        when(tenantService.getTenantByApiKey(any(), 
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
+        
when(tenantService.getTenant("someone-elses-tenant")).thenReturn(tenant);
+        when(securityService.hasSystemAccess()).thenReturn(false);
+        
when(securityService.hasTenantAccess("someone-elses-tenant")).thenReturn(false);
+
+        boolean authenticated = validator.validate(null, null, request, 
response);
+
+        assertFalse(authenticated);
+        verify(executionContextManager, 
never()).createContext("someone-elses-tenant");
+        verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
+    }
+
+    @Test
+    void validate_withoutTenantHeaderAndNoSystemAccess_isRejected() throws 
IOException {
+        // The system context is not the default for an authenticated caller.
+        givenAdministratorRole();
+        when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
+        when(request.getHeader(TENANT_HEADER)).thenReturn(null);
+        when(tenantService.getTenantByApiKey(any(), 
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
+        when(securityService.hasSystemAccess()).thenReturn(false);
+
+        boolean authenticated = validator.validate(null, null, request, 
response);
+
+        assertFalse(authenticated);
+        verify(executionContextManager, 
never()).setCurrentContext(refEq(ExecutionContext.systemContext()));
+        verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
     }
 
     @Test
@@ -113,10 +173,12 @@ class GraphQLServletSecurityValidatorTest {
         Tenant tenant = new Tenant();
         tenant.setItemId("known-tenant");
 
+        givenAdministratorRole();
         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);
+        when(securityService.hasTenantAccess("known-tenant")).thenReturn(true);
         ExecutionContext tenantContext = new ExecutionContext("known-tenant", 
null, null);
         
when(executionContextManager.createContext("known-tenant")).thenReturn(tenantContext);
 
@@ -128,6 +190,21 @@ class GraphQLServletSecurityValidatorTest {
         verify(executionContextManager, 
never()).setCurrentContext(refEq(ExecutionContext.systemContext()));
     }
 
+    @Test
+    void validate_withSystemAccessAndNoTenantHeader_usesSystemContext() throws 
IOException {
+        givenAdministratorRole();
+        when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
+        when(request.getHeader(TENANT_HEADER)).thenReturn(null);
+        when(tenantService.getTenantByApiKey(any(), 
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
+        when(securityService.hasSystemAccess()).thenReturn(true);
+
+        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_withoutAuthorizationHeader_isRejected() throws IOException {
         when(request.getHeader("Authorization")).thenReturn(null);
@@ -151,6 +228,8 @@ class GraphQLServletSecurityValidatorTest {
 
     @Test
     void validateWebSocketUpgrade_withBasicAuth_isAccepted() throws 
IOException {
+        givenAdministratorRole();
+        when(securityService.hasSystemAccess()).thenReturn(true);
         when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
         when(tenantService.getTenantByApiKey(any(), 
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
 
@@ -161,6 +240,21 @@ class GraphQLServletSecurityValidatorTest {
         verify(response, never()).sendError(any(Integer.class));
     }
 
+    /** HTTP authentication scheme names are case-insensitive. */
+    @Test
+    void validateWebSocketUpgrade_withLowercaseScheme_isAccepted() throws 
IOException {
+        givenAdministratorRole();
+        when(securityService.hasSystemAccess()).thenReturn(true);
+        
when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH.replaceFirst("Basic",
 "basic"));
+        when(tenantService.getTenantByApiKey(any(), 
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
+
+        boolean authenticated = validator.validateWebSocketUpgrade(request, 
response);
+
+        assertTrue(authenticated);
+        verify(securityService).setCurrentSubject(any(Subject.class));
+        verify(response, never()).sendError(any(Integer.class));
+    }
+
     @Test
     void validateWebSocketUpgrade_rejectsPublicApiKeyOnly() throws IOException 
{
         // No Authorization header — public API key alone must not open 
subscriptions.
@@ -182,6 +276,8 @@ class GraphQLServletSecurityValidatorTest {
 
         assertFalse(authenticated);
         verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
+        // A 401 must carry a challenge whichever branch produced it.
+        verify(response).addHeader("WWW-Authenticate", "Basic 
realm=\"karaf\"");
         verify(securityService, never()).setCurrentSubject(any());
     }
 
@@ -298,6 +394,9 @@ class GraphQLServletSecurityValidatorTest {
     /** Control: a non-blank credential still reaches the realm and is 
accepted by it. */
     @Test
     void validate_withNonBlankPassword_reachesJaas() throws IOException {
+        // Role + system access are required after JAAS succeeds when no 
tenant header is present.
+        givenAdministratorRole();
+        when(securityService.hasSystemAccess()).thenReturn(true);
         when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
         when(tenantService.getTenantByApiKey(any(), 
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
 
diff --git 
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketTest.java
 
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketTest.java
index c874f2a7a..895d422c2 100644
--- 
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketTest.java
+++ 
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketTest.java
@@ -151,6 +151,7 @@ class SubscriptionWebSocketTest {
         
when(validator.authenticateBasicCredential(anyString())).thenReturn(true);
         when(securityService.getCurrentSubject()).thenReturn(subject);
         
when(executionContextManager.getCurrentContext()).thenReturn(executionContext);
+        when(session.getIdleTimeout()).thenReturn(30_000L);
         SubscriptionWebSocket unauth = new SubscriptionWebSocket(graphQL, 
serviceManager, null, null,
                 securityService, executionContextManager, validator, 
deadlineScheduler);
         unauth.onWebSocketConnect(session);
@@ -161,7 +162,8 @@ class SubscriptionWebSocketTest {
         // Identity captured onto the socket, and not left bound to this 
shared IO thread.
         verify(securityService).clearCurrentSubject();
         verify(session, never()).close(anyInt(), anyString());
-        verify(session).setIdleTimeout(0);
+        // Back to the session's configured idle timeout, not disabled.
+        verify(session).setIdleTimeout(30_000L);
     }
 
     @Test

Reply via email to