From 8ced1f79257e1c20a8537a9dae3c6e610e875ce9 Mon Sep 17 00:00:00 2001 From: oskar Date: Wed, 14 Jan 2026 15:12:10 +0100 Subject: [PATCH] Implement invitation management for patients and add supporting APIs, services, and tests oka: not tested yet - Introduced `Invitation` entity, table schema, and repository. - Added `InvitationController` with APIs for creating, resolving, and accepting invitations. - Implemented `InvitationService` to handle invitation workflows, including token generation and validation. - Created unit tests for controller, service, and repository layers. - Added support for patient-subject relationships with `PatientSubject` entity and repository. - Updated `PatientAccessChecker` to handle subject-based access control. - Extended Keycloak provisioning service for invitation role management. - Updated database migrations to include invitations and patient-subject tables. - Enhanced security configuration to permit invitation resolution API. - Updated build scripts and dependencies for testing support. --- back001/README.md | 7 + back001/app/build.gradle.kts | 1 + .../mosenioring/app/config/SecurityConfig.kt | 1 + .../app/src/main/resources/application.yml | 2 + .../db/migration/V3__add_invitations.sql | 27 +++ .../db/migration/V4__add_patient_subjects.sql | 19 ++ .../identity/api/InvitationControllerTest.kt | 84 ++++++++ .../identity/repo/InvitationRepositoryTest.kt | 69 +++++++ .../common/security/PatientAccessChecker.kt | 4 +- .../common/PatientAccessCheckerTest.kt | 13 ++ back001/modules/identity/build.gradle.kts | 3 + .../com/mosenioring/identity/Invitation.kt | 62 ++++++ .../mosenioring/identity/PatientSubject.kt | 21 ++ .../identity/api/InvitationController.kt | 71 +++++++ .../identity/repo/IdentityRepositories.kt | 8 + .../repo/PatientRelationshipRepositoryImpl.kt | 6 +- .../identity/service/InvitationService.kt | 191 ++++++++++++++++++ .../service/KeycloakProvisioningService.kt | 14 ++ .../identity/service/InvitationServiceTest.kt | 137 +++++++++++++ 19 files changed, 738 insertions(+), 2 deletions(-) create mode 100644 back001/app/src/main/resources/db/migration/V3__add_invitations.sql create mode 100644 back001/app/src/main/resources/db/migration/V4__add_patient_subjects.sql create mode 100644 back001/app/src/test/kotlin/com/mosenioring/identity/api/InvitationControllerTest.kt create mode 100644 back001/app/src/test/kotlin/com/mosenioring/identity/repo/InvitationRepositoryTest.kt create mode 100644 back001/modules/identity/src/main/kotlin/com/mosenioring/identity/Invitation.kt create mode 100644 back001/modules/identity/src/main/kotlin/com/mosenioring/identity/PatientSubject.kt create mode 100644 back001/modules/identity/src/main/kotlin/com/mosenioring/identity/api/InvitationController.kt create mode 100644 back001/modules/identity/src/main/kotlin/com/mosenioring/identity/service/InvitationService.kt create mode 100644 back001/modules/identity/src/main/kotlin/com/mosenioring/identity/service/KeycloakProvisioningService.kt create mode 100644 back001/modules/identity/src/test/kotlin/com/mosenioring/identity/service/InvitationServiceTest.kt diff --git a/back001/README.md b/back001/README.md index 6352708..7aa6539 100644 --- a/back001/README.md +++ b/back001/README.md @@ -53,6 +53,13 @@ Run all tests: - `X-Local-Roles`: e.g., `ADMIN, DOCTOR, CAREGIVER`. - `X-Tenant-Id`: Target tenant. +## 📨 Invitation Onboarding +- **Invite-only registration**: Admins create invites; users accept with a token. +- **Resolve endpoint**: `POST /api/v1/invites/resolve` returns only masked email + expiry. +- **Accept endpoint**: `POST /api/v1/invites/accept` links the authenticated user to a patient. +- **Token hashing**: Invitation tokens are stored as HMAC-SHA256 with a server-side pepper. +- **Config**: Set `app.invites.token-pepper` in `back001/app/src/main/resources/application.yml` for non-dev environments. + ## 🔗 Key Services & Links - **OpenAPI**: [http://localhost:8080/swagger-ui/index.html](http://localhost:8080/swagger-ui/index.html) - **Health**: [http://localhost:8080/health](http://localhost:8080/health) diff --git a/back001/app/build.gradle.kts b/back001/app/build.gradle.kts index 5b17d6e..2f57d15 100644 --- a/back001/app/build.gradle.kts +++ b/back001/app/build.gradle.kts @@ -35,4 +35,5 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.security:spring-security-test") + testImplementation("com.h2database:h2") } diff --git a/back001/app/src/main/kotlin/com/mosenioring/app/config/SecurityConfig.kt b/back001/app/src/main/kotlin/com/mosenioring/app/config/SecurityConfig.kt index 5fa8534..2359988 100644 --- a/back001/app/src/main/kotlin/com/mosenioring/app/config/SecurityConfig.kt +++ b/back001/app/src/main/kotlin/com/mosenioring/app/config/SecurityConfig.kt @@ -25,6 +25,7 @@ class SecurityConfig { .authorizeHttpRequests { it.requestMatchers("/actuator/**", "/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll() it.requestMatchers("/api/v1/demo").permitAll() + it.requestMatchers(HttpMethod.POST, "/api/v1/invites/resolve").permitAll() it.requestMatchers(HttpMethod.POST, "/api/v1/tenants").hasRole("ADMIN") it.anyRequest().authenticated() } diff --git a/back001/app/src/main/resources/application.yml b/back001/app/src/main/resources/application.yml index 13c9fa3..4455791 100644 --- a/back001/app/src/main/resources/application.yml +++ b/back001/app/src/main/resources/application.yml @@ -55,6 +55,8 @@ app: notification-exchange: notification.events medication-queue: medication.plan.created notification-queue: notification.requested + invites: + token-pepper: change-me-in-prod management: endpoints: diff --git a/back001/app/src/main/resources/db/migration/V3__add_invitations.sql b/back001/app/src/main/resources/db/migration/V3__add_invitations.sql new file mode 100644 index 0000000..12534f3 --- /dev/null +++ b/back001/app/src/main/resources/db/migration/V3__add_invitations.sql @@ -0,0 +1,27 @@ +create table invitations ( + id varchar(64) primary key, + token_hash varchar(255) not null, + email varchar(255) not null, + role varchar(64) not null, + patient_id varchar(64) not null, + status varchar(32) not null, + expires_at timestamptz not null, + accepted_at timestamptz, + accepted_by_user_id varchar(64), + created_by_admin varchar(128), + tenant_id varchar(64) not null, + created_at timestamptz not null, + updated_at timestamptz not null, + created_by varchar(128), + updated_by varchar(128) +); + +create unique index idx_invitations_token_hash on invitations(token_hash); +create index idx_invitations_status_expires_at on invitations(status, expires_at); +create index idx_invitations_tenant on invitations(tenant_id); + +alter table invitations add constraint fk_invitations_patient + foreign key (patient_id) references patients(id) on delete cascade; + +alter table invitations add constraint fk_invitations_accepted_by_user + foreign key (accepted_by_user_id) references users(id) on delete set null; diff --git a/back001/app/src/main/resources/db/migration/V4__add_patient_subjects.sql b/back001/app/src/main/resources/db/migration/V4__add_patient_subjects.sql new file mode 100644 index 0000000..9fd3068 --- /dev/null +++ b/back001/app/src/main/resources/db/migration/V4__add_patient_subjects.sql @@ -0,0 +1,19 @@ +create table patient_subjects ( + id varchar(64) primary key, + patient_id varchar(64) not null, + user_id varchar(64) not null, + tenant_id varchar(64) not null, + created_at timestamptz not null, + updated_at timestamptz not null, + created_by varchar(128), + updated_by varchar(128) +); + +create index idx_patient_subjects_tenant on patient_subjects(tenant_id); +create unique index idx_patient_subjects_unique on patient_subjects(tenant_id, patient_id, user_id); + +alter table patient_subjects add constraint fk_patient_subjects_patient + foreign key (patient_id) references patients(id) on delete cascade; + +alter table patient_subjects add constraint fk_patient_subjects_user + foreign key (user_id) references users(id) on delete cascade; diff --git a/back001/app/src/test/kotlin/com/mosenioring/identity/api/InvitationControllerTest.kt b/back001/app/src/test/kotlin/com/mosenioring/identity/api/InvitationControllerTest.kt new file mode 100644 index 0000000..510c445 --- /dev/null +++ b/back001/app/src/test/kotlin/com/mosenioring/identity/api/InvitationControllerTest.kt @@ -0,0 +1,84 @@ +package com.mosenioring.identity.api + +import com.mosenioring.app.config.SecurityConfig +import com.mosenioring.common.web.ProblemDetailsAdvice +import com.mosenioring.identity.service.InvitationService +import com.mosenioring.identity.service.InviteResolveResult +import org.junit.jupiter.api.Test +import org.mockito.Mockito.`when` +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.ImportAutoConfiguration +import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.time.Instant + +@WebMvcTest(controllers = [InvitationController::class]) +@ContextConfiguration(classes = [InvitationController::class, SecurityConfig::class]) +@ImportAutoConfiguration( + exclude = [ + DataSourceAutoConfiguration::class, + DataSourceTransactionManagerAutoConfiguration::class, + HibernateJpaAutoConfiguration::class, + JpaRepositoriesAutoConfiguration::class + ] +) +@Import(SecurityConfig::class, ProblemDetailsAdvice::class) +@AutoConfigureMockMvc +class InvitationControllerTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + @MockBean + private lateinit var invitationService: InvitationService + + @MockBean + private lateinit var jwtDecoder: JwtDecoder + + @Test + fun `resolves invite when valid`() { + val expiresAt = Instant.parse("2024-01-01T12:00:00Z") + `when`(invitationService.resolveInvite("token-123")) + .thenReturn(InviteResolveResult("PATIENT", "i***@example.com", expiresAt)) + + val body = """{ "token": "token-123" }""" + + mockMvc.perform( + post("/api/v1/invites/resolve") + .contentType(MediaType.APPLICATION_JSON) + .content(body) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.role").value("PATIENT")) + .andExpect(jsonPath("$.emailMasked").value("i***@example.com")) + .andExpect(jsonPath("$.expiresAt").value(expiresAt.toString())) + } + + @Test + fun `rejects expired invite`() { + `when`(invitationService.resolveInvite("expired-token")) + .thenThrow(IllegalArgumentException("Invalid invitation")) + + val body = """{ "token": "expired-token" }""" + + mockMvc.perform( + post("/api/v1/invites/resolve") + .contentType(MediaType.APPLICATION_JSON) + .content(body) + ) + .andExpect(status().isBadRequest) + } +} diff --git a/back001/app/src/test/kotlin/com/mosenioring/identity/repo/InvitationRepositoryTest.kt b/back001/app/src/test/kotlin/com/mosenioring/identity/repo/InvitationRepositoryTest.kt new file mode 100644 index 0000000..90cfb8c --- /dev/null +++ b/back001/app/src/test/kotlin/com/mosenioring/identity/repo/InvitationRepositoryTest.kt @@ -0,0 +1,69 @@ +package com.mosenioring.identity.repo + +import com.mosenioring.app.Application +import com.mosenioring.common.tenant.TenantContext +import com.mosenioring.identity.Invitation +import com.mosenioring.identity.Patient +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.TestPropertySource +import java.time.Instant +import java.util.UUID + +@DataJpaTest +@ContextConfiguration(classes = [Application::class]) +@TestPropertySource( + properties = [ + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.flyway.enabled=false" + ] +) +class InvitationRepositoryTest { + + @Autowired + private lateinit var invitationRepository: InvitationRepository + + @Autowired + private lateinit var patientRepository: PatientRepository + + @AfterEach + fun tearDown() { + TenantContext.clear() + } + + @Test + fun `enforces unique token hash`() { + TenantContext.setTenantId("t1") + val patient = patientRepository.save(Patient(UUID.randomUUID().toString(), "invited@example.com")) + val invitation1 = Invitation( + id = UUID.randomUUID().toString(), + tokenHash = "hash-1", + email = "invited@example.com", + role = Invitation.ROLE_PATIENT, + patient = patient, + status = Invitation.STATUS_PENDING, + expiresAt = Instant.now().plusSeconds(3600) + ) + val invitation2 = Invitation( + id = UUID.randomUUID().toString(), + tokenHash = "hash-1", + email = "invited@example.com", + role = Invitation.ROLE_PATIENT, + patient = patient, + status = Invitation.STATUS_PENDING, + expiresAt = Instant.now().plusSeconds(3600) + ) + + invitationRepository.save(invitation1) + invitationRepository.flush() + + assertThrows(DataIntegrityViolationException::class.java) { + invitationRepository.saveAndFlush(invitation2) + } + } +} diff --git a/back001/common/src/main/kotlin/com/mosenioring/common/security/PatientAccessChecker.kt b/back001/common/src/main/kotlin/com/mosenioring/common/security/PatientAccessChecker.kt index 7d82fc9..fdc7a8f 100644 --- a/back001/common/src/main/kotlin/com/mosenioring/common/security/PatientAccessChecker.kt +++ b/back001/common/src/main/kotlin/com/mosenioring/common/security/PatientAccessChecker.kt @@ -7,6 +7,7 @@ import org.springframework.stereotype.Component interface PatientRelationshipRepository { fun isCaregiverOf(tenantId: String, patientId: String, userId: String): Boolean fun isDoctorOf(tenantId: String, patientId: String, userId: String): Boolean + fun isSubjectOf(tenantId: String, patientId: String, userId: String): Boolean } @Component @@ -20,7 +21,8 @@ class PatientAccessChecker( return true } val userId = SecurityUtils.currentUserId() ?: return false - return relationships.isCaregiverOf(tenantId, patientId, userId) || + return relationships.isSubjectOf(tenantId, patientId, userId) || + relationships.isCaregiverOf(tenantId, patientId, userId) || relationships.isDoctorOf(tenantId, patientId, userId) } } diff --git a/back001/common/src/test/kotlin/com/mosenioring/common/PatientAccessCheckerTest.kt b/back001/common/src/test/kotlin/com/mosenioring/common/PatientAccessCheckerTest.kt index c09882a..dcdf87c 100644 --- a/back001/common/src/test/kotlin/com/mosenioring/common/PatientAccessCheckerTest.kt +++ b/back001/common/src/test/kotlin/com/mosenioring/common/PatientAccessCheckerTest.kt @@ -39,6 +39,7 @@ class PatientAccessCheckerTest { SecurityContextHolder.getContext().authentication = auth Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user1")).thenReturn(false) Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user1")).thenReturn(false) + Mockito.`when`(relationships.isSubjectOf("t1", "p1", "user1")).thenReturn(false) assertFalse(checker.hasAccess("p1")) } @@ -49,6 +50,18 @@ class PatientAccessCheckerTest { SecurityContextHolder.getContext().authentication = auth Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user2")).thenReturn(true) Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user2")).thenReturn(false) + Mockito.`when`(relationships.isSubjectOf("t1", "p1", "user2")).thenReturn(false) + assertTrue(checker.hasAccess("p1")) + } + + @Test + fun `allows patient subject relationship`() { + val principal = LocalUserPrincipal("user3", "t1", emptySet()) + val auth = UsernamePasswordAuthenticationToken(principal, "n/a", emptyList()) + SecurityContextHolder.getContext().authentication = auth + Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user3")).thenReturn(false) + Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user3")).thenReturn(false) + Mockito.`when`(relationships.isSubjectOf("t1", "p1", "user3")).thenReturn(true) assertTrue(checker.hasAccess("p1")) } } diff --git a/back001/modules/identity/build.gradle.kts b/back001/modules/identity/build.gradle.kts index 5db2a38..2b8c7c5 100644 --- a/back001/modules/identity/build.gradle.kts +++ b/back001/modules/identity/build.gradle.kts @@ -9,4 +9,7 @@ plugins { dependencies { api(project(":common")) implementation(project(":modules:audit")) + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.mockito:mockito-core") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.3.1") } diff --git a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/Invitation.kt b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/Invitation.kt new file mode 100644 index 0000000..82bcbcf --- /dev/null +++ b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/Invitation.kt @@ -0,0 +1,62 @@ +package com.mosenioring.identity + +import com.mosenioring.common.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import java.time.Instant + +@Entity +@Table( + name = "invitations", + uniqueConstraints = [UniqueConstraint(name = "uq_invitations_token_hash", columnNames = ["token_hash"])], + indexes = [Index(name = "idx_invitations_status_expires_at", columnList = "status,expires_at")] +) +class Invitation( + @Id + @Column(name = "id") + val id: String, + + @Column(name = "token_hash", nullable = false, unique = true) + val tokenHash: String, + + @Column(name = "email", nullable = false) + val email: String, + + @Column(name = "role", nullable = false) + val role: String, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "patient_id", nullable = false) + val patient: Patient, + + @Column(name = "status", nullable = false) + var status: String, + + @Column(name = "expires_at", nullable = false) + var expiresAt: Instant, + + @Column(name = "accepted_at") + var acceptedAt: Instant? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "accepted_by_user_id") + var acceptedBy: User? = null, + + @Column(name = "created_by_admin") + val createdByAdmin: String? = null +) : BaseEntity() { + companion object { + const val STATUS_PENDING = "PENDING" + const val STATUS_ACCEPTED = "ACCEPTED" + const val STATUS_EXPIRED = "EXPIRED" + + const val ROLE_PATIENT = "PATIENT" + } +} diff --git a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/PatientSubject.kt b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/PatientSubject.kt new file mode 100644 index 0000000..87a24cc --- /dev/null +++ b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/PatientSubject.kt @@ -0,0 +1,21 @@ +package com.mosenioring.identity + +import com.mosenioring.common.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "patient_subjects") +class PatientSubject( + @Id + @Column(name = "id") + val id: String, + + @Column(name = "patient_id", nullable = false) + val patientId: String, + + @Column(name = "user_id", nullable = false) + val userId: String +) : BaseEntity() diff --git a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/api/InvitationController.kt b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/api/InvitationController.kt new file mode 100644 index 0000000..f10396c --- /dev/null +++ b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/api/InvitationController.kt @@ -0,0 +1,71 @@ +package com.mosenioring.identity.api + +import com.mosenioring.common.security.SecurityUtils +import com.mosenioring.identity.service.InvitationService +import jakarta.servlet.http.HttpServletRequest +import jakarta.validation.Valid +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +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 org.springframework.web.servlet.support.ServletUriComponentsBuilder + +@RestController +@RequestMapping("/api/v1") +class InvitationController( + private val invitationService: InvitationService +) { + + @PostMapping("/admin/invites") + fun createInvite( + @Valid @RequestBody request: CreateInviteRequest, + servletRequest: HttpServletRequest + ): ResponseEntity { + val result = invitationService.createPatientInvite(request.email, SecurityUtils.currentUserId()) + val inviteLink = buildInviteLink(servletRequest, result.token) + return ResponseEntity.ok(CreateInviteResponse(inviteLink, result.expiresAt)) + } + + @PostMapping("/invites/resolve") + fun resolveInvite(@Valid @RequestBody request: ResolveInviteRequest): ResponseEntity { + val result = invitationService.resolveInvite(request.token) + return ResponseEntity.ok(ResolveInviteResponse(result.role, result.emailMasked, result.expiresAt)) + } + + @PostMapping("/invites/accept") + fun acceptInvite(@Valid @RequestBody request: AcceptInviteRequest): ResponseEntity { + val userId = SecurityUtils.currentUserId() ?: throw IllegalArgumentException("Missing user") + val result = invitationService.acceptInvite(request.token, userId) + return ResponseEntity.ok(AcceptInviteResponse("accepted", result.patientId)) + } + + private fun buildInviteLink(request: HttpServletRequest, token: String): String { + val base = ServletUriComponentsBuilder.fromRequestUri(request) + .replacePath("/invite") + .replaceQuery(null) + .build() + .toUriString() + return "$base?token=$token" + } +} + +data class CreateInviteRequest( + @field:Email val email: String +) + +data class CreateInviteResponse(val inviteLink: String, val expiresAt: java.time.Instant) + +data class ResolveInviteRequest( + @field:NotBlank val token: String +) + +data class ResolveInviteResponse(val role: String, val emailMasked: String, val expiresAt: java.time.Instant) + +data class AcceptInviteRequest( + @field:NotBlank val token: String +) + +data class AcceptInviteResponse(val status: String, val patientId: String) diff --git a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/repo/IdentityRepositories.kt b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/repo/IdentityRepositories.kt index 75441a6..dfd01ba 100644 --- a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/repo/IdentityRepositories.kt +++ b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/repo/IdentityRepositories.kt @@ -20,3 +20,11 @@ interface PatientCaregiverRepository : JpaRepository { interface PatientDoctorRepository : JpaRepository { fun existsByTenantIdAndPatientIdAndUserId(tenantId: String, patientId: String, userId: String): Boolean } + +interface PatientSubjectRepository : JpaRepository { + fun existsByTenantIdAndPatientIdAndUserId(tenantId: String, patientId: String, userId: String): Boolean +} + +interface InvitationRepository : JpaRepository { + fun findByTokenHash(tokenHash: String): Invitation? +} diff --git a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/repo/PatientRelationshipRepositoryImpl.kt b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/repo/PatientRelationshipRepositoryImpl.kt index 0b8db89..845822a 100644 --- a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/repo/PatientRelationshipRepositoryImpl.kt +++ b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/repo/PatientRelationshipRepositoryImpl.kt @@ -6,11 +6,15 @@ import org.springframework.stereotype.Repository @Repository class PatientRelationshipRepositoryImpl( private val caregiverRepository: PatientCaregiverRepository, - private val doctorRepository: PatientDoctorRepository + private val doctorRepository: PatientDoctorRepository, + private val subjectRepository: PatientSubjectRepository ) : PatientRelationshipRepository { override fun isCaregiverOf(tenantId: String, patientId: String, userId: String): Boolean = caregiverRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId) override fun isDoctorOf(tenantId: String, patientId: String, userId: String): Boolean = doctorRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId) + + override fun isSubjectOf(tenantId: String, patientId: String, userId: String): Boolean = + subjectRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId) } diff --git a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/service/InvitationService.kt b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/service/InvitationService.kt new file mode 100644 index 0000000..a7276de --- /dev/null +++ b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/service/InvitationService.kt @@ -0,0 +1,191 @@ +package com.mosenioring.identity.service + +import com.mosenioring.common.security.LocalUserPrincipal +import com.mosenioring.common.tenant.TenantContext +import com.mosenioring.identity.Invitation +import com.mosenioring.identity.Patient +import com.mosenioring.identity.PatientSubject +import com.mosenioring.identity.User +import com.mosenioring.identity.repo.InvitationRepository +import com.mosenioring.identity.repo.PatientRepository +import com.mosenioring.identity.repo.PatientSubjectRepository +import com.mosenioring.identity.repo.UserRepository +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.beans.factory.annotation.Value +import java.security.SecureRandom +import java.time.Duration +import java.time.Instant +import java.util.Base64 +import java.util.UUID +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +data class InviteCreationResult(val token: String, val expiresAt: Instant) +data class InviteResolveResult(val role: String, val emailMasked: String, val expiresAt: Instant) +data class InviteAcceptResult(val patientId: String, val role: String) + +@Service +class InvitationService( + private val invitationRepository: InvitationRepository, + private val patientRepository: PatientRepository, + private val userRepository: UserRepository, + private val subjectRepository: PatientSubjectRepository, + private val keycloakProvisioningService: KeycloakProvisioningService, + @Value("\${app.invites.token-pepper:change-me}") private val tokenPepper: String +) { + @Transactional + @PreAuthorize("hasRole('ADMIN')") + fun createPatientInvite(email: String, createdByAdmin: String?): InviteCreationResult { + requireNotNull(TenantContext.getTenantId()) { "Missing tenant" } + + val patient = Patient(UUID.randomUUID().toString(), generatePatientPlaceholderName()) + val savedPatient = patientRepository.save(patient) + + keycloakProvisioningService.provisionUser(email, Invitation.ROLE_PATIENT)?.let { userId -> + val user = User(userId, email, Invitation.ROLE_PATIENT, "INVITED") + userRepository.save(user) + } + keycloakProvisioningService.sendSetPasswordEmail(email) + + val token = generateToken() + val invitation = Invitation( + id = UUID.randomUUID().toString(), + tokenHash = hashToken(token), + email = email, + role = Invitation.ROLE_PATIENT, + patient = savedPatient, + status = Invitation.STATUS_PENDING, + expiresAt = Instant.now().plus(INVITE_TTL), + acceptedAt = null, + acceptedBy = null, + createdByAdmin = createdByAdmin + ) + invitationRepository.save(invitation) + return InviteCreationResult(token, invitation.expiresAt) + } + + @Transactional + fun resolveInvite(token: String): InviteResolveResult { + val invitation = invitationRepository.findByTokenHash(hashToken(token)) + ?: throw IllegalArgumentException("Invalid invitation") + if (invitation.status != Invitation.STATUS_PENDING || isExpired(invitation)) { + if (isExpired(invitation)) { + markExpired(invitation) + } + throw IllegalArgumentException("Invalid invitation") + } + return InviteResolveResult(invitation.role, maskEmail(invitation.email), invitation.expiresAt) + } + + @Transactional + fun acceptInvite(token: String, authenticatedUserId: String): InviteAcceptResult { + requireNotNull(TenantContext.getTenantId()) { "Missing tenant" } + + val invitation = loadInvitation(token) + if (invitation.status != Invitation.STATUS_PENDING) { + throw IllegalArgumentException("Invitation not available") + } + if (isExpired(invitation)) { + markExpired(invitation) + throw IllegalArgumentException("Invitation expired") + } + + val authenticatedEmail = resolveAuthenticatedEmail() + val user = userRepository.findById(authenticatedUserId).orElseGet { + if (authenticatedEmail.isNullOrBlank()) { + throw IllegalArgumentException("User not found") + } + if (!invitation.email.equals(authenticatedEmail, ignoreCase = true)) { + throw IllegalArgumentException("Invitation email mismatch") + } + val newUser = User(authenticatedUserId, authenticatedEmail, invitation.role, "ACTIVE") + userRepository.save(newUser) + } + + if (!invitation.email.equals(user.email, ignoreCase = true)) { + throw IllegalArgumentException("Invitation email mismatch") + } + + if (invitation.role == Invitation.ROLE_PATIENT) { + linkPatientToUser(invitation.patient.id, user.id) + } + + invitation.status = Invitation.STATUS_ACCEPTED + invitation.acceptedAt = Instant.now() + invitation.acceptedBy = user + invitationRepository.save(invitation) + + return InviteAcceptResult(invitation.patient.id, invitation.role) + } + + private fun loadInvitation(token: String): Invitation = + invitationRepository.findByTokenHash(hashToken(token)) + ?: throw IllegalArgumentException("Invitation not found") + + private fun isExpired(invitation: Invitation): Boolean = + Instant.now().isAfter(invitation.expiresAt) + + private fun markExpired(invitation: Invitation) { + if (invitation.status == Invitation.STATUS_PENDING) { + invitation.status = Invitation.STATUS_EXPIRED + invitationRepository.save(invitation) + } + } + + private fun linkPatientToUser(patientId: String, userId: String) { + val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant") + if (!subjectRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId)) { + val link = PatientSubject(UUID.randomUUID().toString(), patientId, userId) + subjectRepository.save(link) + } + } + + private fun resolveAuthenticatedEmail(): String? { + val authentication = SecurityContextHolder.getContext().authentication ?: return null + return when (authentication) { + is JwtAuthenticationToken -> authentication.token.getClaimAsString("email") + ?: authentication.token.getClaimAsString("preferred_username") + else -> (authentication.principal as? LocalUserPrincipal)?.userId + } + } + + private fun generateToken(): String { + val bytes = ByteArray(TOKEN_BYTES) + secureRandom.nextBytes(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } + + private fun hashToken(token: String): String { + val mac = Mac.getInstance("HmacSHA256") + val key = SecretKeySpec(tokenPepper.toByteArray(Charsets.UTF_8), "HmacSHA256") + mac.init(key) + val hash = mac.doFinal(token.toByteArray(Charsets.UTF_8)) + return hash.joinToString("") { "%02x".format(it) } + } + + private fun maskEmail(email: String): String { + val atIndex = email.indexOf('@') + if (atIndex <= 0) { + return "***" + } + val name = email.substring(0, atIndex) + val domain = email.substring(atIndex + 1) + val masked = if (name.length == 1) "*" else "${name.first()}***" + return "$masked@$domain" + } + + private fun generatePatientPlaceholderName(): String { + val suffix = UUID.randomUUID().toString().replace("-", "").take(6) + return "Patient-$suffix" + } + + companion object { + private val INVITE_TTL = Duration.ofHours(24) + private const val TOKEN_BYTES = 32 + private val secureRandom = SecureRandom() + } +} diff --git a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/service/KeycloakProvisioningService.kt b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/service/KeycloakProvisioningService.kt new file mode 100644 index 0000000..380709c --- /dev/null +++ b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/service/KeycloakProvisioningService.kt @@ -0,0 +1,14 @@ +package com.mosenioring.identity.service + +import org.springframework.stereotype.Service + +interface KeycloakProvisioningService { + fun provisionUser(email: String, role: String): String? + fun sendSetPasswordEmail(email: String) +} + +@Service +class NoopKeycloakProvisioningService : KeycloakProvisioningService { + override fun provisionUser(email: String, role: String): String? = null + override fun sendSetPasswordEmail(email: String) = Unit +} diff --git a/back001/modules/identity/src/test/kotlin/com/mosenioring/identity/service/InvitationServiceTest.kt b/back001/modules/identity/src/test/kotlin/com/mosenioring/identity/service/InvitationServiceTest.kt new file mode 100644 index 0000000..254c6ea --- /dev/null +++ b/back001/modules/identity/src/test/kotlin/com/mosenioring/identity/service/InvitationServiceTest.kt @@ -0,0 +1,137 @@ +package com.mosenioring.identity.service + +import com.mosenioring.common.tenant.TenantContext +import com.mosenioring.identity.Invitation +import com.mosenioring.identity.Patient +import com.mosenioring.identity.PatientSubject +import com.mosenioring.identity.User +import com.mosenioring.identity.repo.InvitationRepository +import com.mosenioring.identity.repo.PatientRepository +import com.mosenioring.identity.repo.PatientSubjectRepository +import com.mosenioring.identity.repo.UserRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import java.time.Instant +import java.util.Optional +import java.util.UUID + +class InvitationServiceTest { + + private val invitationRepository: InvitationRepository = mock() + private val patientRepository: PatientRepository = mock() + private val userRepository: UserRepository = mock() + private val subjectRepository: PatientSubjectRepository = mock() + private val keycloakProvisioningService: KeycloakProvisioningService = mock() + private val service = InvitationService( + invitationRepository, + patientRepository, + userRepository, + subjectRepository, + keycloakProvisioningService, + "test-pepper" + ) + + @BeforeEach + fun setup() { + TenantContext.setTenantId("t1") + } + + @AfterEach + fun tearDown() { + TenantContext.clear() + } + + @Test + fun `accepts valid invite`() { + val token = "token-123" + val patient = Patient("patient-1", "invite@example.com") + val invitation = Invitation( + id = UUID.randomUUID().toString(), + tokenHash = hashToken(token), + email = "invite@example.com", + role = Invitation.ROLE_PATIENT, + patient = patient, + status = Invitation.STATUS_PENDING, + expiresAt = Instant.now().plusSeconds(3600) + ) + val user = User("user-1", "invite@example.com", Invitation.ROLE_PATIENT, "ACTIVE") + + whenever(invitationRepository.findByTokenHash(invitation.tokenHash)).thenReturn(invitation) + whenever(userRepository.findById("user-1")).thenReturn(Optional.of(user)) + whenever(subjectRepository.existsByTenantIdAndPatientIdAndUserId("t1", patient.id, user.id)) + .thenReturn(false) + whenever(subjectRepository.save(any())).thenAnswer { it.arguments[0] as PatientSubject } + whenever(invitationRepository.save(any())).thenAnswer { it.arguments[0] as Invitation } + + val result = service.acceptInvite(token, "user-1") + + assertEquals(patient.id, result.patientId) + assertEquals(Invitation.ROLE_PATIENT, result.role) + assertEquals(Invitation.STATUS_ACCEPTED, invitation.status) + assertNotNull(invitation.acceptedAt) + assertEquals(user, invitation.acceptedBy) + } + + @Test + fun `rejects expired invite`() { + val token = "expired-token" + val patient = Patient("patient-1", "invite@example.com") + val invitation = Invitation( + id = UUID.randomUUID().toString(), + tokenHash = hashToken(token), + email = "invite@example.com", + role = Invitation.ROLE_PATIENT, + patient = patient, + status = Invitation.STATUS_PENDING, + expiresAt = Instant.now().minusSeconds(60) + ) + whenever(invitationRepository.findByTokenHash(invitation.tokenHash)).thenReturn(invitation) + whenever(invitationRepository.save(any())).thenAnswer { it.arguments[0] as Invitation } + + assertThrows(IllegalArgumentException::class.java) { + service.acceptInvite(token, "user-1") + } + + assertEquals(Invitation.STATUS_EXPIRED, invitation.status) + } + + @Test + fun `rejects invite when email mismatches`() { + val token = "mismatch-token" + val patient = Patient("patient-1", "invite@example.com") + val invitation = Invitation( + id = UUID.randomUUID().toString(), + tokenHash = hashToken(token), + email = "invite@example.com", + role = Invitation.ROLE_PATIENT, + patient = patient, + status = Invitation.STATUS_PENDING, + expiresAt = Instant.now().plusSeconds(3600) + ) + val user = User("user-1", "other@example.com", Invitation.ROLE_PATIENT, "ACTIVE") + + whenever(invitationRepository.findByTokenHash(invitation.tokenHash)).thenReturn(invitation) + whenever(userRepository.findById("user-1")).thenReturn(Optional.of(user)) + + assertThrows(IllegalArgumentException::class.java) { + service.acceptInvite(token, "user-1") + } + } + + private fun hashToken(token: String): String { + val mac = Mac.getInstance("HmacSHA256") + val key = SecretKeySpec("test-pepper".toByteArray(Charsets.UTF_8), "HmacSHA256") + mac.init(key) + val hash = mac.doFinal(token.toByteArray(Charsets.UTF_8)) + return hash.joinToString("") { "%02x".format(it) } + } +}