diff --git a/docs/Database-Structure.md b/docs/Database-Structure.md index 45a8b600..04ea4bf8 100644 --- a/docs/Database-Structure.md +++ b/docs/Database-Structure.md @@ -24,23 +24,6 @@ In our case, the authentication tables are prefixed by `uds_`. This chapter explains individual tables and their columns. The column types are used from PostgreSQL dialect, other databases use types that are equivalent (mapping is usually straight-forward). - -### User Claims Table - -Stores user claims. - -#### Schema - -| Name | Type | Info | Note | -|--------------------------|-------------------------------|---------------------------|----------------------------------------------------------------------------------------------------------------| -| `user_id` | `VARCHAR(255)` | `NOT NULL PRIMARY KEY` | Record identifier taken over from the creator. | -| `claims` | `TEXT` | `NOT NULL PRIMARY KEY` | JSON with claims. Format depends on value of `encryption_mode`. | -| `encryption_mode` | `VARCHAR(255)` | `DEFAULT 'NO_ENCRYPTION'` | Drives format of claims. `NO_ENCRYPTION` means plaintext, `AES_HMAC` for AES encryption with HMAC-based index. | -| `timestamp_created` | `TIMESTAMP WITHOUT TIME ZONE` | `DEFAULT NOW()'` | Timestamp of creation. | -| `timestamp_last_updated` | `TIMESTAMP WITHOUT TIME ZONE` | | Timestamp of last update if any. | - - - ### Documents Table @@ -128,3 +111,20 @@ Stores attachments. | `timestamp_last_updated` | `TIMESTAMP WITHOUT TIME ZONE` | | Optional timestamp of last update of the attachment. | + + +### User Claims Table + +Stores user claims. + +#### Schema + +| Name | Type | Info | Note | +|--------------------------|-------------------------------|---------------------------|----------------------------------------------------------------------------------------------------------------| +| `user_id` | `VARCHAR(255)` | `NOT NULL PRIMARY KEY` | Record identifier taken over from the creator. | +| `claims` | `TEXT` | `NOT NULL PRIMARY KEY` | JSON with claims. Format depends on value of `encryption_mode`. | +| `encryption_mode` | `VARCHAR(255)` | `DEFAULT 'NO_ENCRYPTION'` | Drives format of claims. `NO_ENCRYPTION` means plaintext, `AES_HMAC` for AES encryption with HMAC-based index. | +| `timestamp_created` | `TIMESTAMP WITHOUT TIME ZONE` | `DEFAULT NOW()'` | Timestamp of creation. | +| `timestamp_last_updated` | `TIMESTAMP WITHOUT TIME ZONE` | | Timestamp of last update if any. | + + \ No newline at end of file diff --git a/docs/Deploying-User-Data-Store.md b/docs/Deploying-User-Data-Store.md index 16d39d72..244f38a5 100644 --- a/docs/Deploying-User-Data-Store.md +++ b/docs/Deploying-User-Data-Store.md @@ -36,6 +36,7 @@ The deployed application is accessible on `http://localhost:8080/user-data-store ## Supported Java Runtime Versions The following Java runtime versions are supported: +- Java 21 (LTS release) - Java 17 (LTS release) The User Data Store may run on other Java versions, however we do not perform extensive testing with non-LTS releases. diff --git a/docs/User-Data-Store-API.md b/docs/User-Data-Store-API.md index 3c9d19e0..656eeb24 100644 --- a/docs/User-Data-Store-API.md +++ b/docs/User-Data-Store-API.md @@ -152,6 +152,18 @@ Fetch documents for a user. } } ``` + +#### Response 400 + +```json +{ + "status": "ERROR", + "responseObject": { + "code": "NOT_FOUND", + "message": "Document not found, ID: 'e6eea62b-274b-4c6a-81a8-5bbc75811863'" + } +} +``` @@ -311,6 +323,18 @@ Update a document. "status": "OK" } ``` + +#### Response 400 + +```json +{ + "status": "ERROR", + "responseObject": { + "code": "NOT_FOUND", + "message": "Document not found, ID: '8ab06a8d-b850-4259-9756-52ed44514b1'" + } +} +``` @@ -405,6 +429,18 @@ Fetch photos for a user. } } ``` + +#### Response 400 + +```json +{ + "status": "ERROR", + "responseObject": { + "code": "NOT_FOUND", + "message": "Document not found, ID: '8ab06a8d-b850-4259-9756-52ed44514b1'" + } +} +``` @@ -464,6 +500,30 @@ Create a photo. } } ``` + +#### Response 400 + +```json +{ + "status": "ERROR", + "responseObject": { + "code": "NOT_FOUND", + "message": "Document not found, ID: '49c6e850-900e-4d90-bdc8-d9bb47e44384'" + } +} +``` + +#### Response 400 + +```json +{ + "status": "ERROR", + "responseObject": { + "code": "NOT_FOUND", + "message": "User reference not valid, ID: 'user1'" + } +} +``` @@ -521,6 +581,18 @@ Update a photo. "status": "OK" } ``` + +#### Response 400 + +```json +{ + "status": "ERROR", + "responseObject": { + "code": "NOT_FOUND", + "message": "Photo not found, ID: 'e42c8432-6971-419d-9a23-1c4042d91e24'" + } +} +``` @@ -561,6 +633,18 @@ Delete photos. "status": "OK" } ``` + +#### Response 400 + +```json +{ + "status": "ERROR", + "responseObject": { + "code": "NOT_FOUND", + "message": "Document not found, ID: '49c6e850-900e-4d90-bdc8-d9bb47e44384'" + } +} +``` @@ -615,6 +699,18 @@ Fetch attachments for a user. } } ``` + +#### Response 400 + +```json +{ + "status": "ERROR", + "responseObject": { + "code": "NOT_FOUND", + "message": "Document not found, ID: '8ab06a8d-b850-4259-9756-52ed44514b1'" + } +} +``` @@ -674,6 +770,30 @@ Create an attachment. } } ``` + +#### Response 400 + +```json +{ + "status": "ERROR", + "responseObject": { + "code": "NOT_FOUND", + "message": "Document not found, ID: '49c6e850-900e-4d90-bdc8-d9bb47e44384'" + } +} +``` + +#### Response 400 + +```json +{ + "status": "ERROR", + "responseObject": { + "code": "NOT_FOUND", + "message": "User reference not valid, ID: 'user1'" + } +} +``` @@ -731,6 +851,18 @@ Update an attachment. "status": "OK" } ``` + +#### Response 400 + +```json +{ + "status": "ERROR", + "responseObject": { + "code": "NOT_FOUND", + "message": "Attachment not found, ID: '7ae0eef7-d266-4662-9c20-749e42f69f1b'" + } +} +``` @@ -771,6 +903,18 @@ Delete attachments. "status": "OK" } ``` + +#### Response 400 + +```json +{ + "status": "ERROR", + "responseObject": { + "code": "NOT_FOUND", + "message": "Document not found, ID: '49c6e850-900e-4d90-bdc8-d9bb47e44384'" + } +} +``` @@ -816,7 +960,7 @@ Fetch claims for a user. ``` - + ### Create Claims Create a claim. @@ -859,6 +1003,18 @@ Create a claim. "status": "OK" } ``` + +#### Response 400 + +```json +{ + "status": "ERROR", + "responseObject": { + "code": "ALREADY_EXISTS", + "message": "Claims for user 'user1' already exist" + } +} +``` @@ -904,6 +1060,18 @@ Create a claim. "status": "OK" } ``` + +#### Response 400 + +```json +{ + "status": "ERROR", + "responseObject": { + "code": "NOT_FOUND", + "message": "Claims for user 'user1' do not exist" + } +} +``` diff --git a/user-data-store-server/src/main/java/com/wultra/security/userdatastore/errorhandling/DefaultExceptionHandler.java b/user-data-store-server/src/main/java/com/wultra/security/userdatastore/errorhandling/DefaultExceptionHandler.java index 06f8187d..beda1a42 100644 --- a/user-data-store-server/src/main/java/com/wultra/security/userdatastore/errorhandling/DefaultExceptionHandler.java +++ b/user-data-store-server/src/main/java/com/wultra/security/userdatastore/errorhandling/DefaultExceptionHandler.java @@ -19,6 +19,7 @@ import com.wultra.security.userdatastore.model.error.EncryptionException; import com.wultra.security.userdatastore.model.error.InvalidRequestException; +import com.wultra.security.userdatastore.model.error.ResourceAlreadyExistsException; import com.wultra.security.userdatastore.model.error.ResourceNotFoundException; import io.getlime.core.rest.model.base.response.ErrorResponse; import jakarta.validation.ConstraintViolationException; @@ -94,4 +95,17 @@ public ErrorResponse handleNotFoundException(final ResourceNotFoundException e) return new ErrorResponse("NOT_FOUND", e.getMessage()); } + /** + * Exception handler for {@link ResourceAlreadyExistsException}. + * + * @param e Exception. + * @return Response with error details. + */ + @ExceptionHandler(ResourceAlreadyExistsException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleAlreadyExistsException(final ResourceAlreadyExistsException e) { + logger.warn("Error occurred when processing request object.", e); + return new ErrorResponse("ALREADY_EXISTS", e.getMessage()); + } + } diff --git a/user-data-store-server/src/main/java/com/wultra/security/userdatastore/service/AttachmentService.java b/user-data-store-server/src/main/java/com/wultra/security/userdatastore/service/AttachmentService.java index 6f54ad51..3c7939ba 100644 --- a/user-data-store-server/src/main/java/com/wultra/security/userdatastore/service/AttachmentService.java +++ b/user-data-store-server/src/main/java/com/wultra/security/userdatastore/service/AttachmentService.java @@ -60,15 +60,30 @@ public class AttachmentService { private final EncryptionService encryptionService; private final AttachmentConverter attachmentConverter; + @Transactional(readOnly = true) + public AttachmentResponse fetchAttachments(final String userId, final Optional documentId) { + if (documentId.isPresent()) { + final DocumentEntity documentEntity = documentRepository.findById(documentId.get()).orElseThrow( + () -> new ResourceNotFoundException("Document not found, ID: '%s'".formatted(documentId))); + final List attachmentEntities = attachmentRepository.findAllByUserIdAndDocument(userId, documentEntity); + attachmentEntities.forEach(encryptionService::decryptAttachment); + final List attachments = attachmentEntities.stream().map(attachmentConverter::toAttachment).toList(); + audit("Retrieved attachments for document ID: {}", documentId.get()); + return new AttachmentResponse(attachments); + } + final List attachmentEntities = attachmentRepository.findAllByUserId(userId); + attachmentEntities.forEach(encryptionService::decryptAttachment); + final List attachments = attachmentEntities.stream().map(attachmentConverter::toAttachment).toList(); + audit("Retrieved attachments for user ID: {}", userId); + return new AttachmentResponse(attachments); + } + @Transactional public AttachmentCreateResponse createAttachment(final AttachmentCreateRequest request) { final String userId = request.userId(); final String documentId = request.documentId(); - final Optional documentEntityOptional = documentRepository.findById(documentId); - if (documentEntityOptional.isEmpty()) { - throw new ResourceNotFoundException("Document not found, ID: '%s'".formatted(documentId)); - } - final DocumentEntity documentEntity = documentEntityOptional.get(); + final DocumentEntity documentEntity = documentRepository.findById(documentId).orElseThrow( + () -> new ResourceNotFoundException("Document not found, ID: '%s'".formatted(documentId))); if (!documentEntity.getUserId().equals(userId)) { throw new ResourceNotFoundException("User reference not valid, ID: '%s'".formatted(userId)); } @@ -119,35 +134,12 @@ public Response updateAttachment(final String attachmentId, final AttachmentUpda return new Response(); } - @Transactional(readOnly = true) - public AttachmentResponse fetchAttachments(final String userId, final Optional documentId) { - if (documentId.isPresent()) { - final Optional documentEntityOptional = documentRepository.findById(documentId.get()); - if (documentEntityOptional.isEmpty()) { - return new AttachmentResponse(Collections.emptyList()); - } - final DocumentEntity documentEntity = documentEntityOptional.get(); - final List attachmentEntities = attachmentRepository.findAllByUserIdAndDocument(userId, documentEntity); - attachmentEntities.forEach(encryptionService::decryptAttachment); - final List attachments = attachmentEntities.stream().map(attachmentConverter::toAttachment).toList(); - audit("Retrieved attachments for document ID: {}", documentId.get()); - return new AttachmentResponse(attachments); - } - final List attachmentEntities = attachmentRepository.findAllByUserId(userId); - attachmentEntities.forEach(encryptionService::decryptAttachment); - final List attachments = attachmentEntities.stream().map(attachmentConverter::toAttachment).toList(); - audit("Retrieved attachments for user ID: {}", userId); - return new AttachmentResponse(attachments); - } - @Transactional public void deleteAttachments(final String userId, final Optional documentId) { if (documentId.isPresent()) { - final Optional documentEntityOptional = documentRepository.findById(documentId.get()); - if (documentEntityOptional.isEmpty()) { - return; - } - attachmentRepository.deleteAllByUserIdAndDocument(userId, documentEntityOptional.get()); + final DocumentEntity documentEntity = documentRepository.findById(documentId.get()).orElseThrow( + () -> new ResourceNotFoundException("Document not found, ID: '%s'".formatted(documentId))); + attachmentRepository.deleteAllByUserIdAndDocument(userId, documentEntity); audit("Deleted attachments for document ID: {}", documentId.get()); return; } diff --git a/user-data-store-server/src/main/java/com/wultra/security/userdatastore/service/DocumentService.java b/user-data-store-server/src/main/java/com/wultra/security/userdatastore/service/DocumentService.java index f55e7646..9bae027c 100644 --- a/user-data-store-server/src/main/java/com/wultra/security/userdatastore/service/DocumentService.java +++ b/user-data-store-server/src/main/java/com/wultra/security/userdatastore/service/DocumentService.java @@ -61,11 +61,9 @@ public class DocumentService { @Transactional(readOnly = true) public DocumentResponse fetchDocuments(final String userId, final Optional documentId) { if (documentId.isPresent()) { - final Optional documentEntityOptional = documentRepository.findById(documentId.get()); - if (documentEntityOptional.isEmpty()) { - throw new ResourceNotFoundException("Document not found, ID: '%s'".formatted(documentId.get())); - } - final DocumentDto document = documentConverter.toDocument(documentEntityOptional.get()); + final DocumentEntity documentEntity = documentRepository.findById(documentId.get()).orElseThrow( + () -> new ResourceNotFoundException("Document not found, ID: '%s'".formatted(documentId.get()))); + final DocumentDto document = documentConverter.toDocument(documentEntity); return new DocumentResponse(Collections.singletonList(document)); } final List documentEntities = documentRepository.findAllByUserId(userId); @@ -120,11 +118,8 @@ public DocumentCreateResponse createDocument(final DocumentCreateRequest request public Response updateDocument(final String documentId, final DocumentUpdateRequest request) { final String userId = request.userId(); logger.debug("Updating document for user ID: {}", userId); - final Optional documentEntityOptional = documentRepository.findById(documentId); - if (documentEntityOptional.isEmpty()) { - throw new ResourceNotFoundException("Document not found, ID: '%s'".formatted(documentId)); - } - DocumentEntity documentEntity = documentEntityOptional.get(); + final DocumentEntity documentEntity = documentRepository.findById(documentId).orElseThrow( + () -> new ResourceNotFoundException("Document not found, ID: '%s'".formatted(documentId))); documentEntity.setUserId(userId); documentEntity.setDocumentType(request.documentType()); documentEntity.setDataType(request.dataType()); diff --git a/user-data-store-server/src/main/java/com/wultra/security/userdatastore/service/PhotoService.java b/user-data-store-server/src/main/java/com/wultra/security/userdatastore/service/PhotoService.java index 5275c9fc..677c9688 100644 --- a/user-data-store-server/src/main/java/com/wultra/security/userdatastore/service/PhotoService.java +++ b/user-data-store-server/src/main/java/com/wultra/security/userdatastore/service/PhotoService.java @@ -39,7 +39,6 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -63,11 +62,8 @@ public class PhotoService { @Transactional(readOnly = true) public PhotoResponse fetchPhotos(final String userId, final Optional documentId) { if (documentId.isPresent()) { - final Optional documentEntityOptional = documentRepository.findById(documentId.get()); - if (documentEntityOptional.isEmpty()) { - return new PhotoResponse(Collections.emptyList()); - } - final DocumentEntity documentEntity = documentEntityOptional.get(); + final DocumentEntity documentEntity = documentRepository.findById(documentId.get()).orElseThrow( + () -> new ResourceNotFoundException("Document not found, ID: '%s'".formatted(documentId))); final List photoEntities = photoRepository.findAllByUserIdAndDocument(userId, documentEntity); photoEntities.forEach(encryptionService::decryptPhoto); final List photos = photoEntities.stream().map(photoConverter::toPhoto).toList(); @@ -85,11 +81,8 @@ public PhotoResponse fetchPhotos(final String userId, final Optional doc public PhotoCreateResponse createPhoto(final PhotoCreateRequest request) { final String userId = request.userId(); final String documentId = request.documentId(); - final Optional documentEntityOptional = documentRepository.findById(documentId); - if (documentEntityOptional.isEmpty()) { - throw new ResourceNotFoundException("Document not found, ID: '%s'".formatted(documentId)); - } - final DocumentEntity documentEntity = documentEntityOptional.get(); + final DocumentEntity documentEntity = documentRepository.findById(documentId).orElseThrow( + () -> new ResourceNotFoundException("Document not found, ID: '%s'".formatted(documentId))); if (!documentEntity.getUserId().equals(userId)) { throw new ResourceNotFoundException("User reference not valid, ID: '%s'".formatted(userId)); } @@ -144,12 +137,9 @@ public Response updatePhoto(final String photoId, final PhotoUpdateRequest reque @Transactional public void deletePhotos(final String userId, final Optional documentId) { if (documentId.isPresent()) { - final Optional documentEntityOptional = documentRepository.findById(documentId.get()); - if (documentEntityOptional.isEmpty()) { - return; - } - - photoRepository.deleteAllByUserIdAndDocument(userId, documentEntityOptional.get()); + final DocumentEntity documentEntity = documentRepository.findById(documentId.get()).orElseThrow( + () -> new ResourceNotFoundException("Document not found, ID: '%s'".formatted(documentId))); + photoRepository.deleteAllByUserIdAndDocument(userId, documentEntity); audit("Deleted photos for document ID: {}", documentId.get()); return; }