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]