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

rcordier pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit 2f500a7fd9f464498c5302780ce5500c234a3255
Author: Rene Cordier <[email protected]>
AuthorDate: Fri Apr 10 15:29:17 2026 +0700

    JAMES-4201 Implement granular password access control in PasswordFilter
---
 .../james/modules/server/WebAdminServerModule.java |  15 ++-
 .../webadmin/authentication/PasswordFilter.java    | 100 ++++++++++++----
 .../authentication/PasswordFilterTest.java         | 127 ++++++++++++++++++++-
 3 files changed, 216 insertions(+), 26 deletions(-)

diff --git 
a/server/container/guice/protocols/webadmin/src/main/java/org/apache/james/modules/server/WebAdminServerModule.java
 
b/server/container/guice/protocols/webadmin/src/main/java/org/apache/james/modules/server/WebAdminServerModule.java
index bf42c4f701..000e6c9901 100644
--- 
a/server/container/guice/protocols/webadmin/src/main/java/org/apache/james/modules/server/WebAdminServerModule.java
+++ 
b/server/container/guice/protocols/webadmin/src/main/java/org/apache/james/modules/server/WebAdminServerModule.java
@@ -193,14 +193,23 @@ public class WebAdminServerModule extends AbstractModule {
             if (configurationFile.getBoolean("jwt.enabled", 
DEFAULT_JWT_DISABLED)) {
                 return new JwtFilter(jwtTokenVerifier);
             }
-            return webAdminConfiguration.getPassword()
-                .<AuthenticationFilter>map(PasswordFilter::new)
-                .orElse(new NoAuthenticationFilter());
+            if (isPasswordPresent(webAdminConfiguration)) {
+                return new PasswordFilter(webAdminConfiguration.getPassword(),
+                    webAdminConfiguration.getReadOnlyPassword(),
+                    webAdminConfiguration.getNoDeletePassword());
+            }
+            return new NoAuthenticationFilter();
         } catch (FileNotFoundException e) {
             return new NoAuthenticationFilter();
         }
     }
 
+    private boolean isPasswordPresent(WebAdminConfiguration 
webAdminConfiguration) {
+        return webAdminConfiguration.getPassword().isPresent()
+            || webAdminConfiguration.getNoDeletePassword().isPresent()
+            || webAdminConfiguration.getReadOnlyPassword().isPresent();
+    }
+
     @Provides
     @Singleton
     @Named("webadmin")
diff --git 
a/server/protocols/webadmin/webadmin-core/src/main/java/org/apache/james/webadmin/authentication/PasswordFilter.java
 
b/server/protocols/webadmin/webadmin-core/src/main/java/org/apache/james/webadmin/authentication/PasswordFilter.java
index e324f75960..6c46db42ab 100644
--- 
a/server/protocols/webadmin/webadmin-core/src/main/java/org/apache/james/webadmin/authentication/PasswordFilter.java
+++ 
b/server/protocols/webadmin/webadmin-core/src/main/java/org/apache/james/webadmin/authentication/PasswordFilter.java
@@ -29,6 +29,7 @@ import jakarta.inject.Inject;
 import org.eclipse.jetty.http.HttpStatus;
 
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
 
 import spark.Request;
 import spark.Response;
