From bf8bff32257d8544f14f3b0170bfd6fcacc855ed Mon Sep 17 00:00:00 2001 From: vanitha1822 Date: Tue, 3 Nov 2026 15:57:53 +0530 Subject: [PATCH 1/9] fix: add consent APIs --- .dockerignore | 10 + .git-hooks/commit-msg | 4 + .github/workflows/build-on-pull-request.yml | 23 + .github/workflows/checkstyle.yml | 13 + .github/workflows/commit-lint.yml | 29 ++ .github/workflows/package.yml | 36 ++ .github/workflows/sast.yml | 53 +++ .github/workflows/testcase-coverage.yml | 46 ++ .gitignore | 26 + Dockerfile | 22 + README.md | 175 +++++++ checkstyle.xml | 65 +++ commitlint.config.js | 35 ++ lombok.config | 2 + pom.xml | 329 +++++++++++++ .../contracts/patient-consent.openapi.yaml | 447 +++++++++++++++++ src/main/environment/common_ci.properties | 27 ++ src/main/environment/common_docker.properties | 23 + .../environment/common_example.properties | 35 ++ .../iemr/consent/ConsentApiApplication.java | 49 ++ .../com/iemr/consent/config/ClockConfig.java | 41 ++ .../consent/config/CommonApiClientConfig.java | 41 ++ .../consent/config/ConsentProperties.java | 127 +++++ .../config/MissingSettingFailureAnalyzer.java | 85 ++++ .../com/iemr/consent/config/RedisConfig.java | 84 ++++ .../iemr/consent/config/RequiredSettings.java | 79 +++ .../consent/config/SecurityProperties.java | 82 ++++ .../iemr/consent/config/SwaggerConfig.java | 188 ++++++++ .../com/iemr/consent/config/WebConfig.java | 111 +++++ .../controller/consent/ConsentController.java | 77 +++ .../evidence/EvidenceController.java | 91 ++++ .../grant/AccessGrantController.java | 152 ++++++ .../controller/health/HealthController.java | 71 +++ .../controller/notice/NoticeController.java | 74 +++ .../consent/controller/otp/OtpController.java | 92 ++++ .../principal/PrincipalConsentController.java | 126 +++++ .../controller/version/VersionController.java | 62 +++ .../com/iemr/consent/data/AuditColumns.java | 56 +++ .../consent/data/audit/AuditLogEntry.java | 83 ++++ .../iemr/consent/data/consent/Channel.java | 28 ++ .../iemr/consent/data/consent/Consent.java | 125 +++++ .../consent/data/consent/ConsentMode.java | 28 ++ .../consent/data/consent/ConsentPurpose.java | 85 ++++ .../consent/data/consent/ConsentingParty.java | 28 ++ .../iemr/consent/data/consent/Decision.java | 28 ++ .../iemr/consent/data/consent/EventType.java | 31 ++ .../consent/data/consent/GuardianLink.java | 88 ++++ .../consent/data/consent/GuardianType.java | 28 ++ .../data/consent/GuardianVerification.java | 28 ++ .../consent/data/consent/PurposeStatus.java | 28 ++ .../iemr/consent/data/consent/SyncStatus.java | 28 ++ .../data/evidence/ConsentEvidence.java | 104 ++++ .../consent/data/evidence/EvidenceType.java | 45 ++ .../iemr/consent/data/grant/AccessGrant.java | 133 ++++++ .../iemr/consent/data/grant/GrantStatus.java | 28 ++ .../iemr/consent/data/grant/GrantType.java | 28 ++ .../iemr/consent/data/grant/RevokeReason.java | 28 ++ .../com/iemr/consent/data/notice/Notice.java | 76 +++ .../consent/data/notice/NoticeContext.java | 28 ++ .../consent/data/notice/NoticePurpose.java | 55 +++ .../consent/data/notice/NoticePurposeId.java | 44 ++ .../consent/data/notice/NoticeStatus.java | 28 ++ .../data/notice/NoticeTranslation.java | 85 ++++ .../com/iemr/consent/data/notice/Purpose.java | 76 +++ .../model/consent/ConsentPatterns.java | 45 ++ .../consent/model/consent/DecisionItem.java | 38 ++ .../consent/model/consent/GuardianInfo.java | 60 +++ .../com/iemr/consent/model/consent/Proof.java | 47 ++ .../consent/model/consent/PurposeResult.java | 35 ++ .../model/consent/SaveConsentRequest.java | 83 ++++ .../model/consent/SaveConsentResult.java | 37 ++ .../model/evidence/EvidenceUploadResult.java | 32 ++ .../consent/model/grant/AccessGrantView.java | 41 ++ .../consent/model/grant/AccessRequest.java | 57 +++ .../consent/model/grant/ApproveRequest.java | 31 ++ .../iemr/consent/model/grant/GranteeView.java | 27 ++ .../consent/model/notice/ActiveNotice.java | 52 ++ .../consent/model/notice/NoticeLanguage.java | 33 ++ .../model/notice/NoticePurposeView.java | 37 ++ .../consent/model/otp/OtpChallengeView.java | 35 ++ .../com/iemr/consent/model/otp/OtpReason.java | 28 ++ .../consent/model/otp/OtpSendRequest.java | 46 ++ .../consent/model/otp/OtpVerifyRequest.java | 39 ++ .../consent/model/otp/OtpVerifyResult.java | 27 ++ .../model/principal/ConsentEventView.java | 43 ++ .../model/principal/CurrentDecisionView.java | 35 ++ .../model/principal/PatientConsents.java | 29 ++ .../model/principal/PendingAction.java | 35 ++ .../model/principal/PendingReason.java | 28 ++ .../model/principal/PreferenceRequest.java | 69 +++ .../model/principal/PrincipalChange.java | 40 ++ .../consent/model/principal/PurposeCodes.java | 49 ++ .../model/principal/PurposeSelection.java | 79 +++ .../model/principal/WithdrawalRequest.java | 54 +++ .../model/principal/WithdrawalResult.java | 33 ++ .../repository/audit/AuditLogRepository.java | 42 ++ .../consent/ConsentPurposeRepository.java | 55 +++ .../repository/consent/ConsentRepository.java | 44 ++ .../consent/GuardianLinkRepository.java | 37 ++ .../evidence/ConsentEvidenceRepository.java | 50 ++ .../grant/AccessGrantRepository.java | 59 +++ .../notice/NoticePurposeRepository.java | 38 ++ .../repository/notice/NoticeRepository.java | 50 ++ .../notice/NoticeTranslationRepository.java | 41 ++ .../repository/notice/PurposeRepository.java | 38 ++ .../service/audit/AuditLogService.java | 55 +++ .../service/audit/AuditLogServiceImpl.java | 85 ++++ .../service/consent/ConsentProofWriter.java | 33 ++ .../service/consent/ConsentResultMapper.java | 53 +++ .../consent/service/consent/ConsentRules.java | 46 ++ .../service/consent/ConsentService.java | 34 ++ .../service/consent/ConsentServiceImpl.java | 87 ++++ .../service/consent/ConsentValidator.java | 129 +++++ .../service/consent/ConsentWriter.java | 176 +++++++ .../service/consent/EvidenceProofWriter.java | 95 ++++ .../service/consent/GuardianProofWriter.java | 107 +++++ .../consent/service/consent/GuardianRule.java | 71 +++ .../service/consent/ModeProofRule.java | 42 ++ .../consent/service/consent/OtpProofRule.java | 58 +++ .../service/consent/OtpProofWriter.java | 75 +++ .../consent/service/consent/ProofRows.java | 55 +++ .../service/consent/ReadAloudProofRule.java | 49 ++ .../consent/SignaturePhotoProofRule.java | 49 ++ .../service/consent/ValidatedConsent.java | 37 ++ .../service/consent/VoiceClipProofRule.java | 51 ++ .../service/consent/WitnessedProofRule.java | 50 ++ .../EncryptedFileEvidenceStorage.java | 99 ++++ .../service/evidence/EvidenceService.java | 42 ++ .../service/evidence/EvidenceServiceImpl.java | 186 ++++++++ .../service/evidence/EvidenceStorage.java | 36 ++ .../service/evidence/EvidenceUpload.java | 45 ++ .../service/grant/AccessGrantService.java | 59 +++ .../service/grant/AccessGrantServiceImpl.java | 261 ++++++++++ .../service/grant/AccessGrantWriter.java | 74 +++ .../service/grant/GrantReplayStore.java | 84 ++++ .../consent/service/health/HealthService.java | 30 ++ .../service/health/HealthServiceImpl.java | 159 +++++++ .../consent/service/health/HealthStatus.java | 58 +++ .../consent/service/notice/NoticeService.java | 43 ++ .../service/notice/NoticeServiceImpl.java | 144 ++++++ .../service/notice/PublishedNotice.java | 39 ++ .../service/otp/CommonApiOtpClient.java | 98 ++++ .../consent/service/otp/OtpChallenge.java | 68 +++ .../service/otp/OtpChallengeStore.java | 111 +++++ .../iemr/consent/service/otp/OtpService.java | 53 +++ .../consent/service/otp/OtpServiceImpl.java | 156 ++++++ .../service/principal/ConsentViewMapper.java | 102 ++++ .../service/principal/EffectiveStatus.java | 65 +++ .../principal/PendingActionService.java | 39 ++ .../principal/PendingActionServiceImpl.java | 198 ++++++++ .../principal/PreferenceChangeWriter.java | 217 +++++++++ .../principal/PrincipalConsentService.java | 48 ++ .../PrincipalConsentServiceImpl.java | 147 ++++++ .../principal/PrincipalEventFactory.java | 189 ++++++++ .../service/principal/WithdrawalWriter.java | 144 ++++++ .../consent/service/version/BuildInfo.java | 35 ++ .../service/version/VersionService.java | 29 ++ .../service/version/VersionServiceImpl.java | 70 +++ .../consent/utils/auth/AuthenticatedUser.java | 68 +++ .../utils/auth/AuthenticationException.java | 46 ++ .../utils/auth/AuthenticationFilter.java | 163 +++++++ .../iemr/consent/utils/auth/ClientType.java | 33 ++ .../iemr/consent/utils/auth/CurrentUser.java | 39 ++ .../auth/CurrentUserArgumentResolver.java | 59 +++ .../iemr/consent/utils/auth/JwtValidator.java | 85 ++++ .../iemr/consent/utils/auth/PublicPaths.java | 57 +++ .../consent/utils/auth/SessionValidator.java | 104 ++++ .../consent/utils/auth/TokenDenylist.java | 55 +++ .../iemr/consent/utils/cors/OriginFilter.java | 95 ++++ .../iemr/consent/utils/cors/OriginPolicy.java | 68 +++ .../consent/utils/crypto/FieldEncryptor.java | 114 +++++ .../consent/utils/crypto/PhoneMasker.java | 39 ++ .../com/iemr/consent/utils/crypto/Sha256.java | 47 ++ .../utils/exception/ConsentApiException.java | 65 +++ .../exception/ConsentDeniedException.java | 54 +++ .../exception/ConsentErrorController.java | 83 ++++ .../consent/utils/exception/ErrorCode.java | 78 +++ .../utils/exception/ErrorResponseWriter.java | 94 ++++ .../exception/GlobalExceptionHandler.java | 193 ++++++++ .../iemr/consent/utils/http/AfterCommit.java | 46 ++ .../com/iemr/consent/utils/http/ClientIp.java | 37 ++ .../consent/utils/http/ForwardedAuth.java | 74 +++ .../utils/http/PayloadTooLargeException.java | 39 ++ .../utils/http/RequestLoggingFilter.java | 69 +++ .../utils/http/RequestSizeLimitFilter.java | 173 +++++++ .../consent/utils/http/TraceIdFilter.java | 80 ++++ .../utils/idempotency/IdempotencyKey.java | 58 +++ .../utils/idempotency/RequestHasher.java | 54 +++ .../consent/utils/response/ApiResponse.java | 97 ++++ .../consent/utils/response/ErrorBody.java | 39 ++ .../utils/response/FieldErrorDetail.java | 35 ++ src/main/resources/META-INF/spring.factories | 2 + src/main/resources/application.properties | 63 +++ src/main/resources/logback-spring.xml | 27 ++ .../com/iemr/consent/CaptureFlowTest.java | 438 +++++++++++++++++ .../consent/ConsentApiApplicationTest.java | 57 +++ .../consent/PreferenceCentreFlowTest.java | 450 ++++++++++++++++++ .../config/ApiDocsAvailabilityTest.java | 164 +++++++ .../iemr/consent/config/ClockConfigTest.java | 37 ++ .../consent/config/ConsentPropertiesTest.java | 103 ++++ .../MissingSettingFailureAnalyzerTest.java | 105 ++++ .../iemr/consent/config/RedisConfigTest.java | 78 +++ .../consent/config/RequiredSettingsTest.java | 98 ++++ .../config/SecurityPropertiesTest.java | 85 ++++ .../consent/config/SwaggerConfigTest.java | 114 +++++ .../consent/ConsentControllerTest.java | 117 +++++ .../evidence/EvidenceControllerTest.java | 121 +++++ .../grant/AccessGrantControllerTest.java | 208 ++++++++ .../health/HealthControllerTest.java | 103 ++++ .../notice/NoticeControllerTest.java | 102 ++++ .../controller/otp/OtpControllerTest.java | 131 +++++ .../PrincipalConsentControllerTest.java | 234 +++++++++ .../version/VersionControllerTest.java | 58 +++ .../iemr/consent/data/EntityMappingTest.java | 149 ++++++ .../iemr/consent/model/DtoPrivacyTest.java | 64 +++ .../principal/PrincipalRequestsTest.java | 107 +++++ .../audit/AuditLogServiceImplTest.java | 82 ++++ .../consent/ConsentServiceImplTest.java | 151 ++++++ .../service/consent/ConsentValidatorTest.java | 192 ++++++++ .../service/consent/ConsentWriterTest.java | 184 +++++++ .../service/consent/ProofRulesTest.java | 189 ++++++++ .../service/consent/ProofWritersTest.java | 228 +++++++++ .../EncryptedFileEvidenceStorageTest.java | 89 ++++ .../evidence/EvidenceServiceImplTest.java | 218 +++++++++ .../grant/AccessGrantServiceImplTest.java | 347 ++++++++++++++ .../service/grant/AccessGrantWriterTest.java | 71 +++ .../service/grant/GrantReplayStoreTest.java | 88 ++++ .../service/health/HealthServiceImplTest.java | 218 +++++++++ .../service/notice/NoticeServiceImplTest.java | 168 +++++++ .../service/otp/CommonApiOtpClientTest.java | 136 ++++++ .../service/otp/OtpChallengeStoreTest.java | 132 +++++ .../service/otp/OtpServiceImplTest.java | 252 ++++++++++ .../principal/ConsentViewMapperTest.java | 95 ++++ .../principal/EffectiveStatusTest.java | 76 +++ .../PendingActionServiceImplTest.java | 248 ++++++++++ .../principal/PreferenceChangeWriterTest.java | 321 +++++++++++++ .../PrincipalConsentServiceImplTest.java | 198 ++++++++ .../principal/PrincipalEventFactoryTest.java | 227 +++++++++ .../principal/WithdrawalWriterTest.java | 251 ++++++++++ .../version/VersionServiceImplTest.java | 71 +++ .../iemr/consent/support/ConsentFixtures.java | 211 ++++++++ .../consent/support/FullChainWebTest.java | 71 +++ .../com/iemr/consent/support/LogCapture.java | 83 ++++ .../consent/support/PrincipalFixtures.java | 106 +++++ .../consent/support/SampleController.java | 158 ++++++ .../iemr/consent/support/TestFixtures.java | 83 ++++ .../com/iemr/consent/support/TestTokens.java | 77 +++ .../utils/auth/AuthenticatedUserTest.java | 61 +++ .../utils/auth/AuthenticationFilterTest.java | 310 ++++++++++++ .../utils/auth/AuthenticationFlowTest.java | 161 +++++++ .../auth/CurrentUserArgumentResolverTest.java | 76 +++ .../consent/utils/auth/JwtValidatorTest.java | 116 +++++ .../consent/utils/auth/PublicPathsTest.java | 62 +++ .../utils/auth/SessionValidatorTest.java | 143 ++++++ .../consent/utils/auth/TokenDenylistTest.java | 74 +++ .../consent/utils/cors/OriginFilterTest.java | 139 ++++++ .../consent/utils/cors/OriginPolicyTest.java | 75 +++ .../utils/crypto/FieldEncryptorTest.java | 87 ++++ .../iemr/consent/utils/crypto/Sha256Test.java | 52 ++ .../exception/ConsentApiExceptionTest.java | 59 +++ .../exception/ConsentDeniedExceptionTest.java | 68 +++ .../exception/ConsentErrorControllerTest.java | 125 +++++ .../utils/exception/ErrorCodeTest.java | 90 ++++ .../exception/ErrorResponseWriterTest.java | 98 ++++ .../exception/GlobalExceptionHandlerTest.java | 238 +++++++++ .../consent/utils/http/HttpHelpersTest.java | 89 ++++ .../utils/http/RequestLoggingFilterTest.java | 111 +++++ .../http/RequestSizeLimitFilterTest.java | 184 +++++++ .../utils/http/ResponseContractTest.java | 139 ++++++ .../consent/utils/http/TraceIdFilterTest.java | 92 ++++ .../utils/idempotency/IdempotencyKeyTest.java | 104 ++++ .../utils/response/ApiResponseTest.java | 109 +++++ 272 files changed, 24363 insertions(+) create mode 100644 .dockerignore create mode 100755 .git-hooks/commit-msg create mode 100644 .github/workflows/build-on-pull-request.yml create mode 100644 .github/workflows/checkstyle.yml create mode 100644 .github/workflows/commit-lint.yml create mode 100644 .github/workflows/package.yml create mode 100644 .github/workflows/sast.yml create mode 100644 .github/workflows/testcase-coverage.yml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 checkstyle.xml create mode 100644 commitlint.config.js create mode 100644 lombok.config create mode 100644 pom.xml create mode 100644 specs/003-patient-consent-management/contracts/patient-consent.openapi.yaml create mode 100644 src/main/environment/common_ci.properties create mode 100644 src/main/environment/common_docker.properties create mode 100644 src/main/environment/common_example.properties create mode 100644 src/main/java/com/iemr/consent/ConsentApiApplication.java create mode 100644 src/main/java/com/iemr/consent/config/ClockConfig.java create mode 100644 src/main/java/com/iemr/consent/config/CommonApiClientConfig.java create mode 100644 src/main/java/com/iemr/consent/config/ConsentProperties.java create mode 100644 src/main/java/com/iemr/consent/config/MissingSettingFailureAnalyzer.java create mode 100644 src/main/java/com/iemr/consent/config/RedisConfig.java create mode 100644 src/main/java/com/iemr/consent/config/RequiredSettings.java create mode 100644 src/main/java/com/iemr/consent/config/SecurityProperties.java create mode 100644 src/main/java/com/iemr/consent/config/SwaggerConfig.java create mode 100644 src/main/java/com/iemr/consent/config/WebConfig.java create mode 100644 src/main/java/com/iemr/consent/controller/consent/ConsentController.java create mode 100644 src/main/java/com/iemr/consent/controller/evidence/EvidenceController.java create mode 100644 src/main/java/com/iemr/consent/controller/grant/AccessGrantController.java create mode 100644 src/main/java/com/iemr/consent/controller/health/HealthController.java create mode 100644 src/main/java/com/iemr/consent/controller/notice/NoticeController.java create mode 100644 src/main/java/com/iemr/consent/controller/otp/OtpController.java create mode 100644 src/main/java/com/iemr/consent/controller/principal/PrincipalConsentController.java create mode 100644 src/main/java/com/iemr/consent/controller/version/VersionController.java create mode 100644 src/main/java/com/iemr/consent/data/AuditColumns.java create mode 100644 src/main/java/com/iemr/consent/data/audit/AuditLogEntry.java create mode 100644 src/main/java/com/iemr/consent/data/consent/Channel.java create mode 100644 src/main/java/com/iemr/consent/data/consent/Consent.java create mode 100644 src/main/java/com/iemr/consent/data/consent/ConsentMode.java create mode 100644 src/main/java/com/iemr/consent/data/consent/ConsentPurpose.java create mode 100644 src/main/java/com/iemr/consent/data/consent/ConsentingParty.java create mode 100644 src/main/java/com/iemr/consent/data/consent/Decision.java create mode 100644 src/main/java/com/iemr/consent/data/consent/EventType.java create mode 100644 src/main/java/com/iemr/consent/data/consent/GuardianLink.java create mode 100644 src/main/java/com/iemr/consent/data/consent/GuardianType.java create mode 100644 src/main/java/com/iemr/consent/data/consent/GuardianVerification.java create mode 100644 src/main/java/com/iemr/consent/data/consent/PurposeStatus.java create mode 100644 src/main/java/com/iemr/consent/data/consent/SyncStatus.java create mode 100644 src/main/java/com/iemr/consent/data/evidence/ConsentEvidence.java create mode 100644 src/main/java/com/iemr/consent/data/evidence/EvidenceType.java create mode 100644 src/main/java/com/iemr/consent/data/grant/AccessGrant.java create mode 100644 src/main/java/com/iemr/consent/data/grant/GrantStatus.java create mode 100644 src/main/java/com/iemr/consent/data/grant/GrantType.java create mode 100644 src/main/java/com/iemr/consent/data/grant/RevokeReason.java create mode 100644 src/main/java/com/iemr/consent/data/notice/Notice.java create mode 100644 src/main/java/com/iemr/consent/data/notice/NoticeContext.java create mode 100644 src/main/java/com/iemr/consent/data/notice/NoticePurpose.java create mode 100644 src/main/java/com/iemr/consent/data/notice/NoticePurposeId.java create mode 100644 src/main/java/com/iemr/consent/data/notice/NoticeStatus.java create mode 100644 src/main/java/com/iemr/consent/data/notice/NoticeTranslation.java create mode 100644 src/main/java/com/iemr/consent/data/notice/Purpose.java create mode 100644 src/main/java/com/iemr/consent/model/consent/ConsentPatterns.java create mode 100644 src/main/java/com/iemr/consent/model/consent/DecisionItem.java create mode 100644 src/main/java/com/iemr/consent/model/consent/GuardianInfo.java create mode 100644 src/main/java/com/iemr/consent/model/consent/Proof.java create mode 100644 src/main/java/com/iemr/consent/model/consent/PurposeResult.java create mode 100644 src/main/java/com/iemr/consent/model/consent/SaveConsentRequest.java create mode 100644 src/main/java/com/iemr/consent/model/consent/SaveConsentResult.java create mode 100644 src/main/java/com/iemr/consent/model/evidence/EvidenceUploadResult.java create mode 100644 src/main/java/com/iemr/consent/model/grant/AccessGrantView.java create mode 100644 src/main/java/com/iemr/consent/model/grant/AccessRequest.java create mode 100644 src/main/java/com/iemr/consent/model/grant/ApproveRequest.java create mode 100644 src/main/java/com/iemr/consent/model/grant/GranteeView.java create mode 100644 src/main/java/com/iemr/consent/model/notice/ActiveNotice.java create mode 100644 src/main/java/com/iemr/consent/model/notice/NoticeLanguage.java create mode 100644 src/main/java/com/iemr/consent/model/notice/NoticePurposeView.java create mode 100644 src/main/java/com/iemr/consent/model/otp/OtpChallengeView.java create mode 100644 src/main/java/com/iemr/consent/model/otp/OtpReason.java create mode 100644 src/main/java/com/iemr/consent/model/otp/OtpSendRequest.java create mode 100644 src/main/java/com/iemr/consent/model/otp/OtpVerifyRequest.java create mode 100644 src/main/java/com/iemr/consent/model/otp/OtpVerifyResult.java create mode 100644 src/main/java/com/iemr/consent/model/principal/ConsentEventView.java create mode 100644 src/main/java/com/iemr/consent/model/principal/CurrentDecisionView.java create mode 100644 src/main/java/com/iemr/consent/model/principal/PatientConsents.java create mode 100644 src/main/java/com/iemr/consent/model/principal/PendingAction.java create mode 100644 src/main/java/com/iemr/consent/model/principal/PendingReason.java create mode 100644 src/main/java/com/iemr/consent/model/principal/PreferenceRequest.java create mode 100644 src/main/java/com/iemr/consent/model/principal/PrincipalChange.java create mode 100644 src/main/java/com/iemr/consent/model/principal/PurposeCodes.java create mode 100644 src/main/java/com/iemr/consent/model/principal/PurposeSelection.java create mode 100644 src/main/java/com/iemr/consent/model/principal/WithdrawalRequest.java create mode 100644 src/main/java/com/iemr/consent/model/principal/WithdrawalResult.java create mode 100644 src/main/java/com/iemr/consent/repository/audit/AuditLogRepository.java create mode 100644 src/main/java/com/iemr/consent/repository/consent/ConsentPurposeRepository.java create mode 100644 src/main/java/com/iemr/consent/repository/consent/ConsentRepository.java create mode 100644 src/main/java/com/iemr/consent/repository/consent/GuardianLinkRepository.java create mode 100644 src/main/java/com/iemr/consent/repository/evidence/ConsentEvidenceRepository.java create mode 100644 src/main/java/com/iemr/consent/repository/grant/AccessGrantRepository.java create mode 100644 src/main/java/com/iemr/consent/repository/notice/NoticePurposeRepository.java create mode 100644 src/main/java/com/iemr/consent/repository/notice/NoticeRepository.java create mode 100644 src/main/java/com/iemr/consent/repository/notice/NoticeTranslationRepository.java create mode 100644 src/main/java/com/iemr/consent/repository/notice/PurposeRepository.java create mode 100644 src/main/java/com/iemr/consent/service/audit/AuditLogService.java create mode 100644 src/main/java/com/iemr/consent/service/audit/AuditLogServiceImpl.java create mode 100644 src/main/java/com/iemr/consent/service/consent/ConsentProofWriter.java create mode 100644 src/main/java/com/iemr/consent/service/consent/ConsentResultMapper.java create mode 100644 src/main/java/com/iemr/consent/service/consent/ConsentRules.java create mode 100644 src/main/java/com/iemr/consent/service/consent/ConsentService.java create mode 100644 src/main/java/com/iemr/consent/service/consent/ConsentServiceImpl.java create mode 100644 src/main/java/com/iemr/consent/service/consent/ConsentValidator.java create mode 100644 src/main/java/com/iemr/consent/service/consent/ConsentWriter.java create mode 100644 src/main/java/com/iemr/consent/service/consent/EvidenceProofWriter.java create mode 100644 src/main/java/com/iemr/consent/service/consent/GuardianProofWriter.java create mode 100644 src/main/java/com/iemr/consent/service/consent/GuardianRule.java create mode 100644 src/main/java/com/iemr/consent/service/consent/ModeProofRule.java create mode 100644 src/main/java/com/iemr/consent/service/consent/OtpProofRule.java create mode 100644 src/main/java/com/iemr/consent/service/consent/OtpProofWriter.java create mode 100644 src/main/java/com/iemr/consent/service/consent/ProofRows.java create mode 100644 src/main/java/com/iemr/consent/service/consent/ReadAloudProofRule.java create mode 100644 src/main/java/com/iemr/consent/service/consent/SignaturePhotoProofRule.java create mode 100644 src/main/java/com/iemr/consent/service/consent/ValidatedConsent.java create mode 100644 src/main/java/com/iemr/consent/service/consent/VoiceClipProofRule.java create mode 100644 src/main/java/com/iemr/consent/service/consent/WitnessedProofRule.java create mode 100644 src/main/java/com/iemr/consent/service/evidence/EncryptedFileEvidenceStorage.java create mode 100644 src/main/java/com/iemr/consent/service/evidence/EvidenceService.java create mode 100644 src/main/java/com/iemr/consent/service/evidence/EvidenceServiceImpl.java create mode 100644 src/main/java/com/iemr/consent/service/evidence/EvidenceStorage.java create mode 100644 src/main/java/com/iemr/consent/service/evidence/EvidenceUpload.java create mode 100644 src/main/java/com/iemr/consent/service/grant/AccessGrantService.java create mode 100644 src/main/java/com/iemr/consent/service/grant/AccessGrantServiceImpl.java create mode 100644 src/main/java/com/iemr/consent/service/grant/AccessGrantWriter.java create mode 100644 src/main/java/com/iemr/consent/service/grant/GrantReplayStore.java create mode 100644 src/main/java/com/iemr/consent/service/health/HealthService.java create mode 100644 src/main/java/com/iemr/consent/service/health/HealthServiceImpl.java create mode 100644 src/main/java/com/iemr/consent/service/health/HealthStatus.java create mode 100644 src/main/java/com/iemr/consent/service/notice/NoticeService.java create mode 100644 src/main/java/com/iemr/consent/service/notice/NoticeServiceImpl.java create mode 100644 src/main/java/com/iemr/consent/service/notice/PublishedNotice.java create mode 100644 src/main/java/com/iemr/consent/service/otp/CommonApiOtpClient.java create mode 100644 src/main/java/com/iemr/consent/service/otp/OtpChallenge.java create mode 100644 src/main/java/com/iemr/consent/service/otp/OtpChallengeStore.java create mode 100644 src/main/java/com/iemr/consent/service/otp/OtpService.java create mode 100644 src/main/java/com/iemr/consent/service/otp/OtpServiceImpl.java create mode 100644 src/main/java/com/iemr/consent/service/principal/ConsentViewMapper.java create mode 100644 src/main/java/com/iemr/consent/service/principal/EffectiveStatus.java create mode 100644 src/main/java/com/iemr/consent/service/principal/PendingActionService.java create mode 100644 src/main/java/com/iemr/consent/service/principal/PendingActionServiceImpl.java create mode 100644 src/main/java/com/iemr/consent/service/principal/PreferenceChangeWriter.java create mode 100644 src/main/java/com/iemr/consent/service/principal/PrincipalConsentService.java create mode 100644 src/main/java/com/iemr/consent/service/principal/PrincipalConsentServiceImpl.java create mode 100644 src/main/java/com/iemr/consent/service/principal/PrincipalEventFactory.java create mode 100644 src/main/java/com/iemr/consent/service/principal/WithdrawalWriter.java create mode 100644 src/main/java/com/iemr/consent/service/version/BuildInfo.java create mode 100644 src/main/java/com/iemr/consent/service/version/VersionService.java create mode 100644 src/main/java/com/iemr/consent/service/version/VersionServiceImpl.java create mode 100644 src/main/java/com/iemr/consent/utils/auth/AuthenticatedUser.java create mode 100644 src/main/java/com/iemr/consent/utils/auth/AuthenticationException.java create mode 100644 src/main/java/com/iemr/consent/utils/auth/AuthenticationFilter.java create mode 100644 src/main/java/com/iemr/consent/utils/auth/ClientType.java create mode 100644 src/main/java/com/iemr/consent/utils/auth/CurrentUser.java create mode 100644 src/main/java/com/iemr/consent/utils/auth/CurrentUserArgumentResolver.java create mode 100644 src/main/java/com/iemr/consent/utils/auth/JwtValidator.java create mode 100644 src/main/java/com/iemr/consent/utils/auth/PublicPaths.java create mode 100644 src/main/java/com/iemr/consent/utils/auth/SessionValidator.java create mode 100644 src/main/java/com/iemr/consent/utils/auth/TokenDenylist.java create mode 100644 src/main/java/com/iemr/consent/utils/cors/OriginFilter.java create mode 100644 src/main/java/com/iemr/consent/utils/cors/OriginPolicy.java create mode 100644 src/main/java/com/iemr/consent/utils/crypto/FieldEncryptor.java create mode 100644 src/main/java/com/iemr/consent/utils/crypto/PhoneMasker.java create mode 100644 src/main/java/com/iemr/consent/utils/crypto/Sha256.java create mode 100644 src/main/java/com/iemr/consent/utils/exception/ConsentApiException.java create mode 100644 src/main/java/com/iemr/consent/utils/exception/ConsentDeniedException.java create mode 100644 src/main/java/com/iemr/consent/utils/exception/ConsentErrorController.java create mode 100644 src/main/java/com/iemr/consent/utils/exception/ErrorCode.java create mode 100644 src/main/java/com/iemr/consent/utils/exception/ErrorResponseWriter.java create mode 100644 src/main/java/com/iemr/consent/utils/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/iemr/consent/utils/http/AfterCommit.java create mode 100644 src/main/java/com/iemr/consent/utils/http/ClientIp.java create mode 100644 src/main/java/com/iemr/consent/utils/http/ForwardedAuth.java create mode 100644 src/main/java/com/iemr/consent/utils/http/PayloadTooLargeException.java create mode 100644 src/main/java/com/iemr/consent/utils/http/RequestLoggingFilter.java create mode 100644 src/main/java/com/iemr/consent/utils/http/RequestSizeLimitFilter.java create mode 100644 src/main/java/com/iemr/consent/utils/http/TraceIdFilter.java create mode 100644 src/main/java/com/iemr/consent/utils/idempotency/IdempotencyKey.java create mode 100644 src/main/java/com/iemr/consent/utils/idempotency/RequestHasher.java create mode 100644 src/main/java/com/iemr/consent/utils/response/ApiResponse.java create mode 100644 src/main/java/com/iemr/consent/utils/response/ErrorBody.java create mode 100644 src/main/java/com/iemr/consent/utils/response/FieldErrorDetail.java create mode 100644 src/main/resources/META-INF/spring.factories create mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/test/java/com/iemr/consent/CaptureFlowTest.java create mode 100644 src/test/java/com/iemr/consent/ConsentApiApplicationTest.java create mode 100644 src/test/java/com/iemr/consent/PreferenceCentreFlowTest.java create mode 100644 src/test/java/com/iemr/consent/config/ApiDocsAvailabilityTest.java create mode 100644 src/test/java/com/iemr/consent/config/ClockConfigTest.java create mode 100644 src/test/java/com/iemr/consent/config/ConsentPropertiesTest.java create mode 100644 src/test/java/com/iemr/consent/config/MissingSettingFailureAnalyzerTest.java create mode 100644 src/test/java/com/iemr/consent/config/RedisConfigTest.java create mode 100644 src/test/java/com/iemr/consent/config/RequiredSettingsTest.java create mode 100644 src/test/java/com/iemr/consent/config/SecurityPropertiesTest.java create mode 100644 src/test/java/com/iemr/consent/config/SwaggerConfigTest.java create mode 100644 src/test/java/com/iemr/consent/controller/consent/ConsentControllerTest.java create mode 100644 src/test/java/com/iemr/consent/controller/evidence/EvidenceControllerTest.java create mode 100644 src/test/java/com/iemr/consent/controller/grant/AccessGrantControllerTest.java create mode 100644 src/test/java/com/iemr/consent/controller/health/HealthControllerTest.java create mode 100644 src/test/java/com/iemr/consent/controller/notice/NoticeControllerTest.java create mode 100644 src/test/java/com/iemr/consent/controller/otp/OtpControllerTest.java create mode 100644 src/test/java/com/iemr/consent/controller/principal/PrincipalConsentControllerTest.java create mode 100644 src/test/java/com/iemr/consent/controller/version/VersionControllerTest.java create mode 100644 src/test/java/com/iemr/consent/data/EntityMappingTest.java create mode 100644 src/test/java/com/iemr/consent/model/DtoPrivacyTest.java create mode 100644 src/test/java/com/iemr/consent/model/principal/PrincipalRequestsTest.java create mode 100644 src/test/java/com/iemr/consent/service/audit/AuditLogServiceImplTest.java create mode 100644 src/test/java/com/iemr/consent/service/consent/ConsentServiceImplTest.java create mode 100644 src/test/java/com/iemr/consent/service/consent/ConsentValidatorTest.java create mode 100644 src/test/java/com/iemr/consent/service/consent/ConsentWriterTest.java create mode 100644 src/test/java/com/iemr/consent/service/consent/ProofRulesTest.java create mode 100644 src/test/java/com/iemr/consent/service/consent/ProofWritersTest.java create mode 100644 src/test/java/com/iemr/consent/service/evidence/EncryptedFileEvidenceStorageTest.java create mode 100644 src/test/java/com/iemr/consent/service/evidence/EvidenceServiceImplTest.java create mode 100644 src/test/java/com/iemr/consent/service/grant/AccessGrantServiceImplTest.java create mode 100644 src/test/java/com/iemr/consent/service/grant/AccessGrantWriterTest.java create mode 100644 src/test/java/com/iemr/consent/service/grant/GrantReplayStoreTest.java create mode 100644 src/test/java/com/iemr/consent/service/health/HealthServiceImplTest.java create mode 100644 src/test/java/com/iemr/consent/service/notice/NoticeServiceImplTest.java create mode 100644 src/test/java/com/iemr/consent/service/otp/CommonApiOtpClientTest.java create mode 100644 src/test/java/com/iemr/consent/service/otp/OtpChallengeStoreTest.java create mode 100644 src/test/java/com/iemr/consent/service/otp/OtpServiceImplTest.java create mode 100644 src/test/java/com/iemr/consent/service/principal/ConsentViewMapperTest.java create mode 100644 src/test/java/com/iemr/consent/service/principal/EffectiveStatusTest.java create mode 100644 src/test/java/com/iemr/consent/service/principal/PendingActionServiceImplTest.java create mode 100644 src/test/java/com/iemr/consent/service/principal/PreferenceChangeWriterTest.java create mode 100644 src/test/java/com/iemr/consent/service/principal/PrincipalConsentServiceImplTest.java create mode 100644 src/test/java/com/iemr/consent/service/principal/PrincipalEventFactoryTest.java create mode 100644 src/test/java/com/iemr/consent/service/principal/WithdrawalWriterTest.java create mode 100644 src/test/java/com/iemr/consent/service/version/VersionServiceImplTest.java create mode 100644 src/test/java/com/iemr/consent/support/ConsentFixtures.java create mode 100644 src/test/java/com/iemr/consent/support/FullChainWebTest.java create mode 100644 src/test/java/com/iemr/consent/support/LogCapture.java create mode 100644 src/test/java/com/iemr/consent/support/PrincipalFixtures.java create mode 100644 src/test/java/com/iemr/consent/support/SampleController.java create mode 100644 src/test/java/com/iemr/consent/support/TestFixtures.java create mode 100644 src/test/java/com/iemr/consent/support/TestTokens.java create mode 100644 src/test/java/com/iemr/consent/utils/auth/AuthenticatedUserTest.java create mode 100644 src/test/java/com/iemr/consent/utils/auth/AuthenticationFilterTest.java create mode 100644 src/test/java/com/iemr/consent/utils/auth/AuthenticationFlowTest.java create mode 100644 src/test/java/com/iemr/consent/utils/auth/CurrentUserArgumentResolverTest.java create mode 100644 src/test/java/com/iemr/consent/utils/auth/JwtValidatorTest.java create mode 100644 src/test/java/com/iemr/consent/utils/auth/PublicPathsTest.java create mode 100644 src/test/java/com/iemr/consent/utils/auth/SessionValidatorTest.java create mode 100644 src/test/java/com/iemr/consent/utils/auth/TokenDenylistTest.java create mode 100644 src/test/java/com/iemr/consent/utils/cors/OriginFilterTest.java create mode 100644 src/test/java/com/iemr/consent/utils/cors/OriginPolicyTest.java create mode 100644 src/test/java/com/iemr/consent/utils/crypto/FieldEncryptorTest.java create mode 100644 src/test/java/com/iemr/consent/utils/crypto/Sha256Test.java create mode 100644 src/test/java/com/iemr/consent/utils/exception/ConsentApiExceptionTest.java create mode 100644 src/test/java/com/iemr/consent/utils/exception/ConsentDeniedExceptionTest.java create mode 100644 src/test/java/com/iemr/consent/utils/exception/ConsentErrorControllerTest.java create mode 100644 src/test/java/com/iemr/consent/utils/exception/ErrorCodeTest.java create mode 100644 src/test/java/com/iemr/consent/utils/exception/ErrorResponseWriterTest.java create mode 100644 src/test/java/com/iemr/consent/utils/exception/GlobalExceptionHandlerTest.java create mode 100644 src/test/java/com/iemr/consent/utils/http/HttpHelpersTest.java create mode 100644 src/test/java/com/iemr/consent/utils/http/RequestLoggingFilterTest.java create mode 100644 src/test/java/com/iemr/consent/utils/http/RequestSizeLimitFilterTest.java create mode 100644 src/test/java/com/iemr/consent/utils/http/ResponseContractTest.java create mode 100644 src/test/java/com/iemr/consent/utils/http/TraceIdFilterTest.java create mode 100644 src/test/java/com/iemr/consent/utils/idempotency/IdempotencyKeyTest.java create mode 100644 src/test/java/com/iemr/consent/utils/response/ApiResponseTest.java diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f8e3125 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +target/ +.git/ +.idea/ +.vscode/ +logs/ +*.log +.env* +Dockerfile* +.dockerignore +src/main/environment/common_local.properties diff --git a/.git-hooks/commit-msg b/.git-hooks/commit-msg new file mode 100755 index 0000000..d29127d --- /dev/null +++ b/.git-hooks/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +set -e + +npx --yes @commitlint/cli@20.4.3 --edit "$1" \ No newline at end of file diff --git a/.github/workflows/build-on-pull-request.yml b/.github/workflows/build-on-pull-request.yml new file mode 100644 index 0000000..96c62b7 --- /dev/null +++ b/.github/workflows/build-on-pull-request.yml @@ -0,0 +1,23 @@ +name: Build On Pull Request +on: + pull_request: + branches: [ "develop", "main" ] + +jobs: + Build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + + - name: Setup JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'temurin' + + - name: Build with Maven (checkstyle, tests, 99% coverage gate) + run: mvn clean install -DENV_VAR=ci diff --git a/.github/workflows/checkstyle.yml b/.github/workflows/checkstyle.yml new file mode 100644 index 0000000..4b0653a --- /dev/null +++ b/.github/workflows/checkstyle.yml @@ -0,0 +1,13 @@ +name: Call Checkstyle + +on: + pull_request: + branches: ["master", "develop", "main"] + paths: + - '**/*.java' + +jobs: + style-check: + uses: PSMRI/.github/.github/workflows/checkstyle.yml@main + with: + java-version: '17' diff --git a/.github/workflows/commit-lint.yml b/.github/workflows/commit-lint.yml new file mode 100644 index 0000000..aaad8ad --- /dev/null +++ b/.github/workflows/commit-lint.yml @@ -0,0 +1,29 @@ +name: Commit Message Check + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + commit-check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Validate commit messages + run: | + set -eo pipefail + commits=$(git log --no-merges --pretty=format:"%H" "${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}") + for sha in $commits; do + tmp=$(mktemp) + git log -1 --pretty=format:"%B" "$sha" > "$tmp" + npx --yes @commitlint/cli@20.4.3 --edit "$tmp" + rm "$tmp" + done diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..2228d0d --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,36 @@ +name: Package + +on: + push: + branches: [ "develop" ] + paths-ignore: + - target/** + pull_request: + branches: [ "develop" ] + paths-ignore: + - target/** + +env: + ENV_VAR: ci + +jobs: + Package-test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'temurin' + + - name: Build WAR with Maven + run: mvn -B clean install -DENV_VAR=${{ env.ENV_VAR }} + + - name: Upload WAR file as artifact + uses: actions/upload-artifact@v4 + with: + name: Consent-API + path: target/consent-api-*.war diff --git a/.github/workflows/sast.yml b/.github/workflows/sast.yml new file mode 100644 index 0000000..f85911a --- /dev/null +++ b/.github/workflows/sast.yml @@ -0,0 +1,53 @@ +name: CodeQL + +on: + push: + branches: [ "develop", "main" ] + paths-ignore: + - target/** + + pull_request: + branches: [ "develop", "main" ] + paths-ignore: + - target/** + +jobs: + analyze: + name: Analyze + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'java' ] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Setup JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'temurin' + + - name: Build with Maven + run: mvn clean install -DENV_VAR=ci + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/testcase-coverage.yml b/.github/workflows/testcase-coverage.yml new file mode 100644 index 0000000..374b114 --- /dev/null +++ b/.github/workflows/testcase-coverage.yml @@ -0,0 +1,46 @@ +name: TestCase Coverage Check + +on: + pull_request: + types: [opened, synchronize] + +permissions: {} + +jobs: + build_and_check_coverage: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Maven dependencies + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2- + + # The build itself fails below 99% line/branch coverage (JaCoCo check, constitution VI). + - name: Build with Maven + run: mvn clean verify -DENV_VAR=ci + + - name: Run Coverage Check + uses: madrapps/jacoco-report@v1.7.2 + with: + paths: target/site/jacoco/jacoco.xml + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 99 + min-coverage-changed-files: 99 + title: Code Coverage Report + comment-type: pr_comment diff --git a/.gitignore b/.gitignore index d6b130c..bd22f21 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,27 @@ .claude/settings.local.json + +# Build output +target/ +*.class +*.jar +*.war + +# IDE +.idea/ +.vscode/ +*.iml +.settings/ +.project +.classpath +.factorypath + +# Logs +logs/ +*.log + +# Local properties (real values, never committed) +src/main/environment/common_local.properties + +# OS +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f99697f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# --- Stage 1: Build the application using Maven --- +FROM maven:3.9.6-eclipse-temurin-17 AS build + +WORKDIR /app + +COPY . . + +# Build the application while caching Maven dependencies to speed up future builds +RUN --mount=type=cache,target=/root/.m2 \ + mvn clean package -DENV_VAR=docker -DskipTests -Dgit.skip=true + +# --- Stage 2: Run the application with a minimal JRE image --- +FROM eclipse-temurin:17-jre + +WORKDIR /app + +# Copy the built WAR file from the build stage +COPY --from=build /app/target/*.war app.war + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "app.war"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..28f5e2e --- /dev/null +++ b/README.md @@ -0,0 +1,175 @@ +# AMRIT - Consent-API + +Consent-API is the AMRIT consent manager for the Digital Personal Data Protection Act, 2023 +(DPDPA). It is the only AMRIT service that stores or decides consent. See the approved +proposal: [docs/proposal/consent-manager-proposal.md](docs/proposal/consent-manager-proposal.md). + +Delivered so far: + +- `001-service-skeleton`: login, the response standard, health/version checks, configuration + and API documentation. +- `002-mmu-consent-capture`: the MMU consent dialog's endpoints (front-end APIs 2–7). +- `003-patient-consent-management`: the preference centre (front-end APIs 8–14) and the + doctor's access request. + +| Method | Path | Purpose | +|---|---|---| +| GET | `/consent/v1/notices/active/languages?context=` | Languages the active notice is published in | +| GET | `/consent/v1/notices/active?context=&lang=` | Active notice, sections, fingerprint and purposes | +| POST | `/consent/v1/otp/challenges` | Send an OTP (through Common-API's consent OTP) | +| POST | `/consent/v1/otp/challenges/{id}/verify` | Verify an OTP | +| POST | `/consent/v1/evidence` | Upload a voice clip or photo (multipart; encrypted at rest) | +| POST | `/consent/v1/consents` | Save Accept or Decline per purpose | +| GET | `/consent/v1/principals/{benRegId}/consents` | A patient's current consents and history | +| GET | `/consent/v1/principals/{benRegId}/pending-actions?context=` | Consent still needed (missing, legacy, re-consent, guardian-to-self) | +| POST | `/consent/v1/principals/{benRegId}/preferences` | Turn purposes on (with OTP) or off | +| POST | `/consent/v1/principals/{benRegId}/withdrawals` | Withdraw purposes or `ALL`; ends doctor access for them | +| GET | `/consent/v1/principals/{benRegId}/access-grants?status=` | A patient's doctor access grants | +| POST | `/consent/v1/access-grants` | A doctor requests time-limited access | +| POST | `/consent/v1/access-grants/{grantId}/approve` | Patient or guardian approves by OTP | +| POST | `/consent/v1/access-grants/{grantId}/revoke` | Cancel a pending or active grant | + +Every write (`POST` above, except verify) needs an **`Idempotency-Key`** header (a UUID): a +retry with the same key returns the original answer and never saves, sends or stores twice. + +## Tech stack + +Java 17 · Spring Boot 3.2.2 · Spring Data JPA (MySQL 8, `db_identity`) · Redis (shared with +Common-API) · jjwt 0.12.6 · springdoc-openapi 2.3.0 · Lombok · WAR (Wildfly) or `java -jar`. + +## Build and run + +```bash +# 1. Local configuration (git-ignored; never commit real values) +cp src/main/environment/common_example.properties src/main/environment/common_local.properties +# edit spring.datasource.*, spring.redis.*, jwt.secret (same as Common-API), cors.allowed-origins + +# 2. Build: checkstyle, tests, JaCoCo >= 99 % line and branch coverage +mvn clean install -DENV_VAR=local + +# 3. Run on port 8095 +mvn spring-boot:run -DENV_VAR=local +``` + +The environment is chosen with `-DENV_VAR=`, as in every AMRIT service: +`src/main/environment/common_.properties` is merged into `application.properties` at +build time. Files: `common_example` (template), `common_local` (git-ignored), `common_ci` +(dummy values for CI) and `common_docker` (runtime environment variables). + +Swagger UI (where enabled): `http://localhost:8095/swagger-ui.html`. + +### Configuration keys + +| Key | Required | Notes | +|---|---|---| +| `spring.datasource.url` / `username` / `password` | yes | `db_identity` only; no schema changes (`ddl-auto=none`) | +| `spring.redis.host` | yes | the Redis instance Common-API uses | +| `spring.redis.port`, `spring.redis.password` | default `6379` / empty | same keys as Common-API | +| `jwt.secret` | yes | same signing secret as Common-API (at least 32 characters) | +| `cors.allowed-origins` | no | comma-separated, `*` wildcard; empty or missing refuses every browser origin | +| `iemr.session.expiry.time`, `iemr.extend.expiry.time`, `enableIPValidation` | defaults `7200` / `true` / `false` | same as Common-API | +| `springdoc.api-docs.enabled`, `springdoc.swagger-ui.enabled` | default `false` | enable in non-production environments | +| `consent.request.max-body-bytes` | default `1048576` | larger bodies get 413 | +| `consent.health.check-timeout-ms` | default `1500` (100–1900) | keeps `/health` under 2 s | +| `consent.redis.timeout-ms` | default `1500` | Redis command timeout | +| `consent.rate-limit.retry-after-seconds` | default `60` | `Retry-After` on 429 | +| `consent.encryption.key` | yes (secret) | Base64 32-byte AES key for personal fields and evidence files | +| `consent.evidence.storage-dir` | yes | writable directory (a mounted volume) for encrypted evidence | +| `consent.common-api.base-url`, `consent.common-api.timeout-ms` | URL yes / `3000` | Common-API, used for OTP | +| `consent.evidence.max-bytes`, `voice-min-seconds`, `voice-max-seconds` | `10485760` / `30` / `60` | TODO-DPO | +| `consent.capture.max-skew-minutes` | `1440` | device time further off is flagged (TODO-DPO) | +| `consent.otp.ttl-seconds`, `retention-minutes`, `max-attempts` | `300` / `30` / `5` | TODO-DPO | +| `consent.notice.audio-base-url` | empty | prefix for notice audio links | +| `consent.access-grant.max-days` | `30` | longest doctor access a patient can approve (TODO-LEGAL) | +| `consent.access-grant.replay-hours` | `24` | how long an approve or revoke request key is remembered | + +A missing required setting stops start-up with `Missing required setting: ` (the +value is never printed). + +### Docker + +```bash +mvn clean package -DENV_VAR=docker -DskipTests +docker build -t consent-api:local . +docker run --rm -p 8095:8080 -e DATABASE_URL=... -e DATABASE_USERNAME=... -e DATABASE_PASSWORD=... \ + -e REDIS_HOST=... -e JWT_SECRET_KEY=... -e CORS_ALLOWED_ORIGINS=... -e SWAGGER_DOC_ENABLED=false \ + -e CONSENT_ENCRYPTION_KEY=... -e EVIDENCE_STORAGE_DIR=/data/evidence -e COMMON_API_BASE_URL=... \ + -v consent-evidence:/data/evidence \ + consent-api:local +``` + +The variable names are the same as Common-API's. + +## Login + +Consent-API accepts the existing AMRIT login; there is no separate login. The first +credential found is used, in Common-API's order: + +1. `Jwttoken` cookie, then 2. `JwtToken` header (web UIs). An `Authorization` header sent + with them must also be a live Redis session. +3. Mobile apps: `User-Agent` containing `okhttp` or `java/` plus the `Authorization` session key. + +Public paths (no login): `/health`, `/version`, and the API docs where enabled. Everything +else, including unknown paths, returns 401 without a valid login. + +## Response standard + +Every response, including refusals by the login and origin checks, uses one body: + +```json +{ + "success": false, + "statusCode": 401, + "message": "Session expired. Please login again", + "data": null, + "error": { "code": "SESSION_EXPIRED", "purposeCode": null, "details": null }, + "timestamp": "2026-10-29T10:00:00Z", + "traceId": "4f1c2a9b7d3e8f60" +} +``` + +- `statusCode` always equals the HTTP status; the legacy 5000–5010 codes are never used. +- Clients act on `error.code`, never on the message text. +- `traceId` equals the `X-Trace-Id` response header and appears in the server logs. + +Error codes (additive only; never renamed or reused): + +| HTTP | Codes | +|---|---| +| 400 | `VALIDATION_FAILED`, `MALFORMED_REQUEST` | +| 401 | `UNAUTHENTICATED`, `SESSION_EXPIRED` | +| 403 | `ACCESS_DENIED`, `ORIGIN_NOT_ALLOWED`, `CONSENT_NOT_FOUND`, `CONSENT_DENIED`, `CONSENT_WITHDRAWN`, `CONSENT_EXPIRED`, `RECONSENT_REQUIRED`, `GUARDIAN_REQUIRED` | +| 404 | `NOT_FOUND` | +| 405 | `METHOD_NOT_ALLOWED` | +| 409 | `STALE_VERSION`, `STATE_CONFLICT` | +| 413 | `PAYLOAD_TOO_LARGE` | +| 415 | `UNSUPPORTED_MEDIA_TYPE` | +| 422 | `BUSINESS_RULE_VIOLATION` | +| 429 | `RATE_LIMITED` | +| 500 | `INTERNAL_ERROR` | +| 422 | `OTP_EXPIRED`, `OTP_INVALID`, `EVIDENCE_HASH_MISMATCH` (also `BUSINESS_RULE_VIOLATION`) | +| 503 | `DEPENDENCY_UNAVAILABLE` | + +The full contracts are in +[specs/001-service-skeleton/contracts/](specs/001-service-skeleton/contracts/consent-api-skeleton.openapi.yaml) +and [specs/002-mmu-consent-capture/contracts/](specs/002-mmu-consent-capture/contracts/consent-capture.openapi.yaml). + +## Database + +`cm_` tables in `db_identity` come from AMRIT-DB migration +`dbidentity/V30__Consent_Manager_Capture.sql` and (doctor access grants) +`dbidentity/V33__Consent_Access_Grant.sql`. Demo notice and purpose text (placeholders, +`TODO-LEGAL`) is a manual script for dev/demo databases only: +`AMRIT-DB/src/main/resources/db/consent_demo/01_consent_demo_seed.sql`. + +## Contributing + +- Commit messages follow Conventional Commits (`commitlint.config.js`). Enable the hook once + with `git config core.hooksPath .git-hooks`; never use `--no-verify`. +- Code must pass `checkstyle.xml`, and new code needs at least 99 % line and branch coverage + with mock-based tests (no real database or Redis). +- Every change follows the project constitution: `.specify/memory/constitution.md`. + +## License + +GNU General Public License v3.0. See [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/). diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 0000000..38e1e1e --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..abc565a --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,35 @@ +module.exports = { + rules: { + 'body-leading-blank': [1, 'always'], + 'body-max-line-length': [2, 'always', 100], + 'footer-leading-blank': [1, 'always'], + 'footer-max-line-length': [2, 'always', 100], + 'header-max-length': [2, 'always', 100], + 'subject-case': [ + 2, + 'never', + ['sentence-case', 'start-case', 'pascal-case', 'upper-case'], + ], + 'subject-empty': [2, 'never'], + 'subject-full-stop': [2, 'never', '.'], + 'type-case': [2, 'always', 'lower-case'], + 'type-empty': [2, 'never'], + 'type-enum': [ + 2, + 'always', + [ + 'build', + 'chore', + 'ci', + 'docs', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'style', + 'test', + ], + ], + }, +}; \ No newline at end of file diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..df71bb6 --- /dev/null +++ b/lombok.config @@ -0,0 +1,2 @@ +config.stopBubbling = true +lombok.addLombokGeneratedAnnotation = true diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..4264f7d --- /dev/null +++ b/pom.xml @@ -0,0 +1,329 @@ + + + 4.0.0 + + com.iemr.consent-API + consent-api + 0.1.0 + war + + Consent-API + Piramal - AMRIT DPDPA Consent Manager API + + + org.springframework.boot + spring-boot-starter-parent + 3.2.2 + + + + UTF-8 + UTF-8 + 17 + 1.18.36 + ${ENV_VAR} + target/classes/application.properties + target/classes/common_${environment}.properties + 0.8.11 + 0.12.6 + jacoco + java + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + com.zaxxer + HikariCP + + + + + org.apache.tomcat + tomcat-jdbc + + + com.mysql + mysql-connector-j + 8.2.0 + runtime + + + org.springframework.boot + spring-boot-starter-data-redis + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.3.0 + + + + org.projectlombok + lombok + ${org.projectlombok.version} + provided + + + co.elastic.logging + logback-ecs-encoder + 1.3.2 + + + + org.springframework.boot + spring-boot-starter-tomcat + provided + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + ${project.artifactId}-${project.version} + + + io.github.git-commit-id + git-commit-id-maven-plugin + 9.0.2 + + + get-the-git-infos + + revision + + initialize + + + + true + ${project.build.outputDirectory}/git.properties + + ^git.branch$ + ^git.commit.id.abbrev$ + ^git.build.version$ + ^git.build.time$ + + false + false + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + 17 + 17 + true + + + org.projectlombok + lombok + ${org.projectlombok.version} + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + filter-resources + process-resources + + resources + + + @ + false + + + src/main/environment + true + + common_${environment}.properties + + + + + + + + + org.apache.maven.plugins + maven-antrun-plugin + 3.1.0 + + + process-resources + + run + + merge-properties + + + + concatenating properties file + ${target-properties} and + ${source-properties} + + + + + + + + + + process-resources + + run + + clean-property-files + + + + + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + @{argLine} + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + jacoco-initialize + + prepare-agent + + + + jacoco-site + verify + + report + + + + jacoco-check + verify + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.99 + + + BRANCH + COVEREDRATIO + 0.99 + + + + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.3.1 + + + com.puppycrawl.tools + checkstyle + 10.12.7 + + + + checkstyle.xml + UTF-8 + true + true + false + + + + validate + validate + + check + + + + + + org.springframework.boot + spring-boot-maven-plugin + 3.2.2 + + + + repackage + + + + + + + diff --git a/specs/003-patient-consent-management/contracts/patient-consent.openapi.yaml b/specs/003-patient-consent-management/contracts/patient-consent.openapi.yaml new file mode 100644 index 0000000..12f5513 --- /dev/null +++ b/specs/003-patient-consent-management/contracts/patient-consent.openapi.yaml @@ -0,0 +1,447 @@ +openapi: 3.0.3 +info: + title: Consent-API — patient consent management (preference centre) + version: 0.3.0 + description: | + Feature 003 endpoints (front-end APIs 8–14, plus the doctor's access request). + Every response uses the standard ApiResponse body from feature 001 (`success`, + `statusCode` = HTTP status, `message`, `data`, `error{code, purposeCode, details}`, + `timestamp`, `traceId`). Only `data` is described per endpoint below. + All endpoints need the AMRIT login. Writes need an `Idempotency-Key` (UUID). A repeat with + the same key and request changes nothing and creates no record; it returns the original + status with: the original grant (access request), the patient's current consents + (preferences, withdrawals), or the grant's current state (approve, revoke). The same key + with a different body, or for a different patient or grant, is 409 STATE_CONFLICT. +servers: + - url: http://localhost:8095 +security: + - JwtTokenHeader: [] + - JwtTokenCookie: [] + - SessionAuthorization: [] + +paths: + /consent/v1/principals/{benRegId}/consents: + get: + summary: Patient's current consents and history (front-end API 8) + parameters: + - $ref: '#/components/parameters/BenRegId' + responses: + '200': + description: Consents (both lists empty for a patient with no consent) + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - properties: + data: { $ref: '#/components/schemas/PatientConsents' } + '400': { $ref: '#/components/responses/ValidationFailed' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '403': { $ref: '#/components/responses/OriginNotAllowed' } + '500': { $ref: '#/components/responses/InternalError' } + '503': { $ref: '#/components/responses/DependencyUnavailable' } + + /consent/v1/principals/{benRegId}/pending-actions: + get: + summary: Consent actions still needed for a patient (front-end API 9) + parameters: + - $ref: '#/components/parameters/BenRegId' + - name: context + in: query + required: false + description: Which active notice defines the purposes needed (default REGISTRATION) + schema: { type: string, enum: [REGISTRATION, REVISIT], default: REGISTRATION } + responses: + '200': + description: Pending actions (empty = nothing to do, H3) + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - properties: + data: + type: array + items: { $ref: '#/components/schemas/PendingAction' } + '400': { $ref: '#/components/responses/ValidationFailed' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '403': { $ref: '#/components/responses/OriginNotAllowed' } + '500': { $ref: '#/components/responses/InternalError' } + '503': { $ref: '#/components/responses/DependencyUnavailable' } + + /consent/v1/principals/{benRegId}/preferences: + post: + summary: Change one or more purposes to GRANT or DENY (front-end API 10) + parameters: + - $ref: '#/components/parameters/BenRegId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/PreferenceRequest' } + responses: + '200': + description: Updated consents (same shape as API 8) + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - properties: + data: { $ref: '#/components/schemas/PatientConsents' } + '400': { $ref: '#/components/responses/ValidationFailed' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '403': { $ref: '#/components/responses/OriginNotAllowed' } + '409': + description: | + STALE_VERSION — a version in `versions` is not the current one (C20). + STATE_CONFLICT — decision equals the current status, or request key reused with a + different body or for a different patient. + '422': + description: | + PURPOSE_NOT_WITHDRAWABLE (`error.purposeCode`) — DENY of a granted purpose that + cannot be withdrawn. + BUSINESS_RULE_VIOLATION (`error.purposeCode`) — no current decision for the purpose, + or GRANT of a purpose that needs re-consent / is expired / has a newer material + notice (use the full consent screen). + OTP_INVALID / OTP_EXPIRED — GRANT without a usable OTP challenge for this patient. + '500': { $ref: '#/components/responses/InternalError' } + '503': { $ref: '#/components/responses/DependencyUnavailable' } + + /consent/v1/principals/{benRegId}/withdrawals: + post: + summary: Withdraw purposes or ALL (front-end API 11) + parameters: + - $ref: '#/components/parameters/BenRegId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/WithdrawalRequest' } + responses: + '200': + description: Updated consents, plus the purposes `ALL` left unchanged + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - properties: + data: { $ref: '#/components/schemas/WithdrawalResult' } + '400': { $ref: '#/components/responses/ValidationFailed' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '403': { $ref: '#/components/responses/OriginNotAllowed' } + '409': + description: | + STALE_VERSION — a version is not the current one, or a purpose that `ALL` would + withdraw is missing from `versions`. + STATE_CONFLICT — a named purpose is not currently GRANTED, `ALL` finds nothing to + withdraw, or request key reused with a different body or for a different patient / grant. + '422': + description: | + PURPOSE_NOT_WITHDRAWABLE (`error.purposeCode`) — a named purpose cannot be + withdrawn (C12); nothing is changed. + BUSINESS_RULE_VIOLATION (`error.purposeCode`) — no current decision for the purpose. + '500': { $ref: '#/components/responses/InternalError' } + '503': { $ref: '#/components/responses/DependencyUnavailable' } + + /consent/v1/principals/{benRegId}/access-grants: + get: + summary: Patient's access grants (front-end API 12) + parameters: + - $ref: '#/components/parameters/BenRegId' + - name: status + in: query + required: false + description: Comma-separated statuses; omitted = all. EXPIRED matches grants past valid-until. + schema: { type: string, example: 'ACTIVE,PENDING' } + responses: + '200': + description: Grants, newest first + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - properties: + data: + type: array + items: { $ref: '#/components/schemas/AccessGrant' } + '400': { $ref: '#/components/responses/ValidationFailed' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '403': { $ref: '#/components/responses/OriginNotAllowed' } + '500': { $ref: '#/components/responses/InternalError' } + '503': { $ref: '#/components/responses/DependencyUnavailable' } + + /consent/v1/access-grants: + post: + summary: Doctor requests time-limited access to a patient (clarification 2) + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/AccessRequest' } + responses: + '201': + description: PENDING grant; the grantee is the logged-in user + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - properties: + data: { $ref: '#/components/schemas/AccessGrant' } + '400': { $ref: '#/components/responses/ValidationFailed' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '403': { $ref: '#/components/responses/OriginNotAllowed' } + '409': { description: STATE_CONFLICT — request key reused with a different body or for a different patient / grant } + '500': { $ref: '#/components/responses/InternalError' } + '503': { $ref: '#/components/responses/DependencyUnavailable' } + + /consent/v1/access-grants/{grantId}/approve: + post: + summary: Patient or guardian approves a pending grant by OTP (front-end API 13) + parameters: + - $ref: '#/components/parameters/GrantId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [otpChallengeId] + properties: + otpChallengeId: + type: string + format: uuid + description: From API 4 with purpose ACCESS_GRANT and refId = grantId + responses: + '200': + description: The grant, now ACTIVE + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - properties: + data: { $ref: '#/components/schemas/AccessGrant' } + '400': { $ref: '#/components/responses/ValidationFailed' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '403': { $ref: '#/components/responses/OriginNotAllowed' } + '404': { description: NOT_FOUND — no such grant } + '409': { description: 'STATE_CONFLICT — grant not PENDING, is BREAK_GLASS, or request key reused with a different body or for a different patient / grant' } + '422': { description: 'OTP_INVALID / OTP_EXPIRED — challenge not verified, used, for another grant, or (guardian case) another phone' } + '500': { $ref: '#/components/responses/InternalError' } + '503': { $ref: '#/components/responses/DependencyUnavailable' } + + /consent/v1/access-grants/{grantId}/revoke: + post: + summary: Cancel a pending or active grant (front-end API 14) + parameters: + - $ref: '#/components/parameters/GrantId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: { type: object, description: 'Empty object {}' } + responses: + '200': + description: The grant, now REVOKED + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - properties: + data: { $ref: '#/components/schemas/AccessGrant' } + '400': { $ref: '#/components/responses/ValidationFailed' } + '401': { $ref: '#/components/responses/Unauthenticated' } + '403': { $ref: '#/components/responses/OriginNotAllowed' } + '404': { description: NOT_FOUND — no such grant } + '409': { description: 'STATE_CONFLICT — grant already REVOKED or EXPIRED, or request key reused with a different body or for a different patient / grant' } + '500': { $ref: '#/components/responses/InternalError' } + '503': { $ref: '#/components/responses/DependencyUnavailable' } + +components: + securitySchemes: + JwtTokenHeader: { type: apiKey, in: header, name: JwtToken } + JwtTokenCookie: { type: apiKey, in: cookie, name: Jwttoken } + SessionAuthorization: { type: apiKey, in: header, name: Authorization } + + parameters: + BenRegId: + name: benRegId + in: path + required: true + schema: { type: integer, format: int64, minimum: 1 } + GrantId: + name: grantId + in: path + required: true + schema: { type: string, format: uuid } + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: { type: string, format: uuid } + + responses: + ValidationFailed: { description: 'VALIDATION_FAILED / MALFORMED_REQUEST' } + Unauthenticated: { description: 'UNAUTHENTICATED / SESSION_EXPIRED' } + OriginNotAllowed: { description: ORIGIN_NOT_ALLOWED } + InternalError: { description: INTERNAL_ERROR } + DependencyUnavailable: { description: 'DEPENDENCY_UNAVAILABLE — database or Redis' } + + schemas: + ApiResponse: + $ref: '../../001-service-skeleton/contracts/consent-api-skeleton.openapi.yaml#/components/schemas/ApiResponse' + + PurposeStatus: + type: string + enum: [GRANTED, DENIED, WITHDRAWN, EXPIRED, RECONSENT_REQUIRED, SUPERSEDED] + + CurrentDecision: + type: object + properties: + purposeCode: { type: string, example: P-REM } + name: { type: string } + isMandatory: { type: boolean } + isWithdrawable: { type: boolean } + status: { $ref: '#/components/schemas/PurposeStatus' } + validUntil: { type: string, format: date-time, nullable: true } + versionNo: { type: integer, minimum: 1 } + + ConsentEvent: + type: object + properties: + consentId: { type: string, format: uuid } + eventType: { type: string, enum: [NEW, UPDATE, RENEWAL, WITHDRAWAL, LEGACY] } + consentingParty: { type: string, enum: [SELF, GUARDIAN] } + noticeVersionId: { type: integer, format: int64 } + languageCode: { type: string } + mode: { type: string, nullable: true, enum: [SELF_READ, READ_ALOUD, VOICE_CLIP, WITNESSED, SIGNATURE_PHOTO, OTP] } + capturedBy: { type: integer, format: int64 } + capturedAt: { type: string, format: date-time } + facilityId: { type: integer, nullable: true } + serviceLine: { type: string, nullable: true } + deviceId: { type: string } + syncStatus: { type: string, enum: [SYNCED, NOT_SYNCED] } + purposes: + type: array + items: + type: object + properties: + purposeCode: { type: string } + status: { $ref: '#/components/schemas/PurposeStatus' } + versionNo: { type: integer } + + PatientConsents: + type: object + properties: + current: + type: array + items: { $ref: '#/components/schemas/CurrentDecision' } + history: + type: array + description: Newest first + items: { $ref: '#/components/schemas/ConsentEvent' } + + WithdrawalResult: + allOf: + - $ref: '#/components/schemas/PatientConsents' + - type: object + properties: + notWithdrawn: + type: array + description: With ALL — granted purposes left unchanged because they cannot be withdrawn + items: { type: string } + + PendingAction: + type: object + properties: + reason: { type: string, enum: [CONSENT_MISSING, LEGACY, RECONSENT_REQUIRED, GUARDIAN_TO_SELF] } + purposeCodes: { type: array, items: { type: string } } + dueSince: { type: string, format: date-time, nullable: true } + + Versions: + type: object + description: Version last seen per purpose code + additionalProperties: { type: integer, minimum: 1 } + example: { P-REM: 2 } + + ContextFields: + type: object + properties: + deviceId: { type: string, maxLength: 100, nullable: true } + facilityId: { type: integer, nullable: true } + providerServiceMapId: { type: integer, nullable: true } + serviceLine: { type: string, maxLength: 30, nullable: true } + + PreferenceRequest: + allOf: + - $ref: '#/components/schemas/ContextFields' + - type: object + required: [purposeCodes, decision, versions] + properties: + purposeCodes: { type: array, minItems: 1, maxItems: 50, uniqueItems: true, items: { type: string } } + decision: { type: string, enum: [GRANT, DENY] } + versions: { $ref: '#/components/schemas/Versions' } + otpChallengeId: + type: string + format: uuid + nullable: true + description: Required for GRANT — API 4 challenge (CONSENT, or GUARDIAN for a guardian's consent) with refId = benRegId, verified + + WithdrawalRequest: + allOf: + - $ref: '#/components/schemas/ContextFields' + - type: object + required: [purposeCodes, versions] + properties: + purposeCodes: + oneOf: + - type: array + minItems: 1 + maxItems: 50 + uniqueItems: true + items: { type: string } + - type: string + enum: [ALL] + versions: { $ref: '#/components/schemas/Versions' } + + AccessRequest: + type: object + required: [beneficiaryRegId, purposeCodes, days, reason] + properties: + beneficiaryRegId: { type: integer, format: int64, minimum: 1 } + purposeCodes: { type: array, minItems: 1, maxItems: 20, uniqueItems: true, items: { type: string } } + days: { type: integer, minimum: 1, description: 'Maximum = consent.access-grant.max-days (TODO-LEGAL, default 30)' } + reason: { type: string, minLength: 1, maxLength: 255 } + facilityId: { type: integer, nullable: true } + + AccessGrant: + type: object + properties: + grantId: { type: string, format: uuid } + beneficiaryRegId: { type: integer, format: int64 } + grantee: + type: object + properties: + userId: { type: integer, format: int64 } + name: { type: string } + facilityId: { type: integer, nullable: true } + purposes: { type: array, items: { type: string } } + type: { type: string, enum: [CONSENTED, BREAK_GLASS] } + status: { type: string, enum: [PENDING, ACTIVE, REVOKED, EXPIRED] } + days: { type: integer } + reason: { type: string } + validUntil: { type: string, format: date-time, nullable: true } + approvedAt: { type: string, format: date-time, nullable: true } + revokedAt: { type: string, format: date-time, nullable: true } + revokeReason: { type: string, nullable: true, enum: [PATIENT_REQUEST, WITHDRAWAL] } diff --git a/src/main/environment/common_ci.properties b/src/main/environment/common_ci.properties new file mode 100644 index 0000000..64094a8 --- /dev/null +++ b/src/main/environment/common_ci.properties @@ -0,0 +1,27 @@ +# Consent-API CI configuration: dummy values only, no real secrets. + +server.port=8095 + +spring.datasource.url=jdbc:mysql://localhost:3306/db_identity +spring.datasource.username=ci_user +spring.datasource.password=ci_password +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +spring.redis.host=localhost +spring.redis.port=6379 +spring.redis.password= + +jwt.secret=ci-only-dummy-secret-not-used-anywhere-real-0123456789 + +cors.allowed-origins=http://localhost:* + +springdoc.api-docs.enabled=true +springdoc.swagger-ui.enabled=true + +# Consent capture: dummy CI values only +consent.encryption.key=Y2ktb25seS1kdW1teS1lbmNyeXB0aW9uLWtleS0zMmI= +consent.evidence.storage-dir=target/ci-evidence +consent.common-api.base-url=http://localhost:8083/commonapi-v3.0.0 +consent.notice.audio-base-url= + +logging.file.name=logs/consent-api.log diff --git a/src/main/environment/common_docker.properties b/src/main/environment/common_docker.properties new file mode 100644 index 0000000..1c39beb --- /dev/null +++ b/src/main/environment/common_docker.properties @@ -0,0 +1,23 @@ +# Consent-API Docker configuration. Values come from environment variables at runtime, +# using the same variable names as Common-API. An unset variable stops start-up. + +spring.datasource.url=${DATABASE_URL} +spring.datasource.username=${DATABASE_USERNAME} +spring.datasource.password=${DATABASE_PASSWORD} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +spring.redis.host=${REDIS_HOST} + +jwt.secret=${JWT_SECRET_KEY} + +cors.allowed-origins=${CORS_ALLOWED_ORIGINS} + +springdoc.api-docs.enabled=${SWAGGER_DOC_ENABLED} +springdoc.swagger-ui.enabled=${SWAGGER_DOC_ENABLED} + +consent.encryption.key=${CONSENT_ENCRYPTION_KEY} +consent.evidence.storage-dir=${EVIDENCE_STORAGE_DIR} +consent.common-api.base-url=${COMMON_API_BASE_URL} +consent.notice.audio-base-url=${NOTICE_AUDIO_BASE_URL} + +logging.file.name=logs/consent-api.log diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties new file mode 100644 index 0000000..84a3abb --- /dev/null +++ b/src/main/environment/common_example.properties @@ -0,0 +1,35 @@ +# Consent-API local configuration TEMPLATE (placeholders only - never commit real values). +# Copy to common_local.properties (git-ignored) and build with -DENV_VAR=local. + +server.port=8095 + +# Consent data store: db_identity only (constitution, FR-019) +spring.datasource.url=jdbc:mysql://localhost:3306/db_identity +spring.datasource.username= +spring.datasource.password= +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +# Redis: the SAME instance Common-API uses (login sessions and logout denylist) +spring.redis.host=localhost +spring.redis.port=6379 +spring.redis.password= + +# Same signing secret as Common-API (at least 32 characters) +jwt.secret= + +# Comma-separated browser origins; * is a wildcard. Empty = all browser origins refused. +cors.allowed-origins=http://localhost:* + +springdoc.api-docs.enabled=true +springdoc.swagger-ui.enabled=true + +# Consent capture (feature 002) +# Base64 32-byte AES key, e.g. `openssl rand -base64 32`. A secret: never commit a real one. +consent.encryption.key=91/8HVw9yKxsv2L12sPE/mwpSX69WxqOA09w+NTHHh8= +consent.evidence.storage-dir=/home/nd/consent-evidence +consent.common-api.base-url=http://localhost:8083 +consent.notice.audio-base-url= +# Longest doctor access a patient can approve, in days (TODO-LEGAL; default 30) +consent.access-grant.max-days=30 + +logging.file.name=logs/consent-api.log diff --git a/src/main/java/com/iemr/consent/ConsentApiApplication.java b/src/main/java/com/iemr/consent/ConsentApiApplication.java new file mode 100644 index 0000000..e72ce4a --- /dev/null +++ b/src/main/java/com/iemr/consent/ConsentApiApplication.java @@ -0,0 +1,49 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; + +/** + * Entry point of Consent-API, the AMRIT DPDPA consent manager. + * + *

Deployed as a WAR on Wildfly (through {@link SpringBootServletInitializer}) or run with + * {@code java -jar}, the same way as Common-API's {@code CommonApplication}.

+ */ +@SpringBootApplication +@ConfigurationPropertiesScan +public class ConsentApiApplication extends SpringBootServletInitializer { + + public static void main(String[] args) { + SpringApplication.run(ConsentApiApplication.class, args); + } + + @Override + protected SpringApplicationBuilder configure(SpringApplicationBuilder application) { + return application.sources(ConsentApiApplication.class); + } +} diff --git a/src/main/java/com/iemr/consent/config/ClockConfig.java b/src/main/java/com/iemr/consent/config/ClockConfig.java new file mode 100644 index 0000000..d9c22c5 --- /dev/null +++ b/src/main/java/com/iemr/consent/config/ClockConfig.java @@ -0,0 +1,41 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.config; + +import java.time.Clock; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Provides the single {@link Clock} used for all time logic, so tests can fix the time + * (constitution Principle VI). + */ +@Configuration +public class ClockConfig { + + @Bean + public Clock clock() { + return Clock.systemUTC(); + } +} diff --git a/src/main/java/com/iemr/consent/config/CommonApiClientConfig.java b/src/main/java/com/iemr/consent/config/CommonApiClientConfig.java new file mode 100644 index 0000000..3285f29 --- /dev/null +++ b/src/main/java/com/iemr/consent/config/CommonApiClientConfig.java @@ -0,0 +1,41 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +/** HTTP client for Common-API (research R11): base URL and timeouts from {@code consent.common-api.*}. */ +@Configuration +public class CommonApiClientConfig { + + @Bean + public RestClient commonApiRestClient(ConsentProperties properties) { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(properties.commonApi().timeoutMs()); + factory.setReadTimeout(properties.commonApi().timeoutMs()); + return RestClient.builder().baseUrl(properties.commonApi().baseUrl()).requestFactory(factory).build(); + } +} diff --git a/src/main/java/com/iemr/consent/config/ConsentProperties.java b/src/main/java/com/iemr/consent/config/ConsentProperties.java new file mode 100644 index 0000000..4eab915 --- /dev/null +++ b/src/main/java/com/iemr/consent/config/ConsentProperties.java @@ -0,0 +1,127 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.config; + +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; +import org.springframework.validation.annotation.Validated; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; + +/** + * Consent-API's own settings, prefix {@code consent} (research R2–R11 of features 001 and 002). + * + *

Values marked TODO-DPO are placeholders until the DPO signs them off (constitution X): + * evidence size and voice-clip length, capture-time skew, OTP retention and wrong-code limit. + * {@code evidence.storageDir}, {@code commonApi.baseUrl} and {@code encryption.key} have no + * default; start-up fails, naming the key, when they are missing.

+ */ +@Validated +@ConfigurationProperties(prefix = "consent") +public record ConsentProperties(@Valid @DefaultValue Request request, @Valid @DefaultValue Health health, + @Valid @DefaultValue Redis redis, @Valid @DefaultValue RateLimit rateLimit, + @Valid @DefaultValue Evidence evidence, @Valid @DefaultValue Capture capture, + @Valid @DefaultValue CommonApi commonApi, @Valid @DefaultValue Notice notice, + @Valid @DefaultValue Otp otp, @Valid @DefaultValue Encryption encryption, + @Valid @DefaultValue AccessGrant accessGrant) { + + /** @param maxBodyBytes largest accepted request body, in bytes */ + public record Request(@DefaultValue("1048576") @Positive long maxBodyBytes) { + } + + /** @param checkTimeoutMs per-dependency health-check timeout; keeps {@code /health} under 2 s */ + public record Health(@DefaultValue("1500") @Min(100) @Max(1900) long checkTimeoutMs) { + } + + /** @param timeoutMs Redis command timeout */ + public record Redis(@DefaultValue("1500") @Positive long timeoutMs) { + } + + /** @param retryAfterSeconds value of the {@code Retry-After} header on {@code RATE_LIMITED} */ + public record RateLimit(@DefaultValue("60") @Positive long retryAfterSeconds) { + } + + /** + * Proof files (TODO-DPO limits; voice length per proposal §3.1, 30–60 s). + * + * @param storageDir directory of the encrypted evidence store + * @param maxBytes largest accepted file + * @param voiceMinSeconds shortest voice clip + * @param voiceMaxSeconds longest voice clip + * @param allowedContentTypes accepted file content types + */ + public record Evidence(@NotBlank String storageDir, @DefaultValue("10485760") @Positive long maxBytes, + @DefaultValue("30") @Min(1) int voiceMinSeconds, + @DefaultValue("60") @Min(1) int voiceMaxSeconds, + @DefaultValue({ "audio/webm", "audio/ogg", "audio/mpeg", "audio/wav", "image/jpeg", + "image/png" }) @NotEmpty List allowedContentTypes) { + } + + /** @param maxSkewMinutes device capture time further than this from server time is flagged (TODO-DPO) */ + public record Capture(@DefaultValue("1440") @Positive long maxSkewMinutes) { + } + + /** + * @param baseUrl Common-API base URL, e.g. {@code http://localhost:8083/commonapi-v3.0.0} + * @param timeoutMs connect and read timeout for Common-API calls + */ + public record CommonApi(@NotBlank String baseUrl, @DefaultValue("3000") @Positive int timeoutMs) { + } + + /** @param audioBaseUrl prefix for a notice translation's {@code audio_ref}; empty = no audio link */ + public record Notice(@DefaultValue("") String audioBaseUrl) { + } + + /** + * OTP challenges (TODO-DPO values). + * + * @param ttlSeconds code validity, as Common-API's 5 minutes + * @param retentionMinutes how long the challenge record is kept, so a save after a late verify works + * @param maxAttempts wrong codes allowed before the challenge is locked + */ + public record Otp(@DefaultValue("300") @Min(30) long ttlSeconds, + @DefaultValue("30") @Positive long retentionMinutes, + @DefaultValue("5") @Min(1) int maxAttempts) { + } + + /** @param key Base64 AES-256 key of the shared field and file encryption (a secret) */ + public record Encryption(@NotBlank String key) { + } + + /** + * Doctor access grants (feature 003). + * + * @param maxDays longest access a doctor may request, in days (TODO-LEGAL) + * @param replayHours how long an approve or revoke request key is remembered for replay + */ + public record AccessGrant(@DefaultValue("30") @Min(1) int maxDays, + @DefaultValue("24") @Min(1) int replayHours) { + } +} diff --git a/src/main/java/com/iemr/consent/config/MissingSettingFailureAnalyzer.java b/src/main/java/com/iemr/consent/config/MissingSettingFailureAnalyzer.java new file mode 100644 index 0000000..0d7686d --- /dev/null +++ b/src/main/java/com/iemr/consent/config/MissingSettingFailureAnalyzer.java @@ -0,0 +1,85 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.config; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.boot.context.properties.bind.validation.BindValidationException; +import org.springframework.boot.diagnostics.FailureAnalysis; +import org.springframework.boot.diagnostics.FailureAnalyzer; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; + +/** + * Turns a start-up failure caused by a missing or invalid setting into one line naming the + * setting, never its value (FR-016, research R9). Handles: + *
    + *
  • {@link BindValidationException}: a {@code consent.*} value failed validation;
  • + *
  • an unresolvable {@code ${NAME}} placeholder, e.g. an unset Docker variable;
  • + *
  • {@code IllegalStateException("Missing required setting: …")}, e.g. a blank + * {@code jwt.secret}.
  • + *
+ * Any other failure is left to Spring Boot. + */ +public class MissingSettingFailureAnalyzer implements FailureAnalyzer { + + public static final String PREFIX = "Missing required setting: "; + + static final String ACTION = "Set it in src/main/environment/common_.properties (see " + + "common_example.properties), or as an environment variable for the Docker image."; + + private static final Pattern PLACEHOLDER = Pattern.compile("Could not resolve placeholder '([^']+)'"); + + @Override + public FailureAnalysis analyze(Throwable failure) { + for (Throwable cause = failure; cause != null; cause = cause.getCause()) { + String setting = settingName(cause); + if (setting != null) { + return new FailureAnalysis(PREFIX + setting, ACTION, cause); + } + } + return null; + } + + private static String settingName(Throwable cause) { + if (cause instanceof BindValidationException bind) { + return bindName(bind); + } + String message = cause.getMessage(); + if (message == null) { + return null; + } + if (cause instanceof IllegalStateException && message.startsWith(PREFIX)) { + return message.substring(PREFIX.length()); + } + Matcher matcher = PLACEHOLDER.matcher(message); + return cause instanceof IllegalArgumentException && matcher.find() ? matcher.group(1) : null; + } + + private static String bindName(BindValidationException bind) { + String prefix = bind.getValidationErrors().getName().toString(); + ObjectError error = bind.getValidationErrors().getAllErrors().get(0); + return error instanceof FieldError field ? prefix + "." + field.getField() : prefix; + } +} diff --git a/src/main/java/com/iemr/consent/config/RedisConfig.java b/src/main/java/com/iemr/consent/config/RedisConfig.java new file mode 100644 index 0000000..0f045a4 --- /dev/null +++ b/src/main/java/com/iemr/consent/config/RedisConfig.java @@ -0,0 +1,84 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.config; + +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisPassword; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; + +/** + * Connects to the Redis instance shared with Common-API (login sessions and the logout + * denylist). + * + *

The connection is built from Common-API's {@code spring.redis.*} keys, as in Common-API's + * {@code IEMRApplBeans#connectionFactory}. Spring Boot 3.2 auto-configuration only reads + * {@code spring.data.redis.*}, so without this bean {@code REDIS_HOST} would be silently + * ignored.

+ * + *

Two templates match how Common-API writes each kind of key:

+ *
    + *
  • {@code stringRedisTemplate}: plain UTF-8 keys and values, for login sessions + * (Common-API's {@code RedisStorage} uses {@code key.getBytes()}).
  • + *
  • {@code redisTemplate}: Spring's default JDK key serialisation, for the + * {@code denied_} denylist. Common-API's {@code RedisTemplate} sets no key + * serializer, so its denylist keys are JDK-serialised strings; a plain-string lookup would + * never find a logged-out token.
  • + *
+ */ +@Configuration +public class RedisConfig { + + @Bean + public LettuceConnectionFactory redisConnectionFactory(@Value("${spring.redis.host}") String host, + @Value("${spring.redis.port}") int port, @Value("${spring.redis.password:}") String password, + ConsentProperties properties) { + RedisStandaloneConfiguration server = new RedisStandaloneConfiguration(host, port); + if (!password.isBlank()) { + server.setPassword(RedisPassword.of(password)); + } + LettuceClientConfiguration client = LettuceClientConfiguration.builder() + .commandTimeout(Duration.ofMillis(properties.redis().timeoutMs())).build(); + return new LettuceConnectionFactory(server, client); + } + + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { + return new StringRedisTemplate(connectionFactory); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + return template; + } +} diff --git a/src/main/java/com/iemr/consent/config/RequiredSettings.java b/src/main/java/com/iemr/consent/config/RequiredSettings.java new file mode 100644 index 0000000..321f2bf --- /dev/null +++ b/src/main/java/com/iemr/consent/config/RequiredSettings.java @@ -0,0 +1,79 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.config; + +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.EnvironmentAware; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +/** + * Makes start-up fail, naming the setting, when a database or consent-capture setting is missing + * (FR-016). + * + *

Spring Boot binds {@code spring.datasource.*} leniently: an unset Docker variable such as + * {@code ${DATABASE_URL}} is kept as literal text and the service fails later with an unclear + * driver error. This check runs as a {@link BeanFactoryPostProcessor}, before any bean (including + * the datasource) is created, and reads the keys strictly: an unset variable or a blank URL / + * user name stops start-up, and {@link MissingSettingFailureAnalyzer} names the setting. Values + * are never logged.

+ */ +@Component +public class RequiredSettings implements BeanFactoryPostProcessor, EnvironmentAware { + + /** Feature 002 settings with no default; Docker supplies them as environment variables. */ + static final String[] CONSENT_KEYS = {"consent.encryption.key", "consent.evidence.storage-dir", + "consent.common-api.base-url"}; + + private Environment environment; + + @Override + public void setEnvironment(Environment environment) { + this.environment = environment; + } + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) { + check(environment); + } + + /** + * @throws IllegalStateException a blank URL or user name + * @throws IllegalArgumentException an unresolvable {@code ${VARIABLE}} in any datasource key + */ + static void check(Environment environment) { + require(environment.getProperty("spring.datasource.url"), "spring.datasource.url"); + require(environment.getProperty("spring.datasource.username"), "spring.datasource.username"); + environment.getProperty("spring.datasource.password"); + for (String key : CONSENT_KEYS) { + require(environment.getProperty(key), key); + } + } + + private static void require(String value, String key) { + if (value == null || value.isBlank()) { + throw new IllegalStateException(MissingSettingFailureAnalyzer.PREFIX + key); + } + } +} diff --git a/src/main/java/com/iemr/consent/config/SecurityProperties.java b/src/main/java/com/iemr/consent/config/SecurityProperties.java new file mode 100644 index 0000000..69dc06b --- /dev/null +++ b/src/main/java/com/iemr/consent/config/SecurityProperties.java @@ -0,0 +1,82 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.config; + +import java.util.Arrays; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import lombok.Getter; + +/** + * Login and origin settings shared with Common-API. + * + *

These are root-level keys inherited from Common-API ({@code jwt.secret}, + * {@code cors.allowed-origins}, {@code iemr.*}, {@code enableIPValidation}), not one prefix, so + * they are read with {@code @Value} instead of {@code @ConfigurationProperties}. A blank + * {@code jwt.secret} stops start-up with a message naming the key, never its value + * (FR-016).

+ */ +@Getter +@Component +public class SecurityProperties { + + private final String jwtSecret; + + private final List allowedOrigins; + + private final int sessionExpirySeconds; + + private final boolean extendSessionExpiry; + + private final boolean ipValidationEnabled; + + public SecurityProperties(@Value("${jwt.secret:}") String jwtSecret, + @Value("${cors.allowed-origins:}") String allowedOrigins, + @Value("${iemr.session.expiry.time:7200}") int sessionExpirySeconds, + @Value("${iemr.extend.expiry.time:true}") boolean extendSessionExpiry, + @Value("${enableIPValidation:false}") boolean ipValidationEnabled) { + if (jwtSecret == null || jwtSecret.isBlank()) { + throw new IllegalStateException("Missing required setting: jwt.secret"); + } + this.jwtSecret = jwtSecret; + this.allowedOrigins = parseOrigins(allowedOrigins); + this.sessionExpirySeconds = sessionExpirySeconds; + this.extendSessionExpiry = extendSessionExpiry; + this.ipValidationEnabled = ipValidationEnabled; + } + + private static List parseOrigins(String origins) { + if (origins == null) { + return List.of(); + } + return Arrays.stream(origins.split(",")).map(String::trim).filter(s -> !s.isEmpty()).toList(); + } + + @Override + public String toString() { + return "SecurityProperties[allowedOrigins=" + allowedOrigins + "]"; + } +} diff --git a/src/main/java/com/iemr/consent/config/SwaggerConfig.java b/src/main/java/com/iemr/consent/config/SwaggerConfig.java new file mode 100644 index 0000000..9df0141 --- /dev/null +++ b/src/main/java/com/iemr/consent/config/SwaggerConfig.java @@ -0,0 +1,188 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.config; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springdoc.core.customizers.GlobalOpenApiCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.iemr.consent.service.version.VersionService; +import com.iemr.consent.utils.exception.ErrorCode; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.BooleanSchema; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.IntegerSchema; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; + +/** + * API documentation (FR-021, SC-007, research R11), matching + * {@code specs/001-service-skeleton/contracts/consent-api-skeleton.openapi.yaml}. + * + *
    + *
  • Three login schemes so Swagger UI's "Authorize" works: {@code JwtToken} header, + * {@code Jwttoken} cookie and the {@code Authorization} session key.
  • + *
  • The {@code ApiResponse}, {@code ErrorBody}, {@code FieldErrorDetail} and {@code ErrorCode} + * schemas; the {@code ErrorCode} enum is built from {@link ErrorCode#values()}, so the published + * list cannot drift from the code.
  • + *
  • Standard 401, 403, 500 and 503 responses on every operation; {@code /health} and + * {@code /version} are public (no security).
  • + *
+ * Docs are served only where {@code springdoc.api-docs.enabled=true}. + */ +@Configuration +public class SwaggerConfig { + + static final String JWT_HEADER_SCHEME = "JwtTokenHeader"; + + static final String JWT_COOKIE_SCHEME = "JwtTokenCookie"; + + static final String SESSION_SCHEME = "SessionAuthorization"; + + static final Set PUBLIC_PATHS = Set.of("/health", "/version"); + + private static final String SCHEMAS = "#/components/schemas/"; + + private static final Map STANDARD_ERRORS = Map.of("401", "Not logged in or session expired", + "403", "Origin not allowed, access denied or no consent", "500", "Unexpected error", "503", + "Required dependency unavailable"); + + @Bean + public OpenAPI consentOpenApi(VersionService versionService) { + Info info = new Info().title("Consent-API") + .description("AMRIT DPDPA consent manager. " + + "Every response uses the standard ApiResponse body.") + .version(versionService.getBuildInfo().version()); + Components components = new Components() + .addSecuritySchemes(JWT_HEADER_SCHEME, apiKey(SecurityScheme.In.HEADER, "JwtToken")) + .addSecuritySchemes(JWT_COOKIE_SCHEME, apiKey(SecurityScheme.In.COOKIE, "Jwttoken")) + .addSecuritySchemes(SESSION_SCHEME, apiKey(SecurityScheme.In.HEADER, "Authorization")); + return new OpenAPI().info(info).components(components) + .addSecurityItem(new SecurityRequirement().addList(JWT_HEADER_SCHEME)) + .addSecurityItem(new SecurityRequirement().addList(JWT_COOKIE_SCHEME)) + .addSecurityItem(new SecurityRequirement().addList(SESSION_SCHEME)); + } + + @Bean + public GlobalOpenApiCustomizer standardResponsesCustomizer() { + return SwaggerConfig::customize; + } + + static void customize(OpenAPI openApi) { + if (openApi.getComponents() == null) { + openApi.setComponents(new Components()); + } + openApi.getComponents().addSchemas("ErrorCode", errorCodeSchema()) + .addSchemas("FieldErrorDetail", fieldErrorDetailSchema()) + .addSchemas("ErrorBody", errorBodySchema()) + .addSchemas("ApiResponse", apiResponseSchema()); + if (openApi.getPaths() == null) { + return; + } + openApi.getPaths().forEach((path, item) -> customize(path, item)); + } + + private static void customize(String path, PathItem item) { + for (Operation operation : item.readOperations()) { + if (operation.getResponses() == null) { + operation.setResponses(new ApiResponses()); + } + STANDARD_ERRORS.forEach((status, description) -> { + if (!operation.getResponses().containsKey(status)) { + operation.getResponses().addApiResponse(status, errorResponse(description)); + } + }); + if (PUBLIC_PATHS.contains(path)) { + operation.setSecurity(List.of()); + } + } + } + + private static SecurityScheme apiKey(SecurityScheme.In in, String name) { + return new SecurityScheme().type(SecurityScheme.Type.APIKEY).in(in).name(name); + } + + private static ApiResponse errorResponse(String description) { + return new ApiResponse().description(description).content(new Content().addMediaType("application/json", + new MediaType().schema(new Schema<>().$ref(SCHEMAS + "ApiResponse")))); + } + + @SuppressWarnings("rawtypes") + private static Schema errorCodeSchema() { + StringSchema schema = new StringSchema(); + schema.setDescription("Stable machine-readable error code; additive only, never renamed or reused."); + schema.setEnum(Arrays.stream(ErrorCode.values()).map(Enum::name).toList()); + return schema; + } + + @SuppressWarnings("rawtypes") + private static Schema fieldErrorDetailSchema() { + return new ObjectSchema().addProperty("field", new StringSchema()) + .addProperty("message", new StringSchema()).required(List.of("field", "message")); + } + + @SuppressWarnings("rawtypes") + private static Schema errorBodySchema() { + return new ObjectSchema().addProperty("code", new Schema<>().$ref(SCHEMAS + "ErrorCode")) + .addProperty("purposeCode", new StringSchema().nullable(true)) + .addProperty("details", detailsSchema()) + .required(List.of("code")); + } + + @SuppressWarnings("rawtypes") + private static Schema detailsSchema() { + Schema item = new Schema<>().$ref(SCHEMAS + "FieldErrorDetail"); + return new ArraySchema().items(item).nullable(true); + } + + @SuppressWarnings("rawtypes") + private static Schema apiResponseSchema() { + return new ObjectSchema().addProperty("success", new BooleanSchema()) + .addProperty("statusCode", + new IntegerSchema().description("Always equals the HTTP status.")) + .addProperty("message", new StringSchema()) + .addProperty("data", new ObjectSchema().nullable(true)) + .addProperty("error", new Schema<>().$ref(SCHEMAS + "ErrorBody").nullable(true)) + .addProperty("timestamp", new StringSchema().format("date-time")) + .addProperty("traceId", + new StringSchema().description("Same value as the X-Trace-Id header.")) + .required(List.of("success", "statusCode", "message", "timestamp", "traceId")); + } +} diff --git a/src/main/java/com/iemr/consent/config/WebConfig.java b/src/main/java/com/iemr/consent/config/WebConfig.java new file mode 100644 index 0000000..05e4e31 --- /dev/null +++ b/src/main/java/com/iemr/consent/config/WebConfig.java @@ -0,0 +1,111 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.config; + +import java.util.List; + +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import com.iemr.consent.utils.auth.AuthenticationFilter; +import com.iemr.consent.utils.auth.CurrentUserArgumentResolver; +import com.iemr.consent.utils.cors.OriginFilter; +import com.iemr.consent.utils.http.RequestLoggingFilter; +import com.iemr.consent.utils.http.RequestSizeLimitFilter; +import com.iemr.consent.utils.http.TraceIdFilter; + +import jakarta.servlet.DispatcherType; +import jakarta.servlet.Filter; + +/** + * Registers the servlet filters in their fixed order and the {@code @CurrentUser} resolver. + * + *

Order: trace id, request log (wraps everything below, so refusals are logged), origin, + * request size, authentication. Every filter is registered for {@code REQUEST} dispatch only, so + * the container's error forward to {@code /error} is not filtered again and + * {@code /error} does not need to be a public path.

+ */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + public static final int TRACE = Ordered.HIGHEST_PRECEDENCE; + + public static final int REQUEST_LOG = TRACE + 5; + + public static final int ORIGIN = TRACE + 10; + + public static final int SIZE = TRACE + 20; + + public static final int AUTH = TRACE + 30; + + private final CurrentUserArgumentResolver currentUserArgumentResolver; + + public WebConfig(CurrentUserArgumentResolver currentUserArgumentResolver) { + this.currentUserArgumentResolver = currentUserArgumentResolver; + } + + @Bean + public FilterRegistrationBean traceIdFilterRegistration(TraceIdFilter filter) { + return register(filter, TRACE); + } + + @Bean + public FilterRegistrationBean requestLoggingFilterRegistration( + RequestLoggingFilter filter) { + return register(filter, REQUEST_LOG); + } + + @Bean + public FilterRegistrationBean originFilterRegistration(OriginFilter filter) { + return register(filter, ORIGIN); + } + + @Bean + public FilterRegistrationBean requestSizeLimitFilterRegistration( + RequestSizeLimitFilter filter) { + return register(filter, SIZE); + } + + @Bean + public FilterRegistrationBean authenticationFilterRegistration( + AuthenticationFilter filter) { + return register(filter, AUTH); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(currentUserArgumentResolver); + } + + private static FilterRegistrationBean register(T filter, int order) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); + registration.setOrder(order); + registration.addUrlPatterns("/*"); + registration.setDispatcherTypes(DispatcherType.REQUEST); + return registration; + } +} diff --git a/src/main/java/com/iemr/consent/controller/consent/ConsentController.java b/src/main/java/com/iemr/consent/controller/consent/ConsentController.java new file mode 100644 index 0000000..3c50dec --- /dev/null +++ b/src/main/java/com/iemr/consent/controller/consent/ConsentController.java @@ -0,0 +1,77 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.controller.consent; + +import java.time.Clock; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.iemr.consent.model.consent.SaveConsentRequest; +import com.iemr.consent.model.consent.SaveConsentResult; +import com.iemr.consent.service.consent.ConsentService; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.auth.CurrentUser; +import com.iemr.consent.utils.http.ClientIp; +import com.iemr.consent.utils.http.TraceIdFilter; +import com.iemr.consent.utils.idempotency.IdempotencyKey; +import com.iemr.consent.utils.idempotency.RequestHasher; +import com.iemr.consent.utils.response.ApiResponse; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; + +/** Saves the consent dialog's Accept or Decline (front-end API 7; FR-009 – FR-017). */ +@RestController +@RequestMapping(value = "/consent/v1/consents", produces = MediaType.APPLICATION_JSON_VALUE) +public class ConsentController { + + private final ConsentService consentService; + + private final RequestHasher hasher; + + private final Clock clock; + + public ConsentController(ConsentService consentService, RequestHasher hasher, Clock clock) { + this.consentService = consentService; + this.hasher = hasher; + this.clock = clock; + } + + @Operation(summary = "Save a consent decision (needs Idempotency-Key)") + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> save(@Valid @RequestBody SaveConsentRequest body, + @CurrentUser AuthenticatedUser user, HttpServletRequest request) { + String key = IdempotencyKey.require(request); + String ip = ClientIp.of(request); + SaveConsentResult result = consentService.save(body, key, hasher.hash(body), user, ip); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.created(result, TraceIdFilter.currentTraceId(request), clock)); + } +} diff --git a/src/main/java/com/iemr/consent/controller/evidence/EvidenceController.java b/src/main/java/com/iemr/consent/controller/evidence/EvidenceController.java new file mode 100644 index 0000000..2d16109 --- /dev/null +++ b/src/main/java/com/iemr/consent/controller/evidence/EvidenceController.java @@ -0,0 +1,91 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.controller.evidence; + +import java.io.IOException; +import java.math.BigDecimal; +import java.time.Clock; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.iemr.consent.data.evidence.EvidenceType; +import com.iemr.consent.model.evidence.EvidenceUploadResult; +import com.iemr.consent.service.evidence.EvidenceService; +import com.iemr.consent.service.evidence.EvidenceUpload; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.auth.CurrentUser; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; +import com.iemr.consent.utils.http.TraceIdFilter; +import com.iemr.consent.utils.idempotency.IdempotencyKey; +import com.iemr.consent.utils.response.ApiResponse; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.Pattern; + +/** Proof-file upload for the voice and photo modes (front-end API 6; FR-007, FR-008). */ +@RestController +@RequestMapping(value = "/consent/v1/evidence", produces = MediaType.APPLICATION_JSON_VALUE) +public class EvidenceController { + + private final EvidenceService evidenceService; + + private final Clock clock; + + public EvidenceController(EvidenceService evidenceService, Clock clock) { + this.evidenceService = evidenceService; + this.clock = clock; + } + + @Operation(summary = "Upload a proof file (needs Idempotency-Key)") + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> upload(@RequestPart("file") MultipartFile file, + @RequestParam("type") EvidenceType type, + @RequestParam("sha256") @Pattern(regexp = "^[0-9a-f]{64}$") String sha256, + @RequestParam(value = "durationSec", required = false) BigDecimal durationSec, + @CurrentUser AuthenticatedUser user, HttpServletRequest request) { + String key = IdempotencyKey.require(request); + EvidenceUpload upload = new EvidenceUpload(type, sha256, durationSec, file.getContentType(), + bytes(file)); + EvidenceUploadResult result = evidenceService.upload(upload, key, user); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.created(result, TraceIdFilter.currentTraceId(request), clock)); + } + + private static byte[] bytes(MultipartFile file) { + try { + return file.getBytes(); + } catch (IOException e) { + throw new ConsentApiException(ErrorCode.MALFORMED_REQUEST); + } + } +} diff --git a/src/main/java/com/iemr/consent/controller/grant/AccessGrantController.java b/src/main/java/com/iemr/consent/controller/grant/AccessGrantController.java new file mode 100644 index 0000000..a92df68 --- /dev/null +++ b/src/main/java/com/iemr/consent/controller/grant/AccessGrantController.java @@ -0,0 +1,152 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.controller.grant; + +import java.time.Clock; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.iemr.consent.data.grant.GrantStatus; +import com.iemr.consent.model.grant.AccessGrantView; +import com.iemr.consent.model.grant.AccessRequest; +import com.iemr.consent.model.grant.ApproveRequest; +import com.iemr.consent.service.grant.AccessGrantService; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.auth.CurrentUser; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; +import com.iemr.consent.utils.http.ClientIp; +import com.iemr.consent.utils.http.TraceIdFilter; +import com.iemr.consent.utils.idempotency.IdempotencyKey; +import com.iemr.consent.utils.idempotency.RequestHasher; +import com.iemr.consent.utils.response.ApiResponse; +import com.iemr.consent.utils.response.FieldErrorDetail; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; + +/** + * Doctor access grants: the doctor's request, and the patient's list, approval and cancellation (front-end + * APIs 12 – 14). Writes need an {@code Idempotency-Key}; approve and revoke hash the grant id with the body + * (research R2). + */ +@RestController +public class AccessGrantController { + + private static final String UUID = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}" + + "-[0-9a-fA-F]{12}$"; + + private final AccessGrantService service; + + private final RequestHasher hasher; + + private final Clock clock; + + public AccessGrantController(AccessGrantService service, RequestHasher hasher, Clock clock) { + this.service = service; + this.hasher = hasher; + this.clock = clock; + } + + @Operation(summary = "Doctor requests time-limited access to a patient (needs Idempotency-Key)") + @PostMapping(value = "/consent/v1/access-grants", consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> request(@Valid @RequestBody AccessRequest body, + @CurrentUser AuthenticatedUser user, HttpServletRequest request) { + String key = IdempotencyKey.require(request); + AccessGrantView grant = service.request(body, key, hasher.hash(body), user, ClientIp.of(request)); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.created(grant, TraceIdFilter.currentTraceId(request), clock)); + } + + @Operation(summary = "Patient's access grants, optionally by status (front-end API 12)") + @GetMapping(value = "/consent/v1/principals/{benRegId}/access-grants", + produces = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse> list(@PathVariable @Positive Long benRegId, + @RequestParam(required = false) String status, HttpServletRequest request) { + return ApiResponse.ok(service.list(benRegId, statuses(status)), TraceIdFilter.currentTraceId(request), + clock); + } + + @Operation(summary = "Approve a pending grant by OTP (front-end API 13; needs Idempotency-Key)") + @PostMapping(value = "/consent/v1/access-grants/{grantId}/approve", consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse approve(@PathVariable @Pattern(regexp = UUID) String grantId, + @Valid @RequestBody ApproveRequest body, @CurrentUser AuthenticatedUser user, + HttpServletRequest request) { + String key = IdempotencyKey.require(request); + AccessGrantView grant = service.approve(grantId, body, key, hash(grantId, body), user, + ClientIp.of(request)); + return ApiResponse.ok(grant, TraceIdFilter.currentTraceId(request), clock); + } + + @Operation(summary = "Cancel a pending or active grant (front-end API 14; needs Idempotency-Key)") + @PostMapping(value = "/consent/v1/access-grants/{grantId}/revoke", consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse revoke(@PathVariable @Pattern(regexp = UUID) String grantId, + @RequestBody Map body, @CurrentUser AuthenticatedUser user, + HttpServletRequest request) { + String key = IdempotencyKey.require(request); + AccessGrantView grant = service.revoke(grantId, key, hash(grantId, body), user, ClientIp.of(request)); + return ApiResponse.ok(grant, TraceIdFilter.currentTraceId(request), clock); + } + + private String hash(String grantId, Object body) { + return hasher.hash(Map.of("target", grantId.toLowerCase(Locale.ROOT), "body", body)); + } + + /** Parses {@code ACTIVE,PENDING}; blank or missing = all statuses. */ + private static Set statuses(String status) { + Set statuses = EnumSet.noneOf(GrantStatus.class); + if (status == null || status.isBlank()) { + return statuses; + } + for (String value : Arrays.stream(status.split(",")).map(String::trim).toList()) { + try { + statuses.add(GrantStatus.valueOf(value)); + } catch (IllegalArgumentException e) { + throw new ConsentApiException(ErrorCode.VALIDATION_FAILED, null, null, + List.of(new FieldErrorDetail("status", + "must be PENDING, ACTIVE, REVOKED or EXPIRED"))); + } + } + return statuses; + } +} diff --git a/src/main/java/com/iemr/consent/controller/health/HealthController.java b/src/main/java/com/iemr/consent/controller/health/HealthController.java new file mode 100644 index 0000000..5f0488c --- /dev/null +++ b/src/main/java/com/iemr/consent/controller/health/HealthController.java @@ -0,0 +1,71 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.controller.health; + +import java.time.Clock; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.iemr.consent.service.health.HealthService; +import com.iemr.consent.service.health.HealthStatus; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; +import com.iemr.consent.utils.http.TraceIdFilter; +import com.iemr.consent.utils.response.ApiResponse; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; + +/** + * Public health check (FR-017), as Common-API's {@code controller/health/HealthController}: + * 200 when the database and cache are up, 503 {@code DEPENDENCY_UNAVAILABLE} naming the failed + * component(s) otherwise. No connection details are returned. + */ +@RestController +public class HealthController { + + private final HealthService healthService; + + private final Clock clock; + + public HealthController(HealthService healthService, Clock clock) { + this.healthService = healthService; + this.clock = clock; + } + + @Operation(summary = "Service and dependency health", + description = "Public. 200 when the database and cache are up; " + + "503 naming the failed component.") + @GetMapping(value = "/health", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> health(HttpServletRequest request) { + HealthStatus status = healthService.check(); + if (!status.isUp()) { + throw new ConsentApiException(ErrorCode.DEPENDENCY_UNAVAILABLE, + "Service unavailable: " + status.downComponents()); + } + return ResponseEntity.ok(ApiResponse.ok(status, TraceIdFilter.currentTraceId(request), clock)); + } +} diff --git a/src/main/java/com/iemr/consent/controller/notice/NoticeController.java b/src/main/java/com/iemr/consent/controller/notice/NoticeController.java new file mode 100644 index 0000000..7cd8898 --- /dev/null +++ b/src/main/java/com/iemr/consent/controller/notice/NoticeController.java @@ -0,0 +1,74 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.controller.notice; + +import java.time.Clock; +import java.util.List; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.iemr.consent.data.notice.NoticeContext; +import com.iemr.consent.model.notice.ActiveNotice; +import com.iemr.consent.model.notice.NoticeLanguage; +import com.iemr.consent.service.notice.NoticeService; +import com.iemr.consent.utils.http.TraceIdFilter; +import com.iemr.consent.utils.response.ApiResponse; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.Pattern; + +/** The consent dialog's notice step (front-end APIs 2 and 3; FR-001, FR-002). */ +@RestController +@RequestMapping(value = "/consent/v1/notices", produces = MediaType.APPLICATION_JSON_VALUE) +public class NoticeController { + + private final NoticeService noticeService; + + private final Clock clock; + + public NoticeController(NoticeService noticeService, Clock clock) { + this.noticeService = noticeService; + this.clock = clock; + } + + @Operation(summary = "Languages the active notice is published in") + @GetMapping("/active/languages") + public ApiResponse> languages(@RequestParam NoticeContext context, + HttpServletRequest request) { + return ApiResponse.ok(noticeService.languages(context), TraceIdFilter.currentTraceId(request), clock); + } + + @Operation(summary = "Active notice for a context and language") + @GetMapping("/active") + public ApiResponse active(@RequestParam NoticeContext context, + @RequestParam("lang") @Pattern(regexp = "^[a-z]{2,3}$") String languageCode, + HttpServletRequest request) { + return ApiResponse.ok(noticeService.activeNotice(context, languageCode), + TraceIdFilter.currentTraceId(request), clock); + } +} diff --git a/src/main/java/com/iemr/consent/controller/otp/OtpController.java b/src/main/java/com/iemr/consent/controller/otp/OtpController.java new file mode 100644 index 0000000..3da1f03 --- /dev/null +++ b/src/main/java/com/iemr/consent/controller/otp/OtpController.java @@ -0,0 +1,92 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.controller.otp; + +import java.time.Clock; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.iemr.consent.model.otp.OtpChallengeView; +import com.iemr.consent.model.otp.OtpSendRequest; +import com.iemr.consent.model.otp.OtpVerifyRequest; +import com.iemr.consent.model.otp.OtpVerifyResult; +import com.iemr.consent.service.otp.OtpService; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.auth.CurrentUser; +import com.iemr.consent.utils.http.ForwardedAuth; +import com.iemr.consent.utils.http.TraceIdFilter; +import com.iemr.consent.utils.idempotency.IdempotencyKey; +import com.iemr.consent.utils.idempotency.RequestHasher; +import com.iemr.consent.utils.response.ApiResponse; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; + +/** OTP proof for the consent dialog (front-end APIs 4 and 5; FR-004 – FR-006). */ +@RestController +@RequestMapping(value = "/consent/v1/otp/challenges", produces = MediaType.APPLICATION_JSON_VALUE) +public class OtpController { + + private static final String UUID = "^[0-9a-fA-F-]{36}$"; + + private final OtpService otpService; + + private final RequestHasher hasher; + + private final Clock clock; + + public OtpController(OtpService otpService, RequestHasher hasher, Clock clock) { + this.otpService = otpService; + this.hasher = hasher; + this.clock = clock; + } + + @Operation(summary = "Send an OTP (needs Idempotency-Key)") + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> send(@Valid @RequestBody OtpSendRequest body, + @CurrentUser AuthenticatedUser user, HttpServletRequest request) { + String key = IdempotencyKey.require(request); + ForwardedAuth auth = ForwardedAuth.from(request); + OtpChallengeView view = otpService.send(body, key, hasher.hash(body), user, auth); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.created(view, TraceIdFilter.currentTraceId(request), clock)); + } + + @Operation(summary = "Verify an OTP") + @PostMapping(value = "/{challengeId}/verify", consumes = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse verify(@PathVariable @Pattern(regexp = UUID) String challengeId, + @Valid @RequestBody OtpVerifyRequest body, @CurrentUser AuthenticatedUser user, + HttpServletRequest request) { + return ApiResponse.ok(otpService.verify(challengeId, body.otp(), user, ForwardedAuth.from(request)), + TraceIdFilter.currentTraceId(request), clock); + } +} diff --git a/src/main/java/com/iemr/consent/controller/principal/PrincipalConsentController.java b/src/main/java/com/iemr/consent/controller/principal/PrincipalConsentController.java new file mode 100644 index 0000000..d8e3f66 --- /dev/null +++ b/src/main/java/com/iemr/consent/controller/principal/PrincipalConsentController.java @@ -0,0 +1,126 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.controller.principal; + +import java.time.Clock; +import java.util.List; +import java.util.Map; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.iemr.consent.data.notice.NoticeContext; +import com.iemr.consent.model.principal.PatientConsents; +import com.iemr.consent.model.principal.PendingAction; +import com.iemr.consent.model.principal.PreferenceRequest; +import com.iemr.consent.model.principal.WithdrawalRequest; +import com.iemr.consent.model.principal.WithdrawalResult; +import com.iemr.consent.service.principal.PendingActionService; +import com.iemr.consent.service.principal.PrincipalConsentService; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.auth.CurrentUser; +import com.iemr.consent.utils.http.ClientIp; +import com.iemr.consent.utils.http.TraceIdFilter; +import com.iemr.consent.utils.idempotency.IdempotencyKey; +import com.iemr.consent.utils.idempotency.RequestHasher; +import com.iemr.consent.utils.response.ApiResponse; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; + +/** + * A patient's consents in the preference centre (front-end APIs 8 – 11). Writes need an + * {@code Idempotency-Key}; the request hash covers the patient id as well as the body, so a key cannot + * replay another patient's result (research R2). + */ +@RestController +@RequestMapping(value = "/consent/v1/principals/{benRegId}", produces = MediaType.APPLICATION_JSON_VALUE) +public class PrincipalConsentController { + + private final PrincipalConsentService consentService; + + private final PendingActionService pendingActionService; + + private final RequestHasher hasher; + + private final Clock clock; + + public PrincipalConsentController(PrincipalConsentService consentService, + PendingActionService pendingActionService, RequestHasher hasher, Clock clock) { + this.consentService = consentService; + this.pendingActionService = pendingActionService; + this.hasher = hasher; + this.clock = clock; + } + + @Operation(summary = "Patient's current consents and history (front-end API 8)") + @GetMapping("/consents") + public ApiResponse consents(@PathVariable @Positive Long benRegId, + HttpServletRequest request) { + return ApiResponse.ok(consentService.consents(benRegId), TraceIdFilter.currentTraceId(request), clock); + } + + @Operation(summary = "Consent actions still needed for a patient (front-end API 9)") + @GetMapping("/pending-actions") + public ApiResponse> pendingActions(@PathVariable @Positive Long benRegId, + @RequestParam(defaultValue = "REGISTRATION") NoticeContext context, + HttpServletRequest request) { + List actions = pendingActionService.pending(benRegId, context); + return ApiResponse.ok(actions, TraceIdFilter.currentTraceId(request), clock); + } + + @Operation(summary = "Change purposes to GRANT (with OTP) or DENY (front-end API 10; needs Idempotency-Key)") + @PostMapping(value = "/preferences", consumes = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse changePreference(@PathVariable @Positive Long benRegId, + @Valid @RequestBody PreferenceRequest body, @CurrentUser AuthenticatedUser user, + HttpServletRequest request) { + String key = IdempotencyKey.require(request); + PatientConsents result = consentService.changePreference(benRegId, body, key, hash(benRegId, body), + user, + ClientIp.of(request)); + return ApiResponse.ok(result, TraceIdFilter.currentTraceId(request), clock); + } + + @Operation(summary = "Withdraw purposes or ALL (front-end API 11; needs Idempotency-Key)") + @PostMapping(value = "/withdrawals", consumes = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse withdraw(@PathVariable @Positive Long benRegId, + @Valid @RequestBody WithdrawalRequest body, @CurrentUser AuthenticatedUser user, + HttpServletRequest request) { + String key = IdempotencyKey.require(request); + WithdrawalResult result = consentService.withdraw(benRegId, body, key, hash(benRegId, body), user, + ClientIp.of(request)); + return ApiResponse.ok(result, TraceIdFilter.currentTraceId(request), clock); + } + + private String hash(Long benRegId, Object body) { + return hasher.hash(Map.of("target", benRegId, "body", body)); + } +} diff --git a/src/main/java/com/iemr/consent/controller/version/VersionController.java b/src/main/java/com/iemr/consent/controller/version/VersionController.java new file mode 100644 index 0000000..f8b6df9 --- /dev/null +++ b/src/main/java/com/iemr/consent/controller/version/VersionController.java @@ -0,0 +1,62 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.controller.version; + +import java.time.Clock; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.iemr.consent.service.version.BuildInfo; +import com.iemr.consent.service.version.VersionService; +import com.iemr.consent.utils.http.TraceIdFilter; +import com.iemr.consent.utils.response.ApiResponse; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; + +/** + * Public build information (FR-018), as Common-API's + * {@code controller/version/VersionController}. + */ +@RestController +public class VersionController { + + private final VersionService versionService; + + private final Clock clock; + + public VersionController(VersionService versionService, Clock clock) { + this.versionService = versionService; + this.clock = clock; + } + + @Operation(summary = "Running build", description = "Public. Version, build time, branch and commit.") + @GetMapping(value = "/version", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> version(HttpServletRequest request) { + return ResponseEntity.ok(ApiResponse.ok(versionService.getBuildInfo(), + TraceIdFilter.currentTraceId(request), clock)); + } +} diff --git a/src/main/java/com/iemr/consent/data/AuditColumns.java b/src/main/java/com/iemr/consent/data/AuditColumns.java new file mode 100644 index 0000000..ddba4bc --- /dev/null +++ b/src/main/java/com/iemr/consent/data/AuditColumns.java @@ -0,0 +1,56 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data; + +import java.time.Instant; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; + +/** + * Standard AMRIT audit columns with soft delete (constitution IX), for every {@code cm_} table + * except the append-only consent and audit tables. {@code CreatedDate} and {@code LastModDate} + * are maintained by the database. + */ +@Getter +@Setter +@MappedSuperclass +public abstract class AuditColumns { + + @Column(name = "CreatedBy", nullable = false, updatable = false) + private String createdBy; + + @Column(name = "CreatedDate", insertable = false, updatable = false) + private Instant createdDate; + + @Column(name = "ModifiedBy") + private String modifiedBy; + + @Column(name = "LastModDate", insertable = false, updatable = false) + private Instant lastModDate; + + @Column(name = "Deleted", nullable = false) + private boolean deleted; +} diff --git a/src/main/java/com/iemr/consent/data/audit/AuditLogEntry.java b/src/main/java/com/iemr/consent/data/audit/AuditLogEntry.java new file mode 100644 index 0000000..397655f --- /dev/null +++ b/src/main/java/com/iemr/consent/data/audit/AuditLogEntry.java @@ -0,0 +1,83 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.audit; + +import java.time.Instant; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * One hash-chained, append-only audit entry ({@code cm_audit_log}): {@code entry_hash} = + * SHA-256 of {@code prev_hash} and the entry's content. Holds IDs and codes only, no personal + * values. + */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "cm_audit_log") +public class AuditLogEntry { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "action") + private String action; + + @Column(name = "consent_id") + private String consentId; + + @Column(name = "beneficiary_reg_id") + private Long beneficiaryRegId; + + @Column(name = "purpose") + private String purpose; + + @Column(name = "status") + private String status; + + @Column(name = "initiator") + private Long initiator; + + @Column(name = "source_ip") + private String sourceIp; + + @Column(name = "created_at") + private Instant createdAt; + + @Column(name = "prev_hash") + private String prevHash; + + @Column(name = "entry_hash") + private String entryHash; +} diff --git a/src/main/java/com/iemr/consent/data/consent/Channel.java b/src/main/java/com/iemr/consent/data/consent/Channel.java new file mode 100644 index 0000000..c51969d --- /dev/null +++ b/src/main/java/com/iemr/consent/data/consent/Channel.java @@ -0,0 +1,28 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.consent; + +/** Where consent was captured: the consent screen at a facility or van, or the preference centre. */ +public enum Channel { + FACILITY, PREFERENCE_CENTRE +} diff --git a/src/main/java/com/iemr/consent/data/consent/Consent.java b/src/main/java/com/iemr/consent/data/consent/Consent.java new file mode 100644 index 0000000..961d5b5 --- /dev/null +++ b/src/main/java/com/iemr/consent/data/consent/Consent.java @@ -0,0 +1,125 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.consent; + +import java.time.Instant; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * One consent event ({@code cm_consent}). Append-only: the service only ever inserts rows + * (constitution IX, proposal §5). + */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "cm_consent") +public class Consent { + + @Id + @Column(name = "consent_id") + private String consentId; + + @Column(name = "beneficiary_reg_id") + private Long beneficiaryRegId; + + @Enumerated(EnumType.STRING) + @Column(name = "event_type") + private EventType eventType; + + @Enumerated(EnumType.STRING) + @Column(name = "consenting_party") + private ConsentingParty consentingParty; + + @Column(name = "notice_version_id") + private Long noticeVersionId; + + @Column(name = "language_code") + private String languageCode; + + @Column(name = "text_hash") + private String textHash; + + @Enumerated(EnumType.STRING) + @Column(name = "channel") + private Channel channel; + + @Enumerated(EnumType.STRING) + @Column(name = "mode") + private ConsentMode mode; + + @Column(name = "captured_by") + private Long capturedBy; + + @Column(name = "provider_service_map_id") + private Integer providerServiceMapId; + + @Column(name = "facility_id") + private Integer facilityId; + + @Column(name = "service_point_id") + private Integer servicePointId; + + @Column(name = "service_line") + private String serviceLine; + + @Column(name = "device_id") + private String deviceId; + + @Column(name = "call_id") + private String callId; + + @Column(name = "captured_at") + private Instant capturedAt; + + @Column(name = "server_received_at") + private Instant serverReceivedAt; + + @Column(name = "capture_time_flagged") + private boolean captureTimeFlagged; + + @Enumerated(EnumType.STRING) + @Column(name = "sync_status") + private SyncStatus syncStatus; + + @Column(name = "project_id") + private Integer projectId; + + @Column(name = "config_version") + private Integer configVersion; + + @Column(name = "request_key") + private String requestKey; + + @Column(name = "request_hash") + private String requestHash; +} diff --git a/src/main/java/com/iemr/consent/data/consent/ConsentMode.java b/src/main/java/com/iemr/consent/data/consent/ConsentMode.java new file mode 100644 index 0000000..969eb65 --- /dev/null +++ b/src/main/java/com/iemr/consent/data/consent/ConsentMode.java @@ -0,0 +1,28 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.consent; + +/** How consent was taken (proposal §3.1 rule 6). */ +public enum ConsentMode { + SELF_READ, READ_ALOUD, VOICE_CLIP, WITNESSED, SIGNATURE_PHOTO, OTP +} diff --git a/src/main/java/com/iemr/consent/data/consent/ConsentPurpose.java b/src/main/java/com/iemr/consent/data/consent/ConsentPurpose.java new file mode 100644 index 0000000..3c6a275 --- /dev/null +++ b/src/main/java/com/iemr/consent/data/consent/ConsentPurpose.java @@ -0,0 +1,85 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.consent; + +import java.time.Instant; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * The decision for one purpose within a consent event ({@code cm_consent_purpose}). The only + * change ever made to a stored row is the supersede step ({@code is_current}, {@code status}); + * {@code current_key} is a database-generated column backing "one current decision per patient + * and purpose". + */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "cm_consent_purpose") +public class ConsentPurpose { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "consent_purpose_id") + private Long consentPurposeId; + + @Column(name = "consent_id") + private String consentId; + + @Column(name = "beneficiary_reg_id") + private Long beneficiaryRegId; + + @Column(name = "purpose_code") + private String purposeCode; + + @Enumerated(EnumType.STRING) + @Column(name = "decision") + private Decision decision; + + @Enumerated(EnumType.STRING) + @Column(name = "status") + private PurposeStatus status; + + @Column(name = "version_no") + private Integer versionNo; + + @Column(name = "is_current") + private boolean current; + + @Column(name = "valid_until") + private Instant validUntil; + + @Column(name = "current_key", insertable = false, updatable = false) + private String currentKey; +} diff --git a/src/main/java/com/iemr/consent/data/consent/ConsentingParty.java b/src/main/java/com/iemr/consent/data/consent/ConsentingParty.java new file mode 100644 index 0000000..0ceac61 --- /dev/null +++ b/src/main/java/com/iemr/consent/data/consent/ConsentingParty.java @@ -0,0 +1,28 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.consent; + +/** Who gave the consent. */ +public enum ConsentingParty { + SELF, GUARDIAN +} diff --git a/src/main/java/com/iemr/consent/data/consent/Decision.java b/src/main/java/com/iemr/consent/data/consent/Decision.java new file mode 100644 index 0000000..f9e182e --- /dev/null +++ b/src/main/java/com/iemr/consent/data/consent/Decision.java @@ -0,0 +1,28 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.consent; + +/** The patient's decision for one purpose. */ +public enum Decision { + GRANT, DENY +} diff --git a/src/main/java/com/iemr/consent/data/consent/EventType.java b/src/main/java/com/iemr/consent/data/consent/EventType.java new file mode 100644 index 0000000..50f684b --- /dev/null +++ b/src/main/java/com/iemr/consent/data/consent/EventType.java @@ -0,0 +1,31 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.consent; + +/** + * Kind of consent event (data-model {@code cm_consent.event_type}). {@code WITHDRAWAL} is written by the + * withdrawal API; {@code LEGACY} is only read here and is written by the legacy-load migration. + */ +public enum EventType { + NEW, UPDATE, RENEWAL, WITHDRAWAL, LEGACY +} diff --git a/src/main/java/com/iemr/consent/data/consent/GuardianLink.java b/src/main/java/com/iemr/consent/data/consent/GuardianLink.java new file mode 100644 index 0000000..304c8db --- /dev/null +++ b/src/main/java/com/iemr/consent/data/consent/GuardianLink.java @@ -0,0 +1,88 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.consent; + +import java.time.Instant; + +import com.iemr.consent.data.AuditColumns; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * The guardian who consented for a patient ({@code cm_guardian_link}). Name and phone hold + * {@code FieldEncryptor} output, never plain values. + */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "cm_guardian_link") +public class GuardianLink extends AuditColumns { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "guardian_link_id") + private Long guardianLinkId; + + @Column(name = "consent_id") + private String consentId; + + @Column(name = "beneficiary_reg_id") + private Long beneficiaryRegId; + + @Enumerated(EnumType.STRING) + @Column(name = "guardian_type") + private GuardianType guardianType; + + @Column(name = "guardian_name") + private String guardianNameEncrypted; + + @Column(name = "guardian_phone") + private String guardianPhoneEncrypted; + + @Column(name = "relationship") + private String relationship; + + @Enumerated(EnumType.STRING) + @Column(name = "verification_method") + private GuardianVerification verificationMethod; + + @Column(name = "verified_at") + private Instant verifiedAt; + + @Column(name = "follow_up_required") + private boolean followUpRequired; + + @Column(name = "valid_until") + private Instant validUntil; +} diff --git a/src/main/java/com/iemr/consent/data/consent/GuardianType.java b/src/main/java/com/iemr/consent/data/consent/GuardianType.java new file mode 100644 index 0000000..0d9c8d7 --- /dev/null +++ b/src/main/java/com/iemr/consent/data/consent/GuardianType.java @@ -0,0 +1,28 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.consent; + +/** Type of guardian (C6, C8). */ +public enum GuardianType { + PARENT, DISABILITY_GUARDIAN +} diff --git a/src/main/java/com/iemr/consent/data/consent/GuardianVerification.java b/src/main/java/com/iemr/consent/data/consent/GuardianVerification.java new file mode 100644 index 0000000..f6a03f7 --- /dev/null +++ b/src/main/java/com/iemr/consent/data/consent/GuardianVerification.java @@ -0,0 +1,28 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.consent; + +/** How the guardian was verified; anything but OTP needs follow-up (C7, D-03). */ +public enum GuardianVerification { + OTP, ID_SIGHTED, UNVERIFIED +} diff --git a/src/main/java/com/iemr/consent/data/consent/PurposeStatus.java b/src/main/java/com/iemr/consent/data/consent/PurposeStatus.java new file mode 100644 index 0000000..192a758 --- /dev/null +++ b/src/main/java/com/iemr/consent/data/consent/PurposeStatus.java @@ -0,0 +1,28 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.consent; + +/** Status of a purpose decision; WITHDRAWN, EXPIRED and RECONSENT_REQUIRED are set by later features. */ +public enum PurposeStatus { + GRANTED, DENIED, WITHDRAWN, EXPIRED, RECONSENT_REQUIRED, SUPERSEDED +} diff --git a/src/main/java/com/iemr/consent/data/consent/SyncStatus.java b/src/main/java/com/iemr/consent/data/consent/SyncStatus.java new file mode 100644 index 0000000..8ba057a --- /dev/null +++ b/src/main/java/com/iemr/consent/data/consent/SyncStatus.java @@ -0,0 +1,28 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.consent; + +/** Whether the consent has reached central Consent-API. */ +public enum SyncStatus { + SYNCED, NOT_SYNCED +} diff --git a/src/main/java/com/iemr/consent/data/evidence/ConsentEvidence.java b/src/main/java/com/iemr/consent/data/evidence/ConsentEvidence.java new file mode 100644 index 0000000..5e9ab86 --- /dev/null +++ b/src/main/java/com/iemr/consent/data/evidence/ConsentEvidence.java @@ -0,0 +1,104 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.evidence; + +import java.math.BigDecimal; +import java.time.Instant; + +import com.iemr.consent.data.AuditColumns; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A proof file or proof detail ({@code cm_consent_evidence}). Files are stored encrypted outside + * the database; the row keeps the reference and SHA-256. {@code consent_id} is set once, when a + * save links the evidence. {@code witness_name} holds {@code FieldEncryptor} output. + */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "cm_consent_evidence") +public class ConsentEvidence extends AuditColumns { + + @Id + @Column(name = "evidence_id") + private String evidenceId; + + @Column(name = "consent_id") + private String consentId; + + @Enumerated(EnumType.STRING) + @Column(name = "type") + private EvidenceType type; + + @Column(name = "file_ref") + private String fileRef; + + @Column(name = "sha256") + private String sha256; + + @Column(name = "content_type") + private String contentType; + + @Column(name = "size_bytes") + private Long sizeBytes; + + @Column(name = "duration_sec") + private BigDecimal durationSec; + + @Column(name = "witness_name") + private String witnessNameEncrypted; + + @Column(name = "witness_relationship") + private String witnessRelationship; + + @Column(name = "otp_status") + private String otpStatus; + + @Column(name = "masked_phone") + private String maskedPhone; + + @Column(name = "call_id") + private String callId; + + @Column(name = "uploaded_by") + private Long uploadedBy; + + @Column(name = "request_key") + private String requestKey; + + @Column(name = "request_hash") + private String requestHash; + + @Column(name = "created_at") + private Instant createdAt; +} diff --git a/src/main/java/com/iemr/consent/data/evidence/EvidenceType.java b/src/main/java/com/iemr/consent/data/evidence/EvidenceType.java new file mode 100644 index 0000000..a8dbebe --- /dev/null +++ b/src/main/java/com/iemr/consent/data/evidence/EvidenceType.java @@ -0,0 +1,45 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.evidence; + +/** Kind of evidence row: uploaded files, and proof details written at save. */ +public enum EvidenceType { + VOICE_CLIP(true), PATIENT_PHOTO(true), SIGNED_FORM_PHOTO(true), WITNESS(false), WORKER_CONFIRMATION(false), + OTP(false), GUARDIAN_WITNESS(false); + + private final boolean file; + + EvidenceType(boolean file) { + this.file = file; + } + + /** {@code true} for types uploaded as files through the evidence endpoint. */ + public boolean isFile() { + return file; + } + + /** {@code true} for the two photo types of the SIGNATURE_PHOTO mode. */ + public boolean isPhoto() { + return this == PATIENT_PHOTO || this == SIGNED_FORM_PHOTO; + } +} diff --git a/src/main/java/com/iemr/consent/data/grant/AccessGrant.java b/src/main/java/com/iemr/consent/data/grant/AccessGrant.java new file mode 100644 index 0000000..bce9334 --- /dev/null +++ b/src/main/java/com/iemr/consent/data/grant/AccessGrant.java @@ -0,0 +1,133 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.grant; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.TreeSet; + +import com.iemr.consent.data.AuditColumns; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A doctor's time-limited access to a patient's data ({@code cm_access_grant}, AMRIT-DB V33). Only + * approve, revoke and the withdrawal cascade change a row. {@code purposes} holds sorted, unique + * purpose codes joined with commas. + */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "cm_access_grant") +public class AccessGrant extends AuditColumns { + + @Id + @Column(name = "grant_id") + private String grantId; + + @Column(name = "beneficiary_reg_id") + private Long beneficiaryRegId; + + @Enumerated(EnumType.STRING) + @Column(name = "grant_type") + private GrantType grantType; + + @Column(name = "grantee_user_id") + private Long granteeUserId; + + @Column(name = "grantee_name") + private String granteeName; + + @Column(name = "grantee_facility_id") + private Integer granteeFacilityId; + + @Column(name = "purposes") + private String purposes; + + @Column(name = "reason") + private String reason; + + @Column(name = "days") + private Integer days; + + @Enumerated(EnumType.STRING) + @Column(name = "status") + private GrantStatus status; + + @Column(name = "valid_until") + private Instant validUntil; + + @Column(name = "approved_at") + private Instant approvedAt; + + @Column(name = "approved_by") + private Long approvedBy; + + @Column(name = "approval_masked_phone") + private String approvalMaskedPhone; + + @Column(name = "revoked_at") + private Instant revokedAt; + + @Column(name = "reviewed_by") + private Long reviewedBy; + + @Enumerated(EnumType.STRING) + @Column(name = "revoke_reason") + private RevokeReason revokeReason; + + @Column(name = "requested_by") + private Long requestedBy; + + @Column(name = "request_key") + private String requestKey; + + @Column(name = "request_hash") + private String requestHash; + + /** @return the purpose codes, in stored (sorted) order */ + public List purposeList() { + return Arrays.asList(purposes.split(",")); + } + + /** Stores the codes sorted and without duplicates. */ + public void setPurposeList(Collection codes) { + this.purposes = String.join(",", new TreeSet<>(codes)); + } + + @Override + public String toString() { + return "AccessGrant[grantId=" + grantId + ", status=" + status + "]"; + } +} diff --git a/src/main/java/com/iemr/consent/data/grant/GrantStatus.java b/src/main/java/com/iemr/consent/data/grant/GrantStatus.java new file mode 100644 index 0000000..1020ad4 --- /dev/null +++ b/src/main/java/com/iemr/consent/data/grant/GrantStatus.java @@ -0,0 +1,28 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.grant; + +/** Status of an access grant; EXPIRED is also reported for an ACTIVE grant past its end date. */ +public enum GrantStatus { + PENDING, ACTIVE, REVOKED, EXPIRED +} diff --git a/src/main/java/com/iemr/consent/data/grant/GrantType.java b/src/main/java/com/iemr/consent/data/grant/GrantType.java new file mode 100644 index 0000000..621cf6d --- /dev/null +++ b/src/main/java/com/iemr/consent/data/grant/GrantType.java @@ -0,0 +1,28 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.grant; + +/** How an access grant came about: approved by the patient, or emergency (break-glass) access. */ +public enum GrantType { + CONSENTED, BREAK_GLASS +} diff --git a/src/main/java/com/iemr/consent/data/grant/RevokeReason.java b/src/main/java/com/iemr/consent/data/grant/RevokeReason.java new file mode 100644 index 0000000..eac3711 --- /dev/null +++ b/src/main/java/com/iemr/consent/data/grant/RevokeReason.java @@ -0,0 +1,28 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.grant; + +/** Why an access grant was revoked: the patient asked, or a purpose it covers was withdrawn. */ +public enum RevokeReason { + PATIENT_REQUEST, WITHDRAWAL +} diff --git a/src/main/java/com/iemr/consent/data/notice/Notice.java b/src/main/java/com/iemr/consent/data/notice/Notice.java new file mode 100644 index 0000000..bcd0b43 --- /dev/null +++ b/src/main/java/com/iemr/consent/data/notice/Notice.java @@ -0,0 +1,76 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.notice; + +import java.time.Instant; + +import com.iemr.consent.data.AuditColumns; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** A versioned notice for one context ({@code cm_notice}). */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "cm_notice") +public class Notice extends AuditColumns { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "notice_version_id") + private Long noticeVersionId; + + @Column(name = "notice_code") + private String noticeCode; + + @Column(name = "version_no") + private Integer versionNo; + + @Enumerated(EnumType.STRING) + @Column(name = "context") + private NoticeContext context; + + @Column(name = "is_material") + private boolean material; + + @Enumerated(EnumType.STRING) + @Column(name = "status") + private NoticeStatus status; + + @Column(name = "approved_by") + private String approvedBy; + + @Column(name = "published_at") + private Instant publishedAt; +} diff --git a/src/main/java/com/iemr/consent/data/notice/NoticeContext.java b/src/main/java/com/iemr/consent/data/notice/NoticeContext.java new file mode 100644 index 0000000..a7b025b --- /dev/null +++ b/src/main/java/com/iemr/consent/data/notice/NoticeContext.java @@ -0,0 +1,28 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.notice; + +/** Where a notice is shown. */ +public enum NoticeContext { + REGISTRATION, REVISIT +} diff --git a/src/main/java/com/iemr/consent/data/notice/NoticePurpose.java b/src/main/java/com/iemr/consent/data/notice/NoticePurpose.java new file mode 100644 index 0000000..703fd0d --- /dev/null +++ b/src/main/java/com/iemr/consent/data/notice/NoticePurpose.java @@ -0,0 +1,55 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.notice; + +import com.iemr.consent.data.AuditColumns; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** A purpose covered by a notice version, with its display order ({@code cm_notice_purpose}). */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@IdClass(NoticePurposeId.class) +@Table(name = "cm_notice_purpose") +public class NoticePurpose extends AuditColumns { + + @Id + @Column(name = "notice_version_id") + private Long noticeVersionId; + + @Id + @Column(name = "purpose_code") + private String purposeCode; + + @Column(name = "display_rank") + private Integer displayRank; +} diff --git a/src/main/java/com/iemr/consent/data/notice/NoticePurposeId.java b/src/main/java/com/iemr/consent/data/notice/NoticePurposeId.java new file mode 100644 index 0000000..7db6ba7 --- /dev/null +++ b/src/main/java/com/iemr/consent/data/notice/NoticePurposeId.java @@ -0,0 +1,44 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.notice; + +import java.io.Serializable; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** Composite key of {@link NoticePurpose}: notice version and purpose code. */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class NoticePurposeId implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long noticeVersionId; + + private String purposeCode; +} diff --git a/src/main/java/com/iemr/consent/data/notice/NoticeStatus.java b/src/main/java/com/iemr/consent/data/notice/NoticeStatus.java new file mode 100644 index 0000000..3ec3ee3 --- /dev/null +++ b/src/main/java/com/iemr/consent/data/notice/NoticeStatus.java @@ -0,0 +1,28 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.notice; + +/** Notice lifecycle; only PUBLISHED is served. */ +public enum NoticeStatus { + DRAFT, PUBLISHED, RETIRED +} diff --git a/src/main/java/com/iemr/consent/data/notice/NoticeTranslation.java b/src/main/java/com/iemr/consent/data/notice/NoticeTranslation.java new file mode 100644 index 0000000..da79c55 --- /dev/null +++ b/src/main/java/com/iemr/consent/data/notice/NoticeTranslation.java @@ -0,0 +1,85 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.notice; + +import com.iemr.consent.data.AuditColumns; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** Notice text in one language ({@code cm_notice_translation}); never logged. */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "cm_notice_translation") +public class NoticeTranslation extends AuditColumns { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "translation_id") + private Long translationId; + + @Column(name = "notice_version_id") + private Long noticeVersionId; + + @Column(name = "language_code") + private String languageCode; + + @Column(name = "language_name") + private String languageName; + + @Column(name = "body") + private String body; + + @Column(name = "data_items") + private String dataItems; + + @Column(name = "retention") + private String retention; + + @Column(name = "recipients") + private String recipients; + + @Column(name = "withdrawal_info") + private String withdrawalInfo; + + @Column(name = "grievance_info") + private String grievanceInfo; + + @Column(name = "text_hash") + private String textHash; + + @Column(name = "audio_ref") + private String audioRef; + + @Column(name = "helpline_script") + private String helplineScript; +} diff --git a/src/main/java/com/iemr/consent/data/notice/Purpose.java b/src/main/java/com/iemr/consent/data/notice/Purpose.java new file mode 100644 index 0000000..24a0168 --- /dev/null +++ b/src/main/java/com/iemr/consent/data/notice/Purpose.java @@ -0,0 +1,76 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.data.notice; + +import com.iemr.consent.data.AuditColumns; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** A use of patient data ({@code cm_purpose}), versioned. */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "cm_purpose") +public class Purpose extends AuditColumns { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "purpose_id") + private Long purposeId; + + @Column(name = "purpose_code") + private String purposeCode; + + @Column(name = "version") + private Integer version; + + @Column(name = "name") + private String name; + + @Column(name = "description") + private String description; + + @Column(name = "legal_basis") + private String legalBasis; + + @Column(name = "is_mandatory") + private boolean mandatory; + + @Column(name = "is_withdrawable") + private boolean withdrawable; + + @Column(name = "validity_days") + private Integer validityDays; + + @Column(name = "is_active") + private boolean active; +} diff --git a/src/main/java/com/iemr/consent/model/consent/ConsentPatterns.java b/src/main/java/com/iemr/consent/model/consent/ConsentPatterns.java new file mode 100644 index 0000000..5c470bc --- /dev/null +++ b/src/main/java/com/iemr/consent/model/consent/ConsentPatterns.java @@ -0,0 +1,45 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.consent; + +/** Allowed characters for free-text request fields (input sanitising, constitution IX). */ +public final class ConsentPatterns { + + /** Names and relationships: letters in any script, spaces and {@code .'-}. */ + public static final String NAME = "^[\\p{L}\\p{M} .'-]{1,100}$"; + + /** Identifiers such as device id, service line and reference id. */ + public static final String CODE = "^[A-Za-z0-9_.:-]+$"; + + /** Indian 10-digit mobile number. */ + public static final String PHONE = "^[6-9][0-9]{9}$"; + + /** A purpose code, as {@code cm_purpose.purpose_code} (at most 20 characters). */ + public static final String PURPOSE_CODE = "^[A-Za-z0-9_.:-]{1,20}$"; + + /** Short free text, such as an access-request reason: letters, digits, spaces and basic punctuation. */ + public static final String TEXT = "^[\\p{L}\\p{M}\\p{N} .,'()/:;-]{1,255}$"; + + private ConsentPatterns() { + } +} diff --git a/src/main/java/com/iemr/consent/model/consent/DecisionItem.java b/src/main/java/com/iemr/consent/model/consent/DecisionItem.java new file mode 100644 index 0000000..48dc4de --- /dev/null +++ b/src/main/java/com/iemr/consent/model/consent/DecisionItem.java @@ -0,0 +1,38 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.consent; + +import com.iemr.consent.data.consent.Decision; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * The patient's decision for one purpose. + * + * @param purposeCode purpose from the notice + * @param decision GRANT or DENY + */ +public record DecisionItem(@NotBlank @Size(max = 20) String purposeCode, @NotNull Decision decision) { +} diff --git a/src/main/java/com/iemr/consent/model/consent/GuardianInfo.java b/src/main/java/com/iemr/consent/model/consent/GuardianInfo.java new file mode 100644 index 0000000..187754a --- /dev/null +++ b/src/main/java/com/iemr/consent/model/consent/GuardianInfo.java @@ -0,0 +1,60 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.consent; + +import java.util.List; +import java.util.UUID; + +import com.iemr.consent.data.consent.GuardianType; +import com.iemr.consent.data.consent.GuardianVerification; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * The guardian giving consent for a child or a person with a disability (C6, C7, C8). Name and + * phone are personal data: stored encrypted, never logged. + * + * @param guardianType PARENT or DISABILITY_GUARDIAN + * @param name guardian name + * @param relationship relationship to the patient + * @param phone 10-digit mobile; required for OTP verification + * @param verification OTP, ID_SIGHTED or UNVERIFIED (D-03) + * @param otpChallengeId verified GUARDIAN challenge, for OTP + * @param witnessName required for ID_SIGHTED and UNVERIFIED + * @param evidenceIds optional photos of the guardian's signature or ID + */ +public record GuardianInfo(@NotNull GuardianType guardianType, + @NotBlank @Size(max = 100) @Pattern(regexp = ConsentPatterns.NAME) String name, + @NotBlank @Size(max = 50) @Pattern(regexp = ConsentPatterns.NAME) String relationship, + @Pattern(regexp = ConsentPatterns.PHONE) String phone, @NotNull GuardianVerification verification, + UUID otpChallengeId, @Size(max = 100) @Pattern(regexp = ConsentPatterns.NAME) String witnessName, + List evidenceIds) { + + @Override + public String toString() { + return "GuardianInfo[guardianType=" + guardianType + ", verification=" + verification + "]"; + } +} diff --git a/src/main/java/com/iemr/consent/model/consent/Proof.java b/src/main/java/com/iemr/consent/model/consent/Proof.java new file mode 100644 index 0000000..f9282e9 --- /dev/null +++ b/src/main/java/com/iemr/consent/model/consent/Proof.java @@ -0,0 +1,47 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.consent; + +import java.util.UUID; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * Proof details for the chosen mode. Names are personal data: stored encrypted, never logged. + * + * @param workerConfirmed READ_ALOUD: the worker confirms the notice was read out + * @param witnessName WITNESSED + * @param witnessRelationship WITNESSED + * @param otpChallengeId OTP: the verified challenge + */ +public record Proof(Boolean workerConfirmed, + @Size(max = 100) @Pattern(regexp = ConsentPatterns.NAME) String witnessName, + @Size(max = 50) @Pattern(regexp = ConsentPatterns.NAME) String witnessRelationship, + UUID otpChallengeId) { + + @Override + public String toString() { + return "Proof[workerConfirmed=" + workerConfirmed + ", otpChallengeId=" + otpChallengeId + "]"; + } +} diff --git a/src/main/java/com/iemr/consent/model/consent/PurposeResult.java b/src/main/java/com/iemr/consent/model/consent/PurposeResult.java new file mode 100644 index 0000000..6b5c4a7 --- /dev/null +++ b/src/main/java/com/iemr/consent/model/consent/PurposeResult.java @@ -0,0 +1,35 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.consent; + +import com.iemr.consent.data.consent.PurposeStatus; + +/** + * Resulting status of one purpose. + * + * @param purposeCode purpose + * @param status GRANTED or DENIED for a new save + * @param versionNo decision version for this patient and purpose + */ +public record PurposeResult(String purposeCode, PurposeStatus status, Integer versionNo) { +} diff --git a/src/main/java/com/iemr/consent/model/consent/SaveConsentRequest.java b/src/main/java/com/iemr/consent/model/consent/SaveConsentRequest.java new file mode 100644 index 0000000..e6623a3 --- /dev/null +++ b/src/main/java/com/iemr/consent/model/consent/SaveConsentRequest.java @@ -0,0 +1,83 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.consent; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import com.iemr.consent.data.consent.Channel; +import com.iemr.consent.data.consent.ConsentMode; +import com.iemr.consent.data.consent.ConsentingParty; +import com.iemr.consent.data.consent.EventType; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * Save consent (front-end API 7, contract {@code SaveConsentRequest}). {@code capturedBy} is + * accepted for compatibility but ignored: the logged-in user is recorded (FR-015). + * + * @param beneficiaryRegId patient, or {@code null} before registration (stored unlinked) + * @param eventType NEW, UPDATE or RENEWAL + * @param consentingParty SELF or GUARDIAN + * @param noticeVersionId from the active notice + * @param languageCode language shown + * @param textHash fingerprint of the text shown + * @param channel FACILITY + * @param mode how consent was taken; {@code null} only when every decision is DENY + * @param capturedBy ignored + * @param providerServiceMapId context + * @param facilityId context + * @param servicePointId context + * @param serviceLine e.g. {@code MMU} + * @param deviceId capturing device + * @param capturedAt device time of capture + * @param decisions one per notice purpose + * @param evidenceIds uploaded proof files + * @param proof mode proof details + * @param guardian for GUARDIAN + * @param projectId configuration in force (deviation 2) + * @param configVersion configuration version + */ +public record SaveConsentRequest(Long beneficiaryRegId, @NotNull EventType eventType, + @NotNull ConsentingParty consentingParty, @NotNull Long noticeVersionId, + @NotNull @Pattern(regexp = "^[a-z]{2,3}$") String languageCode, + @NotNull @Pattern(regexp = "^[0-9a-f]{64}$") String textHash, @NotNull Channel channel, + ConsentMode mode, + Long capturedBy, Integer providerServiceMapId, Integer facilityId, Integer servicePointId, + @Size(max = 30) @Pattern(regexp = ConsentPatterns.CODE) String serviceLine, + @NotBlank @Size(max = 100) @Pattern(regexp = ConsentPatterns.CODE) String deviceId, + @NotNull Instant capturedAt, @NotEmpty List<@Valid @NotNull DecisionItem> decisions, + List<@NotNull UUID> evidenceIds, @Valid Proof proof, @Valid GuardianInfo guardian, Integer projectId, + Integer configVersion) { + + @Override + public String toString() { + return "SaveConsentRequest[noticeVersionId=" + noticeVersionId + ", mode=" + mode + "]"; + } +} diff --git a/src/main/java/com/iemr/consent/model/consent/SaveConsentResult.java b/src/main/java/com/iemr/consent/model/consent/SaveConsentResult.java new file mode 100644 index 0000000..a10cbb5 --- /dev/null +++ b/src/main/java/com/iemr/consent/model/consent/SaveConsentResult.java @@ -0,0 +1,37 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.consent; + +import java.util.List; + +import com.iemr.consent.data.consent.SyncStatus; + +/** + * Save consent result (front-end API 7). + * + * @param consentId new consent event id + * @param syncStatus SYNCED for online capture + * @param purposes one per decision, in request order + */ +public record SaveConsentResult(String consentId, SyncStatus syncStatus, List purposes) { +} diff --git a/src/main/java/com/iemr/consent/model/evidence/EvidenceUploadResult.java b/src/main/java/com/iemr/consent/model/evidence/EvidenceUploadResult.java new file mode 100644 index 0000000..d23ebf5 --- /dev/null +++ b/src/main/java/com/iemr/consent/model/evidence/EvidenceUploadResult.java @@ -0,0 +1,32 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.evidence; + +/** + * Upload evidence result (front-end API 6). + * + * @param evidenceId id to send with the save + * @param sha256 the confirmed fingerprint + */ +public record EvidenceUploadResult(String evidenceId, String sha256) { +} diff --git a/src/main/java/com/iemr/consent/model/grant/AccessGrantView.java b/src/main/java/com/iemr/consent/model/grant/AccessGrantView.java new file mode 100644 index 0000000..bb225de --- /dev/null +++ b/src/main/java/com/iemr/consent/model/grant/AccessGrantView.java @@ -0,0 +1,41 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.grant; + +import java.time.Instant; +import java.util.List; + +import com.iemr.consent.data.grant.GrantStatus; +import com.iemr.consent.data.grant.GrantType; +import com.iemr.consent.data.grant.RevokeReason; + +/** An access grant as APIs 12–14 return it; {@code status} is the effective status. */ +public record AccessGrantView(String grantId, Long beneficiaryRegId, GranteeView grantee, List purposes, + GrantType type, GrantStatus status, Integer days, String reason, Instant validUntil, Instant approvedAt, + Instant revokedAt, RevokeReason revokeReason) { + + @Override + public String toString() { + return "AccessGrantView[grantId=" + grantId + ", status=" + status + "]"; + } +} diff --git a/src/main/java/com/iemr/consent/model/grant/AccessRequest.java b/src/main/java/com/iemr/consent/model/grant/AccessRequest.java new file mode 100644 index 0000000..8bf200b --- /dev/null +++ b/src/main/java/com/iemr/consent/model/grant/AccessRequest.java @@ -0,0 +1,57 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.grant; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.iemr.consent.model.consent.ConsentPatterns; +import com.iemr.consent.model.principal.PurposeCodes; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; + +/** + * A doctor's request for time-limited access to a patient (clarification 2; contract {@code AccessRequest}). + * The maximum number of days is checked by the service against {@code consent.access-grant.max-days}. + */ +public record AccessRequest(@NotNull @Positive Long beneficiaryRegId, @NotNull List purposeCodes, + @NotNull @Min(1) Integer days, @NotBlank @Pattern(regexp = ConsentPatterns.TEXT) String reason, + Integer facilityId) { + + @JsonIgnore + @AssertTrue(message = "must be 1 to 20 unique purpose codes, at most 255 characters in all") + public boolean isPurposeCodesValid() { + return purposeCodes == null + || PurposeCodes.valid(purposeCodes, 20) && PurposeCodes.fitColumn(purposeCodes); + } + + @Override + public String toString() { + return "AccessRequest[days=" + days + "]"; + } +} diff --git a/src/main/java/com/iemr/consent/model/grant/ApproveRequest.java b/src/main/java/com/iemr/consent/model/grant/ApproveRequest.java new file mode 100644 index 0000000..4a38225 --- /dev/null +++ b/src/main/java/com/iemr/consent/model/grant/ApproveRequest.java @@ -0,0 +1,31 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.grant; + +import java.util.UUID; + +import jakarta.validation.constraints.NotNull; + +/** API 13 body: an API 4 challenge with purpose ACCESS_GRANT and {@code refId} = the grant id, verified. */ +public record ApproveRequest(@NotNull UUID otpChallengeId) { +} diff --git a/src/main/java/com/iemr/consent/model/grant/GranteeView.java b/src/main/java/com/iemr/consent/model/grant/GranteeView.java new file mode 100644 index 0000000..20dbec4 --- /dev/null +++ b/src/main/java/com/iemr/consent/model/grant/GranteeView.java @@ -0,0 +1,27 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.grant; + +/** The doctor or specialist an access grant is for. */ +public record GranteeView(Long userId, String name, Integer facilityId) { +} diff --git a/src/main/java/com/iemr/consent/model/notice/ActiveNotice.java b/src/main/java/com/iemr/consent/model/notice/ActiveNotice.java new file mode 100644 index 0000000..ef3e62c --- /dev/null +++ b/src/main/java/com/iemr/consent/model/notice/ActiveNotice.java @@ -0,0 +1,52 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.notice; + +import java.util.List; + +/** + * The active notice for a context and language (front-end API 3). {@code noticeVersionId} and + * {@code textHash} are sent back when saving, to prove which text was shown. Never logged. + * + * @param noticeVersionId notice version + * @param languageCode language shown + * @param context {@code REGISTRATION} or {@code REVISIT} + * @param body notice text + * @param textHash SHA-256 fingerprint of the text sections + * @param audioUrl recorded audio, or {@code null} + * @param dataItems data collected + * @param retention how long it is kept + * @param recipients who it is shared with + * @param withdrawalInfo how to withdraw + * @param grievanceInfo how to complain + * @param purposes mandatory first, then by display order + */ +public record ActiveNotice(Long noticeVersionId, String languageCode, String context, String body, + String textHash, String audioUrl, String dataItems, String retention, String recipients, + String withdrawalInfo, String grievanceInfo, List purposes) { + + @Override + public String toString() { + return "ActiveNotice[noticeVersionId=" + noticeVersionId + ", languageCode=" + languageCode + "]"; + } +} diff --git a/src/main/java/com/iemr/consent/model/notice/NoticeLanguage.java b/src/main/java/com/iemr/consent/model/notice/NoticeLanguage.java new file mode 100644 index 0000000..681fd26 --- /dev/null +++ b/src/main/java/com/iemr/consent/model/notice/NoticeLanguage.java @@ -0,0 +1,33 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.notice; + +/** + * A language the active notice is published in (front-end API 2). + * + * @param languageCode e.g. {@code hi} + * @param languageName e.g. {@code हिन्दी} + * @param hasAudio the notice has a recorded audio version + */ +public record NoticeLanguage(String languageCode, String languageName, boolean hasAudio) { +} diff --git a/src/main/java/com/iemr/consent/model/notice/NoticePurposeView.java b/src/main/java/com/iemr/consent/model/notice/NoticePurposeView.java new file mode 100644 index 0000000..d5bab56 --- /dev/null +++ b/src/main/java/com/iemr/consent/model/notice/NoticePurposeView.java @@ -0,0 +1,37 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.notice; + +/** + * A purpose as shown in the notice (front-end API 3). + * + * @param purposeCode e.g. {@code P-REM} + * @param name display name + * @param description what the data is used for + * @param isMandatory the service cannot continue without it + * @param isWithdrawable it can be withdrawn later + * @param validityDays days a grant stays valid, or {@code null} for no expiry + */ +public record NoticePurposeView(String purposeCode, String name, String description, boolean isMandatory, + boolean isWithdrawable, Integer validityDays) { +} diff --git a/src/main/java/com/iemr/consent/model/otp/OtpChallengeView.java b/src/main/java/com/iemr/consent/model/otp/OtpChallengeView.java new file mode 100644 index 0000000..8e71281 --- /dev/null +++ b/src/main/java/com/iemr/consent/model/otp/OtpChallengeView.java @@ -0,0 +1,35 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.otp; + +import java.time.Instant; + +/** + * A sent OTP challenge (front-end API 4). + * + * @param challengeId used to verify + * @param maskedPhone e.g. {@code ******3210} + * @param expiresAt when the code stops being accepted + */ +public record OtpChallengeView(String challengeId, String maskedPhone, Instant expiresAt) { +} diff --git a/src/main/java/com/iemr/consent/model/otp/OtpReason.java b/src/main/java/com/iemr/consent/model/otp/OtpReason.java new file mode 100644 index 0000000..fcca71e --- /dev/null +++ b/src/main/java/com/iemr/consent/model/otp/OtpReason.java @@ -0,0 +1,28 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.otp; + +/** Why an OTP is sent (front-end API 4). */ +public enum OtpReason { + CONSENT, GUARDIAN, ACCESS_GRANT +} diff --git a/src/main/java/com/iemr/consent/model/otp/OtpSendRequest.java b/src/main/java/com/iemr/consent/model/otp/OtpSendRequest.java new file mode 100644 index 0000000..14b7464 --- /dev/null +++ b/src/main/java/com/iemr/consent/model/otp/OtpSendRequest.java @@ -0,0 +1,46 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.otp; + +import com.iemr.consent.model.consent.ConsentPatterns; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * Send an OTP (front-end API 4). The phone is personal data: never logged. + * + * @param purpose CONSENT, GUARDIAN or ACCESS_GRANT + * @param phone 10-digit mobile number + * @param refId optional reference, e.g. a grant id + */ +public record OtpSendRequest(@NotNull OtpReason purpose, + @NotNull @Pattern(regexp = ConsentPatterns.PHONE) String phone, + @Size(max = 64) @Pattern(regexp = ConsentPatterns.CODE) String refId) { + + @Override + public String toString() { + return "OtpSendRequest[purpose=" + purpose + "]"; + } +} diff --git a/src/main/java/com/iemr/consent/model/otp/OtpVerifyRequest.java b/src/main/java/com/iemr/consent/model/otp/OtpVerifyRequest.java new file mode 100644 index 0000000..98b0f57 --- /dev/null +++ b/src/main/java/com/iemr/consent/model/otp/OtpVerifyRequest.java @@ -0,0 +1,39 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.otp; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +/** + * Verify an OTP (front-end API 5). Never logged. + * + * @param otp the 6-digit code + */ +public record OtpVerifyRequest(@NotNull @Pattern(regexp = "^[0-9]{6}$") String otp) { + + @Override + public String toString() { + return "OtpVerifyRequest[otp=******]"; + } +} diff --git a/src/main/java/com/iemr/consent/model/otp/OtpVerifyResult.java b/src/main/java/com/iemr/consent/model/otp/OtpVerifyResult.java new file mode 100644 index 0000000..a2b8364 --- /dev/null +++ b/src/main/java/com/iemr/consent/model/otp/OtpVerifyResult.java @@ -0,0 +1,27 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.otp; + +/** @param verified the code was correct */ +public record OtpVerifyResult(boolean verified) { +} diff --git a/src/main/java/com/iemr/consent/model/principal/ConsentEventView.java b/src/main/java/com/iemr/consent/model/principal/ConsentEventView.java new file mode 100644 index 0000000..f50972d --- /dev/null +++ b/src/main/java/com/iemr/consent/model/principal/ConsentEventView.java @@ -0,0 +1,43 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.principal; + +import java.time.Instant; +import java.util.List; + +import com.iemr.consent.data.consent.ConsentMode; +import com.iemr.consent.data.consent.ConsentingParty; +import com.iemr.consent.data.consent.EventType; +import com.iemr.consent.data.consent.PurposeStatus; +import com.iemr.consent.data.consent.SyncStatus; + +/** One consent event in a patient's history (API 8 {@code history[]}). */ +public record ConsentEventView(String consentId, EventType eventType, ConsentingParty consentingParty, + Long noticeVersionId, String languageCode, ConsentMode mode, Long capturedBy, Instant capturedAt, + Integer facilityId, String serviceLine, String deviceId, SyncStatus syncStatus, + List purposes) { + + /** The outcome for one purpose within the event, as stored. */ + public record PurposeOutcome(String purposeCode, PurposeStatus status, Integer versionNo) { + } +} diff --git a/src/main/java/com/iemr/consent/model/principal/CurrentDecisionView.java b/src/main/java/com/iemr/consent/model/principal/CurrentDecisionView.java new file mode 100644 index 0000000..d8104fc --- /dev/null +++ b/src/main/java/com/iemr/consent/model/principal/CurrentDecisionView.java @@ -0,0 +1,35 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.principal; + +import java.time.Instant; + +import com.iemr.consent.data.consent.PurposeStatus; + +/** + * A patient's current decision for one purpose (API 8 {@code current[]}); {@code status} is the + * effective status (an expired grant shows EXPIRED). + */ +public record CurrentDecisionView(String purposeCode, String name, boolean isMandatory, boolean isWithdrawable, + PurposeStatus status, Instant validUntil, Integer versionNo) { +} diff --git a/src/main/java/com/iemr/consent/model/principal/PatientConsents.java b/src/main/java/com/iemr/consent/model/principal/PatientConsents.java new file mode 100644 index 0000000..8247433 --- /dev/null +++ b/src/main/java/com/iemr/consent/model/principal/PatientConsents.java @@ -0,0 +1,29 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.principal; + +import java.util.List; + +/** A patient's current decisions and full history, newest first (front-end API 8). */ +public record PatientConsents(List current, List history) { +} diff --git a/src/main/java/com/iemr/consent/model/principal/PendingAction.java b/src/main/java/com/iemr/consent/model/principal/PendingAction.java new file mode 100644 index 0000000..3b37b24 --- /dev/null +++ b/src/main/java/com/iemr/consent/model/principal/PendingAction.java @@ -0,0 +1,35 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.principal; + +import java.time.Instant; +import java.util.List; + +/** + * One consent action still needed (front-end API 9). + * + * @param purposeCodes the purposes involved, sorted + * @param dueSince when it became due, or null when unknown (e.g. consent never taken) + */ +public record PendingAction(PendingReason reason, List purposeCodes, Instant dueSince) { +} diff --git a/src/main/java/com/iemr/consent/model/principal/PendingReason.java b/src/main/java/com/iemr/consent/model/principal/PendingReason.java new file mode 100644 index 0000000..bb88e4e --- /dev/null +++ b/src/main/java/com/iemr/consent/model/principal/PendingReason.java @@ -0,0 +1,28 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.principal; + +/** Why consent action is still needed for a patient (front-end API 9; research R5). */ +public enum PendingReason { + CONSENT_MISSING, LEGACY, RECONSENT_REQUIRED, GUARDIAN_TO_SELF +} diff --git a/src/main/java/com/iemr/consent/model/principal/PreferenceRequest.java b/src/main/java/com/iemr/consent/model/principal/PreferenceRequest.java new file mode 100644 index 0000000..60850fb --- /dev/null +++ b/src/main/java/com/iemr/consent/model/principal/PreferenceRequest.java @@ -0,0 +1,69 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.principal; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.iemr.consent.data.consent.Decision; +import com.iemr.consent.model.consent.ConsentPatterns; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * Change purposes to GRANT or DENY (front-end API 10; contract {@code PreferenceRequest}). A GRANT needs a + * verified OTP challenge for the patient or guardian (clarification 1). + */ +public record PreferenceRequest(@NotNull List purposeCodes, @NotNull Decision decision, + @NotNull Map<@NotNull String, @NotNull @Min(1) Integer> versions, UUID otpChallengeId, + @Size(max = 100) @Pattern(regexp = ConsentPatterns.CODE) String deviceId, Integer facilityId, + Integer providerServiceMapId, + @Size(max = 30) @Pattern(regexp = ConsentPatterns.CODE) String serviceLine) + implements PrincipalChange { + + @JsonIgnore + @AssertTrue(message = "must be 1 to 50 unique purpose codes") + public boolean isPurposeCodesValid() { + return purposeCodes == null || PurposeCodes.valid(purposeCodes, 50); + } + + @JsonIgnore + @AssertTrue(message = "must name exactly the purposes in purposeCodes") + public boolean isVersionsMatchCodes() { + return purposeCodes == null || versions == null || versions.keySet() + .equals(new HashSet<>(purposeCodes)); + } + + @JsonIgnore + @AssertTrue(message = "is required to grant a purpose") + public boolean isOtpChallengeIdPresent() { + return decision != Decision.GRANT || otpChallengeId != null; + } +} diff --git a/src/main/java/com/iemr/consent/model/principal/PrincipalChange.java b/src/main/java/com/iemr/consent/model/principal/PrincipalChange.java new file mode 100644 index 0000000..9f5a5ac --- /dev/null +++ b/src/main/java/com/iemr/consent/model/principal/PrincipalChange.java @@ -0,0 +1,40 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.principal; + +import java.util.Map; + +/** What preference and withdrawal requests share: the versions last seen and where the change was made. */ +public interface PrincipalChange { + + /** @return version last seen per purpose code */ + Map versions(); + + String deviceId(); + + Integer facilityId(); + + Integer providerServiceMapId(); + + String serviceLine(); +} diff --git a/src/main/java/com/iemr/consent/model/principal/PurposeCodes.java b/src/main/java/com/iemr/consent/model/principal/PurposeCodes.java new file mode 100644 index 0000000..ded1022 --- /dev/null +++ b/src/main/java/com/iemr/consent/model/principal/PurposeCodes.java @@ -0,0 +1,49 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.principal; + +import java.util.HashSet; +import java.util.List; +import java.util.Objects; + +import com.iemr.consent.model.consent.ConsentPatterns; + +/** Shared rule for a list of purpose codes in a request. */ +public final class PurposeCodes { + + private PurposeCodes() { + } + + /** @return true when the list has 1..{@code max} unique, well-formed purpose codes */ + public static boolean valid(List codes, int max) { + return codes != null && !codes.isEmpty() && codes.size() <= max + && codes.stream().allMatch(c -> c != null && c.matches(ConsentPatterns.PURPOSE_CODE)) + && new HashSet<>(codes).size() == codes.size(); + } + + /** @return true when the codes, joined with commas, fit {@code cm_access_grant.purposes} (255) */ + public static boolean fitColumn(List codes) { + return codes == null || String.join(",", codes.stream().map(Objects::toString).toList()) + .length() <= 255; + } +} diff --git a/src/main/java/com/iemr/consent/model/principal/PurposeSelection.java b/src/main/java/com/iemr/consent/model/principal/PurposeSelection.java new file mode 100644 index 0000000..80147bd --- /dev/null +++ b/src/main/java/com/iemr/consent/model/principal/PurposeSelection.java @@ -0,0 +1,79 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.principal; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; + +/** + * The purposes a withdrawal names: either the JSON string {@code "ALL"} or a list of purpose codes + * (front-end API 11). + * + * @param all true for {@code "ALL"} + * @param codes the named codes; empty when {@code all} + */ +@JsonDeserialize(using = PurposeSelection.Reader.class) +public record PurposeSelection(boolean all, List codes) { + + public static final String ALL = "ALL"; + + public static PurposeSelection everything() { + return new PurposeSelection(true, List.of()); + } + + public static PurposeSelection of(List codes) { + return new PurposeSelection(false, codes); + } + + /** Written back as it was read, so request hashes are stable. */ + @JsonValue + public Object json() { + return all ? ALL : codes; + } + + /** Reads {@code "ALL"} or an array of strings; anything else is a malformed request. */ + static class Reader extends JsonDeserializer { + + @Override + public PurposeSelection deserialize(JsonParser parser, + DeserializationContext context) throws IOException { + if (parser.currentToken() == JsonToken.VALUE_STRING) { + if (ALL.equals(parser.getText())) { + return everything(); + } + throw MismatchedInputException.from(parser, PurposeSelection.class, + "purposeCodes must be a list of purpose codes or \"ALL\""); + } + return of(Arrays.asList(context.readValue(parser, String[].class))); + } + } +} diff --git a/src/main/java/com/iemr/consent/model/principal/WithdrawalRequest.java b/src/main/java/com/iemr/consent/model/principal/WithdrawalRequest.java new file mode 100644 index 0000000..7a277d8 --- /dev/null +++ b/src/main/java/com/iemr/consent/model/principal/WithdrawalRequest.java @@ -0,0 +1,54 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.principal; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.iemr.consent.model.consent.ConsentPatterns; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * Withdraw purposes or {@code ALL} (front-end API 11; contract {@code WithdrawalRequest}). + * + * @param purposeCodes the purposes, or {@code "ALL"} + * @param versions version last seen per purpose; must cover every purpose that will change + */ +public record WithdrawalRequest(@NotNull PurposeSelection purposeCodes, + @NotNull Map<@NotNull String, @NotNull @Min(1) Integer> versions, + @Size(max = 100) @Pattern(regexp = ConsentPatterns.CODE) String deviceId, Integer facilityId, + Integer providerServiceMapId, + @Size(max = 30) @Pattern(regexp = ConsentPatterns.CODE) String serviceLine) + implements PrincipalChange { + + @JsonIgnore + @AssertTrue(message = "must be \"ALL\" or 1 to 50 unique purpose codes") + public boolean isPurposeCodesValid() { + return purposeCodes == null || purposeCodes.all() || PurposeCodes.valid(purposeCodes.codes(), 50); + } +} diff --git a/src/main/java/com/iemr/consent/model/principal/WithdrawalResult.java b/src/main/java/com/iemr/consent/model/principal/WithdrawalResult.java new file mode 100644 index 0000000..6900cbd --- /dev/null +++ b/src/main/java/com/iemr/consent/model/principal/WithdrawalResult.java @@ -0,0 +1,33 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.model.principal; + +import java.util.List; + +/** + * API 11 response: the patient's consents (as API 8) and, for {@code ALL}, the granted purposes left + * unchanged because they cannot be withdrawn. + */ +public record WithdrawalResult(List current, List history, + List notWithdrawn) { +} diff --git a/src/main/java/com/iemr/consent/repository/audit/AuditLogRepository.java b/src/main/java/com/iemr/consent/repository/audit/AuditLogRepository.java new file mode 100644 index 0000000..f833e28 --- /dev/null +++ b/src/main/java/com/iemr/consent/repository/audit/AuditLogRepository.java @@ -0,0 +1,42 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.repository.audit; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.stereotype.Repository; + +import com.iemr.consent.data.audit.AuditLogEntry; + +import jakarta.persistence.LockModeType; + +/** The hash-chained audit log ({@code cm_audit_log}); insert-only in code. */ +@Repository +public interface AuditLogRepository extends JpaRepository { + + /** The last entry, locked so appends are serialised and the chain stays linear. */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findTopByOrderByIdDesc(); +} diff --git a/src/main/java/com/iemr/consent/repository/consent/ConsentPurposeRepository.java b/src/main/java/com/iemr/consent/repository/consent/ConsentPurposeRepository.java new file mode 100644 index 0000000..6582845 --- /dev/null +++ b/src/main/java/com/iemr/consent/repository/consent/ConsentPurposeRepository.java @@ -0,0 +1,55 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.repository.consent; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.iemr.consent.data.consent.ConsentPurpose; + +import jakarta.persistence.LockModeType; + +/** Purpose decisions ({@code cm_consent_purpose}). */ +@Repository +public interface ConsentPurposeRepository extends JpaRepository { + + List findByConsentIdOrderByConsentPurposeIdAsc(String consentId); + + List findByConsentIdInOrderByConsentPurposeIdAsc(Collection consentIds); + + /** A patient's current decisions, one per purpose (not locked; reads only). */ + List findByBeneficiaryRegIdAndCurrentTrue(Long beneficiaryRegId); + + /** The current decision for a patient and purpose, locked until the transaction ends. */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select p from ConsentPurpose p where p.beneficiaryRegId = :ben and p.purposeCode = :code" + + " and p.current = true") + Optional findCurrent(@Param("ben") Long beneficiaryRegId, @Param("code") String purposeCode); +} diff --git a/src/main/java/com/iemr/consent/repository/consent/ConsentRepository.java b/src/main/java/com/iemr/consent/repository/consent/ConsentRepository.java new file mode 100644 index 0000000..b954ecc --- /dev/null +++ b/src/main/java/com/iemr/consent/repository/consent/ConsentRepository.java @@ -0,0 +1,44 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.repository.consent; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.iemr.consent.data.consent.Consent; + +/** Consent events ({@code cm_consent}); insert-only in code. */ +@Repository +public interface ConsentRepository extends JpaRepository { + + Optional findByCapturedByAndRequestKey(Long capturedBy, String requestKey); + + /** A patient's consent events, newest first (API 8 history). */ + List findByBeneficiaryRegIdOrderByServerReceivedAtDesc(Long beneficiaryRegId); + + List findByConsentIdIn(Collection consentIds); +} diff --git a/src/main/java/com/iemr/consent/repository/consent/GuardianLinkRepository.java b/src/main/java/com/iemr/consent/repository/consent/GuardianLinkRepository.java new file mode 100644 index 0000000..fbefca6 --- /dev/null +++ b/src/main/java/com/iemr/consent/repository/consent/GuardianLinkRepository.java @@ -0,0 +1,37 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.repository.consent; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.iemr.consent.data.consent.GuardianLink; + +/** Guardian links ({@code cm_guardian_link}). */ +@Repository +public interface GuardianLinkRepository extends JpaRepository { + + Optional findFirstByConsentIdAndDeletedFalse(String consentId); +} diff --git a/src/main/java/com/iemr/consent/repository/evidence/ConsentEvidenceRepository.java b/src/main/java/com/iemr/consent/repository/evidence/ConsentEvidenceRepository.java new file mode 100644 index 0000000..e4b99fa --- /dev/null +++ b/src/main/java/com/iemr/consent/repository/evidence/ConsentEvidenceRepository.java @@ -0,0 +1,50 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.repository.evidence; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.iemr.consent.data.evidence.ConsentEvidence; + +/** Evidence ({@code cm_consent_evidence}). */ +@Repository +public interface ConsentEvidenceRepository extends JpaRepository { + + Optional findByUploadedByAndRequestKey(Long uploadedBy, String requestKey); + + /** + * Links evidence to a consent only if it is still unlinked. + * + * @return 1 when linked, 0 when another consent linked it first + */ + @Modifying + @Query("update ConsentEvidence e set e.consentId = :consentId where e.evidenceId = :evidenceId" + + " and e.consentId is null") + int linkToConsent(@Param("evidenceId") String evidenceId, @Param("consentId") String consentId); +} diff --git a/src/main/java/com/iemr/consent/repository/grant/AccessGrantRepository.java b/src/main/java/com/iemr/consent/repository/grant/AccessGrantRepository.java new file mode 100644 index 0000000..16aabc5 --- /dev/null +++ b/src/main/java/com/iemr/consent/repository/grant/AccessGrantRepository.java @@ -0,0 +1,59 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.repository.grant; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.iemr.consent.data.grant.AccessGrant; +import com.iemr.consent.data.grant.GrantStatus; + +import jakarta.persistence.LockModeType; + +/** Access grants ({@code cm_access_grant}). */ +@Repository +public interface AccessGrantRepository extends JpaRepository { + + List findByBeneficiaryRegIdAndDeletedFalseOrderByCreatedDateDesc(Long beneficiaryRegId); + + Optional findByRequestedByAndRequestKey(Long requestedBy, String requestKey); + + /** One grant, locked until the transaction ends (approve, revoke). */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select g from AccessGrant g where g.grantId = :id and g.deleted = false") + Optional findLockedByGrantId(@Param("id") String grantId); + + /** A patient's grants in the given stored statuses, locked (withdrawal cascade). */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select g from AccessGrant g where g.beneficiaryRegId = :ben and g.status in :statuses" + + " and g.deleted = false") + List findLockedByBeneficiaryRegIdAndStatusIn(@Param("ben") Long beneficiaryRegId, + @Param("statuses") Collection statuses); +} diff --git a/src/main/java/com/iemr/consent/repository/notice/NoticePurposeRepository.java b/src/main/java/com/iemr/consent/repository/notice/NoticePurposeRepository.java new file mode 100644 index 0000000..045b47f --- /dev/null +++ b/src/main/java/com/iemr/consent/repository/notice/NoticePurposeRepository.java @@ -0,0 +1,38 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.repository.notice; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.iemr.consent.data.notice.NoticePurpose; +import com.iemr.consent.data.notice.NoticePurposeId; + +/** Purposes covered by a notice version ({@code cm_notice_purpose}). */ +@Repository +public interface NoticePurposeRepository extends JpaRepository { + + List findByNoticeVersionIdAndDeletedFalseOrderByDisplayRankAsc(Long noticeVersionId); +} diff --git a/src/main/java/com/iemr/consent/repository/notice/NoticeRepository.java b/src/main/java/com/iemr/consent/repository/notice/NoticeRepository.java new file mode 100644 index 0000000..f9cbba6 --- /dev/null +++ b/src/main/java/com/iemr/consent/repository/notice/NoticeRepository.java @@ -0,0 +1,50 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.repository.notice; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import com.iemr.consent.data.notice.Notice; +import com.iemr.consent.data.notice.NoticeContext; +import com.iemr.consent.data.notice.NoticeStatus; + +/** Notice versions ({@code cm_notice}). */ +@Repository +public interface NoticeRepository extends JpaRepository { + + /** The active notice: the highest published version for a context. */ + Optional findFirstByContextAndStatusAndDeletedFalseOrderByVersionNoDesc(NoticeContext context, + NoticeStatus status); + + /** Published material versions of a notice newer than the given one, newest first (C14). */ + @Query("select n from Notice n where n.noticeCode = :code and n.versionNo > :version" + + " and n.status = com.iemr.consent.data.notice.NoticeStatus.PUBLISHED and n.material = true" + + " and n.deleted = false order by n.versionNo desc") + List findNewerMaterial(@Param("code") String noticeCode, @Param("version") Integer versionNo); +} diff --git a/src/main/java/com/iemr/consent/repository/notice/NoticeTranslationRepository.java b/src/main/java/com/iemr/consent/repository/notice/NoticeTranslationRepository.java new file mode 100644 index 0000000..643faeb --- /dev/null +++ b/src/main/java/com/iemr/consent/repository/notice/NoticeTranslationRepository.java @@ -0,0 +1,41 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.repository.notice; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.iemr.consent.data.notice.NoticeTranslation; + +/** Notice translations ({@code cm_notice_translation}). */ +@Repository +public interface NoticeTranslationRepository extends JpaRepository { + + List findByNoticeVersionIdAndDeletedFalseOrderByLanguageCodeAsc(Long noticeVersionId); + + Optional findByNoticeVersionIdAndLanguageCodeAndDeletedFalse(Long noticeVersionId, + String languageCode); +} diff --git a/src/main/java/com/iemr/consent/repository/notice/PurposeRepository.java b/src/main/java/com/iemr/consent/repository/notice/PurposeRepository.java new file mode 100644 index 0000000..a6d53cf --- /dev/null +++ b/src/main/java/com/iemr/consent/repository/notice/PurposeRepository.java @@ -0,0 +1,38 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.repository.notice; + +import java.util.Collection; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.iemr.consent.data.notice.Purpose; + +/** Purposes ({@code cm_purpose}). */ +@Repository +public interface PurposeRepository extends JpaRepository { + + List findByPurposeCodeInAndActiveTrueAndDeletedFalse(Collection purposeCodes); +} diff --git a/src/main/java/com/iemr/consent/service/audit/AuditLogService.java b/src/main/java/com/iemr/consent/service/audit/AuditLogService.java new file mode 100644 index 0000000..db97723 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/audit/AuditLogService.java @@ -0,0 +1,55 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.audit; + +/** Appends to the tamper-evident consent audit log, inside the caller's transaction (FR-013). */ +public interface AuditLogService { + + /** Audit action for a saved consent. */ + String CONSENT_CAPTURED = "CONSENT_CAPTURED"; + + /** Audit action for a preference change (API 10). */ + String PREFERENCE_CHANGED = "PREFERENCE_CHANGED"; + + /** Audit action for a withdrawal (API 11). */ + String CONSENT_WITHDRAWN = "CONSENT_WITHDRAWN"; + + /** + * Audit actions for access grants. Their {@code consentId} is null and their purpose summary is + * {@code grant:}; the grant row holds the purposes. + */ + String ACCESS_REQUESTED = "ACCESS_REQUESTED"; + + /** See {@link #ACCESS_REQUESTED}. */ + String ACCESS_APPROVED = "ACCESS_APPROVED"; + + /** See {@link #ACCESS_REQUESTED}. */ + String ACCESS_REVOKED = "ACCESS_REVOKED"; + + /** + * @param purposeSummary purpose codes with statuses, e.g. {@code P-REG:GRANTED,P-REM:DENIED}; no + * personal values + */ + void append(String action, String consentId, Long beneficiaryRegId, String purposeSummary, String status, + Long initiator, String sourceIp); +} diff --git a/src/main/java/com/iemr/consent/service/audit/AuditLogServiceImpl.java b/src/main/java/com/iemr/consent/service/audit/AuditLogServiceImpl.java new file mode 100644 index 0000000..79ccbff --- /dev/null +++ b/src/main/java/com/iemr/consent/service/audit/AuditLogServiceImpl.java @@ -0,0 +1,85 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.audit; + +import java.time.Clock; +import java.time.Instant; +import java.util.Objects; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.iemr.consent.data.audit.AuditLogEntry; +import com.iemr.consent.repository.audit.AuditLogRepository; +import com.iemr.consent.utils.crypto.Sha256; + +/** + * Hash-chained audit log (research R7). The last entry is read with a row lock, so concurrent + * appends are serialised; the new {@code entry_hash} = SHA-256 of the previous hash and the + * entry's content. It must run inside the transaction of the action it records + * ({@code MANDATORY}), so either both are kept or neither. + */ +@Service +public class AuditLogServiceImpl implements AuditLogService { + + private final AuditLogRepository repository; + + private final Clock clock; + + public AuditLogServiceImpl(AuditLogRepository repository, Clock clock) { + this.repository = repository; + this.clock = clock; + } + + @Override + @Transactional(propagation = Propagation.MANDATORY) + public void append(String action, String consentId, Long beneficiaryRegId, String purposeSummary, + String status, Long initiator, String sourceIp) { + String prevHash = repository.findTopByOrderByIdDesc().map(AuditLogEntry::getEntryHash) + .orElseThrow(() -> new IllegalStateException("Audit log genesis row is missing")); + AuditLogEntry entry = new AuditLogEntry(); + entry.setAction(action); + entry.setConsentId(consentId); + entry.setBeneficiaryRegId(beneficiaryRegId); + entry.setPurpose(purposeSummary); + entry.setStatus(status); + entry.setInitiator(initiator); + entry.setSourceIp(sourceIp); + entry.setCreatedAt(clock.instant()); + entry.setPrevHash(prevHash); + entry.setEntryHash(entryHash(prevHash, entry)); + repository.save(entry); + } + + /** SHA-256 of {@code prevHash} followed by the canonical content of the entry. */ + static String entryHash(String prevHash, AuditLogEntry entry) { + Instant createdAt = entry.getCreatedAt(); + String content = String.join("|", entry.getAction(), Objects.toString(entry.getConsentId(), ""), + Objects.toString(entry.getBeneficiaryRegId(), ""), + Objects.toString(entry.getPurpose(), ""), Objects.toString(entry.getStatus(), ""), + Objects.toString(entry.getInitiator(), ""), Objects.toString(entry.getSourceIp(), ""), + createdAt.toString()); + return Sha256.hex(prevHash + content); + } +} diff --git a/src/main/java/com/iemr/consent/service/consent/ConsentProofWriter.java b/src/main/java/com/iemr/consent/service/consent/ConsentProofWriter.java new file mode 100644 index 0000000..3659a80 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/ConsentProofWriter.java @@ -0,0 +1,33 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import com.iemr.consent.data.consent.Consent; +import com.iemr.consent.model.consent.SaveConsentRequest; +import com.iemr.consent.utils.auth.AuthenticatedUser; + +/** Writes proof rows for a saved consent, inside the save's transaction. */ +public interface ConsentProofWriter { + + void write(Consent consent, SaveConsentRequest request, AuthenticatedUser user); +} diff --git a/src/main/java/com/iemr/consent/service/consent/ConsentResultMapper.java b/src/main/java/com/iemr/consent/service/consent/ConsentResultMapper.java new file mode 100644 index 0000000..3b279f1 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/ConsentResultMapper.java @@ -0,0 +1,53 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import java.util.List; + +import com.iemr.consent.data.consent.Consent; +import com.iemr.consent.data.consent.ConsentPurpose; +import com.iemr.consent.data.consent.Decision; +import com.iemr.consent.data.consent.PurposeStatus; +import com.iemr.consent.model.consent.PurposeResult; +import com.iemr.consent.model.consent.SaveConsentResult; + +/** + * Rebuilds a save's original answer from its stored rows, for a replay (C18, research R4): the + * status comes from the stored {@code decision}, never from the row's current status, so a later + * change cannot alter the replayed answer. + */ +final class ConsentResultMapper { + + private ConsentResultMapper() { + } + + static SaveConsentResult fromStored(Consent consent, List rows) { + List purposes = rows.stream().map(row -> new PurposeResult(row.getPurposeCode(), + statusOf(row.getDecision()), row.getVersionNo())).toList(); + return new SaveConsentResult(consent.getConsentId(), consent.getSyncStatus(), purposes); + } + + static PurposeStatus statusOf(Decision decision) { + return decision == Decision.GRANT ? PurposeStatus.GRANTED : PurposeStatus.DENIED; + } +} diff --git a/src/main/java/com/iemr/consent/service/consent/ConsentRules.java b/src/main/java/com/iemr/consent/service/consent/ConsentRules.java new file mode 100644 index 0000000..eeb7b7b --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/ConsentRules.java @@ -0,0 +1,46 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import java.util.List; + +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; +import com.iemr.consent.utils.response.FieldErrorDetail; + +/** Shared helper for consent validation failures. */ +final class ConsentRules { + + private ConsentRules() { + } + + /** {@code VALIDATION_FAILED} naming one field; the message never contains the value. */ + static ConsentApiException invalid(String field, String message) { + return new ConsentApiException(ErrorCode.VALIDATION_FAILED, null, null, + List.of(new FieldErrorDetail(field, message))); + } + + static boolean blank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/iemr/consent/service/consent/ConsentService.java b/src/main/java/com/iemr/consent/service/consent/ConsentService.java new file mode 100644 index 0000000..355a22a --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/ConsentService.java @@ -0,0 +1,34 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import com.iemr.consent.model.consent.SaveConsentRequest; +import com.iemr.consent.model.consent.SaveConsentResult; +import com.iemr.consent.utils.auth.AuthenticatedUser; + +/** Saving consent decisions (FR-009 – FR-017). */ +public interface ConsentService { + + SaveConsentResult save(SaveConsentRequest request, String requestKey, String requestHash, + AuthenticatedUser user, String sourceIp); +} diff --git a/src/main/java/com/iemr/consent/service/consent/ConsentServiceImpl.java b/src/main/java/com/iemr/consent/service/consent/ConsentServiceImpl.java new file mode 100644 index 0000000..682ad0f --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/ConsentServiceImpl.java @@ -0,0 +1,87 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import com.iemr.consent.data.consent.Consent; +import com.iemr.consent.model.consent.SaveConsentRequest; +import com.iemr.consent.model.consent.SaveConsentResult; +import com.iemr.consent.repository.consent.ConsentPurposeRepository; +import com.iemr.consent.repository.consent.ConsentRepository; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; + +/** + * Saves a consent exactly once per request key (C18, research R4): a repeat with the same key and + * the same request returns the original answer; a different request is {@code STATE_CONFLICT}. + * When two identical requests race, the database's unique key lets one win and the other replays + * it. Validation runs before the write; the write is one transaction ({@link ConsentWriter}). + */ +@Service +public class ConsentServiceImpl implements ConsentService { + + private final ConsentRepository consentRepository; + + private final ConsentPurposeRepository purposeRepository; + + private final ConsentValidator validator; + + private final ConsentWriter writer; + + public ConsentServiceImpl(ConsentRepository consentRepository, ConsentPurposeRepository purposeRepository, + ConsentValidator validator, ConsentWriter writer) { + this.consentRepository = consentRepository; + this.purposeRepository = purposeRepository; + this.validator = validator; + this.writer = writer; + } + + @Override + public SaveConsentResult save(SaveConsentRequest request, String requestKey, String requestHash, + AuthenticatedUser user, String sourceIp) { + Consent existing = consentRepository.findByCapturedByAndRequestKey(user.userId(), requestKey) + .orElse(null); + if (existing != null) { + return replay(existing, requestHash); + } + ValidatedConsent validated = validator.validate(request, user); + try { + return writer.write(request, validated, requestKey, requestHash, user, sourceIp); + } catch (DataIntegrityViolationException e) { + Consent winner = consentRepository.findByCapturedByAndRequestKey(user.userId(), requestKey) + .orElseThrow(() -> new ConsentApiException(ErrorCode.STATE_CONFLICT)); + return replay(winner, requestHash); + } + } + + private SaveConsentResult replay(Consent consent, String requestHash) { + if (!consent.getRequestHash().equals(requestHash)) { + throw new ConsentApiException(ErrorCode.STATE_CONFLICT); + } + return ConsentResultMapper.fromStored(consent, + purposeRepository.findByConsentIdOrderByConsentPurposeIdAsc(consent.getConsentId())); + } +} diff --git a/src/main/java/com/iemr/consent/service/consent/ConsentValidator.java b/src/main/java/com/iemr/consent/service/consent/ConsentValidator.java new file mode 100644 index 0000000..cd055bc --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/ConsentValidator.java @@ -0,0 +1,129 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import com.iemr.consent.data.consent.ConsentMode; +import com.iemr.consent.data.consent.ConsentingParty; +import com.iemr.consent.data.consent.Decision; +import com.iemr.consent.data.consent.EventType; +import com.iemr.consent.data.evidence.ConsentEvidence; +import com.iemr.consent.data.notice.Purpose; +import com.iemr.consent.model.consent.DecisionItem; +import com.iemr.consent.model.consent.SaveConsentRequest; +import com.iemr.consent.service.evidence.EvidenceService; +import com.iemr.consent.service.notice.NoticeService; +import com.iemr.consent.service.notice.PublishedNotice; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; + +/** + * Checks a save before anything is written (FR-010, FR-012, research R6). + * + *
    + *
  • The notice version is still published and the fingerprint matches the language shown; + * otherwise {@code STALE_VERSION}.
  • + *
  • Decisions cover exactly the notice's purposes, once each.
  • + *
  • A GRANT needs a mode and that mode's proof ({@link ModeProofRule}); SELF_READ needs none. + * A decline with no mode is allowed only when every decision is DENY.
  • + *
  • Every evidence id can be linked; a guardian's details pass {@link GuardianRule}.
  • + *
+ */ +@Component +public class ConsentValidator { + + /** Event types the consent screen may save; WITHDRAWAL and LEGACY come from other paths. */ + private static final Set SCREEN_EVENTS = Set.of(EventType.NEW, EventType.UPDATE, EventType.RENEWAL); + + private final NoticeService noticeService; + + private final EvidenceService evidenceService; + + private final GuardianRule guardianRule; + + private final Map rules = new EnumMap<>(ConsentMode.class); + + public ConsentValidator(NoticeService noticeService, EvidenceService evidenceService, + GuardianRule guardianRule, List rules) { + this.noticeService = noticeService; + this.evidenceService = evidenceService; + this.guardianRule = guardianRule; + rules.forEach(rule -> this.rules.put(rule.mode(), rule)); + } + + public ValidatedConsent validate(SaveConsentRequest request, AuthenticatedUser user) { + if (!SCREEN_EVENTS.contains(request.eventType())) { + throw ConsentRules.invalid("eventType", "must be NEW, UPDATE or RENEWAL"); + } + PublishedNotice notice = noticeService.findPublished(request.noticeVersionId(), request.languageCode()) + .filter(n -> n.translation().getTextHash().equals(request.textHash())) + .orElseThrow(() -> new ConsentApiException(ErrorCode.STALE_VERSION)); + checkDecisions(request.decisions(), notice.purposes()); + boolean anyGrant = request.decisions().stream().anyMatch(d -> d.decision() == Decision.GRANT); + if (anyGrant && request.mode() == null) { + throw ConsentRules.invalid("mode", "is required when any purpose is granted"); + } + List evidence = evidenceService.requireLinkable(orEmpty(request.evidenceIds()), user); + if (anyGrant && request.mode() != ConsentMode.SELF_READ) { + ModeProofRule rule = rules.get(request.mode()); + if (rule == null) { + throw ConsentRules.invalid("mode", "is not supported"); + } + rule.check(request, user, evidence); + } + List all = new ArrayList<>(evidence); + if (request.consentingParty() == ConsentingParty.GUARDIAN) { + guardianRule.check(request, user); + all.addAll(evidenceService.requireLinkable(orEmpty(request.guardian().evidenceIds()), user)); + } + return new ValidatedConsent(notice, all); + } + + private static void checkDecisions(List decisions, List purposes) { + Set expected = purposes.stream().map(Purpose::getPurposeCode).collect(Collectors.toSet()); + Set seen = new HashSet<>(); + for (DecisionItem decision : decisions) { + if (!seen.add(decision.purposeCode())) { + throw ConsentRules.invalid("decisions", "has a purpose more than once"); + } + } + if (!seen.equals(expected)) { + throw ConsentRules.invalid("decisions", "must cover exactly the purposes of the notice"); + } + } + + private static List orEmpty(List ids) { + return ids == null ? List.of() : ids; + } +} diff --git a/src/main/java/com/iemr/consent/service/consent/ConsentWriter.java b/src/main/java/com/iemr/consent/service/consent/ConsentWriter.java new file mode 100644 index 0000000..9b0472b --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/ConsentWriter.java @@ -0,0 +1,176 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.iemr.consent.config.ConsentProperties; +import com.iemr.consent.data.consent.Consent; +import com.iemr.consent.data.consent.ConsentPurpose; +import com.iemr.consent.data.consent.PurposeStatus; +import com.iemr.consent.data.consent.SyncStatus; +import com.iemr.consent.data.notice.Purpose; +import com.iemr.consent.model.consent.DecisionItem; +import com.iemr.consent.model.consent.PurposeResult; +import com.iemr.consent.model.consent.SaveConsentRequest; +import com.iemr.consent.model.consent.SaveConsentResult; +import com.iemr.consent.repository.consent.ConsentPurposeRepository; +import com.iemr.consent.repository.consent.ConsentRepository; +import com.iemr.consent.service.audit.AuditLogService; +import com.iemr.consent.utils.auth.AuthenticatedUser; + +/** + * Writes one validated save in a single transaction (FR-011, FR-013, research R6): the consent + * event, one decision per purpose, the proof rows and the audit entry; if anything fails, nothing + * is kept. + * + *

For a known patient, an earlier current decision for the same purpose is marked + * {@code SUPERSEDED} and the new one gets the next version (plan deviation 6); the current row is + * locked, so two simultaneous saves cannot both become current. A consent without a patient + * (taken before registration) is stored unlinked.

+ */ +@Component +public class ConsentWriter { + + private final ConsentRepository consentRepository; + + private final ConsentPurposeRepository purposeRepository; + + private final List proofWriters; + + private final AuditLogService auditLogService; + + private final Clock clock; + + private final Duration maxSkew; + + public ConsentWriter(ConsentRepository consentRepository, ConsentPurposeRepository purposeRepository, + List proofWriters, AuditLogService auditLogService, Clock clock, + ConsentProperties properties) { + this.consentRepository = consentRepository; + this.purposeRepository = purposeRepository; + this.proofWriters = proofWriters; + this.auditLogService = auditLogService; + this.clock = clock; + this.maxSkew = Duration.ofMinutes(properties.capture().maxSkewMinutes()); + } + + @Transactional + public SaveConsentResult write(SaveConsentRequest request, ValidatedConsent validated, String requestKey, + String requestHash, AuthenticatedUser user, String sourceIp) { + Consent consent = consentRepository.saveAndFlush(newConsent(request, requestKey, requestHash, user)); + Map purposes = validated.notice().purposes().stream() + .collect(Collectors.toMap(Purpose::getPurposeCode, Function.identity())); + List results = new ArrayList<>(); + for (DecisionItem decision : request.decisions()) { + results.add(writeDecision(consent, decision, purposes.get(decision.purposeCode()))); + } + proofWriters.forEach(writer -> writer.write(consent, request, user)); + auditLogService.append(AuditLogService.CONSENT_CAPTURED, consent.getConsentId(), + consent.getBeneficiaryRegId(), summary(results), overall(results), user.userId(), + sourceIp); + return new SaveConsentResult(consent.getConsentId(), consent.getSyncStatus(), results); + } + + private PurposeResult writeDecision(Consent consent, DecisionItem decision, Purpose purpose) { + int version = 1; + Long patient = consent.getBeneficiaryRegId(); + if (patient != null) { + ConsentPurpose previous = purposeRepository.findCurrent(patient, decision.purposeCode()) + .orElse(null); + if (previous != null) { + previous.setCurrent(false); + previous.setStatus(PurposeStatus.SUPERSEDED); + purposeRepository.saveAndFlush(previous); + version = previous.getVersionNo() + 1; + } + } + PurposeStatus status = ConsentResultMapper.statusOf(decision.decision()); + ConsentPurpose row = new ConsentPurpose(); + row.setConsentId(consent.getConsentId()); + row.setBeneficiaryRegId(patient); + row.setPurposeCode(decision.purposeCode()); + row.setDecision(decision.decision()); + row.setStatus(status); + row.setVersionNo(version); + row.setCurrent(true); + if (status == PurposeStatus.GRANTED && purpose.getValidityDays() != null) { + row.setValidUntil(consent.getCapturedAt().plus(Duration.ofDays(purpose.getValidityDays()))); + } + purposeRepository.save(row); + return new PurposeResult(decision.purposeCode(), status, version); + } + + private Consent newConsent(SaveConsentRequest request, String requestKey, String requestHash, + AuthenticatedUser user) { + Instant now = clock.instant(); + Consent consent = new Consent(); + consent.setConsentId(UUID.randomUUID().toString()); + consent.setBeneficiaryRegId(request.beneficiaryRegId()); + consent.setEventType(request.eventType()); + consent.setConsentingParty(request.consentingParty()); + consent.setNoticeVersionId(request.noticeVersionId()); + consent.setLanguageCode(request.languageCode()); + consent.setTextHash(request.textHash()); + consent.setChannel(request.channel()); + consent.setMode(request.mode()); + consent.setCapturedBy(user.userId()); + consent.setProviderServiceMapId(request.providerServiceMapId()); + consent.setFacilityId(request.facilityId()); + consent.setServicePointId(request.servicePointId()); + consent.setServiceLine(request.serviceLine()); + consent.setDeviceId(request.deviceId()); + consent.setCapturedAt(request.capturedAt()); + consent.setServerReceivedAt(now); + consent.setCaptureTimeFlagged(Duration.between(request.capturedAt(), now).abs().compareTo(maxSkew) > 0); + consent.setSyncStatus(SyncStatus.SYNCED); + consent.setProjectId(request.projectId()); + consent.setConfigVersion(request.configVersion()); + consent.setRequestKey(requestKey); + consent.setRequestHash(requestHash); + return consent; + } + + private static String summary(List results) { + return results.stream().map(r -> r.purposeCode() + ":" + r.status()).collect(Collectors.joining(",")); + } + + private static String overall(List results) { + long granted = results.stream().filter(r -> r.status() == PurposeStatus.GRANTED).count(); + if (granted == 0) { + return PurposeStatus.DENIED.name(); + } + return granted == results.size() ? PurposeStatus.GRANTED.name() : "PARTIAL"; + } +} diff --git a/src/main/java/com/iemr/consent/service/consent/EvidenceProofWriter.java b/src/main/java/com/iemr/consent/service/consent/EvidenceProofWriter.java new file mode 100644 index 0000000..c294f82 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/EvidenceProofWriter.java @@ -0,0 +1,95 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.springframework.stereotype.Component; + +import com.iemr.consent.data.consent.Consent; +import com.iemr.consent.data.evidence.ConsentEvidence; +import com.iemr.consent.data.evidence.EvidenceType; +import com.iemr.consent.model.consent.Proof; +import com.iemr.consent.model.consent.SaveConsentRequest; +import com.iemr.consent.repository.evidence.ConsentEvidenceRepository; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.crypto.FieldEncryptor; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; + +/** + * Links uploaded files to the consent (once only; a file already linked by another save is + * {@code STATE_CONFLICT}) and records the witness (name encrypted) and the worker's read-aloud + * confirmation. + */ +@Component +public class EvidenceProofWriter implements ConsentProofWriter { + + private final ConsentEvidenceRepository repository; + + private final FieldEncryptor encryptor; + + private final Clock clock; + + public EvidenceProofWriter(ConsentEvidenceRepository repository, FieldEncryptor encryptor, Clock clock) { + this.repository = repository; + this.encryptor = encryptor; + this.clock = clock; + } + + @Override + public void write(Consent consent, SaveConsentRequest request, AuthenticatedUser user) { + for (UUID id : evidenceIds(request)) { + if (repository.linkToConsent(id.toString(), consent.getConsentId()) != 1) { + throw new ConsentApiException(ErrorCode.STATE_CONFLICT); + } + } + Proof proof = request.proof(); + if (proof == null) { + return; + } + if (!ConsentRules.blank(proof.witnessName())) { + ConsentEvidence witness = ProofRows.row(consent, EvidenceType.WITNESS, user, clock); + witness.setWitnessNameEncrypted(encryptor.encrypt(proof.witnessName())); + witness.setWitnessRelationship(proof.witnessRelationship()); + repository.save(witness); + } + if (Boolean.TRUE.equals(proof.workerConfirmed())) { + repository.save(ProofRows.row(consent, EvidenceType.WORKER_CONFIRMATION, user, clock)); + } + } + + private static List evidenceIds(SaveConsentRequest request) { + List ids = new ArrayList<>(); + if (request.evidenceIds() != null) { + ids.addAll(request.evidenceIds()); + } + if (request.guardian() != null && request.guardian().evidenceIds() != null) { + ids.addAll(request.guardian().evidenceIds()); + } + return ids.stream().distinct().toList(); + } +} diff --git a/src/main/java/com/iemr/consent/service/consent/GuardianProofWriter.java b/src/main/java/com/iemr/consent/service/consent/GuardianProofWriter.java new file mode 100644 index 0000000..774a0b4 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/GuardianProofWriter.java @@ -0,0 +1,107 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import java.time.Clock; + +import org.springframework.stereotype.Component; + +import com.iemr.consent.data.consent.Consent; +import com.iemr.consent.data.consent.ConsentingParty; +import com.iemr.consent.data.consent.GuardianLink; +import com.iemr.consent.data.consent.GuardianVerification; +import com.iemr.consent.data.evidence.ConsentEvidence; +import com.iemr.consent.data.evidence.EvidenceType; +import com.iemr.consent.model.consent.GuardianInfo; +import com.iemr.consent.model.consent.SaveConsentRequest; +import com.iemr.consent.model.otp.OtpReason; +import com.iemr.consent.repository.consent.GuardianLinkRepository; +import com.iemr.consent.repository.evidence.ConsentEvidenceRepository; +import com.iemr.consent.service.otp.OtpChallenge; +import com.iemr.consent.service.otp.OtpService; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.crypto.FieldEncryptor; +import com.iemr.consent.utils.http.AfterCommit; + +/** + * Records the guardian who consented (C6, C7, C8): name and phone encrypted; verification other + * than OTP is flagged for follow-up (D-03); an OTP verification records the masked phone and marks + * the challenge used after commit; a witness is recorded with an encrypted name. + */ +@Component +public class GuardianProofWriter implements ConsentProofWriter { + + private final GuardianLinkRepository guardianRepository; + + private final ConsentEvidenceRepository evidenceRepository; + + private final OtpService otpService; + + private final FieldEncryptor encryptor; + + private final Clock clock; + + public GuardianProofWriter(GuardianLinkRepository guardianRepository, + ConsentEvidenceRepository evidenceRepository, OtpService otpService, FieldEncryptor encryptor, + Clock clock) { + this.guardianRepository = guardianRepository; + this.evidenceRepository = evidenceRepository; + this.otpService = otpService; + this.encryptor = encryptor; + this.clock = clock; + } + + @Override + public void write(Consent consent, SaveConsentRequest request, AuthenticatedUser user) { + if (request.consentingParty() != ConsentingParty.GUARDIAN) { + return; + } + GuardianInfo guardian = request.guardian(); + GuardianLink link = new GuardianLink(); + link.setConsentId(consent.getConsentId()); + link.setBeneficiaryRegId(consent.getBeneficiaryRegId()); + link.setGuardianType(guardian.guardianType()); + link.setGuardianNameEncrypted(encryptor.encrypt(guardian.name())); + link.setGuardianPhoneEncrypted(guardian.phone() == null ? null : encryptor.encrypt(guardian.phone())); + link.setRelationship(guardian.relationship()); + link.setVerificationMethod(guardian.verification()); + link.setFollowUpRequired(guardian.verification() != GuardianVerification.OTP); + link.setCreatedBy(user.auditName()); + if (guardian.verification() == GuardianVerification.OTP) { + OtpChallenge challenge = otpService.requireUsable(guardian.otpChallengeId().toString(), + OtpReason.GUARDIAN, user); + link.setVerifiedAt(challenge.verifiedAt()); + ConsentEvidence otp = ProofRows.row(consent, EvidenceType.OTP, user, clock); + otp.setOtpStatus(OtpProofWriter.VERIFIED); + otp.setMaskedPhone(challenge.maskedPhone()); + evidenceRepository.save(otp); + AfterCommit.run(() -> otpService.markUsed(challenge.challengeId())); + } + if (!ConsentRules.blank(guardian.witnessName())) { + ConsentEvidence witness = ProofRows.row(consent, EvidenceType.GUARDIAN_WITNESS, user, clock); + witness.setWitnessNameEncrypted(encryptor.encrypt(guardian.witnessName())); + evidenceRepository.save(witness); + } + guardianRepository.save(link); + } +} diff --git a/src/main/java/com/iemr/consent/service/consent/GuardianRule.java b/src/main/java/com/iemr/consent/service/consent/GuardianRule.java new file mode 100644 index 0000000..31e951d --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/GuardianRule.java @@ -0,0 +1,71 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import org.springframework.stereotype.Component; + +import com.iemr.consent.data.consent.GuardianVerification; +import com.iemr.consent.model.consent.GuardianInfo; +import com.iemr.consent.model.consent.SaveConsentRequest; +import com.iemr.consent.model.otp.OtpReason; +import com.iemr.consent.service.otp.OtpChallenge; +import com.iemr.consent.service.otp.OtpService; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.crypto.Sha256; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; + +/** + * Guardian consent (C6, C7, C8, D-03): OTP needs a verified, unused GUARDIAN challenge for the same + * phone; ID_SIGHTED and UNVERIFIED need a witness and are flagged for follow-up when saved. + */ +@Component +public class GuardianRule { + + private final OtpService otpService; + + public GuardianRule(OtpService otpService) { + this.otpService = otpService; + } + + public void check(SaveConsentRequest request, AuthenticatedUser user) { + GuardianInfo guardian = request.guardian(); + if (guardian == null) { + throw ConsentRules.invalid("guardian", "is required when the guardian consents"); + } + if (guardian.verification() != GuardianVerification.OTP) { + if (ConsentRules.blank(guardian.witnessName())) { + throw ConsentRules.invalid("guardian.witnessName", "required without OTP"); + } + return; + } + if (ConsentRules.blank(guardian.phone()) || guardian.otpChallengeId() == null) { + throw ConsentRules.invalid("guardian.otpChallengeId", "phone and verified OTP are required"); + } + String challengeId = guardian.otpChallengeId().toString(); + OtpChallenge challenge = otpService.requireUsable(challengeId, OtpReason.GUARDIAN, user); + if (!challenge.phoneHash().equals(Sha256.hex(guardian.phone()))) { + throw new ConsentApiException(ErrorCode.OTP_INVALID); + } + } +} diff --git a/src/main/java/com/iemr/consent/service/consent/ModeProofRule.java b/src/main/java/com/iemr/consent/service/consent/ModeProofRule.java new file mode 100644 index 0000000..cb132d8 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/ModeProofRule.java @@ -0,0 +1,42 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import java.util.List; + +import com.iemr.consent.data.consent.ConsentMode; +import com.iemr.consent.data.evidence.ConsentEvidence; +import com.iemr.consent.model.consent.SaveConsentRequest; +import com.iemr.consent.utils.auth.AuthenticatedUser; + +/** Checks the proof a consent mode requires before a GRANT is saved (FR-010). One bean per mode. */ +public interface ModeProofRule { + + ConsentMode mode(); + + /** + * @param evidence the request's linkable evidence files, already checked to exist, belong to the + * user and be unlinked + */ + void check(SaveConsentRequest request, AuthenticatedUser user, List evidence); +} diff --git a/src/main/java/com/iemr/consent/service/consent/OtpProofRule.java b/src/main/java/com/iemr/consent/service/consent/OtpProofRule.java new file mode 100644 index 0000000..d3b1d88 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/OtpProofRule.java @@ -0,0 +1,58 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.iemr.consent.data.consent.ConsentMode; +import com.iemr.consent.data.evidence.ConsentEvidence; +import com.iemr.consent.model.consent.SaveConsentRequest; +import com.iemr.consent.model.otp.OtpReason; +import com.iemr.consent.service.otp.OtpService; +import com.iemr.consent.utils.auth.AuthenticatedUser; + +/** OTP mode: a verified CONSENT challenge created by the same worker and not used before. */ +@Component +public class OtpProofRule implements ModeProofRule { + + private final OtpService otpService; + + public OtpProofRule(OtpService otpService) { + this.otpService = otpService; + } + + @Override + public ConsentMode mode() { + return ConsentMode.OTP; + } + + @Override + public void check(SaveConsentRequest request, AuthenticatedUser user, List evidence) { + if (request.proof() == null || request.proof().otpChallengeId() == null) { + throw ConsentRules.invalid("proof.otpChallengeId", "is required for OTP mode"); + } + otpService.requireUsable(request.proof().otpChallengeId().toString(), OtpReason.CONSENT, user); + } +} diff --git a/src/main/java/com/iemr/consent/service/consent/OtpProofWriter.java b/src/main/java/com/iemr/consent/service/consent/OtpProofWriter.java new file mode 100644 index 0000000..c13491f --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/OtpProofWriter.java @@ -0,0 +1,75 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import java.time.Clock; + +import org.springframework.stereotype.Component; + +import com.iemr.consent.data.consent.Consent; +import com.iemr.consent.data.consent.ConsentMode; +import com.iemr.consent.data.evidence.ConsentEvidence; +import com.iemr.consent.data.evidence.EvidenceType; +import com.iemr.consent.model.consent.SaveConsentRequest; +import com.iemr.consent.model.otp.OtpReason; +import com.iemr.consent.repository.evidence.ConsentEvidenceRepository; +import com.iemr.consent.service.otp.OtpChallenge; +import com.iemr.consent.service.otp.OtpService; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.http.AfterCommit; + +/** + * OTP mode proof: records the verified OTP (masked phone only) and marks the challenge used once + * the save commits, so it cannot prove a second consent. + */ +@Component +public class OtpProofWriter implements ConsentProofWriter { + + static final String VERIFIED = "VERIFIED"; + + private final OtpService otpService; + + private final ConsentEvidenceRepository repository; + + private final Clock clock; + + public OtpProofWriter(OtpService otpService, ConsentEvidenceRepository repository, Clock clock) { + this.otpService = otpService; + this.repository = repository; + this.clock = clock; + } + + @Override + public void write(Consent consent, SaveConsentRequest request, AuthenticatedUser user) { + if (request.mode() != ConsentMode.OTP || !ProofRows.anyGrant(request)) { + return; + } + OtpChallenge challenge = otpService.requireUsable(request.proof().otpChallengeId().toString(), + OtpReason.CONSENT, user); + ConsentEvidence row = ProofRows.row(consent, EvidenceType.OTP, user, clock); + row.setOtpStatus(VERIFIED); + row.setMaskedPhone(challenge.maskedPhone()); + repository.save(row); + AfterCommit.run(() -> otpService.markUsed(challenge.challengeId())); + } +} diff --git a/src/main/java/com/iemr/consent/service/consent/ProofRows.java b/src/main/java/com/iemr/consent/service/consent/ProofRows.java new file mode 100644 index 0000000..2a12def --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/ProofRows.java @@ -0,0 +1,55 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import java.time.Clock; +import java.util.UUID; + +import com.iemr.consent.data.consent.Consent; +import com.iemr.consent.data.consent.Decision; +import com.iemr.consent.data.evidence.ConsentEvidence; +import com.iemr.consent.data.evidence.EvidenceType; +import com.iemr.consent.model.consent.SaveConsentRequest; +import com.iemr.consent.utils.auth.AuthenticatedUser; + +/** Builds proof-detail rows of {@code cm_consent_evidence} for a saved consent or preference change. */ +public final class ProofRows { + + private ProofRows() { + } + + public static ConsentEvidence row(Consent consent, EvidenceType type, AuthenticatedUser user, Clock clock) { + ConsentEvidence row = new ConsentEvidence(); + row.setEvidenceId(UUID.randomUUID().toString()); + row.setConsentId(consent.getConsentId()); + row.setType(type); + row.setUploadedBy(user.userId()); + row.setCreatedAt(clock.instant()); + row.setCreatedBy(user.auditName()); + return row; + } + + static boolean anyGrant(SaveConsentRequest request) { + return request.decisions().stream().anyMatch(d -> d.decision() == Decision.GRANT); + } +} diff --git a/src/main/java/com/iemr/consent/service/consent/ReadAloudProofRule.java b/src/main/java/com/iemr/consent/service/consent/ReadAloudProofRule.java new file mode 100644 index 0000000..515a725 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/ReadAloudProofRule.java @@ -0,0 +1,49 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.iemr.consent.data.consent.ConsentMode; +import com.iemr.consent.data.evidence.ConsentEvidence; +import com.iemr.consent.model.consent.SaveConsentRequest; +import com.iemr.consent.utils.auth.AuthenticatedUser; + +/** READ_ALOUD mode: the worker confirms the notice was read out (H2). */ +@Component +public class ReadAloudProofRule implements ModeProofRule { + + @Override + public ConsentMode mode() { + return ConsentMode.READ_ALOUD; + } + + @Override + public void check(SaveConsentRequest request, AuthenticatedUser user, List evidence) { + if (request.proof() == null || !Boolean.TRUE.equals(request.proof().workerConfirmed())) { + throw ConsentRules.invalid("proof.workerConfirmed", "worker must confirm the reading"); + } + } +} diff --git a/src/main/java/com/iemr/consent/service/consent/SignaturePhotoProofRule.java b/src/main/java/com/iemr/consent/service/consent/SignaturePhotoProofRule.java new file mode 100644 index 0000000..28c4cf9 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/SignaturePhotoProofRule.java @@ -0,0 +1,49 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.iemr.consent.data.consent.ConsentMode; +import com.iemr.consent.data.evidence.ConsentEvidence; +import com.iemr.consent.model.consent.SaveConsentRequest; +import com.iemr.consent.utils.auth.AuthenticatedUser; + +/** SIGNATURE_PHOTO mode: at least one patient or signed-form photo (M3). */ +@Component +public class SignaturePhotoProofRule implements ModeProofRule { + + @Override + public ConsentMode mode() { + return ConsentMode.SIGNATURE_PHOTO; + } + + @Override + public void check(SaveConsentRequest request, AuthenticatedUser user, List evidence) { + if (evidence.stream().noneMatch(e -> e.getType().isPhoto())) { + throw ConsentRules.invalid("evidenceIds", "must contain a patient or signed-form photo"); + } + } +} diff --git a/src/main/java/com/iemr/consent/service/consent/ValidatedConsent.java b/src/main/java/com/iemr/consent/service/consent/ValidatedConsent.java new file mode 100644 index 0000000..270da48 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/ValidatedConsent.java @@ -0,0 +1,37 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import java.util.List; + +import com.iemr.consent.data.evidence.ConsentEvidence; +import com.iemr.consent.service.notice.PublishedNotice; + +/** + * A save that passed every check. + * + * @param notice the published notice and language shown + * @param evidence all evidence files the save links (request and guardian) + */ +public record ValidatedConsent(PublishedNotice notice, List evidence) { +} diff --git a/src/main/java/com/iemr/consent/service/consent/VoiceClipProofRule.java b/src/main/java/com/iemr/consent/service/consent/VoiceClipProofRule.java new file mode 100644 index 0000000..a69de6b --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/VoiceClipProofRule.java @@ -0,0 +1,51 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.iemr.consent.data.consent.ConsentMode; +import com.iemr.consent.data.evidence.ConsentEvidence; +import com.iemr.consent.data.evidence.EvidenceType; +import com.iemr.consent.model.consent.SaveConsentRequest; +import com.iemr.consent.utils.auth.AuthenticatedUser; + +/** VOICE_CLIP mode: exactly one uploaded voice clip (M3). */ +@Component +public class VoiceClipProofRule implements ModeProofRule { + + @Override + public ConsentMode mode() { + return ConsentMode.VOICE_CLIP; + } + + @Override + public void check(SaveConsentRequest request, AuthenticatedUser user, List evidence) { + long clips = evidence.stream().filter(e -> e.getType() == EvidenceType.VOICE_CLIP).count(); + if (clips != 1) { + throw ConsentRules.invalid("evidenceIds", "must contain exactly one voice clip"); + } + } +} diff --git a/src/main/java/com/iemr/consent/service/consent/WitnessedProofRule.java b/src/main/java/com/iemr/consent/service/consent/WitnessedProofRule.java new file mode 100644 index 0000000..c91c8c9 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/consent/WitnessedProofRule.java @@ -0,0 +1,50 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.consent; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.iemr.consent.data.consent.ConsentMode; +import com.iemr.consent.data.evidence.ConsentEvidence; +import com.iemr.consent.model.consent.SaveConsentRequest; +import com.iemr.consent.utils.auth.AuthenticatedUser; + +/** WITNESSED mode: witness name and relationship (proposal §3.1, M3). */ +@Component +public class WitnessedProofRule implements ModeProofRule { + + @Override + public ConsentMode mode() { + return ConsentMode.WITNESSED; + } + + @Override + public void check(SaveConsentRequest request, AuthenticatedUser user, List evidence) { + if (request.proof() == null || ConsentRules.blank(request.proof().witnessName()) + || ConsentRules.blank(request.proof().witnessRelationship())) { + throw ConsentRules.invalid("proof.witnessName", "witness name and relationship are required"); + } + } +} diff --git a/src/main/java/com/iemr/consent/service/evidence/EncryptedFileEvidenceStorage.java b/src/main/java/com/iemr/consent/service/evidence/EncryptedFileEvidenceStorage.java new file mode 100644 index 0000000..6a280c3 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/evidence/EncryptedFileEvidenceStorage.java @@ -0,0 +1,99 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.evidence; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import com.iemr.consent.config.ConsentProperties; +import com.iemr.consent.utils.crypto.FieldEncryptor; +import com.iemr.consent.utils.crypto.Sha256; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; + +/** + * Evidence files encrypted with AES-256-GCM ({@link FieldEncryptor}) in + * {@code consent.evidence.storage-dir}, one file per evidence id (research R2). Common-API's KM + * file manager is not used: it logs the file body, uses {@code db_iemr} and does not encrypt. + */ +@Component +public class EncryptedFileEvidenceStorage implements EvidenceStorage { + + private static final Logger LOGGER = LoggerFactory.getLogger(EncryptedFileEvidenceStorage.class); + + private static final String SUFFIX = ".enc"; + + private final Path directory; + + private final FieldEncryptor encryptor; + + public EncryptedFileEvidenceStorage(ConsentProperties properties, FieldEncryptor encryptor) { + this.directory = Path.of(properties.evidence().storageDir()); + this.encryptor = encryptor; + try { + Files.createDirectories(directory); + } catch (IOException e) { + throw new IllegalStateException("Missing required setting: consent.evidence.storage-dir"); + } + } + + @Override + public String store(String evidenceId, byte[] content) { + String fileRef = evidenceId + SUFFIX; + try { + Files.write(directory.resolve(fileRef), encryptor.encrypt(content)); + return fileRef; + } catch (IOException e) { + LOGGER.error("Evidence file could not be written ({})", e.getClass().getSimpleName()); + throw new ConsentApiException(ErrorCode.DEPENDENCY_UNAVAILABLE); + } + } + + @Override + public byte[] read(String fileRef, String expectedSha256) { + byte[] content; + try { + content = encryptor.decrypt(Files.readAllBytes(directory.resolve(fileRef))); + } catch (IOException e) { + throw new ConsentApiException(ErrorCode.DEPENDENCY_UNAVAILABLE); + } + if (!Sha256.hex(content).equals(expectedSha256)) { + throw new IllegalStateException("Evidence file does not match its hash"); + } + return content; + } + + @Override + public void delete(String fileRef) { + try { + Files.deleteIfExists(directory.resolve(fileRef)); + } catch (IOException e) { + LOGGER.error("Evidence file could not be deleted ({})", e.getClass().getSimpleName()); + } + } +} diff --git a/src/main/java/com/iemr/consent/service/evidence/EvidenceService.java b/src/main/java/com/iemr/consent/service/evidence/EvidenceService.java new file mode 100644 index 0000000..966369c --- /dev/null +++ b/src/main/java/com/iemr/consent/service/evidence/EvidenceService.java @@ -0,0 +1,42 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.evidence; + +import java.util.List; +import java.util.UUID; + +import com.iemr.consent.data.evidence.ConsentEvidence; +import com.iemr.consent.model.evidence.EvidenceUploadResult; +import com.iemr.consent.utils.auth.AuthenticatedUser; + +/** Proof files for the consent dialog (FR-007, FR-008). */ +public interface EvidenceService { + + EvidenceUploadResult upload(EvidenceUpload upload, String requestKey, AuthenticatedUser user); + + /** + * Uploaded files a save may link: each must exist, be a file, belong to this user + * ({@code NOT_FOUND} otherwise) and not be linked yet ({@code STATE_CONFLICT}). + */ + List requireLinkable(List evidenceIds, AuthenticatedUser user); +} diff --git a/src/main/java/com/iemr/consent/service/evidence/EvidenceServiceImpl.java b/src/main/java/com/iemr/consent/service/evidence/EvidenceServiceImpl.java new file mode 100644 index 0000000..4f9882c --- /dev/null +++ b/src/main/java/com/iemr/consent/service/evidence/EvidenceServiceImpl.java @@ -0,0 +1,186 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.evidence; + +import java.math.BigDecimal; +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import com.iemr.consent.config.ConsentProperties; +import com.iemr.consent.data.evidence.ConsentEvidence; +import com.iemr.consent.data.evidence.EvidenceType; +import com.iemr.consent.model.evidence.EvidenceUploadResult; +import com.iemr.consent.repository.evidence.ConsentEvidenceRepository; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.crypto.Sha256; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; +import com.iemr.consent.utils.response.FieldErrorDetail; + +/** + * Stores proof files (research R2, R4). + * + *
    + *
  • The server's SHA-256 of the bytes must equal the browser's ({@code EVIDENCE_HASH_MISMATCH}); + * nothing is stored otherwise.
  • + *
  • Type, content type, size and voice-clip length (30–60 s, TODO-DPO) are checked.
  • + *
  • The same request key and file replay the first result; a different file is + * {@code STATE_CONFLICT}. If the row cannot be saved, the stored file is deleted.
  • + *
+ */ +@Service +public class EvidenceServiceImpl implements EvidenceService { + + private final ConsentEvidenceRepository repository; + + private final EvidenceStorage storage; + + private final Clock clock; + + private final ConsentProperties.Evidence settings; + + public EvidenceServiceImpl(ConsentEvidenceRepository repository, EvidenceStorage storage, Clock clock, + ConsentProperties properties) { + this.repository = repository; + this.storage = storage; + this.clock = clock; + this.settings = properties.evidence(); + } + + @Override + public EvidenceUploadResult upload(EvidenceUpload upload, String requestKey, AuthenticatedUser user) { + String serverSha = Sha256.hex(upload.content()); + String requestHash = Sha256.hex(upload.type() + "|" + upload.sha256() + "|" + upload.durationSec() + "|" + + serverSha); + ConsentEvidence existing = repository.findByUploadedByAndRequestKey(user.userId(), requestKey) + .orElse(null); + if (existing != null) { + return replay(existing, requestHash); + } + String contentType = check(upload); + if (!serverSha.equals(upload.sha256())) { + throw new ConsentApiException(ErrorCode.EVIDENCE_HASH_MISMATCH); + } + ConsentEvidence evidence = newEvidence(upload, contentType, serverSha, requestKey, requestHash, user); + evidence.setFileRef(storage.store(evidence.getEvidenceId(), upload.content())); + try { + repository.saveAndFlush(evidence); + } catch (DataIntegrityViolationException e) { + storage.delete(evidence.getFileRef()); + ConsentEvidence winner = repository.findByUploadedByAndRequestKey(user.userId(), requestKey) + .orElseThrow(() -> new ConsentApiException(ErrorCode.STATE_CONFLICT)); + return replay(winner, requestHash); + } catch (RuntimeException e) { + storage.delete(evidence.getFileRef()); + throw e; + } + return new EvidenceUploadResult(evidence.getEvidenceId(), serverSha); + } + + @Override + public List requireLinkable(List evidenceIds, AuthenticatedUser user) { + List result = new ArrayList<>(); + for (UUID id : evidenceIds.stream().distinct().toList()) { + ConsentEvidence evidence = repository.findById(id.toString()) + .filter(e -> e.getType().isFile() && user.userId().equals(e.getUploadedBy())) + .orElseThrow(() -> new ConsentApiException(ErrorCode.NOT_FOUND)); + if (evidence.getConsentId() != null) { + throw new ConsentApiException(ErrorCode.STATE_CONFLICT); + } + result.add(evidence); + } + return result; + } + + private static EvidenceUploadResult replay(ConsentEvidence existing, String requestHash) { + if (!requestHash.equals(existing.getRequestHash())) { + throw new ConsentApiException(ErrorCode.STATE_CONFLICT); + } + return new EvidenceUploadResult(existing.getEvidenceId(), existing.getSha256()); + } + + /** @return the normalised content type */ + private String check(EvidenceUpload upload) { + if (upload.type() == null || !upload.type().isFile()) { + throw invalid("type", "must be VOICE_CLIP, PATIENT_PHOTO or SIGNED_FORM_PHOTO"); + } + if (upload.content().length == 0) { + throw invalid("file", "must not be empty"); + } + if (upload.content().length > settings.maxBytes()) { + throw new ConsentApiException(ErrorCode.PAYLOAD_TOO_LARGE); + } + String contentType = normalise(upload.contentType()); + String family = upload.type() == EvidenceType.VOICE_CLIP ? "audio/" : "image/"; + if (!settings.allowedContentTypes().contains(contentType) || !contentType.startsWith(family)) { + throw new ConsentApiException(ErrorCode.UNSUPPORTED_MEDIA_TYPE); + } + if (upload.type() == EvidenceType.VOICE_CLIP && !validDuration(upload.durationSec())) { + throw invalid("durationSec", "must be between " + settings.voiceMinSeconds() + " and " + + settings.voiceMaxSeconds() + " seconds"); + } + return contentType; + } + + private boolean validDuration(BigDecimal seconds) { + return seconds != null && seconds.compareTo(BigDecimal.valueOf(settings.voiceMinSeconds())) >= 0 + && seconds.compareTo(BigDecimal.valueOf(settings.voiceMaxSeconds())) <= 0; + } + + private ConsentEvidence newEvidence(EvidenceUpload upload, String contentType, String sha, String requestKey, + String requestHash, AuthenticatedUser user) { + ConsentEvidence evidence = new ConsentEvidence(); + evidence.setEvidenceId(UUID.randomUUID().toString()); + evidence.setType(upload.type()); + evidence.setSha256(sha); + evidence.setContentType(contentType); + evidence.setSizeBytes((long) upload.content().length); + evidence.setDurationSec(upload.durationSec()); + evidence.setUploadedBy(user.userId()); + evidence.setRequestKey(requestKey); + evidence.setRequestHash(requestHash); + evidence.setCreatedAt(clock.instant()); + evidence.setCreatedBy(user.auditName()); + return evidence; + } + + private static String normalise(String contentType) { + if (contentType == null) { + return ""; + } + int semicolon = contentType.indexOf(';'); + String base = semicolon >= 0 ? contentType.substring(0, semicolon) : contentType; + return base.trim().toLowerCase(Locale.ROOT); + } + + private static ConsentApiException invalid(String field, String message) { + return new ConsentApiException(ErrorCode.VALIDATION_FAILED, null, null, + List.of(new FieldErrorDetail(field, message))); + } +} diff --git a/src/main/java/com/iemr/consent/service/evidence/EvidenceStorage.java b/src/main/java/com/iemr/consent/service/evidence/EvidenceStorage.java new file mode 100644 index 0000000..5d2379a --- /dev/null +++ b/src/main/java/com/iemr/consent/service/evidence/EvidenceStorage.java @@ -0,0 +1,36 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.evidence; + +/** Protected file storage for proof files; never the database (constitution IX, research R2). */ +public interface EvidenceStorage { + + /** Stores the file and returns its storage reference. */ + String store(String evidenceId, byte[] content); + + /** Reads a file back, checking it still matches its SHA-256 (hashes are verified on read). */ + byte[] read(String fileRef, String expectedSha256); + + /** Removes a file; used when the database row could not be saved. */ + void delete(String fileRef); +} diff --git a/src/main/java/com/iemr/consent/service/evidence/EvidenceUpload.java b/src/main/java/com/iemr/consent/service/evidence/EvidenceUpload.java new file mode 100644 index 0000000..6f570e8 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/evidence/EvidenceUpload.java @@ -0,0 +1,45 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.evidence; + +import java.math.BigDecimal; + +import com.iemr.consent.data.evidence.EvidenceType; + +/** + * One uploaded proof file. The bytes are never logged. + * + * @param type VOICE_CLIP, PATIENT_PHOTO or SIGNED_FORM_PHOTO + * @param sha256 fingerprint computed by the browser + * @param durationSec voice clip length + * @param contentType file content type + * @param content file bytes + */ +public record EvidenceUpload(EvidenceType type, String sha256, BigDecimal durationSec, String contentType, + byte[] content) { + + @Override + public String toString() { + return "EvidenceUpload[type=" + type + "]"; + } +} diff --git a/src/main/java/com/iemr/consent/service/grant/AccessGrantService.java b/src/main/java/com/iemr/consent/service/grant/AccessGrantService.java new file mode 100644 index 0000000..32f02e0 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/grant/AccessGrantService.java @@ -0,0 +1,59 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.grant; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +import com.iemr.consent.data.grant.AccessGrant; +import com.iemr.consent.data.grant.GrantStatus; +import com.iemr.consent.model.grant.AccessGrantView; +import com.iemr.consent.model.grant.AccessRequest; +import com.iemr.consent.model.grant.ApproveRequest; +import com.iemr.consent.utils.auth.AuthenticatedUser; + +/** A doctor's time-limited access to a patient: request, list (API 12), approve (13), revoke (14). */ +public interface AccessGrantService { + + /** Creates a PENDING grant for the logged-in user; a repeat with the same key returns the original. */ + AccessGrantView request(AccessRequest request, String requestKey, String requestHash, AuthenticatedUser user, + String sourceIp); + + /** @param statuses effective statuses to keep; empty = all */ + List list(Long benRegId, Set statuses); + + /** @param requestHash hash of the grant id and the body, so a key cannot replay another grant */ + AccessGrantView approve(String grantId, ApproveRequest request, String requestKey, String requestHash, + AuthenticatedUser user, String sourceIp); + + AccessGrantView revoke(String grantId, String requestKey, String requestHash, AuthenticatedUser user, + String sourceIp); + + /** + * Revokes, inside the caller's transaction, each pending or active grant that covers a withdrawn purpose + * (FR-012); grants already past their end date are left as they are. + */ + void revokeForWithdrawal(List grants, Collection withdrawnCodes, AuthenticatedUser user, + String sourceIp); +} diff --git a/src/main/java/com/iemr/consent/service/grant/AccessGrantServiceImpl.java b/src/main/java/com/iemr/consent/service/grant/AccessGrantServiceImpl.java new file mode 100644 index 0000000..0306014 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/grant/AccessGrantServiceImpl.java @@ -0,0 +1,261 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.grant; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.iemr.consent.config.ConsentProperties; +import com.iemr.consent.data.consent.ConsentPurpose; +import com.iemr.consent.data.grant.AccessGrant; +import com.iemr.consent.data.grant.GrantStatus; +import com.iemr.consent.data.grant.GrantType; +import com.iemr.consent.data.grant.RevokeReason; +import com.iemr.consent.data.notice.Purpose; +import com.iemr.consent.model.grant.AccessGrantView; +import com.iemr.consent.model.grant.AccessRequest; +import com.iemr.consent.model.grant.ApproveRequest; +import com.iemr.consent.model.grant.GranteeView; +import com.iemr.consent.model.otp.OtpReason; +import com.iemr.consent.repository.consent.ConsentPurposeRepository; +import com.iemr.consent.repository.grant.AccessGrantRepository; +import com.iemr.consent.repository.notice.PurposeRepository; +import com.iemr.consent.service.audit.AuditLogService; +import com.iemr.consent.service.grant.GrantReplayStore.Replay; +import com.iemr.consent.service.otp.OtpChallenge; +import com.iemr.consent.service.otp.OtpService; +import com.iemr.consent.service.principal.EffectiveStatus; +import com.iemr.consent.service.principal.PrincipalEventFactory; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; +import com.iemr.consent.utils.http.AfterCommit; +import com.iemr.consent.utils.response.FieldErrorDetail; + +/** + * Doctor access grants (FR-013 – FR-017; research R2, R6, R7, R8). A request is idempotent through the + * table's request-key unique index; approve and revoke through {@link GrantReplayStore}. Approval needs a + * verified ACCESS_GRANT OTP challenge whose {@code refId} is the grant, from the guardian's phone while a + * guardian's consent is current (T9). Every change is audited in the same transaction. + */ +@Service +public class AccessGrantServiceImpl implements AccessGrantService { + + private final AccessGrantRepository repository; + + private final AccessGrantWriter writer; + + private final GrantReplayStore replays; + + private final PurposeRepository purposes; + + private final ConsentPurposeRepository decisions; + + private final PrincipalEventFactory events; + + private final OtpService otpService; + + private final EffectiveStatus effectiveStatus; + + private final AuditLogService auditLogService; + + private final Clock clock; + + private final int maxDays; + + public AccessGrantServiceImpl(AccessGrantRepository repository, AccessGrantWriter writer, + GrantReplayStore replays, PurposeRepository purposes, ConsentPurposeRepository decisions, + PrincipalEventFactory events, OtpService otpService, EffectiveStatus effectiveStatus, + AuditLogService auditLogService, Clock clock, ConsentProperties properties) { + this.repository = repository; + this.writer = writer; + this.replays = replays; + this.purposes = purposes; + this.decisions = decisions; + this.events = events; + this.otpService = otpService; + this.effectiveStatus = effectiveStatus; + this.auditLogService = auditLogService; + this.clock = clock; + this.maxDays = properties.accessGrant().maxDays(); + } + + @Override + public AccessGrantView request(AccessRequest request, String requestKey, String requestHash, + AuthenticatedUser user, String sourceIp) { + AccessGrant existing = repository.findByRequestedByAndRequestKey(user.userId(), requestKey) + .orElse(null); + if (existing != null) { + return replay(existing, requestHash); + } + if (request.days() > maxDays) { + throw invalid("days", "must be at most " + maxDays); + } + Set known = purposes.findByPurposeCodeInAndActiveTrueAndDeletedFalse(request.purposeCodes()) + .stream().map(Purpose::getPurposeCode).collect(Collectors.toSet()); + if (!known.containsAll(request.purposeCodes())) { + throw invalid("purposeCodes", "must be active purpose codes"); + } + try { + return view(writer.create(request, requestKey, requestHash, user, sourceIp)); + } catch (DataIntegrityViolationException e) { + AccessGrant winner = repository.findByRequestedByAndRequestKey(user.userId(), requestKey) + .orElseThrow(() -> new ConsentApiException(ErrorCode.STATE_CONFLICT)); + return replay(winner, requestHash); + } + } + + @Override + @Transactional(readOnly = true) + public List list(Long benRegId, Set statuses) { + return repository.findByBeneficiaryRegIdAndDeletedFalseOrderByCreatedDateDesc(benRegId).stream() + .map(this::view).filter(v -> statuses.isEmpty() || statuses.contains(v.status())) + .toList(); + } + + @Override + @Transactional + public AccessGrantView approve(String grantId, ApproveRequest request, String requestKey, String requestHash, + AuthenticatedUser user, String sourceIp) { + return changeOnce(grantId, requestKey, requestHash, user, grant -> { + boolean pending = effectiveStatus.of(grant) == GrantStatus.PENDING; + if (grant.getGrantType() != GrantType.CONSENTED || !pending) { + throw new ConsentApiException(ErrorCode.STATE_CONFLICT, + "This access cannot be approved", null, null); + } + String phoneHash = guardianPhoneHash(grant.getBeneficiaryRegId()); + OtpChallenge challenge = otpService.requireUsableFor(request.otpChallengeId().toString(), + Set.of(OtpReason.ACCESS_GRANT), grantId, user, phoneHash); + Instant now = clock.instant(); + grant.setStatus(GrantStatus.ACTIVE); + grant.setApprovedAt(now); + grant.setApprovedBy(user.userId()); + grant.setValidUntil(now.plus(Duration.ofDays(grant.getDays()))); + grant.setApprovalMaskedPhone(challenge.maskedPhone()); + save(grant, AuditLogService.ACCESS_APPROVED, user, sourceIp); + AfterCommit.run(() -> otpService.markUsed(challenge.challengeId())); + }); + } + + @Override + @Transactional + public AccessGrantView revoke(String grantId, String requestKey, String requestHash, AuthenticatedUser user, + String sourceIp) { + return changeOnce(grantId, requestKey, requestHash, user, grant -> { + GrantStatus status = effectiveStatus.of(grant); + if (status != GrantStatus.PENDING && status != GrantStatus.ACTIVE) { + throw new ConsentApiException(ErrorCode.STATE_CONFLICT, "This access has already ended", + null, + null); + } + revoke(grant, RevokeReason.PATIENT_REQUEST, user, sourceIp); + }); + } + + @Override + @Transactional(propagation = Propagation.MANDATORY) + public void revokeForWithdrawal(List grants, Collection withdrawnCodes, + AuthenticatedUser user, String sourceIp) { + for (AccessGrant grant : grants) { + Set covered = new HashSet<>(grant.purposeList()); + covered.retainAll(withdrawnCodes); + if (!covered.isEmpty() && effectiveStatus.of(grant) != GrantStatus.EXPIRED) { + revoke(grant, RevokeReason.WITHDRAWAL, user, sourceIp); + } + } + } + + /** Applies a change to a locked grant once per request key; a repeat returns the grant as it is now. */ + private AccessGrantView changeOnce(String grantId, String requestKey, String requestHash, + AuthenticatedUser user, Consumer change) { + Replay done = replays.find(user.userId(), requestKey).orElse(null); + if (done != null) { + if (!done.requestHash().equals(requestHash)) { + throw new ConsentApiException(ErrorCode.STATE_CONFLICT); + } + return view(repository.findById(done.grantId()) + .orElseThrow(() -> new ConsentApiException(ErrorCode.NOT_FOUND))); + } + AccessGrant grant = repository.findLockedByGrantId(grantId) + .orElseThrow(() -> new ConsentApiException(ErrorCode.NOT_FOUND)); + change.accept(grant); + AfterCommit.run(() -> replays.save(user.userId(), requestKey, new Replay(requestHash, grantId))); + return view(grant); + } + + private void revoke(AccessGrant grant, RevokeReason reason, AuthenticatedUser user, String sourceIp) { + grant.setStatus(GrantStatus.REVOKED); + grant.setRevokeReason(reason); + grant.setReviewedBy(user.userId()); + grant.setRevokedAt(clock.instant()); + save(grant, AuditLogService.ACCESS_REVOKED, user, sourceIp); + } + + private void save(AccessGrant grant, String action, AuthenticatedUser user, String sourceIp) { + grant.setModifiedBy(user.auditName()); + repository.save(grant); + auditLogService.append(action, null, grant.getBeneficiaryRegId(), "grant:" + grant.getGrantId(), + grant.getStatus().name(), user.userId(), sourceIp); + } + + /** The guardian's phone hash while the patient's current consent is a guardian's (T9); else null. */ + private String guardianPhoneHash(Long benRegId) { + List current = decisions.findByBeneficiaryRegIdAndCurrentTrue(benRegId); + if (current.isEmpty()) { + return null; + } + return events.activeGuardian(events.newestSource(current)).map(events::guardianPhoneHash).orElse(null); + } + + private AccessGrantView replay(AccessGrant grant, String requestHash) { + if (!grant.getRequestHash().equals(requestHash)) { + throw new ConsentApiException(ErrorCode.STATE_CONFLICT); + } + return view(grant); + } + + private AccessGrantView view(AccessGrant g) { + GranteeView grantee = new GranteeView(g.getGranteeUserId(), g.getGranteeName(), + g.getGranteeFacilityId()); + return new AccessGrantView(g.getGrantId(), g.getBeneficiaryRegId(), grantee, g.purposeList(), + g.getGrantType(), effectiveStatus.of(g), g.getDays(), g.getReason(), g.getValidUntil(), + g.getApprovedAt(), g.getRevokedAt(), g.getRevokeReason()); + } + + private static ConsentApiException invalid(String field, String message) { + return new ConsentApiException(ErrorCode.VALIDATION_FAILED, null, null, + List.of(new FieldErrorDetail(field, message))); + } +} diff --git a/src/main/java/com/iemr/consent/service/grant/AccessGrantWriter.java b/src/main/java/com/iemr/consent/service/grant/AccessGrantWriter.java new file mode 100644 index 0000000..57d37cf --- /dev/null +++ b/src/main/java/com/iemr/consent/service/grant/AccessGrantWriter.java @@ -0,0 +1,74 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.grant; + +import java.util.UUID; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.iemr.consent.data.grant.AccessGrant; +import com.iemr.consent.data.grant.GrantStatus; +import com.iemr.consent.data.grant.GrantType; +import com.iemr.consent.model.grant.AccessRequest; +import com.iemr.consent.repository.grant.AccessGrantRepository; +import com.iemr.consent.service.audit.AuditLogService; +import com.iemr.consent.utils.auth.AuthenticatedUser; + +/** Saves a new PENDING access grant and its audit entry in one transaction (FR-017, FR-021). */ +@Component +public class AccessGrantWriter { + + private final AccessGrantRepository repository; + + private final AuditLogService auditLogService; + + public AccessGrantWriter(AccessGrantRepository repository, AuditLogService auditLogService) { + this.repository = repository; + this.auditLogService = auditLogService; + } + + @Transactional + public AccessGrant create(AccessRequest request, String requestKey, String requestHash, AuthenticatedUser user, + String sourceIp) { + AccessGrant grant = new AccessGrant(); + grant.setGrantId(UUID.randomUUID().toString()); + grant.setBeneficiaryRegId(request.beneficiaryRegId()); + grant.setGrantType(GrantType.CONSENTED); + grant.setGranteeUserId(user.userId()); + grant.setGranteeName(user.auditName()); + grant.setGranteeFacilityId(request.facilityId()); + grant.setPurposeList(request.purposeCodes()); + grant.setReason(request.reason()); + grant.setDays(request.days()); + grant.setStatus(GrantStatus.PENDING); + grant.setRequestedBy(user.userId()); + grant.setRequestKey(requestKey); + grant.setRequestHash(requestHash); + grant.setCreatedBy(user.auditName()); + AccessGrant saved = repository.saveAndFlush(grant); + auditLogService.append(AuditLogService.ACCESS_REQUESTED, null, saved.getBeneficiaryRegId(), + "grant:" + saved.getGrantId(), GrantStatus.PENDING.name(), user.userId(), sourceIp); + return saved; + } +} diff --git a/src/main/java/com/iemr/consent/service/grant/GrantReplayStore.java b/src/main/java/com/iemr/consent/service/grant/GrantReplayStore.java new file mode 100644 index 0000000..674e88f --- /dev/null +++ b/src/main/java/com/iemr/consent/service/grant/GrantReplayStore.java @@ -0,0 +1,84 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.grant; + +import java.time.Duration; +import java.util.Optional; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iemr.consent.config.ConsentProperties; + +/** + * Redis record of approve and revoke request keys (research R2), kept for + * {@code consent.access-grant.replay-hours}. Written after commit; if it is lost, a retry meets the grant's + * new state and gets {@code STATE_CONFLICT}, so the grant still changes only once. + */ +@Component +public class GrantReplayStore { + + static final String PREFIX = "consent:idem:grant:"; + + private final StringRedisTemplate redis; + + private final ObjectMapper objectMapper; + + private final Duration ttl; + + public GrantReplayStore(StringRedisTemplate redis, ObjectMapper objectMapper, ConsentProperties properties) { + this.redis = redis; + this.objectMapper = objectMapper; + this.ttl = Duration.ofHours(properties.accessGrant().replayHours()); + } + + /** An approve or revoke already done with a request key. */ + public record Replay(String requestHash, String grantId) { + } + + public Optional find(Long userId, String requestKey) { + String json = redis.opsForValue().get(key(userId, requestKey)); + if (json == null) { + return Optional.empty(); + } + try { + return Optional.of(objectMapper.readValue(json, Replay.class)); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Grant replay record could not be read", e); + } + } + + public void save(Long userId, String requestKey, Replay replay) { + try { + redis.opsForValue().set(key(userId, requestKey), objectMapper.writeValueAsString(replay), ttl); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Grant replay record could not be written", e); + } + } + + private static String key(Long userId, String requestKey) { + return PREFIX + userId + ":" + requestKey; + } +} diff --git a/src/main/java/com/iemr/consent/service/health/HealthService.java b/src/main/java/com/iemr/consent/service/health/HealthService.java new file mode 100644 index 0000000..395adec --- /dev/null +++ b/src/main/java/com/iemr/consent/service/health/HealthService.java @@ -0,0 +1,30 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.health; + +/** Checks the service's dependencies for {@code GET /health} (FR-017). */ +public interface HealthService { + + /** Checks the database and the cache; answers within the configured timeout. */ + HealthStatus check(); +} diff --git a/src/main/java/com/iemr/consent/service/health/HealthServiceImpl.java b/src/main/java/com/iemr/consent/service/health/HealthServiceImpl.java new file mode 100644 index 0000000..4fafa5a --- /dev/null +++ b/src/main/java/com/iemr/consent/service/health/HealthServiceImpl.java @@ -0,0 +1,159 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.health; + +import java.sql.Connection; +import java.sql.Statement; +import java.time.Clock; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Service; + +import com.iemr.consent.config.ConsentProperties; +import com.iemr.consent.service.health.HealthStatus.ComponentStatus; + +import jakarta.annotation.PreDestroy; + +/** + * Runs {@code SELECT 1} on the {@code db_identity} datasource and a Redis {@code PING} in + * parallel, each bounded by {@code consent.health.check-timeout-ms}, so {@code /health} answers + * within 2 s even when a dependency hangs (SC-006, research R7). + * + *

Mirrors Common-API's {@code service/health/HealthService}, without its extra diagnostics. + * Checks run on a small private pool with no queue: a check still running after the timeout is + * cancelled, and if the pool is exhausted (several hung checks) the components are reported + * {@code DOWN} instead of waiting. Any exception or timeout is {@code DOWN}; no exception text is + * kept.

+ */ +@Service +public class HealthServiceImpl implements HealthService { + + private static final int QUERY_TIMEOUT_SECONDS = 1; + + private final DataSource dataSource; + + private final RedisConnectionFactory redisConnectionFactory; + + private final Clock clock; + + private final long timeoutMs; + + private final ExecutorService executor; + + @Autowired + public HealthServiceImpl(DataSource dataSource, RedisConnectionFactory redisConnectionFactory, Clock clock, + ConsentProperties properties) { + this(dataSource, redisConnectionFactory, clock, properties.health().checkTimeoutMs(), newExecutor()); + } + + private static ExecutorService newExecutor() { + return new ThreadPoolExecutor(4, 8, 30, TimeUnit.SECONDS, new SynchronousQueue<>(), runnable -> { + Thread thread = new Thread(runnable, "health-check"); + thread.setDaemon(true); + return thread; + }, new ThreadPoolExecutor.AbortPolicy()); + } + + HealthServiceImpl(DataSource dataSource, RedisConnectionFactory redisConnectionFactory, Clock clock, + long timeoutMs, ExecutorService executor) { + this.dataSource = dataSource; + this.redisConnectionFactory = redisConnectionFactory; + this.clock = clock; + this.timeoutMs = timeoutMs; + this.executor = executor; + } + + @Override + public HealthStatus check() { + List> checks = List.of(this::databaseUp, this::cacheUp); + List results = run(checks); + Map components = new LinkedHashMap<>(); + components.put("database", component(results.get(0))); + components.put("cache", component(results.get(1))); + boolean allUp = results.stream().allMatch(Boolean.TRUE::equals); + return new HealthStatus(allUp ? HealthStatus.UP : HealthStatus.DOWN, clock.instant().toString(), + components); + } + + @PreDestroy + public void shutdown() { + executor.shutdownNow(); + } + + private List run(List> checks) { + try { + return executor.invokeAll(checks, timeoutMs, TimeUnit.MILLISECONDS).stream() + .map(HealthServiceImpl::outcome).toList(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return checks.stream().map(check -> Boolean.FALSE).toList(); + } catch (RejectedExecutionException e) { + return checks.stream().map(check -> Boolean.FALSE).toList(); + } + } + + private static Boolean outcome(Future future) { + if (future.isCancelled()) { + return Boolean.FALSE; + } + try { + return future.get(); + } catch (ExecutionException | InterruptedException e) { + return Boolean.FALSE; + } + } + + private static ComponentStatus component(Boolean up) { + return new ComponentStatus(Boolean.TRUE.equals(up) ? HealthStatus.UP : HealthStatus.DOWN); + } + + private Boolean databaseUp() throws Exception { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS); + statement.execute("SELECT 1"); + return Boolean.TRUE; + } + } + + private Boolean cacheUp() { + try (RedisConnection connection = redisConnectionFactory.getConnection()) { + return "PONG".equalsIgnoreCase(connection.ping()); + } + } +} diff --git a/src/main/java/com/iemr/consent/service/health/HealthStatus.java b/src/main/java/com/iemr/consent/service/health/HealthStatus.java new file mode 100644 index 0000000..8c2e102 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/health/HealthStatus.java @@ -0,0 +1,58 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.health; + +import java.util.Map; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * Result of a health check (data-model.md): overall {@code UP} only when every component is + * {@code UP}. Holds no host, port, URL or exception text. + * + * @param status {@code UP} or {@code DOWN} + * @param checkedAt ISO-8601 UTC time of the check + * @param components {@code database} and {@code cache}, in that order + */ +public record HealthStatus(String status, String checkedAt, Map components) { + + public static final String UP = "UP"; + + public static final String DOWN = "DOWN"; + + /** @param status {@code UP} or {@code DOWN} */ + public record ComponentStatus(String status) { + } + + @JsonIgnore + public boolean isUp() { + return UP.equals(status); + } + + /** Names of the components that are down, comma-separated, e.g. {@code database, cache}. */ + public String downComponents() { + return components.entrySet().stream().filter(entry -> !UP.equals(entry.getValue().status())) + .map(Map.Entry::getKey).collect(Collectors.joining(", ")); + } +} diff --git a/src/main/java/com/iemr/consent/service/notice/NoticeService.java b/src/main/java/com/iemr/consent/service/notice/NoticeService.java new file mode 100644 index 0000000..8d89de2 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/notice/NoticeService.java @@ -0,0 +1,43 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.notice; + +import java.util.List; +import java.util.Optional; + +import com.iemr.consent.data.notice.NoticeContext; +import com.iemr.consent.model.notice.ActiveNotice; +import com.iemr.consent.model.notice.NoticeLanguage; + +/** Published notices (FR-001 – FR-003). */ +public interface NoticeService { + + /** Languages of the active notice for a context; empty when none is published. */ + List languages(NoticeContext context); + + /** The active notice in one language; {@code NOT_FOUND} when there is none. */ + ActiveNotice activeNotice(NoticeContext context, String languageCode); + + /** A notice version for a save, only if it is still published and has this language. */ + Optional findPublished(Long noticeVersionId, String languageCode); +} diff --git a/src/main/java/com/iemr/consent/service/notice/NoticeServiceImpl.java b/src/main/java/com/iemr/consent/service/notice/NoticeServiceImpl.java new file mode 100644 index 0000000..21d32e5 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/notice/NoticeServiceImpl.java @@ -0,0 +1,144 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.notice; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.iemr.consent.config.ConsentProperties; +import com.iemr.consent.data.notice.Notice; +import com.iemr.consent.data.notice.NoticeContext; +import com.iemr.consent.data.notice.NoticePurpose; +import com.iemr.consent.data.notice.NoticeStatus; +import com.iemr.consent.data.notice.NoticeTranslation; +import com.iemr.consent.data.notice.Purpose; +import com.iemr.consent.model.notice.ActiveNotice; +import com.iemr.consent.model.notice.NoticeLanguage; +import com.iemr.consent.model.notice.NoticePurposeView; +import com.iemr.consent.repository.notice.NoticePurposeRepository; +import com.iemr.consent.repository.notice.NoticeRepository; +import com.iemr.consent.repository.notice.NoticeTranslationRepository; +import com.iemr.consent.repository.notice.PurposeRepository; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; + +/** + * Serves the active (highest PUBLISHED) notice of a context, in the language the patient chose, + * with the purposes it covers: mandatory first, then display order (proposal §3.1 rule 4, + * research R5). Draft and retired versions are never served (FR-003). + */ +@Service +@Transactional(readOnly = true) +public class NoticeServiceImpl implements NoticeService { + + private final NoticeRepository noticeRepository; + + private final NoticeTranslationRepository translationRepository; + + private final NoticePurposeRepository noticePurposeRepository; + + private final PurposeRepository purposeRepository; + + private final String audioBaseUrl; + + public NoticeServiceImpl(NoticeRepository noticeRepository, NoticeTranslationRepository translationRepository, + NoticePurposeRepository noticePurposeRepository, PurposeRepository purposeRepository, + ConsentProperties properties) { + this.noticeRepository = noticeRepository; + this.translationRepository = translationRepository; + this.noticePurposeRepository = noticePurposeRepository; + this.purposeRepository = purposeRepository; + this.audioBaseUrl = properties.notice().audioBaseUrl(); + } + + @Override + public List languages(NoticeContext context) { + return active(context).map(notice -> translationRepository + .findByNoticeVersionIdAndDeletedFalseOrderByLanguageCodeAsc(notice.getNoticeVersionId()) + .stream().map(NoticeServiceImpl::language).toList()).orElse(List.of()); + } + + @Override + public ActiveNotice activeNotice(NoticeContext context, String languageCode) { + Notice notice = active(context).orElseThrow(() -> new ConsentApiException(ErrorCode.NOT_FOUND)); + NoticeTranslation t = translation(notice.getNoticeVersionId(), languageCode) + .orElseThrow(() -> new ConsentApiException(ErrorCode.NOT_FOUND)); + List purposes = purposesOf(notice).stream() + .map(p -> new NoticePurposeView(p.getPurposeCode(), p.getName(), p.getDescription(), + p.isMandatory(), p.isWithdrawable(), p.getValidityDays())) + .toList(); + return new ActiveNotice(notice.getNoticeVersionId(), t.getLanguageCode(), notice.getContext().name(), + t.getBody(), t.getTextHash(), audioUrl(t.getAudioRef()), t.getDataItems(), + t.getRetention(), t.getRecipients(), t.getWithdrawalInfo(), t.getGrievanceInfo(), + purposes); + } + + @Override + public Optional findPublished(Long noticeVersionId, String languageCode) { + return noticeRepository.findById(noticeVersionId) + .filter(n -> n.getStatus() == NoticeStatus.PUBLISHED && !n.isDeleted()) + .flatMap(n -> translation(noticeVersionId, languageCode) + .map(t -> new PublishedNotice(n, t, purposesOf(n)))); + } + + private Optional translation(Long noticeVersionId, String languageCode) { + return translationRepository.findByNoticeVersionIdAndLanguageCodeAndDeletedFalse(noticeVersionId, + languageCode); + } + + private static NoticeLanguage language(NoticeTranslation t) { + return new NoticeLanguage(t.getLanguageCode(), t.getLanguageName(), t.getAudioRef() != null); + } + + private Optional active(NoticeContext context) { + return noticeRepository.findFirstByContextAndStatusAndDeletedFalseOrderByVersionNoDesc(context, + NoticeStatus.PUBLISHED); + } + + /** The notice's purposes (latest active version of each), mandatory first, then display order. */ + private List purposesOf(Notice notice) { + List links = noticePurposeRepository + .findByNoticeVersionIdAndDeletedFalseOrderByDisplayRankAsc(notice.getNoticeVersionId()); + Map rank = links.stream().collect( + Collectors.toMap(NoticePurpose::getPurposeCode, NoticePurpose::getDisplayRank)); + Map latest = purposeRepository + .findByPurposeCodeInAndActiveTrueAndDeletedFalse(rank.keySet()).stream() + .collect(Collectors.toMap(Purpose::getPurposeCode, Function.identity(), + (a, b) -> a.getVersion() >= b.getVersion() ? a : b)); + return latest.values().stream() + .sorted(Comparator.comparing((Purpose p) -> !p.isMandatory()) + .thenComparing(p -> rank.get(p.getPurposeCode()))) + .toList(); + } + + private String audioUrl(String audioRef) { + return audioRef == null || audioBaseUrl.isEmpty() ? null : audioBaseUrl + audioRef; + } +} diff --git a/src/main/java/com/iemr/consent/service/notice/PublishedNotice.java b/src/main/java/com/iemr/consent/service/notice/PublishedNotice.java new file mode 100644 index 0000000..cd33580 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/notice/PublishedNotice.java @@ -0,0 +1,39 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.notice; + +import java.util.List; + +import com.iemr.consent.data.notice.Notice; +import com.iemr.consent.data.notice.NoticeTranslation; +import com.iemr.consent.data.notice.Purpose; + +/** + * A published notice version in one language with its purposes, used to validate a save. + * + * @param notice the notice version + * @param translation the language shown + * @param purposes purposes the notice covers, mandatory first then display order + */ +public record PublishedNotice(Notice notice, NoticeTranslation translation, List purposes) { +} diff --git a/src/main/java/com/iemr/consent/service/otp/CommonApiOtpClient.java b/src/main/java/com/iemr/consent/service/otp/CommonApiOtpClient.java new file mode 100644 index 0000000..e7c1006 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/otp/CommonApiOtpClient.java @@ -0,0 +1,98 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.otp; + +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; +import com.iemr.consent.utils.http.ForwardedAuth; + +/** + * Common-API's consent OTP ({@code /beneficiaryConsent/sendConsent}, {@code /validateConsent}), + * which has the consent SMS template and the per-number rate limit (research R1, constitution IX). + * + *

Common-API answers with its legacy body ({@code statusCode} inside HTTP 200). A wrong code + * is HTTP 200 with a non-200 {@code statusCode}; an HTTP or network error is + * {@code DEPENDENCY_UNAVAILABLE}. Phones, codes and bodies are never logged.

+ */ +@Component +public class CommonApiOtpClient { + + static final String SEND_PATH = "/beneficiaryConsent/sendConsent"; + + static final String VALIDATE_PATH = "/beneficiaryConsent/validateConsent"; + + private static final Logger LOGGER = LoggerFactory.getLogger(CommonApiOtpClient.class); + + private static final int OK = 200; + + private static final int TOO_MANY = 429; + + private final RestClient restClient; + + public CommonApiOtpClient(RestClient commonApiRestClient) { + this.restClient = commonApiRestClient; + } + + /** Common-API's legacy response; only {@code statusCode} is used. */ + @JsonIgnoreProperties(ignoreUnknown = true) + record LegacyResponse(Integer statusCode) { + } + + /** Sends the OTP SMS. */ + public void send(String phone, ForwardedAuth auth) { + int status = call(SEND_PATH, Map.of("mobNo", phone), auth); + if (status == TOO_MANY) { + throw new ConsentApiException(ErrorCode.RATE_LIMITED); + } + if (status != OK) { + LOGGER.warn("Common-API OTP send failed (statusCode {})", status); + throw new ConsentApiException(ErrorCode.DEPENDENCY_UNAVAILABLE); + } + } + + /** @return {@code true} when Common-API accepts the code */ + public boolean validate(String phone, String otp, ForwardedAuth auth) { + return call(VALIDATE_PATH, Map.of("mobNo", phone, "otp", Integer.parseInt(otp)), auth) == OK; + } + + private int call(String path, Map body, ForwardedAuth auth) { + try { + LegacyResponse response = restClient.post().uri(path).contentType(MediaType.APPLICATION_JSON) + .headers(auth::applyTo).body(body).retrieve().body(LegacyResponse.class); + return response == null || response.statusCode() == null ? -1 : response.statusCode(); + } catch (RestClientException e) { + LOGGER.warn("Common-API OTP call failed ({})", e.getClass().getSimpleName()); + throw new ConsentApiException(ErrorCode.DEPENDENCY_UNAVAILABLE); + } + } +} diff --git a/src/main/java/com/iemr/consent/service/otp/OtpChallenge.java b/src/main/java/com/iemr/consent/service/otp/OtpChallenge.java new file mode 100644 index 0000000..2c37ab1 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/otp/OtpChallenge.java @@ -0,0 +1,68 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.otp; + +import java.time.Instant; + +import com.iemr.consent.model.otp.OtpReason; + +/** + * An OTP challenge held in Redis (data-model.md). The phone is kept only encrypted, as a SHA-256 + * hash and masked; the OTP itself is never stored. + * + * @param challengeId id + * @param reason CONSENT, GUARDIAN or ACCESS_GRANT + * @param phoneEnc {@code FieldEncryptor} output, needed to verify with Common-API + * @param phoneHash SHA-256 of the phone, compared with a guardian's phone at save + * @param maskedPhone e.g. {@code ******3210} + * @param refId optional reference + * @param createdBy user who sent it + * @param expiresAt code expiry + * @param verifiedAt set once by a correct verify + * @param used set by the save that uses it + * @param attempts wrong codes so far + */ +public record OtpChallenge(String challengeId, OtpReason reason, String phoneEnc, String phoneHash, + String maskedPhone, String refId, Long createdBy, Instant expiresAt, Instant verifiedAt, boolean used, + int attempts) { + + public OtpChallenge verified(Instant at) { + return new OtpChallenge(challengeId, reason, phoneEnc, phoneHash, maskedPhone, refId, createdBy, + expiresAt, at, used, attempts); + } + + public OtpChallenge markedUsed() { + return new OtpChallenge(challengeId, reason, phoneEnc, phoneHash, maskedPhone, refId, createdBy, + expiresAt, verifiedAt, true, attempts); + } + + public OtpChallenge failedAttempt() { + return new OtpChallenge(challengeId, reason, phoneEnc, phoneHash, maskedPhone, refId, createdBy, + expiresAt, verifiedAt, used, attempts + 1); + } + + @Override + public String toString() { + return "OtpChallenge[challengeId=" + challengeId + ", reason=" + reason + "]"; + } +} diff --git a/src/main/java/com/iemr/consent/service/otp/OtpChallengeStore.java b/src/main/java/com/iemr/consent/service/otp/OtpChallengeStore.java new file mode 100644 index 0000000..ba3c5d3 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/otp/OtpChallengeStore.java @@ -0,0 +1,111 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.otp; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iemr.consent.config.ConsentProperties; +import com.iemr.consent.model.otp.OtpChallengeView; + +/** + * Redis storage of OTP challenges and of OTP-send replays (research R1, R4). Records live for + * {@code consent.otp.retention-minutes}, longer than the code's expiry, so a save shortly after a + * late verify still finds its challenge. + */ +@Component +public class OtpChallengeStore { + + static final String CHALLENGE_PREFIX = "consent:otp:challenge:"; + + static final String REPLAY_PREFIX = "consent:idem:otp:"; + + private final StringRedisTemplate redis; + + private final ObjectMapper objectMapper; + + private final Duration retention; + + public OtpChallengeStore(StringRedisTemplate redis, ObjectMapper objectMapper, ConsentProperties properties) { + this.redis = redis; + this.objectMapper = objectMapper; + this.retention = Duration.ofMinutes(properties.otp().retentionMinutes()); + } + + /** A stored OTP send, replayed for a repeat with the same request key. */ + public record SendReplay(String requestHash, OtpChallengeView response) { + } + + public void create(OtpChallenge challenge) { + redis.opsForValue().set(CHALLENGE_PREFIX + challenge.challengeId(), write(challenge), retention); + } + + public Optional find(String challengeId) { + return read(redis.opsForValue().get(CHALLENGE_PREFIX + challengeId), OtpChallenge.class); + } + + /** Updates a challenge, keeping its remaining lifetime. */ + public void update(OtpChallenge challenge) { + String key = CHALLENGE_PREFIX + challenge.challengeId(); + Long seconds = redis.getExpire(key, TimeUnit.SECONDS); + Duration ttl = seconds != null && seconds > 0 ? Duration.ofSeconds(seconds) : retention; + redis.opsForValue().set(key, write(challenge), ttl); + } + + public Optional findSendReplay(Long userId, String requestKey) { + return read(redis.opsForValue().get(replayKey(userId, requestKey)), SendReplay.class); + } + + public void saveSendReplay(Long userId, String requestKey, SendReplay replay) { + redis.opsForValue().set(replayKey(userId, requestKey), write(replay), retention); + } + + private static String replayKey(Long userId, String requestKey) { + return REPLAY_PREFIX + userId + ":" + requestKey; + } + + private String write(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new IllegalStateException("OTP record could not be written", e); + } + } + + private Optional read(String json, Class type) { + if (json == null) { + return Optional.empty(); + } + try { + return Optional.of(objectMapper.readValue(json, type)); + } catch (JsonProcessingException e) { + throw new IllegalStateException("OTP record could not be read", e); + } + } +} diff --git a/src/main/java/com/iemr/consent/service/otp/OtpService.java b/src/main/java/com/iemr/consent/service/otp/OtpService.java new file mode 100644 index 0000000..78972d2 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/otp/OtpService.java @@ -0,0 +1,53 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.otp; + +import java.util.Set; + +import com.iemr.consent.model.otp.OtpChallengeView; +import com.iemr.consent.model.otp.OtpReason; +import com.iemr.consent.model.otp.OtpSendRequest; +import com.iemr.consent.model.otp.OtpVerifyResult; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.http.ForwardedAuth; + +/** OTP challenges for consent and guardian proof (FR-004 – FR-006). */ +public interface OtpService { + + OtpChallengeView send(OtpSendRequest request, String requestKey, String requestHash, AuthenticatedUser user, + ForwardedAuth auth); + + OtpVerifyResult verify(String challengeId, String otp, AuthenticatedUser user, ForwardedAuth auth); + + /** A verified, unused challenge of this reason created by this user; else {@code OTP_INVALID}. */ + OtpChallenge requireUsable(String challengeId, OtpReason reason, AuthenticatedUser user); + + /** + * A verified, unused challenge created by this user, with one of {@code reasons}, a {@code refId} equal to + * {@code refId} and, when {@code expectedPhoneHash} is not null, that phone; else {@code OTP_INVALID}. + */ + OtpChallenge requireUsableFor(String challengeId, Set reasons, String refId, AuthenticatedUser user, + String expectedPhoneHash); + + void markUsed(String challengeId); +} diff --git a/src/main/java/com/iemr/consent/service/otp/OtpServiceImpl.java b/src/main/java/com/iemr/consent/service/otp/OtpServiceImpl.java new file mode 100644 index 0000000..11eb561 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/otp/OtpServiceImpl.java @@ -0,0 +1,156 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.otp; + +import java.time.Clock; +import java.time.Instant; +import java.util.Set; +import java.util.UUID; + +import org.springframework.stereotype.Service; + +import com.iemr.consent.config.ConsentProperties; +import com.iemr.consent.model.otp.OtpChallengeView; +import com.iemr.consent.model.otp.OtpReason; +import com.iemr.consent.model.otp.OtpSendRequest; +import com.iemr.consent.model.otp.OtpVerifyResult; +import com.iemr.consent.service.otp.OtpChallengeStore.SendReplay; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.crypto.FieldEncryptor; +import com.iemr.consent.utils.crypto.PhoneMasker; +import com.iemr.consent.utils.crypto.Sha256; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; +import com.iemr.consent.utils.http.ForwardedAuth; + +/** + * OTP challenges wrapping Common-API's consent OTP (research R1). + * + *
    + *
  • Send: a repeat with the same request key replays the first answer and sends no second SMS + * (C18).
  • + *
  • Verify: the code expires after {@code consent.otp.ttl-seconds} ({@code OTP_EXPIRED}); after + * {@code consent.otp.max-attempts} wrong codes the challenge is locked ({@code RATE_LIMITED}) + * without calling Common-API; a challenge is verified at most once.
  • + *
  • A challenge belongs to the user who sent it; others get {@code NOT_FOUND}.
  • + *
+ */ +@Service +public class OtpServiceImpl implements OtpService { + + private final OtpChallengeStore store; + + private final CommonApiOtpClient client; + + private final FieldEncryptor encryptor; + + private final Clock clock; + + private final ConsentProperties.Otp settings; + + public OtpServiceImpl(OtpChallengeStore store, CommonApiOtpClient client, FieldEncryptor encryptor, Clock clock, + ConsentProperties properties) { + this.store = store; + this.client = client; + this.encryptor = encryptor; + this.clock = clock; + this.settings = properties.otp(); + } + + @Override + public OtpChallengeView send(OtpSendRequest request, String requestKey, String requestHash, + AuthenticatedUser user, ForwardedAuth auth) { + SendReplay replay = store.findSendReplay(user.userId(), requestKey).orElse(null); + if (replay != null) { + if (!replay.requestHash().equals(requestHash)) { + throw new ConsentApiException(ErrorCode.STATE_CONFLICT); + } + return replay.response(); + } + String phone = request.phone(); + OtpChallenge challenge = new OtpChallenge(UUID.randomUUID().toString(), request.purpose(), + encryptor.encrypt(phone), Sha256.hex(phone), PhoneMasker.mask(phone), request.refId(), + user.userId(), clock.instant().plusSeconds(settings.ttlSeconds()), null, false, 0); + store.create(challenge); + client.send(phone, auth); + OtpChallengeView view = new OtpChallengeView(challenge.challengeId(), challenge.maskedPhone(), + challenge.expiresAt()); + store.saveSendReplay(user.userId(), requestKey, new SendReplay(requestHash, view)); + return view; + } + + @Override + public OtpVerifyResult verify(String challengeId, String otp, AuthenticatedUser user, ForwardedAuth auth) { + OtpChallenge challenge = owned(challengeId, user); + if (challenge.verifiedAt() != null || challenge.used()) { + throw new ConsentApiException(ErrorCode.STATE_CONFLICT); + } + if (challenge.attempts() >= settings.maxAttempts()) { + throw new ConsentApiException(ErrorCode.RATE_LIMITED); + } + Instant now = clock.instant(); + if (now.isAfter(challenge.expiresAt())) { + throw new ConsentApiException(ErrorCode.OTP_EXPIRED); + } + boolean verified = client.validate(encryptor.decrypt(challenge.phoneEnc()), otp, auth); + store.update(verified ? challenge.verified(now) : challenge.failedAttempt()); + return new OtpVerifyResult(verified); + } + + @Override + public OtpChallenge requireUsable(String challengeId, OtpReason reason, AuthenticatedUser user) { + OtpChallenge challenge = store.find(challengeId) + .orElseThrow(() -> new ConsentApiException(ErrorCode.NOT_FOUND)); + boolean usable = challenge.reason() == reason && challenge.verifiedAt() != null && !challenge.used() + && challenge.createdBy().equals(user.userId()); + if (!usable) { + throw new ConsentApiException(ErrorCode.OTP_INVALID); + } + return challenge; + } + + @Override + public OtpChallenge requireUsableFor(String challengeId, Set reasons, String refId, + AuthenticatedUser user, String expectedPhoneHash) { + OtpChallenge challenge = store.find(challengeId) + .orElseThrow(() -> new ConsentApiException(ErrorCode.OTP_INVALID)); + boolean usable = reasons.contains(challenge.reason()) && challenge.verifiedAt() != null + && !challenge.used() && challenge.createdBy().equals(user.userId()) + && refId.equals(challenge.refId()) + && (expectedPhoneHash == null || expectedPhoneHash.equals(challenge.phoneHash())); + if (!usable) { + throw new ConsentApiException(ErrorCode.OTP_INVALID); + } + return challenge; + } + + @Override + public void markUsed(String challengeId) { + store.find(challengeId).ifPresent(challenge -> store.update(challenge.markedUsed())); + } + + private OtpChallenge owned(String challengeId, AuthenticatedUser user) { + return store.find(challengeId).filter(c -> c.createdBy().equals(user.userId())) + .orElseThrow(() -> new ConsentApiException(ErrorCode.NOT_FOUND)); + } +} diff --git a/src/main/java/com/iemr/consent/service/principal/ConsentViewMapper.java b/src/main/java/com/iemr/consent/service/principal/ConsentViewMapper.java new file mode 100644 index 0000000..dbdf9d1 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/principal/ConsentViewMapper.java @@ -0,0 +1,102 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.principal; + +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import com.iemr.consent.data.consent.Consent; +import com.iemr.consent.data.consent.ConsentPurpose; +import com.iemr.consent.data.notice.Purpose; +import com.iemr.consent.model.principal.ConsentEventView; +import com.iemr.consent.model.principal.ConsentEventView.PurposeOutcome; +import com.iemr.consent.model.principal.CurrentDecisionView; + +/** Maps stored decisions and events to the API 8 views (contract {@code PatientConsents}). */ +@Component +public class ConsentViewMapper { + + private final EffectiveStatus effectiveStatus; + + public ConsentViewMapper(EffectiveStatus effectiveStatus) { + this.effectiveStatus = effectiveStatus; + } + + /** + * Keeps the highest version of each purpose code. + * + * @return purposes by code + */ + public static Map latestByCode(Collection purposes) { + BinaryOperator newer = (a, b) -> a.getVersion() >= b.getVersion() ? a : b; + return purposes.stream().collect(Collectors.toMap(Purpose::getPurposeCode, Function.identity(), newer)); + } + + /** + * @param rows current decisions + * @param purposes purposes by code; a missing purpose shows no name and is neither mandatory nor + * withdrawable + * @return one view per decision, sorted by purpose code + */ + public List current(List rows, Map purposes) { + return rows.stream().sorted(Comparator.comparing(ConsentPurpose::getPurposeCode)) + .map(row -> view(row, purposes.get(row.getPurposeCode()))).toList(); + } + + /** + * @param events events in the order to show them + * @param rows the events' purpose rows, in row-id order + */ + public List history(List events, List rows) { + Map> byEvent = rows.stream().collect(Collectors.groupingBy( + ConsentPurpose::getConsentId, LinkedHashMap::new, + Collectors.mapping(ConsentViewMapper::outcome, Collectors.toList()))); + return events.stream().map(e -> event(e, byEvent.getOrDefault(e.getConsentId(), List.of()))) + .toList(); + } + + private static PurposeOutcome outcome(ConsentPurpose row) { + return new PurposeOutcome(row.getPurposeCode(), row.getStatus(), row.getVersionNo()); + } + + private static ConsentEventView event(Consent e, List purposes) { + return new ConsentEventView(e.getConsentId(), e.getEventType(), e.getConsentingParty(), + e.getNoticeVersionId(), e.getLanguageCode(), e.getMode(), e.getCapturedBy(), + e.getCapturedAt(), e.getFacilityId(), e.getServiceLine(), e.getDeviceId(), + e.getSyncStatus(), purposes); + } + + private CurrentDecisionView view(ConsentPurpose row, Purpose purpose) { + return new CurrentDecisionView(row.getPurposeCode(), purpose == null ? null : purpose.getName(), + purpose != null && purpose.isMandatory(), purpose != null && purpose.isWithdrawable(), + effectiveStatus.of(row), row.getValidUntil(), row.getVersionNo()); + } +} diff --git a/src/main/java/com/iemr/consent/service/principal/EffectiveStatus.java b/src/main/java/com/iemr/consent/service/principal/EffectiveStatus.java new file mode 100644 index 0000000..e5c8c57 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/principal/EffectiveStatus.java @@ -0,0 +1,65 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.principal; + +import java.time.Clock; +import java.time.Instant; + +import org.springframework.stereotype.Component; + +import com.iemr.consent.data.consent.ConsentPurpose; +import com.iemr.consent.data.consent.PurposeStatus; +import com.iemr.consent.data.grant.AccessGrant; +import com.iemr.consent.data.grant.GrantStatus; + +/** + * Status as of now (research R4): a GRANTED decision or an ACTIVE grant whose end date has passed is + * EXPIRED, even before a scheduled job records it. Used by reads and writes alike; nothing is written. + */ +@Component +public class EffectiveStatus { + + private final Clock clock; + + public EffectiveStatus(Clock clock) { + this.clock = clock; + } + + public PurposeStatus of(ConsentPurpose decision) { + if (decision.getStatus() == PurposeStatus.GRANTED && ended(decision.getValidUntil())) { + return PurposeStatus.EXPIRED; + } + return decision.getStatus(); + } + + public GrantStatus of(AccessGrant grant) { + if (grant.getStatus() == GrantStatus.ACTIVE && ended(grant.getValidUntil())) { + return GrantStatus.EXPIRED; + } + return grant.getStatus(); + } + + private boolean ended(Instant validUntil) { + return validUntil != null && validUntil.isBefore(clock.instant()); + } +} diff --git a/src/main/java/com/iemr/consent/service/principal/PendingActionService.java b/src/main/java/com/iemr/consent/service/principal/PendingActionService.java new file mode 100644 index 0000000..cd42891 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/principal/PendingActionService.java @@ -0,0 +1,39 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.principal; + +import java.util.List; + +import com.iemr.consent.data.notice.NoticeContext; +import com.iemr.consent.model.principal.PendingAction; + +/** Consent actions still needed for a patient (front-end API 9; research R5). */ +public interface PendingActionService { + + /** + * @param context which active notice defines the purposes needed + * @return actions in the order CONSENT_MISSING, LEGACY, RECONSENT_REQUIRED, GUARDIAN_TO_SELF; empty when + * nothing is needed (H3) + */ + List pending(Long benRegId, NoticeContext context); +} diff --git a/src/main/java/com/iemr/consent/service/principal/PendingActionServiceImpl.java b/src/main/java/com/iemr/consent/service/principal/PendingActionServiceImpl.java new file mode 100644 index 0000000..d4c04c3 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/principal/PendingActionServiceImpl.java @@ -0,0 +1,198 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.principal; + +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.iemr.consent.data.consent.Consent; +import com.iemr.consent.data.consent.ConsentPurpose; +import com.iemr.consent.data.consent.ConsentingParty; +import com.iemr.consent.data.consent.EventType; +import com.iemr.consent.data.consent.GuardianLink; +import com.iemr.consent.data.consent.PurposeStatus; +import com.iemr.consent.data.notice.Notice; +import com.iemr.consent.data.notice.NoticeContext; +import com.iemr.consent.data.notice.NoticePurpose; +import com.iemr.consent.data.notice.NoticeStatus; +import com.iemr.consent.model.principal.PendingAction; +import com.iemr.consent.model.principal.PendingReason; +import com.iemr.consent.repository.consent.ConsentPurposeRepository; +import com.iemr.consent.repository.consent.ConsentRepository; +import com.iemr.consent.repository.consent.GuardianLinkRepository; +import com.iemr.consent.repository.notice.NoticePurposeRepository; +import com.iemr.consent.repository.notice.NoticeRepository; + +/** + * Derives a patient's pending consent actions on each call (research R5); nothing is stored, so the answer + * is right before the expiry, guardian and notice jobs exist. + *
    + *
  • CONSENT_MISSING: purposes of the active notice with no current decision (C4).
  • + *
  • LEGACY: decisions from a pre-DPDP (legacy) event (C5).
  • + *
  • RECONSENT_REQUIRED: decisions marked so, past their end date (C16), or taken on a notice that has a + * newer published material version (C14).
  • + *
  • GUARDIAN_TO_SELF: decisions a guardian gave whose guardian link has ended (C9).
  • + *
+ * WITHDRAWN and DENIED are valid decisions and need nothing. {@code dueSince} is the earliest date among an + * action's purposes. + */ +@Service +public class PendingActionServiceImpl implements PendingActionService { + + private final ConsentPurposeRepository purposeRepository; + + private final ConsentRepository consentRepository; + + private final NoticeRepository noticeRepository; + + private final NoticePurposeRepository noticePurposeRepository; + + private final GuardianLinkRepository guardianLinkRepository; + + private final EffectiveStatus effectiveStatus; + + private final Clock clock; + + public PendingActionServiceImpl(ConsentPurposeRepository purposeRepository, + ConsentRepository consentRepository, NoticeRepository noticeRepository, + NoticePurposeRepository noticePurposeRepository, GuardianLinkRepository guardianLinkRepository, + EffectiveStatus effectiveStatus, Clock clock) { + this.purposeRepository = purposeRepository; + this.consentRepository = consentRepository; + this.noticeRepository = noticeRepository; + this.noticePurposeRepository = noticePurposeRepository; + this.guardianLinkRepository = guardianLinkRepository; + this.effectiveStatus = effectiveStatus; + this.clock = clock; + } + + @Override + @Transactional(readOnly = true) + public List pending(Long benRegId, NoticeContext context) { + List current = purposeRepository.findByBeneficiaryRegIdAndCurrentTrue(benRegId); + List eventIds = current.stream().map(ConsentPurpose::getConsentId).toList(); + Map sources = current.isEmpty() ? Map.of() + : consentRepository.findByConsentIdIn(eventIds).stream() + .collect(Collectors.toMap(Consent::getConsentId, Function.identity())); + List actions = new ArrayList<>(); + missing(context, current).ifPresent(actions::add); + Map legacy = new TreeMap<>(); + Map reconsent = new TreeMap<>(); + Map guardianEnded = new TreeMap<>(); + Map> newerNotice = new HashMap<>(); + Map> linkEnds = new HashMap<>(); + for (ConsentPurpose row : current) { + Consent source = sources.get(row.getConsentId()); + if (source.getEventType() == EventType.LEGACY) { + legacy.put(row.getPurposeCode(), source.getCapturedAt()); + } + reconsentDue(row, source, newerNotice).ifPresent(due -> reconsent.put(row.getPurposeCode(), + due)); + linkEnds.computeIfAbsent(source.getConsentId(), id -> guardianEnd(source)) + .ifPresent(end -> guardianEnded.put(row.getPurposeCode(), end)); + } + action(PendingReason.LEGACY, legacy).ifPresent(actions::add); + action(PendingReason.RECONSENT_REQUIRED, reconsent).ifPresent(actions::add); + action(PendingReason.GUARDIAN_TO_SELF, guardianEnded).ifPresent(actions::add); + return actions; + } + + /** CONSENT_MISSING for the active notice's purposes that have no current decision. */ + private Optional missing(NoticeContext context, List current) { + Optional notice = noticeRepository + .findFirstByContextAndStatusAndDeletedFalseOrderByVersionNoDesc(context, + NoticeStatus.PUBLISHED); + if (notice.isEmpty()) { + return Optional.empty(); + } + Set decided = current.stream().map(ConsentPurpose::getPurposeCode).collect(Collectors.toSet()); + List codes = noticePurposeRepository + .findByNoticeVersionIdAndDeletedFalseOrderByDisplayRankAsc(notice.get() + .getNoticeVersionId()).stream() + .map(NoticePurpose::getPurposeCode).filter(code -> !decided.contains(code)).sorted() + .toList(); + return codes.isEmpty() ? Optional.empty() + : Optional.of(new PendingAction(PendingReason.CONSENT_MISSING, codes, null)); + } + + /** The date re-consent became due for a granted or denied decision, if it is due. */ + private Optional reconsentDue(ConsentPurpose row, Consent source, + Map> newerNotice) { + PurposeStatus status = effectiveStatus.of(row); + if (status == PurposeStatus.EXPIRED) { + return Optional.of(row.getValidUntil()); + } + if (status == PurposeStatus.RECONSENT_REQUIRED) { + return Optional.of(source.getCapturedAt()); + } + if (status == PurposeStatus.WITHDRAWN) { + return Optional.empty(); + } + return newerNotice.computeIfAbsent(source.getNoticeVersionId(), this::newerMaterialPublishedAt); + } + + private Optional newerMaterialPublishedAt(Long noticeVersionId) { + return noticeRepository.findById(noticeVersionId) + .flatMap(n -> noticeRepository.findNewerMaterial(n.getNoticeCode(), n.getVersionNo()) + .stream() + .findFirst()) + .map(Notice::getPublishedAt); + } + + /** The end of a guardian's link, when the decision is a guardian's and the link has ended (C9). */ + private Optional guardianEnd(Consent source) { + if (source.getConsentingParty() != ConsentingParty.GUARDIAN) { + return Optional.empty(); + } + Instant now = clock.instant(); + return guardianLinkRepository.findFirstByConsentIdAndDeletedFalse(source.getConsentId()) + .map(GuardianLink::getValidUntil).filter(end -> end.isBefore(now)); + } + + private static Optional action(PendingReason reason, Map dueByCode) { + if (dueByCode.isEmpty()) { + return Optional.empty(); + } + Instant earliest = earliest(dueByCode.values()); + return Optional.of(new PendingAction(reason, List.copyOf(dueByCode.keySet()), earliest)); + } + + private static Instant earliest(Collection dates) { + return dates.stream().filter(Objects::nonNull).min(Comparator.naturalOrder()).orElse(null); + } +} diff --git a/src/main/java/com/iemr/consent/service/principal/PreferenceChangeWriter.java b/src/main/java/com/iemr/consent/service/principal/PreferenceChangeWriter.java new file mode 100644 index 0000000..2fda3de --- /dev/null +++ b/src/main/java/com/iemr/consent/service/principal/PreferenceChangeWriter.java @@ -0,0 +1,217 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.principal; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.iemr.consent.data.consent.Consent; +import com.iemr.consent.data.consent.ConsentMode; +import com.iemr.consent.data.consent.ConsentPurpose; +import com.iemr.consent.data.consent.ConsentingParty; +import com.iemr.consent.data.consent.Decision; +import com.iemr.consent.data.consent.EventType; +import com.iemr.consent.data.consent.GuardianLink; +import com.iemr.consent.data.consent.PurposeStatus; +import com.iemr.consent.data.evidence.ConsentEvidence; +import com.iemr.consent.data.evidence.EvidenceType; +import com.iemr.consent.data.notice.Notice; +import com.iemr.consent.data.notice.Purpose; +import com.iemr.consent.model.otp.OtpReason; +import com.iemr.consent.model.principal.PreferenceRequest; +import com.iemr.consent.repository.consent.ConsentRepository; +import com.iemr.consent.repository.evidence.ConsentEvidenceRepository; +import com.iemr.consent.repository.notice.NoticeRepository; +import com.iemr.consent.repository.notice.PurposeRepository; +import com.iemr.consent.service.audit.AuditLogService; +import com.iemr.consent.service.consent.ProofRows; +import com.iemr.consent.service.otp.OtpChallenge; +import com.iemr.consent.service.otp.OtpService; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; +import com.iemr.consent.utils.http.AfterCommit; + +/** + * Writes one preference change (front-end API 10; FR-005 – FR-008) in a single transaction: lock and check + * the named decisions, record an UPDATE event with each purpose GRANTED or DENIED at the next version, keep + * the OTP proof of a GRANT, and audit it. + * + *

A GRANT needs a verified OTP challenge bound to the patient ({@code refId} = benRegId), from the + * guardian's phone while a guardian's consent is current (research R6), and is refused when the purpose + * needs the full consent screen again (re-consent required, expired, or a newer material notice). A DENY of + * a granted purpose that cannot be withdrawn is refused. The consent-check cache, once it exists, is cleared + * after commit here (consent-check feature).

+ */ +@Component +public class PreferenceChangeWriter { + + static final String VERIFIED = "VERIFIED"; + + private final PrincipalEventFactory events; + + private final PurposeRepository purposes; + + private final ConsentRepository consentRepository; + + private final NoticeRepository noticeRepository; + + private final OtpService otpService; + + private final ConsentEvidenceRepository evidenceRepository; + + private final EffectiveStatus effectiveStatus; + + private final AuditLogService auditLogService; + + private final Clock clock; + + public PreferenceChangeWriter(PrincipalEventFactory events, PurposeRepository purposes, + ConsentRepository consentRepository, NoticeRepository noticeRepository, OtpService otpService, + ConsentEvidenceRepository evidenceRepository, EffectiveStatus effectiveStatus, + AuditLogService auditLogService, Clock clock) { + this.events = events; + this.purposes = purposes; + this.consentRepository = consentRepository; + this.noticeRepository = noticeRepository; + this.otpService = otpService; + this.evidenceRepository = evidenceRepository; + this.effectiveStatus = effectiveStatus; + this.auditLogService = auditLogService; + this.clock = clock; + } + + @Transactional + public void write(Long benRegId, PreferenceRequest request, String requestKey, String requestHash, + AuthenticatedUser user, String sourceIp) { + boolean grant = request.decision() == Decision.GRANT; + Map rows = events.lockCurrent(benRegId, request.purposeCodes(), + request.versions()); + Map byCode = ConsentViewMapper + .latestByCode(purposes.findByPurposeCodeInAndActiveTrueAndDeletedFalse(rows.keySet())); + if (grant) { + checkGrantable(rows); + } else { + checkDeniable(rows, byCode); + } + Consent source = events.newestSource(rows.values()); + ConsentingParty party = events.partyFor(source); + OtpChallenge challenge = grant ? otp(request, benRegId, source, party, user) : null; + Consent event = events.saveEvent(benRegId, EventType.UPDATE, grant ? ConsentMode.OTP : null, source, + party, request, requestKey, requestHash, user); + PurposeStatus status = grant ? PurposeStatus.GRANTED : PurposeStatus.DENIED; + rows.values().forEach(row -> events.replace(row, event, status, + grant ? validUntil(byCode.get(row.getPurposeCode())) : null)); + if (challenge != null) { + saveOtpProof(event, challenge, user); + } + String summary = rows.keySet().stream().map(c -> c + ":" + status).collect(Collectors.joining(",")); + auditLogService.append(AuditLogService.PREFERENCE_CHANGED, event.getConsentId(), benRegId, summary, + status.name(), user.userId(), sourceIp); + } + + /** GRANT: not already granted, and not a purpose that needs the full consent screen (C14, C16). */ + private void checkGrantable(Map rows) { + Map sources = consentRepository + .findByConsentIdIn(rows.values().stream().map(ConsentPurpose::getConsentId).toList()) + .stream() + .collect(Collectors.toMap(Consent::getConsentId, c -> c)); + Map superseded = new HashMap<>(); + for (ConsentPurpose row : rows.values()) { + PurposeStatus status = effectiveStatus.of(row); + if (status == PurposeStatus.GRANTED) { + throw new ConsentApiException(ErrorCode.STATE_CONFLICT, + "This purpose is already granted", + row.getPurposeCode(), null); + } + Long noticeId = sources.get(row.getConsentId()).getNoticeVersionId(); + boolean newerNotice = superseded.computeIfAbsent(noticeId, this::hasNewerMaterialNotice); + if (status == PurposeStatus.RECONSENT_REQUIRED || status == PurposeStatus.EXPIRED + || newerNotice) { + throw new ConsentApiException(ErrorCode.BUSINESS_RULE_VIOLATION, + "Take consent again through the consent screen", row.getPurposeCode(), + null); + } + } + } + + /** DENY: not already denied, and a granted purpose must be one that can be withdrawn. */ + private void checkDeniable(Map rows, Map byCode) { + for (ConsentPurpose row : rows.values()) { + PurposeStatus status = effectiveStatus.of(row); + if (status == PurposeStatus.DENIED) { + throw new ConsentApiException(ErrorCode.STATE_CONFLICT, + "This purpose is already denied", + row.getPurposeCode(), null); + } + Purpose purpose = byCode.get(row.getPurposeCode()); + if (status == PurposeStatus.GRANTED && (purpose == null || !purpose.isWithdrawable())) { + throw new ConsentApiException(ErrorCode.PURPOSE_NOT_WITHDRAWABLE, null, + row.getPurposeCode(), null); + } + } + } + + private boolean hasNewerMaterialNotice(Long noticeVersionId) { + Optional notice = noticeRepository.findById(noticeVersionId); + return notice.isPresent() && !noticeRepository + .findNewerMaterial(notice.get().getNoticeCode(), notice.get().getVersionNo()).isEmpty(); + } + + private OtpChallenge otp(PreferenceRequest request, Long benRegId, Consent source, ConsentingParty party, + AuthenticatedUser user) { + String phoneHash = null; + Set reasons = Set.of(OtpReason.CONSENT); + if (party == ConsentingParty.GUARDIAN) { + GuardianLink link = events.activeGuardian(source).orElseThrow(); + phoneHash = events.guardianPhoneHash(link); + reasons = Set.of(OtpReason.GUARDIAN); + } + return otpService.requireUsableFor(request.otpChallengeId().toString(), reasons, + String.valueOf(benRegId), user, phoneHash); + } + + private Instant validUntil(Purpose purpose) { + if (purpose == null || purpose.getValidityDays() == null) { + return null; + } + return clock.instant().plus(Duration.ofDays(purpose.getValidityDays())); + } + + private void saveOtpProof(Consent event, OtpChallenge challenge, AuthenticatedUser user) { + ConsentEvidence row = ProofRows.row(event, EvidenceType.OTP, user, clock); + row.setOtpStatus(VERIFIED); + row.setMaskedPhone(challenge.maskedPhone()); + evidenceRepository.save(row); + AfterCommit.run(() -> otpService.markUsed(challenge.challengeId())); + } +} diff --git a/src/main/java/com/iemr/consent/service/principal/PrincipalConsentService.java b/src/main/java/com/iemr/consent/service/principal/PrincipalConsentService.java new file mode 100644 index 0000000..5870785 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/principal/PrincipalConsentService.java @@ -0,0 +1,48 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.principal; + +import com.iemr.consent.model.principal.PatientConsents; +import com.iemr.consent.model.principal.PreferenceRequest; +import com.iemr.consent.model.principal.WithdrawalRequest; +import com.iemr.consent.model.principal.WithdrawalResult; +import com.iemr.consent.utils.auth.AuthenticatedUser; + +/** A patient's consents in the preference centre (front-end APIs 8, 10 and 11). */ +public interface PrincipalConsentService { + + /** API 8: current decisions and full history; both empty for a patient with no consent. */ + PatientConsents consents(Long benRegId); + + /** + * API 10. A repeat with the same request key changes nothing and returns the current consents (FR-018). + * + * @param requestHash hash of the patient id and the body, so a key cannot replay another patient + */ + PatientConsents changePreference(Long benRegId, PreferenceRequest request, String requestKey, + String requestHash, AuthenticatedUser user, String sourceIp); + + /** API 11; repeats behave as for {@link #changePreference}. */ + WithdrawalResult withdraw(Long benRegId, WithdrawalRequest request, String requestKey, String requestHash, + AuthenticatedUser user, String sourceIp); +} diff --git a/src/main/java/com/iemr/consent/service/principal/PrincipalConsentServiceImpl.java b/src/main/java/com/iemr/consent/service/principal/PrincipalConsentServiceImpl.java new file mode 100644 index 0000000..a215c66 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/principal/PrincipalConsentServiceImpl.java @@ -0,0 +1,147 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.principal; + +import java.util.List; +import java.util.Map; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.iemr.consent.data.consent.Consent; +import com.iemr.consent.data.consent.ConsentPurpose; +import com.iemr.consent.data.consent.PurposeStatus; +import com.iemr.consent.data.notice.Purpose; +import com.iemr.consent.model.principal.CurrentDecisionView; +import com.iemr.consent.model.principal.PatientConsents; +import com.iemr.consent.model.principal.PreferenceRequest; +import com.iemr.consent.model.principal.WithdrawalRequest; +import com.iemr.consent.model.principal.WithdrawalResult; +import com.iemr.consent.repository.consent.ConsentPurposeRepository; +import com.iemr.consent.repository.consent.ConsentRepository; +import com.iemr.consent.repository.notice.PurposeRepository; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; + +/** + * Reads a patient's consents (API 8) and applies preference changes and withdrawals (APIs 10, 11) exactly + * once per request key, as API 7 does (research R2): a repeat with the same key and request changes nothing + * and returns the current consents; the same key with another request, or for another patient, is + * {@code STATE_CONFLICT}. When two identical requests race, the database's unique key lets one win. + */ +@Service +public class PrincipalConsentServiceImpl implements PrincipalConsentService { + + private final ConsentRepository consentRepository; + + private final ConsentPurposeRepository purposeRepository; + + private final PurposeRepository purposes; + + private final ConsentViewMapper mapper; + + private final PreferenceChangeWriter preferenceWriter; + + private final WithdrawalWriter withdrawalWriter; + + public PrincipalConsentServiceImpl(ConsentRepository consentRepository, + ConsentPurposeRepository purposeRepository, PurposeRepository purposes, + ConsentViewMapper mapper, + PreferenceChangeWriter preferenceWriter, WithdrawalWriter withdrawalWriter) { + this.consentRepository = consentRepository; + this.purposeRepository = purposeRepository; + this.purposes = purposes; + this.mapper = mapper; + this.preferenceWriter = preferenceWriter; + this.withdrawalWriter = withdrawalWriter; + } + + @Override + @Transactional(readOnly = true) + public PatientConsents consents(Long benRegId) { + List events = consentRepository.findByBeneficiaryRegIdOrderByServerReceivedAtDesc(benRegId); + if (events.isEmpty()) { + return new PatientConsents(List.of(), List.of()); + } + List rows = purposeRepository + .findByConsentIdInOrderByConsentPurposeIdAsc(events.stream().map(Consent::getConsentId) + .toList()); + List current = rows.stream().filter(ConsentPurpose::isCurrent).toList(); + Map byCode = ConsentViewMapper.latestByCode(purposes + .findByPurposeCodeInAndActiveTrueAndDeletedFalse( + current.stream().map(ConsentPurpose::getPurposeCode).toList())); + return new PatientConsents(mapper.current(current, byCode), mapper.history(events, rows)); + } + + @Override + public PatientConsents changePreference(Long benRegId, PreferenceRequest request, String requestKey, + String requestHash, AuthenticatedUser user, String sourceIp) { + writeOnce(user, requestKey, requestHash, + () -> preferenceWriter.write(benRegId, request, requestKey, requestHash, user, + sourceIp)); + return consents(benRegId); + } + + @Override + public WithdrawalResult withdraw(Long benRegId, WithdrawalRequest request, String requestKey, + String requestHash, AuthenticatedUser user, String sourceIp) { + writeOnce(user, requestKey, requestHash, + () -> withdrawalWriter.write(benRegId, request, requestKey, requestHash, user, + sourceIp)); + PatientConsents consents = consents(benRegId); + List notWithdrawn = request.purposeCodes().all() ? notWithdrawable(consents.current()) + : List.of(); + return new WithdrawalResult(consents.current(), consents.history(), notWithdrawn); + } + + /** Runs the write unless this user already used the key; a repeat must be the same request. */ + private void writeOnce(AuthenticatedUser user, String requestKey, String requestHash, Runnable write) { + Consent existing = consentRepository.findByCapturedByAndRequestKey(user.userId(), requestKey) + .orElse(null); + if (existing != null) { + requireSameRequest(existing, requestHash); + return; + } + try { + write.run(); + } catch (DataIntegrityViolationException e) { + Consent winner = consentRepository.findByCapturedByAndRequestKey(user.userId(), requestKey) + .orElseThrow(() -> new ConsentApiException(ErrorCode.STATE_CONFLICT)); + requireSameRequest(winner, requestHash); + } + } + + private static void requireSameRequest(Consent consent, String requestHash) { + if (!consent.getRequestHash().equals(requestHash)) { + throw new ConsentApiException(ErrorCode.STATE_CONFLICT); + } + } + + /** Granted purposes that cannot be withdrawn: what {@code ALL} leaves unchanged. */ + private static List notWithdrawable(List current) { + return current.stream().filter(d -> d.status() == PurposeStatus.GRANTED && !d.isWithdrawable()) + .map(CurrentDecisionView::purposeCode).toList(); + } +} diff --git a/src/main/java/com/iemr/consent/service/principal/PrincipalEventFactory.java b/src/main/java/com/iemr/consent/service/principal/PrincipalEventFactory.java new file mode 100644 index 0000000..40a702a --- /dev/null +++ b/src/main/java/com/iemr/consent/service/principal/PrincipalEventFactory.java @@ -0,0 +1,189 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.principal; + +import java.time.Clock; +import java.time.Instant; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.TreeSet; +import java.util.UUID; + +import org.springframework.stereotype.Component; + +import com.iemr.consent.data.consent.Channel; +import com.iemr.consent.data.consent.Consent; +import com.iemr.consent.data.consent.ConsentMode; +import com.iemr.consent.data.consent.ConsentPurpose; +import com.iemr.consent.data.consent.ConsentingParty; +import com.iemr.consent.data.consent.Decision; +import com.iemr.consent.data.consent.EventType; +import com.iemr.consent.data.consent.GuardianLink; +import com.iemr.consent.data.consent.PurposeStatus; +import com.iemr.consent.data.consent.SyncStatus; +import com.iemr.consent.model.principal.PrincipalChange; +import com.iemr.consent.repository.consent.ConsentPurposeRepository; +import com.iemr.consent.repository.consent.ConsentRepository; +import com.iemr.consent.repository.consent.GuardianLinkRepository; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.crypto.FieldEncryptor; +import com.iemr.consent.utils.crypto.Sha256; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; + +/** + * Shared steps of a preference change or withdrawal (research R1, R3): lock the current decisions and + * check their versions, find the source event, write the new event and replace a decision with a new + * version. The caller's transaction holds the locks. + */ +@Component +public class PrincipalEventFactory { + + /** Device id recorded when the request carries none. */ + static final String DEFAULT_DEVICE = "PREFERENCE_CENTRE"; + + private final ConsentRepository consentRepository; + + private final ConsentPurposeRepository purposeRepository; + + private final GuardianLinkRepository guardianLinkRepository; + + private final FieldEncryptor encryptor; + + private final Clock clock; + + public PrincipalEventFactory(ConsentRepository consentRepository, ConsentPurposeRepository purposeRepository, + GuardianLinkRepository guardianLinkRepository, FieldEncryptor encryptor, Clock clock) { + this.consentRepository = consentRepository; + this.purposeRepository = purposeRepository; + this.guardianLinkRepository = guardianLinkRepository; + this.encryptor = encryptor; + this.clock = clock; + } + + /** + * Locks the current decision of each purpose, in code order so two requests cannot deadlock, then checks + * the versions. + * + * @return current decisions by code, in code order + * @throws ConsentApiException {@code BUSINESS_RULE_VIOLATION} when a purpose has no current decision; + * {@code STALE_VERSION} when a version is missing or not the current one + */ + public Map lockCurrent(Long benRegId, Collection codes, + Map versions) { + Map rows = new LinkedHashMap<>(); + for (String code : new TreeSet<>(codes)) { + rows.put(code, purposeRepository.findCurrent(benRegId, code) + .orElseThrow(() -> new ConsentApiException(ErrorCode.BUSINESS_RULE_VIOLATION, + "No consent is recorded for this purpose", code, null))); + } + boolean stale = rows.values().stream() + .anyMatch(r -> !Objects.equals(versions.get(r.getPurposeCode()), r.getVersionNo())); + if (stale) { + throw new ConsentApiException(ErrorCode.STALE_VERSION); + } + return rows; + } + + /** @return the event of the newest of the given decisions (the one the change is recorded against) */ + public Consent newestSource(Collection rows) { + return consentRepository.findByConsentIdIn(rows.stream().map(ConsentPurpose::getConsentId).toList()) + .stream().max(Comparator.comparing(Consent::getServerReceivedAt)) + .orElseThrow(() -> new IllegalStateException("Decision without its consent event")); + } + + /** @return the guardian link of a GUARDIAN event while it is still valid */ + public Optional activeGuardian(Consent source) { + if (source.getConsentingParty() != ConsentingParty.GUARDIAN) { + return Optional.empty(); + } + Instant now = clock.instant(); + return guardianLinkRepository.findFirstByConsentIdAndDeletedFalse(source.getConsentId()) + .filter(link -> link.getValidUntil() == null || !link.getValidUntil().isBefore(now)); + } + + /** @return GUARDIAN while the source event's guardian link is valid, else SELF (R1) */ + public ConsentingParty partyFor(Consent source) { + return activeGuardian(source).isPresent() ? ConsentingParty.GUARDIAN : ConsentingParty.SELF; + } + + /** + * @return SHA-256 of the guardian's phone, for matching an OTP challenge (T9) + * @throws ConsentApiException {@code OTP_INVALID} when the guardian has no phone + */ + public String guardianPhoneHash(GuardianLink link) { + if (link.getGuardianPhoneEncrypted() == null) { + throw new ConsentApiException(ErrorCode.OTP_INVALID); + } + return Sha256.hex(encryptor.decrypt(link.getGuardianPhoneEncrypted())); + } + + /** Saves the new event, copying notice, language and fingerprint from the source event (R1). */ + public Consent saveEvent(Long benRegId, EventType type, ConsentMode mode, Consent source, ConsentingParty party, + PrincipalChange change, String requestKey, String requestHash, AuthenticatedUser user) { + Instant now = clock.instant(); + Consent event = new Consent(); + event.setConsentId(UUID.randomUUID().toString()); + event.setBeneficiaryRegId(benRegId); + event.setEventType(type); + event.setConsentingParty(party); + event.setNoticeVersionId(source.getNoticeVersionId()); + event.setLanguageCode(source.getLanguageCode()); + event.setTextHash(source.getTextHash()); + event.setChannel(Channel.PREFERENCE_CENTRE); + event.setMode(mode); + event.setCapturedBy(user.userId()); + event.setProviderServiceMapId(change.providerServiceMapId()); + event.setFacilityId(change.facilityId()); + event.setServiceLine(change.serviceLine()); + event.setDeviceId(change.deviceId() != null ? change.deviceId() : DEFAULT_DEVICE); + event.setCapturedAt(now); + event.setServerReceivedAt(now); + event.setSyncStatus(SyncStatus.SYNCED); + event.setRequestKey(requestKey); + event.setRequestHash(requestHash); + return consentRepository.saveAndFlush(event); + } + + /** Marks the current decision SUPERSEDED and saves its successor at the next version. */ + public ConsentPurpose replace(ConsentPurpose previous, Consent event, PurposeStatus status, + Instant validUntil) { + previous.setCurrent(false); + previous.setStatus(PurposeStatus.SUPERSEDED); + purposeRepository.saveAndFlush(previous); + ConsentPurpose row = new ConsentPurpose(); + row.setConsentId(event.getConsentId()); + row.setBeneficiaryRegId(event.getBeneficiaryRegId()); + row.setPurposeCode(previous.getPurposeCode()); + row.setDecision(status == PurposeStatus.GRANTED ? Decision.GRANT : Decision.DENY); + row.setStatus(status); + row.setVersionNo(previous.getVersionNo() + 1); + row.setCurrent(true); + row.setValidUntil(validUntil); + return purposeRepository.save(row); + } +} diff --git a/src/main/java/com/iemr/consent/service/principal/WithdrawalWriter.java b/src/main/java/com/iemr/consent/service/principal/WithdrawalWriter.java new file mode 100644 index 0000000..4712650 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/principal/WithdrawalWriter.java @@ -0,0 +1,144 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.principal; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.iemr.consent.data.consent.Consent; +import com.iemr.consent.data.consent.ConsentPurpose; +import com.iemr.consent.data.consent.EventType; +import com.iemr.consent.data.consent.PurposeStatus; +import com.iemr.consent.data.grant.AccessGrant; +import com.iemr.consent.data.grant.GrantStatus; +import com.iemr.consent.data.notice.Purpose; +import com.iemr.consent.model.principal.WithdrawalRequest; +import com.iemr.consent.repository.consent.ConsentPurposeRepository; +import com.iemr.consent.repository.grant.AccessGrantRepository; +import com.iemr.consent.repository.notice.PurposeRepository; +import com.iemr.consent.service.audit.AuditLogService; +import com.iemr.consent.service.grant.AccessGrantService; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; + +/** + * Writes one withdrawal (front-end API 11; FR-009 – FR-012) in a single transaction: lock and check the + * named decisions, record a WITHDRAWAL event with each purpose WITHDRAWN at the next version, end every + * pending or active access grant covering a withdrawn purpose, and audit it. + * + *

Checks run in this order (research R3): no current decision (422), stale version (409), purpose + * cannot be withdrawn (422 {@code PURPOSE_NOT_WITHDRAWABLE}), purpose not currently granted (409). The + * consent-check cache, once it exists, is cleared after commit here (consent-check feature).

+ */ +@Component +public class WithdrawalWriter { + + private final ConsentPurposeRepository purposeRepository; + + private final PurposeRepository purposes; + + private final AccessGrantRepository grantRepository; + + private final AccessGrantService grantService; + + private final PrincipalEventFactory events; + + private final EffectiveStatus effectiveStatus; + + private final AuditLogService auditLogService; + + public WithdrawalWriter(ConsentPurposeRepository purposeRepository, PurposeRepository purposes, + AccessGrantRepository grantRepository, AccessGrantService grantService, + PrincipalEventFactory events, + EffectiveStatus effectiveStatus, AuditLogService auditLogService) { + this.purposeRepository = purposeRepository; + this.purposes = purposes; + this.grantRepository = grantRepository; + this.grantService = grantService; + this.events = events; + this.effectiveStatus = effectiveStatus; + this.auditLogService = auditLogService; + } + + @Transactional + public void write(Long benRegId, WithdrawalRequest request, String requestKey, String requestHash, + AuthenticatedUser user, String sourceIp) { + Set codes = request.purposeCodes().all() ? withdrawableGranted(benRegId) + : new TreeSet<>(request.purposeCodes().codes()); + Map rows = events.lockCurrent(benRegId, codes, request.versions()); + Map byCode = latest(codes); + for (String code : codes) { + if (!withdrawable(byCode.get(code))) { + throw new ConsentApiException(ErrorCode.PURPOSE_NOT_WITHDRAWABLE, null, code, null); + } + } + for (ConsentPurpose row : rows.values()) { + if (effectiveStatus.of(row) != PurposeStatus.GRANTED) { + throw new ConsentApiException(ErrorCode.STATE_CONFLICT, + "This purpose is not currently granted", + row.getPurposeCode(), null); + } + } + Consent source = events.newestSource(rows.values()); + Consent event = events.saveEvent(benRegId, EventType.WITHDRAWAL, null, source, events.partyFor(source), + request, requestKey, requestHash, user); + rows.values().forEach(row -> events.replace(row, event, PurposeStatus.WITHDRAWN, null)); + List open = grantRepository.findLockedByBeneficiaryRegIdAndStatusIn(benRegId, + List.of(GrantStatus.PENDING, GrantStatus.ACTIVE)); + grantService.revokeForWithdrawal(open, codes, user, sourceIp); + String summary = codes.stream().map(c -> c + ":" + PurposeStatus.WITHDRAWN) + .collect(Collectors.joining(",")); + auditLogService.append(AuditLogService.CONSENT_WITHDRAWN, event.getConsentId(), benRegId, summary, + PurposeStatus.WITHDRAWN.name(), user.userId(), sourceIp); + } + + /** {@code ALL}: the patient's currently granted purposes that can be withdrawn. */ + private Set withdrawableGranted(Long benRegId) { + List granted = purposeRepository.findByBeneficiaryRegIdAndCurrentTrue(benRegId).stream() + .filter(row -> effectiveStatus.of(row) == PurposeStatus.GRANTED).toList(); + Map byCode = latest(granted.stream().map(ConsentPurpose::getPurposeCode).toList()); + Set codes = granted.stream().map(ConsentPurpose::getPurposeCode) + .filter(code -> withdrawable(byCode.get(code))) + .collect(Collectors.toCollection(TreeSet::new)); + if (codes.isEmpty()) { + throw new ConsentApiException(ErrorCode.STATE_CONFLICT, "Nothing can be withdrawn", null, null); + } + return codes; + } + + private Map latest(Collection codes) { + return ConsentViewMapper.latestByCode(purposes.findByPurposeCodeInAndActiveTrueAndDeletedFalse(codes)); + } + + private static boolean withdrawable(Purpose purpose) { + return purpose != null && purpose.isWithdrawable(); + } +} diff --git a/src/main/java/com/iemr/consent/service/version/BuildInfo.java b/src/main/java/com/iemr/consent/service/version/BuildInfo.java new file mode 100644 index 0000000..7267b08 --- /dev/null +++ b/src/main/java/com/iemr/consent/service/version/BuildInfo.java @@ -0,0 +1,35 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.version; + +/** + * Build information returned by {@code GET /version} (FR-018); each value is + * {@code "unknown"} when not available. + * + * @param version {@code git.build.version} + * @param buildTimestamp {@code git.build.time} + * @param branch {@code git.branch} + * @param commitHash {@code git.commit.id.abbrev} + */ +public record BuildInfo(String version, String buildTimestamp, String branch, String commitHash) { +} diff --git a/src/main/java/com/iemr/consent/service/version/VersionService.java b/src/main/java/com/iemr/consent/service/version/VersionService.java new file mode 100644 index 0000000..3f82dce --- /dev/null +++ b/src/main/java/com/iemr/consent/service/version/VersionService.java @@ -0,0 +1,29 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.version; + +/** Supplies the running build's information for {@code GET /version} (FR-018). */ +public interface VersionService { + + BuildInfo getBuildInfo(); +} diff --git a/src/main/java/com/iemr/consent/service/version/VersionServiceImpl.java b/src/main/java/com/iemr/consent/service/version/VersionServiceImpl.java new file mode 100644 index 0000000..4ee919d --- /dev/null +++ b/src/main/java/com/iemr/consent/service/version/VersionServiceImpl.java @@ -0,0 +1,70 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.service.version; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; + +/** + * Reads {@code git.properties}, written at build time by {@code git-commit-id-maven-plugin}, as + * Common-API's {@code controller/version/VersionController} does (research R8). A missing file, + * key or read error gives {@code "unknown"}. + */ +@Service +public class VersionServiceImpl implements VersionService { + + static final String UNKNOWN = "unknown"; + + private final Resource gitProperties; + + public VersionServiceImpl(@Value("classpath:git.properties") Resource gitProperties) { + this.gitProperties = gitProperties; + } + + @Override + public BuildInfo getBuildInfo() { + Properties properties = load(); + return new BuildInfo(properties.getProperty("git.build.version", UNKNOWN), + properties.getProperty("git.build.time", UNKNOWN), + properties.getProperty("git.branch", UNKNOWN), + properties.getProperty("git.commit.id.abbrev", UNKNOWN)); + } + + private Properties load() { + Properties properties = new Properties(); + if (!gitProperties.exists()) { + return properties; + } + try (InputStream input = gitProperties.getInputStream()) { + properties.load(input); + } catch (IOException e) { + return new Properties(); + } + return properties; + } +} diff --git a/src/main/java/com/iemr/consent/utils/auth/AuthenticatedUser.java b/src/main/java/com/iemr/consent/utils/auth/AuthenticatedUser.java new file mode 100644 index 0000000..a3fc7c0 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/auth/AuthenticatedUser.java @@ -0,0 +1,68 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.auth; + +import com.iemr.consent.utils.exception.ErrorCode; + +/** + * The AMRIT user making the current request (FR-005). Built by {@link AuthenticationFilter}, + * kept as a request attribute and resolved into controllers with {@link CurrentUser}. + * + * @param userId AMRIT user id; always positive + * @param userName user name, or {@code null} when the login carries none; never logged + * @param clientType web or mobile login + */ +public record AuthenticatedUser(Long userId, String userName, ClientType clientType) { + + /** Request attribute holding the {@code AuthenticatedUser}. */ + public static final String ATTRIBUTE = AuthenticatedUser.class.getName(); + + /** + * Builds a user from a raw id taken from a token claim or session. + * + * @throws AuthenticationException {@code UNAUTHENTICATED} if the id is missing, not a number + * or not positive + */ + public static AuthenticatedUser of(Object rawUserId, String userName, ClientType clientType) { + long userId; + try { + userId = Long.parseLong(String.valueOf(rawUserId).trim()); + } catch (NumberFormatException e) { + throw new AuthenticationException(ErrorCode.UNAUTHENTICATED); + } + if (userId <= 0) { + throw new AuthenticationException(ErrorCode.UNAUTHENTICATED); + } + return new AuthenticatedUser(userId, userName, clientType); + } + + /** Value for the standard {@code CreatedBy} / {@code ModifiedBy} audit columns. */ + public String auditName() { + return userName != null ? userName : String.valueOf(userId); + } + + @Override + public String toString() { + return "AuthenticatedUser[userId=" + userId + ", clientType=" + clientType + "]"; + } +} diff --git a/src/main/java/com/iemr/consent/utils/auth/AuthenticationException.java b/src/main/java/com/iemr/consent/utils/auth/AuthenticationException.java new file mode 100644 index 0000000..7378fb9 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/auth/AuthenticationException.java @@ -0,0 +1,46 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.auth; + +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; + +/** + * A refused login: only {@code UNAUTHENTICATED} or {@code SESSION_EXPIRED} (FR-002, + * clarification 3). + */ +public class AuthenticationException extends ConsentApiException { + + private static final long serialVersionUID = 1L; + + public AuthenticationException(ErrorCode code) { + super(requireAuthCode(code)); + } + + private static ErrorCode requireAuthCode(ErrorCode code) { + if (code != ErrorCode.UNAUTHENTICATED && code != ErrorCode.SESSION_EXPIRED) { + throw new IllegalArgumentException("code must be UNAUTHENTICATED or SESSION_EXPIRED"); + } + return code; + } +} diff --git a/src/main/java/com/iemr/consent/utils/auth/AuthenticationFilter.java b/src/main/java/com/iemr/consent/utils/auth/AuthenticationFilter.java new file mode 100644 index 0000000..fe4d3cf --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/auth/AuthenticationFilter.java @@ -0,0 +1,163 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.auth; + +import java.io.IOException; +import java.util.Locale; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.iemr.consent.utils.exception.ErrorCode; +import com.iemr.consent.utils.exception.ErrorResponseWriter; +import com.iemr.consent.utils.http.ClientIp; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Accepts only requests carrying a valid AMRIT login, before any controller runs (FR-001, + * FR-002). + * + *

The credential is chosen in Common-API's {@code JwtUserIdValidationFilter} order, and only + * the first one found is used (clarification 2):

+ *
    + *
  1. {@code Jwttoken} cookie, 2. {@code JwtToken} header (web; an {@code Authorization} header + * sent with them must also be a live session, as in Common-API's interceptor);
  2. + *
  3. mobile: {@code User-Agent} containing {@code okhttp} or {@code java/} plus an + * {@code Authorization} session key.
  4. + *
+ *

Refusals are written in the standard body. A Redis or database failure while checking is + * 503 {@code DEPENDENCY_UNAVAILABLE}; any other failure is 500 {@code INTERNAL_ERROR}. Tokens, + * cookies, the {@code Authorization} value and exception messages are never logged.

+ */ +@Component +public class AuthenticationFilter extends OncePerRequestFilter { + + static final String JWT_COOKIE = "Jwttoken"; + + static final String JWT_HEADER = "JwtToken"; + + static final String USER_ID_MDC = "userId"; + + private static final Logger LOGGER = LoggerFactory.getLogger(AuthenticationFilter.class); + + private final PublicPaths publicPaths; + + private final JwtValidator jwtValidator; + + private final SessionValidator sessionValidator; + + private final ErrorResponseWriter errorResponseWriter; + + public AuthenticationFilter(PublicPaths publicPaths, JwtValidator jwtValidator, + SessionValidator sessionValidator, ErrorResponseWriter errorResponseWriter) { + this.publicPaths = publicPaths; + this.jwtValidator = jwtValidator; + this.sessionValidator = sessionValidator; + this.errorResponseWriter = errorResponseWriter; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if ("OPTIONS".equalsIgnoreCase(request.getMethod()) || publicPaths.isPublic(request)) { + filterChain.doFilter(request, response); + return; + } + AuthenticatedUser user; + try { + user = authenticate(request); + } catch (AuthenticationException e) { + errorResponseWriter.write(request, response, e.getErrorCode(), null); + return; + } catch (DataAccessException e) { + LOGGER.error("Login check failed: dependency unavailable ({})", e.getClass().getSimpleName()); + errorResponseWriter.write(request, response, ErrorCode.DEPENDENCY_UNAVAILABLE, null); + return; + } catch (RuntimeException e) { + LOGGER.error("Login check failed unexpectedly ({})", e.getClass().getSimpleName()); + errorResponseWriter.write(request, response, ErrorCode.INTERNAL_ERROR, null); + return; + } + request.setAttribute(AuthenticatedUser.ATTRIBUTE, user); + MDC.put(USER_ID_MDC, String.valueOf(user.userId())); + try { + filterChain.doFilter(request, response); + } finally { + MDC.remove(USER_ID_MDC); + } + } + + private AuthenticatedUser authenticate(HttpServletRequest request) { + String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); + String jwt = jwtFromCookie(request); + if (jwt == null) { + jwt = request.getHeader(JWT_HEADER); + } + if (jwt != null) { + AuthenticatedUser user = jwtValidator.validate(jwt); + if (authorization != null) { + sessionValidator.validate(authorization, clientIp(request), ClientType.WEB); + } + return user; + } + if (authorization != null && isMobileClient(request.getHeader(HttpHeaders.USER_AGENT))) { + return sessionValidator.validate(authorization, clientIp(request), ClientType.MOBILE); + } + throw new AuthenticationException(ErrorCode.UNAUTHENTICATED); + } + + private static String jwtFromCookie(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return null; + } + for (Cookie cookie : cookies) { + if (JWT_COOKIE.equalsIgnoreCase(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } + + private static boolean isMobileClient(String userAgent) { + if (userAgent == null) { + return false; + } + String agent = userAgent.toLowerCase(Locale.ROOT); + return agent.contains("okhttp") || agent.contains("java/"); + } + + private static String clientIp(HttpServletRequest request) { + return ClientIp.of(request); + } +} diff --git a/src/main/java/com/iemr/consent/utils/auth/ClientType.java b/src/main/java/com/iemr/consent/utils/auth/ClientType.java new file mode 100644 index 0000000..eb537e2 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/auth/ClientType.java @@ -0,0 +1,33 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.auth; + +/** Which AMRIT client the login came from (research R1). */ +public enum ClientType { + + /** Web UI: {@code Jwttoken} cookie or {@code JwtToken} header. */ + WEB, + + /** Mobile app: {@code Authorization} session key with an {@code okhttp} / {@code java/} agent. */ + MOBILE +} diff --git a/src/main/java/com/iemr/consent/utils/auth/CurrentUser.java b/src/main/java/com/iemr/consent/utils/auth/CurrentUser.java new file mode 100644 index 0000000..baf4b1f --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/auth/CurrentUser.java @@ -0,0 +1,39 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.auth; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a controller parameter of type {@link AuthenticatedUser} that receives the logged-in + * user (FR-005), resolved by {@link CurrentUserArgumentResolver}. + */ +@Documented +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface CurrentUser { +} diff --git a/src/main/java/com/iemr/consent/utils/auth/CurrentUserArgumentResolver.java b/src/main/java/com/iemr/consent/utils/auth/CurrentUserArgumentResolver.java new file mode 100644 index 0000000..d8ac4e8 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/auth/CurrentUserArgumentResolver.java @@ -0,0 +1,59 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.auth; + +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; + +/** + * Supplies the logged-in {@link AuthenticatedUser} to controller parameters annotated + * {@link CurrentUser} (FR-005). The user is a request attribute, not a static + * {@code ThreadLocal}, so nothing leaks between requests (research R2). + */ +@Component +public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class) + && AuthenticatedUser.class.equals(parameter.getParameterType()); + } + + @Override + public AuthenticatedUser resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + Object user = webRequest.getAttribute(AuthenticatedUser.ATTRIBUTE, RequestAttributes.SCOPE_REQUEST); + if (user instanceof AuthenticatedUser authenticatedUser) { + return authenticatedUser; + } + throw new ConsentApiException(ErrorCode.UNAUTHENTICATED); + } +} diff --git a/src/main/java/com/iemr/consent/utils/auth/JwtValidator.java b/src/main/java/com/iemr/consent/utils/auth/JwtValidator.java new file mode 100644 index 0000000..14ab1af --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/auth/JwtValidator.java @@ -0,0 +1,85 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.auth; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.Date; + +import org.springframework.stereotype.Component; + +import com.iemr.consent.config.SecurityProperties; +import com.iemr.consent.utils.exception.ErrorCode; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; + +/** + * Validates an AMRIT web login token exactly as Common-API's {@code JwtUtil#validateToken}: + * HS signature with the shared {@code jwt.secret}, expiry, and the logout denylist. + * + *

Unlike Common-API it tells an expired token ({@code SESSION_EXPIRED}) apart from an + * invalid one ({@code UNAUTHENTICATED}) (clarification 3). The token and parser exception + * messages are never logged (research R13).

+ */ +@Component +public class JwtValidator { + + static final String USER_ID_CLAIM = "userId"; + + private final JwtParser parser; + + private final TokenDenylist tokenDenylist; + + public JwtValidator(SecurityProperties securityProperties, TokenDenylist tokenDenylist, Clock clock) { + byte[] secret = securityProperties.getJwtSecret().getBytes(StandardCharsets.UTF_8); + this.parser = Jwts.parser().verifyWith(Keys.hmacShaKeyFor(secret)) + .clock(() -> Date.from(clock.instant())).build(); + this.tokenDenylist = tokenDenylist; + } + + /** + * @return the web user named by the token + * @throws AuthenticationException {@code SESSION_EXPIRED} if expired, otherwise + * {@code UNAUTHENTICATED} + */ + public AuthenticatedUser validate(String token) { + Claims claims; + try { + claims = parser.parseSignedClaims(token).getPayload(); + } catch (ExpiredJwtException e) { + throw new AuthenticationException(ErrorCode.SESSION_EXPIRED); + } catch (JwtException | IllegalArgumentException e) { + throw new AuthenticationException(ErrorCode.UNAUTHENTICATED); + } + String jti = claims.getId(); + if (jti != null && tokenDenylist.isDenylisted(jti)) { + throw new AuthenticationException(ErrorCode.UNAUTHENTICATED); + } + return AuthenticatedUser.of(claims.get(USER_ID_CLAIM), claims.getSubject(), ClientType.WEB); + } +} diff --git a/src/main/java/com/iemr/consent/utils/auth/PublicPaths.java b/src/main/java/com/iemr/consent/utils/auth/PublicPaths.java new file mode 100644 index 0000000..90c8b48 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/auth/PublicPaths.java @@ -0,0 +1,57 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.auth; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * The single list of paths reachable without login (FR-003): {@code /health}, + * {@code /version}, and the API docs only when {@code springdoc.api-docs.enabled=true}. + * Everything else, including unknown paths and {@code /error}, needs a login (research R12). + */ +@Component +public class PublicPaths { + + private final boolean docsEnabled; + + public PublicPaths(@Value("${springdoc.api-docs.enabled:false}") boolean docsEnabled) { + this.docsEnabled = docsEnabled; + } + + /** {@code true} if the request path (without the context path) is public. */ + public boolean isPublic(HttpServletRequest request) { + String path = request.getRequestURI().substring(request.getContextPath().length()); + if ("/health".equals(path) || "/version".equals(path)) { + return true; + } + return docsEnabled && isDocsPath(path); + } + + private static boolean isDocsPath(String path) { + return "/v3/api-docs".equals(path) || path.startsWith("/v3/api-docs/") + || "/swagger-ui.html".equals(path) || path.startsWith("/swagger-ui/"); + } +} diff --git a/src/main/java/com/iemr/consent/utils/auth/SessionValidator.java b/src/main/java/com/iemr/consent/utils/auth/SessionValidator.java new file mode 100644 index 0000000..e44c6ab --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/auth/SessionValidator.java @@ -0,0 +1,104 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.auth; + +import java.time.Duration; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iemr.consent.config.SecurityProperties; +import com.iemr.consent.utils.exception.ErrorCode; + +/** + * Checks the Redis login session named by the {@code Authorization} header, as Common-API's + * {@code HTTPRequestInterceptor} / {@code Validator#checkKeyExists} do. + * + *

The key is the header value (a {@code Bearer } prefix is removed). A missing or unreadable + * session, or a login from another IP when {@code enableIPValidation=true}, is + * {@code SESSION_EXPIRED} (research R1). A found session's lifetime is extended by + * {@code iemr.session.expiry.time} when {@code iemr.extend.expiry.time=true}.

+ */ +@Component +public class SessionValidator { + + private static final String BEARER = "Bearer "; + + private final StringRedisTemplate redisTemplate; + + private final SecurityProperties securityProperties; + + private final ObjectMapper objectMapper; + + public SessionValidator(StringRedisTemplate redisTemplate, SecurityProperties securityProperties, + ObjectMapper objectMapper) { + this.redisTemplate = redisTemplate; + this.securityProperties = securityProperties; + this.objectMapper = objectMapper; + } + + /** + * @param authorization value of the {@code Authorization} header + * @param clientIp {@code X-FORWARDED-FOR}, else the remote address + * @param clientType client type for the returned user + * @return the user stored in the session ({@code userID}, {@code userName}) + */ + public AuthenticatedUser validate(String authorization, String clientIp, ClientType clientType) { + String key = authorization.startsWith(BEARER) ? authorization.substring(BEARER.length()) + : authorization; + key = key.trim(); + if (key.isEmpty()) { + throw new AuthenticationException(ErrorCode.SESSION_EXPIRED); + } + String sessionJson = redisTemplate.opsForValue().get(key); + if (sessionJson == null || sessionJson.isBlank()) { + throw new AuthenticationException(ErrorCode.SESSION_EXPIRED); + } + JsonNode session = parse(sessionJson); + if (securityProperties.isIpValidationEnabled() + && !session.path("loginIPAddress").asText("").equals(clientIp)) { + throw new AuthenticationException(ErrorCode.SESSION_EXPIRED); + } + if (securityProperties.isExtendSessionExpiry()) { + redisTemplate.expire(key, Duration.ofSeconds(securityProperties.getSessionExpirySeconds())); + } + JsonNode userName = session.get("userName"); + return AuthenticatedUser.of(session.path("userID").asText(""), + userName == null || userName.isNull() ? null : userName.asText(), clientType); + } + + private JsonNode parse(String sessionJson) { + try { + JsonNode node = objectMapper.readTree(sessionJson); + if (!node.isObject()) { + throw new AuthenticationException(ErrorCode.SESSION_EXPIRED); + } + return node; + } catch (JsonProcessingException e) { + throw new AuthenticationException(ErrorCode.SESSION_EXPIRED); + } + } +} diff --git a/src/main/java/com/iemr/consent/utils/auth/TokenDenylist.java b/src/main/java/com/iemr/consent/utils/auth/TokenDenylist.java new file mode 100644 index 0000000..d9600a8 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/auth/TokenDenylist.java @@ -0,0 +1,55 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.auth; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +/** + * Reads the logout denylist that Common-API writes at logout ({@code denied_}). + * + *

Mirrors Common-API's {@code TokenDenylist#isTokenDenylisted}, using the same key format and + * the same {@code RedisTemplate} key serialisation (see {@code RedisConfig}). Unlike Common-API + * it does not fail open: a Redis error propagates, and the authentication + * filter answers 503 {@code DEPENDENCY_UNAVAILABLE}. Treating the token as "not logged out" would + * let a logged-out token reach consent records (plan deviation 4).

+ */ +@Component +public class TokenDenylist { + + static final String PREFIX = "denied_"; + + private final RedisTemplate redisTemplate; + + public TokenDenylist(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + /** {@code true} if the token with this {@code jti} was logged out; a blank jti is never denylisted. */ + public boolean isDenylisted(String jti) { + if (jti == null || jti.isBlank()) { + return false; + } + return Boolean.TRUE.equals(redisTemplate.hasKey(PREFIX + jti)); + } +} diff --git a/src/main/java/com/iemr/consent/utils/cors/OriginFilter.java b/src/main/java/com/iemr/consent/utils/cors/OriginFilter.java new file mode 100644 index 0000000..5640df9 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/cors/OriginFilter.java @@ -0,0 +1,95 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.cors; + +import java.io.IOException; + +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.iemr.consent.utils.exception.ErrorCode; +import com.iemr.consent.utils.exception.ErrorResponseWriter; +import com.iemr.consent.utils.http.TraceIdFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Refuses browser requests from origins not on the allow-list and answers allowed + * pre-flights (FR-004). + * + *

Mirrors steps 1–4 of Common-API's {@code JwtUserIdValidationFilter}:

+ *
    + *
  • {@code OPTIONS} needs an allowed {@code Origin}; otherwise 403.
  • + *
  • Any other request carrying a disallowed {@code Origin} gets 403.
  • + *
  • Requests without {@code Origin} (servers, mobile apps) pass.
  • + *
+ *

It runs before the public-path bypass, so {@code /health} is origin-checked too. Refusals + * use the standard body ({@code ORIGIN_NOT_ALLOWED}) instead of {@code sendError}.

+ */ +@Component +public class OriginFilter extends OncePerRequestFilter { + + static final String ALLOW_METHODS = "GET, POST, PUT, PATCH, DELETE, OPTIONS"; + + static final String ALLOW_HEADERS = "Authorization, Content-Type, Accept, Jwttoken, serverAuthorization," + + " ServerAuthorization, serverauthorization, Serverauthorization, Idempotency-Key"; + + private static final String OPTIONS = "OPTIONS"; + + private final OriginPolicy originPolicy; + + private final ErrorResponseWriter errorResponseWriter; + + public OriginFilter(OriginPolicy originPolicy, ErrorResponseWriter errorResponseWriter) { + this.originPolicy = originPolicy; + this.errorResponseWriter = errorResponseWriter; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String origin = request.getHeader(HttpHeaders.ORIGIN); + boolean preflight = OPTIONS.equalsIgnoreCase(request.getMethod()); + if ((preflight || origin != null) && !originPolicy.isAllowed(origin)) { + errorResponseWriter.write(request, response, ErrorCode.ORIGIN_NOT_ALLOWED, null); + return; + } + if (origin != null) { + response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin); + response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, ALLOW_METHODS); + response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, ALLOW_HEADERS); + response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); + response.setHeader(HttpHeaders.ACCESS_CONTROL_MAX_AGE, "3600"); + response.setHeader(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, TraceIdFilter.HEADER); + } + if (preflight) { + response.setStatus(HttpServletResponse.SC_OK); + return; + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/iemr/consent/utils/cors/OriginPolicy.java b/src/main/java/com/iemr/consent/utils/cors/OriginPolicy.java new file mode 100644 index 0000000..48c7118 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/cors/OriginPolicy.java @@ -0,0 +1,68 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.cors; + +import java.util.List; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Component; + +import com.iemr.consent.config.SecurityProperties; + +/** + * Decides whether a browser origin is on {@code cors.allowed-origins} (FR-004, research R3). + * + *

Mirrors Common-API's {@code JwtUserIdValidationFilter#isOriginAllowed}: comma-separated + * patterns, {@code *} as a wildcard, and an empty list allows nothing (fail closed). Unlike + * Common-API, every literal part is quoted with {@link Pattern#quote}, so characters such as + * {@code +}, {@code ?} or {@code (} in a configured origin cannot change the match.

+ */ +@Component +public class OriginPolicy { + + private final List patterns; + + public OriginPolicy(SecurityProperties securityProperties) { + this.patterns = securityProperties.getAllowedOrigins().stream().map(OriginPolicy::toPattern).toList(); + } + + /** {@code true} only if {@code origin} is non-null and matches a configured pattern. */ + public boolean isAllowed(String origin) { + if (origin == null) { + return false; + } + return patterns.stream().anyMatch(pattern -> pattern.matcher(origin).matches()); + } + + private static Pattern toPattern(String configured) { + String[] literals = configured.split("\\*", -1); + StringBuilder regex = new StringBuilder(); + for (int i = 0; i < literals.length; i++) { + if (i > 0) { + regex.append(".*"); + } + regex.append(Pattern.quote(literals[i])); + } + return Pattern.compile(regex.toString()); + } +} diff --git a/src/main/java/com/iemr/consent/utils/crypto/FieldEncryptor.java b/src/main/java/com/iemr/consent/utils/crypto/FieldEncryptor.java new file mode 100644 index 0000000..caea7f9 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/crypto/FieldEncryptor.java @@ -0,0 +1,114 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.crypto; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.stereotype.Component; + +import com.iemr.consent.config.ConsentProperties; + +/** + * The constitution's "one shared, configurable encryption component" (research R3): AES-256-GCM + * with a random 12-byte IV per value and a 128-bit tag. + * + *

Used for personal fields in {@code cm_} tables (guardian and witness names and phones), the + * phone held in an OTP challenge, and evidence files. Strings are stored as Base64 of + * IV‖ciphertext; bytes as IV‖ciphertext. The key is {@code consent.encryption.key} (Base64, + * 32 bytes); a missing or wrong-length key stops start-up. No value ever appears in an + * exception message.

+ */ +@Component +public class FieldEncryptor { + + static final String KEY_SETTING = "Missing required setting: consent.encryption.key"; + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + + private static final int IV_BYTES = 12; + + private static final int TAG_BITS = 128; + + private static final int KEY_BYTES = 32; + + private final SecretKey key; + + private final SecureRandom random = new SecureRandom(); + + public FieldEncryptor(ConsentProperties properties) { + this.key = new SecretKeySpec(decodeKey(properties.encryption().key()), "AES"); + } + + public String encrypt(String plain) { + return Base64.getEncoder().encodeToString(encrypt(plain.getBytes(StandardCharsets.UTF_8))); + } + + public String decrypt(String encrypted) { + return new String(decrypt(Base64.getDecoder().decode(encrypted)), StandardCharsets.UTF_8); + } + + public byte[] encrypt(byte[] plain) { + byte[] iv = new byte[IV_BYTES]; + random.nextBytes(iv); + try { + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + byte[] cipherText = cipher.doFinal(plain); + return ByteBuffer.allocate(iv.length + cipherText.length).put(iv).put(cipherText).array(); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Encryption failed", e); + } + } + + public byte[] decrypt(byte[] encrypted) { + try { + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, encrypted, 0, IV_BYTES)); + return cipher.doFinal(encrypted, IV_BYTES, encrypted.length - IV_BYTES); + } catch (GeneralSecurityException | IllegalArgumentException e) { + throw new IllegalStateException("Decryption failed"); + } + } + + private static byte[] decodeKey(String encoded) { + byte[] bytes; + try { + bytes = Base64.getDecoder().decode(encoded); + } catch (IllegalArgumentException e) { + throw new IllegalStateException(KEY_SETTING); + } + if (bytes.length != KEY_BYTES) { + throw new IllegalStateException(KEY_SETTING); + } + return bytes; + } +} diff --git a/src/main/java/com/iemr/consent/utils/crypto/PhoneMasker.java b/src/main/java/com/iemr/consent/utils/crypto/PhoneMasker.java new file mode 100644 index 0000000..257df0e --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/crypto/PhoneMasker.java @@ -0,0 +1,39 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.crypto; + +/** Masks a phone number for display: {@code 9876543210} → {@code ******3210} (constitution IX). */ +public final class PhoneMasker { + + private static final int VISIBLE = 4; + + private PhoneMasker() { + } + + public static String mask(String phone) { + if (phone == null || phone.length() <= VISIBLE) { + throw new IllegalArgumentException("phone too short to mask"); + } + return "*".repeat(phone.length() - VISIBLE) + phone.substring(phone.length() - VISIBLE); + } +} diff --git a/src/main/java/com/iemr/consent/utils/crypto/Sha256.java b/src/main/java/com/iemr/consent/utils/crypto/Sha256.java new file mode 100644 index 0000000..5a88608 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/crypto/Sha256.java @@ -0,0 +1,47 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.crypto; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** SHA-256 as lowercase hex: evidence fingerprints, phone hashes, request hashes, audit chain. */ +public final class Sha256 { + + private Sha256() { + } + + public static String hex(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + public static String hex(String text) { + return hex(text.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/com/iemr/consent/utils/exception/ConsentApiException.java b/src/main/java/com/iemr/consent/utils/exception/ConsentApiException.java new file mode 100644 index 0000000..40f275e --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/exception/ConsentApiException.java @@ -0,0 +1,65 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.exception; + +import java.util.List; + +import com.iemr.consent.utils.response.FieldErrorDetail; + +import lombok.Getter; + +/** + * Typed base exception: every expected failure is thrown as this (or a subclass) and turned + * into the standard error body by {@link GlobalExceptionHandler}. Controllers never build error + * responses themselves (FR-010). + */ +@Getter +public class ConsentApiException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final ErrorCode errorCode; + + private final String purposeCode; + + private final transient List details; + + public ConsentApiException(ErrorCode errorCode) { + this(errorCode, null, null, null); + } + + /** + * @param safeMessage message shown to the caller; must not contain internal or personal data + */ + public ConsentApiException(ErrorCode errorCode, String safeMessage) { + this(errorCode, safeMessage, null, null); + } + + public ConsentApiException(ErrorCode errorCode, String safeMessage, String purposeCode, + List details) { + super(safeMessage != null ? safeMessage : errorCode.getDefaultMessage()); + this.errorCode = errorCode; + this.purposeCode = purposeCode; + this.details = details; + } +} diff --git a/src/main/java/com/iemr/consent/utils/exception/ConsentDeniedException.java b/src/main/java/com/iemr/consent/utils/exception/ConsentDeniedException.java new file mode 100644 index 0000000..397c1be --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/exception/ConsentDeniedException.java @@ -0,0 +1,54 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.exception; + +/** + * The one shared way to refuse an action for lack of consent (FR-009): HTTP 403 with one of + * the six consent reason codes and the purpose code. + */ +public class ConsentDeniedException extends ConsentApiException { + + private static final long serialVersionUID = 1L; + + /** + * @param reason a consent reason code ({@link ErrorCode#isConsentReason()}) + * @param purposeCode the purpose that lacks consent, e.g. {@code P-SHR}; required + */ + public ConsentDeniedException(ErrorCode reason, String purposeCode) { + super(requireConsentReason(reason), null, requirePurpose(purposeCode), null); + } + + private static ErrorCode requireConsentReason(ErrorCode reason) { + if (reason == null || !reason.isConsentReason()) { + throw new IllegalArgumentException("reason must be a consent reason code"); + } + return reason; + } + + private static String requirePurpose(String purposeCode) { + if (purposeCode == null || purposeCode.isBlank()) { + throw new IllegalArgumentException("purposeCode is required"); + } + return purposeCode; + } +} diff --git a/src/main/java/com/iemr/consent/utils/exception/ConsentErrorController.java b/src/main/java/com/iemr/consent/utils/exception/ConsentErrorController.java new file mode 100644 index 0000000..00efc30 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/exception/ConsentErrorController.java @@ -0,0 +1,83 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.exception; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.iemr.consent.utils.response.ApiResponse; + +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.servlet.RequestDispatcher; +import jakarta.servlet.http.HttpServletRequest; + +/** + * Replaces Spring Boot's {@code BasicErrorController} so that container-level errors, including + * exceptions that escape a filter (which {@link GlobalExceptionHandler} never sees), still return + * the standard body (FR-006). + * + *

It needs no login check: the filters are registered for {@code REQUEST} dispatch only, so + * the container's forward to {@code /error} is not filtered, and {@code /error} is not a public + * path. A direct request to {@code /error} is refused with 401 by the authentication filter.

+ */ +@Hidden +@RestController +public class ConsentErrorController implements ErrorController { + + private static final Logger LOGGER = LoggerFactory.getLogger(ConsentErrorController.class); + + private final ErrorResponseWriter writer; + + public ConsentErrorController(ErrorResponseWriter writer) { + this.writer = writer; + } + + @RequestMapping("/error") + public ResponseEntity> error(HttpServletRequest request) { + Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE); + ErrorCode code = toErrorCode(status instanceof Integer value ? value : 500); + Object exception = request.getAttribute(RequestDispatcher.ERROR_EXCEPTION); + if (exception != null) { + LOGGER.error("Unhandled error ({})", exception.getClass().getName()); + } + return writer.toResponseEntity(request, code, null, null, null); + } + + static ErrorCode toErrorCode(int status) { + return switch (status) { + case 404 -> ErrorCode.NOT_FOUND; + case 405 -> ErrorCode.METHOD_NOT_ALLOWED; + case 413 -> ErrorCode.PAYLOAD_TOO_LARGE; + case 415 -> ErrorCode.UNSUPPORTED_MEDIA_TYPE; + default -> isClientError(status) ? ErrorCode.MALFORMED_REQUEST : ErrorCode.INTERNAL_ERROR; + }; + } + + private static boolean isClientError(int status) { + return status >= 400 && status < 500; + } +} diff --git a/src/main/java/com/iemr/consent/utils/exception/ErrorCode.java b/src/main/java/com/iemr/consent/utils/exception/ErrorCode.java new file mode 100644 index 0000000..cd920be --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/exception/ErrorCode.java @@ -0,0 +1,78 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +/** + * Central list of machine-readable error codes returned in {@code error.code}. + * + *

Central list, additive only — never rename or reuse a code (FR-008). Clients act on + * the code, never on the message text. Each code fixes its HTTP status, so the body + * {@code statusCode} always equals the transport status (constitution API Response + * Standard).

+ */ +@Getter +public enum ErrorCode { + + VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "One or more fields are invalid", false), + MALFORMED_REQUEST(HttpStatus.BAD_REQUEST, "The request could not be read", false), + UNAUTHENTICATED(HttpStatus.UNAUTHORIZED, "Login required", false), + SESSION_EXPIRED(HttpStatus.UNAUTHORIZED, "Session expired. Please login again", false), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "You are not permitted to perform this action", false), + ORIGIN_NOT_ALLOWED(HttpStatus.FORBIDDEN, "Origin not allowed", false), + CONSENT_NOT_FOUND(HttpStatus.FORBIDDEN, "No consent is recorded for this purpose", true), + CONSENT_DENIED(HttpStatus.FORBIDDEN, "Consent was refused for this purpose", true), + CONSENT_WITHDRAWN(HttpStatus.FORBIDDEN, "Consent was withdrawn for this purpose", true), + CONSENT_EXPIRED(HttpStatus.FORBIDDEN, "Consent has expired for this purpose", true), + RECONSENT_REQUIRED(HttpStatus.FORBIDDEN, "Consent must be taken again for this purpose", true), + GUARDIAN_REQUIRED(HttpStatus.FORBIDDEN, "Guardian consent is required for this purpose", true), + NOT_FOUND(HttpStatus.NOT_FOUND, "The requested resource was not found", false), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "HTTP method not allowed for this resource", false), + STALE_VERSION(HttpStatus.CONFLICT, "The record was changed by someone else. Reload and try again", false), + STATE_CONFLICT(HttpStatus.CONFLICT, "The request conflicts with the current state of the record", false), + PAYLOAD_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "The request body is too large", false), + UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "Unsupported content type", false), + BUSINESS_RULE_VIOLATION(HttpStatus.UNPROCESSABLE_ENTITY, "The request breaks a business rule", false), + RATE_LIMITED(HttpStatus.TOO_MANY_REQUESTS, "Too many requests. Try again later", false), + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred", false), + DEPENDENCY_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "Service unavailable", false), + OTP_EXPIRED(HttpStatus.UNPROCESSABLE_ENTITY, "The OTP has expired. Send a new one", false), + OTP_INVALID(HttpStatus.UNPROCESSABLE_ENTITY, "The OTP was not verified for this consent", false), + EVIDENCE_HASH_MISMATCH(HttpStatus.UNPROCESSABLE_ENTITY, "The file does not match its fingerprint", false), + PURPOSE_NOT_WITHDRAWABLE(HttpStatus.UNPROCESSABLE_ENTITY, "This purpose cannot be withdrawn", false); + + private final HttpStatus status; + + private final String defaultMessage; + + private final boolean consentReason; + + ErrorCode(HttpStatus status, String defaultMessage, boolean consentReason) { + this.status = status; + this.defaultMessage = defaultMessage; + this.consentReason = consentReason; + } +} diff --git a/src/main/java/com/iemr/consent/utils/exception/ErrorResponseWriter.java b/src/main/java/com/iemr/consent/utils/exception/ErrorResponseWriter.java new file mode 100644 index 0000000..13fe2a2 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/exception/ErrorResponseWriter.java @@ -0,0 +1,94 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.exception; + +import java.io.IOException; +import java.time.Clock; +import java.util.List; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iemr.consent.config.ConsentProperties; +import com.iemr.consent.utils.http.TraceIdFilter; +import com.iemr.consent.utils.response.ApiResponse; +import com.iemr.consent.utils.response.FieldErrorDetail; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Builds every error response, so filters, {@link GlobalExceptionHandler} and + * {@link ConsentErrorController} all produce the same standard body (FR-006). + * + *

Filters run before Spring MVC and cannot reach {@code @RestControllerAdvice}; they call + * {@link #write}. It serialises with the application's {@link ObjectMapper} and never uses + * {@code sendError}, which would produce a container error page.

+ */ +@Component +public class ErrorResponseWriter { + + /** Request attribute holding the error code of the response, read by the request log. */ + public static final String ERROR_CODE_ATTRIBUTE = "consent.errorCode"; + + private final ObjectMapper objectMapper; + + private final Clock clock; + + private final ConsentProperties properties; + + public ErrorResponseWriter(ObjectMapper objectMapper, Clock clock, ConsentProperties properties) { + this.objectMapper = objectMapper; + this.clock = clock; + this.properties = properties; + } + + /** Writes a refusal from a filter, with the code's default message. */ + public void write(HttpServletRequest request, HttpServletResponse response, ErrorCode code, + String purposeCode) throws IOException { + ResponseEntity> entity = toResponseEntity(request, code, null, purposeCode, null); + response.setStatus(entity.getStatusCode().value()); + entity.getHeaders().forEach((name, values) -> values.forEach(value -> response.addHeader(name, value))); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getOutputStream(), entity.getBody()); + } + + /** Builds the standard error response for a handler or controller. */ + public ResponseEntity> toResponseEntity(HttpServletRequest request, ErrorCode code, + String message, String purposeCode, List details) { + request.setAttribute(ERROR_CODE_ATTRIBUTE, code); + ApiResponse body = ApiResponse.error(code, message, purposeCode, details, + TraceIdFilter.currentTraceId(request), clock); + ResponseEntity.BodyBuilder builder = ResponseEntity.status(code.getStatus()) + .contentType(MediaType.APPLICATION_JSON); + if (code == ErrorCode.RATE_LIMITED) { + long retryAfter = properties.rateLimit().retryAfterSeconds(); + builder.header(HttpHeaders.RETRY_AFTER, String.valueOf(retryAfter)); + } + return builder.body(body); + } +} diff --git a/src/main/java/com/iemr/consent/utils/exception/GlobalExceptionHandler.java b/src/main/java/com/iemr/consent/utils/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..4b0025e --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/exception/GlobalExceptionHandler.java @@ -0,0 +1,193 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.exception; + +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.jdbc.CannotGetJdbcConnectionException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.multipart.MultipartException; +import org.springframework.web.multipart.support.MissingServletRequestPartException; +import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import com.iemr.consent.utils.http.PayloadTooLargeException; +import com.iemr.consent.utils.response.ApiResponse; +import com.iemr.consent.utils.response.FieldErrorDetail; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; + +/** + * Turns every exception from a controller into the standard error body with the right HTTP + * status and {@link ErrorCode} (FR-006 – FR-011, research R4). + * + *

Mirrors Common-API's {@code utils/exception/GlobalExceptionHandler}, extended to the full + * status table of the constitution's API Response Standard. Rejected values, exception messages + * and stack traces are never returned; unexpected errors are logged with the exception class and + * trace id only (the message may hold personal data).

+ */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + private final ErrorResponseWriter writer; + + public GlobalExceptionHandler(ErrorResponseWriter writer) { + this.writer = writer; + } + + @ExceptionHandler(ConsentApiException.class) + public ResponseEntity> handleConsentApi(ConsentApiException e, HttpServletRequest request) { + return writer.toResponseEntity(request, e.getErrorCode(), e.getMessage(), e.getPurposeCode(), + e.getDetails()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleInvalidBody(MethodArgumentNotValidException e, + HttpServletRequest request) { + List details = new ArrayList<>(); + e.getBindingResult().getFieldErrors().forEach( + error -> details.add(detail(error.getField(), error.getDefaultMessage()))); + e.getBindingResult().getGlobalErrors().forEach( + error -> details.add(detail(error.getObjectName(), error.getDefaultMessage()))); + return validationFailed(request, details); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolation(ConstraintViolationException e, + HttpServletRequest request) { + List details = e.getConstraintViolations().stream() + .map(v -> new FieldErrorDetail(v.getPropertyPath().toString(), v.getMessage())) + .toList(); + return validationFailed(request, details); + } + + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity> handleMethodValidation(HandlerMethodValidationException e, + HttpServletRequest request) { + List details = new ArrayList<>(); + e.getAllValidationResults().forEach(result -> { + String parameter = result.getMethodParameter().getParameterName(); + result.getResolvableErrors() + .forEach(error -> details.add(detail(parameter, error.getDefaultMessage()))); + }); + return validationFailed(request, details); + } + + @ExceptionHandler({ MissingServletRequestParameterException.class, + MethodArgumentTypeMismatchException.class }) + public ResponseEntity> handleMalformed(Exception e, HttpServletRequest request) { + return writer.toResponseEntity(request, ErrorCode.MALFORMED_REQUEST, null, null, null); + } + + /** Unreadable body; a body cut off by the size limit is 413, not 400. */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleUnreadable(HttpMessageNotReadableException e, + HttpServletRequest request) { + ErrorCode code = hasCause(e, PayloadTooLargeException.class) ? ErrorCode.PAYLOAD_TOO_LARGE + : ErrorCode.MALFORMED_REQUEST; + return writer.toResponseEntity(request, code, null, null, null); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleMethod(HttpRequestMethodNotSupportedException e, + HttpServletRequest request) { + return writer.toResponseEntity(request, ErrorCode.METHOD_NOT_ALLOWED, null, null, null); + } + + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity> handleMediaType(HttpMediaTypeNotSupportedException e, + HttpServletRequest request) { + return writer.toResponseEntity(request, ErrorCode.UNSUPPORTED_MEDIA_TYPE, null, null, null); + } + + @ExceptionHandler(MissingServletRequestPartException.class) + public ResponseEntity> handleMissingPart(MissingServletRequestPartException e, + HttpServletRequest request) { + return validationFailed(request, List.of(detail(e.getRequestPartName(), "is required"))); + } + + @ExceptionHandler(MultipartException.class) + public ResponseEntity> handleMultipart(MultipartException e, HttpServletRequest request) { + return writer.toResponseEntity(request, ErrorCode.MALFORMED_REQUEST, null, null, null); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity> handleUploadSize(MaxUploadSizeExceededException e, + HttpServletRequest request) { + return writer.toResponseEntity(request, ErrorCode.PAYLOAD_TOO_LARGE, null, null, null); + } + + @ExceptionHandler({ NoResourceFoundException.class, NoHandlerFoundException.class }) + public ResponseEntity> handleNotFound(Exception e, HttpServletRequest request) { + return writer.toResponseEntity(request, ErrorCode.NOT_FOUND, null, null, null); + } + + @ExceptionHandler({ DataAccessResourceFailureException.class, CannotGetJdbcConnectionException.class, + RedisConnectionFailureException.class }) + public ResponseEntity> handleDependency(Exception e, HttpServletRequest request) { + LOGGER.warn("Dependency unavailable ({})", e.getClass().getSimpleName()); + return writer.toResponseEntity(request, ErrorCode.DEPENDENCY_UNAVAILABLE, null, null, null); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleUnexpected(Exception e, HttpServletRequest request) { + LOGGER.error("Unexpected error ({})", e.getClass().getName()); + return writer.toResponseEntity(request, ErrorCode.INTERNAL_ERROR, null, null, null); + } + + private ResponseEntity> validationFailed(HttpServletRequest request, + List details) { + return writer.toResponseEntity(request, ErrorCode.VALIDATION_FAILED, null, null, details); + } + + private static FieldErrorDetail detail(String field, String message) { + return new FieldErrorDetail(field, message); + } + + private static boolean hasCause(Throwable error, Class type) { + for (Throwable cause = error; cause != null; cause = cause.getCause()) { + if (type.isInstance(cause)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/iemr/consent/utils/http/AfterCommit.java b/src/main/java/com/iemr/consent/utils/http/AfterCommit.java new file mode 100644 index 0000000..f758103 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/http/AfterCommit.java @@ -0,0 +1,46 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.http; + +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** Runs an action after the current transaction commits, or at once when there is none. */ +public final class AfterCommit { + + private AfterCommit() { + } + + public static void run(Runnable action) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + action.run(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + action.run(); + } + }); + } +} diff --git a/src/main/java/com/iemr/consent/utils/http/ClientIp.java b/src/main/java/com/iemr/consent/utils/http/ClientIp.java new file mode 100644 index 0000000..c1843de --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/http/ClientIp.java @@ -0,0 +1,37 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.http; + +import jakarta.servlet.http.HttpServletRequest; + +/** The caller's IP as Common-API reads it: {@code X-FORWARDED-FOR}, else the remote address. */ +public final class ClientIp { + + private ClientIp() { + } + + public static String of(HttpServletRequest request) { + String forwarded = request.getHeader("X-FORWARDED-FOR"); + return forwarded != null ? forwarded : request.getRemoteAddr(); + } +} diff --git a/src/main/java/com/iemr/consent/utils/http/ForwardedAuth.java b/src/main/java/com/iemr/consent/utils/http/ForwardedAuth.java new file mode 100644 index 0000000..fa6cde9 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/http/ForwardedAuth.java @@ -0,0 +1,74 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.http; + +import org.springframework.http.HttpHeaders; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; + +/** + * The caller's own AMRIT login, forwarded on server-to-server calls to Common-API so Common-API + * applies its usual login checks (research R11). Never logged. + * + * @param jwt {@code Jwttoken} cookie or {@code JwtToken} header, or {@code null} + * @param authorization {@code Authorization} header, or {@code null} + * @param userAgent {@code User-Agent}, needed by Common-API for mobile logins + */ +public record ForwardedAuth(String jwt, String authorization, String userAgent) { + + public static ForwardedAuth from(HttpServletRequest request) { + String jwt = null; + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if ("Jwttoken".equalsIgnoreCase(cookie.getName())) { + jwt = cookie.getValue(); + } + } + } + if (jwt == null) { + jwt = request.getHeader("JwtToken"); + } + return new ForwardedAuth(jwt, request.getHeader(HttpHeaders.AUTHORIZATION), + request.getHeader(HttpHeaders.USER_AGENT)); + } + + /** Copies the non-null values onto outgoing headers. */ + public void applyTo(HttpHeaders headers) { + if (jwt != null) { + headers.set("JwtToken", jwt); + } + if (authorization != null) { + headers.set(HttpHeaders.AUTHORIZATION, authorization); + } + if (userAgent != null) { + headers.set(HttpHeaders.USER_AGENT, userAgent); + } + } + + @Override + public String toString() { + return "ForwardedAuth[hidden]"; + } +} diff --git a/src/main/java/com/iemr/consent/utils/http/PayloadTooLargeException.java b/src/main/java/com/iemr/consent/utils/http/PayloadTooLargeException.java new file mode 100644 index 0000000..ecf9183 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/http/PayloadTooLargeException.java @@ -0,0 +1,39 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.http; + +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; + +/** + * Thrown while reading a request body that grows past {@code consent.request.max-body-bytes}; + * rendered as 413 {@code PAYLOAD_TOO_LARGE}. + */ +public class PayloadTooLargeException extends ConsentApiException { + + private static final long serialVersionUID = 1L; + + public PayloadTooLargeException() { + super(ErrorCode.PAYLOAD_TOO_LARGE); + } +} diff --git a/src/main/java/com/iemr/consent/utils/http/RequestLoggingFilter.java b/src/main/java/com/iemr/consent/utils/http/RequestLoggingFilter.java new file mode 100644 index 0000000..7399dc1 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/http/RequestLoggingFilter.java @@ -0,0 +1,69 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.http; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.exception.ErrorResponseWriter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Writes one summary line per request: method, path, status, error code, trace id and user id + * (FR-020, research R13). + * + *

It wraps the origin, size and authentication filters, so their refusals are logged too. + * It never logs the query string, headers, cookies or body, which may hold tokens or personal + * data.

+ */ +@Component +public class RequestLoggingFilter extends OncePerRequestFilter { + + private static final Logger LOGGER = LoggerFactory.getLogger(RequestLoggingFilter.class); + + private static final String NONE = "-"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } finally { + Object code = request.getAttribute(ErrorResponseWriter.ERROR_CODE_ATTRIBUTE); + Object user = request.getAttribute(AuthenticatedUser.ATTRIBUTE); + Object userId = user instanceof AuthenticatedUser authenticated ? authenticated.userId() : NONE; + LOGGER.info("{} {} status={} errorCode={} traceId={} userId={}", request.getMethod(), + request.getRequestURI(), response.getStatus(), code != null ? code : NONE, + TraceIdFilter.currentTraceId(request), userId); + } + } +} diff --git a/src/main/java/com/iemr/consent/utils/http/RequestSizeLimitFilter.java b/src/main/java/com/iemr/consent/utils/http/RequestSizeLimitFilter.java new file mode 100644 index 0000000..9e8ee3c --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/http/RequestSizeLimitFilter.java @@ -0,0 +1,173 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.http; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.iemr.consent.config.ConsentProperties; +import com.iemr.consent.utils.exception.ErrorCode; +import com.iemr.consent.utils.exception.ErrorResponseWriter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Refuses request bodies larger than {@code consent.request.max-body-bytes} with 413 + * {@code PAYLOAD_TOO_LARGE} (research R6). + * + *

A declared {@code Content-Length} over the limit is refused at once. Otherwise the body + * stream is wrapped and counted, so a chunked body that grows past the limit throws + * {@link PayloadTooLargeException} while it is read.

+ */ +@Component +public class RequestSizeLimitFilter extends OncePerRequestFilter { + + static final String EVIDENCE_PATH = "/consent/v1/evidence"; + + static final long MULTIPART_ENVELOPE_BYTES = 65536; + + private final ConsentProperties properties; + + private final ErrorResponseWriter errorResponseWriter; + + public RequestSizeLimitFilter(ConsentProperties properties, ErrorResponseWriter errorResponseWriter) { + this.properties = properties; + this.errorResponseWriter = errorResponseWriter; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + long limit = limitFor(request); + if (request.getContentLengthLong() > limit) { + errorResponseWriter.write(request, response, ErrorCode.PAYLOAD_TOO_LARGE, null); + return; + } + filterChain.doFilter(new LimitedRequest(request, limit), response); + } + + /** + * The evidence upload has its own, larger limit ({@code consent.evidence.max-bytes} plus room for + * the multipart envelope); every other request uses {@code consent.request.max-body-bytes}. + */ + private long limitFor(HttpServletRequest request) { + String path = request.getRequestURI().substring(request.getContextPath().length()); + return EVIDENCE_PATH.equals(path) ? properties.evidence().maxBytes() + MULTIPART_ENVELOPE_BYTES + : properties.request().maxBodyBytes(); + } + + /** Request whose body stream throws once more than {@code limit} bytes are read. */ + static final class LimitedRequest extends HttpServletRequestWrapper { + + private final long limit; + + private ServletInputStream stream; + + LimitedRequest(HttpServletRequest request, long limit) { + super(request); + this.limit = limit; + } + + @Override + public ServletInputStream getInputStream() throws IOException { + if (stream == null) { + stream = new LimitedInputStream(super.getInputStream(), limit); + } + return stream; + } + + @Override + public BufferedReader getReader() throws IOException { + String encoding = getCharacterEncoding(); + Charset charset = encoding != null ? Charset.forName(encoding) : StandardCharsets.UTF_8; + return new BufferedReader(new InputStreamReader(getInputStream(), charset)); + } + } + + /** Counting stream; throws {@link PayloadTooLargeException} past the limit. */ + static final class LimitedInputStream extends ServletInputStream { + + private final ServletInputStream delegate; + + private final long limit; + + private long count; + + LimitedInputStream(ServletInputStream delegate, long limit) { + this.delegate = delegate; + this.limit = limit; + } + + @Override + public int read() throws IOException { + int value = delegate.read(); + if (value != -1) { + add(1); + } + return value; + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + int read = delegate.read(buffer, offset, length); + if (read > 0) { + add(read); + } + return read; + } + + @Override + public boolean isFinished() { + return delegate.isFinished(); + } + + @Override + public boolean isReady() { + return delegate.isReady(); + } + + @Override + public void setReadListener(ReadListener readListener) { + delegate.setReadListener(readListener); + } + + private void add(long bytes) { + count += bytes; + if (count > limit) { + throw new PayloadTooLargeException(); + } + } + } +} diff --git a/src/main/java/com/iemr/consent/utils/http/TraceIdFilter.java b/src/main/java/com/iemr/consent/utils/http/TraceIdFilter.java new file mode 100644 index 0000000..9bd16a2 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/http/TraceIdFilter.java @@ -0,0 +1,80 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.http; + +import java.io.IOException; +import java.security.SecureRandom; +import java.util.HexFormat; + +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * First filter: gives every request a new trace id (FR-012, research R5). + * + *

The id is a random 16-hex-character value, set as the {@code X-Trace-Id} response header + * before the chain runs (so refusals carry it too), as a request attribute and as + * the MDC key {@code traceId} for the logs. An incoming {@code X-Trace-Id} is never trusted: + * it could inject text into the logs.

+ */ +@Component +public class TraceIdFilter extends OncePerRequestFilter { + + /** Request attribute and MDC key holding the trace id. */ + public static final String ATTRIBUTE = "traceId"; + + /** Response header carrying the trace id. */ + public static final String HEADER = "X-Trace-Id"; + + private static final int ID_BYTES = 8; + + private final SecureRandom random = new SecureRandom(); + + /** Returns the current request's trace id, or {@code null} if none was assigned. */ + public static String currentTraceId(HttpServletRequest request) { + Object value = request.getAttribute(ATTRIBUTE); + return value instanceof String id ? id : null; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + byte[] bytes = new byte[ID_BYTES]; + random.nextBytes(bytes); + String traceId = HexFormat.of().formatHex(bytes); + request.setAttribute(ATTRIBUTE, traceId); + response.setHeader(HEADER, traceId); + MDC.put(ATTRIBUTE, traceId); + try { + filterChain.doFilter(request, response); + } finally { + MDC.remove(ATTRIBUTE); + } + } +} diff --git a/src/main/java/com/iemr/consent/utils/idempotency/IdempotencyKey.java b/src/main/java/com/iemr/consent/utils/idempotency/IdempotencyKey.java new file mode 100644 index 0000000..7cbb7dc --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/idempotency/IdempotencyKey.java @@ -0,0 +1,58 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.idempotency; + +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; +import com.iemr.consent.utils.response.FieldErrorDetail; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Reads the client request key that every write must carry (FR-017, constitution Consent-API + * constraints, research R4). A missing or non-UUID key is {@code VALIDATION_FAILED}. + */ +public final class IdempotencyKey { + + public static final String HEADER = "Idempotency-Key"; + + private static final Pattern UUID = Pattern + .compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + + private IdempotencyKey() { + } + + /** @return the key, lower-cased */ + public static String require(HttpServletRequest request) { + String key = request.getHeader(HEADER); + if (key == null || !UUID.matcher(key.trim()).matches()) { + throw new ConsentApiException(ErrorCode.VALIDATION_FAILED, null, null, + List.of(new FieldErrorDetail(HEADER, "must be a UUID"))); + } + return key.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/com/iemr/consent/utils/idempotency/RequestHasher.java b/src/main/java/com/iemr/consent/utils/idempotency/RequestHasher.java new file mode 100644 index 0000000..f7c6d17 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/idempotency/RequestHasher.java @@ -0,0 +1,54 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.idempotency; + +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.iemr.consent.utils.crypto.Sha256; + +/** + * SHA-256 of a request's canonical JSON (sorted properties and map keys), stored with the + * request key so a repeat with a different body is detected (research R4). + */ +@Component +public class RequestHasher { + + private final ObjectMapper canonical; + + public RequestHasher(ObjectMapper objectMapper) { + this.canonical = objectMapper.copy().configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + } + + public String hash(Object request) { + try { + return Sha256.hex(canonical.writeValueAsBytes(request)); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Request could not be hashed", e); + } + } +} diff --git a/src/main/java/com/iemr/consent/utils/response/ApiResponse.java b/src/main/java/com/iemr/consent/utils/response/ApiResponse.java new file mode 100644 index 0000000..f7961e7 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/response/ApiResponse.java @@ -0,0 +1,97 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.response; + +import java.time.Clock; +import java.util.List; + +import org.springframework.http.HttpStatus; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.iemr.consent.utils.exception.ErrorCode; + +import lombok.Getter; + +/** + * The single body shape of every Consent-API response (constitution API Response Standard). + * + *

Extends Common-API's {@code utils/response/ApiResponse} ({@code success}, + * {@code statusCode}, {@code message}, {@code data}) with {@code error}, {@code timestamp} and + * {@code traceId}. {@code statusCode} is always derived from the HTTP status used, so it can + * never differ from the transport status.

+ * + * @param type of {@code data} + */ +@Getter +@JsonInclude(JsonInclude.Include.ALWAYS) +@JsonPropertyOrder({ "success", "statusCode", "message", "data", "error", "timestamp", "traceId" }) +public final class ApiResponse { + + private final boolean success; + + private final int statusCode; + + private final String message; + + private final T data; + + private final ErrorBody error; + + private final String timestamp; + + private final String traceId; + + private ApiResponse(HttpStatus status, String message, T data, ErrorBody error, String traceId, + Clock clock) { + this.success = status.is2xxSuccessful(); + this.statusCode = status.value(); + this.message = message; + this.data = data; + this.error = error; + this.timestamp = clock.instant().toString(); + this.traceId = traceId; + } + + /** 200 OK with {@code data}. */ + public static ApiResponse ok(T data, String traceId, Clock clock) { + return new ApiResponse<>(HttpStatus.OK, "OK", data, null, traceId, clock); + } + + /** 201 Created with the new record in {@code data}. */ + public static ApiResponse created(T data, String traceId, Clock clock) { + return new ApiResponse<>(HttpStatus.CREATED, "Created", data, null, traceId, clock); + } + + /** + * Error response; status and code come from {@code code}. + * + * @param message safe override, or {@code null} for the code's default message + */ + public static ApiResponse error(ErrorCode code, String message, String purposeCode, + List details, String traceId, Clock clock) { + String text = message != null ? message : code.getDefaultMessage(); + return new ApiResponse<>(code.getStatus(), text, null, new ErrorBody(code, purposeCode, details), + traceId, clock); + } +} diff --git a/src/main/java/com/iemr/consent/utils/response/ErrorBody.java b/src/main/java/com/iemr/consent/utils/response/ErrorBody.java new file mode 100644 index 0000000..2aafe11 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/response/ErrorBody.java @@ -0,0 +1,39 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.response; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.iemr.consent.utils.exception.ErrorCode; + +/** + * The {@code error} part of an {@link ApiResponse}. + * + * @param code stable machine-readable code + * @param purposeCode purpose of a consent refusal; {@code null} otherwise + * @param details invalid fields for {@code VALIDATION_FAILED}; {@code null} otherwise + */ +@JsonInclude(JsonInclude.Include.ALWAYS) +public record ErrorBody(ErrorCode code, String purposeCode, List details) { +} diff --git a/src/main/java/com/iemr/consent/utils/response/FieldErrorDetail.java b/src/main/java/com/iemr/consent/utils/response/FieldErrorDetail.java new file mode 100644 index 0000000..56adfb4 --- /dev/null +++ b/src/main/java/com/iemr/consent/utils/response/FieldErrorDetail.java @@ -0,0 +1,35 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.utils.response; + +/** + * One invalid input field in a {@code VALIDATION_FAILED} response. + * + *

{@code message} is the Bean Validation message only; the rejected value is never echoed + * back, so a response cannot leak personal data.

+ * + * @param field binding path, e.g. {@code purposes[0].decision} + * @param message safe, human-readable message + */ +public record FieldErrorDetail(String field, String message) { +} diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..36bd6df --- /dev/null +++ b/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.diagnostics.FailureAnalyzer=\ +com.iemr.consent.config.MissingSettingFailureAnalyzer diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..3fd8ab0 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,63 @@ +spring.application.name=consent-api + +# JPA: no schema changes at runtime (constitution Principle VIII, FR-019) +spring.jpa.hibernate.ddl-auto=none +spring.jpa.open-in-view=false +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect +# Column names are used exactly as written in @Column (as in Common-API) +spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl + +# tomcat-jdbc pool (as in Common-API) + bounded waits so /health answers within 2 s +spring.datasource.type=org.apache.tomcat.jdbc.pool.DataSource +spring.datasource.tomcat.initial-size=5 +spring.datasource.tomcat.max-active=30 +spring.datasource.tomcat.max-idle=15 +spring.datasource.tomcat.min-idle=5 +spring.datasource.tomcat.min-evictable-idle-time-millis=15000 +spring.datasource.tomcat.remove-abandoned=true +spring.datasource.tomcat.logAbandoned=true +spring.datasource.tomcat.remove-abandoned-timeout=600 +spring.datasource.tomcat.max-wait=1500 +spring.datasource.tomcat.validation-query-timeout=1 + +# Redis (same keys as Common-API; bound in RedisConfig) +spring.redis.port=6379 +spring.redis.password= + +# Login session (same keys and defaults as Common-API) +iemr.extend.expiry.time=true +iemr.session.expiry.time=7200 +enableIPValidation=false + +# API documentation: off unless the environment turns it on +springdoc.api-docs.enabled=false +springdoc.swagger-ui.enabled=false + +# Consent-API settings +consent.request.max-body-bytes=1048576 +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=10MB +consent.health.check-timeout-ms=1500 +consent.redis.timeout-ms=1500 +consent.rate-limit.retry-after-seconds=60 + +# Feature 002 - consent capture (TODO-DPO values are placeholders until signed off) +consent.evidence.max-bytes=10485760 +consent.evidence.voice-min-seconds=30 +consent.evidence.voice-max-seconds=60 +consent.evidence.allowed-content-types=audio/webm,audio/ogg,audio/mpeg,audio/wav,image/jpeg,image/png +consent.capture.max-skew-minutes=1440 +consent.common-api.timeout-ms=3000 +consent.otp.ttl-seconds=300 +consent.otp.retention-minutes=30 +consent.otp.max-attempts=5 +# Doctor access grants (feature 003). max-days is TODO-LEGAL. +consent.access-grant.max-days=30 +consent.access-grant.replay-hours=24 + +# Every error uses the standard ApiResponse body +spring.mvc.problemdetails.enabled=false +server.error.whitelabel.enabled=false + +logging.level.root=INFO +logging.level.com.iemr=INFO diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..659eb23 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,27 @@ + + + + + + + ${LOG_FILE}.json + + ${LOG_FILE}.json.%d{yyyy-MM-dd}.gz + 7 + + + + + + 512 + 0 + false + + + + + + + diff --git a/src/test/java/com/iemr/consent/CaptureFlowTest.java b/src/test/java/com/iemr/consent/CaptureFlowTest.java new file mode 100644 index 0000000..e64f54c --- /dev/null +++ b/src/test/java/com/iemr/consent/CaptureFlowTest.java @@ -0,0 +1,438 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Clock; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import com.iemr.consent.config.ConsentProperties; +import com.iemr.consent.config.SecurityProperties; +import com.iemr.consent.controller.consent.ConsentController; +import com.iemr.consent.controller.evidence.EvidenceController; +import com.iemr.consent.controller.notice.NoticeController; +import com.iemr.consent.controller.otp.OtpController; +import com.iemr.consent.data.audit.AuditLogEntry; +import com.iemr.consent.data.consent.Consent; +import com.iemr.consent.data.consent.ConsentPurpose; +import com.iemr.consent.data.consent.GuardianLink; +import com.iemr.consent.data.evidence.ConsentEvidence; +import com.iemr.consent.data.notice.NoticeContext; +import com.iemr.consent.data.notice.NoticePurpose; +import com.iemr.consent.data.notice.NoticeStatus; +import com.iemr.consent.model.otp.OtpReason; +import com.iemr.consent.repository.audit.AuditLogRepository; +import com.iemr.consent.repository.consent.ConsentPurposeRepository; +import com.iemr.consent.repository.consent.ConsentRepository; +import com.iemr.consent.repository.consent.GuardianLinkRepository; +import com.iemr.consent.repository.evidence.ConsentEvidenceRepository; +import com.iemr.consent.repository.notice.NoticePurposeRepository; +import com.iemr.consent.repository.notice.NoticeRepository; +import com.iemr.consent.repository.notice.NoticeTranslationRepository; +import com.iemr.consent.repository.notice.PurposeRepository; +import com.iemr.consent.service.audit.AuditLogServiceImpl; +import com.iemr.consent.service.consent.ConsentServiceImpl; +import com.iemr.consent.service.consent.ConsentValidator; +import com.iemr.consent.service.consent.ConsentWriter; +import com.iemr.consent.service.consent.EvidenceProofWriter; +import com.iemr.consent.service.consent.GuardianProofWriter; +import com.iemr.consent.service.consent.GuardianRule; +import com.iemr.consent.service.consent.OtpProofRule; +import com.iemr.consent.service.consent.OtpProofWriter; +import com.iemr.consent.service.consent.ReadAloudProofRule; +import com.iemr.consent.service.consent.SignaturePhotoProofRule; +import com.iemr.consent.service.consent.VoiceClipProofRule; +import com.iemr.consent.service.consent.WitnessedProofRule; +import com.iemr.consent.service.evidence.EvidenceServiceImpl; +import com.iemr.consent.service.evidence.EvidenceStorage; +import com.iemr.consent.service.notice.NoticeServiceImpl; +import com.iemr.consent.service.otp.CommonApiOtpClient; +import com.iemr.consent.service.otp.OtpChallenge; +import com.iemr.consent.service.otp.OtpChallengeStore; +import com.iemr.consent.service.otp.OtpServiceImpl; +import com.iemr.consent.support.ConsentFixtures; +import com.iemr.consent.support.LogCapture; +import com.iemr.consent.support.TestFixtures; +import com.iemr.consent.support.TestTokens; +import com.iemr.consent.utils.auth.JwtValidator; +import com.iemr.consent.utils.auth.PublicPaths; +import com.iemr.consent.utils.auth.SessionValidator; +import com.iemr.consent.utils.auth.TokenDenylist; +import com.iemr.consent.utils.cors.OriginPolicy; +import com.iemr.consent.utils.crypto.FieldEncryptor; +import com.iemr.consent.utils.crypto.Sha256; +import com.iemr.consent.utils.exception.ErrorResponseWriter; +import com.iemr.consent.utils.idempotency.RequestHasher; + +/** + * The MMU consent dialog end to end (feature 002): real filters, controllers, services, validator, + * rules and writers; repositories, Redis, Common-API and file storage mocked. Named by scenario. + */ +@WebMvcTest(controllers = { NoticeController.class, ConsentController.class, OtpController.class, + EvidenceController.class }, properties = { "jwt.secret=" + TestTokens.SECRET, + "cors.allowed-origins=" + TestFixtures.ALLOWED_ORIGINS, + "consent.evidence.storage-dir=" + TestFixtures.STORAGE_DIR, + "consent.common-api.base-url=" + TestFixtures.COMMON_API_URL, + "consent.encryption.key=" + TestFixtures.ENCRYPTION_KEY }) +@Import({ ErrorResponseWriter.class, OriginPolicy.class, PublicPaths.class, JwtValidator.class, + SessionValidator.class, SecurityProperties.class, RequestHasher.class, FieldEncryptor.class, + NoticeServiceImpl.class, ConsentServiceImpl.class, ConsentValidator.class, ConsentWriter.class, + GuardianRule.class, OtpProofRule.class, VoiceClipProofRule.class, SignaturePhotoProofRule.class, + WitnessedProofRule.class, ReadAloudProofRule.class, EvidenceProofWriter.class, OtpProofWriter.class, + GuardianProofWriter.class, OtpServiceImpl.class, EvidenceServiceImpl.class, AuditLogServiceImpl.class, + CaptureFlowTest.SliceConfig.class }) +class CaptureFlowTest { + + private static final String TOKEN = TestTokens.valid("42", "worker42", "jti-42"); + + @TestConfiguration + @EnableConfigurationProperties(ConsentProperties.class) + static class SliceConfig { + + @Bean + Clock clock() { + return TestFixtures.CLOCK; + } + } + + @RegisterExtension + final LogCapture logs = new LogCapture(); + + @Autowired + private MockMvc mvc; + + @Autowired + private FieldEncryptor encryptor; + + @MockBean + private TokenDenylist tokenDenylist; + + @MockBean + private StringRedisTemplate stringRedisTemplate; + + @MockBean + private NoticeRepository noticeRepository; + + @MockBean + private NoticeTranslationRepository translationRepository; + + @MockBean + private NoticePurposeRepository noticePurposeRepository; + + @MockBean + private PurposeRepository purposeRepository; + + @MockBean + private ConsentRepository consentRepository; + + @MockBean + private ConsentPurposeRepository consentPurposeRepository; + + @MockBean + private GuardianLinkRepository guardianLinkRepository; + + @MockBean + private ConsentEvidenceRepository evidenceRepository; + + @MockBean + private AuditLogRepository auditLogRepository; + + @MockBean + private OtpChallengeStore otpStore; + + @MockBean + private CommonApiOtpClient commonApi; + + @MockBean + private EvidenceStorage evidenceStorage; + + private final Map consents = new HashMap<>(); + + private final List purposeRows = new ArrayList<>(); + + private final Map evidence = new HashMap<>(); + + @BeforeEach + void setUp() { + var notice = ConsentFixtures.notice(NoticeStatus.PUBLISHED); + when(noticeRepository.findFirstByContextAndStatusAndDeletedFalseOrderByVersionNoDesc( + NoticeContext.REGISTRATION, NoticeStatus.PUBLISHED)).thenReturn(Optional.of(notice)); + when(noticeRepository.findById(ConsentFixtures.NOTICE_ID)).thenReturn(Optional.of(notice)); + when(translationRepository.findByNoticeVersionIdAndDeletedFalseOrderByLanguageCodeAsc(ConsentFixtures.NOTICE_ID)) + .thenReturn(List.of(ConsentFixtures.translation("en", "a.mp3"), ConsentFixtures.translation("hi", null))); + when(translationRepository.findByNoticeVersionIdAndLanguageCodeAndDeletedFalse(ConsentFixtures.NOTICE_ID, + "hi")).thenReturn(Optional.of(ConsentFixtures.translation("hi", null))); + NoticePurpose reg = new NoticePurpose(); + reg.setPurposeCode("P-REG"); + reg.setDisplayRank(1); + NoticePurpose rem = new NoticePurpose(); + rem.setPurposeCode("P-REM"); + rem.setDisplayRank(2); + when(noticePurposeRepository.findByNoticeVersionIdAndDeletedFalseOrderByDisplayRankAsc( + ConsentFixtures.NOTICE_ID)).thenReturn(List.of(reg, rem)); + when(purposeRepository.findByPurposeCodeInAndActiveTrueAndDeletedFalse(any())) + .thenReturn(ConsentFixtures.purposes()); + AuditLogEntry genesis = new AuditLogEntry(); + genesis.setEntryHash("0".repeat(64)); + when(auditLogRepository.findTopByOrderByIdDesc()).thenReturn(Optional.of(genesis)); + when(consentRepository.saveAndFlush(any())).thenAnswer(i -> { + Consent c = i.getArgument(0); + consents.put(c.getRequestKey(), c); + return c; + }); + when(consentRepository.findByCapturedByAndRequestKey(any(), anyString())) + .thenAnswer(i -> Optional.ofNullable(consents.get(i.getArgument(1, String.class)))); + when(consentPurposeRepository.save(any())).thenAnswer(i -> { + purposeRows.add(i.getArgument(0)); + return i.getArgument(0); + }); + when(consentPurposeRepository.findCurrent(any(), anyString())).thenReturn(Optional.empty()); + when(consentPurposeRepository.findByConsentIdOrderByConsentPurposeIdAsc(anyString())).thenAnswer( + i -> purposeRows.stream().filter(r -> r.getConsentId().equals(i.getArgument(0))).toList()); + when(evidenceRepository.saveAndFlush(any())).thenAnswer(i -> { + ConsentEvidence e = i.getArgument(0); + evidence.put(e.getEvidenceId(), e); + return e; + }); + when(evidenceRepository.findById(anyString())) + .thenAnswer(i -> Optional.ofNullable(evidence.get(i.getArgument(0, String.class)))); + when(evidenceRepository.findByUploadedByAndRequestKey(any(), anyString())).thenReturn(Optional.empty()); + when(evidenceRepository.linkToConsent(anyString(), anyString())).thenReturn(1); + when(evidenceStorage.store(anyString(), any())).thenAnswer(i -> i.getArgument(0) + ".enc"); + } + + private static MockHttpServletRequestBuilder save(String body) { + return post("/consent/v1/consents").header("JwtToken", TOKEN) + .header("Idempotency-Key", UUID.randomUUID().toString()).contentType(MediaType.APPLICATION_JSON) + .content(body); + } + + private static String body(String mode, String reg, String rem, String extra) { + return "{\"beneficiaryRegId\":1001,\"eventType\":\"NEW\",\"consentingParty\":\"SELF\"," + + "\"noticeVersionId\":11,\"languageCode\":\"hi\",\"textHash\":\"" + ConsentFixtures.TEXT_HASH + "\"," + + "\"channel\":\"FACILITY\",\"mode\":" + mode + ",\"capturedBy\":999,\"deviceId\":\"van-07\"," + + "\"capturedAt\":\"2026-10-29T09:59:00Z\",\"decisions\":[{\"purposeCode\":\"P-REG\",\"decision\":\"" + + reg + "\"},{\"purposeCode\":\"P-REM\",\"decision\":\"" + rem + "\"}]" + extra + "}"; + } + + @Test + @DisplayName("C1 / H1: language, notice, self-read accept of everything") + void c1AcceptAll() throws Exception { + mvc.perform(get("/consent/v1/notices/active/languages").param("context", "REGISTRATION").header("JwtToken", + TOKEN)).andExpect(status().isOk()).andExpect(jsonPath("$.data[0].languageCode").value("en")); + mvc.perform(get("/consent/v1/notices/active").param("context", "REGISTRATION").param("lang", "hi") + .header("JwtToken", TOKEN)).andExpect(status().isOk()) + .andExpect(jsonPath("$.data.purposes[0].purposeCode").value("P-REG")); + + mvc.perform(save(body("\"SELF_READ\"", "GRANT", "GRANT", ""))).andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.purposes[0].status").value("GRANTED")) + .andExpect(jsonPath("$.data.purposes[1].status").value("GRANTED")) + .andExpect(jsonPath("$.data.syncStatus").value("SYNCED")); + + assertThat(consents.values()).singleElement().satisfies(c -> assertThat(c.getCapturedBy()).isEqualTo(42L)); + verify(auditLogRepository).save(any()); + logs.assertNoneContain("notice body", TOKEN); + } + + @Test + @DisplayName("C2: partial consent") + void c2Partial() throws Exception { + mvc.perform(save(body("\"SELF_READ\"", "GRANT", "DENY", ""))).andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.purposes[1].status").value("DENIED")); + } + + @Test + @DisplayName("C3: decline with no mode") + void c3Decline() throws Exception { + mvc.perform(save(body("null", "DENY", "DENY", ""))).andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.purposes[0].status").value("DENIED")); + } + + @Test + @DisplayName("C4: closing the dialog saves nothing") + void c4NothingSavedWithoutAnExplicitSave() throws Exception { + mvc.perform(get("/consent/v1/notices/active").param("context", "REGISTRATION").param("lang", "hi") + .header("JwtToken", TOKEN)).andExpect(status().isOk()); + verify(consentRepository, never()).saveAndFlush(any()); + } + + @Test + @DisplayName("C17: the save must name the text actually shown") + void c17LanguageAndTextMustMatch() throws Exception { + mvc.perform(save(body("\"SELF_READ\"", "GRANT", "GRANT", "").replace("\"hi\"", "\"en\""))) + .andExpect(status().isConflict()).andExpect(jsonPath("$.error.code").value("STALE_VERSION")); + } + + @Test + @DisplayName("C18: a retry with the same key saves once and answers the same") + void c18Retry() throws Exception { + String key = UUID.randomUUID().toString(); + String body = body("\"SELF_READ\"", "GRANT", "DENY", ""); + String first = mvc.perform(post("/consent/v1/consents").header("JwtToken", TOKEN).header("Idempotency-Key", key) + .contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isCreated()).andReturn() + .getResponse().getContentAsString(); + mvc.perform(post("/consent/v1/consents").header("JwtToken", TOKEN).header("Idempotency-Key", key) + .contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.consentId").value(containsString(consents.get(key).getConsentId()))); + mvc.perform(post("/consent/v1/consents").header("JwtToken", TOKEN).header("Idempotency-Key", key) + .contentType(MediaType.APPLICATION_JSON).content(body.replace("DENY", "GRANT"))) + .andExpect(status().isConflict()); + assertThat(consents).hasSize(1); + assertThat(first).contains(consents.get(key).getConsentId()); + } + + @Test + @DisplayName("C18: the browser may send Idempotency-Key") + void preflightAllowsTheRequestKey() throws Exception { + mvc.perform(options("/consent/v1/consents").header("Origin", "http://localhost:4200") + .header("Access-Control-Request-Method", "POST") + .header("Access-Control-Request-Headers", "idempotency-key,content-type")).andExpect(status().isOk()) + .andExpect(header().string("Access-Control-Allow-Headers", containsString("Idempotency-Key"))); + } + + private void verifiedChallenge(String id, OtpReason reason, String phone) { + when(otpStore.find(id)).thenReturn(Optional.of(new OtpChallenge(id, reason, encryptor.encrypt(phone), + Sha256.hex(phone), "******" + phone.substring(6), null, 42L, TestFixtures.NOW.plusSeconds(300), + TestFixtures.NOW, false, 0))); + } + + @Test + @DisplayName("C6: guardian consents with a verified OTP") + void c6GuardianOtp() throws Exception { + String challenge = UUID.randomUUID().toString(); + verifiedChallenge(challenge, OtpReason.GUARDIAN, ConsentFixtures.GUARDIAN_PHONE); + String guardian = ",\"guardian\":{\"guardianType\":\"PARENT\",\"name\":\"Sita Devi\",\"relationship\":" + + "\"Mother\",\"phone\":\"9876543210\",\"verification\":\"OTP\",\"otpChallengeId\":\"" + challenge + + "\"}"; + + mvc.perform(save(body("\"SELF_READ\"", "GRANT", "GRANT", guardian).replace("\"SELF\"", "\"GUARDIAN\""))) + .andExpect(status().isCreated()); + + ArgumentCaptor link = ArgumentCaptor.forClass(GuardianLink.class); + verify(guardianLinkRepository).save(link.capture()); + assertThat(link.getValue().getGuardianPhoneEncrypted()).doesNotContain("9876543210"); + assertThat(encryptor.decrypt(link.getValue().getGuardianNameEncrypted())).isEqualTo("Sita Devi"); + verify(otpStore).update(any()); + logs.assertNoneContain("9876543210", "Sita Devi"); + } + + @Test + @DisplayName("C7 / C8: guardian without OTP is flagged for follow-up") + void c7GuardianFallback() throws Exception { + String guardian = ",\"guardian\":{\"guardianType\":\"DISABILITY_GUARDIAN\",\"name\":\"Ravi Kumar\"," + + "\"relationship\":\"Brother\",\"verification\":\"ID_SIGHTED\",\"witnessName\":\"Gita Bai\"}"; + + mvc.perform(save(body("\"SELF_READ\"", "GRANT", "GRANT", guardian).replace("\"SELF\"", "\"GUARDIAN\""))) + .andExpect(status().isCreated()); + + ArgumentCaptor link = ArgumentCaptor.forClass(GuardianLink.class); + verify(guardianLinkRepository).save(link.capture()); + assertThat(link.getValue().isFollowUpRequired()).isTrue(); + } + + @Test + @DisplayName("OTP mode: a challenge proves one consent only") + void otpMode() throws Exception { + String challenge = UUID.randomUUID().toString(); + verifiedChallenge(challenge, OtpReason.CONSENT, "9123456789"); + String proof = ",\"proof\":{\"otpChallengeId\":\"" + challenge + "\"}"; + + mvc.perform(save(body("\"OTP\"", "GRANT", "GRANT", proof))).andExpect(status().isCreated()); + verify(otpStore).update(any()); + } + + @Test + @DisplayName("M3: low-literacy patient, voice clip uploaded then linked") + void m3VoiceClip() throws Exception { + byte[] clip = "synthetic-clip".getBytes(); + String evidenceId = new com.fasterxml.jackson.databind.ObjectMapper().readTree(mvc.perform( + multipart("/consent/v1/evidence").file(new MockMultipartFile("file", "c.webm", "audio/webm", clip)) + .param("type", "VOICE_CLIP").param("sha256", Sha256.hex(clip)).param("durationSec", "35") + .header("JwtToken", TOKEN).header("Idempotency-Key", UUID.randomUUID().toString())) + .andExpect(status().isCreated()).andReturn().getResponse().getContentAsString()) + .at("/data/evidenceId").asText(); + + mvc.perform(save(body("\"VOICE_CLIP\"", "GRANT", "GRANT", ",\"evidenceIds\":[\"" + evidenceId + "\"]"))) + .andExpect(status().isCreated()); + verify(evidenceRepository).linkToConsent(anyString(), anyString()); + } + + @Test + @DisplayName("M3: witnessed mode without a witness is refused") + void m3WitnessRequired() throws Exception { + mvc.perform(save(body("\"WITNESSED\"", "GRANT", "GRANT", ""))).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.details[0].field").value("proof.witnessName")); + } + + @Test + @DisplayName("H2: read aloud with the worker's confirmation") + void h2ReadAloud() throws Exception { + mvc.perform(save(body("\"READ_ALOUD\"", "GRANT", "GRANT", ",\"proof\":{\"workerConfirmed\":true}"))) + .andExpect(status().isCreated()); + } + + @Test + void everyCaptureEndpointNeedsALogin() throws Exception { + mvc.perform(get("/consent/v1/notices/active").param("context", "REGISTRATION").param("lang", "hi")) + .andExpect(status().isUnauthorized()); + mvc.perform(post("/consent/v1/consents").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/com/iemr/consent/ConsentApiApplicationTest.java b/src/test/java/com/iemr/consent/ConsentApiApplicationTest.java new file mode 100644 index 0000000..acda7a0 --- /dev/null +++ b/src/test/java/com/iemr/consent/ConsentApiApplicationTest.java @@ -0,0 +1,57 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; + +class ConsentApiApplicationTest { + + @Test + void mainRunsSpringApplication() { + try (MockedStatic spring = mockStatic(SpringApplication.class)) { + String[] args = { "--x=1" }; + ConsentApiApplication.main(args); + spring.verify(() -> SpringApplication.run(eq(ConsentApiApplication.class), eq(args))); + } + } + + @Test + void configureRegistersTheApplicationForWarDeployment() { + SpringApplicationBuilder builder = mock(SpringApplicationBuilder.class); + when(builder.sources(any(Class[].class))).thenReturn(builder); + + assertThat(new ConsentApiApplication().configure(builder)).isSameAs(builder); + verify(builder).sources(ConsentApiApplication.class); + } +} diff --git a/src/test/java/com/iemr/consent/PreferenceCentreFlowTest.java b/src/test/java/com/iemr/consent/PreferenceCentreFlowTest.java new file mode 100644 index 0000000..30b7d5d --- /dev/null +++ b/src/test/java/com/iemr/consent/PreferenceCentreFlowTest.java @@ -0,0 +1,450 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iemr.consent.config.ConsentProperties; +import com.iemr.consent.config.SecurityProperties; +import com.iemr.consent.controller.grant.AccessGrantController; +import com.iemr.consent.controller.principal.PrincipalConsentController; +import com.iemr.consent.data.audit.AuditLogEntry; +import com.iemr.consent.data.consent.Consent; +import com.iemr.consent.data.consent.ConsentPurpose; +import com.iemr.consent.data.consent.EventType; +import com.iemr.consent.data.consent.PurposeStatus; +import com.iemr.consent.data.grant.AccessGrant; +import com.iemr.consent.data.notice.Notice; +import com.iemr.consent.data.notice.NoticeContext; +import com.iemr.consent.data.notice.NoticePurpose; +import com.iemr.consent.data.notice.NoticeStatus; +import com.iemr.consent.data.notice.Purpose; +import com.iemr.consent.model.otp.OtpReason; +import com.iemr.consent.repository.audit.AuditLogRepository; +import com.iemr.consent.repository.consent.ConsentPurposeRepository; +import com.iemr.consent.repository.consent.ConsentRepository; +import com.iemr.consent.repository.consent.GuardianLinkRepository; +import com.iemr.consent.repository.evidence.ConsentEvidenceRepository; +import com.iemr.consent.repository.grant.AccessGrantRepository; +import com.iemr.consent.repository.notice.NoticePurposeRepository; +import com.iemr.consent.repository.notice.NoticeRepository; +import com.iemr.consent.repository.notice.PurposeRepository; +import com.iemr.consent.service.audit.AuditLogServiceImpl; +import com.iemr.consent.service.grant.AccessGrantServiceImpl; +import com.iemr.consent.service.grant.AccessGrantWriter; +import com.iemr.consent.service.grant.GrantReplayStore; +import com.iemr.consent.service.otp.CommonApiOtpClient; +import com.iemr.consent.service.otp.OtpChallenge; +import com.iemr.consent.service.otp.OtpChallengeStore; +import com.iemr.consent.service.otp.OtpServiceImpl; +import com.iemr.consent.service.principal.ConsentViewMapper; +import com.iemr.consent.service.principal.EffectiveStatus; +import com.iemr.consent.service.principal.PendingActionServiceImpl; +import com.iemr.consent.service.principal.PreferenceChangeWriter; +import com.iemr.consent.service.principal.PrincipalConsentServiceImpl; +import com.iemr.consent.service.principal.PrincipalEventFactory; +import com.iemr.consent.service.principal.WithdrawalWriter; +import com.iemr.consent.support.ConsentFixtures; +import com.iemr.consent.support.LogCapture; +import com.iemr.consent.support.PrincipalFixtures; +import com.iemr.consent.support.TestFixtures; +import com.iemr.consent.support.TestTokens; +import com.iemr.consent.utils.auth.JwtValidator; +import com.iemr.consent.utils.auth.PublicPaths; +import com.iemr.consent.utils.auth.SessionValidator; +import com.iemr.consent.utils.auth.TokenDenylist; +import com.iemr.consent.utils.cors.OriginPolicy; +import com.iemr.consent.utils.crypto.FieldEncryptor; +import com.iemr.consent.utils.exception.ErrorResponseWriter; +import com.iemr.consent.utils.idempotency.RequestHasher; + +/** + * Preference centre end to end (quickstart scenarios 1 – 15): the real filter chain, controllers and services + * of feature 003 over in-memory repositories, Redis and OTP store. Scenario ids are in the display names. + */ +@WebMvcTest(controllers = { PrincipalConsentController.class, AccessGrantController.class }, + properties = { "jwt.secret=" + TestTokens.SECRET, "cors.allowed-origins=" + TestFixtures.ALLOWED_ORIGINS, + "consent.evidence.storage-dir=" + TestFixtures.STORAGE_DIR, + "consent.common-api.base-url=" + TestFixtures.COMMON_API_URL, + "consent.encryption.key=" + TestFixtures.ENCRYPTION_KEY }) +@Import({ ErrorResponseWriter.class, OriginPolicy.class, PublicPaths.class, JwtValidator.class, + SessionValidator.class, SecurityProperties.class, RequestHasher.class, FieldEncryptor.class, + PrincipalConsentServiceImpl.class, PendingActionServiceImpl.class, PreferenceChangeWriter.class, + WithdrawalWriter.class, PrincipalEventFactory.class, EffectiveStatus.class, ConsentViewMapper.class, + AccessGrantServiceImpl.class, AccessGrantWriter.class, GrantReplayStore.class, OtpServiceImpl.class, + AuditLogServiceImpl.class, PreferenceCentreFlowTest.SliceConfig.class }) +class PreferenceCentreFlowTest { + + private static final String WORKER = TestTokens.valid("42", "worker42", "jti-42"); + + private static final String DOCTOR = TestTokens.valid("77", "doctor77", "jti-77"); + + private static final String BASE = "/consent/v1/principals/1001/"; + + @TestConfiguration + @EnableConfigurationProperties(ConsentProperties.class) + static class SliceConfig { + + @Bean + Clock clock() { + return TestFixtures.CLOCK; + } + } + + @RegisterExtension + final LogCapture logs = new LogCapture(); + + @Autowired + private MockMvc mvc; + + @Autowired + private ObjectMapper json; + + @MockBean + private TokenDenylist tokenDenylist; + + @MockBean + private StringRedisTemplate redis; + + @MockBean + private NoticeRepository noticeRepository; + + @MockBean + private NoticePurposeRepository noticePurposeRepository; + + @MockBean + private PurposeRepository purposeRepository; + + @MockBean + private ConsentRepository consentRepository; + + @MockBean + private ConsentPurposeRepository decisionRepository; + + @MockBean + private GuardianLinkRepository guardianLinkRepository; + + @MockBean + private ConsentEvidenceRepository evidenceRepository; + + @MockBean + private AccessGrantRepository grantRepository; + + @MockBean + private AuditLogRepository auditLogRepository; + + @MockBean + private OtpChallengeStore otpStore; + + @MockBean + private CommonApiOtpClient commonApi; + + private final Map events = new HashMap<>(); + + private final List rows = new ArrayList<>(); + + private final Map grants = new HashMap<>(); + + private final Map redisValues = new HashMap<>(); + + private final Map challenges = new HashMap<>(); + + @BeforeEach + void setUp() { + notices(); + consents(); + accessGrants(); + otpAndRedis(); + AuditLogEntry genesis = new AuditLogEntry(); + genesis.setEntryHash("0".repeat(64)); + when(auditLogRepository.findTopByOrderByIdDesc()).thenReturn(Optional.of(genesis)); + Consent first = PrincipalFixtures.event("c-1", TestFixtures.NOW.minusSeconds(86400)); + events.put("c-1", first); + rows.add(PrincipalFixtures.row("c-1", "P-REG", PurposeStatus.GRANTED, 1, null)); + rows.add(PrincipalFixtures.row("c-1", "P-REM", PurposeStatus.GRANTED, 1, + TestFixtures.NOW.plus(Duration.ofDays(300)))); + rows.add(PrincipalFixtures.row("c-1", "P-FAM", PurposeStatus.DENIED, 1, null)); + } + + private void notices() { + Notice notice = ConsentFixtures.notice(NoticeStatus.PUBLISHED); + when(noticeRepository.findFirstByContextAndStatusAndDeletedFalseOrderByVersionNoDesc( + NoticeContext.REGISTRATION, NoticeStatus.PUBLISHED)).thenReturn(Optional.of(notice)); + when(noticeRepository.findById(ConsentFixtures.NOTICE_ID)).thenReturn(Optional.of(notice)); + when(noticePurposeRepository.findByNoticeVersionIdAndDeletedFalseOrderByDisplayRankAsc( + ConsentFixtures.NOTICE_ID)).thenReturn(List.of(noticePurpose("P-REG"), noticePurpose("P-REM"))); + List catalogue = new ArrayList<>(ConsentFixtures.purposes()); + catalogue.add(ConsentFixtures.purpose("P-FAM", false, 180, 1)); + when(purposeRepository.findByPurposeCodeInAndActiveTrueAndDeletedFalse(any())).thenReturn(catalogue); + } + + private static NoticePurpose noticePurpose(String code) { + NoticePurpose purpose = new NoticePurpose(); + purpose.setPurposeCode(code); + return purpose; + } + + private void consents() { + when(consentRepository.saveAndFlush(any())).thenAnswer(i -> { + Consent c = i.getArgument(0); + events.put(c.getConsentId(), c); + return c; + }); + when(consentRepository.findByCapturedByAndRequestKey(any(), anyString())).thenAnswer(i -> events.values() + .stream().filter(c -> c.getCapturedBy().equals(i.getArgument(0)) + && c.getRequestKey().equals(i.getArgument(1))) + .findFirst()); + when(consentRepository.findByBeneficiaryRegIdOrderByServerReceivedAtDesc(any())).thenAnswer(i -> events + .values().stream().sorted(Comparator.comparing(Consent::getServerReceivedAt).reversed()).toList()); + when(consentRepository.findByConsentIdIn(any())).thenAnswer( + i -> events.values().stream().filter(c -> i.getArgument(0, List.class).contains(c.getConsentId())) + .toList()); + when(decisionRepository.findCurrent(any(), anyString())).thenAnswer(i -> rows.stream() + .filter(r -> r.isCurrent() && r.getPurposeCode().equals(i.getArgument(1))).findFirst()); + when(decisionRepository.findByBeneficiaryRegIdAndCurrentTrue(any())) + .thenAnswer(i -> rows.stream().filter(ConsentPurpose::isCurrent).toList()); + when(decisionRepository.findByConsentIdInOrderByConsentPurposeIdAsc(any())).thenAnswer(i -> List.copyOf(rows)); + when(decisionRepository.save(any())).thenAnswer(i -> { + rows.add(i.getArgument(0)); + return i.getArgument(0); + }); + } + + private void accessGrants() { + when(grantRepository.saveAndFlush(any())).thenAnswer(i -> { + AccessGrant g = i.getArgument(0); + grants.put(g.getGrantId(), g); + return g; + }); + when(grantRepository.findByRequestedByAndRequestKey(any(), anyString())).thenAnswer(i -> grants.values() + .stream().filter(g -> g.getRequestedBy().equals(i.getArgument(0)) + && g.getRequestKey().equals(i.getArgument(1))) + .findFirst()); + when(grantRepository.findLockedByGrantId(anyString())) + .thenAnswer(i -> Optional.ofNullable(grants.get(i.getArgument(0, String.class)))); + when(grantRepository.findById(anyString())) + .thenAnswer(i -> Optional.ofNullable(grants.get(i.getArgument(0, String.class)))); + when(grantRepository.findByBeneficiaryRegIdAndDeletedFalseOrderByCreatedDateDesc(any())) + .thenAnswer(i -> List.copyOf(grants.values())); + when(grantRepository.findLockedByBeneficiaryRegIdAndStatusIn(any(), any())).thenAnswer(i -> grants.values() + .stream().filter(g -> i.getArgument(1, List.class).contains(g.getStatus())).toList()); + } + + @SuppressWarnings("unchecked") + private void otpAndRedis() { + when(otpStore.find(anyString())) + .thenAnswer(i -> Optional.ofNullable(challenges.get(i.getArgument(0, String.class)))); + ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + when(values.get(anyString())).thenAnswer(i -> redisValues.get(i.getArgument(0, String.class))); + doAnswer(i -> redisValues.put(i.getArgument(0), i.getArgument(1))).when(values) + .set(anyString(), anyString(), any(Duration.class)); + } + + private String verifiedOtp(OtpReason reason, String refId) { + String id = UUID.randomUUID().toString(); + challenges.put(id, new OtpChallenge(id, reason, "enc", "hash", "******3210", refId, 42L, + TestFixtures.NOW.plusSeconds(300), TestFixtures.NOW, false, 0)); + return id; + } + + private static MockHttpServletRequestBuilder write(String token, String path, String key, String body) { + return post(path).header("JwtToken", token).header("Idempotency-Key", key) + .contentType(MediaType.APPLICATION_JSON).content(body); + } + + private JsonNode data(MockHttpServletRequestBuilder request, int status) throws Exception { + String body = mvc.perform(request).andExpect(status().is(status)).andReturn().getResponse() + .getContentAsString(); + return json.readTree(body).get("data"); + } + + private String requestAccess(String key) throws Exception { + return data(write(DOCTOR, "/consent/v1/access-grants", key, "{\"beneficiaryRegId\":1001," + + "\"purposeCodes\":[\"P-REM\"],\"days\":7,\"reason\":\"Referral follow-up\"}"), 201) + .get("grantId").asText(); + } + + @Test + @DisplayName("H3 / C4 / C14: pending actions for a consented patient, a new patient and a material change") + void pendingActions() throws Exception { + mvc.perform(get(BASE + "pending-actions").header("JwtToken", WORKER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isEmpty()); + + rows.clear(); + mvc.perform(get(BASE + "pending-actions").header("JwtToken", WORKER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].reason").value("CONSENT_MISSING")) + .andExpect(jsonPath("$.data[0].purposeCodes.length()").value(2)); + + rows.add(PrincipalFixtures.row("c-1", "P-REG", PurposeStatus.GRANTED, 1, null)); + rows.add(PrincipalFixtures.row("c-1", "P-REM", PurposeStatus.DENIED, 1, null)); + Notice newer = ConsentFixtures.notice(NoticeStatus.PUBLISHED); + newer.setPublishedAt(TestFixtures.NOW.minusSeconds(60)); + when(noticeRepository.findNewerMaterial("MMU-REGISTRATION", 1)).thenReturn(List.of(newer)); + mvc.perform(get(BASE + "pending-actions").header("JwtToken", WORKER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].reason").value("RECONSENT_REQUIRED")) + .andExpect(jsonPath("$.data[0].dueSince").value("2026-10-29T09:59:00Z")); + } + + @Test + @DisplayName("H6 / T3 / C11 / T5 / C18 / C20 / C12: approve access, withdraw, repeat, stale and refused") + void accessApprovalThenWithdrawalEndsIt() throws Exception { + mvc.perform(get(BASE + "consents").header("JwtToken", WORKER)).andExpect(status().isOk()) + .andExpect(jsonPath("$.data.current.length()").value(3)) + .andExpect(jsonPath("$.data.history[0].consentId").value("c-1")); + + String grantId = requestAccess(UUID.randomUUID().toString()); + assertThat(grants.get(grantId).getGranteeUserId()).isEqualTo(77L); + mvc.perform(get(BASE + "access-grants").param("status", "PENDING").header("JwtToken", WORKER)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data[0].grantId").value(grantId)); + String otp = verifiedOtp(OtpReason.ACCESS_GRANT, grantId); + JsonNode approved = data(write(WORKER, "/consent/v1/access-grants/" + grantId + "/approve", + UUID.randomUUID().toString(), "{\"otpChallengeId\":\"" + otp + "\"}"), 200); + assertThat(approved.get("status").asText()).isEqualTo("ACTIVE"); + assertThat(approved.get("validUntil").asText()).isEqualTo("2026-11-05T10:00:00Z"); + verify(otpStore).update(challenges.get(otp).markedUsed()); + + String key = UUID.randomUUID().toString(); + String withdrawal = "{\"purposeCodes\":[\"P-REM\"],\"versions\":{\"P-REM\":1}}"; + JsonNode first = data(write(WORKER, BASE + "withdrawals", key, withdrawal), 200); + assertThat(first.at("/current/2/purposeCode").asText()).isEqualTo("P-REM"); + assertThat(first.at("/current/2/status").asText()).isEqualTo("WITHDRAWN"); + assertThat(first.at("/current/2/versionNo").asInt()).isEqualTo(2); + assertThat(grants.get(grantId).getStatus().name()).isEqualTo("REVOKED"); + assertThat(grants.get(grantId).getRevokeReason().name()).isEqualTo("WITHDRAWAL"); + + data(write(WORKER, BASE + "withdrawals", key, withdrawal), 200); + assertThat(events.values()).filteredOn(e -> e.getEventType() == EventType.WITHDRAWAL).hasSize(1); + mvc.perform(write(WORKER, "/consent/v1/principals/2002/withdrawals", key, withdrawal)) + .andExpect(status().isConflict()).andExpect(jsonPath("$.error.code").value("STATE_CONFLICT")); + + mvc.perform(write(WORKER, BASE + "withdrawals", UUID.randomUUID().toString(), withdrawal)) + .andExpect(status().isConflict()).andExpect(jsonPath("$.error.code").value("STALE_VERSION")); + mvc.perform(write(WORKER, BASE + "withdrawals", UUID.randomUUID().toString(), + "{\"purposeCodes\":[\"P-REG\"],\"versions\":{\"P-REG\":1}}")).andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.error.code").value("PURPOSE_NOT_WITHDRAWABLE")) + .andExpect(jsonPath("$.error.purposeCode").value("P-REG")); + logs.assertNoneContain("Referral follow-up", "******3210", WORKER); + } + + @Test + @DisplayName("C10 / C11: grant a purpose with an OTP, then ALL withdraws what it can") + void grantWithOtpThenWithdrawAll() throws Exception { + String otp = verifiedOtp(OtpReason.CONSENT, "1001"); + JsonNode granted = data(write(WORKER, BASE + "preferences", UUID.randomUUID().toString(), + "{\"purposeCodes\":[\"P-FAM\"],\"decision\":\"GRANT\",\"versions\":{\"P-FAM\":1}," + + "\"otpChallengeId\":\"" + otp + "\"}"), 200); + assertThat(granted.at("/current/0/purposeCode").asText()).isEqualTo("P-FAM"); + assertThat(granted.at("/current/0/status").asText()).isEqualTo("GRANTED"); + assertThat(granted.at("/history/0/mode").asText()).isEqualTo("OTP"); + verify(evidenceRepository).save(any()); + + JsonNode all = data(write(WORKER, BASE + "withdrawals", UUID.randomUUID().toString(), + "{\"purposeCodes\":\"ALL\",\"versions\":{\"P-FAM\":2,\"P-REM\":1}}"), 200); + assertThat(all.get("notWithdrawn").get(0).asText()).isEqualTo("P-REG"); + assertThat(all.at("/current/0/status").asText()).isEqualTo("WITHDRAWN"); + assertThat(all.at("/current/2/status").asText()).isEqualTo("WITHDRAWN"); + } + + @Test + @DisplayName("T3: the patient cancels a pending request; a second cancel is a conflict") + void cancelAPendingRequest() throws Exception { + String grantId = requestAccess(UUID.randomUUID().toString()); + JsonNode revoked = data(write(WORKER, "/consent/v1/access-grants/" + grantId + "/revoke", + UUID.randomUUID().toString(), "{}"), 200); + assertThat(revoked.get("status").asText()).isEqualTo("REVOKED"); + assertThat(revoked.get("revokeReason").asText()).isEqualTo("PATIENT_REQUEST"); + mvc.perform(write(WORKER, "/consent/v1/access-grants/" + grantId + "/revoke", UUID.randomUUID().toString(), + "{}")).andExpect(status().isConflict()); + } + + @Test + @DisplayName("API Response Standard: every new endpoint's errors carry statusCode = HTTP status and a code") + void everyNewEndpointErrorFollowsTheResponseContract() throws Exception { + String grant = "/consent/v1/access-grants/" + UUID.randomUUID(); + List refused = List.of( + get("/consent/v1/principals/0/consents").header("JwtToken", WORKER), + get(BASE + "pending-actions").param("context", "OTHER").header("JwtToken", WORKER), + get(BASE + "access-grants").param("status", "BAD").header("JwtToken", WORKER), + write(WORKER, BASE + "preferences", UUID.randomUUID().toString(), "{}"), + write(WORKER, BASE + "withdrawals", UUID.randomUUID().toString(), "{}"), + write(DOCTOR, "/consent/v1/access-grants", UUID.randomUUID().toString(), "{}"), + write(WORKER, grant + "/approve", UUID.randomUUID().toString(), "{}"), + write(WORKER, grant + "/revoke", "not-a-key", "{}")); + for (MockHttpServletRequestBuilder request : refused) { + String body = mvc.perform(request).andExpect(status().isBadRequest()).andReturn().getResponse() + .getContentAsString(); + JsonNode response = json.readTree(body); + assertThat(response.get("statusCode").asInt()).isEqualTo(400); + assertThat(response.get("success").asBoolean()).isFalse(); + assertThat(response.at("/error/code").asText()).isIn("VALIDATION_FAILED", "MALFORMED_REQUEST"); + } + } + + @Test + void everyEndpointNeedsTheLogin() throws Exception { + mvc.perform(get(BASE + "consents")).andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error.code").value("UNAUTHENTICATED")); + mvc.perform(post(BASE + "withdrawals").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/com/iemr/consent/config/ApiDocsAvailabilityTest.java b/src/test/java/com/iemr/consent/config/ApiDocsAvailabilityTest.java new file mode 100644 index 0000000..84b6222 --- /dev/null +++ b/src/test/java/com/iemr/consent/config/ApiDocsAvailabilityTest.java @@ -0,0 +1,164 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.iemr.consent.repository.audit.AuditLogRepository; +import com.iemr.consent.repository.consent.ConsentPurposeRepository; +import com.iemr.consent.repository.consent.ConsentRepository; +import com.iemr.consent.repository.consent.GuardianLinkRepository; +import com.iemr.consent.repository.evidence.ConsentEvidenceRepository; +import com.iemr.consent.repository.grant.AccessGrantRepository; +import com.iemr.consent.repository.notice.NoticePurposeRepository; +import com.iemr.consent.repository.notice.NoticeRepository; +import com.iemr.consent.repository.notice.NoticeTranslationRepository; +import com.iemr.consent.repository.notice.PurposeRepository; +import com.iemr.consent.support.TestFixtures; +import com.iemr.consent.support.TestTokens; + +/** + * User Story 4 on the full application context (database and Redis mocked): docs list exactly + * the deployed endpoints, and are not served where configuration disables them. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK, properties = { + "jwt.secret=" + TestTokens.SECRET, "spring.datasource.url=jdbc:mysql://localhost:3306/db_identity", + "spring.datasource.username=test", "spring.redis.host=localhost", + "consent.evidence.storage-dir=" + TestFixtures.STORAGE_DIR, + "consent.common-api.base-url=" + TestFixtures.COMMON_API_URL, + "consent.encryption.key=" + TestFixtures.ENCRYPTION_KEY, + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration," + + "org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration" }) +@AutoConfigureMockMvc +class ApiDocsAvailabilityTest { + + @MockBean + DataSource dataSource; + + @MockBean + RedisConnectionFactory redisConnectionFactory; + + @MockBean + NoticeRepository noticeRepository; + + @MockBean + NoticeTranslationRepository noticeTranslationRepository; + + @MockBean + NoticePurposeRepository noticePurposeRepository; + + @MockBean + PurposeRepository purposeRepository; + + @MockBean + ConsentRepository consentRepository; + + @MockBean + ConsentPurposeRepository consentPurposeRepository; + + @MockBean + GuardianLinkRepository guardianLinkRepository; + + @MockBean + ConsentEvidenceRepository consentEvidenceRepository; + + @MockBean + AuditLogRepository auditLogRepository; + + @MockBean + AccessGrantRepository accessGrantRepository; + + @Nested + @TestPropertySource(properties = { "springdoc.api-docs.enabled=true", "springdoc.swagger-ui.enabled=true" }) + @DisplayName("US4-1: docs enabled") + class Enabled { + + @Autowired + MockMvc mvc; + + @Test + void docsArePublicAndListExactlyTheDeployedEndpoints() throws Exception { + String json = mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse() + .getContentAsString(); + JsonNode docs = TestFixtures.objectMapper().readTree(json); + + List paths = new java.util.ArrayList<>(); + docs.get("paths").fieldNames().forEachRemaining(paths::add); + assertThat(paths).containsExactlyInAnyOrder("/health", "/version", "/consent/v1/notices/active", + "/consent/v1/notices/active/languages", "/consent/v1/otp/challenges", + "/consent/v1/otp/challenges/{challengeId}/verify", "/consent/v1/evidence", "/consent/v1/consents", + "/consent/v1/principals/{benRegId}/consents", "/consent/v1/principals/{benRegId}/pending-actions", + "/consent/v1/principals/{benRegId}/preferences", "/consent/v1/principals/{benRegId}/withdrawals", + "/consent/v1/principals/{benRegId}/access-grants", "/consent/v1/access-grants", + "/consent/v1/access-grants/{grantId}/approve", "/consent/v1/access-grants/{grantId}/revoke"); + assertThat(json).doesNotContain("_test").doesNotContain("/error"); + assertThat(docs.at("/components/schemas/ErrorCode/enum").size()).isEqualTo(26); + assertThat(docs.at("/components/securitySchemes").size()).isEqualTo(3); + assertThat(docs.at("/paths/~1health/get/responses").has("503")).isTrue(); + } + } + + @Nested + @TestPropertySource(properties = { "springdoc.api-docs.enabled=false", "springdoc.swagger-ui.enabled=false" }) + @DisplayName("US4-2: docs disabled") + class Disabled { + + @Autowired + MockMvc mvc; + + @Test + void docsAreNotServed() throws Exception { + mvc.perform(get("/v3/api-docs")).andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error.code").value("UNAUTHENTICATED")); + mvc.perform(get("/swagger-ui.html")).andExpect(status().isUnauthorized()); + } + + @Test + void versionStillAnswersPublicly() throws Exception { + mvc.perform(get("/version")).andExpect(status().isOk()).andExpect(jsonPath("$.statusCode").value(200)); + } + } + +} diff --git a/src/test/java/com/iemr/consent/config/ClockConfigTest.java b/src/test/java/com/iemr/consent/config/ClockConfigTest.java new file mode 100644 index 0000000..795bf79 --- /dev/null +++ b/src/test/java/com/iemr/consent/config/ClockConfigTest.java @@ -0,0 +1,37 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.ZoneOffset; + +import org.junit.jupiter.api.Test; + +class ClockConfigTest { + + @Test + void providesUtcSystemClock() { + assertThat(new ClockConfig().clock().getZone()).isEqualTo(ZoneOffset.UTC); + } +} diff --git a/src/test/java/com/iemr/consent/config/ConsentPropertiesTest.java b/src/test/java/com/iemr/consent/config/ConsentPropertiesTest.java new file mode 100644 index 0000000..50bcf74 --- /dev/null +++ b/src/test/java/com/iemr/consent/config/ConsentPropertiesTest.java @@ -0,0 +1,103 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.bind.validation.BindValidationException; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +class ConsentPropertiesTest { + + @Configuration + @EnableConfigurationProperties(ConsentProperties.class) + static class Config { + } + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ValidationAutoConfiguration.class)) + .withUserConfiguration(Config.class) + .withPropertyValues("consent.evidence.storage-dir=/tmp/evidence", + "consent.common-api.base-url=http://common-api", "consent.encryption.key=a2V5"); + + @Test + void defaults() { + runner.run(context -> { + ConsentProperties properties = context.getBean(ConsentProperties.class); + assertThat(properties.request().maxBodyBytes()).isEqualTo(1048576L); + assertThat(properties.health().checkTimeoutMs()).isEqualTo(1500L); + assertThat(properties.redis().timeoutMs()).isEqualTo(1500L); + assertThat(properties.rateLimit().retryAfterSeconds()).isEqualTo(60L); + assertThat(properties.evidence().maxBytes()).isEqualTo(10485760L); + assertThat(properties.evidence().voiceMinSeconds()).isEqualTo(30); + assertThat(properties.evidence().voiceMaxSeconds()).isEqualTo(60); + assertThat(properties.evidence().allowedContentTypes()).contains("audio/webm", "image/png"); + assertThat(properties.capture().maxSkewMinutes()).isEqualTo(1440L); + assertThat(properties.commonApi().timeoutMs()).isEqualTo(3000); + assertThat(properties.notice().audioBaseUrl()).isEmpty(); + assertThat(properties.otp().ttlSeconds()).isEqualTo(300L); + assertThat(properties.otp().retentionMinutes()).isEqualTo(30L); + assertThat(properties.otp().maxAttempts()).isEqualTo(5); + assertThat(properties.encryption().key()).isEqualTo("a2V5"); + assertThat(properties.accessGrant().maxDays()).isEqualTo(30); + assertThat(properties.accessGrant().replayHours()).isEqualTo(24); + }); + } + + @ParameterizedTest + @ValueSource(strings = { "consent.evidence.storage-dir", "consent.common-api.base-url", "consent.encryption.key" }) + void requiredSettingsWithoutDefaultFailWhenBlank(String key) { + runner.withPropertyValues(key + "=").run(context -> assertThat(context).hasFailed().getFailure() + .hasRootCauseInstanceOf(BindValidationException.class)); + } + + @Test + void bindsConfiguredValues() { + runner.withPropertyValues("consent.request.max-body-bytes=10", "consent.health.check-timeout-ms=100", + "consent.redis.timeout-ms=200", "consent.rate-limit.retry-after-seconds=5").run(context -> { + ConsentProperties properties = context.getBean(ConsentProperties.class); + assertThat(properties.request().maxBodyBytes()).isEqualTo(10L); + assertThat(properties.health().checkTimeoutMs()).isEqualTo(100L); + assertThat(properties.redis().timeoutMs()).isEqualTo(200L); + assertThat(properties.rateLimit().retryAfterSeconds()).isEqualTo(5L); + }); + } + + @ParameterizedTest + @ValueSource(strings = { "consent.request.max-body-bytes=0", "consent.health.check-timeout-ms=99", + "consent.health.check-timeout-ms=1901", "consent.redis.timeout-ms=0", + "consent.rate-limit.retry-after-seconds=0", "consent.evidence.voice-min-seconds=0", + "consent.otp.max-attempts=0", "consent.otp.ttl-seconds=10", "consent.capture.max-skew-minutes=0", + "consent.access-grant.max-days=0", "consent.access-grant.replay-hours=0" }) + void rejectsOutOfRangeValues(String setting) { + runner.withPropertyValues(setting).run(context -> assertThat(context).hasFailed().getFailure() + .hasRootCauseInstanceOf(BindValidationException.class)); + } +} diff --git a/src/test/java/com/iemr/consent/config/MissingSettingFailureAnalyzerTest.java b/src/test/java/com/iemr/consent/config/MissingSettingFailureAnalyzerTest.java new file mode 100644 index 0000000..f7eacfa --- /dev/null +++ b/src/test/java/com/iemr/consent/config/MissingSettingFailureAnalyzerTest.java @@ -0,0 +1,105 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.boot.context.properties.bind.validation.BindValidationException; +import org.springframework.boot.context.properties.bind.validation.ValidationErrors; +import org.springframework.boot.context.properties.source.ConfigurationPropertyName; +import org.springframework.boot.diagnostics.FailureAnalysis; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; + +class MissingSettingFailureAnalyzerTest { + + private static final String SYNTHETIC_VALUE = "synthetic-secret-value-123"; + + private final MissingSettingFailureAnalyzer analyzer = new MissingSettingFailureAnalyzer(); + + private static BindValidationException bindFailure(ObjectError error) { + ValidationErrors errors = mock(ValidationErrors.class); + when(errors.getName()).thenReturn(ConfigurationPropertyName.of("consent")); + when(errors.getAllErrors()).thenReturn(List.of(error)); + BindValidationException bind = mock(BindValidationException.class); + when(bind.getValidationErrors()).thenReturn(errors); + return bind; + } + + @Test + void bindValidationNamesTheSettingWithoutItsValue() { + FieldError error = new FieldError("consent", "health.checkTimeoutMs", SYNTHETIC_VALUE, false, null, null, + "must be less than or equal to 1900"); + BindValidationException bind = bindFailure(error); + + IllegalStateException wrapper = new IllegalStateException("wrapper"); + wrapper.initCause(bind); + FailureAnalysis analysis = analyzer.analyze(wrapper); + + assertThat(analysis.getDescription()).isEqualTo("Missing required setting: consent.health.checkTimeoutMs"); + assertThat(analysis.getDescription() + analysis.getAction()).doesNotContain(SYNTHETIC_VALUE); + } + + @Test + void bindValidationWithObjectErrorNamesThePrefix() { + ObjectError error = new ObjectError("consent", "invalid"); + BindValidationException bind = bindFailure(error); + + assertThat(analyzer.analyze(bind).getDescription()).isEqualTo("Missing required setting: consent"); + } + + @Test + void unresolvedPlaceholderNamesTheVariable() { + IllegalArgumentException cause = new IllegalArgumentException( + "Could not resolve placeholder 'JWT_SECRET_KEY' in value \"${JWT_SECRET_KEY}\""); + + FailureAnalysis analysis = analyzer.analyze(new BeanCreationException("securityProperties", cause)); + + assertThat(analysis.getDescription()).isEqualTo("Missing required setting: JWT_SECRET_KEY"); + assertThat(analysis.getAction()).contains("common_example.properties"); + } + + @Test + void missingRequiredSettingMessageIsPassedThrough() { + IllegalStateException cause = new IllegalStateException("Missing required setting: jwt.secret"); + + FailureAnalysis analysis = analyzer.analyze(new BeanCreationException("securityProperties", cause)); + + assertThat(analysis.getDescription()).isEqualTo("Missing required setting: jwt.secret"); + } + + @Test + void unrelatedFailuresAreLeftToSpringBoot() { + assertThat(analyzer.analyze(new IllegalStateException("something else"))).isNull(); + assertThat(analyzer.analyze(new IllegalArgumentException("Could not do it"))).isNull(); + assertThat(analyzer.analyze(new RuntimeException("Missing required setting: x"))).isNull(); + assertThat(analyzer.analyze(new RuntimeException("Could not resolve placeholder 'X'"))).isNull(); + assertThat(analyzer.analyze(new RuntimeException((String) null))).isNull(); + } +} diff --git a/src/test/java/com/iemr/consent/config/RedisConfigTest.java b/src/test/java/com/iemr/consent/config/RedisConfigTest.java new file mode 100644 index 0000000..61ae22e --- /dev/null +++ b/src/test/java/com/iemr/consent/config/RedisConfigTest.java @@ -0,0 +1,78 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import com.iemr.consent.support.TestFixtures; + +class RedisConfigTest { + + private final RedisConfig config = new RedisConfig(); + + @Test + void connectsToConfiguredHostPortPasswordAndTimeout() { + LettuceConnectionFactory factory = config.redisConnectionFactory("redis.internal", 6380, "pw", + TestFixtures.consentProperties()); + + assertThat(factory.getHostName()).isEqualTo("redis.internal"); + assertThat(factory.getPort()).isEqualTo(6380); + assertThat(factory.getStandaloneConfiguration().getPassword().get()).isEqualTo("pw".toCharArray()); + assertThat(factory.getClientConfiguration().getCommandTimeout()).isEqualTo(Duration.ofMillis(1500)); + } + + @Test + void blankPasswordMeansNoPassword() { + LettuceConnectionFactory factory = config.redisConnectionFactory("localhost", 6379, "", + TestFixtures.consentProperties()); + + assertThat(factory.getStandaloneConfiguration().getPassword().isPresent()).isFalse(); + } + + @Test + void sessionTemplateUsesPlainStringKeys() { + StringRedisTemplate template = config.stringRedisTemplate(mock(RedisConnectionFactory.class)); + + assertThat(template.getKeySerializer()).isInstanceOf(StringRedisSerializer.class); + assertThat(template.getValueSerializer()).isInstanceOf(StringRedisSerializer.class); + } + + @Test + void denylistTemplateUsesCommonApiDefaultJdkKeySerialisation() { + RedisTemplate template = config.redisTemplate(mock(RedisConnectionFactory.class)); + template.afterPropertiesSet(); + + assertThat(template.getKeySerializer()).isInstanceOf(JdkSerializationRedisSerializer.class); + } +} diff --git a/src/test/java/com/iemr/consent/config/RequiredSettingsTest.java b/src/test/java/com/iemr/consent/config/RequiredSettingsTest.java new file mode 100644 index 0000000..b4fbc99 --- /dev/null +++ b/src/test/java/com/iemr/consent/config/RequiredSettingsTest.java @@ -0,0 +1,98 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.mock.env.MockEnvironment; + +class RequiredSettingsTest { + + private static MockEnvironment environment(String url, String username) { + MockEnvironment environment = new MockEnvironment(); + if (url != null) { + environment.setProperty("spring.datasource.url", url); + } + if (username != null) { + environment.setProperty("spring.datasource.username", username); + } + environment.setProperty("consent.encryption.key", "a2V5"); + environment.setProperty("consent.evidence.storage-dir", "/tmp/e"); + environment.setProperty("consent.common-api.base-url", "http://c"); + return environment; + } + + @Test + void acceptsConfiguredDatabaseSettings() { + RequiredSettings settings = new RequiredSettings(); + settings.setEnvironment(environment("jdbc:mysql://h/db_identity", "user")); + + assertThatNoException().isThrownBy(() -> settings.postProcessBeanFactory( + mock(ConfigurableListableBeanFactory.class))); + } + + @Test + void missingOrBlankUrlOrUserStopsStartUpNamingTheKey() { + assertThatIllegalStateException().isThrownBy(() -> RequiredSettings.check(environment(null, "u"))) + .withMessage("Missing required setting: spring.datasource.url"); + assertThatIllegalStateException().isThrownBy(() -> RequiredSettings.check(environment(" ", "u"))) + .withMessage("Missing required setting: spring.datasource.url"); + assertThatIllegalStateException().isThrownBy(() -> RequiredSettings.check(environment("jdbc:x", ""))) + .withMessage("Missing required setting: spring.datasource.username"); + } + + @Test + void missingConsentSettingIsRefused() { + MockEnvironment environment = environment("jdbc:x", "u"); + environment.setProperty("consent.evidence.storage-dir", "${EVIDENCE_STORAGE_DIR}"); + assertThatIllegalArgumentException().isThrownBy(() -> RequiredSettings.check(environment)) + .withMessageContaining("EVIDENCE_STORAGE_DIR"); + environment.setProperty("consent.evidence.storage-dir", " "); + assertThatIllegalStateException().isThrownBy(() -> RequiredSettings.check(environment)) + .withMessage("Missing required setting: consent.evidence.storage-dir"); + } + + @Test + void unsetDockerVariableInPasswordIsRefused() { + MockEnvironment environment = environment("jdbc:x", "u"); + environment.setProperty("spring.datasource.password", "${DATABASE_PASSWORD}"); + + assertThatIllegalArgumentException().isThrownBy(() -> RequiredSettings.check(environment)) + .withMessageContaining("Could not resolve placeholder 'DATABASE_PASSWORD'"); + } + + @Test + void unsetDockerVariableStopsStartUpBeforeAnyBeanIsCreated() { + new ApplicationContextRunner().withBean(RequiredSettings.class) + .withPropertyValues("spring.datasource.url=${DATABASE_URL}", "spring.datasource.username=u") + .run(context -> assertThat(context).hasFailed().getFailure() + .hasMessageContaining("Could not resolve placeholder 'DATABASE_URL'")); + } +} diff --git a/src/test/java/com/iemr/consent/config/SecurityPropertiesTest.java b/src/test/java/com/iemr/consent/config/SecurityPropertiesTest.java new file mode 100644 index 0000000..bdbfb7d --- /dev/null +++ b/src/test/java/com/iemr/consent/config/SecurityPropertiesTest.java @@ -0,0 +1,85 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import com.iemr.consent.support.TestTokens; + +class SecurityPropertiesTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withBean(SecurityProperties.class); + + @Test + void bindsCommonApiKeys() { + runner.withPropertyValues("jwt.secret=" + TestTokens.SECRET, + "cors.allowed-origins= http://localhost:4200 , https://*.example.org ,", + "iemr.session.expiry.time=600", "iemr.extend.expiry.time=false", "enableIPValidation=true") + .run(context -> { + SecurityProperties properties = context.getBean(SecurityProperties.class); + assertThat(properties.getJwtSecret()).isEqualTo(TestTokens.SECRET); + assertThat(properties.getAllowedOrigins()) + .containsExactly("http://localhost:4200", "https://*.example.org"); + assertThat(properties.getSessionExpirySeconds()).isEqualTo(600); + assertThat(properties.isExtendSessionExpiry()).isFalse(); + assertThat(properties.isIpValidationEnabled()).isTrue(); + assertThat(properties.toString()).doesNotContain(TestTokens.SECRET); + }); + } + + @Test + void defaultsMatchCommonApiAndMissingOriginsGiveEmptyList() { + runner.withPropertyValues("jwt.secret=" + TestTokens.SECRET).run(context -> { + SecurityProperties properties = context.getBean(SecurityProperties.class); + assertThat(properties.getAllowedOrigins()).isEmpty(); + assertThat(properties.getSessionExpirySeconds()).isEqualTo(7200); + assertThat(properties.isExtendSessionExpiry()).isTrue(); + assertThat(properties.isIpValidationEnabled()).isFalse(); + }); + } + + @Test + void emptyOriginsGiveEmptyList() { + runner.withPropertyValues("jwt.secret=" + TestTokens.SECRET, "cors.allowed-origins=") + .run(context -> assertThat(context.getBean(SecurityProperties.class).getAllowedOrigins()).isEmpty()); + } + + @Test + void missingSecretStopsStartUpNamingOnlyTheKey() { + runner.run(context -> assertThat(context).hasFailed().getFailure() + .rootCause().hasMessage("Missing required setting: jwt.secret")); + } + + @Test + void blankOrNullSecretIsRejectedAndNullOriginsAreEmpty() { + assertThatIllegalStateException().isThrownBy(() -> new SecurityProperties(" ", "", 1, true, false)) + .withMessage("Missing required setting: jwt.secret"); + assertThatIllegalStateException().isThrownBy(() -> new SecurityProperties(null, "", 1, true, false)); + assertThat(new SecurityProperties("s", null, 1, true, false).getAllowedOrigins()).isEmpty(); + } +} diff --git a/src/test/java/com/iemr/consent/config/SwaggerConfigTest.java b/src/test/java/com/iemr/consent/config/SwaggerConfigTest.java new file mode 100644 index 0000000..c3a3163 --- /dev/null +++ b/src/test/java/com/iemr/consent/config/SwaggerConfigTest.java @@ -0,0 +1,114 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.iemr.consent.service.version.BuildInfo; +import com.iemr.consent.service.version.VersionService; +import com.iemr.consent.utils.exception.ErrorCode; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import io.swagger.v3.oas.models.security.SecurityScheme; + +class SwaggerConfigTest { + + private final SwaggerConfig config = new SwaggerConfig(); + + private OpenAPI openApi() { + VersionService versionService = mock(VersionService.class); + when(versionService.getBuildInfo()).thenReturn(new BuildInfo("0.1.0", "t", "b", "c")); + return config.consentOpenApi(versionService); + } + + @Test + void declaresTheThreeLoginSchemes() { + OpenAPI openApi = openApi(); + + assertThat(openApi.getInfo().getTitle()).isEqualTo("Consent-API"); + assertThat(openApi.getInfo().getVersion()).isEqualTo("0.1.0"); + SecurityScheme header = openApi.getComponents().getSecuritySchemes().get("JwtTokenHeader"); + SecurityScheme cookie = openApi.getComponents().getSecuritySchemes().get("JwtTokenCookie"); + SecurityScheme session = openApi.getComponents().getSecuritySchemes().get("SessionAuthorization"); + assertThat(header.getIn()).isEqualTo(SecurityScheme.In.HEADER); + assertThat(header.getName()).isEqualTo("JwtToken"); + assertThat(cookie.getIn()).isEqualTo(SecurityScheme.In.COOKIE); + assertThat(cookie.getName()).isEqualTo("Jwttoken"); + assertThat(session.getName()).isEqualTo("Authorization"); + assertThat(header.getType()).isEqualTo(SecurityScheme.Type.APIKEY); + assertThat(openApi.getSecurity()).hasSize(3); + } + + @Test + void errorCodeSchemaEqualsTheEnumSoDocsCannotDrift() { + OpenAPI openApi = openApi(); + config.standardResponsesCustomizer().customise(openApi); + + assertThat(openApi.getComponents().getSchemas().get("ErrorCode").getEnum()) + .isEqualTo(Arrays.stream(ErrorCode.values()).map(Enum::name).toList()).hasSize(26); + assertThat(openApi.getComponents().getSchemas()).containsKeys("ApiResponse", "ErrorBody", "FieldErrorDetail"); + } + + @Test + void addsStandardErrorsToEveryOperationAndClearsSecurityOnPublicPaths() { + Operation health = new Operation(); + Operation business = new Operation().responses(new ApiResponses().addApiResponse("403", + new ApiResponse().description("custom"))); + Operation noResponses = new Operation(); + noResponses.setResponses(null); + OpenAPI openApi = new OpenAPI().paths(new Paths().addPathItem("/health", new PathItem().get(health)) + .addPathItem("/version", new PathItem().get(noResponses)) + .addPathItem("/consent/v1/x", new PathItem().post(business))); + + SwaggerConfig.customize(openApi); + + assertThat(health.getResponses()).containsKeys("401", "403", "500", "503"); + assertThat(health.getSecurity()).isEmpty(); + assertThat(noResponses.getResponses()).containsKeys("401", "403", "500", "503"); + assertThat(business.getSecurity()).isNull(); + assertThat(business.getResponses().get("403").getDescription()).isEqualTo("custom"); + assertThat(business.getResponses().get("503").getContent().get("application/json").getSchema().get$ref()) + .isEqualTo("#/components/schemas/ApiResponse"); + } + + @Test + void documentWithoutPathsOrComponentsStillGetsTheSchemas() { + OpenAPI openApi = new OpenAPI(); + SwaggerConfig.customize(openApi); + + assertThat(openApi.getComponents().getSchemas()).containsKeys("ErrorCode", "ApiResponse"); + assertThat(List.copyOf(openApi.getComponents().getSchemas().keySet())).hasSize(4); + } +} diff --git a/src/test/java/com/iemr/consent/controller/consent/ConsentControllerTest.java b/src/test/java/com/iemr/consent/controller/consent/ConsentControllerTest.java new file mode 100644 index 0000000..647de99 --- /dev/null +++ b/src/test/java/com/iemr/consent/controller/consent/ConsentControllerTest.java @@ -0,0 +1,117 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.controller.consent; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.iemr.consent.data.consent.PurposeStatus; +import com.iemr.consent.data.consent.SyncStatus; +import com.iemr.consent.model.consent.PurposeResult; +import com.iemr.consent.model.consent.SaveConsentResult; +import com.iemr.consent.service.consent.ConsentService; +import com.iemr.consent.support.ConsentFixtures; +import com.iemr.consent.support.TestFixtures; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.auth.CurrentUserArgumentResolver; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; +import com.iemr.consent.utils.exception.ErrorResponseWriter; +import com.iemr.consent.utils.exception.GlobalExceptionHandler; +import com.iemr.consent.utils.http.TraceIdFilter; +import com.iemr.consent.utils.idempotency.RequestHasher; + +class ConsentControllerTest { + + static final String KEY = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"; + + static final String BODY = "{\"beneficiaryRegId\":1001,\"eventType\":\"NEW\",\"consentingParty\":\"SELF\"," + + "\"noticeVersionId\":11,\"languageCode\":\"hi\",\"textHash\":\"" + "a".repeat(64) + "\"," + + "\"channel\":\"FACILITY\",\"mode\":\"SELF_READ\",\"capturedBy\":999,\"deviceId\":\"van-07\"," + + "\"serviceLine\":\"MMU\",\"capturedAt\":\"2026-10-29T10:00:00Z\",\"decisions\":[" + + "{\"purposeCode\":\"P-REG\",\"decision\":\"GRANT\"},{\"purposeCode\":\"P-REM\",\"decision\":\"DENY\"}]}"; + + private ConsentService service; + + private MockMvc mvc; + + @BeforeEach + void setUp() { + service = mock(ConsentService.class); + mvc = MockMvcBuilders + .standaloneSetup(new ConsentController(service, new RequestHasher(TestFixtures.objectMapper()), + TestFixtures.CLOCK)) + .setControllerAdvice(new GlobalExceptionHandler(new ErrorResponseWriter(TestFixtures.objectMapper(), + TestFixtures.CLOCK, TestFixtures.consentProperties()))) + .setCustomArgumentResolvers(new CurrentUserArgumentResolver()).addFilters(new TraceIdFilter()).build(); + } + + private static MockHttpServletRequestBuilder save(String body) { + return post("/consent/v1/consents").requestAttr(AuthenticatedUser.ATTRIBUTE, ConsentFixtures.WORKER) + .contentType(MediaType.APPLICATION_JSON).content(body); + } + + @Test + void savedConsentIs201WithStatuses() throws Exception { + when(service.save(any(), eq(KEY), anyString(), eq(ConsentFixtures.WORKER), anyString())) + .thenReturn(new SaveConsentResult("c-1", SyncStatus.SYNCED, + List.of(new PurposeResult("P-REG", PurposeStatus.GRANTED, 1)))); + + mvc.perform(save(BODY).header("Idempotency-Key", KEY)).andExpect(status().isCreated()) + .andExpect(jsonPath("$.statusCode").value(201)).andExpect(jsonPath("$.data.consentId").value("c-1")) + .andExpect(jsonPath("$.data.purposes[0].status").value("GRANTED")); + } + + @Test + void missingKeyOrInvalidBodyIs400() throws Exception { + mvc.perform(save(BODY)).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.details[0].field").value("Idempotency-Key")); + mvc.perform(save(BODY.replace("van-07", "van 07; drop")).header("Idempotency-Key", KEY)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error.details[0].field").value("deviceId")); + mvc.perform(save("{}").header("Idempotency-Key", KEY)).andExpect(status().isBadRequest()); + } + + @Test + void staleNoticeIs409() throws Exception { + when(service.save(any(), anyString(), anyString(), any(), anyString())) + .thenThrow(new ConsentApiException(ErrorCode.STALE_VERSION)); + + mvc.perform(save(BODY).header("Idempotency-Key", KEY)).andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("STALE_VERSION")); + } +} diff --git a/src/test/java/com/iemr/consent/controller/evidence/EvidenceControllerTest.java b/src/test/java/com/iemr/consent/controller/evidence/EvidenceControllerTest.java new file mode 100644 index 0000000..a472f87 --- /dev/null +++ b/src/test/java/com/iemr/consent/controller/evidence/EvidenceControllerTest.java @@ -0,0 +1,121 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.controller.evidence; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.io.IOException; +import java.math.BigDecimal; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.iemr.consent.data.evidence.EvidenceType; +import com.iemr.consent.model.evidence.EvidenceUploadResult; +import com.iemr.consent.service.evidence.EvidenceService; +import com.iemr.consent.service.evidence.EvidenceUpload; +import com.iemr.consent.support.ConsentFixtures; +import com.iemr.consent.support.TestFixtures; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.auth.CurrentUserArgumentResolver; +import com.iemr.consent.utils.exception.ErrorResponseWriter; +import com.iemr.consent.utils.exception.GlobalExceptionHandler; +import com.iemr.consent.utils.http.TraceIdFilter; + +class EvidenceControllerTest { + + private static final String KEY = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"; + + private static final String SHA = "b".repeat(64); + + private EvidenceService service; + + private MockMvc mvc; + + @BeforeEach + void setUp() { + service = mock(EvidenceService.class); + mvc = MockMvcBuilders.standaloneSetup(new EvidenceController(service, TestFixtures.CLOCK)) + .setControllerAdvice(new GlobalExceptionHandler(new ErrorResponseWriter(TestFixtures.objectMapper(), + TestFixtures.CLOCK, TestFixtures.consentProperties()))) + .setCustomArgumentResolvers(new CurrentUserArgumentResolver()).addFilters(new TraceIdFilter()).build(); + } + + @Test + void uploadIs201() throws Exception { + when(service.upload(any(), eq(KEY), eq(ConsentFixtures.WORKER))).thenReturn(new EvidenceUploadResult("e1", SHA)); + + mvc.perform(multipart("/consent/v1/evidence") + .file(new MockMultipartFile("file", "clip.webm", "audio/webm", "abc".getBytes())) + .param("type", "VOICE_CLIP").param("sha256", SHA).param("durationSec", "35") + .header("Idempotency-Key", KEY).requestAttr(AuthenticatedUser.ATTRIBUTE, ConsentFixtures.WORKER)) + .andExpect(status().isCreated()).andExpect(jsonPath("$.data.evidenceId").value("e1")); + + ArgumentCaptor upload = ArgumentCaptor.forClass(EvidenceUpload.class); + verify(service).upload(upload.capture(), eq(KEY), eq(ConsentFixtures.WORKER)); + assertThat(upload.getValue().type()).isEqualTo(EvidenceType.VOICE_CLIP); + assertThat(upload.getValue().durationSec()).isEqualByComparingTo(BigDecimal.valueOf(35)); + assertThat(upload.getValue().contentType()).isEqualTo("audio/webm"); + } + + @Test + void missingPartBadHashOrJsonBody() throws Exception { + mvc.perform(multipart("/consent/v1/evidence").param("type", "VOICE_CLIP").param("sha256", SHA) + .header("Idempotency-Key", KEY).requestAttr(AuthenticatedUser.ATTRIBUTE, ConsentFixtures.WORKER)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error.details[0].field").value("file")); + mvc.perform(multipart("/consent/v1/evidence") + .file(new MockMultipartFile("file", "x", "image/png", "abc".getBytes())).param("type", "PATIENT_PHOTO") + .param("sha256", "XYZ").header("Idempotency-Key", KEY) + .requestAttr(AuthenticatedUser.ATTRIBUTE, ConsentFixtures.WORKER)).andExpect(status().isBadRequest()); + mvc.perform(post("/consent/v1/evidence").contentType(MediaType.APPLICATION_JSON).content("{}") + .requestAttr(AuthenticatedUser.ATTRIBUTE, ConsentFixtures.WORKER)) + .andExpect(status().isUnsupportedMediaType()); + } + + @Test + void unreadableFileIsMalformed() throws Exception { + MockMultipartFile broken = new MockMultipartFile("file", "x", "image/png", "abc".getBytes()) { + @Override + public byte[] getBytes() throws IOException { + throw new IOException("disk"); + } + }; + mvc.perform(multipart("/consent/v1/evidence").file(broken).param("type", "PATIENT_PHOTO").param("sha256", SHA) + .header("Idempotency-Key", KEY).requestAttr(AuthenticatedUser.ATTRIBUTE, ConsentFixtures.WORKER)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error.code").value("MALFORMED_REQUEST")); + } +} diff --git a/src/test/java/com/iemr/consent/controller/grant/AccessGrantControllerTest.java b/src/test/java/com/iemr/consent/controller/grant/AccessGrantControllerTest.java new file mode 100644 index 0000000..d266a3c --- /dev/null +++ b/src/test/java/com/iemr/consent/controller/grant/AccessGrantControllerTest.java @@ -0,0 +1,208 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package com.iemr.consent.controller.grant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.iemr.consent.data.grant.GrantStatus; +import com.iemr.consent.data.grant.GrantType; +import com.iemr.consent.model.grant.AccessGrantView; +import com.iemr.consent.model.grant.ApproveRequest; +import com.iemr.consent.model.grant.GranteeView; +import com.iemr.consent.service.grant.AccessGrantService; +import com.iemr.consent.support.ConsentFixtures; +import com.iemr.consent.support.LogCapture; +import com.iemr.consent.support.TestFixtures; +import com.iemr.consent.utils.auth.AuthenticatedUser; +import com.iemr.consent.utils.auth.CurrentUserArgumentResolver; +import com.iemr.consent.utils.exception.ConsentApiException; +import com.iemr.consent.utils.exception.ErrorCode; +import com.iemr.consent.utils.exception.ErrorResponseWriter; +import com.iemr.consent.utils.exception.GlobalExceptionHandler; +import com.iemr.consent.utils.http.TraceIdFilter; +import com.iemr.consent.utils.idempotency.RequestHasher; + +class AccessGrantControllerTest { + + static final String KEY = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"; + + static final String GRANT = "9a2504e0-4f89-11d3-9a0c-0305e82c3301"; + + static final String OTP = "6f2504e0-4f89-11d3-9a0c-0305e82c3301"; + + static final String REQUEST = "{\"beneficiaryRegId\":1001,\"purposeCodes\":[\"P-REM\"],\"days\":7," + + "\"reason\":\"Referral follow-up\",\"facilityId\":5}"; + + @RegisterExtension + final LogCapture logs = new LogCapture(); + + private final RequestHasher hasher = new RequestHasher(TestFixtures.objectMapper()); + + private AccessGrantService service; + + private MockMvc mvc; + + private final AccessGrantView grant = new AccessGrantView(GRANT, 1001L, new GranteeView(77L, "doctor77", 5), + List.of("P-REM"), GrantType.CONSENTED, GrantStatus.PENDING, 7, "Referral follow-up", null, null, null, + null); + + @BeforeEach + void setUp() { + service = mock(AccessGrantService.class); + mvc = MockMvcBuilders.standaloneSetup(new AccessGrantController(service, hasher, TestFixtures.CLOCK)) + .setControllerAdvice(new GlobalExceptionHandler(new ErrorResponseWriter(TestFixtures.objectMapper(), + TestFixtures.CLOCK, TestFixtures.consentProperties()))) + .setCustomArgumentResolvers(new CurrentUserArgumentResolver()).addFilters(new TraceIdFilter()).build(); + } + + private static MockHttpServletRequestBuilder write(String path, String body) { + return post(path).requestAttr(AuthenticatedUser.ATTRIBUTE, ConsentFixtures.WORKER) + .contentType(MediaType.APPLICATION_JSON).content(body).header("Idempotency-Key", KEY); + } + + @Test + void anAccessRequestIs201AndNeverLogsTheReason() throws Exception { + when(service.request(any(), eq(KEY), anyString(), eq(ConsentFixtures.WORKER), anyString())).thenReturn(grant); + mvc.perform(write("/consent/v1/access-grants", REQUEST)).andExpect(status().isCreated()) + .andExpect(jsonPath("$.statusCode").value(201)).andExpect(jsonPath("$.data.grantId").value(GRANT)) + .andExpect(jsonPath("$.data.grantee.name").value("doctor77")) + .andExpect(jsonPath("$.data.status").value("PENDING")); + logs.assertNoneContain("Referral follow-up"); + } + + @Test + void accessRequestsAreValidated() throws Exception { + String[] bad = { REQUEST.replace("\"days\":7", "\"days\":0"), REQUEST.replace("Referral follow-up", " "), + REQUEST.replace("Referral follow-up", "a".repeat(256)), REQUEST.replace("Referral follow-up", "