From ec335b7e88be531786c0774c357040eea56548ef Mon Sep 17 00:00:00 2001 From: krestar Date: Wed, 5 Aug 2026 12:59:42 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix(security):=20Worker=20Link=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EC=A0=84=20=EC=82=AC=EC=97=85=EC=9E=A5=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=B0=94=EC=9D=B8=EB=94=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Worker Link 발급 서비스가 ActorContext를 tenant 신뢰 원본으로 사용하도록 변경 - 첫 Repository 접근 전에 transaction-local company_id 설정 - 발급 Command에서 companyId와 issuedBy 제거 - tenant context가 Repository보다 먼저 설정되는 순서 테스트 추가 --- .../workerlink/api/WorkerLinkController.java | 4 +- .../application/WorkerLinkIssueCommand.java | 14 ---- .../application/WorkerLinkService.java | 20 +++-- .../application/WorkerLinkServiceTest.java | 75 +++++++++++++++++++ 4 files changed, 89 insertions(+), 24 deletions(-) create mode 100644 src/test/java/com/fowoco/server/workerlink/application/WorkerLinkServiceTest.java diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java index 7b538393..6867733a 100644 --- a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkController.java @@ -76,13 +76,11 @@ public ResponseEntity issue( ActorContext actor = actorContextProvider.requireCurrentActor(); WorkerLinkIssueCommand command = new WorkerLinkIssueCommand( taskId, - actor.companyId(), request.getExpiresInHours(), request.isRotateExisting(), - actor.actorId(), idempotencyKey ); - WorkerLinkIssueResult result = workerLinkService.issue(command); + WorkerLinkIssueResult result = workerLinkService.issue(command, actor); return ResponseEntity.status(HttpStatus.CREATED).body(WorkerLinkIssueResponse.from(result)); } } diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java index ba3c0f7e..fd31f2f7 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkIssueCommand.java @@ -5,25 +5,19 @@ public final class WorkerLinkIssueCommand { private final UUID taskId; - private final UUID companyId; private final Long expiresInHours; private final boolean rotateExisting; - private final UUID issuedBy; private final String idempotencyKey; public WorkerLinkIssueCommand( UUID taskId, - UUID companyId, Long expiresInHours, boolean rotateExisting, - UUID issuedBy, String idempotencyKey ) { this.taskId = taskId; - this.companyId = companyId; this.expiresInHours = expiresInHours; this.rotateExisting = rotateExisting; - this.issuedBy = issuedBy; this.idempotencyKey = idempotencyKey; } @@ -31,10 +25,6 @@ public UUID taskId() { return taskId; } - public UUID companyId() { - return companyId; - } - public Long expiresInHours() { return expiresInHours; } @@ -43,10 +33,6 @@ public boolean rotateExisting() { return rotateExisting; } - public UUID issuedBy() { - return issuedBy; - } - public String idempotencyKey() { return idempotencyKey; } diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java index 53d52a37..8c2a3073 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java @@ -2,9 +2,10 @@ import com.fowoco.server.approval.application.port.ApprovalRequestRepository; import com.fowoco.server.approval.domain.ApprovalRequest; +import com.fowoco.server.auth.application.ActorContext; import com.fowoco.server.common.error.ApiException; import com.fowoco.server.common.id.UuidGenerator; -import com.fowoco.server.task.application.error.TaskErrorCode; +import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.task.application.port.TaskRepository; import com.fowoco.server.task.domain.Task; import com.fowoco.server.workerlink.application.error.WorkerLinkErrorCode; @@ -29,6 +30,7 @@ public class WorkerLinkService { private final WorkerLinkRepository workerLinkRepository; private final WorkerLinkGenerator workerLinkGenerator; private final WorkerLinkHasher workerLinkHasher; + private final TenantDatabaseContext tenantDatabaseContext; private final UuidGenerator uuidGenerator; private final Clock clock; @@ -38,6 +40,7 @@ public WorkerLinkService( WorkerLinkRepository workerLinkRepository, WorkerLinkGenerator workerLinkGenerator, WorkerLinkHasher workerLinkHasher, + TenantDatabaseContext tenantDatabaseContext, UuidGenerator uuidGenerator, Clock clock ) { @@ -46,17 +49,20 @@ public WorkerLinkService( this.workerLinkRepository = workerLinkRepository; this.workerLinkGenerator = workerLinkGenerator; this.workerLinkHasher = workerLinkHasher; + this.tenantDatabaseContext = tenantDatabaseContext; this.uuidGenerator = uuidGenerator; this.clock = clock; } @Transactional - public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command) { - Task task = taskRepository.findByIdAndCompanyId(command.taskId(), command.companyId()) + public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command, ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + + Task task = taskRepository.findByIdAndCompanyId(command.taskId(), actor.companyId()) .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.TASK_NOT_FOUND)); ApprovalRequest approval = approvalRequestRepository - .findLatestApprovedByTaskIdAndCompanyId(command.taskId(), command.companyId()) + .findLatestApprovedByTaskIdAndCompanyId(command.taskId(), actor.companyId()) .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.TASK_NOT_APPROVED)); if (!approval.isValidFor(task.contentRevision(), task.criticalFingerprint())) { @@ -73,7 +79,7 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command) { Instant now = clock.instant(); Optional existingActive = workerLinkRepository - .findActiveByTaskIdAndCompanyId(command.taskId(), command.companyId()); + .findActiveByTaskIdAndCompanyId(command.taskId(), actor.companyId()); WorkerLink previousLink = null; if (existingActive.isPresent()) { @@ -92,10 +98,10 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command) { WorkerLink workerLink = WorkerLink.issue( uuidGenerator.generate(), command.taskId(), - command.companyId(), + actor.companyId(), generated.tokenHash(), expiresAt, - command.issuedBy(), + actor.actorId(), previousLink != null ? previousLink.workerLinkId() : null, idempotencyKeyHash, now diff --git a/src/test/java/com/fowoco/server/workerlink/application/WorkerLinkServiceTest.java b/src/test/java/com/fowoco/server/workerlink/application/WorkerLinkServiceTest.java new file mode 100644 index 00000000..9fbf199a --- /dev/null +++ b/src/test/java/com/fowoco/server/workerlink/application/WorkerLinkServiceTest.java @@ -0,0 +1,75 @@ +package com.fowoco.server.workerlink.application; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fowoco.server.approval.application.port.ApprovalRequestRepository; +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.workerlink.application.port.WorkerLinkGenerator; +import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; +import com.fowoco.server.workerlink.infrastructure.security.WorkerLinkHasher; +import java.time.Clock; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +class WorkerLinkServiceTest { + + private static final UUID TASK_ID = UUID.fromString( + "10000000-0000-0000-0000-000000000001" + ); + private static final UUID COMPANY_ID = UUID.fromString( + "20000000-0000-0000-0000-000000000002" + ); + private static final UUID ACTOR_ID = UUID.fromString( + "30000000-0000-0000-0000-000000000003" + ); + + @Test + void bindsActorTenantBeforeFirstRepositoryAccess() { + TaskRepository taskRepository = mock(TaskRepository.class); + ApprovalRequestRepository approvalRepository = mock(ApprovalRequestRepository.class); + WorkerLinkRepository workerLinkRepository = mock(WorkerLinkRepository.class); + TenantDatabaseContext tenantDatabaseContext = mock(TenantDatabaseContext.class); + when(taskRepository.findByIdAndCompanyId(TASK_ID, COMPANY_ID)) + .thenReturn(Optional.empty()); + + WorkerLinkService service = new WorkerLinkService( + taskRepository, + approvalRepository, + workerLinkRepository, + mock(WorkerLinkGenerator.class), + mock(WorkerLinkHasher.class), + tenantDatabaseContext, + mock(UuidGenerator.class), + mock(Clock.class) + ); + WorkerLinkIssueCommand command = new WorkerLinkIssueCommand( + TASK_ID, + null, + false, + "worker-link-issue-1" + ); + ActorContext actor = new ActorContext( + ACTOR_ID, + COMPANY_ID, + Set.of(UserRole.HR) + ); + + assertThatThrownBy(() -> service.issue(command, actor)) + .isInstanceOf(ApiException.class); + + InOrder order = inOrder(tenantDatabaseContext, taskRepository); + order.verify(tenantDatabaseContext).setCompanyIdForCurrentTransaction(COMPANY_ID); + order.verify(taskRepository).findByIdAndCompanyId(TASK_ID, COMPANY_ID); + } +} From b80aa9ee8c518bf9d1fa80c0c904225809e6a749 Mon Sep 17 00:00:00 2001 From: krestar Date: Wed, 5 Aug 2026 13:51:01 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(database):=20V16=20=EC=82=AC=EC=97=85?= =?UTF-8?q?=EC=9E=A5=20=EB=AC=B4=EA=B2=B0=EC=84=B1=20=EB=B0=8F=20=EC=98=81?= =?UTF-8?q?=EC=86=8D=20=EA=B3=84=EC=B8=B5=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Worker Link 자식 테이블에 company_id를 backfill하고 NOT NULL로 전환 - Worker Response, 업로드 파일 및 멱등성 기록에 tenant-aware 복합 FK 적용 - Worker Link 대체 관계를 동일 사업장 복합 FK와 RESTRICT 정책으로 변경 - WorkerDocument와 Task의 근로자·사업장 복합 무결성 적용 - 문서 등록 시 Task 존재 여부와 Task–Worker 일치 검증 - Worker Link persistence 쓰기·조회 경로에 companyId 전달 - V16 migration 실패 조건과 최초 배포 비호환 전제를 문서화 - H2 기능 테스트와 PostgreSQL migration 계약 테스트 보강 --- docs/database/postgresql-rls-rollout.md | 33 ++- .../application/WorkerDocumentService.java | 18 ++ .../application/error/WorkerErrorCode.java | 6 +- .../WorkerLinkDocumentService.java | 9 +- .../application/WorkerResponseService.java | 4 +- ...erDocumentUploadIdempotencyRepository.java | 4 +- .../port/WorkerResponseRepository.java | 4 +- ...erDocumentUploadIdempotencyRepository.java | 29 ++- .../JpaWorkerResponseRepository.java | 14 +- .../V16__harden_tenant_integrity.sql | 103 ++++++++ .../server/PostgreSqlMigrationTests.java | 41 +++- .../V16TenantIntegrityMigrationTest.java | 225 ++++++++++++++++++ ...WorkerDocumentSecurityIntegrationTest.java | 64 ++++- 13 files changed, 523 insertions(+), 31 deletions(-) create mode 100644 src/main/resources/db/migration/V16__harden_tenant_integrity.sql create mode 100644 src/test/java/com/fowoco/server/V16TenantIntegrityMigrationTest.java diff --git a/docs/database/postgresql-rls-rollout.md b/docs/database/postgresql-rls-rollout.md index 2c25ba2b..00bb83f7 100644 --- a/docs/database/postgresql-rls-rollout.md +++ b/docs/database/postgresql-rls-rollout.md @@ -20,15 +20,17 @@ transaction-local tenant context와 connection pool 비누수 테스트를 준 JWT로 인증된 Worker·Task·Approval·Audit 업무 transaction은 요청 값이 아니라 `ActorContext.companyId`를 transaction-local context의 신뢰 원본으로 사용합니다. H2는 PostgreSQL custom setting을 흉내 내지 않고 transaction 경계만 검증합니다. -`V10`에서 bootstrap 함수와 tenant 테이블 RLS policy를 생성했으며, RLS는 아직 활성화하지 않았습니다. +`V10`에서 공통 bootstrap 함수와 기존 tenant 테이블 policy를, `V13`에서 Worker Link +bootstrap 함수와 policy를, `V14`에서 AI 실행 테이블 policy를 생성했습니다. RLS는 아직 +활성화하지 않았습니다. 로그인·Refresh Token·Logout은 tenant context가 생기기 전 최소 bootstrap 조회가 필요합니다. Issue #34 작성 뒤 추가된 사업장 회원가입도 새 tenant 행을 처음 만드는 -별도 bootstrap 흐름으로 함께 검토해야 합니다. Worker Link는 해당 기능이 구현된 뒤 -같은 기준으로 확장합니다. +별도 bootstrap 흐름으로 함께 검토해야 합니다. Worker Link는 `V13`에서 같은 기준으로 +확장했습니다. -현재 `main`의 V1~V9에는 `company_id`를 직접 보유한 아래 16개 tenant table과, -부모 초안의 tenant를 따르는 `document_request_draft_type`이 존재합니다. 기반 단계의 +`V16` 적용 후에는 `company_id`를 직접 보유한 아래 tenant table과, 부모 초안의 tenant를 +따르는 `document_request_draft_type`이 존재합니다. 기반 단계의 제한 role 테스트는 이 전체 범위에 업무 DML만 허용하고, table owner·DDL·`TRUNCATE`· `REFERENCES` 권한과 RLS 우회 권한이 없음을 확인합니다. @@ -38,6 +40,27 @@ H2는 PostgreSQL custom setting을 흉내 내지 않고 transaction 경계만 - `approval_request`, `external_submission`, `task_evidence`, `audit_event` - `event_publication`, `event_consumption` - `document_request_draft`, `document_request_draft_type` +- `worker_link`, `worker_response`, `worker_response_upload` +- `worker_document_upload_idempotency` +- `ai_run`, `ai_attempt`, `ai_question`, `ai_candidate` + +### V16 최초 배포 전제 + +`V16`은 Worker Link 자식 테이블의 `company_id`를 backfill하고 `NOT NULL`, 복합 +`UNIQUE`, tenant-aware 복합 FK를 한 번에 적용합니다. 또한 `worker_document`의 Task +참조를 `(task_id, worker_id, company_id)` 복합 FK로 전환합니다. + +이 migration은 pre-V16 애플리케이션이 같은 DB에 계속 쓰는 상황과 +backward-compatible하지 않습니다. 현재는 운영 DB·운영 트래픽·구버전 Pod가 없는 최초 +배포 전이므로 이 전제를 충족하며 expand-contract migration을 적용하지 않습니다. 이 +전제를 충족하지 않는 환경에 적용할 때는 쓰기 중단 또는 expand-contract 절차를 먼저 +설계해야 합니다. + +기존 개발·테스트 데이터는 신뢰할 수 있는 부모 관계에서 사업장을 복원합니다. +`worker_response_upload`는 `worker_response`, +`worker_document_upload_idempotency`는 `worker_link`를 기준으로 backfill합니다. 복원한 +사업장이 `stored_file.company_id`와 다르거나 NULL·orphan·교차 tenant 관계가 남으면 +migration을 실패시키며, 임의 사업장으로 보정하거나 행을 삭제하지 않습니다. `document_request_draft_type`에는 `company_id`가 없으므로 부모 `document_request_draft`의 `draft_id`와 현재 tenant context를 확인하는 `EXISTS` diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java index 0593bbc8..22fb2d10 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java @@ -13,6 +13,9 @@ import com.fowoco.server.common.time.DatabaseTimestamp; import com.fowoco.server.common.web.RequestMetadata; import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.task.application.error.TaskErrorCode; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; import com.fowoco.server.worker.application.error.WorkerErrorCode; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; import com.fowoco.server.worker.application.port.WorkerRepository; @@ -31,6 +34,7 @@ public class WorkerDocumentService { private final WorkerDocumentRepository workerDocumentRepository; private final WorkerRepository workerRepository; + private final TaskRepository taskRepository; private final StoredFileRepository storedFileRepository; private final AuditEventRepository auditRepository; private final TenantDatabaseContext tenantDatabaseContext; @@ -40,6 +44,7 @@ public class WorkerDocumentService { public WorkerDocumentService( WorkerDocumentRepository workerDocumentRepository, WorkerRepository workerRepository, + TaskRepository taskRepository, StoredFileRepository storedFileRepository, AuditEventRepository auditRepository, TenantDatabaseContext tenantDatabaseContext, @@ -48,6 +53,7 @@ public WorkerDocumentService( ) { this.workerDocumentRepository = workerDocumentRepository; this.workerRepository = workerRepository; + this.taskRepository = taskRepository; this.storedFileRepository = storedFileRepository; this.auditRepository = auditRepository; this.tenantDatabaseContext = tenantDatabaseContext; @@ -60,6 +66,7 @@ public WorkerDocument register(WorkerDocumentCreateCommand command, ActorContext bindTenant(actor); workerRepository.findByWorkerIdAndCompanyId(command.workerId(), actor.companyId()) .orElseThrow(() -> new ApiException(WorkerErrorCode.WORKER_NOT_FOUND)); + validateTaskAssignment(command, actor.companyId()); WorkerDocument document = WorkerDocument.create( uuidGenerator.generate(), @@ -154,6 +161,17 @@ private UUID resolveFileId(UUID requestedFileId, UUID companyId, UUID existingFi return requestedFileId; } + private void validateTaskAssignment(WorkerDocumentCreateCommand command, UUID companyId) { + if (command.taskId() == null) { + return; + } + Task task = taskRepository.findByIdAndCompanyId(command.taskId(), companyId) + .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); + if (!task.workerId().equals(command.workerId())) { + throw new ApiException(WorkerErrorCode.WORKER_DOCUMENT_TASK_WORKER_MISMATCH); + } + } + private void appendAudit( ActorContext actor, AuditAction action, diff --git a/src/main/java/com/fowoco/server/worker/application/error/WorkerErrorCode.java b/src/main/java/com/fowoco/server/worker/application/error/WorkerErrorCode.java index d1ca6fe9..51940453 100644 --- a/src/main/java/com/fowoco/server/worker/application/error/WorkerErrorCode.java +++ b/src/main/java/com/fowoco/server/worker/application/error/WorkerErrorCode.java @@ -8,7 +8,11 @@ public enum WorkerErrorCode implements ApiErrorCode { WORKER_VERSION_CONFLICT(HttpStatus.CONFLICT, "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요."), WORKER_DOCUMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "서류를 찾을 수 없습니다."), WORKER_DOCUMENT_VERSION_CONFLICT(HttpStatus.CONFLICT, "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요."), - WORKER_DOCUMENT_FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "연결할 파일을 찾을 수 없습니다."); + WORKER_DOCUMENT_FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "연결할 파일을 찾을 수 없습니다."), + WORKER_DOCUMENT_TASK_WORKER_MISMATCH( + HttpStatus.UNPROCESSABLE_CONTENT, + "업무카드의 근로자와 서류의 근로자가 일치하지 않습니다." + ); private final HttpStatus status; private final String defaultMessage; diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java index 0bf8269f..92a88dac 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkDocumentService.java @@ -102,7 +102,7 @@ public WorkerLinkDocumentUploadResult upload(WorkerLinkDocumentUploadCommand com } Optional existingStoredFileId = uploadIdempotencyRepository - .findStoredFileId(link.workerLinkId(), command.clientRequestId()); + .findStoredFileId(link.workerLinkId(), companyId, command.clientRequestId()); if (existingStoredFileId.isPresent()) { StoredFile existingFile = storedFileRepository.findByIdAndCompanyId(existingStoredFileId.get(), companyId) .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE)); @@ -130,7 +130,12 @@ public WorkerLinkDocumentUploadResult upload(WorkerLinkDocumentUploadCommand com fileStorage.store(storageKey, command.content(), command.size(), command.mimeType()); storedFileRepository.insert(verifiedFile); - uploadIdempotencyRepository.save(link.workerLinkId(), command.clientRequestId(), storedFileId); + uploadIdempotencyRepository.save( + link.workerLinkId(), + companyId, + command.clientRequestId(), + storedFileId + ); auditRepository.append(new AuditEvent( uuidGenerator.generate(), diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java index 158e296d..5771901f 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java @@ -96,7 +96,7 @@ public WorkerResponseSubmitResult submit(WorkerResponseSubmitCommand command, Re if (!storedFile.verified() || !link.taskId().equals(storedFile.taskId())) { throw new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE); } - if (workerResponseRepository.isUploadAlreadyLinked(uploadId)) { + if (workerResponseRepository.isUploadAlreadyLinked(uploadId, companyId)) { throw new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE); } } @@ -114,7 +114,7 @@ public WorkerResponseSubmitResult submit(WorkerResponseSubmitCommand command, Re workerResponseRepository.insert(response); for (UUID uploadId : uploadIds) { - workerResponseRepository.linkUpload(responseId, uploadId); + workerResponseRepository.linkUpload(responseId, uploadId, companyId); } if (command.responseType() == WorkerResponseType.QUESTION diff --git a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerDocumentUploadIdempotencyRepository.java b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerDocumentUploadIdempotencyRepository.java index da488d62..23e91c4c 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerDocumentUploadIdempotencyRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerDocumentUploadIdempotencyRepository.java @@ -5,7 +5,7 @@ public interface WorkerDocumentUploadIdempotencyRepository { - Optional findStoredFileId(UUID workerLinkId, String clientRequestId); + Optional findStoredFileId(UUID workerLinkId, UUID companyId, String clientRequestId); - void save(UUID workerLinkId, String clientRequestId, UUID storedFileId); + void save(UUID workerLinkId, UUID companyId, String clientRequestId, UUID storedFileId); } diff --git a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java index 07ca385a..75223716 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseRepository.java @@ -10,7 +10,7 @@ public interface WorkerResponseRepository { Optional findByWorkerLinkIdAndIdempotencyKey(UUID workerLinkId, String idempotencyKey); - void linkUpload(UUID responseId, UUID storedFileId); + void linkUpload(UUID responseId, UUID storedFileId, UUID companyId); - boolean isUploadAlreadyLinked(UUID storedFileId); + boolean isUploadAlreadyLinked(UUID storedFileId, UUID companyId); } diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerDocumentUploadIdempotencyRepository.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerDocumentUploadIdempotencyRepository.java index 157b101b..a1a3b2d2 100644 --- a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerDocumentUploadIdempotencyRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerDocumentUploadIdempotencyRepository.java @@ -18,32 +18,47 @@ public JpaWorkerDocumentUploadIdempotencyRepository(EntityManager entityManager) } @Override - public Optional findStoredFileId(UUID workerLinkId, String clientRequestId) { + public Optional findStoredFileId( + UUID workerLinkId, + UUID companyId, + String clientRequestId + ) { Objects.requireNonNull(workerLinkId, "workerLinkId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); Objects.requireNonNull(clientRequestId, "clientRequestId must not be null"); return entityManager.createNativeQuery( "SELECT CAST(stored_file_id AS VARCHAR) FROM worker_document_upload_idempotency " - + "WHERE worker_link_id = ?1 AND client_request_id = ?2" + + "WHERE worker_link_id = ?1 AND company_id = ?2 " + + "AND client_request_id = ?3" ) .setParameter(1, workerLinkId) - .setParameter(2, clientRequestId) + .setParameter(2, companyId) + .setParameter(3, clientRequestId) .getResultStream() .findFirst() .map(result -> UUID.fromString(result.toString())); } @Override - public void save(UUID workerLinkId, String clientRequestId, UUID storedFileId) { + public void save( + UUID workerLinkId, + UUID companyId, + String clientRequestId, + UUID storedFileId + ) { Objects.requireNonNull(workerLinkId, "workerLinkId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); Objects.requireNonNull(clientRequestId, "clientRequestId must not be null"); Objects.requireNonNull(storedFileId, "storedFileId must not be null"); Query query = entityManager.createNativeQuery( "INSERT INTO worker_document_upload_idempotency " - + "(worker_link_id, client_request_id, stored_file_id) VALUES (?1, ?2, ?3)" + + "(worker_link_id, company_id, client_request_id, stored_file_id) " + + "VALUES (?1, ?2, ?3, ?4)" ); query.setParameter(1, workerLinkId); - query.setParameter(2, clientRequestId); - query.setParameter(3, storedFileId); + query.setParameter(2, companyId); + query.setParameter(3, clientRequestId); + query.setParameter(4, storedFileId); query.executeUpdate(); } } diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java index d30252cb..8ea83f44 100644 --- a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java @@ -46,24 +46,30 @@ public Optional findByWorkerLinkIdAndIdempotencyKey(UUID workerL } @Override - public void linkUpload(UUID responseId, UUID storedFileId) { + public void linkUpload(UUID responseId, UUID storedFileId, UUID companyId) { Objects.requireNonNull(responseId, "responseId must not be null"); Objects.requireNonNull(storedFileId, "storedFileId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); Query query = entityManager.createNativeQuery( - "INSERT INTO worker_response_upload (response_id, stored_file_id) VALUES (?1, ?2)" + "INSERT INTO worker_response_upload " + + "(response_id, stored_file_id, company_id) VALUES (?1, ?2, ?3)" ); query.setParameter(1, responseId); query.setParameter(2, storedFileId); + query.setParameter(3, companyId); query.executeUpdate(); } @Override - public boolean isUploadAlreadyLinked(UUID storedFileId) { + public boolean isUploadAlreadyLinked(UUID storedFileId, UUID companyId) { Objects.requireNonNull(storedFileId, "storedFileId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); Long count = (Long) entityManager.createNativeQuery( - "SELECT COUNT(*) FROM worker_response_upload WHERE stored_file_id = ?1" + "SELECT COUNT(*) FROM worker_response_upload " + + "WHERE stored_file_id = ?1 AND company_id = ?2" ) .setParameter(1, storedFileId) + .setParameter(2, companyId) .getSingleResult(); return count != null && count > 0; } diff --git a/src/main/resources/db/migration/V16__harden_tenant_integrity.sql b/src/main/resources/db/migration/V16__harden_tenant_integrity.sql new file mode 100644 index 00000000..affa1182 --- /dev/null +++ b/src/main/resources/db/migration/V16__harden_tenant_integrity.sql @@ -0,0 +1,103 @@ +ALTER TABLE worker_response_upload + ADD COLUMN company_id UUID; + +ALTER TABLE worker_document_upload_idempotency + ADD COLUMN company_id UUID; + +UPDATE worker_response_upload +SET company_id = ( + SELECT response.company_id + FROM worker_response response + WHERE response.response_id = worker_response_upload.response_id +); + +UPDATE worker_document_upload_idempotency +SET company_id = ( + SELECT link.company_id + FROM worker_link link + WHERE link.worker_link_id = worker_document_upload_idempotency.worker_link_id +); + +ALTER TABLE task + ADD CONSTRAINT uq_task_id_worker_company + UNIQUE (task_id, worker_id, company_id); + +ALTER TABLE worker_link + ADD CONSTRAINT uq_worker_link_id_company + UNIQUE (worker_link_id, company_id); + +ALTER TABLE worker_response + ADD CONSTRAINT uq_worker_response_id_company + UNIQUE (response_id, company_id); + +ALTER TABLE stored_file + ADD CONSTRAINT uq_stored_file_id_company + UNIQUE (stored_file_id, company_id); + +ALTER TABLE worker_document + ADD CONSTRAINT fk_worker_document_task_worker_company + FOREIGN KEY (task_id, worker_id, company_id) + REFERENCES task (task_id, worker_id, company_id) ON DELETE RESTRICT; + +ALTER TABLE worker_link + ADD CONSTRAINT fk_worker_link_replaces_company + FOREIGN KEY (replaces_link_id, company_id) + REFERENCES worker_link (worker_link_id, company_id) ON DELETE RESTRICT; + +ALTER TABLE worker_response + ADD CONSTRAINT fk_worker_response_link_company + FOREIGN KEY (worker_link_id, company_id) + REFERENCES worker_link (worker_link_id, company_id) ON DELETE RESTRICT; + +ALTER TABLE worker_response_upload + ADD CONSTRAINT fk_worker_response_upload_response_company + FOREIGN KEY (response_id, company_id) + REFERENCES worker_response (response_id, company_id) ON DELETE CASCADE; + +ALTER TABLE worker_response_upload + ADD CONSTRAINT fk_worker_response_upload_file_company + FOREIGN KEY (stored_file_id, company_id) + REFERENCES stored_file (stored_file_id, company_id) ON DELETE RESTRICT; + +ALTER TABLE worker_document_upload_idempotency + ADD CONSTRAINT fk_worker_document_upload_idempotency_link_company + FOREIGN KEY (worker_link_id, company_id) + REFERENCES worker_link (worker_link_id, company_id) ON DELETE RESTRICT; + +ALTER TABLE worker_document_upload_idempotency + ADD CONSTRAINT fk_worker_document_upload_idempotency_file_company + FOREIGN KEY (stored_file_id, company_id) + REFERENCES stored_file (stored_file_id, company_id) ON DELETE RESTRICT; + +ALTER TABLE worker_document + DROP CONSTRAINT fk_worker_document_task_company; + +ALTER TABLE worker_link + DROP CONSTRAINT fk_worker_link_replaces; + +ALTER TABLE worker_response + DROP CONSTRAINT fk_worker_response_link; + +ALTER TABLE worker_response_upload + DROP CONSTRAINT fk_worker_response_upload_response; + +ALTER TABLE worker_response_upload + DROP CONSTRAINT fk_worker_response_upload_file; + +ALTER TABLE worker_document_upload_idempotency + DROP CONSTRAINT fk_worker_document_upload_idempotency_link; + +ALTER TABLE worker_document_upload_idempotency + DROP CONSTRAINT fk_worker_document_upload_idempotency_file; + +ALTER TABLE worker_response_upload + ALTER COLUMN company_id SET NOT NULL; + +ALTER TABLE worker_document_upload_idempotency + ALTER COLUMN company_id SET NOT NULL; + +CREATE INDEX idx_worker_response_upload_company + ON worker_response_upload (company_id); + +CREATE INDEX idx_worker_document_upload_idempotency_company + ON worker_document_upload_idempotency (company_id); diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 0cc06a90..db071537 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -82,7 +82,11 @@ private void assertSchemaContract(Connection connection) throws SQLException { "ai_run", "ai_attempt", "ai_question", - "ai_candidate" + "ai_candidate", + "worker_link", + "worker_response", + "worker_response_upload", + "worker_document_upload_idempotency" ); assertThat(columnSpecs(connection, "company")) @@ -120,6 +124,7 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsEntry("worker_document_id", new ColumnSpec("uuid", false)) .containsEntry("worker_id", new ColumnSpec("uuid", false)) .containsEntry("company_id", new ColumnSpec("uuid", false)) + .containsEntry("task_id", new ColumnSpec("uuid", true)) .containsEntry("document_type", new ColumnSpec("varchar", false)) .containsEntry("submission_status", new ColumnSpec("varchar", false)) .containsEntry("version", new ColumnSpec("int8", false)); @@ -200,6 +205,23 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsEntry("ai_attempt_id", new ColumnSpec("uuid", false)) .containsEntry("worker_id", new ColumnSpec("uuid", false)) .containsEntry("confidence", new ColumnSpec("numeric", false)); + assertThat(columnSpecs(connection, "worker_link")) + .containsEntry("worker_link_id", new ColumnSpec("uuid", false)) + .containsEntry("task_id", new ColumnSpec("uuid", false)) + .containsEntry("company_id", new ColumnSpec("uuid", false)) + .containsEntry("replaces_link_id", new ColumnSpec("uuid", true)); + assertThat(columnSpecs(connection, "worker_response")) + .containsEntry("response_id", new ColumnSpec("uuid", false)) + .containsEntry("worker_link_id", new ColumnSpec("uuid", false)) + .containsEntry("company_id", new ColumnSpec("uuid", false)); + assertThat(columnSpecs(connection, "worker_response_upload")) + .containsEntry("response_id", new ColumnSpec("uuid", false)) + .containsEntry("stored_file_id", new ColumnSpec("uuid", false)) + .containsEntry("company_id", new ColumnSpec("uuid", false)); + assertThat(columnSpecs(connection, "worker_document_upload_idempotency")) + .containsEntry("worker_link_id", new ColumnSpec("uuid", false)) + .containsEntry("stored_file_id", new ColumnSpec("uuid", false)) + .containsEntry("company_id", new ColumnSpec("uuid", false)); assertThat(constraintNames(connection)) .contains( @@ -237,7 +259,18 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pk_ai_question", "fk_ai_question_attempt_company", "pk_ai_candidate", - "fk_ai_candidate_worker_company" + "fk_ai_candidate_worker_company", + "uq_task_id_worker_company", + "fk_worker_document_task_worker_company", + "uq_worker_link_id_company", + "fk_worker_link_replaces_company", + "uq_worker_response_id_company", + "fk_worker_response_link_company", + "uq_stored_file_id_company", + "fk_worker_response_upload_response_company", + "fk_worker_response_upload_file_company", + "fk_worker_document_upload_idempotency_link_company", + "fk_worker_document_upload_idempotency_file_company" ); assertThat(indexNames(connection)) .contains( @@ -258,7 +291,9 @@ private void assertSchemaContract(Connection connection) throws SQLException { "idx_ai_run_company_created", "idx_ai_attempt_run", "idx_ai_question_run", - "idx_ai_candidate_run" + "idx_ai_candidate_run", + "idx_worker_response_upload_company", + "idx_worker_document_upload_idempotency_company" ); assertThat(policyNames(connection)) .containsExactlyInAnyOrder( diff --git a/src/test/java/com/fowoco/server/V16TenantIntegrityMigrationTest.java b/src/test/java/com/fowoco/server/V16TenantIntegrityMigrationTest.java new file mode 100644 index 00000000..af164e8c --- /dev/null +++ b/src/test/java/com/fowoco/server/V16TenantIntegrityMigrationTest.java @@ -0,0 +1,225 @@ +package com.fowoco.server; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.UUID; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.init.ScriptUtils; + +class V16TenantIntegrityMigrationTest { + + private static final String COMPANY_A = "10000000-0000-0000-0000-000000000001"; + private static final String COMPANY_B = "20000000-0000-0000-0000-000000000002"; + private static final String WORKER_A = "12000000-0000-0000-0000-000000000001"; + private static final String WORKER_A2 = "12000000-0000-0000-0000-000000000002"; + private static final String TASK_A = "13000000-0000-0000-0000-000000000001"; + private static final String LINK_A = "14000000-0000-0000-0000-000000000001"; + private static final String RESPONSE_A = "15000000-0000-0000-0000-000000000001"; + private static final String FILE_A = "16000000-0000-0000-0000-000000000001"; + private static final String FILE_B = "26000000-0000-0000-0000-000000000002"; + + @Test + void backfillsCompanyIdsBeforeEnforcingTenantAwareRelationships() throws SQLException { + try (Connection connection = dataSource().getConnection()) { + createPreV16Schema(connection); + insertBaseFixture(connection, FILE_A, COMPANY_A); + execute(connection, """ + INSERT INTO worker_response_upload (response_id, stored_file_id) + VALUES ('%s', '%s') + """.formatted(RESPONSE_A, FILE_A)); + execute(connection, """ + INSERT INTO worker_document_upload_idempotency ( + worker_link_id, client_request_id, stored_file_id + ) VALUES ('%s', 'upload-request-1', '%s') + """.formatted(LINK_A, FILE_A)); + execute(connection, workerDocumentInsert(WORKER_A, TASK_A)); + + applyV16(connection); + + assertThat(queryString( + connection, + "SELECT company_id FROM worker_response_upload WHERE response_id = '" + RESPONSE_A + "'" + )).isEqualTo(COMPANY_A); + assertThat(queryString( + connection, + "SELECT company_id FROM worker_document_upload_idempotency " + + "WHERE worker_link_id = '" + LINK_A + "'" + )).isEqualTo(COMPANY_A); + } + } + + @Test + void rejectsExistingCrossTenantStoredFileRelationship() throws SQLException { + try (Connection connection = dataSource().getConnection()) { + createPreV16Schema(connection); + insertBaseFixture(connection, FILE_B, COMPANY_B); + execute(connection, """ + INSERT INTO worker_document_upload_idempotency ( + worker_link_id, client_request_id, stored_file_id + ) VALUES ('%s', 'cross-tenant-upload', '%s') + """.formatted(LINK_A, FILE_B)); + + assertThatThrownBy(() -> applyV16(connection)) + .isInstanceOf(RuntimeException.class); + } + } + + @Test + void rejectsExistingTaskLinkedToAnotherWorker() throws SQLException { + try (Connection connection = dataSource().getConnection()) { + createPreV16Schema(connection); + insertBaseFixture(connection, FILE_A, COMPANY_A); + execute(connection, workerDocumentInsert(WORKER_A2, TASK_A)); + + assertThatThrownBy(() -> applyV16(connection)) + .isInstanceOf(RuntimeException.class); + } + } + + private void createPreV16Schema(Connection connection) { + String sql = """ + CREATE TABLE task ( + task_id UUID NOT NULL, + worker_id UUID NOT NULL, + company_id UUID NOT NULL, + CONSTRAINT pk_task PRIMARY KEY (task_id), + CONSTRAINT uq_task_id_company UNIQUE (task_id, company_id) + ); + + CREATE TABLE worker_document ( + worker_document_id UUID NOT NULL, + worker_id UUID NOT NULL, + company_id UUID NOT NULL, + task_id UUID, + CONSTRAINT pk_worker_document PRIMARY KEY (worker_document_id), + CONSTRAINT fk_worker_document_task_company + FOREIGN KEY (task_id, company_id) + REFERENCES task (task_id, company_id) ON DELETE RESTRICT + ); + + CREATE TABLE worker_link ( + worker_link_id UUID NOT NULL, + company_id UUID NOT NULL, + replaces_link_id UUID, + CONSTRAINT pk_worker_link PRIMARY KEY (worker_link_id), + CONSTRAINT fk_worker_link_replaces + FOREIGN KEY (replaces_link_id) + REFERENCES worker_link (worker_link_id) ON DELETE SET NULL + ); + + CREATE TABLE worker_response ( + response_id UUID NOT NULL, + worker_link_id UUID NOT NULL, + company_id UUID NOT NULL, + CONSTRAINT pk_worker_response PRIMARY KEY (response_id), + CONSTRAINT fk_worker_response_link + FOREIGN KEY (worker_link_id) + REFERENCES worker_link (worker_link_id) ON DELETE RESTRICT + ); + + CREATE TABLE stored_file ( + stored_file_id UUID NOT NULL, + company_id UUID NOT NULL, + CONSTRAINT pk_stored_file PRIMARY KEY (stored_file_id) + ); + + CREATE TABLE worker_response_upload ( + response_id UUID NOT NULL, + stored_file_id UUID NOT NULL, + CONSTRAINT pk_worker_response_upload PRIMARY KEY (response_id, stored_file_id), + CONSTRAINT fk_worker_response_upload_response + FOREIGN KEY (response_id) + REFERENCES worker_response (response_id) ON DELETE CASCADE, + CONSTRAINT fk_worker_response_upload_file + FOREIGN KEY (stored_file_id) + REFERENCES stored_file (stored_file_id) ON DELETE RESTRICT + ); + + CREATE TABLE worker_document_upload_idempotency ( + worker_link_id UUID NOT NULL, + client_request_id VARCHAR(100) NOT NULL, + stored_file_id UUID NOT NULL, + CONSTRAINT pk_worker_document_upload_idempotency + PRIMARY KEY (worker_link_id, client_request_id), + CONSTRAINT fk_worker_document_upload_idempotency_link + FOREIGN KEY (worker_link_id) + REFERENCES worker_link (worker_link_id) ON DELETE RESTRICT, + CONSTRAINT fk_worker_document_upload_idempotency_file + FOREIGN KEY (stored_file_id) + REFERENCES stored_file (stored_file_id) ON DELETE RESTRICT + ); + """; + ScriptUtils.executeSqlScript( + connection, + new ByteArrayResource(sql.getBytes(StandardCharsets.UTF_8)) + ); + } + + private void insertBaseFixture( + Connection connection, + String storedFileId, + String storedFileCompanyId + ) throws SQLException { + execute(connection, """ + INSERT INTO task (task_id, worker_id, company_id) + VALUES ('%s', '%s', '%s') + """.formatted(TASK_A, WORKER_A, COMPANY_A)); + execute(connection, """ + INSERT INTO stored_file (stored_file_id, company_id) + VALUES ('%s', '%s') + """.formatted(storedFileId, storedFileCompanyId)); + execute(connection, """ + INSERT INTO worker_link (worker_link_id, company_id) + VALUES ('%s', '%s') + """.formatted(LINK_A, COMPANY_A)); + execute(connection, """ + INSERT INTO worker_response (response_id, worker_link_id, company_id) + VALUES ('%s', '%s', '%s') + """.formatted(RESPONSE_A, LINK_A, COMPANY_A)); + } + + private String workerDocumentInsert(String workerId, String taskId) { + return """ + INSERT INTO worker_document ( + worker_document_id, worker_id, company_id, task_id + ) VALUES ('%s', '%s', '%s', '%s') + """.formatted(UUID.randomUUID(), workerId, COMPANY_A, taskId); + } + + private void applyV16(Connection connection) { + ScriptUtils.executeSqlScript( + connection, + new ClassPathResource("db/migration/V16__harden_tenant_integrity.sql") + ); + } + + private DataSource dataSource() { + String url = "jdbc:h2:mem:v16_" + UUID.randomUUID() + + ";MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH"; + return new DriverManagerDataSource(url, "sa", ""); + } + + private void execute(Connection connection, String sql) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute(sql); + } + } + + private String queryString(Connection connection, String sql) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(sql)) { + assertThat(resultSet.next()).isTrue(); + return resultSet.getString(1); + } + } +} diff --git a/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java index 48c87c31..e52fe646 100644 --- a/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/worker/WorkerDocumentSecurityIntegrationTest.java @@ -175,6 +175,56 @@ void listDocumentsFiltersByTaskId() throws Exception { assertThat(ids).doesNotContain(documentIdWithoutTask); } + @Test + void registerRejectsTaskOwnedByAnotherWorkerInSameCompany() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + String anotherWorkerId = registerWorker(accessToken, "다른 근로자"); + String anotherWorkerTaskId = createTask(accessToken, anotherWorkerId); + + HttpResponse response = registerDocumentRequest( + accessToken, + workerIdInCompanyA, + anotherWorkerTaskId + ); + + assertThat(response.statusCode()).isEqualTo(422); + assertThat(JsonPath.read(response.body(), "$.code")) + .isEqualTo("WORKER_DOCUMENT_TASK_WORKER_MISMATCH"); + } + + @Test + void registerReturnsNotFoundForTaskFromAnotherCompany() throws Exception { + String companyAToken = accessToken(login(HR_A_EMAIL)); + String companyBToken = accessToken(login(HR_B_EMAIL)); + String companyBWorkerId = registerWorker(companyBToken, "다른 사업장 근로자"); + String companyBTaskId = createTask(companyBToken, companyBWorkerId); + + HttpResponse response = registerDocumentRequest( + companyAToken, + workerIdInCompanyA, + companyBTaskId + ); + + assertThat(response.statusCode()).isEqualTo(404); + assertThat(JsonPath.read(response.body(), "$.code")) + .isEqualTo("TASK_NOT_FOUND"); + } + + @Test + void registerReturnsNotFoundForUnknownTask() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = registerDocumentRequest( + accessToken, + workerIdInCompanyA, + "70000000-0000-0000-0000-000000000099" + ); + + assertThat(response.statusCode()).isEqualTo(404); + assertThat(JsonPath.read(response.body(), "$.code")) + .isEqualTo("TASK_NOT_FOUND"); + } + @Test void documentFromAnotherCompanyIsReturnedAsNotFoundOnPatch() throws Exception { String companyAToken = accessToken(login(HR_A_EMAIL)); @@ -235,16 +285,24 @@ private String registerDocument(String accessToken, String workerId) throws Exce } private String registerDocument(String accessToken, String workerId, String taskId) throws Exception { + HttpResponse response = registerDocumentRequest(accessToken, workerId, taskId); + assertThat(response.statusCode()).isEqualTo(201); + return JsonPath.read(response.body(), "$.worker_document_id"); + } + + private HttpResponse registerDocumentRequest( + String accessToken, + String workerId, + String taskId + ) throws Exception { String body = """ {"document_type": "PASSPORT_COPY", "submission_status": "MISSING", "task_id": %s} """.formatted(taskId == null ? "null" : "\"" + taskId + "\""); - HttpResponse response = postJson( + return postJson( "/api/v1/workers/" + workerId + "/documents", body, accessToken ); - assertThat(response.statusCode()).isEqualTo(201); - return JsonPath.read(response.body(), "$.worker_document_id"); } private String createTask(String accessToken, String workerId) throws Exception { From df953d70ce1c50ce939663677adee17ddb88ba8a Mon Sep 17 00:00:00 2001 From: krestar Date: Wed, 5 Aug 2026 14:16:51 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix(database):=20Worker=20Link=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=ED=8C=8C=EC=9D=BC=EC=9D=98=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=20=EC=97=B0=EA=B2=B0=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 응답 업로드 파일에 사업장 단위 복합 UNIQUE 제약 추가 - 업로드 멱등성 파일 복합 FK 조회 인덱스 추가 - 동시 연결 시 발생한 특정 UNIQUE 위반을 도메인 오류로 변환 - 다른 데이터 무결성 오류가 잘못 변환되지 않도록 제약 이름과 SQLSTATE 검증 - V16 migration 및 PostgreSQL 스키마 계약 테스트 보강 --- .../application/WorkerResponseService.java | 7 +++- ...rResponseUploadAlreadyLinkedException.java | 8 ++++ .../JpaWorkerResponseRepository.java | 38 ++++++++++++++++++- .../V16__harden_tenant_integrity.sql | 7 ++++ .../server/PostgreSqlMigrationTests.java | 4 +- .../V16TenantIntegrityMigrationTest.java | 28 ++++++++++++++ .../JpaWorkerResponseRepositoryTest.java | 32 ++++++++++++++++ 7 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseUploadAlreadyLinkedException.java create mode 100644 src/test/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepositoryTest.java diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java index 5771901f..a82d6c98 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java @@ -15,6 +15,7 @@ import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; import com.fowoco.server.workerlink.application.port.WorkerLinkTenantBootstrap; import com.fowoco.server.workerlink.application.port.WorkerResponseRepository; +import com.fowoco.server.workerlink.application.port.WorkerResponseUploadAlreadyLinkedException; import com.fowoco.server.workerlink.domain.WorkerLink; import com.fowoco.server.workerlink.domain.WorkerResponse; import com.fowoco.server.workerlink.domain.WorkerResponseType; @@ -114,7 +115,11 @@ public WorkerResponseSubmitResult submit(WorkerResponseSubmitCommand command, Re workerResponseRepository.insert(response); for (UUID uploadId : uploadIds) { - workerResponseRepository.linkUpload(responseId, uploadId, companyId); + try { + workerResponseRepository.linkUpload(responseId, uploadId, companyId); + } catch (WorkerResponseUploadAlreadyLinkedException exception) { + throw new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE); + } } if (command.responseType() == WorkerResponseType.QUESTION diff --git a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseUploadAlreadyLinkedException.java b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseUploadAlreadyLinkedException.java new file mode 100644 index 00000000..884a755f --- /dev/null +++ b/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseUploadAlreadyLinkedException.java @@ -0,0 +1,8 @@ +package com.fowoco.server.workerlink.application.port; + +public class WorkerResponseUploadAlreadyLinkedException extends RuntimeException { + + public WorkerResponseUploadAlreadyLinkedException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java index 8ea83f44..b3ea931a 100644 --- a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java @@ -1,17 +1,24 @@ package com.fowoco.server.workerlink.infrastructure.persistence; import com.fowoco.server.workerlink.application.port.WorkerResponseRepository; +import com.fowoco.server.workerlink.application.port.WorkerResponseUploadAlreadyLinkedException; import com.fowoco.server.workerlink.domain.WorkerResponse; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; +import java.sql.SQLException; +import java.util.Locale; import java.util.Objects; import java.util.Optional; import java.util.UUID; +import org.hibernate.exception.ConstraintViolationException; import org.springframework.stereotype.Repository; @Repository public class JpaWorkerResponseRepository implements WorkerResponseRepository { + private static final String UNIQUE_UPLOAD_FILE_CONSTRAINT = + "uq_worker_response_upload_file_company"; + private final EntityManager entityManager; public JpaWorkerResponseRepository(EntityManager entityManager) { @@ -57,7 +64,14 @@ public void linkUpload(UUID responseId, UUID storedFileId, UUID companyId) { query.setParameter(1, responseId); query.setParameter(2, storedFileId); query.setParameter(3, companyId); - query.executeUpdate(); + try { + query.executeUpdate(); + } catch (RuntimeException exception) { + if (isUniqueUploadFileViolation(exception)) { + throw new WorkerResponseUploadAlreadyLinkedException(exception); + } + throw exception; + } } @Override @@ -73,4 +87,26 @@ public boolean isUploadAlreadyLinked(UUID storedFileId, UUID companyId) { .getSingleResult(); return count != null && count > 0; } + + static boolean isUniqueUploadFileViolation(Throwable failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof ConstraintViolationException constraintViolation + && containsConstraintName(constraintViolation.getConstraintName())) { + return true; + } + if (current instanceof SQLException sqlException + && "23505".equals(sqlException.getSQLState()) + && containsConstraintName(sqlException.getMessage())) { + return true; + } + current = current.getCause(); + } + return false; + } + + private static boolean containsConstraintName(String value) { + return value != null + && value.toLowerCase(Locale.ROOT).contains(UNIQUE_UPLOAD_FILE_CONSTRAINT); + } } diff --git a/src/main/resources/db/migration/V16__harden_tenant_integrity.sql b/src/main/resources/db/migration/V16__harden_tenant_integrity.sql index affa1182..998e1cd3 100644 --- a/src/main/resources/db/migration/V16__harden_tenant_integrity.sql +++ b/src/main/resources/db/migration/V16__harden_tenant_integrity.sql @@ -34,6 +34,10 @@ ALTER TABLE stored_file ADD CONSTRAINT uq_stored_file_id_company UNIQUE (stored_file_id, company_id); +ALTER TABLE worker_response_upload + ADD CONSTRAINT uq_worker_response_upload_file_company + UNIQUE (stored_file_id, company_id); + ALTER TABLE worker_document ADD CONSTRAINT fk_worker_document_task_worker_company FOREIGN KEY (task_id, worker_id, company_id) @@ -101,3 +105,6 @@ CREATE INDEX idx_worker_response_upload_company CREATE INDEX idx_worker_document_upload_idempotency_company ON worker_document_upload_idempotency (company_id); + +CREATE INDEX idx_worker_document_upload_idempotency_file_company + ON worker_document_upload_idempotency (stored_file_id, company_id); diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index db071537..0194c45f 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -267,6 +267,7 @@ private void assertSchemaContract(Connection connection) throws SQLException { "uq_worker_response_id_company", "fk_worker_response_link_company", "uq_stored_file_id_company", + "uq_worker_response_upload_file_company", "fk_worker_response_upload_response_company", "fk_worker_response_upload_file_company", "fk_worker_document_upload_idempotency_link_company", @@ -293,7 +294,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "idx_ai_question_run", "idx_ai_candidate_run", "idx_worker_response_upload_company", - "idx_worker_document_upload_idempotency_company" + "idx_worker_document_upload_idempotency_company", + "idx_worker_document_upload_idempotency_file_company" ); assertThat(policyNames(connection)) .containsExactlyInAnyOrder( diff --git a/src/test/java/com/fowoco/server/V16TenantIntegrityMigrationTest.java b/src/test/java/com/fowoco/server/V16TenantIntegrityMigrationTest.java index af164e8c..074a6b26 100644 --- a/src/test/java/com/fowoco/server/V16TenantIntegrityMigrationTest.java +++ b/src/test/java/com/fowoco/server/V16TenantIntegrityMigrationTest.java @@ -86,6 +86,34 @@ void rejectsExistingTaskLinkedToAnotherWorker() throws SQLException { } } + @Test + void preventsStoredFileFromBeingLinkedToMultipleResponses() throws SQLException { + try (Connection connection = dataSource().getConnection()) { + createPreV16Schema(connection); + insertBaseFixture(connection, FILE_A, COMPANY_A); + applyV16(connection); + + String secondResponseId = "15000000-0000-0000-0000-000000000002"; + execute(connection, """ + INSERT INTO worker_response ( + response_id, worker_link_id, company_id + ) VALUES ('%s', '%s', '%s') + """.formatted(secondResponseId, LINK_A, COMPANY_A)); + execute(connection, """ + INSERT INTO worker_response_upload ( + response_id, stored_file_id, company_id + ) VALUES ('%s', '%s', '%s') + """.formatted(RESPONSE_A, FILE_A, COMPANY_A)); + + assertThatThrownBy(() -> execute(connection, """ + INSERT INTO worker_response_upload ( + response_id, stored_file_id, company_id + ) VALUES ('%s', '%s', '%s') + """.formatted(secondResponseId, FILE_A, COMPANY_A))) + .isInstanceOf(SQLException.class); + } + } + private void createPreV16Schema(Connection connection) { String sql = """ CREATE TABLE task ( diff --git a/src/test/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepositoryTest.java b/src/test/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepositoryTest.java new file mode 100644 index 00000000..2583c83c --- /dev/null +++ b/src/test/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepositoryTest.java @@ -0,0 +1,32 @@ +package com.fowoco.server.workerlink.infrastructure.persistence; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +class JpaWorkerResponseRepositoryTest { + + @Test + void identifiesOnlyTheUploadFileUniqueConstraint() { + RuntimeException uploadAlreadyLinked = new RuntimeException(new SQLException( + "duplicate key violates constraint uq_worker_response_upload_file_company", + "23505" + )); + RuntimeException unrelatedUniqueViolation = new RuntimeException(new SQLException( + "duplicate key violates constraint uq_worker_response_idempotency", + "23505" + )); + RuntimeException uploadForeignKeyViolation = new RuntimeException(new SQLException( + "violates constraint uq_worker_response_upload_file_company", + "23503" + )); + + assertThat(JpaWorkerResponseRepository.isUniqueUploadFileViolation(uploadAlreadyLinked)) + .isTrue(); + assertThat(JpaWorkerResponseRepository.isUniqueUploadFileViolation(unrelatedUniqueViolation)) + .isFalse(); + assertThat(JpaWorkerResponseRepository.isUniqueUploadFileViolation(uploadForeignKeyViolation)) + .isFalse(); + } +} From d7141c62dd2408f6288b043b6e71c075fa2fc04b Mon Sep 17 00:00:00 2001 From: krestar Date: Wed, 5 Aug 2026 14:26:10 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor(workerlink):=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=97=B0=EA=B2=B0=20=EC=98=88=EC=99=B8=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 업로드 중복 연결 예외를 application.port에서 application.error로 이동 --- .../server/workerlink/application/WorkerResponseService.java | 2 +- .../WorkerResponseUploadAlreadyLinkedException.java | 2 +- .../infrastructure/persistence/JpaWorkerResponseRepository.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/main/java/com/fowoco/server/workerlink/application/{port => error}/WorkerResponseUploadAlreadyLinkedException.java (77%) diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java index a82d6c98..f75d8e4c 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerResponseService.java @@ -12,10 +12,10 @@ import com.fowoco.server.file.application.port.StoredFileRepository; import com.fowoco.server.file.domain.StoredFile; import com.fowoco.server.workerlink.application.error.WorkerLinkErrorCode; +import com.fowoco.server.workerlink.application.error.WorkerResponseUploadAlreadyLinkedException; import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; import com.fowoco.server.workerlink.application.port.WorkerLinkTenantBootstrap; import com.fowoco.server.workerlink.application.port.WorkerResponseRepository; -import com.fowoco.server.workerlink.application.port.WorkerResponseUploadAlreadyLinkedException; import com.fowoco.server.workerlink.domain.WorkerLink; import com.fowoco.server.workerlink.domain.WorkerResponse; import com.fowoco.server.workerlink.domain.WorkerResponseType; diff --git a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseUploadAlreadyLinkedException.java b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerResponseUploadAlreadyLinkedException.java similarity index 77% rename from src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseUploadAlreadyLinkedException.java rename to src/main/java/com/fowoco/server/workerlink/application/error/WorkerResponseUploadAlreadyLinkedException.java index 884a755f..976125c3 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/port/WorkerResponseUploadAlreadyLinkedException.java +++ b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerResponseUploadAlreadyLinkedException.java @@ -1,4 +1,4 @@ -package com.fowoco.server.workerlink.application.port; +package com.fowoco.server.workerlink.application.error; public class WorkerResponseUploadAlreadyLinkedException extends RuntimeException { diff --git a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java index b3ea931a..a963e7da 100644 --- a/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java +++ b/src/main/java/com/fowoco/server/workerlink/infrastructure/persistence/JpaWorkerResponseRepository.java @@ -1,7 +1,7 @@ package com.fowoco.server.workerlink.infrastructure.persistence; +import com.fowoco.server.workerlink.application.error.WorkerResponseUploadAlreadyLinkedException; import com.fowoco.server.workerlink.application.port.WorkerResponseRepository; -import com.fowoco.server.workerlink.application.port.WorkerResponseUploadAlreadyLinkedException; import com.fowoco.server.workerlink.domain.WorkerResponse; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; From 90a01fa2a573628acee3de32330382a1a37b0048 Mon Sep 17 00:00:00 2001 From: krestar Date: Wed, 5 Aug 2026 14:54:10 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat(database):=20V17=20Worker=20Link=20RLS?= =?UTF-8?q?=20=EC=A0=95=EC=B1=85=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Worker Response Upload 정책을 직접 company_id 비교 방식으로 전환 - Worker Document Upload 멱등성 테이블에 tenant 격리 정책 추가 - V17에서는 RLS를 활성화하지 않고 정책만 준비 - PostgreSQL migration 정책 계약 테스트 보강 - 제한 runtime role의 Worker Link tenant 격리 테스트 추가 - RLS rollout 문서에 V17 적용 범위 반영 --- docs/database/postgresql-rls-rollout.md | 5 +- ...gn_worker_link_rls_with_tenant_columns.sql | 23 ++++ .../server/PostgreSqlMigrationTests.java | 3 +- .../security/PostgreSqlRlsIsolationTest.java | 125 +++++++++++++++++- 4 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 src/main/resources/db/migration-postgresql/V17__align_worker_link_rls_with_tenant_columns.sql diff --git a/docs/database/postgresql-rls-rollout.md b/docs/database/postgresql-rls-rollout.md index 00bb83f7..4ee9d240 100644 --- a/docs/database/postgresql-rls-rollout.md +++ b/docs/database/postgresql-rls-rollout.md @@ -21,8 +21,9 @@ JWT로 인증된 Worker·Task·Approval·Audit 업무 transaction은 요청 값 `ActorContext.companyId`를 transaction-local context의 신뢰 원본으로 사용합니다. H2는 PostgreSQL custom setting을 흉내 내지 않고 transaction 경계만 검증합니다. `V10`에서 공통 bootstrap 함수와 기존 tenant 테이블 policy를, `V13`에서 Worker Link -bootstrap 함수와 policy를, `V14`에서 AI 실행 테이블 policy를 생성했습니다. RLS는 아직 -활성화하지 않았습니다. +bootstrap 함수와 policy를, `V14`에서 AI 실행 테이블 policy를 생성했습니다. `V17`은 +V16에서 추가한 직접 tenant 컬럼에 맞춰 Worker Link 업로드 policy를 단순화하고 업로드 +멱등성 테이블 policy를 추가합니다. RLS는 아직 활성화하지 않았습니다. 로그인·Refresh Token·Logout은 tenant context가 생기기 전 최소 bootstrap 조회가 필요합니다. Issue #34 작성 뒤 추가된 사업장 회원가입도 새 tenant 행을 처음 만드는 diff --git a/src/main/resources/db/migration-postgresql/V17__align_worker_link_rls_with_tenant_columns.sql b/src/main/resources/db/migration-postgresql/V17__align_worker_link_rls_with_tenant_columns.sql new file mode 100644 index 00000000..4944c31b --- /dev/null +++ b/src/main/resources/db/migration-postgresql/V17__align_worker_link_rls_with_tenant_columns.sql @@ -0,0 +1,23 @@ +ALTER POLICY pl_worker_response_upload_tenant_isolation + ON public.worker_response_upload + USING ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); + +CREATE POLICY pl_worker_document_upload_idempotency_tenant_isolation + ON public.worker_document_upload_idempotency + FOR ALL + TO PUBLIC + USING ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 0194c45f..3309b8f6 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -322,7 +322,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pl_ai_candidate_tenant_isolation", "pl_worker_link_tenant_isolation", "pl_worker_response_tenant_isolation", - "pl_worker_response_upload_tenant_isolation" + "pl_worker_response_upload_tenant_isolation", + "pl_worker_document_upload_idempotency_tenant_isolation" ); assertThat(rlsEnabledTables(connection)).isEmpty(); assertThat(securityDefinerFunctionNames(connection)) diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java index 8494fa67..d75e0ce8 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java @@ -45,6 +45,14 @@ class PostgreSqlRlsIsolationTest { UUID.fromString("a6000000-0000-0000-0000-000000000001"); private static final UUID DRAFT_B = UUID.fromString("b6000000-0000-0000-0000-000000000002"); + private static final UUID WORKER_LINK_A = + UUID.fromString("a7000000-0000-0000-0000-000000000001"); + private static final UUID WORKER_LINK_B = + UUID.fromString("b7000000-0000-0000-0000-000000000002"); + private static final UUID WORKER_RESPONSE_A = + UUID.fromString("a8000000-0000-0000-0000-000000000001"); + private static final UUID WORKER_RESPONSE_B = + UUID.fromString("b8000000-0000-0000-0000-000000000002"); @Test void restrictedRoleEnforcesTenantCrudAndFailsClosedWithoutValidContext() @@ -114,7 +122,9 @@ private void prepareFixture( "GRANT SELECT, INSERT, UPDATE, DELETE " + "ON TABLE public.company, public.worker, " + "public.stored_file, public.document_request_draft, " - + "public.document_request_draft_type TO " + + "public.document_request_draft_type, public.worker_link, " + + "public.worker_response, public.worker_response_upload, " + + "public.worker_document_upload_idempotency TO " + quotedRole ); @@ -184,6 +194,52 @@ INSERT INTO document_request_draft_type (draft_id, document_type) ('%s', 'PASSPORT_COPY'), ('%s', 'ARC') """.formatted(DRAFT_A, DRAFT_B)); + statement.execute(""" + INSERT INTO worker_link ( + worker_link_id, task_id, company_id, token_hash, expires_at, + status, conversation_status, issued_by, idempotency_key + ) VALUES + ('%s', '%s', '%s', repeat('c', 64), + CURRENT_TIMESTAMP + INTERVAL '1 day', 'ACTIVE', + 'WAITING_WORKER', '%s', 'rls-link-a'), + ('%s', '%s', '%s', repeat('d', 64), + CURRENT_TIMESTAMP + INTERVAL '1 day', 'ACTIVE', + 'WAITING_WORKER', '%s', 'rls-link-b') + """.formatted( + WORKER_LINK_A, TASK_A, COMPANY_A, USER_A, + WORKER_LINK_B, TASK_B, COMPANY_B, USER_B + )); + statement.execute(""" + INSERT INTO worker_response ( + response_id, worker_link_id, company_id, + response_type, idempotency_key + ) VALUES + ('%s', '%s', '%s', 'DOCUMENT_SUBMITTED', 'rls-response-a'), + ('%s', '%s', '%s', 'DOCUMENT_SUBMITTED', 'rls-response-b') + """.formatted( + WORKER_RESPONSE_A, WORKER_LINK_A, COMPANY_A, + WORKER_RESPONSE_B, WORKER_LINK_B, COMPANY_B + )); + statement.execute(""" + INSERT INTO worker_response_upload ( + response_id, stored_file_id, company_id + ) VALUES + ('%s', '%s', '%s'), + ('%s', '%s', '%s') + """.formatted( + WORKER_RESPONSE_A, STORED_FILE_A, COMPANY_A, + WORKER_RESPONSE_B, STORED_FILE_B, COMPANY_B + )); + statement.execute(""" + INSERT INTO worker_document_upload_idempotency ( + worker_link_id, company_id, client_request_id, stored_file_id + ) VALUES + ('%s', '%s', 'rls-upload-a', '%s'), + ('%s', '%s', 'rls-upload-b', '%s') + """.formatted( + WORKER_LINK_A, COMPANY_A, STORED_FILE_A, + WORKER_LINK_B, COMPANY_B, STORED_FILE_B + )); statement.execute("ALTER TABLE public.company ENABLE ROW LEVEL SECURITY"); statement.execute("ALTER TABLE public.worker ENABLE ROW LEVEL SECURITY"); @@ -192,6 +248,15 @@ INSERT INTO document_request_draft_type (draft_id, document_type) statement.execute( "ALTER TABLE public.document_request_draft_type ENABLE ROW LEVEL SECURITY" ); + statement.execute("ALTER TABLE public.worker_link ENABLE ROW LEVEL SECURITY"); + statement.execute("ALTER TABLE public.worker_response ENABLE ROW LEVEL SECURITY"); + statement.execute( + "ALTER TABLE public.worker_response_upload ENABLE ROW LEVEL SECURITY" + ); + statement.execute( + "ALTER TABLE public.worker_document_upload_idempotency " + + "ENABLE ROW LEVEL SECURITY" + ); } } @@ -203,6 +268,10 @@ private void assertMissingAndInvalidContextFailClosed(Connection connection) assertThat(tableCount(connection, "stored_file")).isZero(); assertThat(tableCount(connection, "document_request_draft")).isZero(); assertThat(tableCount(connection, "document_request_draft_type")).isZero(); + assertThat(tableCount(connection, "worker_link")).isZero(); + assertThat(tableCount(connection, "worker_response")).isZero(); + assertThat(tableCount(connection, "worker_response_upload")).isZero(); + assertThat(tableCount(connection, "worker_document_upload_idempotency")).isZero(); setTenantContext(connection, ""); assertThat(workerCount(connection)).isZero(); @@ -234,6 +303,24 @@ private void assertTenantCrudIsolation(Connection connection) throws SQLExceptio "SELECT document_type FROM public.document_request_draft_type " + "ORDER BY document_type" )).containsExactly("PASSPORT_COPY"); + assertThat(uuidValues( + connection, + "SELECT worker_link_id FROM public.worker_link ORDER BY worker_link_id" + )).containsExactly(WORKER_LINK_A); + assertThat(uuidValues( + connection, + "SELECT response_id FROM public.worker_response ORDER BY response_id" + )).containsExactly(WORKER_RESPONSE_A); + assertThat(uuidValues( + connection, + "SELECT stored_file_id FROM public.worker_response_upload " + + "ORDER BY stored_file_id" + )).containsExactly(STORED_FILE_A); + assertThat(uuidValues( + connection, + "SELECT stored_file_id FROM public.worker_document_upload_idempotency " + + "ORDER BY stored_file_id" + )).containsExactly(STORED_FILE_A); assertThat(executeUpdate( connection, """ @@ -292,6 +379,17 @@ INSERT INTO document_request_draft_type (draft_id, document_type) VALUES ('%s', 'CONTRACT') """.formatted(DRAFT_B) ); + assertSqlState( + connection, + "42501", + """ + INSERT INTO worker_document_upload_idempotency ( + worker_link_id, company_id, client_request_id, stored_file_id + ) VALUES ( + '%s', '%s', 'forbidden-rls-upload-b', '%s' + ) + """.formatted(WORKER_LINK_B, COMPANY_B, STORED_FILE_B) + ); assertThat(executeUpdate( connection, @@ -327,6 +425,15 @@ private void assertCommittedContextDoesNotLeak(Connection connection) throws SQL private void restoreFixture(Connection connection, String runtimeRole) throws SQLException { try (Statement statement = connection.createStatement()) { + statement.execute( + "ALTER TABLE public.worker_document_upload_idempotency " + + "DISABLE ROW LEVEL SECURITY" + ); + statement.execute( + "ALTER TABLE public.worker_response_upload DISABLE ROW LEVEL SECURITY" + ); + statement.execute("ALTER TABLE public.worker_response DISABLE ROW LEVEL SECURITY"); + statement.execute("ALTER TABLE public.worker_link DISABLE ROW LEVEL SECURITY"); statement.execute( "ALTER TABLE public.document_request_draft_type DISABLE ROW LEVEL SECURITY" ); @@ -344,6 +451,22 @@ private void restoreFixture(Connection connection, String runtimeRole) throws SQ } private void deleteFixtureRows(Statement statement) throws SQLException { + statement.execute(""" + DELETE FROM worker_document_upload_idempotency + WHERE worker_link_id IN ('%s', '%s') + """.formatted(WORKER_LINK_A, WORKER_LINK_B)); + statement.execute(""" + DELETE FROM worker_response_upload + WHERE response_id IN ('%s', '%s') + """.formatted(WORKER_RESPONSE_A, WORKER_RESPONSE_B)); + statement.execute(""" + DELETE FROM worker_response + WHERE response_id IN ('%s', '%s') + """.formatted(WORKER_RESPONSE_A, WORKER_RESPONSE_B)); + statement.execute(""" + DELETE FROM worker_link + WHERE worker_link_id IN ('%s', '%s') + """.formatted(WORKER_LINK_A, WORKER_LINK_B)); statement.execute(""" DELETE FROM document_request_draft_type WHERE draft_id IN ('%s', '%s') From 2a05ef16103cb69d0518d36bdbd270ea88e66691 Mon Sep 17 00:00:00 2001 From: krestar Date: Wed, 5 Aug 2026 15:33:05 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix(security):=20Worker=20Link=20bootstrap?= =?UTF-8?q?=20=ED=97=88=EC=9A=A9=20=EC=A1=B0=EA=B1=B4=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Worker Link bootstrap이 ACTIVE 상태의 링크만 사업장을 반환하도록 제한 - DB 현재 시각을 기준으로 만료된 링크의 tenant bootstrap 차단 - ACTIVE, REVOKED, 만료 및 미등록 token hash PostgreSQL 테스트 추가 - Worker Response Upload의 교차 tenant INSERT 차단 테스트 보강 - PostgreSQL RLS rollout 문서에 bootstrap 조건 반영 --- docs/database/postgresql-rls-rollout.md | 3 +- ...gn_worker_link_rls_with_tenant_columns.sql | 21 ++++++++ .../server/PostgreSqlMigrationTests.java | 53 +++++++++++++++++++ .../security/PostgreSqlRlsIsolationTest.java | 30 +++++++++-- 4 files changed, 102 insertions(+), 5 deletions(-) diff --git a/docs/database/postgresql-rls-rollout.md b/docs/database/postgresql-rls-rollout.md index 4ee9d240..e224098d 100644 --- a/docs/database/postgresql-rls-rollout.md +++ b/docs/database/postgresql-rls-rollout.md @@ -23,7 +23,8 @@ H2는 PostgreSQL custom setting을 흉내 내지 않고 transaction 경계만 `V10`에서 공통 bootstrap 함수와 기존 tenant 테이블 policy를, `V13`에서 Worker Link bootstrap 함수와 policy를, `V14`에서 AI 실행 테이블 policy를 생성했습니다. `V17`은 V16에서 추가한 직접 tenant 컬럼에 맞춰 Worker Link 업로드 policy를 단순화하고 업로드 -멱등성 테이블 policy를 추가합니다. RLS는 아직 활성화하지 않았습니다. +멱등성 테이블 policy를 추가합니다. Worker Link bootstrap도 ACTIVE이면서 DB 시각 기준 +미만료인 링크만 사업장을 반환하도록 제한합니다. RLS는 아직 활성화하지 않았습니다. 로그인·Refresh Token·Logout은 tenant context가 생기기 전 최소 bootstrap 조회가 필요합니다. Issue #34 작성 뒤 추가된 사업장 회원가입도 새 tenant 행을 처음 만드는 diff --git a/src/main/resources/db/migration-postgresql/V17__align_worker_link_rls_with_tenant_columns.sql b/src/main/resources/db/migration-postgresql/V17__align_worker_link_rls_with_tenant_columns.sql index 4944c31b..8be2ba25 100644 --- a/src/main/resources/db/migration-postgresql/V17__align_worker_link_rls_with_tenant_columns.sql +++ b/src/main/resources/db/migration-postgresql/V17__align_worker_link_rls_with_tenant_columns.sql @@ -1,3 +1,24 @@ +CREATE OR REPLACE FUNCTION public.bootstrap_company_id_by_worker_link_token_hash( + p_token_hash TEXT +) +RETURNS UUID +LANGUAGE SQL +STABLE +SECURITY DEFINER +SET search_path = pg_catalog, public, pg_temp +AS $$ + SELECT link.company_id + FROM public.worker_link AS link + WHERE link.token_hash = p_token_hash + AND link.status = 'ACTIVE' + AND link.expires_at > CURRENT_TIMESTAMP + LIMIT 1 +$$; + +REVOKE ALL + ON FUNCTION public.bootstrap_company_id_by_worker_link_token_hash(TEXT) + FROM PUBLIC; + ALTER POLICY pl_worker_response_upload_tenant_isolation ON public.worker_response_upload USING ( diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 3309b8f6..8c6041af 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -28,6 +28,9 @@ class PostgreSqlMigrationTests { private static final String TASK_A = "13000000-0000-0000-0000-000000000001"; private static final String EVENT_A = "18000000-0000-0000-0000-000000000001"; private static final String TOKEN_HASH_A = "a".repeat(64); + private static final String ACTIVE_WORKER_LINK_TOKEN_HASH = "b".repeat(64); + private static final String REVOKED_WORKER_LINK_TOKEN_HASH = "c".repeat(64); + private static final String EXPIRED_WORKER_LINK_TOKEN_HASH = "d".repeat(64); @Test void migrationsApplyCanonicalServerSchemaOnPostgreSql() throws SQLException { @@ -395,6 +398,36 @@ INSERT INTO task ( CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """.formatted(TASK_A, COMPANY_A, WORKER_A, "f".repeat(64), USER_A, USER_A)); + execute(connection, """ + INSERT INTO worker_link ( + worker_link_id, task_id, company_id, token_hash, expires_at, + status, conversation_status, issued_by, idempotency_key, + created_at, updated_at + ) VALUES + ( + '21000000-0000-0000-0000-000000000001', '%s', '%s', '%s', + CURRENT_TIMESTAMP + INTERVAL '1 day', 'ACTIVE', + 'WAITING_WORKER', '%s', 'active-link', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ), + ( + '21000000-0000-0000-0000-000000000002', '%s', '%s', '%s', + CURRENT_TIMESTAMP + INTERVAL '1 day', 'REVOKED', + 'WAITING_WORKER', '%s', 'revoked-link', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ), + ( + '21000000-0000-0000-0000-000000000003', '%s', '%s', '%s', + CURRENT_TIMESTAMP - INTERVAL '1 day', 'ACTIVE', + 'WAITING_WORKER', '%s', 'expired-link', + CURRENT_TIMESTAMP - INTERVAL '2 days', + CURRENT_TIMESTAMP - INTERVAL '2 days' + ) + """.formatted( + TASK_A, COMPANY_A, ACTIVE_WORKER_LINK_TOKEN_HASH, USER_A, + TASK_A, COMPANY_A, REVOKED_WORKER_LINK_TOKEN_HASH, USER_A, + TASK_A, COMPANY_A, EXPIRED_WORKER_LINK_TOKEN_HASH, USER_A + )); execute(connection, """ INSERT INTO approval_request ( approval_request_id, task_id, company_id, @@ -450,6 +483,26 @@ INSERT INTO event_consumption ( "SELECT public.bootstrap_company_id_by_refresh_token_hash(?)", "0".repeat(64) )).isNull(); + assertThat(queryNullableString( + connection, + "SELECT public.bootstrap_company_id_by_worker_link_token_hash(?)", + ACTIVE_WORKER_LINK_TOKEN_HASH + )).isEqualTo(COMPANY_A); + assertThat(queryNullableString( + connection, + "SELECT public.bootstrap_company_id_by_worker_link_token_hash(?)", + REVOKED_WORKER_LINK_TOKEN_HASH + )).isNull(); + assertThat(queryNullableString( + connection, + "SELECT public.bootstrap_company_id_by_worker_link_token_hash(?)", + EXPIRED_WORKER_LINK_TOKEN_HASH + )).isNull(); + assertThat(queryNullableString( + connection, + "SELECT public.bootstrap_company_id_by_worker_link_token_hash(?)", + "0".repeat(64) + )).isNull(); assertSqlState(connection, "23505", """ INSERT INTO user_account ( diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java index d75e0ce8..430a97e1 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java @@ -41,6 +41,8 @@ class PostgreSqlRlsIsolationTest { UUID.fromString("a5000000-0000-0000-0000-000000000001"); private static final UUID STORED_FILE_B = UUID.fromString("b5000000-0000-0000-0000-000000000002"); + private static final UUID STORED_FILE_B_UNLINKED = + UUID.fromString("b5000000-0000-0000-0000-000000000003"); private static final UUID DRAFT_A = UUID.fromString("a6000000-0000-0000-0000-000000000001"); private static final UUID DRAFT_B = @@ -179,8 +181,14 @@ INSERT INTO stored_file ( ('%s', '%s', 'tenant-a.pdf', 'application/pdf', 1, 'RLS_TEST', 'rls-tenant-a', 'NOT_SCANNED'), ('%s', '%s', 'tenant-b.pdf', 'application/pdf', 1, - 'RLS_TEST', 'rls-tenant-b', 'NOT_SCANNED') - """.formatted(STORED_FILE_A, COMPANY_A, STORED_FILE_B, COMPANY_B)); + 'RLS_TEST', 'rls-tenant-b', 'NOT_SCANNED'), + ('%s', '%s', 'tenant-b-unlinked.pdf', 'application/pdf', 1, + 'RLS_TEST', 'rls-tenant-b-unlinked', 'NOT_SCANNED') + """.formatted( + STORED_FILE_A, COMPANY_A, + STORED_FILE_B, COMPANY_B, + STORED_FILE_B_UNLINKED, COMPANY_B + )); statement.execute(""" INSERT INTO document_request_draft ( draft_id, task_id, company_id, language, message, review_status @@ -390,6 +398,20 @@ INSERT INTO worker_document_upload_idempotency ( ) """.formatted(WORKER_LINK_B, COMPANY_B, STORED_FILE_B) ); + assertSqlState( + connection, + "42501", + """ + INSERT INTO worker_response_upload ( + response_id, stored_file_id, company_id + ) VALUES ( + '%s', '%s', '%s' + ) + """.formatted( + WORKER_RESPONSE_B, + STORED_FILE_B_UNLINKED, + COMPANY_B + )); assertThat(executeUpdate( connection, @@ -477,9 +499,9 @@ WHERE draft_id IN ('%s', '%s') """.formatted(DRAFT_A, DRAFT_B)); statement.execute(""" DELETE FROM stored_file - WHERE stored_file_id IN ('%s', '%s') + WHERE stored_file_id IN ('%s', '%s', '%s') OR storage_key = 'rls-forbidden-b' - """.formatted(STORED_FILE_A, STORED_FILE_B)); + """.formatted(STORED_FILE_A, STORED_FILE_B, STORED_FILE_B_UNLINKED)); statement.execute(""" DELETE FROM task WHERE task_id IN ('%s', '%s') From 97becbab698f1c051b1e4fcd0830341d8ec14409 Mon Sep 17 00:00:00 2001 From: krestar Date: Wed, 5 Aug 2026 16:05:51 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix(demo):=20=EB=B3=80=EA=B2=BD=EB=90=9C=20?= =?UTF-8?q?=EB=8D=B0=EB=AA=A8=20=ED=8C=8C=EC=9D=BC=20fixture=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 본 커밋에서 다룬 문제점은 Demo 환경 테스트 중 발견 되었습니다. 테스트의 원활함을 위해 PR과는 무관한 주제지만, fix 조치 하였습니다. - 예약된 데모 파일의 내용이 변경되면 현재 classpath fixture로 원자적으로 교체 - 일반 파일이 아닌 storage key 경로는 덮어쓰지 않고 거부 - 동일 크기 변조 및 크기가 다른 구버전 fixture 복구 테스트 추가 - 데모 fixture 재실행 계약 문서화 --- docs/demo-seed-fixture-manifest.md | 8 ++-- .../seed/DemoFileFixtureInstaller.java | 41 +++++++++++++------ .../seed/DemoFileFixtureInstallerTest.java | 17 +++++--- 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/docs/demo-seed-fixture-manifest.md b/docs/demo-seed-fixture-manifest.md index 8a3129c9..3313752c 100644 --- a/docs/demo-seed-fixture-manifest.md +++ b/docs/demo-seed-fixture-manifest.md @@ -65,9 +65,11 @@ PDF는 모두 합성 데이터이며 `DEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSIO 검증한다. Demo Seed가 활성화되면 classpath PDF를 `app.file-storage.local-path`에 설치한다. -동일한 storage key의 파일은 크기와 SHA-256이 같을 때만 재사용하며, 다른 파일은 -덮어쓰지 않고 시작을 중단한다. 파일만 남거나 DB row만 남은 상태는 동일 fixture인 -경우 복구한다. 현재 installer는 `LocalFileStorage` 구성만 지원한다. +동일한 storage key의 일반 파일은 크기와 SHA-256이 같으면 재사용하고, classpath +fixture가 변경되었으면 현재 내용으로 원자적으로 교체한다. 디렉터리나 심볼릭 링크처럼 +일반 파일이 아닌 경로는 덮어쓰지 않고 시작을 중단한다. DB row의 예약 ID·소유권·메타데이터 +충돌도 기존 값을 덮어쓰지 않고 시작을 중단한다. 파일만 남거나 DB row만 남은 상태는 +동일 fixture인 경우 복구한다. 현재 installer는 `LocalFileStorage` 구성만 지원한다. ## 근로자와 지원 언어 diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstaller.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstaller.java index 4cce70f0..dc28f768 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstaller.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstaller.java @@ -5,8 +5,8 @@ import java.io.InputStream; import java.io.UncheckedIOException; import java.nio.file.AtomicMoveNotSupportedException; -import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.security.MessageDigest; @@ -31,16 +31,16 @@ void install(StoredFileSeed seed) { Path target = target(seed.storageKey()); try { Files.createDirectories(rootDirectory); - if (Files.exists(target)) { - verify(target, expected, seed); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + requireRegularFile(target, seed); + } + if (matches(target, expected)) { return; } Path temporary = Files.createTempFile(rootDirectory, ".demo-fixture-", ".tmp"); try { Files.write(temporary, expected); - moveWithoutOverwrite(temporary, target); - } catch (FileAlreadyExistsException exception) { - verify(target, expected, seed); + moveReplacing(temporary, target); } finally { Files.deleteIfExists(temporary); } @@ -76,24 +76,41 @@ private Path target(String storageKey) { return target; } - private void moveWithoutOverwrite(Path source, Path target) throws IOException { + private void moveReplacing(Path source, Path target) throws IOException { try { - Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + Files.move( + source, + target, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ); } catch (AtomicMoveNotSupportedException exception) { - Files.move(source, target); + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); } } private void verify(Path target, byte[] expected, StoredFileSeed seed) throws IOException { - if (!Files.isRegularFile(target) - || Files.size(target) != expected.length - || !Arrays.equals(sha256(Files.readAllBytes(target)), sha256(expected))) { + if (!matches(target, expected)) { throw new IllegalStateException( "a demo file storage key already contains different content: " + seed.storageKey() ); } } + private boolean matches(Path target, byte[] expected) throws IOException { + return Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) + && Files.size(target) == expected.length + && Arrays.equals(sha256(Files.readAllBytes(target)), sha256(expected)); + } + + private void requireRegularFile(Path target, StoredFileSeed seed) { + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalStateException( + "a demo file storage key is not a regular file: " + seed.storageKey() + ); + } + } + private byte[] sha256(byte[] content) { try { return MessageDigest.getInstance("SHA-256").digest(content); diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java index 1b7bd4ca..c11c621b 100644 --- a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java +++ b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java @@ -1,8 +1,6 @@ package com.fowoco.server.demo.infrastructure.seed; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.StoredFileSeed; import java.nio.file.Files; import java.nio.file.Path; @@ -17,7 +15,7 @@ class DemoFileFixtureInstallerTest { Path storageRoot; @Test - void installsReusesAndRejectsConflictingFixtureContent() throws Exception { + void installsReusesAndRefreshesChangedFixtureContent() throws Exception { UUID fileId = UUID.fromString("94800000-0000-0000-0000-000000000001"); StoredFileSeed seed = new StoredFileSeed( fileId, @@ -45,8 +43,15 @@ void installsReusesAndRejectsConflictingFixtureContent() throws Exception { conflicting[conflicting.length - 1] ^= 1; Files.write(installed, conflicting); assertThat(Files.size(installed)).isEqualTo(expected.length); - assertThatThrownBy(() -> installer.install(seed)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining(seed.storageKey()); + + installer.install(seed); + + assertThat(Files.readAllBytes(installed)).isEqualTo(expected); + + Files.write(installed, new byte[]{1}); + + installer.install(seed); + + assertThat(Files.readAllBytes(installed)).isEqualTo(expected); } } From f1d8acc8f1764095530a7d25f846f86d7e43fd7f Mon Sep 17 00:00:00 2001 From: krestar Date: Thu, 6 Aug 2026 11:46:33 +0900 Subject: [PATCH 8/8] =?UTF-8?q?chore(database):=20Worker=20Link=20migratio?= =?UTF-8?q?n=20=EB=B2=88=ED=98=B8=EB=A5=BC=20V20=C2=B7V21=EB=A1=9C=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V16 공통 migration을 V20으로 변경 - V17 PostgreSQL migration을 V21로 변경 - migration 전용 테스트 이름과 직접 로딩 경로를 V20에 맞게 수정 - RLS rollout 문서와 PR 본문의 migration 번호 및 배포 전제를 갱신 - V18·V19 이후 순서로 Worker Link migration을 재배치 --- docs/database/postgresql-rls-rollout.md | 12 ++++----- ...n_worker_link_rls_with_tenant_columns.sql} | 0 ...y.sql => V20__harden_tenant_integrity.sql} | 0 ...a => V20TenantIntegrityMigrationTest.java} | 26 +++++++++---------- 4 files changed, 19 insertions(+), 19 deletions(-) rename src/main/resources/db/migration-postgresql/{V17__align_worker_link_rls_with_tenant_columns.sql => V21__align_worker_link_rls_with_tenant_columns.sql} (100%) rename src/main/resources/db/migration/{V16__harden_tenant_integrity.sql => V20__harden_tenant_integrity.sql} (100%) rename src/test/java/com/fowoco/server/{V16TenantIntegrityMigrationTest.java => V20TenantIntegrityMigrationTest.java} (94%) diff --git a/docs/database/postgresql-rls-rollout.md b/docs/database/postgresql-rls-rollout.md index e224098d..0d2b9fd9 100644 --- a/docs/database/postgresql-rls-rollout.md +++ b/docs/database/postgresql-rls-rollout.md @@ -21,8 +21,8 @@ JWT로 인증된 Worker·Task·Approval·Audit 업무 transaction은 요청 값 `ActorContext.companyId`를 transaction-local context의 신뢰 원본으로 사용합니다. H2는 PostgreSQL custom setting을 흉내 내지 않고 transaction 경계만 검증합니다. `V10`에서 공통 bootstrap 함수와 기존 tenant 테이블 policy를, `V13`에서 Worker Link -bootstrap 함수와 policy를, `V14`에서 AI 실행 테이블 policy를 생성했습니다. `V17`은 -V16에서 추가한 직접 tenant 컬럼에 맞춰 Worker Link 업로드 policy를 단순화하고 업로드 +bootstrap 함수와 policy를, `V14`에서 AI 실행 테이블 policy를 생성했습니다. `V21`은 +V20에서 추가한 직접 tenant 컬럼에 맞춰 Worker Link 업로드 policy를 단순화하고 업로드 멱등성 테이블 policy를 추가합니다. Worker Link bootstrap도 ACTIVE이면서 DB 시각 기준 미만료인 링크만 사업장을 반환하도록 제한합니다. RLS는 아직 활성화하지 않았습니다. @@ -31,7 +31,7 @@ V16에서 추가한 직접 tenant 컬럼에 맞춰 Worker Link 업로드 policy 별도 bootstrap 흐름으로 함께 검토해야 합니다. Worker Link는 `V13`에서 같은 기준으로 확장했습니다. -`V16` 적용 후에는 `company_id`를 직접 보유한 아래 tenant table과, 부모 초안의 tenant를 +`V20` 적용 후에는 `company_id`를 직접 보유한 아래 tenant table과, 부모 초안의 tenant를 따르는 `document_request_draft_type`이 존재합니다. 기반 단계의 제한 role 테스트는 이 전체 범위에 업무 DML만 허용하고, table owner·DDL·`TRUNCATE`· `REFERENCES` 권한과 RLS 우회 권한이 없음을 확인합니다. @@ -46,13 +46,13 @@ V16에서 추가한 직접 tenant 컬럼에 맞춰 Worker Link 업로드 policy - `worker_document_upload_idempotency` - `ai_run`, `ai_attempt`, `ai_question`, `ai_candidate` -### V16 최초 배포 전제 +### V20 최초 배포 전제 -`V16`은 Worker Link 자식 테이블의 `company_id`를 backfill하고 `NOT NULL`, 복합 +`V20`은 Worker Link 자식 테이블의 `company_id`를 backfill하고 `NOT NULL`, 복합 `UNIQUE`, tenant-aware 복합 FK를 한 번에 적용합니다. 또한 `worker_document`의 Task 참조를 `(task_id, worker_id, company_id)` 복합 FK로 전환합니다. -이 migration은 pre-V16 애플리케이션이 같은 DB에 계속 쓰는 상황과 +이 migration은 pre-V20 애플리케이션이 같은 DB에 계속 쓰는 상황과 backward-compatible하지 않습니다. 현재는 운영 DB·운영 트래픽·구버전 Pod가 없는 최초 배포 전이므로 이 전제를 충족하며 expand-contract migration을 적용하지 않습니다. 이 전제를 충족하지 않는 환경에 적용할 때는 쓰기 중단 또는 expand-contract 절차를 먼저 diff --git a/src/main/resources/db/migration-postgresql/V17__align_worker_link_rls_with_tenant_columns.sql b/src/main/resources/db/migration-postgresql/V21__align_worker_link_rls_with_tenant_columns.sql similarity index 100% rename from src/main/resources/db/migration-postgresql/V17__align_worker_link_rls_with_tenant_columns.sql rename to src/main/resources/db/migration-postgresql/V21__align_worker_link_rls_with_tenant_columns.sql diff --git a/src/main/resources/db/migration/V16__harden_tenant_integrity.sql b/src/main/resources/db/migration/V20__harden_tenant_integrity.sql similarity index 100% rename from src/main/resources/db/migration/V16__harden_tenant_integrity.sql rename to src/main/resources/db/migration/V20__harden_tenant_integrity.sql diff --git a/src/test/java/com/fowoco/server/V16TenantIntegrityMigrationTest.java b/src/test/java/com/fowoco/server/V20TenantIntegrityMigrationTest.java similarity index 94% rename from src/test/java/com/fowoco/server/V16TenantIntegrityMigrationTest.java rename to src/test/java/com/fowoco/server/V20TenantIntegrityMigrationTest.java index 074a6b26..00f39a19 100644 --- a/src/test/java/com/fowoco/server/V16TenantIntegrityMigrationTest.java +++ b/src/test/java/com/fowoco/server/V20TenantIntegrityMigrationTest.java @@ -16,7 +16,7 @@ import org.springframework.jdbc.datasource.DriverManagerDataSource; import org.springframework.jdbc.datasource.init.ScriptUtils; -class V16TenantIntegrityMigrationTest { +class V20TenantIntegrityMigrationTest { private static final String COMPANY_A = "10000000-0000-0000-0000-000000000001"; private static final String COMPANY_B = "20000000-0000-0000-0000-000000000002"; @@ -31,7 +31,7 @@ class V16TenantIntegrityMigrationTest { @Test void backfillsCompanyIdsBeforeEnforcingTenantAwareRelationships() throws SQLException { try (Connection connection = dataSource().getConnection()) { - createPreV16Schema(connection); + createPreV20Schema(connection); insertBaseFixture(connection, FILE_A, COMPANY_A); execute(connection, """ INSERT INTO worker_response_upload (response_id, stored_file_id) @@ -44,7 +44,7 @@ INSERT INTO worker_document_upload_idempotency ( """.formatted(LINK_A, FILE_A)); execute(connection, workerDocumentInsert(WORKER_A, TASK_A)); - applyV16(connection); + applyV20(connection); assertThat(queryString( connection, @@ -61,7 +61,7 @@ INSERT INTO worker_document_upload_idempotency ( @Test void rejectsExistingCrossTenantStoredFileRelationship() throws SQLException { try (Connection connection = dataSource().getConnection()) { - createPreV16Schema(connection); + createPreV20Schema(connection); insertBaseFixture(connection, FILE_B, COMPANY_B); execute(connection, """ INSERT INTO worker_document_upload_idempotency ( @@ -69,7 +69,7 @@ INSERT INTO worker_document_upload_idempotency ( ) VALUES ('%s', 'cross-tenant-upload', '%s') """.formatted(LINK_A, FILE_B)); - assertThatThrownBy(() -> applyV16(connection)) + assertThatThrownBy(() -> applyV20(connection)) .isInstanceOf(RuntimeException.class); } } @@ -77,11 +77,11 @@ INSERT INTO worker_document_upload_idempotency ( @Test void rejectsExistingTaskLinkedToAnotherWorker() throws SQLException { try (Connection connection = dataSource().getConnection()) { - createPreV16Schema(connection); + createPreV20Schema(connection); insertBaseFixture(connection, FILE_A, COMPANY_A); execute(connection, workerDocumentInsert(WORKER_A2, TASK_A)); - assertThatThrownBy(() -> applyV16(connection)) + assertThatThrownBy(() -> applyV20(connection)) .isInstanceOf(RuntimeException.class); } } @@ -89,9 +89,9 @@ void rejectsExistingTaskLinkedToAnotherWorker() throws SQLException { @Test void preventsStoredFileFromBeingLinkedToMultipleResponses() throws SQLException { try (Connection connection = dataSource().getConnection()) { - createPreV16Schema(connection); + createPreV20Schema(connection); insertBaseFixture(connection, FILE_A, COMPANY_A); - applyV16(connection); + applyV20(connection); String secondResponseId = "15000000-0000-0000-0000-000000000002"; execute(connection, """ @@ -114,7 +114,7 @@ INSERT INTO worker_response_upload ( } } - private void createPreV16Schema(Connection connection) { + private void createPreV20Schema(Connection connection) { String sql = """ CREATE TABLE task ( task_id UUID NOT NULL, @@ -224,15 +224,15 @@ INSERT INTO worker_document ( """.formatted(UUID.randomUUID(), workerId, COMPANY_A, taskId); } - private void applyV16(Connection connection) { + private void applyV20(Connection connection) { ScriptUtils.executeSqlScript( connection, - new ClassPathResource("db/migration/V16__harden_tenant_integrity.sql") + new ClassPathResource("db/migration/V20__harden_tenant_integrity.sql") ); } private DataSource dataSource() { - String url = "jdbc:h2:mem:v16_" + UUID.randomUUID() + String url = "jdbc:h2:mem:v20_" + UUID.randomUUID() + ";MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH"; return new DriverManagerDataSource(url, "sa", ""); }