@@ -39,12 +40,69 @@ public class PasswordFilter implements AuthenticationFilter 
{
     public static final String AUTHORIZATION_HEADER_PREFIX = "Bearer ";
     public static final String AUTHORIZATION_HEADER_NAME = "Authorization";
 
-    private final List<String> passwords;
+    private static final String GET_METHOD = "GET";
+    private static final String HEAD_METHOD = "HEAD";
+    private static final String DELETE_METHOD = "DELETE";
 
+    private final Optional<List<String>> passwords;
+    private final Optional<List<String>> readOnlyPasswords;
+    private final Optional<List<String>> noDeletePasswords;
+
+    /**
+     * @param passwordString optional comma-separated list of full-access 
passwords
+     * @param readOnlyPasswordString optional comma-separated list of 
read-only passwords
+     * @param noDeletePasswordString optional comma-separated list of 
no-delete passwords
+     */
     @Inject
-    public PasswordFilter(String passwordString) {
-        this.passwords = Splitter.on(',')
-            .splitToList(passwordString);
+    public PasswordFilter(Optional<String> passwordString, Optional<String> 
readOnlyPasswordString, Optional<String> noDeletePasswordString) {
+        this.passwords = splitOptionalPasswords(passwordString);
+        this.readOnlyPasswords = 
splitOptionalPasswords(readOnlyPasswordString);
+        this.noDeletePasswords = 
splitOptionalPasswords(noDeletePasswordString);
+    }
+
+    private Optional<List<String>> splitOptionalPasswords(Optional<String> 
optionalPasswordString) {
+        return optionalPasswordString.map(this::splitPasswords);
+    }
+
+    private List<String> splitPasswords(String passwordString) {
+        if (passwordString == null || passwordString.isEmpty()) {
+            return ImmutableList.of();
+        }
+        return Splitter.on(',').splitToList(passwordString);
+    }
+
+    private enum AccessLevel {
+        FULL,
+        NO_DELETE,
+        READ_ONLY,
+        NONE
+    }
+
+    private AccessLevel getAccessLevel(String password) {
+        if (passwords.isPresent() && passwords.get().contains(password)) {
+            return AccessLevel.FULL;
+        }
+        if (noDeletePasswords.isPresent() && 
noDeletePasswords.get().contains(password)) {
+            return AccessLevel.NO_DELETE;
+        }
+        if (readOnlyPasswords.isPresent() && 
readOnlyPasswords.get().contains(password)) {
+            return AccessLevel.READ_ONLY;
+        }
+        return AccessLevel.NONE;
+    }
+
+    private boolean isAccessAllowed(AccessLevel accessLevel, String 
httpMethod) {
+        switch (accessLevel) {
+            case FULL:
+                return true;
+            case NO_DELETE:
+                return !httpMethod.equals(DELETE_METHOD);
+            case READ_ONLY:
+                return httpMethod.equals(GET_METHOD) || 
httpMethod.equals(HEAD_METHOD);
+            case NONE:
+            default:
+                return false;
+        }
     }
 
     @Override
@@ -53,22 +111,24 @@ public class PasswordFilter implements 
AuthenticationFilter {
             Optional<String> password = 
Optional.ofNullable(request.headers(PASSWORD));
             Optional<String> authorization = 
Optional.ofNullable(request.headers(AUTHORIZATION_HEADER_NAME));
 
-            if (!password.isPresent()) {
-                if (authorization.isPresent()) {
-                    Optional<String> bearer = authorization
-                        .filter(value -> 
value.startsWith(AUTHORIZATION_HEADER_PREFIX))
-                        .map(value -> 
value.substring(AUTHORIZATION_HEADER_PREFIX.length()));
-
-                    if (!bearer.filter(passwords::contains).isPresent()) {
-                        halt(HttpStatus.UNAUTHORIZED_401, "Wrong Bearer.");
-                    }
-                } else {
-                    halt(HttpStatus.UNAUTHORIZED_401, "No Password header.");
-                }
-            } else {
-                if (!passwords.contains(password.get())) {
-                    halt(HttpStatus.UNAUTHORIZED_401, "Wrong Password 
header.");
-                }
+            Optional<String> providedPassword = password
+                .or(() -> authorization
+                    .filter(value -> 
value.startsWith(AUTHORIZATION_HEADER_PREFIX))
+                    .map(value -> 
value.substring(AUTHORIZATION_HEADER_PREFIX.length())));
+
+            if (providedPassword.isEmpty()) {
+                halt(HttpStatus.UNAUTHORIZED_401, "No Password in header.");
+                return;
+            }
+
+            AccessLevel accessLevel = getAccessLevel(providedPassword.get());
+            if (accessLevel == AccessLevel.NONE) {
+                halt(HttpStatus.UNAUTHORIZED_401, "Wrong password.");
+                return;
+            }
+
+            if (!isAccessAllowed(accessLevel, request.requestMethod())) {
+                halt(HttpStatus.FORBIDDEN_403, "Insufficient permissions for 
this operation.");
             }
         }
     }
diff --git 
a/server/protocols/webadmin/webadmin-core/src/test/java/org/apache/james/webadmin/authentication/PasswordFilterTest.java
 
b/server/protocols/webadmin/webadmin-core/src/test/java/org/apache/james/webadmin/authentication/PasswordFilterTest.java
index 29667c7dee..b2295b44df 100644
--- 
a/server/protocols/webadmin/webadmin-core/src/test/java/org/apache/james/webadmin/authentication/PasswordFilterTest.java
+++ 
b/server/protocols/webadmin/webadmin-core/src/test/java/org/apache/james/webadmin/authentication/PasswordFilterTest.java
@@ -24,6 +24,8 @@ import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
+import java.util.Optional;
+
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
@@ -38,7 +40,7 @@ class PasswordFilterTest {
 
     @BeforeEach
     void setUp() {
-        testee = new PasswordFilter("abc,def");
+        testee = new PasswordFilter(Optional.of("abc,def"), 
Optional.of("readonly1,readonly2"), Optional.of("nodelete1,nodelete2"));
     }
 
     @Test
@@ -105,7 +107,6 @@ class PasswordFilterTest {
     void handleShouldAcceptValidPassword() throws Exception {
         Request request = mock(Request.class);
         when(request.requestMethod()).thenReturn("GET");
-        when(request.requestMethod()).thenReturn("GET");
         when(request.headers("Password")).thenReturn("abc");
 
         testee.handle(request, mock(Response.class));
@@ -115,9 +116,129 @@ class PasswordFilterTest {
     void handleShouldAcceptValidPasswordWhenBearer() throws Exception {
         Request request = mock(Request.class);
         when(request.requestMethod()).thenReturn("GET");
-        when(request.requestMethod()).thenReturn("GET");
         when(request.headers("Authorization")).thenReturn("Bearer abc");
 
         testee.handle(request, mock(Response.class));
     }
+
+    @Test
+    void handleShouldAcceptValidReadOnlyPasswordOnGet() throws Exception {
+        Request request = mock(Request.class);
+        when(request.requestMethod()).thenReturn("GET");
+        when(request.headers("Password")).thenReturn("readonly1");
+
+        testee.handle(request, mock(Response.class));
+    }
+
+    @Test
+    void handleShouldRejectReadOnlyPasswordOnPost() {
+        Request request = mock(Request.class);
+        when(request.requestMethod()).thenReturn("POST");
+        when(request.headers("Password")).thenReturn("readonly1");
+
+        assertThatThrownBy(() -> testee.handle(request, mock(Response.class)))
+            .isInstanceOf(HaltException.class)
+            .extracting(e -> HaltException.class.cast(e).statusCode())
+            .isEqualTo(403);
+    }
+
+    @Test
+    void handleShouldRejectReadOnlyPasswordOnDelete() {
+        Request request = mock(Request.class);
+        when(request.requestMethod()).thenReturn("DELETE");
+        when(request.headers("Password")).thenReturn("readonly1");
+
+        assertThatThrownBy(() -> testee.handle(request, mock(Response.class)))
+            .isInstanceOf(HaltException.class)
+            .extracting(e -> HaltException.class.cast(e).statusCode())
+            .isEqualTo(403);
+    }
+
+    @Test
+    void handleShouldAcceptValidReadOnlyPasswordOnGetViaBearer() throws 
Exception {
+        Request request = mock(Request.class);
+        when(request.requestMethod()).thenReturn("GET");
+        when(request.headers("Authorization")).thenReturn("Bearer readonly2");
+
+        testee.handle(request, mock(Response.class));
+    }
+
+    @Test
+    void handleShouldAcceptValidReadOnlyPasswordOnHeadViaBearer() throws 
Exception {
+        Request request = mock(Request.class);
+        when(request.requestMethod()).thenReturn("HEAD");
+        when(request.headers("Authorization")).thenReturn("Bearer readonly2");
+
+        testee.handle(request, mock(Response.class));
+    }
+
+    @Test
+    void handleShouldAcceptValidNoDeletePasswordOnGet() throws Exception {
+        Request request = mock(Request.class);
+        when(request.requestMethod()).thenReturn("GET");
+        when(request.headers("Password")).thenReturn("nodelete1");
+
+        testee.handle(request, mock(Response.class));
+    }
+
+    @Test
+    void handleShouldAcceptValidNoDeletePasswordOnPost() throws Exception {
+        Request request = mock(Request.class);
+        when(request.requestMethod()).thenReturn("POST");
+        when(request.headers("Password")).thenReturn("nodelete1");
+
+        testee.handle(request, mock(Response.class));
+    }
+
+    @Test
+    void handleShouldRejectNoDeletePasswordOnDelete() {
+        Request request = mock(Request.class);
+        when(request.requestMethod()).thenReturn("DELETE");
+        when(request.headers("Password")).thenReturn("nodelete1");
+
+        assertThatThrownBy(() -> testee.handle(request, mock(Response.class)))
+            .isInstanceOf(HaltException.class)
+            .extracting(e -> HaltException.class.cast(e).statusCode())
+            .isEqualTo(403);
+    }
+
+    @Test
+    void handleShouldAcceptValidNoDeletePasswordOnPut() throws Exception {
+        Request request = mock(Request.class);
+        when(request.requestMethod()).thenReturn("PUT");
+        when(request.headers("Password")).thenReturn("nodelete2");
+
+        testee.handle(request, mock(Response.class));
+    }
+
+    @Test
+    void handleShouldAcceptFullPasswordOnDelete() throws Exception {
+        Request request = mock(Request.class);
+        when(request.requestMethod()).thenReturn("DELETE");
+        when(request.headers("Password")).thenReturn("abc");
+
+        testee.handle(request, mock(Response.class));
+    }
+
+    @Test
+    void handleShouldAcceptFullPasswordOnPost() throws Exception {
+        Request request = mock(Request.class);
+        when(request.requestMethod()).thenReturn("POST");
+        when(request.headers("Password")).thenReturn("def");
+
+        testee.handle(request, mock(Response.class));
+    }
+
+    @Test
+    void handleShouldRejectWhenNoConfiguredPasswords() {
+        PasswordFilter filterWithNulls = new PasswordFilter(Optional.empty(), 
Optional.empty(), Optional.empty());
+        Request request = mock(Request.class);
+        when(request.requestMethod()).thenReturn("GET");
+        when(request.headers("Password")).thenReturn("anypassword");
+
+        assertThatThrownBy(() -> filterWithNulls.handle(request, 
mock(Response.class)))
+            .isInstanceOf(HaltException.class)
+            .extracting(e -> HaltException.class.cast(e).statusCode())
+            .isEqualTo(401);
+    }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to