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

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


The following commit(s) were added to refs/heads/master by this push:
     new 7d802b2400 [ENHANCEMENT] Implement negative ACL for JMAP
7d802b2400 is described below

commit 7d802b2400c46aa22c0e6eaf584403cef12474fa
Author: Benoit TELLIER <[email protected]>
AuthorDate: Mon Jun 1 21:35:07 2026 +0200

    [ENHANCEMENT] Implement negative ACL for JMAP
---
 .../james/mailbox/store/StoreRightManager.java     |  19 +-
 .../james/mailbox/store/StoreRightManagerTest.java |  71 +++++++-
 .../rfc8621/contract/EmailGetMethodContract.scala  |  61 +++++++
 .../rfc8621/contract/EmailSetMethodContract.scala  | 191 +++++++++++++++++++++
 .../contract/MailboxGetMethodContract.scala        | 138 +++++++++++++++
 .../contract/MailboxSetMethodContract.scala        | 180 +++++++++++++++++++
 6 files changed, 650 insertions(+), 10 deletions(-)

diff --git 
a/mailbox/store/src/main/java/org/apache/james/mailbox/store/StoreRightManager.java
 
b/mailbox/store/src/main/java/org/apache/james/mailbox/store/StoreRightManager.java
index 49ad62fe69..36aaf093da 100644
--- 
a/mailbox/store/src/main/java/org/apache/james/mailbox/store/StoreRightManager.java
+++ 
b/mailbox/store/src/main/java/org/apache/james/mailbox/store/StoreRightManager.java
@@ -278,10 +278,12 @@ public class StoreRightManager implements RightManager {
 
     private void assertHaveAccessTo(Mailbox mailbox, MailboxSession session) 
throws InsufficientRightsException, MailboxNotFoundException {
         if (!mailbox.generateAssociatedPath().belongsTo(session)) {
-            Rfc4314Rights acl = 
mailbox.getACL().getEntries().get(EntryKey.createUserEntryKey(session.getUser()));
-            if (acl == null) {
+            // Use the effective rights (positive entries minus applicable 
negative entries) rather
+            // than the raw positive ACL entry, so that a negative ACL 
removing Administer is honoured.
+            Rfc4314Rights rights = myRights(mailbox, session);
+            if (rights.isEmpty()) {
                 throw new MailboxNotFoundException(mailbox.getMailboxId());
-            } else if (!acl.contains(Right.Administer)) {
+            } else if (!rights.contains(Right.Administer)) {
                 throw new InsufficientRightsException("Setting ACL is only 
permitted to the owner and admins of the mailbox");
             }
         }
@@ -333,17 +335,20 @@ public class StoreRightManager implements RightManager {
      * the connected user.
      */
     @VisibleForTesting
-    static MailboxACL filteredForSession(Mailbox mailbox, MailboxACL acl, 
MailboxSession mailboxSession) {
+    MailboxACL filteredForSession(Mailbox mailbox, MailboxACL acl, 
MailboxSession mailboxSession) throws UnsupportedRightException {
         if (mailbox.generateAssociatedPath().belongsTo(mailboxSession)) {
             return acl;
         }
 
         MailboxACL.EntryKey userAsKey = 
MailboxACL.EntryKey.createUserEntryKey(mailboxSession.getUser());
-        Rfc4314Rights rights = acl.getEntries().getOrDefault(userAsKey, new 
Rfc4314Rights());
-        if (rights.contains(MailboxACL.Right.Administer)) {
+        // Expose the full ACL only when the user effectively holds Administer 
(i.e. after applicable
+        // negative ACL entries have been subtracted), not merely from their 
raw positive entry.
+        Rfc4314Rights effectiveRights = 
aclResolver.resolveRights(mailboxSession.getUser(), acl, mailbox.getUser());
+        if (effectiveRights.contains(MailboxACL.Right.Administer)) {
             return acl;
         }
-        return new MailboxACL(ImmutableMap.of(userAsKey, rights));
+        Rfc4314Rights ownRights = acl.getEntries().getOrDefault(userAsKey, new 
Rfc4314Rights());
+        return new MailboxACL(ImmutableMap.of(userAsKey, ownRights));
     }
 
     /** Sets an ACL for a mailbox *WITHOUT* checking if the user of current 
session is allowed to do so.
diff --git 
a/mailbox/store/src/test/java/org/apache/james/mailbox/store/StoreRightManagerTest.java
 
b/mailbox/store/src/test/java/org/apache/james/mailbox/store/StoreRightManagerTest.java
index cb5b7f7b75..ace59dd966 100644
--- 
a/mailbox/store/src/test/java/org/apache/james/mailbox/store/StoreRightManagerTest.java
+++ 
b/mailbox/store/src/test/java/org/apache/james/mailbox/store/StoreRightManagerTest.java
@@ -42,6 +42,7 @@ import org.apache.james.mailbox.acl.MailboxACLResolver;
 import org.apache.james.mailbox.acl.UnionMailboxACLResolver;
 import org.apache.james.mailbox.events.MailboxIdRegistrationKey;
 import org.apache.james.mailbox.exception.DifferentDomainException;
+import org.apache.james.mailbox.exception.InsufficientRightsException;
 import org.apache.james.mailbox.exception.MailboxException;
 import org.apache.james.mailbox.exception.MailboxNotFoundException;
 import org.apache.james.mailbox.exception.UnsupportedRightException;
@@ -49,6 +50,7 @@ import org.apache.james.mailbox.fixture.MailboxFixture;
 import org.apache.james.mailbox.model.Mailbox;
 import org.apache.james.mailbox.model.MailboxACL;
 import org.apache.james.mailbox.model.MailboxACL.ACLCommand;
+import org.apache.james.mailbox.model.MailboxACL.EntryKey;
 import org.apache.james.mailbox.model.MailboxACL.Right;
 import org.apache.james.mailbox.model.MailboxConstants;
 import org.apache.james.mailbox.model.MailboxId;
@@ -214,7 +216,7 @@ class StoreRightManagerTest {
         MailboxACL acl = new MailboxACL()
             .apply(MailboxACL.command().rights(Right.Read, 
Right.Write).forUser(BOB).asAddition())
             .apply(MailboxACL.command().rights(Right.Read, Right.Write, 
Right.Administer).forUser(CEDRIC).asAddition());
-        MailboxACL actual = StoreRightManager.filteredForSession(
+        MailboxACL actual = storeRightManager.filteredForSession(
             new Mailbox(INBOX_ALICE, UID_VALIDITY, MAILBOX_ID), acl, 
aliceSession);
         assertThat(actual).isEqualTo(acl);
     }
@@ -224,7 +226,7 @@ class StoreRightManagerTest {
         MailboxACL acl = new MailboxACL()
             .apply(MailboxACL.command().rights(Right.Read, 
Right.Write).forUser(BOB).asAddition())
             .apply(MailboxACL.command().rights(Right.Read, Right.Write, 
Right.Administer).forUser(CEDRIC).asAddition());
-        MailboxACL actual = StoreRightManager.filteredForSession(
+        MailboxACL actual = storeRightManager.filteredForSession(
             new Mailbox(INBOX_ALICE, UID_VALIDITY, MAILBOX_ID), acl, 
MailboxSessionUtil.create(CEDRIC));
         assertThat(actual).isEqualTo(acl);
     }
@@ -234,7 +236,7 @@ class StoreRightManagerTest {
         MailboxACL acl = new MailboxACL()
             .apply(MailboxACL.command().rights(Right.Read, 
Right.Write).forUser(BOB).asAddition())
             .apply(MailboxACL.command().rights(Right.Read, Right.Write, 
Right.Administer).forUser(CEDRIC).asAddition());
-        MailboxACL actual = StoreRightManager.filteredForSession(
+        MailboxACL actual = storeRightManager.filteredForSession(
             new Mailbox(INBOX_ALICE, UID_VALIDITY, MAILBOX_ID), acl, 
MailboxSessionUtil.create(BOB));
         
assertThat(actual.getEntries()).containsKey(MailboxACL.EntryKey.createUserEntryKey(BOB));
     }
@@ -335,4 +337,67 @@ class StoreRightManagerTest {
             StoreRightManager.IS_CROSS_DOMAIN_ACCESS_ALLOWED = false;
         }
     }
+
+    @Test
+    void 
applyRightsCommandShouldThrowWhenAdministerIsRemovedByANegativeAclEntry() 
throws Exception {
+        // Alice holds a raw positive Administer grant, but an applicable 
negative ACL entry
+        // (as can be set through IMAP SETACL "-alice a") removes Administer 
from her effective rights.
+        MailboxPath mailboxPath = MailboxPath.forUser(BOB, "mailbox");
+        Mailbox mailbox = new Mailbox(mailboxPath, UID_VALIDITY, MAILBOX_ID);
+        mailbox.setACL(new MailboxACL()
+            .apply(MailboxACL.command().forUser(ALICE).rights(Right.Lookup, 
Right.Administer).asAddition())
+            .apply(MailboxACL.command().key(EntryKey.createUserEntryKey(ALICE, 
true)).rights(Right.Administer).asAddition()));
+
+        // Sanity check: Alice's effective rights no longer contain Administer
+        assertThat(storeRightManager.hasRight(mailbox, Right.Administer, 
aliceSession)).isFalse();
+
+        ACLCommand aclCommand = MailboxACL.command()
+            .forUser(ALICE)
+            .rights(Right.Read)
+            .asAddition();
+
+        
when(mockedMailboxMapper.findMailboxByPath(mailboxPath)).thenReturn(Mono.just(mailbox));
+
+        assertThatThrownBy(() -> 
storeRightManager.applyRightsCommand(mailboxPath, aclCommand, aliceSession))
+            .isInstanceOf(InsufficientRightsException.class);
+    }
+
+    @Test
+    void applyRightsCommandShouldNotThrowWhenEffectiveAdministerIsHeld() 
throws Exception {
+        // Baseline counterpart: with a positive Administer grant and no 
negating entry,
+        // the mutation is legitimately authorized.
+        MailboxPath mailboxPath = MailboxPath.forUser(BOB, "mailbox");
+        Mailbox mailbox = new Mailbox(mailboxPath, UID_VALIDITY, MAILBOX_ID);
+        mailbox.setACL(new MailboxACL(new MailboxACL.Entry(ALICE.asString(), 
Right.Lookup, Right.Administer)));
+
+        assertThat(storeRightManager.hasRight(mailbox, Right.Administer, 
aliceSession)).isTrue();
+
+        ACLCommand aclCommand = MailboxACL.command()
+            .forUser(ALICE)
+            .rights(Right.Read)
+            .asAddition();
+
+        
when(mockedMailboxMapper.findMailboxByPath(mailboxPath)).thenReturn(Mono.just(mailbox));
+        when(mockedMailboxMapper.updateACL(mailbox, 
aclCommand)).thenReturn(Mono.just(ACLDiff.computeDiff(MailboxACL.EMPTY, 
MailboxACL.EMPTY)));
+        when(eventBus.dispatch(any(Event.class), 
any(MailboxIdRegistrationKey.class))).thenReturn(Mono.empty());
+
+        assertThatCode(() -> storeRightManager.applyRightsCommand(mailboxPath, 
aclCommand, aliceSession))
+            .doesNotThrowAnyException();
+    }
+
+    @Test
+    void 
filteredForSessionShouldHideOtherEntriesWhenAdministerIsRemovedByANegativeAclEntry()
 throws UnsupportedRightException {
+        // BOB holds a raw positive Administer grant negated by an applicable 
negative ACL entry:
+        // his effective Administer right is false, so he must not be granted 
full-ACL visibility.
+        MailboxACL acl = new MailboxACL()
+            .apply(MailboxACL.command().forUser(BOB).rights(Right.Lookup, 
Right.Administer).asAddition())
+            .apply(MailboxACL.command().key(EntryKey.createUserEntryKey(BOB, 
true)).rights(Right.Administer).asAddition())
+            .apply(MailboxACL.command().rights(Right.Read, Right.Write, 
Right.Administer).forUser(CEDRIC).asAddition());
+
+        MailboxACL actual = storeRightManager.filteredForSession(
+            new Mailbox(INBOX_ALICE, UID_VALIDITY, MAILBOX_ID), acl, 
MailboxSessionUtil.create(BOB));
+
+        assertThat(actual.getEntries())
+            .doesNotContainKey(EntryKey.createUserEntryKey(CEDRIC));
+    }
 }
\ No newline at end of file
diff --git 
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailGetMethodContract.scala
 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailGetMethodContract.scala
index 54edc73ede..0823a95a44 100644
--- 
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailGetMethodContract.scala
+++ 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailGetMethodContract.scala
@@ -3147,6 +3147,67 @@ trait EmailGetMethodContract {
          |}""".stripMargin)
   }
 
+  @Test
+  def getShouldReturnNotFoundWhenReadRightIsRemovedByANegativeAclEntry(server: 
GuiceJamesServer): Unit = {
+    // Regression guard: Email/get gates on EFFECTIVE rights. Bob holds a raw 
positive Read right
+    // negated by an IMAP-style negative ACL entry, so the message must not be 
readable.
+    val andreMailbox: String = "andrecustom"
+    val path = MailboxPath.forUser(andreUsername, andreMailbox)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    val message: Message = Message.Builder
+      .of
+      .setSubject("test")
+      .setBody("testmail", StandardCharsets.UTF_8)
+      .build
+    val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(andreUsername.asString, path, AppendCommand.from(message))
+      .getMessageId
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(path, bobUsername.asString, new 
MailboxACL.Rfc4314Rights(Right.Lookup, Right.Read))
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(path, "-" + bobUsername.asString, new 
MailboxACL.Rfc4314Rights(Right.Read))
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [[
+         |    "Email/get",
+         |    {
+         |      "accountId": "$bobAccountId",
+         |      "ids": ["${messageId.serialize}"]
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response).isEqualTo(
+      s"""{
+         |    "sessionState": "${SESSION_STATE.value}",
+         |    "methodResponses": [[
+         |            "Email/get",
+         |            {
+         |                "accountId": "$bobAccountId",
+         |                "state": "${INSTANCE.value}",
+         |                "list": [],
+         |                "notFound": ["${messageId.serialize}"]
+         |            },
+         |            "c1"
+         |        ]]
+         |}""".stripMargin)
+  }
+
   @Test
   def emailGetShouldReturnUnknownMethodWhenMissingOneCapability(): Unit = {
     val messageId: MessageId = randomMessageId
diff --git 
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailSetMethodContract.scala
 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailSetMethodContract.scala
index 362035eb89..e33d80215a 100644
--- 
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailSetMethodContract.scala
+++ 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailSetMethodContract.scala
@@ -1514,6 +1514,66 @@ trait EmailSetMethodContract {
            |}]""".stripMargin)
   }
 
+  @Test
+  def createShouldFailWhenInsertRightIsRemovedByANegativeAclEntry(server: 
GuiceJamesServer): Unit = {
+    // Regression guard: unlike ACL mutation/visibility, Email/set gates on 
EFFECTIVE rights.
+    // Bob holds a raw positive Insert right negated by an IMAP-style negative 
ACL entry ("-bob i"),
+    // so his effective Insert is false and he must not be able to append into 
the delegated mailbox.
+    val andrePath = MailboxPath.inbox(andreUsername)
+    val mailboxId = 
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(andrePath)
+
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(andrePath, bobUsername.asString, new 
MailboxACL.Rfc4314Rights(Right.Insert, Right.Lookup, Right.Read))
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(andrePath, "-" + bobUsername.asString, new 
MailboxACL.Rfc4314Rights(Right.Insert))
+
+    val request =
+      s"""{
+         |  "using": ["urn:ietf:params:jmap:core", 
"urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "$bobAccountId",
+         |      "create": {
+         |        "aaaaaa":{
+         |          "mailboxIds": {
+         |             "${mailboxId.serialize}": true
+         |          }
+         |        }
+         |      }
+         |    }, "c1"], ["Email/get",
+         |     {
+         |       "accountId": "$bobAccountId",
+         |       "ids": ["#aaaaaa"],
+         |       "properties": ["mailboxIds"]
+         |     },
+         |     "c2"]]
+         |}""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    // The message must not have been created in the delegated mailbox...
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].notCreated.aaaaaa")
+      .isObject
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].created")
+      .isAbsent()
+    // ...and must not be retrievable.
+    assertThatJson(response)
+      .inPath("methodResponses[1][1].list")
+      .isEqualTo("[]")
+  }
+
   @Test
   def createShouldSupportHtmlBody(server: GuiceJamesServer): Unit = {
     val bobPath = MailboxPath.inbox(bobUsername)
@@ -5521,6 +5581,71 @@ trait EmailSetMethodContract {
            |}""".stripMargin)
   }
 
+  @Test
+  def 
shouldNotUpdateInDelegatedMailboxesWhenWriteRightIsRemovedByANegativeAclEntry(server:
 GuiceJamesServer): Unit = {
+    // Regression guard: Email/set update gates on EFFECTIVE rights. Bob holds 
a raw positive Write
+    // right negated by an IMAP-style negative ACL entry, so updating keywords 
must be rejected.
+    val andreMailbox: String = "andrecustom"
+    val andrePath = MailboxPath.forUser(andreUsername, andreMailbox)
+    server.getProbe(classOf[MailboxProbeImpl]).createMailbox(andrePath)
+    val message: Message = Message.Builder
+      .of
+      .setSender(bobUsername.asString())
+      .setFrom(andreUsername.asString())
+      .setSubject("test")
+      .setBody("testmail", StandardCharsets.UTF_8)
+      .build
+    val messageId: MessageId = server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(andreUsername.asString, andrePath, 
AppendCommand.from(message))
+      .getMessageId
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(andrePath, bobUsername.asString, 
MailboxACL.Rfc4314Rights.of(Set(Right.Read, Right.Lookup, Right.Write).asJava))
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(andrePath, "-" + bobUsername.asString, new 
MailboxACL.Rfc4314Rights(Right.Write))
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |  ["Email/set",
+         |    {
+         |      "accountId": "$bobAccountId",
+         |      "update": {
+         |        "${messageId.serialize}":{
+         |          "keywords": {
+         |             "music": true
+         |          }
+         |        }
+         |      }
+         |    },
+         |    "c1"]]
+         |}""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].notUpdated")
+      .isEqualTo(
+        s"""{
+           |  "${messageId.serialize}":{
+           |     "type": "notFound",
+           |     "description": "Mailbox not found"
+           |  }
+           |}""".stripMargin)
+  }
+
   @Test
   def shouldResetFlagsInDelegatedMailboxesWhenHadAtLeastWriteRight(server: 
GuiceJamesServer): Unit = {
     val andreMailbox: String = "andrecustom"
@@ -6454,6 +6579,72 @@ trait EmailSetMethodContract {
            |}""".stripMargin)
   }
 
+  @Test
+  def 
emailSetDestroyShouldFailWhenDeleteRightIsRemovedByANegativeAclEntry(server: 
GuiceJamesServer): Unit = {
+    // Regression guard: Email/set destroy gates on EFFECTIVE rights. Bob 
holds a raw positive
+    // DeleteMessages right negated by an IMAP-style negative ACL entry, so 
destroy must be rejected.
+    val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
+
+    val andreMailbox: String = "andrecustom"
+    val path = MailboxPath.forUser(andreUsername, andreMailbox)
+    mailboxProbe.createMailbox(path)
+
+    val messageId: MessageId = mailboxProbe
+      .appendMessage(andreUsername.asString, path,
+        AppendCommand.from(
+          buildTestMessage))
+      .getMessageId
+
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(path, bobUsername.asString, new 
MailboxACL.Rfc4314Rights(Right.Read, Right.Lookup, Right.DeleteMessages))
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(path, "-" + bobUsername.asString, new 
MailboxACL.Rfc4314Rights(Right.DeleteMessages))
+
+    val request =
+      s"""{
+         |  "using": [
+         |    "urn:ietf:params:jmap:core",
+         |    "urn:ietf:params:jmap:mail"],
+         |  "methodCalls": [
+         |    ["Email/set", {
+         |      "accountId": "$bobAccountId",
+         |      "destroy": ["${messageId.serialize}"]
+         |    }, "c1"],
+         |    ["Email/get", {
+         |      "accountId": "$bobAccountId",
+         |      "ids": ["${messageId.serialize}"],
+         |      "properties": ["id"]
+         |    }, "c2"]]
+         |}""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    // The destroy is rejected...
+    assertThatJson(response)
+      .inPath("methodResponses[0][1].notDestroyed")
+      .isEqualTo(
+        s"""{
+           |  "${messageId.serialize}": {
+           |    "type": "notFound",
+           |    "description": "Cannot find message with messageId: 
${messageId.serialize}"
+           |  }
+           |}""".stripMargin)
+    // ...and the message is still there (Bob keeps the Read right).
+    assertThatJson(response)
+      .inPath("methodResponses[1][1].list")
+      .isEqualTo(s"""[{ "id": "${messageId.serialize}" }]""")
+  }
+
   @Test
   def emailSetDestroyShouldDestroyEmailWhenShareeHasDeleteRight(server: 
GuiceJamesServer): Unit = {
     val mailboxProbe = server.getProbe(classOf[MailboxProbeImpl])
diff --git 
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxGetMethodContract.scala
 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxGetMethodContract.scala
index 5485b9227c..133603c01d 100644
--- 
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxGetMethodContract.scala
+++ 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxGetMethodContract.scala
@@ -851,6 +851,144 @@ trait MailboxGetMethodContract {
       .body(s"$FIRST_MAILBOX.rights['$targetUser2']", contains(LOOKUP, READ))
   }
 
+  @Test
+  def 
getMailboxesShouldNotLeakFullAclWhenEffectiveAdministerRightIsRemovedByANegativeAclEntry(server:
 GuiceJamesServer): Unit = {
+    val otherUser: String = "touser@" + DOMAIN.asString
+    val mailboxName: String = "AndreShared"
+    val andreMailboxPath = MailboxPath.forUser(andreUsername, mailboxName)
+    val mailboxId: String = server.getProbe(classOf[MailboxProbeImpl])
+      .createMailbox(andreMailboxPath)
+      .serialize
+
+    // Bob holds a raw positive Administer right negated by an IMAP-style 
negative ACL entry:
+    // his effective Administer is false, so he must not be granted full-ACL 
visibility.
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(andreMailboxPath, bobUsername.asString, new 
MailboxACL.Rfc4314Rights(Right.Lookup, Right.Administer))
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(andreMailboxPath, "-" + bobUsername.asString, new 
MailboxACL.Rfc4314Rights(Right.Administer))
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(andreMailboxPath, otherUser, new 
MailboxACL.Rfc4314Rights(Right.Read, Right.Lookup))
+
+    val response: String = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(s"""{
+               |  "using": [
+               |    "urn:ietf:params:jmap:core",
+               |    "urn:ietf:params:jmap:mail",
+               |    "urn:apache:james:params:jmap:mail:shares"],
+               |  "methodCalls": [[
+               |      "Mailbox/get",
+               |      {
+               |        "accountId": "$bobAccountId",
+               |        "ids": ["${mailboxId}"]
+               |      },
+               |      "c1"]]
+               |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .extract
+      .body
+      .asString
+
+    // The mailbox is visible to Bob (he keeps the Lookup right), but Bob (no 
effective Administer)
+    // must only see his own ACL entry, not the other sharee's entry.
+    assertThatJson(response)
+      .withOptions(Option.IGNORING_ARRAY_ORDER)
+      .inPath("methodResponses[0][1].list[0].rights")
+      .isEqualTo(s"""{ "${bobUsername.asString}": [ "a", "l" ] }""")
+  }
+
+  @Test
+  def 
getMailboxesShouldReturnNotFoundWhenLookupRightIsRemovedByANegativeAclEntry(server:
 GuiceJamesServer): Unit = {
+    // Regression guard: a mailbox is exposed only when the user effectively 
keeps Lookup.
+    // Bob holds raw positive Lookup+Read negated by IMAP-style negative ACL 
entries, so the
+    // delegated mailbox must no longer be visible to him.
+    val mailboxName: String = "AndreShared"
+    val andreMailboxPath = MailboxPath.forUser(andreUsername, mailboxName)
+    val mailboxId: String = server.getProbe(classOf[MailboxProbeImpl])
+      .createMailbox(andreMailboxPath)
+      .serialize
+
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(andreMailboxPath, bobUsername.asString, new 
MailboxACL.Rfc4314Rights(Right.Lookup, Right.Read))
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(andreMailboxPath, "-" + bobUsername.asString, new 
MailboxACL.Rfc4314Rights(Right.Lookup, Right.Read))
+
+    `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(s"""{
+               |  "using": [
+               |    "urn:ietf:params:jmap:core",
+               |    "urn:ietf:params:jmap:mail",
+               |    "urn:apache:james:params:jmap:mail:shares"],
+               |  "methodCalls": [[
+               |      "Mailbox/get",
+               |      {
+               |        "accountId": "$bobAccountId",
+               |        "ids": ["${mailboxId}"]
+               |      },
+               |      "c1"]]
+               |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .body(s"$ARGUMENTS.list", hasSize(0))
+      .body(s"$ARGUMENTS.notFound", hasSize(1))
+      .body(s"$ARGUMENTS.notFound[0]", equalTo(mailboxId))
+  }
+
+  @Test
+  def 
getMailboxesShouldNotDiscloseCountsWhenReadRightIsRemovedByANegativeAclEntry(server:
 GuiceJamesServer): Unit = {
+    // Regression guard: mailbox counts are disclosed only when the user 
effectively keeps Read.
+    // Bob keeps Lookup (the mailbox stays visible) but his Read is negated by 
an IMAP-style
+    // negative ACL entry, so total/unread counts must be hidden (reported as 
0).
+    val mailboxName: String = "AndreShared"
+    val andreMailboxPath = MailboxPath.forUser(andreUsername, mailboxName)
+    val mailboxId: String = server.getProbe(classOf[MailboxProbeImpl])
+      .createMailbox(andreMailboxPath)
+      .serialize
+
+    val message: Message = Message.Builder
+      .of
+      .setSubject("test")
+      .setBody("testmail", StandardCharsets.UTF_8)
+      .build
+    server.getProbe(classOf[MailboxProbeImpl])
+      .appendMessage(andreUsername.asString, andreMailboxPath, 
AppendCommand.from(message))
+
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(andreMailboxPath, bobUsername.asString, new 
MailboxACL.Rfc4314Rights(Right.Lookup, Right.Read))
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(andreMailboxPath, "-" + bobUsername.asString, new 
MailboxACL.Rfc4314Rights(Right.Read))
+
+    `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(s"""{
+               |  "using": [
+               |    "urn:ietf:params:jmap:core",
+               |    "urn:ietf:params:jmap:mail",
+               |    "urn:apache:james:params:jmap:mail:shares"],
+               |  "methodCalls": [[
+               |      "Mailbox/get",
+               |      {
+               |        "accountId": "$bobAccountId",
+               |        "ids": ["${mailboxId}"]
+               |      },
+               |      "c1"]]
+               |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .body(s"$ARGUMENTS.list", hasSize(1))
+      .body(s"$FIRST_MAILBOX.namespace", 
equalTo(s"Delegated[${andreUsername.asString}]"))
+      .body(s"$FIRST_MAILBOX.totalEmails", equalTo(0))
+      .body(s"$FIRST_MAILBOX.unreadEmails", equalTo(0))
+  }
+
   @Test
   def getMailboxesShouldReturnDeleteMailboxRight(server: GuiceJamesServer): 
Unit = {
     val targetUser2: String = "touser2@" + DOMAIN.asString
diff --git 
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala
 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala
index 1b11433f4b..c2f13885fd 100644
--- 
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala
+++ 
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala
@@ -2202,6 +2202,73 @@ trait MailboxSetMethodContract {
          |}""".stripMargin)
   }
 
+  @Test
+  def 
mailboxSetShouldNotCreateChildMailboxWhenCreateMailboxRightIsRemovedByANegativeAclEntry(server:
 GuiceJamesServer): Unit = {
+    // Regression guard: child creation gates on EFFECTIVE CreateMailbox. Bob 
holds a raw positive
+    // CreateMailbox right negated by an IMAP-style negative ACL entry, so it 
must not be honoured.
+    val path = MailboxPath.forUser(andreUsername, "mailbox")
+    val mailboxId: MailboxId = 
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(path, bobUsername.asString, new 
MailboxACL.Rfc4314Rights(Right.Lookup, Right.Read, Right.CreateMailbox))
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(path, "-" + bobUsername.asString, new 
MailboxACL.Rfc4314Rights(Right.CreateMailbox))
+    val request =
+      s"""
+        |{
+        |   "using": [ "urn:ietf:params:jmap:core", 
"urn:ietf:params:jmap:mail", "urn:apache:james:params:jmap:mail:shares" ],
+        |   "methodCalls": [
+        |       [
+        |           "Mailbox/set",
+        |           {
+        |                "accountId": "$bobAccountId",
+        |                "create": {
+        |                    "C42": {
+        |                      "name": "childMailbox",
+        |                      "parentId":"${mailboxId.serialize}"
+        |                    }
+        |                }
+        |           },
+        |    "c1"
+        |       ]
+        |   ]
+        |}
+        |""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .log().ifValidationFails()
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .whenIgnoringPaths("methodResponses[0][1].newState", 
"methodResponses[0][1].oldState")
+      .isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [[
+         |    "Mailbox/set",
+         |    {
+         |      "accountId": "$bobAccountId",
+         |      "notCreated": {
+         |        "C42": {
+         |          "type": "forbidden",
+         |          "description": "Insufficient rights",
+         |          "properties":["parentId"]
+         |        }
+         |      }
+         |    },
+         |    "c1"]]
+         |}""".stripMargin)
+  }
+
   @Test
   def 
mailboxSetShouldCreateChildMailboxWhenSharedParentMailboxWithCreateRight(server:
 GuiceJamesServer): Unit = {
     val path = MailboxPath.forUser(andreUsername, "mailbox")
@@ -2789,6 +2856,45 @@ trait MailboxSetMethodContract {
       .body("methodResponses[0][1].notDestroyed." + mailboxId.serialize + 
".type", equalTo("invalidArguments"))
   }
 
+  @Test
+  def 
deleteSharedMailboxShouldFailWhenDeleteMailboxRightIsRemovedByANegativeAclEntry(server:
 GuiceJamesServer): Unit = {
+    // Regression guard: Mailbox/set destroy gates on EFFECTIVE rights. Bob 
holds a raw positive
+    // DeleteMailbox right negated by an IMAP-style negative ACL entry, so it 
must not be honoured.
+    val path = MailboxPath.forUser(andreUsername, "mailbox")
+    val mailboxId: MailboxId = 
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(path, bobUsername.asString, new 
MailboxACL.Rfc4314Rights(Right.Lookup, Right.Read, Right.DeleteMailbox))
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(path, "-" + bobUsername.asString, new 
MailboxACL.Rfc4314Rights(Right.DeleteMailbox))
+
+    `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(
+        s"""
+           |{
+           |   "using": [ "urn:ietf:params:jmap:core", 
"urn:ietf:params:jmap:mail", "urn:apache:james:params:jmap:mail:shares" ],
+           |   "methodCalls": [
+           |       [
+           |           "Mailbox/set",
+           |           {
+           |                "accountId": "$bobAccountId",
+           |                "destroy": ["${mailboxId.serialize}"]
+           |           },
+           |    "c1"
+           |       ]
+           |   ]
+           |}
+           |""".stripMargin)
+    .when
+      .post
+    .`then`
+      .log().ifValidationFails()
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .body("methodResponses[0][1].notDestroyed", hasKey(mailboxId.serialize))
+      .body("methodResponses[0][1].notDestroyed." + mailboxId.serialize + 
".type", equalTo("invalidArguments"))
+  }
+
   @Test
   def deleteShouldHandleInvalidMailboxId(): Unit = {
     val request =
@@ -6127,6 +6233,80 @@ trait MailboxSetMethodContract {
       .doesNotContainKeys(EntryKey.createUserEntryKey(andreUsername))
   }
 
+  @Test
+  def 
updateShouldFailWhenEffectiveAdministerRightIsRemovedByANegativeAclEntry(server:
 GuiceJamesServer): Unit = {
+    // Andre owns the mailbox and delegates it to Bob.
+    val path = MailboxPath.forUser(andreUsername, "mailbox")
+    val mailboxId = 
server.getProbe(classOf[MailboxProbeImpl]).createMailbox(path)
+
+    // Bob is granted a raw positive Administer right...
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(path, bobUsername.asString(), new 
MailboxACL.Rfc4314Rights(Right.Lookup, Right.Administer))
+    // ...but an IMAP-style negative ACL entry ("-bob a") removes Administer 
from his effective rights.
+    // JMAP does not let users set negative entries, but it must still enforce 
those defined through IMAP.
+    server.getProbe(classOf[ACLProbeImpl])
+      .replaceRights(path, "-" + bobUsername.asString(), new 
MailboxACL.Rfc4314Rights(Right.Administer))
+
+    val request =
+      s"""
+         |{
+         |   "using": [ "urn:ietf:params:jmap:core", 
"urn:ietf:params:jmap:mail", "urn:apache:james:params:jmap:mail:shares" ],
+         |   "methodCalls": [
+         |       [
+         |           "Mailbox/set",
+         |           {
+         |                "accountId": "$bobAccountId",
+         |                "update": {
+         |                    "${mailboxId.serialize}": {
+         |                      "sharedWith": {
+         |                        "${DAVID.asString()}":["r", "l"]
+         |                      }
+         |                    }
+         |                }
+         |           },
+         |    "c1"
+         |       ]
+         |   ]
+         |}
+         |""".stripMargin
+
+    val response = `given`
+      .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .body(request)
+    .when
+      .post
+    .`then`
+      .log().ifValidationFails()
+      .statusCode(SC_OK)
+      .contentType(JSON)
+      .extract
+      .body
+      .asString
+
+    assertThatJson(response)
+      .whenIgnoringPaths("methodResponses[0][1].oldState", 
"methodResponses[0][1].newState")
+      .isEqualTo(
+      s"""{
+         |  "sessionState": "${SESSION_STATE.value}",
+         |  "methodResponses": [
+         |    ["Mailbox/set", {
+         |      "accountId": "$bobAccountId",
+         |      "notUpdated": {
+         |        "${mailboxId.serialize}": {
+         |          "type": "forbidden",
+         |          "description": "Invalid change to a delegated mailbox"
+         |        }
+         |      }
+         |    }, "c1"]
+         |  ]
+         |}""".stripMargin)
+
+    // The ACL mutation must not have happened: David has not been granted any 
right.
+    assertThat(server.getProbe(classOf[ACLProbeImpl]).retrieveRights(path)
+      .getEntries)
+      .doesNotContainKeys(EntryKey.createUserEntryKey(DAVID))
+  }
+
   @Test
   def updateShouldFailOnOthersMailbox(server: GuiceJamesServer): Unit = {
     val path = MailboxPath.forUser(andreUsername, "mailbox")


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


Reply via email to