I'm using Spring-Boot 2.5.0 and MongoDB to persist some documents. Here the Github Project.
For each document I also need to automatically save some auditing info, therefore I extend the following class:
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import org.springframework.data.annotation.*;
import java.time.Instant;
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
public abstract class AuditingDocument {
@Version
private Long version;
@CreatedBy
private String creator;
@CreatedDate
private Instant created;
@LastModifiedBy
private String modifier;
@LastModifiedDate
private Instant modified;
}
E.g. let's consider the Book
class:
@Data
@SuperBuilder
@Document
@NoArgsConstructor
@AllArgsConstructor
public class Book extends AuditingDocument {
@Id
private String id;
private String name;
}
The problem I'm encountering is that when I update the document, via JSON
/REST API
,
I am able to alter/overwrite the value of the @CreatedBy
and @CreatedDate
fields.
Meaning that if the fields are not provided then the resulting values will be saved as null, otherwise, it will save the new value for the creator and created fields.
This should not be allowed since it represents a security issue in most use cases. How can I make these two fields not updatable? If the creator is present there is no need to update it later. Such values are automatically populated hence there could be no error requiring to update the value.
I found other similar questions but they are about JPA not MongoDB, e.g.
Here they use
@Column(name = "created_by", updatable = false)
to protect the fields from updates.
Unfortunately, the @Field
for MongoDB has no such property.
How can I protect all such fields from being modified after they are already present in the database? Obviously, I need a solution that is able to scale with all the @Document
entities without needing to handle each one separately, e.g. by reading it manually from the DB and fixing the document to be saved first.
UPDATE
I'm trying to implement this behaviour by overriding the doUpdate
method in a MongoTemplate
subclass.
public class CustomMongoTemplate extends MongoTemplate {
public CustomMongoTemplate(MongoClient mongoClient, String databaseName) {
super(mongoClient, databaseName);
}
public CustomMongoTemplate(MongoDatabaseFactory mongoDbFactory) {
super(mongoDbFactory);
}
public CustomMongoTemplate(MongoDatabaseFactory mongoDbFactory, MongoConverter mongoConverter) {
super(mongoDbFactory, mongoConverter);
}
@Override
protected UpdateResult doUpdate(String collectionName, Query query, UpdateDefinition update, Class<?> entityClass, boolean upsert, boolean multi) {
Document updateDocument = update.getUpdateObject();
List<?> list = this.find(query, entityClass);
if (!list.isEmpty()) {
Object existingObject = list.get(0);
Document existingDocument = new Document();
this.getConverter().write(existingObject, existingDocument);
// Keep the values of the existing document
if (existingDocument.keySet().containsAll(Arrays.asList("version", "creator", "created"))) {
// Long version = existingDocument.getLong("version");
String creator = existingDocument.getString("creator");
Date created = existingDocument.getDate("created");
System.out.println("Creator: " + creator);
System.out.println("Created: " + created);
// updateDocument.put("version", version++);
updateDocument.put("creator", creator);
updateDocument.put("created", created);
System.out.println("Update Document");
System.out.println(updateDocument.toJson());
}
return super.doUpdate(collectionName, query, Update.fromDocument(updateDocument), entityClass, upsert, multi);
} else {
return super.doUpdate(collectionName, query, update, entityClass, upsert, multi);
}
}
}
This approach is partially working, meaning that after I call the save method of a repository, the update's object does not overwrite the existing creator and created fields, however for some reason the save method returns an object with null values for creator and created, even if in the database the document has such values.
I also tried to get all the documents of the collection at once and their values (creator, created) are correctly populated and returned by the API endpoint. It seems like the doUpdate()
method is messing up with something, but I can't get with wath.
UPDATE 2
Each document is saved in the DB using a Service implementing this interface, which simply calls the corresponding save()
method of the MongoRepository.
import org.apache.commons.collections4.IterableUtils;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.List;
import java.util.Optional;
public interface EntityService<T, K> {
MongoRepository<T, K> getRepository();
default Optional<T> findById(K id) {
return this.getRepository().findById(id);
}
default List<T> findAll(){
return this.getRepository().findAll();
}
default List<T> findAllByIds(List<K> ids){
return IterableUtils.toList(this.getRepository().findAllById(ids));
}
default T save(T entity) {
return this.getRepository().save(entity);
}
default List<T> save(Iterable<T> entities) {
return this.getRepository().saveAll(entities);
}
default void delete(T entity) {
this.getRepository().delete(entity);
}
default void delete(Iterable<T> entity) {
this.getRepository().deleteAll(entity);
}
}
and this is the corresponding @Repository
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface BookRepository extends MongoRepository<Book, String>, QuerydslPredicateExecutor<Book> {}
UPDATE 3
The RestController calls this method, where the service is the one defined above:
default T save(T entity) {
return this.convert(this.getService().save(this.decode(entity)));
}
and these are the convert and decode methods:
@Override
public BookDTO convert(Book source) {
return BookDTO.builder()
.id(source.getId())
// Auditing Info
.version(source.getVersion())
.creator(source.getCreator())
.created(source.getCreated())
.modifier(source.getModifier())
.modified(source.getModified())
.build();
}
@Override
public Book decode(BookDTO target) {
return Book.builder()
.id(target.getId())
// Auditing Info
.version(target.getVersion())
// .creator(target.getCreator())
// .created(target.getCreated())
// .modifier(target.getModifier())
// .modified(target.getModified())
.build();
}
Update 4
I just created a Spring Boot/Java 16 MWP to reproduce the error on GitHub.
This is the RestController:
@RestController
@RequiredArgsConstructor
public class BookController {
private final BookRepository bookRepository;
@PostMapping(value = "/book")
public Book save(@RequestBody Book entity) {
return this.bookRepository.save(entity);
}
@GetMapping(value = "/book/test")
public Book test() {
Book book = Book.builder().name("Book1").build();
return this.bookRepository.save(book);
}
@GetMapping(value = "/books")
public List<Book> books() {
return this.bookRepository.findAll();
}
}
If I update the document via the "/book"
endpoint, the document in the DB is saved correctly (with the existing creator & created fields), but it is returned with null values for these fields by the Rest Controller.
However, the "/books"
returns all the books with all the fields correctly populated.
It seems like there is something between the doUpdate
method and the controller return that sets these fields to null.
Update 5
I created some tests in order to better check the save method of the BookRepository
.
What I found:
version, creator, created, modifier, modified
) populated as expected.creator
and created
fields for subsequent find queries.Here are my test methods (also available on GitHub).
import com.example.demo.domain.Book;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;
import org.springframework.transaction.annotation.Transactional;
@SpringBootTest
@Rollback
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class BookRepositoryTests {
@Autowired
private BookRepository bookRepository;
@Test
@Order(1)
@Transactional
public void testCreateBook() {
this.doCreateBook("1001", "Java Programming");
}
@Test
@Order(2)
@Transactional
public void testUpdateBookAndFind() {
this.doCreateBook("1002", "Python Programming");
Book existingBook = this.bookRepository.findById("1002").orElse(null);
// Check Existing Book
Assertions.assertNotNull(existingBook);
// Update
existingBook.setCreated(null);
existingBook.setCreator(null);
existingBook.setModifier(null);
existingBook.setModified(null);
this.bookRepository.save(existingBook);
Book existingUpdatedBook = this.bookRepository.findById("1002").orElse(null);
// Check Existing Updated Book (Working)
Assertions.assertNotNull(existingUpdatedBook);
Assertions.assertNotNull(existingUpdatedBook.getCreator());
Assertions.assertNotNull(existingUpdatedBook.getCreated());
Assertions.assertNotNull(existingUpdatedBook.getModifier());
Assertions.assertNotNull(existingUpdatedBook.getModified());
}
@Test
@Order(3)
@Transactional
public void testUpdateBookDirect() {
this.doCreateBook("1003", "Go Programming");
Book existingBook = this.bookRepository.findById("1003").orElse(null);
// Check Existing Book
Assertions.assertNotNull(existingBook);
// Update
existingBook.setCreated(null);
existingBook.setCreator(null);
existingBook.setModifier(null);
existingBook.setModified(null);
Book updatedBook = this.bookRepository.save(existingBook);
// Check Updated Book (Not working)
Assertions.assertNotNull(updatedBook);
Assertions.assertNotNull(updatedBook.getCreator());
Assertions.assertNotNull(updatedBook.getCreated());
Assertions.assertNotNull(updatedBook.getModifier());
Assertions.assertNotNull(updatedBook.getModified());
}
private void doCreateBook(String bookID, String bookName) {
// Create Book
Book book = Book.builder().id(bookID).name(bookName).build();
Book createdBook = this.bookRepository.save(book);
Assertions.assertNotNull(createdBook);
Assertions.assertEquals(bookID, createdBook.getId());
Assertions.assertEquals(bookName, createdBook.getName());
// Check Auditing Fields
Assertions.assertNotNull(createdBook.getVersion());
Assertions.assertNotNull(createdBook.getCreator());
Assertions.assertNotNull(createdBook.getCreated());
Assertions.assertNotNull(createdBook.getModifier());
Assertions.assertNotNull(createdBook.getModified());
}
}
In synthesis, only the testUpdateBookDirect()
method's assertions are not working. It seems there's some sort of interceptor right after the CustomMongoTemplate.doUpdate()
method that overwrites these fields (creator, created).
A possible solution or workaround is to:
MongoTemplate
classdoUpdate
methodsave
and find
methods to update and return the updated object with the auditing fields correctly populated. This is required since for some reason the repository.save
method returns null for the auditing fields (creator
, created
) even if they are then correctly populated in the DB.Here we need to override the doUpdate
method of the MongoTemplate
.
@Override
protected UpdateResult doUpdate(String collectionName, Query query, UpdateDefinition update, Class<?> entityClass, boolean upsert, boolean multi) {
Document updateDocument = update.getUpdateObject();
List<?> list = this.find(query, entityClass);
if (!list.isEmpty()) {
Object existingObject = list.get(0);
Document existingDocument = new Document();
this.getConverter().write(existingObject, existingDocument);
// Keep the values of the existing document
if (existingDocument.keySet().containsAll(Arrays.asList("version", "creator", "created"))) {
String creator = existingDocument.getString("creator");
Date created = existingDocument.getDate("created");
System.out.println("Creator: " + creator);
System.out.println("Created: " + created);
updateDocument.put("creator", creator);
updateDocument.put("created", created);
System.out.println("Update Document");
System.out.println(updateDocument.toJson());
}
return super.doUpdate(collectionName, query, Update.fromDocument(updateDocument), entityClass, upsert, multi);
} else {
return super.doUpdate(collectionName, query, update, entityClass, upsert, multi);
}
}
In the end I use a service that calls the repository for the save and find operations. This is the interface it implements.
public interface EntityService<T extends MongoDBDocument<K>, K> {
MongoRepository<T, K> getRepository();
default T save(T entity) {
// First save it
this.getRepository().save(entity);
// Then find it by ID
return this.getRepository().findById(entity.getId()).orElse(entity);
}
default List<T> save(Iterable<T> entities) {
// First save them
List<T> savedEntities = this.getRepository().saveAll(entities);
List<K> savedEntitiesIDs = savedEntities.stream().map(entity -> entity.getId()).collect(Collectors.toList());
// Then find them by IDs
return IterableUtils.toList(this.getRepository().findAllById(savedEntitiesIDs));
}
}
In this way I able to do what I was looking for:
save
and update
API endpoints.