This is an automated email from the ASF dual-hosted git repository.
Arsnael 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 bbce4eae6d [ENHANCEMENT] BlobResolver::validateAccess allow to
validate user access to a blobId without fetch the full content (#3051)
bbce4eae6d is described below
commit bbce4eae6df4e6b4596c3ffeb17b3568222d476b
Author: Trần Hồng Quân <[email protected]>
AuthorDate: Thu May 28 08:45:17 2026 +0700
[ENHANCEMENT] BlobResolver::validateAccess allow to validate user access to
a blobId without fetch the full content (#3051)
---
.../upload/CassandraUploadRepository.java | 6 ++
.../postgres/upload/PostgresUploadRepository.java | 6 ++
.../james/jmap/api/upload/UploadRepository.java | 2 +
.../james/jmap/api/upload/UploadService.java | 2 +
.../jmap/api/upload/UploadServiceDefaultImpl.java | 5 ++
.../memory/upload/InMemoryUploadRepository.java | 10 +++
.../jmap/api/upload/UploadRepositoryContract.scala | 34 ++++++++-
.../rfc8621/contract/CustomMethodContract.scala | 4 +
.../apache/james/jmap/routes/DownloadRoutes.scala | 86 +++++++++++++++++-----
9 files changed, 136 insertions(+), 19 deletions(-)
diff --git
a/server/data/data-jmap-cassandra/src/main/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepository.java
b/server/data/data-jmap-cassandra/src/main/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepository.java
index a83c9eecc0..c88659fca5 100644
---
a/server/data/data-jmap-cassandra/src/main/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepository.java
+++
b/server/data/data-jmap-cassandra/src/main/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepository.java
@@ -82,6 +82,12 @@ public class CassandraUploadRepository implements
UploadRepository {
.switchIfEmpty(Mono.error(() -> new UploadNotFoundException(id)));
}
+ @Override
+ public Mono<Boolean> validateAccess(UploadId id, Username user) {
+ return uploadDAO.retrieve(user, id)
+ .hasElement();
+ }
+
@Override
public Mono<Boolean> delete(UploadId id, Username user) {
return uploadDAO.delete(user, id);
diff --git
a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java
b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java
index fbec035da7..ca6c2ecbaa 100644
---
a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java
+++
b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java
@@ -88,6 +88,12 @@ public class PostgresUploadRepository implements
UploadRepository {
.switchIfEmpty(Mono.error(() -> new UploadNotFoundException(id)));
}
+ @Override
+ public Mono<Boolean> validateAccess(UploadId id, Username user) {
+ return uploadDAOFactory.create(user.getDomainPart()).get(id, user)
+ .hasElement();
+ }
+
@Override
public Mono<Boolean> delete(UploadId id, Username user) {
return uploadDAOFactory.create(user.getDomainPart()).delete(id, user);
diff --git
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadRepository.java
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadRepository.java
index 60c7d207ac..568688bd4b 100644
---
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadRepository.java
+++
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadRepository.java
@@ -34,6 +34,8 @@ public interface UploadRepository {
Publisher<Upload> retrieve(UploadId id, Username user);
+ Publisher<Boolean> validateAccess(UploadId id, Username user);
+
Publisher<Boolean> delete(UploadId id, Username user);
Publisher<UploadMetaData> listUploads(Username user);
diff --git
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadService.java
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadService.java
index 46eee4c2ea..f4185a40e0 100644
---
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadService.java
+++
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadService.java
@@ -32,4 +32,6 @@ public interface UploadService {
Publisher<UploadMetaData> upload(InputStream data, ContentType
contentType, Username user);
Publisher<Upload> retrieve(UploadId id, Username user);
+
+ Publisher<Boolean> validateAccess(UploadId id, Username user);
}
diff --git
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadServiceDefaultImpl.java
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadServiceDefaultImpl.java
index e981c9459c..4df983bd84 100644
---
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadServiceDefaultImpl.java
+++
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadServiceDefaultImpl.java
@@ -71,6 +71,11 @@ public class UploadServiceDefaultImpl implements
UploadService {
return uploadRepository.retrieve(id, user);
}
+ @Override
+ public Publisher<Boolean> validateAccess(UploadId id, Username user) {
+ return uploadRepository.validateAccess(id, user);
+ }
+
private Mono<Void> cleanupUploadIfNeeded(Username username, UploadMetaData
notCleanUpload) {
return Mono.from(uploadUsageRepository.getSpaceUsage(username))
.map(QuotaSizeUsage::asLong)
diff --git
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/memory/upload/InMemoryUploadRepository.java
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/memory/upload/InMemoryUploadRepository.java
index 72788eb724..523d7f3351 100644
---
a/server/data/data-jmap/src/main/java/org/apache/james/jmap/memory/upload/InMemoryUploadRepository.java
+++
b/server/data/data-jmap/src/main/java/org/apache/james/jmap/memory/upload/InMemoryUploadRepository.java
@@ -98,6 +98,16 @@ public class InMemoryUploadRepository implements
UploadRepository {
.switchIfEmpty(Mono.error(() -> new UploadNotFoundException(id)));
}
+ @Override
+ public Publisher<Boolean> validateAccess(UploadId id, Username user) {
+ Preconditions.checkNotNull(id);
+ Preconditions.checkNotNull(user);
+
+ return Mono.justOrEmpty(uploadStore.get(id))
+ .filter(pair -> user.equals(pair.left))
+ .hasElement();
+ }
+
@Override
public Publisher<Boolean> delete(UploadId id, Username user) {
return Mono.justOrEmpty(uploadStore.get(id))
diff --git
a/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala
b/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala
index 2bdb85804e..774298c7bd 100644
---
a/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala
+++
b/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala
@@ -29,7 +29,7 @@
import org.apache.james.core.Username
import org.apache.james.jmap.api.model.Size.sanitizeSize
import org.apache.james.jmap.api.model.{Upload, UploadId, UploadMetaData,
UploadNotFoundException}
- import
org.apache.james.jmap.api.upload.UploadRepositoryContract.{CONTENT_TYPE,
DATA_STRING, UPLOAD_BUCKET, USER}
+ import org.apache.james.jmap.api.upload.UploadRepositoryContract.{ALICE,
CONTENT_TYPE, DATA_STRING, UPLOAD_BUCKET, USER}
import org.apache.james.mailbox.model.ContentType
import org.apache.james.utils.UpdatableTickingClock
import org.assertj.core.api.Assertions.{assertThat, assertThatCode,
assertThatThrownBy}
@@ -43,6 +43,7 @@
private lazy val CONTENT_TYPE: ContentType = ContentType
.of("text/html")
private lazy val DATA_STRING: String = "123321"
+ private lazy val ALICE: Username = Username.of("Alice")
private lazy val USER: Username = Username.of("Bob")
private lazy val UPLOAD_BUCKET: BucketName = BucketName.of("jmap-uploads")
}
@@ -59,6 +60,37 @@
def data(): InputStream = IOUtils.toInputStream(DATA_STRING,
StandardCharsets.UTF_8)
+ @Test
+ def validateAccessShouldReturnTrueWhenUploadBelongsToUser(): Unit = {
+ val uploadId: UploadId = SMono.fromPublisher(testee.upload(data(),
CONTENT_TYPE, USER)).block().uploadId
+
+ assertThat(SMono.fromPublisher(testee.validateAccess(uploadId,
USER)).block())
+ .isTrue
+ }
+
+ @Test
+ def validateAccessShouldReturnFalseWhenUploadDoesNotExist(): Unit =
+ assertThat(SMono.fromPublisher(testee.validateAccess(randomUploadId(),
USER)).block())
+ .isFalse
+
+ @Test
+ def validateAccessShouldReturnFalseWhenUploadBelongsToAnotherUser(): Unit =
{
+ val uploadId: UploadId = SMono.fromPublisher(testee.upload(data(),
CONTENT_TYPE, ALICE)).block().uploadId
+
+ assertThat(SMono.fromPublisher(testee.validateAccess(uploadId,
USER)).block())
+ .isFalse
+ }
+
+ @Test
+ def validateAccessShouldReturnFalseWhenUploadWasDeleted(): Unit = {
+ val uploadId: UploadId = SMono.fromPublisher(testee.upload(data(),
CONTENT_TYPE, USER)).block().uploadId
+
+ SMono.fromPublisher(testee.delete(uploadId, USER)).block()
+
+ assertThat(SMono.fromPublisher(testee.validateAccess(uploadId,
USER)).block())
+ .isFalse
+ }
+
@Test
def uploadShouldSuccess(): Unit = {
val uploadId: UploadId = SMono.fromPublisher(testee.upload(data(),
CONTENT_TYPE, USER)).block().uploadId
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/CustomMethodContract.scala
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/CustomMethodContract.scala
index 9d1a0864be..a35a7a0eaa 100644
---
a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/CustomMethodContract.scala
+++
b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/CustomMethodContract.scala
@@ -20,6 +20,7 @@
package org.apache.james.jmap.rfc8621.contract
import java.io.{ByteArrayInputStream, InputStream}
+import java.lang.Boolean.valueOf
import java.net.URI
import java.nio.charset.StandardCharsets
@@ -228,6 +229,9 @@ class CustomBlobResolver extends BlobResolver {
} else {
SMono.just(NonApplicable)
}
+
+ override def validateAccess(blobId: org.apache.james.jmap.mail.BlobId,
mailboxSession: MailboxSession): SMono[java.lang.Boolean] =
+ SMono.just(valueOf(blobId.equals(CustomBlob.blobId)))
}
class CustomMethod extends Method {
diff --git
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/DownloadRoutes.scala
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/DownloadRoutes.scala
index ffbd68c5cb..a91155f186 100644
---
a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/DownloadRoutes.scala
+++
b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/DownloadRoutes.scala
@@ -19,6 +19,7 @@
package org.apache.james.jmap.routes
import java.io.InputStream
+import java.lang.Boolean.{FALSE, TRUE, valueOf}
import java.nio.charset.StandardCharsets
import java.util.concurrent.Callable
import java.util.function.Consumer
@@ -87,6 +88,8 @@ case class Applicable(blob: SMono[Blob]) extends
BlobResolutionResult {
trait BlobResolver {
def resolve(blobId: BlobId, mailboxSession: MailboxSession):
Publisher[BlobResolutionResult]
+
+ def validateAccess(blobId: BlobId, mailboxSession: MailboxSession):
Publisher[java.lang.Boolean]
}
trait Blob {
@@ -157,41 +160,61 @@ case class EmailBodyPartBlob(blobId: BlobId, part:
MinimalEmailBodyPart) extends
class MessageBlobResolver @Inject()(val messageIdFactory: MessageId.Factory,
val messageIdManager: MessageIdManager)
extends BlobResolver {
+ private def asMessageId(blobId: BlobId): Option[MessageId] =
+ Try(messageIdFactory.fromString(blobId.value.value)).toOption
+
override def resolve(blobId: BlobId, mailboxSession: MailboxSession):
SMono[BlobResolutionResult] = {
- Try(messageIdFactory.fromString(blobId.value.value)) match {
- case Failure(_) => SMono.just(NonApplicable)
- case Success(messageId) =>
SMono.fromPublisher(messageIdManager.getMessagesReactive(List(messageId).asJava,
FetchGroup.FULL_CONTENT, mailboxSession))
+ asMessageId(blobId) match {
+ case None => SMono.just(NonApplicable)
+ case Some(messageId) =>
SMono.fromPublisher(messageIdManager.getMessagesReactive(List(messageId).asJava,
FetchGroup.FULL_CONTENT, mailboxSession))
.map(message => Applicable(SMono.just(MessageBlob(blobId, message))))
.switchIfEmpty(SMono.just(NonApplicable))
}
}
+
+ override def validateAccess(blobId: BlobId, mailboxSession: MailboxSession):
SMono[java.lang.Boolean] =
+ asMessageId(blobId) match {
+ case None => SMono.just(FALSE)
+ case Some(messageId) =>
SFlux.fromPublisher(messageIdManager.getMessagesReactive(List(messageId).asJava,
FetchGroup.MINIMAL, mailboxSession))
+ .hasElements
+ .map(valueOf)
+ }
}
class UploadResolver @Inject()(val uploadService: UploadService) extends
BlobResolver {
private val prefix = "uploads-"
+ private def asUploadId(blobId: BlobId): Option[UploadId] =
+ Option(blobId.value.value)
+ .filter(_.startsWith(prefix))
+ .flatMap(value =>
Try(UploadId.from(value.substring(prefix.length))).toOption)
+
override def resolve(blobId: BlobId, mailboxSession: MailboxSession):
SMono[BlobResolutionResult] = {
- if (!blobId.value.value.startsWith(prefix)) {
- SMono.just(NonApplicable)
- } else {
- val uploadIdAsString = blobId.value.value.substring(prefix.length)
- Try(UploadId.from(uploadIdAsString)) match {
- case Failure(_) => SMono.just(NonApplicable)
- case Success(uploadId) =>
SMono.fromPublisher(uploadService.retrieve(uploadId, mailboxSession.getUser))
- .map(upload => Applicable(SMono.just(UploadedBlob(blobId,
upload))))
- .onErrorResume {
- case _: UploadNotFoundException => SMono.just(NonApplicable)
- case e => SMono.error[BlobResolutionResult](e)
- }
+ asUploadId(blobId) match {
+ case None => SMono.just(NonApplicable)
+ case Some(uploadId) =>
SMono.fromPublisher(uploadService.retrieve(uploadId, mailboxSession.getUser))
+ .map(upload => Applicable(SMono.just(UploadedBlob(blobId, upload))))
+ .onErrorResume {
+ case _: UploadNotFoundException => SMono.just(NonApplicable)
+ case e => SMono.error[BlobResolutionResult](e)
}
}
}
+
+ override def validateAccess(blobId: BlobId, mailboxSession: MailboxSession):
SMono[java.lang.Boolean] =
+ asUploadId(blobId) match {
+ case None => SMono.just(FALSE)
+ case Some(uploadId) =>
SMono.fromPublisher(uploadService.validateAccess(uploadId,
mailboxSession.getUser))
+ }
}
class AttachmentBlobResolver @Inject()(val attachmentManager:
AttachmentManager, val attachmentIdFactory: AttachmentIdFactory) extends
BlobResolver {
+ private def asAttachmentId(blobId: BlobId): Option[AttachmentId] =
+ Try(attachmentIdFactory.from(blobId.value.value)).toOption
+
override def resolve(blobId: BlobId, mailboxSession: MailboxSession):
SMono[BlobResolutionResult] =
- Try(attachmentIdFactory.from(blobId.value.value)) match {
- case Success(attachmentId) =>
+ asAttachmentId(blobId) match {
+ case Some(attachmentId) =>
SMono(attachmentManager.getAttachmentReactive(attachmentId,
mailboxSession))
.map(attachmentMetadata =>
Applicable(SMono(attachmentManager.loadReactive(attachmentMetadata,
mailboxSession))
.map(content => AttachmentBlob(attachmentMetadata, content))))
@@ -199,7 +222,19 @@ class AttachmentBlobResolver @Inject()(val
attachmentManager: AttachmentManager,
case e: AttachmentNotFoundException =>
SMono.just(NonApplicable.asInstanceOf[BlobResolutionResult])
case e => SMono.error[BlobResolutionResult](e)
}
- case _ => SMono.just(NonApplicable)
+ case None => SMono.just(NonApplicable)
+ }
+
+ override def validateAccess(blobId: BlobId, mailboxSession: MailboxSession):
SMono[java.lang.Boolean] =
+ asAttachmentId(blobId) match {
+ case Some(attachmentId) =>
+ SMono(attachmentManager.getAttachmentReactive(attachmentId,
mailboxSession))
+ .map(_ => TRUE)
+ .onErrorResume {
+ case _: AttachmentNotFoundException => SMono.just(FALSE)
+ case e => SMono.error[java.lang.Boolean](e)
+ }
+ case None => SMono.just(FALSE)
}
}
@@ -244,6 +279,14 @@ class MessagePartBlobResolver @Inject()(val
messageIdFactory: MessageId.Factory,
.switchIfEmpty(SMono.just(NonApplicable))
}
}
+
+ override def validateAccess(blobId: BlobId, mailboxSession: MailboxSession):
SMono[java.lang.Boolean] =
+ asMessageAndPartIds(blobId) match {
+ case Failure(_) => SMono.just(FALSE)
+ case Success((messageId, _)) =>
SFlux.fromPublisher(messageIdManager.getMessagesReactive(List(messageId).asJava,
FetchGroup.MINIMAL, mailboxSession))
+ .hasElements
+ .map(valueOf)
+ }
}
class BlobResolvers(blobResolvers: Set[BlobResolver]) {
@@ -262,6 +305,13 @@ class BlobResolvers(blobResolvers: Set[BlobResolver]) {
}
.concatMap(result =>
result.asOption.getOrElse(SMono.error(BlobNotFoundException(blobId))))
.next().switchIfEmpty(SMono.error(BlobNotFoundException(blobId)))
+
+ def validateAccess(blobId: BlobId, mailboxSession: MailboxSession):
SMono[java.lang.Boolean] =
+ SFlux.fromIterable(blobResolvers)
+ .concatMap(resolver => resolver.validateAccess(blobId, mailboxSession))
+ .filter(_.booleanValue())
+ .next()
+ .switchIfEmpty(SMono.just(FALSE))
}
class DownloadRoutes @Inject()(@Named(InjectionKeys.RFC_8621) val
authenticator: Authenticator,
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]