From 0f54c34750fac317ec44cef3edc0d00d0b176da2 Mon Sep 17 00:00:00 2001 From: krestar Date: Fri, 24 Jul 2026 22:00:44 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(security):=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=EB=90=9C=20=EC=97=85=EB=AC=B4=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=EC=97=90=20tenant=20context=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ActorContext의 companyId를 transaction-local DB context로 설정 - Worker·Task·Approval·Audit 업무 흐름에 tenant context 연결 - H2와 PostgreSQL 환경별 tenant context 구현 분리 - transaction 외부 접근과 PostgreSQL context 설정 검증 보강 --- docs/database/postgresql-rls-rollout.md | 27 +++++++++---- .../approval/application/ApprovalService.java | 17 ++++++++ .../audit/application/AuditQueryService.java | 10 +++++ .../PostgreSqlTenantDatabaseContext.java | 5 +++ .../TransactionOnlyTenantDatabaseContext.java | 29 ++++++++++++++ .../task/application/TaskWorkflowService.java | 14 +++++++ .../server/worker/api/WorkerController.java | 19 +++++---- .../worker/api/WorkerDocumentController.java | 11 +++-- .../application/WorkerCreateCommand.java | 8 ---- .../WorkerDocumentCreateCommand.java | 7 ---- .../WorkerDocumentPatchCommand.java | 7 ---- .../application/WorkerDocumentService.java | 40 +++++++++++++++---- .../application/WorkerPatchCommand.java | 7 ---- .../worker/application/WorkerService.java | 39 +++++++++++++----- src/main/resources/application.yaml | 6 +++ .../PostgreSqlTenantDatabaseContextTest.java | 1 + ...nsactionOnlyTenantDatabaseContextTest.java | 38 ++++++++++++++++++ 17 files changed, 215 insertions(+), 70 deletions(-) create mode 100644 src/main/java/com/fowoco/server/common/security/TransactionOnlyTenantDatabaseContext.java create mode 100644 src/test/java/com/fowoco/server/common/security/TransactionOnlyTenantDatabaseContextTest.java diff --git a/docs/database/postgresql-rls-rollout.md b/docs/database/postgresql-rls-rollout.md index 91051f4..dd58cdb 100644 --- a/docs/database/postgresql-rls-rollout.md +++ b/docs/database/postgresql-rls-rollout.md @@ -16,8 +16,16 @@ RLS는 기존 `ActorContext`, Repository의 `company_id` 조건, tenant-aware DB placeholder를 만들지 않습니다. 현재 기반 단계에서는 runtime/Flyway 설정 경계, PostgreSQL 전용 Flyway location, -transaction-local tenant context와 connection pool 비누수 테스트만 준비합니다. -아직 policy를 만들거나 RLS를 활성화하지 않습니다. +transaction-local tenant context와 connection pool 비누수 테스트를 준비했습니다. +JWT로 인증된 Worker·Task·Approval·Audit 업무 transaction은 요청 값이 아니라 +`ActorContext.companyId`를 transaction-local context의 신뢰 원본으로 사용합니다. +H2는 PostgreSQL custom setting을 흉내 내지 않고 transaction 경계만 검증합니다. +아직 bootstrap 함수나 policy를 만들거나 RLS를 활성화하지 않습니다. + +로그인·Refresh Token·Logout은 tenant context가 생기기 전 최소 bootstrap 조회가 +필요합니다. Issue #34 작성 뒤 추가된 사업장 회원가입도 새 tenant 행을 처음 만드는 +별도 bootstrap 흐름으로 함께 검토해야 합니다. Worker Link는 해당 기능이 구현된 뒤 +같은 기준으로 확장합니다. 현재 `main`의 V1~V6에는 아래 12개 tenant table이 존재합니다. 기반 단계의 제한 role 테스트는 이 전체 범위에 업무 DML만 허용하고, table owner·DDL·`TRUNCATE`· @@ -51,13 +59,16 @@ DDL, `TRUNCATE`, `REFERENCES` 권한을 갖지 않습니다. 실제 값은 배 ## Staging 적용 순서 1. 대상 table과 tenant-aware FK·UNIQUE 제약이 `main`에 병합됐는지 확인합니다. -2. 준비 migration에서 bootstrap 함수와 policy를 만들되 RLS는 켜지 않습니다. -3. tenant context와 bootstrap 호환 코드를 배포합니다. -4. #9에서 분리된 runtime role, 최소 GRANT와 Secret을 적용합니다. -5. RLS 비활성 상태에서 Login·Refresh·tenant A/B·connection pool 회귀 테스트를 +2. 인증된 업무 transaction이 `ActorContext.companyId`를 context로 설정하는지 + 검증합니다. +3. 준비 migration에서 Login·Refresh·Signup bootstrap 함수와 policy를 만들되 + RLS는 켜지 않습니다. +4. bootstrap 호환 코드를 배포합니다. +5. #9에서 분리된 runtime role, 최소 GRANT와 Secret을 적용합니다. +6. RLS 비활성 상태에서 Signup·Login·Refresh·tenant A/B·connection pool 회귀 테스트를 실행합니다. -6. 별도 forward migration으로 `ENABLE ROW LEVEL SECURITY`를 적용합니다. -7. 제한된 runtime role로 Smoke Test를 실행합니다. +7. 별도 forward migration으로 `ENABLE ROW LEVEL SECURITY`를 적용합니다. +8. 제한된 runtime role로 Smoke Test를 실행합니다. ## Smoke Test diff --git a/src/main/java/com/fowoco/server/approval/application/ApprovalService.java b/src/main/java/com/fowoco/server/approval/application/ApprovalService.java index 02d73bf..df6d13d 100644 --- a/src/main/java/com/fowoco/server/approval/application/ApprovalService.java +++ b/src/main/java/com/fowoco/server/approval/application/ApprovalService.java @@ -17,6 +17,7 @@ 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.common.web.RequestMetadata; import com.fowoco.server.task.application.error.TaskErrorCode; import com.fowoco.server.task.application.TaskReadinessChecker; @@ -40,6 +41,7 @@ public class ApprovalService implements ApprovalControlPort { private static final String AUDIT_EVENT_VERSION = "1"; private final ActorAuthorizer actorAuthorizer; + private final TenantDatabaseContext tenantDatabaseContext; private final TaskRepository taskRepository; private final TaskTransitionRecorder transitionRecorder; private final TaskReadinessChecker taskReadinessChecker; @@ -53,6 +55,7 @@ public class ApprovalService implements ApprovalControlPort { public ApprovalService( ActorAuthorizer actorAuthorizer, + TenantDatabaseContext tenantDatabaseContext, TaskRepository taskRepository, TaskTransitionRecorder transitionRecorder, TaskReadinessChecker taskReadinessChecker, @@ -65,6 +68,7 @@ public ApprovalService( Clock clock ) { this.actorAuthorizer = actorAuthorizer; + this.tenantDatabaseContext = tenantDatabaseContext; this.taskRepository = taskRepository; this.transitionRecorder = transitionRecorder; this.taskReadinessChecker = taskReadinessChecker; @@ -84,6 +88,7 @@ public ApprovalResult requestApproval( ActorContext actor, RequestMetadata metadata ) { + bindTenant(actor); actorAuthorizer.requireHrWrite(actor); approvalRepository.findPendingByTaskIdAndCompanyId(taskId, actor.companyId()) .ifPresent(ignored -> { @@ -141,6 +146,7 @@ public ApprovalResult approve( ActorContext actor, RequestMetadata metadata ) { + bindTenant(actor); actorAuthorizer.requireHrWrite(actor); Task task = requireTask(taskId, actor.companyId()); requireTaskVersion(task, command.expectedVersion()); @@ -178,6 +184,7 @@ public ApprovalResult reject( ActorContext actor, RequestMetadata metadata ) { + bindTenant(actor); actorAuthorizer.requireHrWrite(actor); Task task = requireTask(taskId, actor.companyId()); requireTaskVersion(task, command.expectedVersion()); @@ -207,6 +214,7 @@ public TaskActionResult recordExternalSubmission( ActorContext actor, RequestMetadata metadata ) { + bindTenant(actor); actorAuthorizer.requireHrWrite(actor); Task task = requireTask(taskId, actor.companyId()); requireValidApproval(task); @@ -257,6 +265,7 @@ public TaskActionResult recordEvidence( ActorContext actor, RequestMetadata metadata ) { + bindTenant(actor); actorAuthorizer.requireHrWrite(actor); Task task = requireTask(taskId, actor.companyId()); if (task.status() != TaskStatus.APPROVED @@ -303,6 +312,7 @@ public TaskActionResult complete( ActorContext actor, RequestMetadata metadata ) { + bindTenant(actor); actorAuthorizer.requireHrWrite(actor); Task task = requireTask(taskId, actor.companyId()); boolean approved = hasValidApproval( @@ -341,6 +351,7 @@ public boolean hasValidApproval( long contentRevision, String criticalFingerprint ) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); return approvalRepository.findLatestApprovedByTaskIdAndCompanyId(taskId, companyId) .filter(approval -> approval.isValidFor(contentRevision, criticalFingerprint)) .isPresent(); @@ -355,6 +366,7 @@ public void invalidateForCriticalChange( Instant occurredAt, RequestMetadata metadata ) { + bindTenant(actor); actorAuthorizer.requireHrWrite(actor); Task task = requireTask(taskId, actor.companyId()); List active = invalidateActiveApprovals( @@ -384,6 +396,7 @@ public Task replaceReviewAfterCriticalChange( Instant occurredAt, RequestMetadata metadata ) { + bindTenant(actor); actorAuthorizer.requireHrWrite(actor); Task task = requireTask(taskId, actor.companyId()); List invalidated = invalidateActiveApprovals( @@ -557,6 +570,10 @@ private UserRole effectiveRole(ActorContext actor) { .orElseThrow(); } + private void bindTenant(ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + } + private int rolePriority(UserRole role) { return switch (role) { case ADMIN -> 0; diff --git a/src/main/java/com/fowoco/server/audit/application/AuditQueryService.java b/src/main/java/com/fowoco/server/audit/application/AuditQueryService.java index 741b617..b180632 100644 --- a/src/main/java/com/fowoco/server/audit/application/AuditQueryService.java +++ b/src/main/java/com/fowoco/server/audit/application/AuditQueryService.java @@ -11,6 +11,7 @@ import com.fowoco.server.auth.domain.UserRole; import com.fowoco.server.common.error.ApiException; import com.fowoco.server.common.error.ErrorCode; +import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.task.application.error.TaskErrorCode; import com.fowoco.server.task.application.port.TaskRepository; import java.time.Instant; @@ -25,17 +26,20 @@ public class AuditQueryService { private static final int MAX_PAGE_SIZE = 100; private final ActorAuthorizer actorAuthorizer; + private final TenantDatabaseContext tenantDatabaseContext; private final TaskRepository taskRepository; private final AuditEventRepository auditRepository; private final AuditCursorCodec cursorCodec; public AuditQueryService( ActorAuthorizer actorAuthorizer, + TenantDatabaseContext tenantDatabaseContext, TaskRepository taskRepository, AuditEventRepository auditRepository, AuditCursorCodec cursorCodec ) { this.actorAuthorizer = actorAuthorizer; + this.tenantDatabaseContext = tenantDatabaseContext; this.taskRepository = taskRepository; this.auditRepository = auditRepository; this.cursorCodec = cursorCodec; @@ -43,6 +47,7 @@ public AuditQueryService( @Transactional(readOnly = true) public List getTaskActivities(UUID taskId, ActorContext actor) { + bindTenant(actor); actorAuthorizer.requireAnyRole(actor, UserRole.ADMIN, UserRole.HR, UserRole.VIEWER); taskRepository.findByIdAndCompanyId(taskId, actor.companyId()) .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); @@ -64,6 +69,7 @@ public AuditPageResult search( int requestedLimit, ActorContext actor ) { + bindTenant(actor); actorAuthorizer.requireAnyRole(actor, UserRole.ADMIN); if (createdFrom != null && createdTo != null && createdFrom.isAfter(createdTo)) { throw new ApiException(ErrorCode.INVALID_REQUEST); @@ -95,4 +101,8 @@ public AuditPageResult search( private String normalizeTraceId(String traceId) { return traceId == null || traceId.isBlank() ? null : traceId.trim(); } + + private void bindTenant(ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + } } diff --git a/src/main/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContext.java b/src/main/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContext.java index 6821539..8fabb19 100644 --- a/src/main/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContext.java +++ b/src/main/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContext.java @@ -3,6 +3,7 @@ import jakarta.persistence.EntityManager; import java.util.Objects; import java.util.UUID; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionSynchronizationManager; @@ -10,6 +11,10 @@ * PostgreSQL tenant context backed by a transaction-local custom setting. */ @Component +@ConditionalOnProperty( + name = "app.database.tenant-context-mode", + havingValue = "postgresql" +) public final class PostgreSqlTenantDatabaseContext implements TenantDatabaseContext { private static final String READ_COMPANY_ID_SQL = """ diff --git a/src/main/java/com/fowoco/server/common/security/TransactionOnlyTenantDatabaseContext.java b/src/main/java/com/fowoco/server/common/security/TransactionOnlyTenantDatabaseContext.java new file mode 100644 index 0000000..6e70865 --- /dev/null +++ b/src/main/java/com/fowoco/server/common/security/TransactionOnlyTenantDatabaseContext.java @@ -0,0 +1,29 @@ +package com.fowoco.server.common.security; + +import java.util.Objects; +import java.util.UUID; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * Validates tenant transaction boundaries on databases that do not support PostgreSQL settings. + */ +@Component +@ConditionalOnProperty( + name = "app.database.tenant-context-mode", + havingValue = "transaction-only", + matchIfMissing = true +) +public final class TransactionOnlyTenantDatabaseContext implements TenantDatabaseContext { + + @Override + public void setCompanyIdForCurrentTransaction(UUID companyId) { + Objects.requireNonNull(companyId, "companyId must not be null"); + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + throw new IllegalStateException( + "Tenant database context requires an active transaction." + ); + } + } +} diff --git a/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java index ac6a993..4920d6d 100644 --- a/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java +++ b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java @@ -11,6 +11,7 @@ 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.common.web.RequestMetadata; import com.fowoco.server.task.application.TaskContentCodec.EncodedTaskContent; import com.fowoco.server.task.application.error.TaskErrorCode; @@ -44,6 +45,7 @@ public class TaskWorkflowService { private static final String AUDIT_EVENT_VERSION = "1"; private final ActorAuthorizer actorAuthorizer; + private final TenantDatabaseContext tenantDatabaseContext; private final TaskRepository taskRepository; private final TaskChecklistRepository checklistRepository; private final TaskTransitionRecorder transitionRecorder; @@ -57,6 +59,7 @@ public class TaskWorkflowService { public TaskWorkflowService( ActorAuthorizer actorAuthorizer, + TenantDatabaseContext tenantDatabaseContext, TaskRepository taskRepository, TaskChecklistRepository checklistRepository, TaskTransitionRecorder transitionRecorder, @@ -69,6 +72,7 @@ public TaskWorkflowService( Clock clock ) { this.actorAuthorizer = actorAuthorizer; + this.tenantDatabaseContext = tenantDatabaseContext; this.taskRepository = taskRepository; this.checklistRepository = checklistRepository; this.transitionRecorder = transitionRecorder; @@ -87,6 +91,7 @@ public TaskResult create( ActorContext actor, RequestMetadata metadata ) { + bindTenant(actor); actorAuthorizer.requireHrWrite(actor); WorkflowDefinition workflow = catalogService.requireWorkflow(command.workflowId()); if (!workflow.supportedTaskTypes().contains(command.taskType())) { @@ -169,6 +174,7 @@ public TaskPageResult findAll( int size, ActorContext actor ) { + bindTenant(actor); requireRead(actor); if (dueFrom != null && dueTo != null && dueFrom.isAfter(dueTo)) { throw new ApiException(TaskErrorCode.INVALID_TASK_FILTER); @@ -195,6 +201,7 @@ public TaskPageResult findAll( @Transactional(readOnly = true) public TaskResult findById(UUID taskId, ActorContext actor) { + bindTenant(actor); requireRead(actor); Task task = requireTask(taskId, actor.companyId()); return toResult( @@ -212,6 +219,7 @@ public TaskResult update( ActorContext actor, RequestMetadata metadata ) { + bindTenant(actor); actorAuthorizer.requireHrWrite(actor); Task task = requireTask(taskId, actor.companyId()); WorkflowDefinition workflow = catalogService.requireWorkflow(task.workflowId()); @@ -304,6 +312,7 @@ public TaskResult updateChecklistItem( ActorContext actor, RequestMetadata metadata ) { + bindTenant(actor); actorAuthorizer.requireHrWrite(actor); Task task = requireTask(taskId, actor.companyId()); TaskChecklistItem item = checklistRepository @@ -379,6 +388,7 @@ public TaskResult cancel( ActorContext actor, RequestMetadata metadata ) { + bindTenant(actor); actorAuthorizer.requireHrWrite(actor); Task task = requireTask(taskId, actor.companyId()); String reason = contentCodec.safeText(command.reason(), 500); @@ -533,6 +543,10 @@ private UserRole effectiveRole(ActorContext actor) { .orElseThrow(); } + private void bindTenant(ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + } + private int rolePriority(UserRole role) { return switch (role) { case ADMIN -> 0; diff --git a/src/main/java/com/fowoco/server/worker/api/WorkerController.java b/src/main/java/com/fowoco/server/worker/api/WorkerController.java index ee7fbd0..905fbd5 100644 --- a/src/main/java/com/fowoco/server/worker/api/WorkerController.java +++ b/src/main/java/com/fowoco/server/worker/api/WorkerController.java @@ -1,5 +1,6 @@ package com.fowoco.server.worker.api; +import com.fowoco.server.auth.application.ActorContext; import com.fowoco.server.auth.application.port.ActorContextProvider; import com.fowoco.server.worker.application.WorkerCreateCommand; import com.fowoco.server.worker.application.WorkerPageResult; @@ -83,9 +84,9 @@ public WorkerPageResponse list( @Parameter(description = "페이지당 항목 수 (1~100)") @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size ) { - UUID companyId = actorContextProvider.requireCurrentActor().companyId(); + ActorContext actor = actorContextProvider.requireCurrentActor(); WorkerSearchQuery query = new WorkerSearchQuery(status, language, expiryBefore, page, size); - WorkerPageResult result = workerService.findPage(companyId, query); + WorkerPageResult result = workerService.findPage(actor, query); List items = result.items().stream().map(WorkerResponse::from).toList(); return new WorkerPageResponse(items, result.page(), result.size(), result.totalElements()); } @@ -112,9 +113,8 @@ public WorkerPageResponse list( @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) @PreAuthorize("hasAnyRole('ADMIN', 'HR')") public ResponseEntity register(@Valid @RequestBody WorkerCreateRequest request) { - UUID companyId = actorContextProvider.requireCurrentActor().companyId(); + ActorContext actor = actorContextProvider.requireCurrentActor(); WorkerCreateCommand command = new WorkerCreateCommand( - companyId, request.getDisplayName(), request.getNationalityCode(), request.getPreferredLanguage(), @@ -122,7 +122,7 @@ public ResponseEntity register(@Valid @RequestBody WorkerCreateR request.getContractStartDate(), request.getContractEndDate() ); - Worker worker = workerService.register(command); + Worker worker = workerService.register(command, actor); return ResponseEntity.status(HttpStatus.CREATED).body(WorkerResponse.from(worker)); } @@ -150,8 +150,8 @@ public ResponseEntity register(@Valid @RequestBody WorkerCreateR public WorkerResponse getDetail( @Parameter(description = "근로자 ID") @PathVariable UUID workerId ) { - UUID companyId = actorContextProvider.requireCurrentActor().companyId(); - Worker worker = workerService.findDetail(workerId, companyId); + ActorContext actor = actorContextProvider.requireCurrentActor(); + Worker worker = workerService.findDetail(workerId, actor); return WorkerResponse.from(worker); } @@ -194,10 +194,9 @@ public WorkerResponse patch( @Parameter(description = "근로자 ID") @PathVariable UUID workerId, @Valid @RequestBody WorkerPatchRequest request ) { - UUID companyId = actorContextProvider.requireCurrentActor().companyId(); + ActorContext actor = actorContextProvider.requireCurrentActor(); WorkerPatchCommand command = new WorkerPatchCommand( workerId, - companyId, request.getDisplayName(), request.getNationalityCode(), request.getPreferredLanguage(), @@ -207,7 +206,7 @@ public WorkerResponse patch( request.getContractEndDate(), request.getExpectedVersion() ); - Worker worker = workerService.patch(command); + Worker worker = workerService.patch(command, actor); return WorkerResponse.from(worker); } } diff --git a/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java b/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java index 1932b38..805c6d4 100644 --- a/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java +++ b/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java @@ -1,5 +1,6 @@ package com.fowoco.server.worker.api; +import com.fowoco.server.auth.application.ActorContext; import com.fowoco.server.auth.application.port.ActorContextProvider; import com.fowoco.server.worker.application.WorkerDocumentCreateCommand; import com.fowoco.server.worker.application.WorkerDocumentPatchCommand; @@ -68,17 +69,16 @@ public ResponseEntity register( @Parameter(description = "근로자 ID") @PathVariable UUID workerId, @Valid @RequestBody WorkerDocumentCreateRequest request ) { - UUID companyId = actorContextProvider.requireCurrentActor().companyId(); + ActorContext actor = actorContextProvider.requireCurrentActor(); WorkerDocumentCreateCommand command = new WorkerDocumentCreateCommand( workerId, - companyId, request.getDocumentType(), request.getSubmissionStatus(), request.getExpiryDate(), request.getDestination(), request.getNote() ); - WorkerDocument document = workerDocumentService.register(command); + WorkerDocument document = workerDocumentService.register(command, actor); return ResponseEntity.status(HttpStatus.CREATED).body(WorkerDocumentResponse.from(document)); } @@ -122,11 +122,10 @@ public WorkerDocumentResponse patch( @Parameter(description = "서류 ID") @PathVariable UUID documentId, @Valid @RequestBody WorkerDocumentPatchRequest request ) { - UUID companyId = actorContextProvider.requireCurrentActor().companyId(); + ActorContext actor = actorContextProvider.requireCurrentActor(); WorkerDocumentPatchCommand command = new WorkerDocumentPatchCommand( documentId, workerId, - companyId, request.getDocumentType(), request.getSubmissionStatus(), request.getExpiryDate(), @@ -134,7 +133,7 @@ public WorkerDocumentResponse patch( request.getNote(), request.getExpectedVersion() ); - WorkerDocument document = workerDocumentService.patch(command); + WorkerDocument document = workerDocumentService.patch(command, actor); return WorkerDocumentResponse.from(document); } } diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerCreateCommand.java b/src/main/java/com/fowoco/server/worker/application/WorkerCreateCommand.java index 7b5285b..9caa5b0 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerCreateCommand.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerCreateCommand.java @@ -1,11 +1,9 @@ package com.fowoco.server.worker.application; import java.time.LocalDate; -import java.util.UUID; public final class WorkerCreateCommand { - private final UUID companyId; private final String displayName; private final String nationalityCode; private final String preferredLanguage; @@ -14,7 +12,6 @@ public final class WorkerCreateCommand { private final LocalDate contractEndDate; public WorkerCreateCommand( - UUID companyId, String displayName, String nationalityCode, String preferredLanguage, @@ -22,7 +19,6 @@ public WorkerCreateCommand( LocalDate contractStartDate, LocalDate contractEndDate ) { - this.companyId = companyId; this.displayName = displayName; this.nationalityCode = nationalityCode; this.preferredLanguage = preferredLanguage; @@ -31,10 +27,6 @@ public WorkerCreateCommand( this.contractEndDate = contractEndDate; } - public UUID companyId() { - return companyId; - } - public String displayName() { return displayName; } diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentCreateCommand.java b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentCreateCommand.java index b3681e4..45d98f4 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentCreateCommand.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentCreateCommand.java @@ -8,7 +8,6 @@ public final class WorkerDocumentCreateCommand { private final UUID workerId; - private final UUID companyId; private final DocumentType documentType; private final SubmissionStatus submissionStatus; private final LocalDate expiryDate; @@ -17,7 +16,6 @@ public final class WorkerDocumentCreateCommand { public WorkerDocumentCreateCommand( UUID workerId, - UUID companyId, DocumentType documentType, SubmissionStatus submissionStatus, LocalDate expiryDate, @@ -25,7 +23,6 @@ public WorkerDocumentCreateCommand( String note ) { this.workerId = workerId; - this.companyId = companyId; this.documentType = documentType; this.submissionStatus = submissionStatus; this.expiryDate = expiryDate; @@ -37,10 +34,6 @@ public UUID workerId() { return workerId; } - public UUID companyId() { - return companyId; - } - public DocumentType documentType() { return documentType; } diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentPatchCommand.java b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentPatchCommand.java index 0d6680f..25c5e90 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentPatchCommand.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentPatchCommand.java @@ -9,7 +9,6 @@ public final class WorkerDocumentPatchCommand { private final UUID workerDocumentId; private final UUID workerId; - private final UUID companyId; private final DocumentType documentType; private final SubmissionStatus submissionStatus; private final LocalDate expiryDate; @@ -20,7 +19,6 @@ public final class WorkerDocumentPatchCommand { public WorkerDocumentPatchCommand( UUID workerDocumentId, UUID workerId, - UUID companyId, DocumentType documentType, SubmissionStatus submissionStatus, LocalDate expiryDate, @@ -30,7 +28,6 @@ public WorkerDocumentPatchCommand( ) { this.workerDocumentId = workerDocumentId; this.workerId = workerId; - this.companyId = companyId; this.documentType = documentType; this.submissionStatus = submissionStatus; this.expiryDate = expiryDate; @@ -47,10 +44,6 @@ public UUID workerId() { return workerId; } - public UUID companyId() { - return companyId; - } - public DocumentType documentType() { return documentType; } 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 507b036..89d8550 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java @@ -1,11 +1,14 @@ package com.fowoco.server.worker.application; +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.common.security.TenantDatabaseContext; import com.fowoco.server.worker.application.error.WorkerErrorCode; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; import com.fowoco.server.worker.domain.WorkerDocument; import java.time.Clock; +import java.time.Instant; import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,25 +17,29 @@ public class WorkerDocumentService { private final WorkerDocumentRepository workerDocumentRepository; + private final TenantDatabaseContext tenantDatabaseContext; private final UuidGenerator uuidGenerator; private final Clock clock; public WorkerDocumentService( WorkerDocumentRepository workerDocumentRepository, + TenantDatabaseContext tenantDatabaseContext, UuidGenerator uuidGenerator, Clock clock ) { this.workerDocumentRepository = workerDocumentRepository; + this.tenantDatabaseContext = tenantDatabaseContext; this.uuidGenerator = uuidGenerator; this.clock = clock; } @Transactional - public WorkerDocument register(WorkerDocumentCreateCommand command) { + public WorkerDocument register(WorkerDocumentCreateCommand command, ActorContext actor) { + bindTenant(actor); WorkerDocument document = WorkerDocument.create( uuidGenerator.generate(), command.workerId(), - command.companyId(), + actor.companyId(), command.documentType(), command.submissionStatus(), command.expiryDate(), @@ -45,17 +52,27 @@ public WorkerDocument register(WorkerDocumentCreateCommand command) { } @Transactional(readOnly = true) - public WorkerDocument findDetail(UUID workerDocumentId, UUID workerId, UUID companyId) { - return workerDocumentRepository.findByIdAndWorkerIdAndCompanyId(workerDocumentId, workerId, companyId) + public WorkerDocument findDetail( + UUID workerDocumentId, + UUID workerId, + ActorContext actor + ) { + bindTenant(actor); + return workerDocumentRepository.findByIdAndWorkerIdAndCompanyId( + workerDocumentId, + workerId, + actor.companyId() + ) .orElseThrow(() -> new ApiException(WorkerErrorCode.WORKER_DOCUMENT_NOT_FOUND)); } @Transactional - public WorkerDocument patch(WorkerDocumentPatchCommand command) { + public WorkerDocument patch(WorkerDocumentPatchCommand command, ActorContext actor) { + bindTenant(actor); WorkerDocument existing = findDetail( command.workerDocumentId(), command.workerId(), - command.companyId() + actor ); if (existing.version() != command.expectedVersion()) { throw new ApiException(WorkerErrorCode.WORKER_DOCUMENT_VERSION_CONFLICT); @@ -72,7 +89,7 @@ public WorkerDocument patch(WorkerDocumentPatchCommand command) { orElseKeep(command.note(), existing.note()), existing.fileId(), existing.createdAt(), - clock.instant(), + updateTime(existing.createdAt()), existing.version() ); @@ -82,4 +99,13 @@ public WorkerDocument patch(WorkerDocumentPatchCommand command) { private static T orElseKeep(T newValue, T existingValue) { return newValue != null ? newValue : existingValue; } + + private void bindTenant(ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + } + + private Instant updateTime(Instant createdAt) { + Instant now = clock.instant(); + return now.isBefore(createdAt) ? createdAt : now; + } } diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerPatchCommand.java b/src/main/java/com/fowoco/server/worker/application/WorkerPatchCommand.java index 2a6212b..abd7a34 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerPatchCommand.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerPatchCommand.java @@ -7,7 +7,6 @@ public final class WorkerPatchCommand { private final UUID workerId; - private final UUID companyId; private final String displayName; private final String nationalityCode; private final String preferredLanguage; @@ -19,7 +18,6 @@ public final class WorkerPatchCommand { public WorkerPatchCommand( UUID workerId, - UUID companyId, String displayName, String nationalityCode, String preferredLanguage, @@ -30,7 +28,6 @@ public WorkerPatchCommand( long expectedVersion ) { this.workerId = workerId; - this.companyId = companyId; this.displayName = displayName; this.nationalityCode = nationalityCode; this.preferredLanguage = preferredLanguage; @@ -45,10 +42,6 @@ public UUID workerId() { return workerId; } - public UUID companyId() { - return companyId; - } - public String displayName() { return displayName; } diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerService.java b/src/main/java/com/fowoco/server/worker/application/WorkerService.java index a061e9c..4bc0a07 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerService.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerService.java @@ -1,11 +1,14 @@ package com.fowoco.server.worker.application; +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.common.security.TenantDatabaseContext; import com.fowoco.server.worker.application.error.WorkerErrorCode; import com.fowoco.server.worker.application.port.WorkerRepository; import com.fowoco.server.worker.domain.Worker; import java.time.Clock; +import java.time.Instant; import java.util.List; import java.util.UUID; import org.springframework.stereotype.Service; @@ -15,24 +18,28 @@ public class WorkerService { private final WorkerRepository workerRepository; + private final TenantDatabaseContext tenantDatabaseContext; private final UuidGenerator uuidGenerator; private final Clock clock; public WorkerService( WorkerRepository workerRepository, + TenantDatabaseContext tenantDatabaseContext, UuidGenerator uuidGenerator, Clock clock ) { this.workerRepository = workerRepository; + this.tenantDatabaseContext = tenantDatabaseContext; this.uuidGenerator = uuidGenerator; this.clock = clock; } @Transactional - public Worker register(WorkerCreateCommand command) { + public Worker register(WorkerCreateCommand command, ActorContext actor) { + bindTenant(actor); Worker worker = Worker.create( uuidGenerator.generate(), - command.companyId(), + actor.companyId(), command.displayName(), command.nationalityCode(), command.preferredLanguage(), @@ -46,21 +53,24 @@ public Worker register(WorkerCreateCommand command) { } @Transactional(readOnly = true) - public Worker findDetail(UUID workerId, UUID companyId) { - return workerRepository.findByWorkerIdAndCompanyId(workerId, companyId) + public Worker findDetail(UUID workerId, ActorContext actor) { + bindTenant(actor); + return workerRepository.findByWorkerIdAndCompanyId(workerId, actor.companyId()) .orElseThrow(() -> new ApiException(WorkerErrorCode.WORKER_NOT_FOUND)); } @Transactional(readOnly = true) - public WorkerPageResult findPage(UUID companyId, WorkerSearchQuery query) { - List items = workerRepository.findPage(companyId, query); - long totalElements = workerRepository.countPage(companyId, query); + public WorkerPageResult findPage(ActorContext actor, WorkerSearchQuery query) { + bindTenant(actor); + List items = workerRepository.findPage(actor.companyId(), query); + long totalElements = workerRepository.countPage(actor.companyId(), query); return new WorkerPageResult(items, query.page(), query.size(), totalElements); } @Transactional - public Worker patch(WorkerPatchCommand command) { - Worker existing = findDetail(command.workerId(), command.companyId()); + public Worker patch(WorkerPatchCommand command, ActorContext actor) { + bindTenant(actor); + Worker existing = findDetail(command.workerId(), actor); if (existing.version() != command.expectedVersion()) { throw new ApiException(WorkerErrorCode.WORKER_VERSION_CONFLICT); } @@ -76,7 +86,7 @@ public Worker patch(WorkerPatchCommand command) { orElseKeep(command.contractStartDate(), existing.contractStartDate()), orElseKeep(command.contractEndDate(), existing.contractEndDate()), existing.createdAt(), - clock.instant(), + updateTime(existing.createdAt()), existing.version() ); @@ -86,4 +96,13 @@ public Worker patch(WorkerPatchCommand command) { private static T orElseKeep(T newValue, T existingValue) { return newValue != null ? newValue : existingValue; } + + private void bindTenant(ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + } + + private Instant updateTime(Instant createdAt) { + Instant now = clock.instant(); + return now.isBefore(createdAt) ? createdAt : now; + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 5be0ad9..e388c93 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -37,6 +37,8 @@ springdoc: path: /swagger-ui.html app: + database: + tenant-context-mode: transaction-only workflow: catalog: location: ${WORKFLOW_CATALOG_LOCATION:classpath:workflow/catalog-projection.local.json} @@ -110,6 +112,8 @@ spring: jpa: show-sql: true app: + database: + tenant-context-mode: postgresql demo-seed: enabled: false auth: @@ -169,6 +173,8 @@ springdoc: swagger-ui: enabled: false app: + database: + tenant-context-mode: postgresql demo-seed: enabled: false workflow: diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java index b92a64a..3f63f2f 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java @@ -334,6 +334,7 @@ private ConfigurableApplicationContext startRestrictedRuntimeApplication() { "classpath:db/migration,classpath:db/migration-postgresql" ); properties.put("app.demo-seed.enabled", "false"); + properties.put("app.database.tenant-context-mode", "postgresql"); properties.put("server.port", "0"); StandardEnvironment environment = new StandardEnvironment(); diff --git a/src/test/java/com/fowoco/server/common/security/TransactionOnlyTenantDatabaseContextTest.java b/src/test/java/com/fowoco/server/common/security/TransactionOnlyTenantDatabaseContextTest.java new file mode 100644 index 0000000..1f92498 --- /dev/null +++ b/src/test/java/com/fowoco/server/common/security/TransactionOnlyTenantDatabaseContextTest.java @@ -0,0 +1,38 @@ +package com.fowoco.server.common.security; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +class TransactionOnlyTenantDatabaseContextTest { + + private final TenantDatabaseContext tenantDatabaseContext = + new TransactionOnlyTenantDatabaseContext(); + + @AfterEach + void clearTransactionState() { + TransactionSynchronizationManager.clear(); + } + + @Test + void rejectsTenantBindingOutsideATransaction() { + assertThatThrownBy(() -> tenantDatabaseContext.setCompanyIdForCurrentTransaction( + UUID.randomUUID() + )) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("active transaction"); + } + + @Test + void acceptsTrustedTenantInsideATransactionBoundary() { + TransactionSynchronizationManager.setActualTransactionActive(true); + + assertThatCode(() -> tenantDatabaseContext.setCompanyIdForCurrentTransaction( + UUID.randomUUID() + )).doesNotThrowAnyException(); + } +} From a31ed13255e1935e37ee1eced0ec25c49ddb7f61 Mon Sep 17 00:00:00 2001 From: krestar Date: Mon, 27 Jul 2026 10:23:08 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(db):=20PostgreSQL=20RLS=20=EC=A4=80?= =?UTF-8?q?=EB=B9=84=20migration=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Login과 Refresh Token의 최소 tenant bootstrap 함수를 추가 - payload를 노출하지 않는 tenant-safe Outbox claim 함수를 추가 - 현재 14개 tenant 테이블의 RLS policy를 비활성 상태로 준비 - SECURITY DEFINER 함수와 policy 카탈로그 및 권한 경계를 검증 --- .../V8__prepare_postgresql_rls.sql | 307 ++++++++++++++++++ .../server/PostgreSqlMigrationTests.java | 113 +++++++ .../PostgreSqlTenantDatabaseContextTest.java | 30 ++ 3 files changed, 450 insertions(+) create mode 100644 src/main/resources/db/migration-postgresql/V8__prepare_postgresql_rls.sql diff --git a/src/main/resources/db/migration-postgresql/V8__prepare_postgresql_rls.sql b/src/main/resources/db/migration-postgresql/V8__prepare_postgresql_rls.sql new file mode 100644 index 0000000..ba95d39 --- /dev/null +++ b/src/main/resources/db/migration-postgresql/V8__prepare_postgresql_rls.sql @@ -0,0 +1,307 @@ +CREATE FUNCTION public.bootstrap_company_id_by_normalized_email( + p_normalized_email TEXT +) +RETURNS UUID +LANGUAGE SQL +STABLE +SECURITY DEFINER +SET search_path = pg_catalog, public, pg_temp +AS $$ + SELECT account.company_id + FROM public.user_account AS account + WHERE account.normalized_email = p_normalized_email + LIMIT 1 +$$; + +CREATE FUNCTION public.bootstrap_company_id_by_refresh_token_hash( + p_token_hash TEXT +) +RETURNS UUID +LANGUAGE SQL +STABLE +SECURITY DEFINER +SET search_path = pg_catalog, public, pg_temp +AS $$ + SELECT token.company_id + FROM public.refresh_token AS token + WHERE token.token_hash = p_token_hash + LIMIT 1 +$$; + +CREATE FUNCTION public.bootstrap_claim_event_publications( + p_owner TEXT, + p_now TIMESTAMPTZ, + p_lease_expires_at TIMESTAMPTZ, + p_batch_size INTEGER, + p_max_attempts INTEGER +) +RETURNS TABLE ( + event_id UUID, + company_id UUID, + review_required BOOLEAN +) +LANGUAGE SQL +VOLATILE +SECURITY DEFINER +SET search_path = pg_catalog, public, pg_temp +AS $$ + WITH candidates AS ( + SELECT publication.event_id + FROM public.event_publication AS publication + WHERE p_owner IS NOT NULL + AND CHAR_LENGTH(BTRIM(p_owner)) > 0 + AND p_now IS NOT NULL + AND p_lease_expires_at > p_now + AND p_batch_size > 0 + AND p_max_attempts >= 0 + AND ( + ( + publication.status IN ('PENDING', 'RETRY_WAIT') + AND publication.next_attempt_at <= p_now + ) + OR ( + publication.status = 'PROCESSING' + AND publication.lease_expires_at <= p_now + ) + ) + ORDER BY publication.occurred_at, publication.event_id + FOR UPDATE SKIP LOCKED + LIMIT p_batch_size + ), + claimed AS ( + UPDATE public.event_publication AS publication + SET attempt_count = publication.attempt_count + 1, + status = CASE + WHEN publication.attempt_count + 1 > p_max_attempts + THEN 'REVIEW_REQUIRED' + ELSE 'PROCESSING' + END, + next_attempt_at = NULL, + lease_owner = CASE + WHEN publication.attempt_count + 1 > p_max_attempts + THEN NULL + ELSE p_owner + END, + lease_expires_at = CASE + WHEN publication.attempt_count + 1 > p_max_attempts + THEN NULL + ELSE p_lease_expires_at + END, + last_error_code = CASE + WHEN publication.attempt_count + 1 > p_max_attempts + THEN 'EVENT_ATTEMPTS_EXHAUSTED' + ELSE NULL + END, + updated_at = GREATEST(publication.updated_at, p_now), + version = publication.version + 1 + FROM candidates + WHERE publication.event_id = candidates.event_id + RETURNING + publication.event_id, + publication.company_id, + publication.status = 'REVIEW_REQUIRED' AS review_required + ) + SELECT + claimed.event_id, + claimed.company_id, + claimed.review_required + FROM claimed +$$; + +REVOKE ALL + ON FUNCTION public.bootstrap_company_id_by_normalized_email(TEXT) + FROM PUBLIC; +REVOKE ALL + ON FUNCTION public.bootstrap_company_id_by_refresh_token_hash(TEXT) + FROM PUBLIC; +REVOKE ALL + ON FUNCTION public.bootstrap_claim_event_publications( + TEXT, + TIMESTAMPTZ, + TIMESTAMPTZ, + INTEGER, + INTEGER + ) + FROM PUBLIC; + +CREATE POLICY pl_company_tenant_isolation + ON public.company + 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 + ); + +CREATE POLICY pl_user_account_tenant_isolation + ON public.user_account + 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 + ); + +CREATE POLICY pl_refresh_token_tenant_isolation + ON public.refresh_token + 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 + ); + +CREATE POLICY pl_worker_tenant_isolation + ON public.worker + 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 + ); + +CREATE POLICY pl_worker_document_tenant_isolation + ON public.worker_document + 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 + ); + +CREATE POLICY pl_task_tenant_isolation + ON public.task + 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 + ); + +CREATE POLICY pl_task_checklist_item_tenant_isolation + ON public.task_checklist_item + 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 + ); + +CREATE POLICY pl_task_transition_history_tenant_isolation + ON public.task_transition_history + 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 + ); + +CREATE POLICY pl_approval_request_tenant_isolation + ON public.approval_request + 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 + ); + +CREATE POLICY pl_external_submission_tenant_isolation + ON public.external_submission + 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 + ); + +CREATE POLICY pl_task_evidence_tenant_isolation + ON public.task_evidence + 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 + ); + +CREATE POLICY pl_audit_event_tenant_isolation + ON public.audit_event + 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 + ); + +CREATE POLICY pl_event_publication_tenant_isolation + ON public.event_publication + 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 + ); + +CREATE POLICY pl_event_consumption_tenant_isolation + ON public.event_consumption + 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 d7676c8..c4ba9c3 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -192,6 +192,36 @@ private void assertSchemaContract(Connection connection) throws SQLException { "idx_event_publication_company_time", "idx_event_consumption_company_event" ); + assertThat(policyNames(connection)) + .containsExactlyInAnyOrder( + "pl_company_tenant_isolation", + "pl_user_account_tenant_isolation", + "pl_refresh_token_tenant_isolation", + "pl_worker_tenant_isolation", + "pl_worker_document_tenant_isolation", + "pl_task_tenant_isolation", + "pl_task_checklist_item_tenant_isolation", + "pl_task_transition_history_tenant_isolation", + "pl_approval_request_tenant_isolation", + "pl_external_submission_tenant_isolation", + "pl_task_evidence_tenant_isolation", + "pl_audit_event_tenant_isolation", + "pl_event_publication_tenant_isolation", + "pl_event_consumption_tenant_isolation" + ); + assertThat(rlsEnabledTables(connection)).isEmpty(); + assertThat(securityDefinerFunctionNames(connection)) + .containsExactlyInAnyOrder( + "bootstrap_company_id_by_normalized_email", + "bootstrap_company_id_by_refresh_token_hash", + "bootstrap_claim_event_publications" + ); + assertThat(functionsWithLockedSearchPath(connection)) + .containsExactlyInAnyOrder( + "bootstrap_company_id_by_normalized_email", + "bootstrap_company_id_by_refresh_token_hash", + "bootstrap_claim_event_publications" + ); } private void assertConstraintBehavior(Connection connection) throws SQLException { @@ -278,6 +308,27 @@ INSERT INTO event_consumption ( ) """.formatted(EVENT_A, COMPANY_A)); + assertThat(queryNullableString( + connection, + "SELECT public.bootstrap_company_id_by_normalized_email(?)", + "admin.a@example.com" + )).isEqualTo(COMPANY_A); + assertThat(queryNullableString( + connection, + "SELECT public.bootstrap_company_id_by_normalized_email(?)", + "missing@example.com" + )).isNull(); + assertThat(queryNullableString( + connection, + "SELECT public.bootstrap_company_id_by_refresh_token_hash(?)", + TOKEN_HASH_A + )).isEqualTo(COMPANY_A); + assertThat(queryNullableString( + connection, + "SELECT public.bootstrap_company_id_by_refresh_token_hash(?)", + "0".repeat(64) + )).isNull(); + assertSqlState(connection, "23505", """ INSERT INTO user_account ( user_id, company_id, email, normalized_email, @@ -498,6 +549,57 @@ private Set indexNames(Connection connection) throws SQLException { ); } + private Set policyNames(Connection connection) throws SQLException { + return queryStrings( + connection, + """ + SELECT policyname + FROM pg_catalog.pg_policies + WHERE schemaname = 'public' + """ + ); + } + + private Set rlsEnabledTables(Connection connection) throws SQLException { + return queryStrings( + connection, + """ + SELECT relname + FROM pg_catalog.pg_class + WHERE relnamespace = 'public'::regnamespace + AND relkind = 'r' + AND relrowsecurity + """ + ); + } + + private Set securityDefinerFunctionNames(Connection connection) throws SQLException { + return queryStrings( + connection, + """ + SELECT routine.routine_name + FROM information_schema.routines AS routine + WHERE routine.routine_schema = 'public' + AND routine.security_type = 'DEFINER' + AND routine.routine_name LIKE 'bootstrap_%' + """ + ); + } + + private Set functionsWithLockedSearchPath(Connection connection) throws SQLException { + return queryStrings( + connection, + """ + SELECT procedure.proname + FROM pg_catalog.pg_proc AS procedure + WHERE procedure.pronamespace = 'public'::regnamespace + AND procedure.proname LIKE 'bootstrap_%' + AND 'search_path=pg_catalog, public, pg_temp' = + ANY(procedure.proconfig) + """ + ); + } + private void assertSqlState(Connection connection, String expectedSqlState, String sql) throws SQLException { Savepoint savepoint = connection.setSavepoint(); @@ -523,6 +625,17 @@ private void execute(Connection connection, String sql) throws SQLException { } } + private String queryNullableString(Connection connection, String sql, String parameter) + throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, parameter); + try (ResultSet resultSet = statement.executeQuery()) { + assertThat(resultSet.next()).isTrue(); + return resultSet.getString(1); + } + } + } + private Set queryStrings(Connection connection, String sql, String... parameters) throws SQLException { try (PreparedStatement statement = connection.prepareStatement(sql)) { diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java index b3f0433..4614ecf 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java @@ -238,6 +238,20 @@ SELECT COUNT(*) assertThat(hasTablePrivilege("flyway_schema_history", "INSERT")).isFalse(); assertThat(hasTablePrivilege("flyway_schema_history", "UPDATE")).isFalse(); assertThat(hasTablePrivilege("flyway_schema_history", "DELETE")).isFalse(); + assertThat(hasFunctionPrivilege( + "bootstrap_company_id_by_normalized_email(text)", + "EXECUTE" + )).isFalse(); + assertThat(hasFunctionPrivilege( + "bootstrap_company_id_by_refresh_token_hash(text)", + "EXECUTE" + )).isFalse(); + assertThat(hasFunctionPrivilege( + "bootstrap_claim_event_publications(" + + "text,timestamp with time zone,timestamp with time zone,integer,integer" + + ")", + "EXECUTE" + )).isFalse(); } @Test @@ -398,6 +412,22 @@ private boolean hasTablePrivilege(String table, String privileges) { return Boolean.TRUE.equals(allowed); } + private boolean hasFunctionPrivilege(String function, String privileges) { + Boolean allowed = runtimeJdbc.queryForObject( + """ + SELECT pg_catalog.has_function_privilege( + CURRENT_USER, + ?, + ? + ) + """, + Boolean.class, + "public." + function, + privileges + ); + return Boolean.TRUE.equals(allowed); + } + private Connection migrationConnection() throws SQLException { return DriverManager.getConnection( migrationUrl, From 9ec15cd3da6981e14c9902026a38279b69cd3706 Mon Sep 17 00:00:00 2001 From: krestar Date: Mon, 27 Jul 2026 10:44:23 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat(security):=20=EC=9D=B8=EC=A6=9D=20boot?= =?UTF-8?q?strap=20tenant=20context=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Login과 Signup에서 email 기반 tenant bootstrap을 적용 - Refresh와 Logout에서 token hash 기반 tenant bootstrap을 적용 - PostgreSQL SECURITY DEFINER 함수와 H2 조회 adapter를 분리 - tenant 확정 후 기존 Repository와 token family lock을 수행 --- .../server/auth/application/AuthService.java | 22 ++++++- .../RefreshTokenLogoutTransaction.java | 22 +++++++ .../RefreshTokenRotationTransaction.java | 22 +++++++ .../auth/application/SignupService.java | 11 +++- .../application/port/AuthTenantBootstrap.java | 14 +++++ .../persistence/JpaAuthTenantBootstrap.java | 61 +++++++++++++++++++ .../PostgreSqlAuthTenantBootstrap.java | 58 ++++++++++++++++++ .../RefreshTokenTransactionOrderingTest.java | 18 ++++++ 8 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/fowoco/server/auth/application/port/AuthTenantBootstrap.java create mode 100644 src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaAuthTenantBootstrap.java create mode 100644 src/main/java/com/fowoco/server/auth/infrastructure/persistence/PostgreSqlAuthTenantBootstrap.java diff --git a/src/main/java/com/fowoco/server/auth/application/AuthService.java b/src/main/java/com/fowoco/server/auth/application/AuthService.java index afa373f..427090b 100644 --- a/src/main/java/com/fowoco/server/auth/application/AuthService.java +++ b/src/main/java/com/fowoco/server/auth/application/AuthService.java @@ -4,6 +4,7 @@ import com.fowoco.server.auth.application.error.InvalidRefreshTokenException; import com.fowoco.server.auth.application.port.AccessTokenIssuer; import com.fowoco.server.auth.application.port.AuthAuditPort; +import com.fowoco.server.auth.application.port.AuthTenantBootstrap; import com.fowoco.server.auth.application.port.PasswordVerifier; import com.fowoco.server.auth.application.port.RefreshTokenGenerator; import com.fowoco.server.auth.application.port.RefreshTokenHashPort; @@ -13,11 +14,13 @@ import com.fowoco.server.auth.domain.UserAccount; 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.company.application.CompanyAuthenticationReader; import com.fowoco.server.company.application.CompanyAuthenticationSnapshot; import java.time.Clock; import java.time.Instant; import java.util.Optional; +import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,6 +28,8 @@ public class AuthService { private final UserAccountRepository userAccountRepository; + private final AuthTenantBootstrap authTenantBootstrap; + private final TenantDatabaseContext tenantDatabaseContext; private final CompanyAuthenticationReader companyAuthenticationReader; private final PasswordVerifier passwordVerifier; private final AccessTokenIssuer accessTokenIssuer; @@ -39,6 +44,8 @@ public class AuthService { public AuthService( UserAccountRepository userAccountRepository, + AuthTenantBootstrap authTenantBootstrap, + TenantDatabaseContext tenantDatabaseContext, CompanyAuthenticationReader companyAuthenticationReader, PasswordVerifier passwordVerifier, AccessTokenIssuer accessTokenIssuer, @@ -52,6 +59,8 @@ public AuthService( Clock clock ) { this.userAccountRepository = userAccountRepository; + this.authTenantBootstrap = authTenantBootstrap; + this.tenantDatabaseContext = tenantDatabaseContext; this.companyAuthenticationReader = companyAuthenticationReader; this.passwordVerifier = passwordVerifier; this.accessTokenIssuer = accessTokenIssuer; @@ -68,7 +77,18 @@ public AuthService( @Transactional public LoginResult login(LoginCommand command) { String normalizedEmail = UserAccount.normalizeEmail(command.email()); - Optional userAccountCandidate = userAccountRepository.findByNormalizedEmail(normalizedEmail); + Optional companyIdCandidate = + authTenantBootstrap.findCompanyIdByNormalizedEmail(normalizedEmail); + if (companyIdCandidate.isEmpty()) { + passwordVerifier.performDummyCheck(command.password()); + throw invalidCredentialsWithAudit(); + } + + tenantDatabaseContext.setCompanyIdForCurrentTransaction( + companyIdCandidate.orElseThrow() + ); + Optional userAccountCandidate = + userAccountRepository.findByNormalizedEmail(normalizedEmail); if (userAccountCandidate.isEmpty()) { passwordVerifier.performDummyCheck(command.password()); diff --git a/src/main/java/com/fowoco/server/auth/application/RefreshTokenLogoutTransaction.java b/src/main/java/com/fowoco/server/auth/application/RefreshTokenLogoutTransaction.java index cc50ef3..7663a14 100644 --- a/src/main/java/com/fowoco/server/auth/application/RefreshTokenLogoutTransaction.java +++ b/src/main/java/com/fowoco/server/auth/application/RefreshTokenLogoutTransaction.java @@ -1,11 +1,14 @@ package com.fowoco.server.auth.application; import com.fowoco.server.auth.application.port.AuthAuditPort; +import com.fowoco.server.auth.application.port.AuthTenantBootstrap; import com.fowoco.server.auth.application.port.RefreshTokenRepository; import com.fowoco.server.auth.domain.RefreshToken; +import com.fowoco.server.common.security.TenantDatabaseContext; import java.time.Clock; import java.time.Instant; import java.util.Optional; +import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,21 +16,40 @@ public class RefreshTokenLogoutTransaction { private final RefreshTokenRepository refreshTokenRepository; + private final AuthTenantBootstrap authTenantBootstrap; + private final TenantDatabaseContext tenantDatabaseContext; private final AuthAuditPort authAuditPort; private final Clock clock; public RefreshTokenLogoutTransaction( RefreshTokenRepository refreshTokenRepository, + AuthTenantBootstrap authTenantBootstrap, + TenantDatabaseContext tenantDatabaseContext, AuthAuditPort authAuditPort, Clock clock ) { this.refreshTokenRepository = refreshTokenRepository; + this.authTenantBootstrap = authTenantBootstrap; + this.tenantDatabaseContext = tenantDatabaseContext; this.authAuditPort = authAuditPort; this.clock = clock; } @Transactional public void revokeIfKnown(String tokenHash) { + Optional companyIdCandidate = + authTenantBootstrap.findCompanyIdByRefreshTokenHash(tokenHash); + if (companyIdCandidate.isEmpty()) { + authAuditPort.record(AuthAuditEvent.anonymous( + AuthAuditEvent.Action.LOGOUT_COMPLETED, + clock.instant() + )); + return; + } + tenantDatabaseContext.setCompanyIdForCurrentTransaction( + companyIdCandidate.orElseThrow() + ); + Optional refreshTokenCandidate = refreshTokenRepository.findByTokenHashWithFamilyLock(tokenHash); Instant now = clock.instant(); diff --git a/src/main/java/com/fowoco/server/auth/application/RefreshTokenRotationTransaction.java b/src/main/java/com/fowoco/server/auth/application/RefreshTokenRotationTransaction.java index 228f283..140556a 100644 --- a/src/main/java/com/fowoco/server/auth/application/RefreshTokenRotationTransaction.java +++ b/src/main/java/com/fowoco/server/auth/application/RefreshTokenRotationTransaction.java @@ -2,16 +2,19 @@ import com.fowoco.server.auth.application.port.AccessTokenIssuer; import com.fowoco.server.auth.application.port.AuthAuditPort; +import com.fowoco.server.auth.application.port.AuthTenantBootstrap; import com.fowoco.server.auth.application.port.RefreshTokenGenerator; import com.fowoco.server.auth.application.port.RefreshTokenRepository; import com.fowoco.server.auth.application.port.UserAccountRepository; import com.fowoco.server.auth.domain.RefreshToken; import com.fowoco.server.auth.domain.UserAccount; import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.company.application.CompanyAuthenticationReader; import java.time.Clock; import java.time.Instant; import java.util.Optional; +import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,6 +22,8 @@ public class RefreshTokenRotationTransaction { private final RefreshTokenRepository refreshTokenRepository; + private final AuthTenantBootstrap authTenantBootstrap; + private final TenantDatabaseContext tenantDatabaseContext; private final UserAccountRepository userAccountRepository; private final CompanyAuthenticationReader companyAuthenticationReader; private final AccessTokenIssuer accessTokenIssuer; @@ -29,6 +34,8 @@ public class RefreshTokenRotationTransaction { public RefreshTokenRotationTransaction( RefreshTokenRepository refreshTokenRepository, + AuthTenantBootstrap authTenantBootstrap, + TenantDatabaseContext tenantDatabaseContext, UserAccountRepository userAccountRepository, CompanyAuthenticationReader companyAuthenticationReader, AccessTokenIssuer accessTokenIssuer, @@ -38,6 +45,8 @@ public RefreshTokenRotationTransaction( Clock clock ) { this.refreshTokenRepository = refreshTokenRepository; + this.authTenantBootstrap = authTenantBootstrap; + this.tenantDatabaseContext = tenantDatabaseContext; this.userAccountRepository = userAccountRepository; this.companyAuthenticationReader = companyAuthenticationReader; this.accessTokenIssuer = accessTokenIssuer; @@ -49,6 +58,19 @@ public RefreshTokenRotationTransaction( @Transactional public RefreshOutcome rotate(String tokenHash) { + Optional companyIdCandidate = + authTenantBootstrap.findCompanyIdByRefreshTokenHash(tokenHash); + if (companyIdCandidate.isEmpty()) { + authAuditPort.record(AuthAuditEvent.anonymous( + AuthAuditEvent.Action.REFRESH_REJECTED, + clock.instant() + )); + return RefreshOutcome.rejected(RefreshOutcome.Status.INVALID); + } + tenantDatabaseContext.setCompanyIdForCurrentTransaction( + companyIdCandidate.orElseThrow() + ); + Optional presentedTokenCandidate = refreshTokenRepository.findByTokenHashWithFamilyLock(tokenHash); Instant now = clock.instant(); diff --git a/src/main/java/com/fowoco/server/auth/application/SignupService.java b/src/main/java/com/fowoco/server/auth/application/SignupService.java index 5e459fc..281637d 100644 --- a/src/main/java/com/fowoco/server/auth/application/SignupService.java +++ b/src/main/java/com/fowoco/server/auth/application/SignupService.java @@ -2,12 +2,14 @@ import com.fowoco.server.auth.application.error.AuthErrorCode; import com.fowoco.server.auth.application.port.AuthAuditPort; +import com.fowoco.server.auth.application.port.AuthTenantBootstrap; import com.fowoco.server.auth.application.port.PasswordHasher; import com.fowoco.server.auth.application.port.UserAccountRepository; import com.fowoco.server.auth.domain.UserAccount; 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.company.application.port.CompanyRepository; import com.fowoco.server.company.domain.Company; import java.time.Clock; @@ -21,6 +23,8 @@ public class SignupService { private final CompanyRepository companyRepository; private final UserAccountRepository userAccountRepository; + private final AuthTenantBootstrap authTenantBootstrap; + private final TenantDatabaseContext tenantDatabaseContext; private final PasswordHasher passwordHasher; private final AuthAuditPort authAuditPort; private final UuidGenerator uuidGenerator; @@ -29,6 +33,8 @@ public class SignupService { public SignupService( CompanyRepository companyRepository, UserAccountRepository userAccountRepository, + AuthTenantBootstrap authTenantBootstrap, + TenantDatabaseContext tenantDatabaseContext, PasswordHasher passwordHasher, AuthAuditPort authAuditPort, UuidGenerator uuidGenerator, @@ -36,6 +42,8 @@ public SignupService( ) { this.companyRepository = companyRepository; this.userAccountRepository = userAccountRepository; + this.authTenantBootstrap = authTenantBootstrap; + this.tenantDatabaseContext = tenantDatabaseContext; this.passwordHasher = passwordHasher; this.authAuditPort = authAuditPort; this.uuidGenerator = uuidGenerator; @@ -45,7 +53,7 @@ public SignupService( @Transactional public SignupResult signup(SignupCommand command) { String normalizedEmail = UserAccount.normalizeEmail(command.email()); - if (userAccountRepository.existsByNormalizedEmail(normalizedEmail)) { + if (authTenantBootstrap.findCompanyIdByNormalizedEmail(normalizedEmail).isPresent()) { throw duplicateEmail(); } @@ -55,6 +63,7 @@ public SignupResult signup(SignupCommand command) { command.companyName(), now ); + tenantDatabaseContext.setCompanyIdForCurrentTransaction(company.companyId()); UserAccount initialAdmin = UserAccount.create( uuidGenerator.generate(), company.companyId(), diff --git a/src/main/java/com/fowoco/server/auth/application/port/AuthTenantBootstrap.java b/src/main/java/com/fowoco/server/auth/application/port/AuthTenantBootstrap.java new file mode 100644 index 0000000..03774bf --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/application/port/AuthTenantBootstrap.java @@ -0,0 +1,14 @@ +package com.fowoco.server.auth.application.port; + +import java.util.Optional; +import java.util.UUID; + +/** + * Resolves only the tenant identifier needed to enter an authentication transaction. + */ +public interface AuthTenantBootstrap { + + Optional findCompanyIdByNormalizedEmail(String normalizedEmail); + + Optional findCompanyIdByRefreshTokenHash(String tokenHash); +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaAuthTenantBootstrap.java b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaAuthTenantBootstrap.java new file mode 100644 index 0000000..7d113bd --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaAuthTenantBootstrap.java @@ -0,0 +1,61 @@ +package com.fowoco.server.auth.infrastructure.persistence; + +import com.fowoco.server.auth.application.port.AuthTenantBootstrap; +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +/** + * H2/local bootstrap adapter used where PostgreSQL SECURITY DEFINER functions are unavailable. + */ +@Repository +@ConditionalOnProperty( + name = "app.database.tenant-context-mode", + havingValue = "transaction-only", + matchIfMissing = true +) +public class JpaAuthTenantBootstrap implements AuthTenantBootstrap { + + private final EntityManager entityManager; + + public JpaAuthTenantBootstrap(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public Optional findCompanyIdByNormalizedEmail(String normalizedEmail) { + Objects.requireNonNull(normalizedEmail, "normalizedEmail must not be null"); + return entityManager.createQuery( + """ + select account.companyId + from UserAccountJpaEntity account + where account.normalizedEmail = :normalizedEmail + """, + UUID.class + ) + .setParameter("normalizedEmail", normalizedEmail) + .setMaxResults(1) + .getResultStream() + .findFirst(); + } + + @Override + public Optional findCompanyIdByRefreshTokenHash(String tokenHash) { + Objects.requireNonNull(tokenHash, "tokenHash must not be null"); + return entityManager.createQuery( + """ + select token.companyId + from RefreshTokenJpaEntity token + where token.tokenHash = :tokenHash + """, + UUID.class + ) + .setParameter("tokenHash", tokenHash) + .setMaxResults(1) + .getResultStream() + .findFirst(); + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/PostgreSqlAuthTenantBootstrap.java b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/PostgreSqlAuthTenantBootstrap.java new file mode 100644 index 0000000..4c74854 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/PostgreSqlAuthTenantBootstrap.java @@ -0,0 +1,58 @@ +package com.fowoco.server.auth.infrastructure.persistence; + +import com.fowoco.server.auth.application.port.AuthTenantBootstrap; +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +/** + * PostgreSQL bootstrap adapter restricted to company-id-only SECURITY DEFINER functions. + */ +@Repository +@ConditionalOnProperty( + name = "app.database.tenant-context-mode", + havingValue = "postgresql" +) +public class PostgreSqlAuthTenantBootstrap implements AuthTenantBootstrap { + + private static final String EMAIL_BOOTSTRAP_SQL = """ + SELECT public.bootstrap_company_id_by_normalized_email(?1) + """; + private static final String REFRESH_TOKEN_BOOTSTRAP_SQL = """ + SELECT public.bootstrap_company_id_by_refresh_token_hash(?1) + """; + + private final EntityManager entityManager; + + public PostgreSqlAuthTenantBootstrap(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public Optional findCompanyIdByNormalizedEmail(String normalizedEmail) { + Objects.requireNonNull(normalizedEmail, "normalizedEmail must not be null"); + return queryCompanyId(EMAIL_BOOTSTRAP_SQL, normalizedEmail); + } + + @Override + public Optional findCompanyIdByRefreshTokenHash(String tokenHash) { + Objects.requireNonNull(tokenHash, "tokenHash must not be null"); + return queryCompanyId(REFRESH_TOKEN_BOOTSTRAP_SQL, tokenHash); + } + + private Optional queryCompanyId(String sql, String lookupValue) { + Object result = entityManager.createNativeQuery(sql) + .setParameter(1, lookupValue) + .getSingleResult(); + if (result == null) { + return Optional.empty(); + } + if (result instanceof UUID companyId) { + return Optional.of(companyId); + } + return Optional.of(UUID.fromString(result.toString())); + } +} diff --git a/src/test/java/com/fowoco/server/auth/application/RefreshTokenTransactionOrderingTest.java b/src/test/java/com/fowoco/server/auth/application/RefreshTokenTransactionOrderingTest.java index 83a6262..7ed00c0 100644 --- a/src/test/java/com/fowoco/server/auth/application/RefreshTokenTransactionOrderingTest.java +++ b/src/test/java/com/fowoco/server/auth/application/RefreshTokenTransactionOrderingTest.java @@ -6,20 +6,25 @@ import com.fowoco.server.auth.application.port.AccessTokenIssuer; import com.fowoco.server.auth.application.port.AuthAuditPort; +import com.fowoco.server.auth.application.port.AuthTenantBootstrap; import com.fowoco.server.auth.application.port.RefreshTokenGenerator; import com.fowoco.server.auth.application.port.RefreshTokenRepository; import com.fowoco.server.auth.application.port.UserAccountRepository; import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.company.application.CompanyAuthenticationReader; import java.time.Clock; import java.time.Instant; import java.util.Optional; +import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.Test; class RefreshTokenTransactionOrderingTest { private static final String TOKEN_HASH = "a".repeat(64); + private static final UUID COMPANY_ID = + UUID.fromString("10000000-0000-0000-0000-000000000001"); private static final Instant NOW = Instant.parse("2026-07-22T00:00:00Z"); @Test @@ -28,8 +33,11 @@ void rotationSamplesTimeOnlyAfterTheFamilyLookupAndLock() { RefreshTokenRepository repository = repositoryThatCompletes(familyLookupCompleted); Clock clock = clockThatRequiresCompletedLookup(familyLookupCompleted); AuthAuditPort auditPort = event -> { }; + AuthTenantBootstrap tenantBootstrap = tenantBootstrap(); RefreshTokenRotationTransaction transaction = new RefreshTokenRotationTransaction( repository, + tenantBootstrap, + mock(TenantDatabaseContext.class), mock(UserAccountRepository.class), mock(CompanyAuthenticationReader.class), mock(AccessTokenIssuer.class), @@ -50,8 +58,11 @@ void logoutSamplesTimeOnlyAfterTheFamilyLookupAndLock() { RefreshTokenRepository repository = repositoryThatCompletes(familyLookupCompleted); Clock clock = clockThatRequiresCompletedLookup(familyLookupCompleted); AuthAuditPort auditPort = event -> { }; + AuthTenantBootstrap tenantBootstrap = tenantBootstrap(); RefreshTokenLogoutTransaction transaction = new RefreshTokenLogoutTransaction( repository, + tenantBootstrap, + mock(TenantDatabaseContext.class), auditPort, clock ); @@ -70,6 +81,13 @@ private RefreshTokenRepository repositoryThatCompletes(AtomicBoolean familyLooku return repository; } + private AuthTenantBootstrap tenantBootstrap() { + AuthTenantBootstrap bootstrap = mock(AuthTenantBootstrap.class); + when(bootstrap.findCompanyIdByRefreshTokenHash(TOKEN_HASH)) + .thenReturn(Optional.of(COMPANY_ID)); + return bootstrap; + } + private Clock clockThatRequiresCompletedLookup(AtomicBoolean familyLookupCompleted) { Clock clock = mock(Clock.class); when(clock.instant()).thenAnswer(invocation -> { From e59431b9ceaf2772e17db00d7da15fc213b94df7 Mon Sep 17 00:00:00 2001 From: krestar Date: Mon, 27 Jul 2026 11:13:08 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat(reliability):=20Outbox=20tenant-safe?= =?UTF-8?q?=20bootstrap=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Outbox claim 단계에서 tenant 좌표만 반환하도록 bootstrap adapter 적용 - handler, 완료 및 실패 transaction에 tenant context 연결 - EventPublication 조회를 event_id와 company_id 복합 조건으로 제한 - RLS 적용 후에도 backlog metric이 동작하도록 payload-free 집계 함수 추가 - 제한 runtime role의 claim 권한과 교차 tenant payload 차단 검증 --- .../application/OutboxClaimService.java | 41 +++--- .../OutboxCompletionTransaction.java | 10 +- .../application/OutboxFailureTransaction.java | 9 +- .../application/OutboxHandlerTransaction.java | 9 +- .../application/OutboxMetrics.java | 10 +- .../application/OutboxProcessor.java | 35 ++++-- .../application/OutboxReadService.java | 13 +- .../port/EventPublicationRepository.java | 4 +- .../application/port/OutboxBacklogReader.java | 14 +++ .../port/OutboxClaimBootstrap.java | 29 +++++ .../JpaEventPublicationRepository.java | 16 ++- .../persistence/JpaOutboxBacklogReader.java | 33 +++++ .../persistence/JpaOutboxClaimBootstrap.java | 57 +++++++++ .../PostgreSqlOutboxBacklogReader.java | 47 +++++++ .../PostgreSqlOutboxClaimBootstrap.java | 65 ++++++++++ ...ringDataEventPublicationJpaRepository.java | 11 +- .../V8__prepare_postgresql_rls.sql | 40 ++++++ .../server/PostgreSqlMigrationTests.java | 8 +- .../PostgreSqlTenantDatabaseContextTest.java | 119 ++++++++++++++++++ .../reliability/OutboxIntegrationTest.java | 5 +- 20 files changed, 526 insertions(+), 49 deletions(-) create mode 100644 src/main/java/com/fowoco/server/reliability/application/port/OutboxBacklogReader.java create mode 100644 src/main/java/com/fowoco/server/reliability/application/port/OutboxClaimBootstrap.java create mode 100644 src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaOutboxBacklogReader.java create mode 100644 src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaOutboxClaimBootstrap.java create mode 100644 src/main/java/com/fowoco/server/reliability/infrastructure/persistence/PostgreSqlOutboxBacklogReader.java create mode 100644 src/main/java/com/fowoco/server/reliability/infrastructure/persistence/PostgreSqlOutboxClaimBootstrap.java diff --git a/src/main/java/com/fowoco/server/reliability/application/OutboxClaimService.java b/src/main/java/com/fowoco/server/reliability/application/OutboxClaimService.java index f79876a..f5e328b 100644 --- a/src/main/java/com/fowoco/server/reliability/application/OutboxClaimService.java +++ b/src/main/java/com/fowoco/server/reliability/application/OutboxClaimService.java @@ -1,8 +1,8 @@ package com.fowoco.server.reliability.application; -import com.fowoco.server.reliability.application.port.EventPublicationRepository; +import com.fowoco.server.reliability.application.port.OutboxClaimBootstrap; +import com.fowoco.server.reliability.application.port.OutboxClaimBootstrap.ClaimResult; import com.fowoco.server.reliability.config.OutboxProperties; -import com.fowoco.server.reliability.domain.EventPublication; import java.time.Clock; import java.time.Instant; import java.util.ArrayList; @@ -14,39 +14,50 @@ @Service public class OutboxClaimService { - private final EventPublicationRepository repository; + private final OutboxClaimBootstrap claimBootstrap; private final OutboxProperties properties; private final OutboxMetrics metrics; private final Clock clock; public OutboxClaimService( - EventPublicationRepository repository, + OutboxClaimBootstrap claimBootstrap, OutboxProperties properties, OutboxMetrics metrics, Clock clock ) { - this.repository = repository; + this.claimBootstrap = claimBootstrap; this.properties = properties; this.metrics = metrics; this.clock = clock; } @Transactional - public List claimBatch(String owner) { + public List claimBatch(String owner) { Instant now = clock.instant(); - List candidates = - repository.lockClaimable(now, properties.getBatchSize()); - List claimed = new ArrayList<>(candidates.size()); - for (EventPublication publication : candidates) { - publication.claim(owner, now, properties.getLeaseDuration()); - if (publication.attemptCount() > properties.getMaxAttempts()) { - publication.requireReview(owner, "EVENT_ATTEMPTS_EXHAUSTED", now); + List results = claimBootstrap.claim( + owner, + now, + properties.getLeaseDuration(), + properties.getBatchSize(), + properties.getMaxAttempts() + ); + List claimed = new ArrayList<>(results.size()); + for (ClaimResult result : results) { + if (result.reviewRequired()) { metrics.recordReviewRequired(); } else { - claimed.add(publication.eventId()); + claimed.add(new ClaimedEvent(result.eventId(), result.companyId())); } - repository.save(publication); } return List.copyOf(claimed); } + + public record ClaimedEvent(UUID eventId, UUID companyId) { + + public ClaimedEvent { + if (eventId == null || companyId == null) { + throw new IllegalArgumentException("claimed event identifiers must not be null"); + } + } + } } diff --git a/src/main/java/com/fowoco/server/reliability/application/OutboxCompletionTransaction.java b/src/main/java/com/fowoco/server/reliability/application/OutboxCompletionTransaction.java index f8628dd..dba5962 100644 --- a/src/main/java/com/fowoco/server/reliability/application/OutboxCompletionTransaction.java +++ b/src/main/java/com/fowoco/server/reliability/application/OutboxCompletionTransaction.java @@ -1,5 +1,6 @@ package com.fowoco.server.reliability.application; +import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.reliability.application.port.EventPublicationRepository; import com.fowoco.server.reliability.domain.EventPublication; import java.time.Clock; @@ -13,23 +14,28 @@ public class OutboxCompletionTransaction { private final EventPublicationRepository repository; + private final TenantDatabaseContext tenantDatabaseContext; private final OutboxMetrics metrics; private final Clock clock; public OutboxCompletionTransaction( EventPublicationRepository repository, + TenantDatabaseContext tenantDatabaseContext, OutboxMetrics metrics, Clock clock ) { this.repository = repository; + this.tenantDatabaseContext = tenantDatabaseContext; this.metrics = metrics; this.clock = clock; } @Transactional(propagation = Propagation.REQUIRES_NEW) - public void complete(UUID eventId, String owner) { + public void complete(UUID eventId, UUID companyId, String owner) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); Instant now = clock.instant(); - EventPublication publication = repository.findByIdForUpdate(eventId) + EventPublication publication = repository + .findByIdAndCompanyIdForUpdate(eventId, companyId) .orElseThrow(() -> new IllegalStateException("Event publication not found.")); publication.complete(owner, now); repository.save(publication); diff --git a/src/main/java/com/fowoco/server/reliability/application/OutboxFailureTransaction.java b/src/main/java/com/fowoco/server/reliability/application/OutboxFailureTransaction.java index 558edb4..9d37afd 100644 --- a/src/main/java/com/fowoco/server/reliability/application/OutboxFailureTransaction.java +++ b/src/main/java/com/fowoco/server/reliability/application/OutboxFailureTransaction.java @@ -1,5 +1,6 @@ package com.fowoco.server.reliability.application; +import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.reliability.application.OutboxFailureClassifier.FailureClassification; import com.fowoco.server.reliability.application.port.EventPublicationRepository; import com.fowoco.server.reliability.config.OutboxProperties; @@ -15,6 +16,7 @@ public class OutboxFailureTransaction { private final EventPublicationRepository repository; + private final TenantDatabaseContext tenantDatabaseContext; private final OutboxFailureClassifier classifier; private final OutboxBackoffPolicy backoffPolicy; private final OutboxProperties properties; @@ -23,6 +25,7 @@ public class OutboxFailureTransaction { public OutboxFailureTransaction( EventPublicationRepository repository, + TenantDatabaseContext tenantDatabaseContext, OutboxFailureClassifier classifier, OutboxBackoffPolicy backoffPolicy, OutboxProperties properties, @@ -30,6 +33,7 @@ public OutboxFailureTransaction( Clock clock ) { this.repository = repository; + this.tenantDatabaseContext = tenantDatabaseContext; this.classifier = classifier; this.backoffPolicy = backoffPolicy; this.properties = properties; @@ -40,11 +44,14 @@ public OutboxFailureTransaction( @Transactional(propagation = Propagation.REQUIRES_NEW) public FailureOutcome recordFailure( UUID eventId, + UUID companyId, String owner, Throwable failure ) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); Instant now = clock.instant(); - EventPublication publication = repository.findByIdForUpdate(eventId) + EventPublication publication = repository + .findByIdAndCompanyIdForUpdate(eventId, companyId) .orElseThrow(() -> new IllegalStateException("Event publication not found.")); FailureClassification classification = classifier.classify(failure); boolean exhausted = publication.attemptCount() >= properties.getMaxAttempts(); diff --git a/src/main/java/com/fowoco/server/reliability/application/OutboxHandlerTransaction.java b/src/main/java/com/fowoco/server/reliability/application/OutboxHandlerTransaction.java index 5996ed5..b7c058d 100644 --- a/src/main/java/com/fowoco/server/reliability/application/OutboxHandlerTransaction.java +++ b/src/main/java/com/fowoco/server/reliability/application/OutboxHandlerTransaction.java @@ -1,6 +1,7 @@ package com.fowoco.server.reliability.application; import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.reliability.application.port.DomainEventHandler; import com.fowoco.server.reliability.application.port.EventConsumptionRepository; import com.fowoco.server.reliability.application.port.EventPublicationRepository; @@ -20,6 +21,7 @@ public class OutboxHandlerTransaction { private final EventPublicationRepository publicationRepository; private final EventConsumptionRepository consumptionRepository; + private final TenantDatabaseContext tenantDatabaseContext; private final EventPayloadCodec payloadCodec; private final UuidGenerator uuidGenerator; private final Clock clock; @@ -27,12 +29,14 @@ public class OutboxHandlerTransaction { public OutboxHandlerTransaction( EventPublicationRepository publicationRepository, EventConsumptionRepository consumptionRepository, + TenantDatabaseContext tenantDatabaseContext, EventPayloadCodec payloadCodec, UuidGenerator uuidGenerator, Clock clock ) { this.publicationRepository = publicationRepository; this.consumptionRepository = consumptionRepository; + this.tenantDatabaseContext = tenantDatabaseContext; this.payloadCodec = payloadCodec; this.uuidGenerator = uuidGenerator; this.clock = clock; @@ -41,11 +45,14 @@ public OutboxHandlerTransaction( @Transactional(propagation = Propagation.REQUIRES_NEW) public boolean deliver( UUID eventId, + UUID companyId, String owner, DomainEventHandler handler ) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); Instant now = clock.instant(); - EventPublication publication = publicationRepository.findByIdForUpdate(eventId) + EventPublication publication = publicationRepository + .findByIdAndCompanyIdForUpdate(eventId, companyId) .orElseThrow(() -> new IllegalStateException("Event publication not found.")); publication.requireActiveLease(owner, now); String handlerName = handler.handlerName(); diff --git a/src/main/java/com/fowoco/server/reliability/application/OutboxMetrics.java b/src/main/java/com/fowoco/server/reliability/application/OutboxMetrics.java index 9e1f8a8..06409a6 100644 --- a/src/main/java/com/fowoco/server/reliability/application/OutboxMetrics.java +++ b/src/main/java/com/fowoco/server/reliability/application/OutboxMetrics.java @@ -1,6 +1,6 @@ package com.fowoco.server.reliability.application; -import com.fowoco.server.reliability.application.port.EventPublicationRepository; +import com.fowoco.server.reliability.application.port.OutboxBacklogReader; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.MeterRegistry; @@ -17,7 +17,7 @@ public class OutboxMetrics { public OutboxMetrics( MeterRegistry meterRegistry, - EventPublicationRepository repository, + OutboxBacklogReader backlogReader, Clock clock ) { completed = counter(meterRegistry, "completed"); @@ -25,14 +25,14 @@ public OutboxMetrics( reviewRequired = counter(meterRegistry, "review_required"); Gauge.builder( "fowoco.outbox.publications.backlog", - repository, - EventPublicationRepository::countOutstanding + backlogReader, + OutboxBacklogReader::countOutstanding ) .description("Outstanding durable event publications") .register(meterRegistry); Gauge.builder( "fowoco.outbox.publications.oldest.delay.seconds", - repository, + backlogReader, candidate -> candidate.findOldestOutstandingOccurredAt() .map(occurredAt -> Math.max( 0.0, diff --git a/src/main/java/com/fowoco/server/reliability/application/OutboxProcessor.java b/src/main/java/com/fowoco/server/reliability/application/OutboxProcessor.java index ad59e48..0eb60ee 100644 --- a/src/main/java/com/fowoco/server/reliability/application/OutboxProcessor.java +++ b/src/main/java/com/fowoco/server/reliability/application/OutboxProcessor.java @@ -4,7 +4,6 @@ import com.fowoco.server.reliability.config.OutboxWorkerIdentity; import com.fowoco.server.reliability.domain.EventPublication; import java.util.List; -import java.util.UUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @@ -41,32 +40,46 @@ public OutboxProcessor( } public int processAvailable() { - List eventIds = claimService.claimBatch(workerIdentity.value()); - eventIds.forEach(this::processOne); - return eventIds.size(); + List claimedEvents = + claimService.claimBatch(workerIdentity.value()); + claimedEvents.forEach(this::processOne); + return claimedEvents.size(); } - private void processOne(UUID eventId) { - EventPublication publication = readService.requirePublication(eventId); + private void processOne(OutboxClaimService.ClaimedEvent claimedEvent) { + EventPublication publication = readService.requirePublication( + claimedEvent.eventId(), + claimedEvent.companyId() + ); try { List handlers = handlerRegistry.handlersFor(publication.eventType()); for (DomainEventHandler handler : handlers) { - handlerTransaction.deliver(eventId, workerIdentity.value(), handler); + handlerTransaction.deliver( + claimedEvent.eventId(), + claimedEvent.companyId(), + workerIdentity.value(), + handler + ); } - completionTransaction.complete(eventId, workerIdentity.value()); + completionTransaction.complete( + claimedEvent.eventId(), + claimedEvent.companyId(), + workerIdentity.value() + ); } catch (RuntimeException failure) { try { OutboxFailureTransaction.FailureOutcome outcome = failureTransaction.recordFailure( - eventId, + claimedEvent.eventId(), + claimedEvent.companyId(), workerIdentity.value(), failure ); log.warn( "Outbox event processing failed: eventId={}, eventType={}, " + "attempt={}, errorCode={}, retryScheduled={}", - eventId, + claimedEvent.eventId(), publication.eventType(), publication.attemptCount(), outcome.errorCode(), @@ -75,7 +88,7 @@ private void processOne(UUID eventId) { } catch (RuntimeException recordingFailure) { log.error( "Outbox failure state could not be recorded: eventId={}, eventType={}", - eventId, + claimedEvent.eventId(), publication.eventType() ); } diff --git a/src/main/java/com/fowoco/server/reliability/application/OutboxReadService.java b/src/main/java/com/fowoco/server/reliability/application/OutboxReadService.java index 82e1590..23d3efa 100644 --- a/src/main/java/com/fowoco/server/reliability/application/OutboxReadService.java +++ b/src/main/java/com/fowoco/server/reliability/application/OutboxReadService.java @@ -1,5 +1,6 @@ package com.fowoco.server.reliability.application; +import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.reliability.application.port.EventPublicationRepository; import com.fowoco.server.reliability.domain.EventPublication; import java.util.UUID; @@ -10,14 +11,20 @@ public class OutboxReadService { private final EventPublicationRepository repository; + private final TenantDatabaseContext tenantDatabaseContext; - public OutboxReadService(EventPublicationRepository repository) { + public OutboxReadService( + EventPublicationRepository repository, + TenantDatabaseContext tenantDatabaseContext + ) { this.repository = repository; + this.tenantDatabaseContext = tenantDatabaseContext; } @Transactional(readOnly = true) - public EventPublication requirePublication(UUID eventId) { - return repository.findById(eventId) + public EventPublication requirePublication(UUID eventId, UUID companyId) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + return repository.findByIdAndCompanyId(eventId, companyId) .orElseThrow(() -> new IllegalStateException("Event publication not found.")); } } diff --git a/src/main/java/com/fowoco/server/reliability/application/port/EventPublicationRepository.java b/src/main/java/com/fowoco/server/reliability/application/port/EventPublicationRepository.java index 5f40baf..9f1cb1f 100644 --- a/src/main/java/com/fowoco/server/reliability/application/port/EventPublicationRepository.java +++ b/src/main/java/com/fowoco/server/reliability/application/port/EventPublicationRepository.java @@ -14,9 +14,9 @@ public interface EventPublicationRepository { List lockClaimable(Instant now, int limit); - Optional findById(UUID eventId); + Optional findByIdAndCompanyId(UUID eventId, UUID companyId); - Optional findByIdForUpdate(UUID eventId); + Optional findByIdAndCompanyIdForUpdate(UUID eventId, UUID companyId); long countOutstanding(); diff --git a/src/main/java/com/fowoco/server/reliability/application/port/OutboxBacklogReader.java b/src/main/java/com/fowoco/server/reliability/application/port/OutboxBacklogReader.java new file mode 100644 index 0000000..853984a --- /dev/null +++ b/src/main/java/com/fowoco/server/reliability/application/port/OutboxBacklogReader.java @@ -0,0 +1,14 @@ +package com.fowoco.server.reliability.application.port; + +import java.time.Instant; +import java.util.Optional; + +/** + * Reads payload-free, cross-tenant backlog aggregates for operational metrics. + */ +public interface OutboxBacklogReader { + + long countOutstanding(); + + Optional findOldestOutstandingOccurredAt(); +} diff --git a/src/main/java/com/fowoco/server/reliability/application/port/OutboxClaimBootstrap.java b/src/main/java/com/fowoco/server/reliability/application/port/OutboxClaimBootstrap.java new file mode 100644 index 0000000..f76d28f --- /dev/null +++ b/src/main/java/com/fowoco/server/reliability/application/port/OutboxClaimBootstrap.java @@ -0,0 +1,29 @@ +package com.fowoco.server.reliability.application.port; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +/** + * Claims cross-tenant outbox work without exposing event payloads. + */ +public interface OutboxClaimBootstrap { + + List claim( + String owner, + Instant now, + Duration leaseDuration, + int batchSize, + int maxAttempts + ); + + record ClaimResult(UUID eventId, UUID companyId, boolean reviewRequired) { + + public ClaimResult { + if (eventId == null || companyId == null) { + throw new IllegalArgumentException("claimed event identifiers must not be null"); + } + } + } +} diff --git a/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaEventPublicationRepository.java b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaEventPublicationRepository.java index 256053f..9688e00 100644 --- a/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaEventPublicationRepository.java +++ b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaEventPublicationRepository.java @@ -39,7 +39,10 @@ public EventPublication append(EventPublication publication) { @Override public EventPublication save(EventPublication publication) { - EventPublicationJpaEntity entity = repository.findById(publication.eventId()) + EventPublicationJpaEntity entity = repository.findByEventIdAndCompanyId( + publication.eventId(), + publication.companyId() + ) .orElseThrow(() -> new IllegalStateException("Event publication not found.")); entity.apply(publication); return repository.saveAndFlush(entity).toDomain(); @@ -59,14 +62,17 @@ public List lockClaimable(Instant now, int limit) { } @Override - public Optional findById(UUID eventId) { - return repository.findById(eventId) + public Optional findByIdAndCompanyId(UUID eventId, UUID companyId) { + return repository.findByEventIdAndCompanyId(eventId, companyId) .map(EventPublicationJpaEntity::toDomain); } @Override - public Optional findByIdForUpdate(UUID eventId) { - return repository.findByIdForUpdate(eventId) + public Optional findByIdAndCompanyIdForUpdate( + UUID eventId, + UUID companyId + ) { + return repository.findByIdAndCompanyIdForUpdate(eventId, companyId) .map(EventPublicationJpaEntity::toDomain); } diff --git a/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaOutboxBacklogReader.java b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaOutboxBacklogReader.java new file mode 100644 index 0000000..392dbb7 --- /dev/null +++ b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaOutboxBacklogReader.java @@ -0,0 +1,33 @@ +package com.fowoco.server.reliability.infrastructure.persistence; + +import com.fowoco.server.reliability.application.port.EventPublicationRepository; +import com.fowoco.server.reliability.application.port.OutboxBacklogReader; +import java.time.Instant; +import java.util.Optional; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +@Repository +@ConditionalOnProperty( + name = "app.database.tenant-context-mode", + havingValue = "transaction-only", + matchIfMissing = true +) +public class JpaOutboxBacklogReader implements OutboxBacklogReader { + + private final EventPublicationRepository repository; + + public JpaOutboxBacklogReader(EventPublicationRepository repository) { + this.repository = repository; + } + + @Override + public long countOutstanding() { + return repository.countOutstanding(); + } + + @Override + public Optional findOldestOutstandingOccurredAt() { + return repository.findOldestOutstandingOccurredAt(); + } +} diff --git a/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaOutboxClaimBootstrap.java b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaOutboxClaimBootstrap.java new file mode 100644 index 0000000..ece02aa --- /dev/null +++ b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaOutboxClaimBootstrap.java @@ -0,0 +1,57 @@ +package com.fowoco.server.reliability.infrastructure.persistence; + +import com.fowoco.server.reliability.application.port.EventPublicationRepository; +import com.fowoco.server.reliability.application.port.OutboxClaimBootstrap; +import com.fowoco.server.reliability.domain.EventPublication; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +/** + * H2/local claim adapter preserving the domain claim behavior without PostgreSQL functions. + */ +@Repository +@ConditionalOnProperty( + name = "app.database.tenant-context-mode", + havingValue = "transaction-only", + matchIfMissing = true +) +public class JpaOutboxClaimBootstrap implements OutboxClaimBootstrap { + + private static final String ATTEMPTS_EXHAUSTED = "EVENT_ATTEMPTS_EXHAUSTED"; + + private final EventPublicationRepository repository; + + public JpaOutboxClaimBootstrap(EventPublicationRepository repository) { + this.repository = repository; + } + + @Override + public List claim( + String owner, + Instant now, + Duration leaseDuration, + int batchSize, + int maxAttempts + ) { + List candidates = repository.lockClaimable(now, batchSize); + List results = new ArrayList<>(candidates.size()); + for (EventPublication publication : candidates) { + publication.claim(owner, now, leaseDuration); + boolean reviewRequired = publication.attemptCount() > maxAttempts; + if (reviewRequired) { + publication.requireReview(owner, ATTEMPTS_EXHAUSTED, now); + } + repository.save(publication); + results.add(new ClaimResult( + publication.eventId(), + publication.companyId(), + reviewRequired + )); + } + return List.copyOf(results); + } +} diff --git a/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/PostgreSqlOutboxBacklogReader.java b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/PostgreSqlOutboxBacklogReader.java new file mode 100644 index 0000000..bf71325 --- /dev/null +++ b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/PostgreSqlOutboxBacklogReader.java @@ -0,0 +1,47 @@ +package com.fowoco.server.reliability.infrastructure.persistence; + +import com.fowoco.server.reliability.application.port.OutboxBacklogReader; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.Optional; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +@Repository +@ConditionalOnProperty( + name = "app.database.tenant-context-mode", + havingValue = "postgresql" +) +public class PostgreSqlOutboxBacklogReader implements OutboxBacklogReader { + + private final EntityManager entityManager; + + public PostgreSqlOutboxBacklogReader(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public long countOutstanding() { + return ((Number) entityManager.createNativeQuery( + "SELECT public.bootstrap_count_outstanding_event_publications()" + ).getSingleResult()).longValue(); + } + + @Override + public Optional findOldestOutstandingOccurredAt() { + Object result = entityManager.createNativeQuery( + "SELECT public.bootstrap_oldest_outstanding_event_occurred_at()" + ).getSingleResult(); + if (result == null) { + return Optional.empty(); + } + if (result instanceof Instant instant) { + return Optional.of(instant); + } + if (result instanceof OffsetDateTime offsetDateTime) { + return Optional.of(offsetDateTime.toInstant()); + } + throw new IllegalStateException("Unexpected PostgreSQL timestamp mapping."); + } +} diff --git a/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/PostgreSqlOutboxClaimBootstrap.java b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/PostgreSqlOutboxClaimBootstrap.java new file mode 100644 index 0000000..1c8abd2 --- /dev/null +++ b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/PostgreSqlOutboxClaimBootstrap.java @@ -0,0 +1,65 @@ +package com.fowoco.server.reliability.infrastructure.persistence; + +import com.fowoco.server.reliability.application.port.OutboxClaimBootstrap; +import jakarta.persistence.EntityManager; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +/** + * PostgreSQL claim adapter returning only identifiers from the restricted bootstrap function. + */ +@Repository +@ConditionalOnProperty( + name = "app.database.tenant-context-mode", + havingValue = "postgresql" +) +public class PostgreSqlOutboxClaimBootstrap implements OutboxClaimBootstrap { + + private static final String CLAIM_SQL = """ + SELECT event_id, company_id, review_required + FROM public.bootstrap_claim_event_publications(?1, ?2, ?3, ?4, ?5) + """; + + private final EntityManager entityManager; + + public PostgreSqlOutboxClaimBootstrap(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public List claim( + String owner, + Instant now, + Duration leaseDuration, + int batchSize, + int maxAttempts + ) { + Instant leaseExpiresAt = now.plus(leaseDuration); + @SuppressWarnings("unchecked") + List rows = entityManager.createNativeQuery(CLAIM_SQL) + .setParameter(1, owner) + .setParameter(2, now) + .setParameter(3, leaseExpiresAt) + .setParameter(4, batchSize) + .setParameter(5, maxAttempts) + .getResultList(); + return rows.stream() + .map(row -> new ClaimResult( + asUuid(row[0]), + asUuid(row[1]), + (Boolean) row[2] + )) + .toList(); + } + + private UUID asUuid(Object value) { + if (value instanceof UUID uuid) { + return uuid; + } + return UUID.fromString(value.toString()); + } +} diff --git a/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/SpringDataEventPublicationJpaRepository.java b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/SpringDataEventPublicationJpaRepository.java index a493bf7..a6af697 100644 --- a/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/SpringDataEventPublicationJpaRepository.java +++ b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/SpringDataEventPublicationJpaRepository.java @@ -41,8 +41,17 @@ List lockClaimable( SELECT publication FROM EventPublicationJpaEntity publication WHERE publication.eventId = :eventId + AND publication.companyId = :companyId """) - Optional findByIdForUpdate(@Param("eventId") UUID eventId); + Optional findByIdAndCompanyIdForUpdate( + @Param("eventId") UUID eventId, + @Param("companyId") UUID companyId + ); + + Optional findByEventIdAndCompanyId( + UUID eventId, + UUID companyId + ); long countByStatusIn(Collection statuses); diff --git a/src/main/resources/db/migration-postgresql/V8__prepare_postgresql_rls.sql b/src/main/resources/db/migration-postgresql/V8__prepare_postgresql_rls.sql index ba95d39..6479a46 100644 --- a/src/main/resources/db/migration-postgresql/V8__prepare_postgresql_rls.sql +++ b/src/main/resources/db/migration-postgresql/V8__prepare_postgresql_rls.sql @@ -108,6 +108,40 @@ AS $$ FROM claimed $$; +CREATE FUNCTION public.bootstrap_count_outstanding_event_publications() +RETURNS BIGINT +LANGUAGE SQL +STABLE +SECURITY DEFINER +SET search_path = pg_catalog, public, pg_temp +AS $$ + SELECT COUNT(*) + FROM public.event_publication AS publication + WHERE publication.status IN ( + 'PENDING', + 'PROCESSING', + 'RETRY_WAIT', + 'REVIEW_REQUIRED' + ) +$$; + +CREATE FUNCTION public.bootstrap_oldest_outstanding_event_occurred_at() +RETURNS TIMESTAMPTZ +LANGUAGE SQL +STABLE +SECURITY DEFINER +SET search_path = pg_catalog, public, pg_temp +AS $$ + SELECT MIN(publication.occurred_at) + FROM public.event_publication AS publication + WHERE publication.status IN ( + 'PENDING', + 'PROCESSING', + 'RETRY_WAIT', + 'REVIEW_REQUIRED' + ) +$$; + REVOKE ALL ON FUNCTION public.bootstrap_company_id_by_normalized_email(TEXT) FROM PUBLIC; @@ -123,6 +157,12 @@ REVOKE ALL INTEGER ) FROM PUBLIC; +REVOKE ALL + ON FUNCTION public.bootstrap_count_outstanding_event_publications() + FROM PUBLIC; +REVOKE ALL + ON FUNCTION public.bootstrap_oldest_outstanding_event_occurred_at() + FROM PUBLIC; CREATE POLICY pl_company_tenant_isolation ON public.company diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index c4ba9c3..4fcf600 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -214,13 +214,17 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsExactlyInAnyOrder( "bootstrap_company_id_by_normalized_email", "bootstrap_company_id_by_refresh_token_hash", - "bootstrap_claim_event_publications" + "bootstrap_claim_event_publications", + "bootstrap_count_outstanding_event_publications", + "bootstrap_oldest_outstanding_event_occurred_at" ); assertThat(functionsWithLockedSearchPath(connection)) .containsExactlyInAnyOrder( "bootstrap_company_id_by_normalized_email", "bootstrap_company_id_by_refresh_token_hash", - "bootstrap_claim_event_publications" + "bootstrap_claim_event_publications", + "bootstrap_count_outstanding_event_publications", + "bootstrap_oldest_outstanding_event_occurred_at" ); } diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java index 4614ecf..bb8192b 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java @@ -4,6 +4,10 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.fowoco.server.ServerApplication; +import com.fowoco.server.reliability.application.OutboxClaimService; +import com.fowoco.server.reliability.application.OutboxReadService; +import com.fowoco.server.reliability.application.port.OutboxBacklogReader; +import com.fowoco.server.reliability.domain.EventPublication; import jakarta.persistence.EntityManager; import java.sql.Connection; import java.sql.DriverManager; @@ -41,6 +45,10 @@ class PostgreSqlTenantDatabaseContextTest { UUID.fromString("a0000000-0000-0000-0000-000000000001"); private static final UUID COMPANY_B = UUID.fromString("b0000000-0000-0000-0000-000000000002"); + private static final UUID OUTBOX_EVENT_A = + UUID.fromString("a8000000-0000-0000-0000-000000000001"); + private static final UUID OUTBOX_EVENT_B = + UUID.fromString("b8000000-0000-0000-0000-000000000002"); private static final String[] TENANT_TABLES = { "company", "user_account", @@ -252,6 +260,14 @@ SELECT COUNT(*) + ")", "EXECUTE" )).isFalse(); + assertThat(hasFunctionPrivilege( + "bootstrap_count_outstanding_event_publications()", + "EXECUTE" + )).isFalse(); + assertThat(hasFunctionPrivilege( + "bootstrap_oldest_outstanding_event_occurred_at()", + "EXECUTE" + )).isFalse(); } @Test @@ -333,6 +349,76 @@ void springJpaTransactionUsesTheRestrictedRuntimeConnection() { assertThat(contextProbe.companyId()).isEqualTo(COMPANY_A.toString()); } + @Test + void outboxClaimReturnsOnlyTenantCoordinatesAndScopedReadsRejectMismatches() { + String function = "public.bootstrap_claim_event_publications(" + + "text,timestamptz,timestamptz,integer,integer" + + ")"; + String countFunction = + "public.bootstrap_count_outstanding_event_publications()"; + String oldestFunction = + "public.bootstrap_oldest_outstanding_event_occurred_at()"; + String quotedRole = quoteIdentifier(runtimeRole); + migrationJdbc.execute("GRANT EXECUTE ON FUNCTION " + function + " TO " + quotedRole); + migrationJdbc.execute( + "GRANT EXECUTE ON FUNCTION " + countFunction + " TO " + quotedRole + ); + migrationJdbc.execute( + "GRANT EXECUTE ON FUNCTION " + oldestFunction + " TO " + quotedRole + ); + try { + insertOutboxProbe(COMPANY_A, OUTBOX_EVENT_A, "tenant-a-payload"); + insertOutboxProbe(COMPANY_B, OUTBOX_EVENT_B, "tenant-b-payload"); + + OutboxClaimService claimService = + applicationContext.getBean(OutboxClaimService.class); + assertThat(claimService.claimBatch("rls-outbox-test")) + .containsExactlyInAnyOrder( + new OutboxClaimService.ClaimedEvent( + OUTBOX_EVENT_A, + COMPANY_A + ), + new OutboxClaimService.ClaimedEvent( + OUTBOX_EVENT_B, + COMPANY_B + ) + ); + + OutboxReadService readService = + applicationContext.getBean(OutboxReadService.class); + assertThatThrownBy( + () -> readService.requirePublication(OUTBOX_EVENT_A, COMPANY_B) + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("not found"); + + EventPublication publication = + readService.requirePublication(OUTBOX_EVENT_A, COMPANY_A); + assertThat(publication.payloadJson()).isEqualTo("tenant-a-payload"); + + OutboxBacklogReader backlogReader = + applicationContext.getBean(OutboxBacklogReader.class); + assertThat(backlogReader.countOutstanding()).isEqualTo(2); + assertThat(backlogReader.findOldestOutstandingOccurredAt()).isPresent(); + } finally { + migrationJdbc.execute("DELETE FROM event_consumption WHERE event_id IN ('" + + OUTBOX_EVENT_A + "', '" + OUTBOX_EVENT_B + "')"); + migrationJdbc.execute("DELETE FROM event_publication WHERE event_id IN ('" + + OUTBOX_EVENT_A + "', '" + OUTBOX_EVENT_B + "')"); + migrationJdbc.execute("DELETE FROM company WHERE company_id IN ('" + + COMPANY_A + "', '" + COMPANY_B + "')"); + migrationJdbc.execute( + "REVOKE EXECUTE ON FUNCTION " + function + " FROM " + quotedRole + ); + migrationJdbc.execute( + "REVOKE EXECUTE ON FUNCTION " + countFunction + " FROM " + quotedRole + ); + migrationJdbc.execute( + "REVOKE EXECUTE ON FUNCTION " + oldestFunction + " FROM " + quotedRole + ); + } + } + private ConfigurableApplicationContext startRestrictedRuntimeApplication() { Map properties = new LinkedHashMap<>(); properties.put("spring.datasource.url", migrationUrl); @@ -365,6 +451,39 @@ private ConfigurableApplicationContext startRestrictedRuntimeApplication() { return application.run(); } + private void insertOutboxProbe(UUID companyId, UUID eventId, String payload) { + migrationJdbc.update( + """ + INSERT INTO company ( + company_id, name, status, created_at, updated_at, version + ) VALUES (?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + ON CONFLICT (company_id) DO NOTHING + """, + companyId, + "Outbox probe " + companyId + ); + migrationJdbc.update( + """ + INSERT INTO event_publication ( + event_id, company_id, event_type, payload_version, + aggregate_type, aggregate_id, actor_type, request_id, + payload_json, status, attempt_count, next_attempt_at, + occurred_at, created_at, updated_at, version + ) VALUES ( + ?, ?, 'RlsOutboxProbe', '1', + 'RlsProbe', ?, 'SYSTEM_RULE', ?, + ?, 'PENDING', 0, CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0 + ) + """, + eventId, + companyId, + eventId, + "rls-outbox-" + eventId, + payload + ); + } + private ContextProbe bindAndRead(UUID companyId) { return transactionTemplate.execute(status -> { tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); diff --git a/src/test/java/com/fowoco/server/reliability/OutboxIntegrationTest.java b/src/test/java/com/fowoco/server/reliability/OutboxIntegrationTest.java index fc05dab..dca393e 100644 --- a/src/test/java/com/fowoco/server/reliability/OutboxIntegrationTest.java +++ b/src/test/java/com/fowoco/server/reliability/OutboxIntegrationTest.java @@ -176,7 +176,10 @@ void expiredLeaseIsRecoveredAfterServerRestart() { publish(event); assertThat(claimService.claimBatch("stopped-server")) - .containsExactly(event.eventId()); + .containsExactly(new OutboxClaimService.ClaimedEvent( + event.eventId(), + COMPANY_ID + )); assertThat(publicationStatus(event.eventId())).isEqualTo("PROCESSING"); jdbcTemplate.update( From 361f5597ebf345a4d9d41a7fd5e34f5dc5db8878 Mon Sep 17 00:00:00 2001 From: krestar Date: Mon, 27 Jul 2026 11:25:56 +0900 Subject: [PATCH 5/9] =?UTF-8?q?test(security):=20PostgreSQL=20RLS=20tenant?= =?UTF-8?q?=20=EA=B2=A9=EB=A6=AC=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 제한된 runtime role로 RLS를 임시 활성화해 회사별 CRUD 차단과 transaction-local tenant context 비누수를 검증합니다. --- .../security/PostgreSqlRlsIsolationTest.java | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java new file mode 100644 index 0000000..8ec7f49 --- /dev/null +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java @@ -0,0 +1,319 @@ +package com.fowoco.server.common.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Savepoint; +import java.sql.Statement; +import java.util.UUID; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +@EnabledIfEnvironmentVariable(named = "POSTGRES_TEST_ENABLED", matches = "true") +class PostgreSqlRlsIsolationTest { + + private static final UUID COMPANY_A = + UUID.fromString("a1000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = + UUID.fromString("b1000000-0000-0000-0000-000000000002"); + private static final UUID WORKER_A = + UUID.fromString("a2000000-0000-0000-0000-000000000001"); + private static final UUID WORKER_B = + UUID.fromString("b2000000-0000-0000-0000-000000000002"); + private static final UUID WORKER_A_NEW = + UUID.fromString("a2000000-0000-0000-0000-000000000003"); + private static final UUID WORKER_B_NEW = + UUID.fromString("b2000000-0000-0000-0000-000000000004"); + + @Test + void restrictedRoleEnforcesTenantCrudAndFailsClosedWithoutValidContext() + throws SQLException { + String url = requiredEnvironmentVariable("POSTGRES_TEST_URL"); + String migrationUsername = requiredEnvironmentVariable("POSTGRES_TEST_USERNAME"); + String migrationPassword = requiredEnvironmentVariable("POSTGRES_TEST_PASSWORD"); + Flyway.configure() + .dataSource(url, migrationUsername, migrationPassword) + .locations( + "classpath:db/migration", + "classpath:db/migration-postgresql" + ) + .load() + .migrate(); + + String runtimeRole = "rls_isolation_test_" + + UUID.randomUUID().toString().replace("-", "").substring(0, 12); + String runtimePassword = "Rls-isolation-" + UUID.randomUUID(); + + try (Connection migrationConnection = DriverManager.getConnection( + url, + migrationUsername, + migrationPassword + )) { + prepareFixture(migrationConnection, runtimeRole, runtimePassword); + try (Connection runtimeConnection = DriverManager.getConnection( + url, + runtimeRole, + runtimePassword + )) { + assertMissingAndInvalidContextFailClosed(runtimeConnection); + assertTenantCrudIsolation(runtimeConnection); + assertCommittedContextDoesNotLeak(runtimeConnection); + } finally { + restoreFixture(migrationConnection, runtimeRole); + } + } + } + + private void prepareFixture( + Connection connection, + String runtimeRole, + String runtimePassword + ) throws SQLException { + try (Statement statement = connection.createStatement()) { + String quotedRole = quoteIdentifier(runtimeRole); + statement.execute(""" + CREATE ROLE %s + LOGIN + PASSWORD %s + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOINHERIT + NOREPLICATION + NOBYPASSRLS + """.formatted(quotedRole, quoteLiteral(runtimePassword))); + statement.execute( + "GRANT CONNECT ON DATABASE " + + quoteIdentifier(connection.getCatalog()) + + " TO " + + quotedRole + ); + statement.execute("GRANT USAGE ON SCHEMA public TO " + quotedRole); + statement.execute( + "GRANT SELECT, INSERT, UPDATE, DELETE " + + "ON TABLE public.company, public.worker TO " + + quotedRole + ); + + deleteFixtureRows(statement); + statement.execute(""" + INSERT INTO company (company_id, name, status) + VALUES + ('%s', 'RLS Tenant A', 'ACTIVE'), + ('%s', 'RLS Tenant B', 'ACTIVE') + """.formatted(COMPANY_A, COMPANY_B)); + statement.execute(""" + INSERT INTO worker ( + worker_id, company_id, display_name, work_status + ) VALUES + ('%s', '%s', 'Worker A', 'ACTIVE'), + ('%s', '%s', 'Worker B', 'ACTIVE') + """.formatted(WORKER_A, COMPANY_A, WORKER_B, COMPANY_B)); + + statement.execute("ALTER TABLE public.company ENABLE ROW LEVEL SECURITY"); + statement.execute("ALTER TABLE public.worker ENABLE ROW LEVEL SECURITY"); + } + } + + private void assertMissingAndInvalidContextFailClosed(Connection connection) + throws SQLException { + connection.setAutoCommit(false); + try { + assertThat(workerCount(connection)).isZero(); + + setTenantContext(connection, ""); + assertThat(workerCount(connection)).isZero(); + connection.rollback(); + + setTenantContext(connection, "not-a-uuid"); + assertSqlState(connection, "22P02", "SELECT COUNT(*) FROM public.worker"); + } finally { + connection.rollback(); + } + } + + private void assertTenantCrudIsolation(Connection connection) throws SQLException { + connection.setAutoCommit(false); + try { + setTenantContext(connection, COMPANY_A.toString()); + + assertThat(workerIds(connection)).containsExactly(WORKER_A); + assertThat(executeUpdate( + connection, + """ + INSERT INTO worker ( + worker_id, company_id, display_name, work_status + ) VALUES (?, ?, 'Worker A New', 'ACTIVE') + """, + WORKER_A_NEW, + COMPANY_A + )).isOne(); + assertThat(executeUpdate( + connection, + "UPDATE worker SET display_name = 'Worker A Updated' WHERE worker_id = ?", + WORKER_A_NEW + )).isOne(); + + assertSqlState( + connection, + "42501", + """ + INSERT INTO worker ( + worker_id, company_id, display_name, work_status + ) VALUES ( + '%s', '%s', 'Forbidden Worker B', 'ACTIVE' + ) + """.formatted(WORKER_B_NEW, COMPANY_B) + ); + assertSqlState( + connection, + "42501", + """ + UPDATE worker + SET company_id = '%s' + WHERE worker_id = '%s' + """.formatted(COMPANY_B, WORKER_A_NEW) + ); + + assertThat(executeUpdate( + connection, + "UPDATE worker SET display_name = 'Hidden Update' WHERE worker_id = ?", + WORKER_B + )).isZero(); + assertThat(executeUpdate( + connection, + "DELETE FROM worker WHERE worker_id = ?", + WORKER_B + )).isZero(); + assertThat(executeUpdate( + connection, + "DELETE FROM worker WHERE worker_id = ?", + WORKER_A_NEW + )).isOne(); + } finally { + connection.rollback(); + } + } + + private void assertCommittedContextDoesNotLeak(Connection connection) throws SQLException { + connection.setAutoCommit(false); + setTenantContext(connection, COMPANY_A.toString()); + assertThat(workerIds(connection)).containsExactly(WORKER_A); + connection.commit(); + + assertThat(workerCount(connection)).isZero(); + setTenantContext(connection, COMPANY_B.toString()); + assertThat(workerIds(connection)).containsExactly(WORKER_B); + connection.rollback(); + } + + private void restoreFixture(Connection connection, String runtimeRole) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute("ALTER TABLE public.worker DISABLE ROW LEVEL SECURITY"); + statement.execute("ALTER TABLE public.company DISABLE ROW LEVEL SECURITY"); + deleteFixtureRows(statement); + String quotedRole = quoteIdentifier(runtimeRole); + statement.execute("DROP OWNED BY " + quotedRole); + statement.execute("DROP ROLE " + quotedRole); + } + } + + private void deleteFixtureRows(Statement statement) throws SQLException { + statement.execute(""" + DELETE FROM worker + WHERE worker_id IN ( + '%s', '%s', '%s', '%s' + ) + """.formatted(WORKER_A, WORKER_B, WORKER_A_NEW, WORKER_B_NEW)); + statement.execute(""" + DELETE FROM company + WHERE company_id IN ('%s', '%s') + """.formatted(COMPANY_A, COMPANY_B)); + } + + private void setTenantContext(Connection connection, String companyId) + throws SQLException { + try (PreparedStatement statement = connection.prepareStatement( + "SELECT pg_catalog.set_config('app.company_id', ?, true)" + )) { + statement.setString(1, companyId); + statement.executeQuery(); + } + } + + private int workerCount(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery( + "SELECT COUNT(*) FROM public.worker" + )) { + assertThat(resultSet.next()).isTrue(); + return resultSet.getInt(1); + } + } + + private java.util.List workerIds(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery( + "SELECT worker_id FROM public.worker ORDER BY worker_id" + )) { + java.util.List workerIds = new java.util.ArrayList<>(); + while (resultSet.next()) { + workerIds.add(resultSet.getObject(1, UUID.class)); + } + return java.util.List.copyOf(workerIds); + } + } + + private int executeUpdate( + Connection connection, + String sql, + Object... parameters + ) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.length; index++) { + statement.setObject(index + 1, parameters[index]); + } + return statement.executeUpdate(); + } + } + + private void assertSqlState(Connection connection, String expectedSqlState, String sql) + throws SQLException { + Savepoint savepoint = connection.setSavepoint(); + SQLException failure = null; + try (Statement statement = connection.createStatement()) { + statement.execute(sql); + } catch (SQLException exception) { + failure = exception; + } finally { + connection.rollback(savepoint); + connection.releaseSavepoint(savepoint); + } + + assertThat((Throwable) failure) + .as("SQL must fail with SQLSTATE %s", expectedSqlState) + .isNotNull(); + assertThat(failure.getSQLState()).isEqualTo(expectedSqlState); + } + + private static String quoteIdentifier(String value) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + + private static String quoteLiteral(String value) { + return "'" + value.replace("'", "''") + "'"; + } + + private static String requiredEnvironmentVariable(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException(name + " environment variable is required."); + } + return value; + } +} From d17c268711f7d342919766eb2b98cb7f540beb9e Mon Sep 17 00:00:00 2001 From: krestar <78223145+krestar@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:43:23 +0900 Subject: [PATCH 6/9] =?UTF-8?q?docs(db):=20RLS=20=EC=A4=80=EB=B9=84=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=AC=B8=EC=84=9C=20=ED=98=84=ED=96=89?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V8에서 bootstrap 함수와 RLS policy가 생성된 상태를 반영하고, RLS는 아직 비활성 상태임을 명확히 합니다. --- docs/database/postgresql-rls-rollout.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/database/postgresql-rls-rollout.md b/docs/database/postgresql-rls-rollout.md index 6e347fa..ae11bb3 100644 --- a/docs/database/postgresql-rls-rollout.md +++ b/docs/database/postgresql-rls-rollout.md @@ -20,7 +20,7 @@ transaction-local tenant context와 connection pool 비누수 테스트를 준 JWT로 인증된 Worker·Task·Approval·Audit 업무 transaction은 요청 값이 아니라 `ActorContext.companyId`를 transaction-local context의 신뢰 원본으로 사용합니다. H2는 PostgreSQL custom setting을 흉내 내지 않고 transaction 경계만 검증합니다. -아직 bootstrap 함수나 policy를 만들거나 RLS를 활성화하지 않습니다. +`V8`에서 bootstrap 함수와 tenant 테이블 RLS policy를 생성했으며, RLS는 아직 활성화하지 않았습니다. 로그인·Refresh Token·Logout은 tenant context가 생기기 전 최소 bootstrap 조회가 필요합니다. Issue #34 작성 뒤 추가된 사업장 회원가입도 새 tenant 행을 처음 만드는 @@ -70,8 +70,8 @@ DDL, `TRUNCATE`, `REFERENCES` 권한을 갖지 않습니다. 실제 값은 배 1. 대상 table과 tenant-aware FK·UNIQUE 제약이 `main`에 병합됐는지 확인합니다. 2. 인증된 업무 transaction이 `ActorContext.companyId`를 context로 설정하는지 검증합니다. -3. 준비 migration에서 Login·Refresh·Signup bootstrap 함수와 policy를 만들되 - RLS는 켜지 않습니다. +3. 준비 migration에서 Login·Refresh·Outbox bootstrap 함수와 tenant 테이블 policy를 생성하되, + RLS는 활성화하지 않습니다. 4. bootstrap 호환 코드를 배포합니다. 5. #9에서 분리된 runtime role, 최소 GRANT와 Secret을 적용합니다. 6. RLS 비활성 상태에서 Signup·Login·Refresh·tenant A/B·connection pool 회귀 테스트를 From a441aba953356c95bf9f7c99c366a5ba606257b4 Mon Sep 17 00:00:00 2001 From: krestar Date: Mon, 27 Jul 2026 13:47:11 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix(reliability):=20Outbox=20bootstrap=20cl?= =?UTF-8?q?aim=20=EA=B2=BD=EA=B3=84=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PostgreSQL statement timestamp를 claim과 lease의 기준 시각으로 사용 - owner, lease, batch size, max attempts를 DB 함수 경계에서 검증 - H2/local Clock 동작과 제한 role PostgreSQL 회귀 테스트를 유지·보강 --- .../application/OutboxClaimService.java | 9 +- .../port/OutboxClaimBootstrap.java | 2 - .../reliability/config/OutboxProperties.java | 8 +- .../persistence/JpaOutboxClaimBootstrap.java | 10 ++- .../PostgreSqlOutboxClaimBootstrap.java | 12 +-- .../V8__prepare_postgresql_rls.sql | 57 +++++++++---- .../PostgreSqlTenantDatabaseContextTest.java | 84 ++++++++++++++++++- .../config/OutboxPropertiesTest.java | 24 ++++++ 8 files changed, 166 insertions(+), 40 deletions(-) create mode 100644 src/test/java/com/fowoco/server/reliability/config/OutboxPropertiesTest.java diff --git a/src/main/java/com/fowoco/server/reliability/application/OutboxClaimService.java b/src/main/java/com/fowoco/server/reliability/application/OutboxClaimService.java index f5e328b..1c953fb 100644 --- a/src/main/java/com/fowoco/server/reliability/application/OutboxClaimService.java +++ b/src/main/java/com/fowoco/server/reliability/application/OutboxClaimService.java @@ -3,8 +3,6 @@ import com.fowoco.server.reliability.application.port.OutboxClaimBootstrap; import com.fowoco.server.reliability.application.port.OutboxClaimBootstrap.ClaimResult; import com.fowoco.server.reliability.config.OutboxProperties; -import java.time.Clock; -import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -17,26 +15,21 @@ public class OutboxClaimService { private final OutboxClaimBootstrap claimBootstrap; private final OutboxProperties properties; private final OutboxMetrics metrics; - private final Clock clock; public OutboxClaimService( OutboxClaimBootstrap claimBootstrap, OutboxProperties properties, - OutboxMetrics metrics, - Clock clock + OutboxMetrics metrics ) { this.claimBootstrap = claimBootstrap; this.properties = properties; this.metrics = metrics; - this.clock = clock; } @Transactional public List claimBatch(String owner) { - Instant now = clock.instant(); List results = claimBootstrap.claim( owner, - now, properties.getLeaseDuration(), properties.getBatchSize(), properties.getMaxAttempts() diff --git a/src/main/java/com/fowoco/server/reliability/application/port/OutboxClaimBootstrap.java b/src/main/java/com/fowoco/server/reliability/application/port/OutboxClaimBootstrap.java index f76d28f..c78ffcf 100644 --- a/src/main/java/com/fowoco/server/reliability/application/port/OutboxClaimBootstrap.java +++ b/src/main/java/com/fowoco/server/reliability/application/port/OutboxClaimBootstrap.java @@ -1,7 +1,6 @@ package com.fowoco.server.reliability.application.port; import java.time.Duration; -import java.time.Instant; import java.util.List; import java.util.UUID; @@ -12,7 +11,6 @@ public interface OutboxClaimBootstrap { List claim( String owner, - Instant now, Duration leaseDuration, int batchSize, int maxAttempts diff --git a/src/main/java/com/fowoco/server/reliability/config/OutboxProperties.java b/src/main/java/com/fowoco/server/reliability/config/OutboxProperties.java index 313fd9a..d38dd3c 100644 --- a/src/main/java/com/fowoco/server/reliability/config/OutboxProperties.java +++ b/src/main/java/com/fowoco/server/reliability/config/OutboxProperties.java @@ -6,6 +6,8 @@ @ConfigurationProperties(prefix = "app.reliability.outbox") public class OutboxProperties { + private static final Duration MAX_LEASE_DURATION = Duration.ofDays(1); + private boolean enabled = true; private Duration pollInterval = Duration.ofSeconds(1); private int batchSize = 20; @@ -46,7 +48,11 @@ public Duration getLeaseDuration() { } public void setLeaseDuration(Duration leaseDuration) { - this.leaseDuration = requirePositive(leaseDuration, "leaseDuration"); + Duration validated = requirePositive(leaseDuration, "leaseDuration"); + if (validated.compareTo(MAX_LEASE_DURATION) > 0) { + throw new IllegalArgumentException("leaseDuration must not exceed 1 day"); + } + this.leaseDuration = validated; } public int getMaxAttempts() { diff --git a/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaOutboxClaimBootstrap.java b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaOutboxClaimBootstrap.java index ece02aa..43eff1c 100644 --- a/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaOutboxClaimBootstrap.java +++ b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaOutboxClaimBootstrap.java @@ -3,6 +3,7 @@ import com.fowoco.server.reliability.application.port.EventPublicationRepository; import com.fowoco.server.reliability.application.port.OutboxClaimBootstrap; import com.fowoco.server.reliability.domain.EventPublication; +import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; @@ -24,19 +25,24 @@ public class JpaOutboxClaimBootstrap implements OutboxClaimBootstrap { private static final String ATTEMPTS_EXHAUSTED = "EVENT_ATTEMPTS_EXHAUSTED"; private final EventPublicationRepository repository; + private final Clock clock; - public JpaOutboxClaimBootstrap(EventPublicationRepository repository) { + public JpaOutboxClaimBootstrap( + EventPublicationRepository repository, + Clock clock + ) { this.repository = repository; + this.clock = clock; } @Override public List claim( String owner, - Instant now, Duration leaseDuration, int batchSize, int maxAttempts ) { + Instant now = clock.instant(); List candidates = repository.lockClaimable(now, batchSize); List results = new ArrayList<>(candidates.size()); for (EventPublication publication : candidates) { diff --git a/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/PostgreSqlOutboxClaimBootstrap.java b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/PostgreSqlOutboxClaimBootstrap.java index 1c8abd2..a87b4b9 100644 --- a/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/PostgreSqlOutboxClaimBootstrap.java +++ b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/PostgreSqlOutboxClaimBootstrap.java @@ -3,7 +3,6 @@ import com.fowoco.server.reliability.application.port.OutboxClaimBootstrap; import jakarta.persistence.EntityManager; import java.time.Duration; -import java.time.Instant; import java.util.List; import java.util.UUID; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -21,7 +20,7 @@ public class PostgreSqlOutboxClaimBootstrap implements OutboxClaimBootstrap { private static final String CLAIM_SQL = """ SELECT event_id, company_id, review_required - FROM public.bootstrap_claim_event_publications(?1, ?2, ?3, ?4, ?5) + FROM public.bootstrap_claim_event_publications(?1, ?2, ?3, ?4) """; private final EntityManager entityManager; @@ -33,19 +32,16 @@ public PostgreSqlOutboxClaimBootstrap(EntityManager entityManager) { @Override public List claim( String owner, - Instant now, Duration leaseDuration, int batchSize, int maxAttempts ) { - Instant leaseExpiresAt = now.plus(leaseDuration); @SuppressWarnings("unchecked") List rows = entityManager.createNativeQuery(CLAIM_SQL) .setParameter(1, owner) - .setParameter(2, now) - .setParameter(3, leaseExpiresAt) - .setParameter(4, batchSize) - .setParameter(5, maxAttempts) + .setParameter(2, leaseDuration.toMillis()) + .setParameter(3, batchSize) + .setParameter(4, maxAttempts) .getResultList(); return rows.stream() .map(row -> new ClaimResult( diff --git a/src/main/resources/db/migration-postgresql/V8__prepare_postgresql_rls.sql b/src/main/resources/db/migration-postgresql/V8__prepare_postgresql_rls.sql index 6479a46..3dc8985 100644 --- a/src/main/resources/db/migration-postgresql/V8__prepare_postgresql_rls.sql +++ b/src/main/resources/db/migration-postgresql/V8__prepare_postgresql_rls.sql @@ -30,8 +30,7 @@ $$; CREATE FUNCTION public.bootstrap_claim_event_publications( p_owner TEXT, - p_now TIMESTAMPTZ, - p_lease_expires_at TIMESTAMPTZ, + p_lease_duration_millis BIGINT, p_batch_size INTEGER, p_max_attempts INTEGER ) @@ -40,28 +39,51 @@ RETURNS TABLE ( company_id UUID, review_required BOOLEAN ) -LANGUAGE SQL +LANGUAGE PLPGSQL VOLATILE SECURITY DEFINER SET search_path = pg_catalog, public, pg_temp AS $$ +DECLARE + v_owner TEXT; + v_now TIMESTAMPTZ; + v_lease_expires_at TIMESTAMPTZ; +BEGIN + v_owner := BTRIM(p_owner); + IF v_owner IS NULL OR CHAR_LENGTH(v_owner) NOT BETWEEN 1 AND 128 THEN + RAISE EXCEPTION 'Outbox claim owner must be 1 to 128 characters.' + USING ERRCODE = '22023'; + END IF; + IF p_lease_duration_millis IS NULL + OR p_lease_duration_millis NOT BETWEEN 1 AND 86400000 THEN + RAISE EXCEPTION 'Outbox claim lease duration must be between 1 millisecond and 1 day.' + USING ERRCODE = '22023'; + END IF; + IF p_batch_size IS NULL OR p_batch_size NOT BETWEEN 1 AND 500 THEN + RAISE EXCEPTION 'Outbox claim batch size must be between 1 and 500.' + USING ERRCODE = '22023'; + END IF; + IF p_max_attempts IS NULL OR p_max_attempts NOT BETWEEN 1 AND 100 THEN + RAISE EXCEPTION 'Outbox claim max attempts must be between 1 and 100.' + USING ERRCODE = '22023'; + END IF; + + v_now := statement_timestamp(); + v_lease_expires_at := + v_now + p_lease_duration_millis * INTERVAL '1 millisecond'; + + RETURN QUERY WITH candidates AS ( SELECT publication.event_id FROM public.event_publication AS publication - WHERE p_owner IS NOT NULL - AND CHAR_LENGTH(BTRIM(p_owner)) > 0 - AND p_now IS NOT NULL - AND p_lease_expires_at > p_now - AND p_batch_size > 0 - AND p_max_attempts >= 0 - AND ( + WHERE ( ( publication.status IN ('PENDING', 'RETRY_WAIT') - AND publication.next_attempt_at <= p_now + AND publication.next_attempt_at <= v_now ) OR ( publication.status = 'PROCESSING' - AND publication.lease_expires_at <= p_now + AND publication.lease_expires_at <= v_now ) ) ORDER BY publication.occurred_at, publication.event_id @@ -80,19 +102,19 @@ AS $$ lease_owner = CASE WHEN publication.attempt_count + 1 > p_max_attempts THEN NULL - ELSE p_owner + ELSE v_owner END, lease_expires_at = CASE WHEN publication.attempt_count + 1 > p_max_attempts THEN NULL - ELSE p_lease_expires_at + ELSE v_lease_expires_at END, last_error_code = CASE WHEN publication.attempt_count + 1 > p_max_attempts THEN 'EVENT_ATTEMPTS_EXHAUSTED' ELSE NULL END, - updated_at = GREATEST(publication.updated_at, p_now), + updated_at = GREATEST(publication.updated_at, v_now), version = publication.version + 1 FROM candidates WHERE publication.event_id = candidates.event_id @@ -106,6 +128,8 @@ AS $$ claimed.company_id, claimed.review_required FROM claimed + ; +END; $$; CREATE FUNCTION public.bootstrap_count_outstanding_event_publications() @@ -151,8 +175,7 @@ REVOKE ALL REVOKE ALL ON FUNCTION public.bootstrap_claim_event_publications( TEXT, - TIMESTAMPTZ, - TIMESTAMPTZ, + BIGINT, INTEGER, INTEGER ) diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java index bb8192b..b2c9e40 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java @@ -14,6 +14,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.time.OffsetDateTime; import java.util.LinkedHashMap; import java.util.Map; import java.util.UUID; @@ -256,7 +257,7 @@ SELECT COUNT(*) )).isFalse(); assertThat(hasFunctionPrivilege( "bootstrap_claim_event_publications(" - + "text,timestamp with time zone,timestamp with time zone,integer,integer" + + "text,bigint,integer,integer" + ")", "EXECUTE" )).isFalse(); @@ -352,7 +353,7 @@ void springJpaTransactionUsesTheRestrictedRuntimeConnection() { @Test void outboxClaimReturnsOnlyTenantCoordinatesAndScopedReadsRejectMismatches() { String function = "public.bootstrap_claim_event_publications(" - + "text,timestamptz,timestamptz,integer,integer" + + "text,bigint,integer,integer" + ")"; String countFunction = "public.bootstrap_count_outstanding_event_publications()"; @@ -369,9 +370,14 @@ void outboxClaimReturnsOnlyTenantCoordinatesAndScopedReadsRejectMismatches() { try { insertOutboxProbe(COMPANY_A, OUTBOX_EVENT_A, "tenant-a-payload"); insertOutboxProbe(COMPANY_B, OUTBOX_EVENT_B, "tenant-b-payload"); + assertInvalidOutboxClaimInputsDoNotModifyPublications(); OutboxClaimService claimService = applicationContext.getBean(OutboxClaimService.class); + OffsetDateTime databaseTimeBeforeClaim = runtimeJdbc.queryForObject( + "SELECT pg_catalog.statement_timestamp()", + OffsetDateTime.class + ); assertThat(claimService.claimBatch("rls-outbox-test")) .containsExactlyInAnyOrder( new OutboxClaimService.ClaimedEvent( @@ -383,6 +389,15 @@ void outboxClaimReturnsOnlyTenantCoordinatesAndScopedReadsRejectMismatches() { COMPANY_B ) ); + OffsetDateTime databaseTimeAfterClaim = runtimeJdbc.queryForObject( + "SELECT pg_catalog.statement_timestamp()", + OffsetDateTime.class + ); + assertClaimLeaseUsesDatabaseClock( + OUTBOX_EVENT_A, + databaseTimeBeforeClaim, + databaseTimeAfterClaim + ); OutboxReadService readService = applicationContext.getBean(OutboxReadService.class); @@ -419,6 +434,71 @@ void outboxClaimReturnsOnlyTenantCoordinatesAndScopedReadsRejectMismatches() { } } + private void assertInvalidOutboxClaimInputsDoNotModifyPublications() { + Object[][] invalidArguments = { + {null, 30_000L, 20, 8}, + {" ", 30_000L, 20, 8}, + {"rls-outbox-test", 0L, 20, 8}, + {"rls-outbox-test", 86_400_001L, 20, 8}, + {"rls-outbox-test", 30_000L, 501, 8}, + {"rls-outbox-test", 30_000L, 20, 0}, + {"rls-outbox-test", 30_000L, 20, 101} + }; + + for (Object[] arguments : invalidArguments) { + assertThatThrownBy(() -> runtimeJdbc.queryForList( + """ + SELECT * + FROM public.bootstrap_claim_event_publications( + CAST(? AS TEXT), + CAST(? AS BIGINT), + CAST(? AS INTEGER), + CAST(? AS INTEGER) + ) + """, + arguments + )).isInstanceOf(DataAccessException.class); + assertOutboxProbeRemainsUnclaimed(OUTBOX_EVENT_A); + assertOutboxProbeRemainsUnclaimed(OUTBOX_EVENT_B); + } + } + + private void assertOutboxProbeRemainsUnclaimed(UUID eventId) { + Map state = migrationJdbc.queryForMap( + """ + SELECT status, attempt_count, lease_owner, lease_expires_at + FROM event_publication + WHERE event_id = ? + """, + eventId + ); + assertThat(state.get("status")).isEqualTo("PENDING"); + assertThat(state.get("attempt_count")).isEqualTo(0); + assertThat(state.get("lease_owner")).isNull(); + assertThat(state.get("lease_expires_at")).isNull(); + } + + private void assertClaimLeaseUsesDatabaseClock( + UUID eventId, + OffsetDateTime databaseTimeBeforeClaim, + OffsetDateTime databaseTimeAfterClaim + ) { + OffsetDateTime leaseExpiresAt = migrationJdbc.queryForObject( + """ + SELECT lease_expires_at + FROM event_publication + WHERE event_id = ? + """, + OffsetDateTime.class, + eventId + ); + assertThat(leaseExpiresAt).isNotNull(); + assertThat(leaseExpiresAt.toInstant()).isBetween( + databaseTimeBeforeClaim.toInstant().plusSeconds(30), + databaseTimeAfterClaim.toInstant().plusSeconds(30) + ); + } + private ConfigurableApplicationContext startRestrictedRuntimeApplication() { Map properties = new LinkedHashMap<>(); properties.put("spring.datasource.url", migrationUrl); diff --git a/src/test/java/com/fowoco/server/reliability/config/OutboxPropertiesTest.java b/src/test/java/com/fowoco/server/reliability/config/OutboxPropertiesTest.java new file mode 100644 index 0000000..912b7cd --- /dev/null +++ b/src/test/java/com/fowoco/server/reliability/config/OutboxPropertiesTest.java @@ -0,0 +1,24 @@ +package com.fowoco.server.reliability.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class OutboxPropertiesTest { + + @Test + void leaseDurationCannotExceedDatabaseFunctionBoundary() { + OutboxProperties properties = new OutboxProperties(); + + properties.setLeaseDuration(Duration.ofDays(1)); + + assertThat(properties.getLeaseDuration()).isEqualTo(Duration.ofDays(1)); + assertThatThrownBy( + () -> properties.setLeaseDuration(Duration.ofDays(1).plusMillis(1)) + ) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1 day"); + } +} From 45aa0cdd553e58b13d62e798a47f4eda40f3241d Mon Sep 17 00:00:00 2001 From: krestar Date: Mon, 27 Jul 2026 14:23:05 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix(reliability):=20Outbox=20lifecycle?= =?UTF-8?q?=EC=9D=84=20DB=20clock=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PostgreSQL Outbox 시간 소스로 statement_timestamp()를 사용 - claim, handler, completion, failure의 lease 판단 기준을 DB 시각으로 통일 - 행 잠금 획득 후 현재 시각을 조회해 대기 시간에 따른 시각 오차를 방지 - 로컬 및 H2 환경에서는 기존 Clock 기반 시간 소스를 유지 - lease duration을 1ms 이상 1일 이하로 제한 - 공백 문자로만 구성되거나 128자를 초과하는 claim owner를 거부 - 잘못된 claim 인자의 SQLSTATE 22023을 검증 - skewed JVM Clock 환경에서도 Outbox lifecycle이 정상 동작하는 회귀 테스트를 추가 --- .../OutboxCompletionTransaction.java | 10 +- .../application/OutboxFailureTransaction.java | 10 +- .../application/OutboxHandlerTransaction.java | 10 +- .../application/port/OutboxTimeSource.java | 8 + .../reliability/config/OutboxProperties.java | 12 +- .../persistence/ClockOutboxTimeSource.java | 27 +++ .../persistence/JpaOutboxClaimBootstrap.java | 10 +- .../PostgreSqlOutboxTimeSource.java | 36 ++++ .../V8__prepare_postgresql_rls.sql | 7 +- .../PostgreSqlTenantDatabaseContextTest.java | 174 +++++++++++++++++- .../config/OutboxPropertiesTest.java | 6 + 11 files changed, 277 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/fowoco/server/reliability/application/port/OutboxTimeSource.java create mode 100644 src/main/java/com/fowoco/server/reliability/infrastructure/persistence/ClockOutboxTimeSource.java create mode 100644 src/main/java/com/fowoco/server/reliability/infrastructure/persistence/PostgreSqlOutboxTimeSource.java diff --git a/src/main/java/com/fowoco/server/reliability/application/OutboxCompletionTransaction.java b/src/main/java/com/fowoco/server/reliability/application/OutboxCompletionTransaction.java index dba5962..3568033 100644 --- a/src/main/java/com/fowoco/server/reliability/application/OutboxCompletionTransaction.java +++ b/src/main/java/com/fowoco/server/reliability/application/OutboxCompletionTransaction.java @@ -2,8 +2,8 @@ import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.reliability.application.port.EventPublicationRepository; +import com.fowoco.server.reliability.application.port.OutboxTimeSource; import com.fowoco.server.reliability.domain.EventPublication; -import java.time.Clock; import java.time.Instant; import java.util.UUID; import org.springframework.stereotype.Service; @@ -16,27 +16,27 @@ public class OutboxCompletionTransaction { private final EventPublicationRepository repository; private final TenantDatabaseContext tenantDatabaseContext; private final OutboxMetrics metrics; - private final Clock clock; + private final OutboxTimeSource timeSource; public OutboxCompletionTransaction( EventPublicationRepository repository, TenantDatabaseContext tenantDatabaseContext, OutboxMetrics metrics, - Clock clock + OutboxTimeSource timeSource ) { this.repository = repository; this.tenantDatabaseContext = tenantDatabaseContext; this.metrics = metrics; - this.clock = clock; + this.timeSource = timeSource; } @Transactional(propagation = Propagation.REQUIRES_NEW) public void complete(UUID eventId, UUID companyId, String owner) { tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); - Instant now = clock.instant(); EventPublication publication = repository .findByIdAndCompanyIdForUpdate(eventId, companyId) .orElseThrow(() -> new IllegalStateException("Event publication not found.")); + Instant now = timeSource.now(); publication.complete(owner, now); repository.save(publication); metrics.recordCompleted(); diff --git a/src/main/java/com/fowoco/server/reliability/application/OutboxFailureTransaction.java b/src/main/java/com/fowoco/server/reliability/application/OutboxFailureTransaction.java index 9d37afd..0113a96 100644 --- a/src/main/java/com/fowoco/server/reliability/application/OutboxFailureTransaction.java +++ b/src/main/java/com/fowoco/server/reliability/application/OutboxFailureTransaction.java @@ -3,9 +3,9 @@ import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.reliability.application.OutboxFailureClassifier.FailureClassification; import com.fowoco.server.reliability.application.port.EventPublicationRepository; +import com.fowoco.server.reliability.application.port.OutboxTimeSource; import com.fowoco.server.reliability.config.OutboxProperties; import com.fowoco.server.reliability.domain.EventPublication; -import java.time.Clock; import java.time.Instant; import java.util.UUID; import org.springframework.stereotype.Service; @@ -21,7 +21,7 @@ public class OutboxFailureTransaction { private final OutboxBackoffPolicy backoffPolicy; private final OutboxProperties properties; private final OutboxMetrics metrics; - private final Clock clock; + private final OutboxTimeSource timeSource; public OutboxFailureTransaction( EventPublicationRepository repository, @@ -30,7 +30,7 @@ public OutboxFailureTransaction( OutboxBackoffPolicy backoffPolicy, OutboxProperties properties, OutboxMetrics metrics, - Clock clock + OutboxTimeSource timeSource ) { this.repository = repository; this.tenantDatabaseContext = tenantDatabaseContext; @@ -38,7 +38,7 @@ public OutboxFailureTransaction( this.backoffPolicy = backoffPolicy; this.properties = properties; this.metrics = metrics; - this.clock = clock; + this.timeSource = timeSource; } @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -49,10 +49,10 @@ public FailureOutcome recordFailure( Throwable failure ) { tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); - Instant now = clock.instant(); EventPublication publication = repository .findByIdAndCompanyIdForUpdate(eventId, companyId) .orElseThrow(() -> new IllegalStateException("Event publication not found.")); + Instant now = timeSource.now(); FailureClassification classification = classifier.classify(failure); boolean exhausted = publication.attemptCount() >= properties.getMaxAttempts(); if (!classification.retryable() || exhausted) { diff --git a/src/main/java/com/fowoco/server/reliability/application/OutboxHandlerTransaction.java b/src/main/java/com/fowoco/server/reliability/application/OutboxHandlerTransaction.java index b7c058d..5ba776a 100644 --- a/src/main/java/com/fowoco/server/reliability/application/OutboxHandlerTransaction.java +++ b/src/main/java/com/fowoco/server/reliability/application/OutboxHandlerTransaction.java @@ -5,11 +5,11 @@ import com.fowoco.server.reliability.application.port.DomainEventHandler; import com.fowoco.server.reliability.application.port.EventConsumptionRepository; import com.fowoco.server.reliability.application.port.EventPublicationRepository; +import com.fowoco.server.reliability.application.port.OutboxTimeSource; import com.fowoco.server.reliability.domain.DomainEventEnvelope; import com.fowoco.server.reliability.domain.EventConsumption; import com.fowoco.server.reliability.domain.EventPublication; import com.fowoco.server.reliability.infrastructure.serialization.EventPayloadCodec; -import java.time.Clock; import java.time.Instant; import java.util.UUID; import org.springframework.stereotype.Service; @@ -24,7 +24,7 @@ public class OutboxHandlerTransaction { private final TenantDatabaseContext tenantDatabaseContext; private final EventPayloadCodec payloadCodec; private final UuidGenerator uuidGenerator; - private final Clock clock; + private final OutboxTimeSource timeSource; public OutboxHandlerTransaction( EventPublicationRepository publicationRepository, @@ -32,14 +32,14 @@ public OutboxHandlerTransaction( TenantDatabaseContext tenantDatabaseContext, EventPayloadCodec payloadCodec, UuidGenerator uuidGenerator, - Clock clock + OutboxTimeSource timeSource ) { this.publicationRepository = publicationRepository; this.consumptionRepository = consumptionRepository; this.tenantDatabaseContext = tenantDatabaseContext; this.payloadCodec = payloadCodec; this.uuidGenerator = uuidGenerator; - this.clock = clock; + this.timeSource = timeSource; } @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -50,10 +50,10 @@ public boolean deliver( DomainEventHandler handler ) { tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); - Instant now = clock.instant(); EventPublication publication = publicationRepository .findByIdAndCompanyIdForUpdate(eventId, companyId) .orElseThrow(() -> new IllegalStateException("Event publication not found.")); + Instant now = timeSource.now(); publication.requireActiveLease(owner, now); String handlerName = handler.handlerName(); if (consumptionRepository.existsByEventIdAndHandlerName(eventId, handlerName)) { diff --git a/src/main/java/com/fowoco/server/reliability/application/port/OutboxTimeSource.java b/src/main/java/com/fowoco/server/reliability/application/port/OutboxTimeSource.java new file mode 100644 index 0000000..6076f37 --- /dev/null +++ b/src/main/java/com/fowoco/server/reliability/application/port/OutboxTimeSource.java @@ -0,0 +1,8 @@ +package com.fowoco.server.reliability.application.port; + +import java.time.Instant; + +public interface OutboxTimeSource { + + Instant now(); +} diff --git a/src/main/java/com/fowoco/server/reliability/config/OutboxProperties.java b/src/main/java/com/fowoco/server/reliability/config/OutboxProperties.java index d38dd3c..4b5daf7 100644 --- a/src/main/java/com/fowoco/server/reliability/config/OutboxProperties.java +++ b/src/main/java/com/fowoco/server/reliability/config/OutboxProperties.java @@ -6,6 +6,7 @@ @ConfigurationProperties(prefix = "app.reliability.outbox") public class OutboxProperties { + private static final Duration MIN_LEASE_DURATION = Duration.ofMillis(1); private static final Duration MAX_LEASE_DURATION = Duration.ofDays(1); private boolean enabled = true; @@ -48,11 +49,14 @@ public Duration getLeaseDuration() { } public void setLeaseDuration(Duration leaseDuration) { - Duration validated = requirePositive(leaseDuration, "leaseDuration"); - if (validated.compareTo(MAX_LEASE_DURATION) > 0) { - throw new IllegalArgumentException("leaseDuration must not exceed 1 day"); + if (leaseDuration == null + || leaseDuration.compareTo(MIN_LEASE_DURATION) < 0 + || leaseDuration.compareTo(MAX_LEASE_DURATION) > 0) { + throw new IllegalArgumentException( + "leaseDuration must be between 1 millisecond and 1 day" + ); } - this.leaseDuration = validated; + this.leaseDuration = leaseDuration; } public int getMaxAttempts() { diff --git a/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/ClockOutboxTimeSource.java b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/ClockOutboxTimeSource.java new file mode 100644 index 0000000..bf2e107 --- /dev/null +++ b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/ClockOutboxTimeSource.java @@ -0,0 +1,27 @@ +package com.fowoco.server.reliability.infrastructure.persistence; + +import com.fowoco.server.reliability.application.port.OutboxTimeSource; +import java.time.Clock; +import java.time.Instant; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty( + name = "app.database.tenant-context-mode", + havingValue = "transaction-only", + matchIfMissing = true +) +public class ClockOutboxTimeSource implements OutboxTimeSource { + + private final Clock clock; + + public ClockOutboxTimeSource(Clock clock) { + this.clock = clock; + } + + @Override + public Instant now() { + return clock.instant(); + } +} diff --git a/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaOutboxClaimBootstrap.java b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaOutboxClaimBootstrap.java index 43eff1c..3038244 100644 --- a/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaOutboxClaimBootstrap.java +++ b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaOutboxClaimBootstrap.java @@ -2,8 +2,8 @@ import com.fowoco.server.reliability.application.port.EventPublicationRepository; import com.fowoco.server.reliability.application.port.OutboxClaimBootstrap; +import com.fowoco.server.reliability.application.port.OutboxTimeSource; import com.fowoco.server.reliability.domain.EventPublication; -import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; @@ -25,14 +25,14 @@ public class JpaOutboxClaimBootstrap implements OutboxClaimBootstrap { private static final String ATTEMPTS_EXHAUSTED = "EVENT_ATTEMPTS_EXHAUSTED"; private final EventPublicationRepository repository; - private final Clock clock; + private final OutboxTimeSource timeSource; public JpaOutboxClaimBootstrap( EventPublicationRepository repository, - Clock clock + OutboxTimeSource timeSource ) { this.repository = repository; - this.clock = clock; + this.timeSource = timeSource; } @Override @@ -42,7 +42,7 @@ public List claim( int batchSize, int maxAttempts ) { - Instant now = clock.instant(); + Instant now = timeSource.now(); List candidates = repository.lockClaimable(now, batchSize); List results = new ArrayList<>(candidates.size()); for (EventPublication publication : candidates) { diff --git a/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/PostgreSqlOutboxTimeSource.java b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/PostgreSqlOutboxTimeSource.java new file mode 100644 index 0000000..3b52bde --- /dev/null +++ b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/PostgreSqlOutboxTimeSource.java @@ -0,0 +1,36 @@ +package com.fowoco.server.reliability.infrastructure.persistence; + +import com.fowoco.server.reliability.application.port.OutboxTimeSource; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import java.time.OffsetDateTime; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty( + name = "app.database.tenant-context-mode", + havingValue = "postgresql" +) +public class PostgreSqlOutboxTimeSource implements OutboxTimeSource { + + private final EntityManager entityManager; + + public PostgreSqlOutboxTimeSource(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public Instant now() { + Object result = entityManager.createNativeQuery( + "SELECT pg_catalog.statement_timestamp()" + ).getSingleResult(); + if (result instanceof Instant instant) { + return instant; + } + if (result instanceof OffsetDateTime offsetDateTime) { + return offsetDateTime.toInstant(); + } + throw new IllegalStateException("Unexpected PostgreSQL timestamp mapping."); + } +} diff --git a/src/main/resources/db/migration-postgresql/V8__prepare_postgresql_rls.sql b/src/main/resources/db/migration-postgresql/V8__prepare_postgresql_rls.sql index 3dc8985..eae6b84 100644 --- a/src/main/resources/db/migration-postgresql/V8__prepare_postgresql_rls.sql +++ b/src/main/resources/db/migration-postgresql/V8__prepare_postgresql_rls.sql @@ -49,7 +49,12 @@ DECLARE v_now TIMESTAMPTZ; v_lease_expires_at TIMESTAMPTZ; BEGIN - v_owner := BTRIM(p_owner); + v_owner := REGEXP_REPLACE( + p_owner, + '^[[:space:]]+|[[:space:]]+$', + '', + 'g' + ); IF v_owner IS NULL OR CHAR_LENGTH(v_owner) NOT BETWEEN 1 AND 128 THEN RAISE EXCEPTION 'Outbox claim owner must be 1 to 128 characters.' USING ERRCODE = '22023'; diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java index b2c9e40..abbd035 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java @@ -1,12 +1,19 @@ package com.fowoco.server.common.security; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.fowoco.server.ServerApplication; import com.fowoco.server.reliability.application.OutboxClaimService; +import com.fowoco.server.reliability.application.OutboxCompletionTransaction; +import com.fowoco.server.reliability.application.OutboxFailureTransaction; +import com.fowoco.server.reliability.application.OutboxHandlerTransaction; import com.fowoco.server.reliability.application.OutboxReadService; +import com.fowoco.server.reliability.application.RetryableEventHandlingException; +import com.fowoco.server.reliability.application.port.DomainEventHandler; import com.fowoco.server.reliability.application.port.OutboxBacklogReader; +import com.fowoco.server.reliability.domain.DomainEventEnvelope; import com.fowoco.server.reliability.domain.EventPublication; import jakarta.persistence.EntityManager; import java.sql.Connection; @@ -14,7 +21,10 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.time.Clock; +import java.time.Instant; import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.LinkedHashMap; import java.util.Map; import java.util.UUID; @@ -28,6 +38,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.WebApplicationType; import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.core.env.MapPropertySource; import org.springframework.core.env.StandardEnvironment; import org.springframework.dao.DataAccessException; @@ -351,7 +364,7 @@ void springJpaTransactionUsesTheRestrictedRuntimeConnection() { } @Test - void outboxClaimReturnsOnlyTenantCoordinatesAndScopedReadsRejectMismatches() { + void outboxLifecycleUsesDatabaseClockAndScopedReadsRejectMismatches() { String function = "public.bootstrap_claim_event_publications(" + "text,bigint,integer,integer" + ")"; @@ -368,8 +381,8 @@ void outboxClaimReturnsOnlyTenantCoordinatesAndScopedReadsRejectMismatches() { "GRANT EXECUTE ON FUNCTION " + oldestFunction + " TO " + quotedRole ); try { - insertOutboxProbe(COMPANY_A, OUTBOX_EVENT_A, "tenant-a-payload"); - insertOutboxProbe(COMPANY_B, OUTBOX_EVENT_B, "tenant-b-payload"); + insertOutboxProbe(COMPANY_A, OUTBOX_EVENT_A, "{}"); + insertOutboxProbe(COMPANY_B, OUTBOX_EVENT_B, "{}"); assertInvalidOutboxClaimInputsDoNotModifyPublications(); OutboxClaimService claimService = @@ -409,11 +422,13 @@ void outboxClaimReturnsOnlyTenantCoordinatesAndScopedReadsRejectMismatches() { EventPublication publication = readService.requirePublication(OUTBOX_EVENT_A, COMPANY_A); - assertThat(publication.payloadJson()).isEqualTo("tenant-a-payload"); + assertThat(publication.payloadJson()).isEqualTo("{}"); + + assertOutboxLifecycleIgnoresSkewedApplicationClock(); OutboxBacklogReader backlogReader = applicationContext.getBean(OutboxBacklogReader.class); - assertThat(backlogReader.countOutstanding()).isEqualTo(2); + assertThat(backlogReader.countOutstanding()).isEqualTo(1); assertThat(backlogReader.findOldestOutstandingOccurredAt()).isPresent(); } finally { migrationJdbc.execute("DELETE FROM event_consumption WHERE event_id IN ('" @@ -438,15 +453,20 @@ private void assertInvalidOutboxClaimInputsDoNotModifyPublications() { Object[][] invalidArguments = { {null, 30_000L, 20, 8}, {" ", 30_000L, 20, 8}, + {"\t\r\n", 30_000L, 20, 8}, + {"a".repeat(129), 30_000L, 20, 8}, + {"rls-outbox-test", null, 20, 8}, {"rls-outbox-test", 0L, 20, 8}, {"rls-outbox-test", 86_400_001L, 20, 8}, + {"rls-outbox-test", 30_000L, null, 8}, {"rls-outbox-test", 30_000L, 501, 8}, + {"rls-outbox-test", 30_000L, 20, null}, {"rls-outbox-test", 30_000L, 20, 0}, {"rls-outbox-test", 30_000L, 20, 101} }; for (Object[] arguments : invalidArguments) { - assertThatThrownBy(() -> runtimeJdbc.queryForList( + Throwable thrown = catchThrowable(() -> runtimeJdbc.queryForList( """ SELECT * FROM public.bootstrap_claim_event_publications( @@ -457,12 +477,116 @@ private void assertInvalidOutboxClaimInputsDoNotModifyPublications() { ) """, arguments - )).isInstanceOf(DataAccessException.class); + )); + assertThat(thrown).isInstanceOf(DataAccessException.class); + Throwable rootCause = + ((DataAccessException) thrown).getMostSpecificCause(); + assertThat(rootCause).isInstanceOf(SQLException.class); + assertThat(((SQLException) rootCause).getSQLState()).isEqualTo("22023"); assertOutboxProbeRemainsUnclaimed(OUTBOX_EVENT_A); assertOutboxProbeRemainsUnclaimed(OUTBOX_EVENT_B); } } + private void assertOutboxLifecycleIgnoresSkewedApplicationClock() { + String owner = "rls-outbox-test"; + OffsetDateTime beforeLifecycle = runtimeJdbc.queryForObject( + "SELECT pg_catalog.statement_timestamp()", + OffsetDateTime.class + ); + + OutboxHandlerTransaction handlerTransaction = + applicationContext.getBean(OutboxHandlerTransaction.class); + assertThat(handlerTransaction.deliver( + OUTBOX_EVENT_A, + COMPANY_A, + owner, + new NoOpProbeHandler() + )).isTrue(); + applicationContext.getBean(OutboxCompletionTransaction.class) + .complete(OUTBOX_EVENT_A, COMPANY_A, owner); + + OutboxFailureTransaction.FailureOutcome failureOutcome = + applicationContext.getBean(OutboxFailureTransaction.class) + .recordFailure( + OUTBOX_EVENT_B, + COMPANY_B, + owner, + new RetryableEventHandlingException( + "RLS_OUTBOX_PROBE_RETRY" + ) + ); + assertThat(failureOutcome.retryScheduled()).isTrue(); + + OffsetDateTime afterLifecycle = runtimeJdbc.queryForObject( + "SELECT pg_catalog.statement_timestamp()", + OffsetDateTime.class + ); + String completedStatus = migrationJdbc.queryForObject( + """ + SELECT status + FROM event_publication + WHERE event_id = ? + """, + String.class, + OUTBOX_EVENT_A + ); + OffsetDateTime completedAt = migrationJdbc.queryForObject( + """ + SELECT completed_at + FROM event_publication + WHERE event_id = ? + """, + OffsetDateTime.class, + OUTBOX_EVENT_A + ); + assertThat(completedStatus).isEqualTo("COMPLETED"); + assertThat(completedAt.toInstant()) + .isBetween( + beforeLifecycle.toInstant(), + afterLifecycle.toInstant() + ); + + String retryStatus = migrationJdbc.queryForObject( + """ + SELECT status + FROM event_publication + WHERE event_id = ? + """, + String.class, + OUTBOX_EVENT_B + ); + OffsetDateTime retryUpdatedAt = migrationJdbc.queryForObject( + """ + SELECT updated_at + FROM event_publication + WHERE event_id = ? + """, + OffsetDateTime.class, + OUTBOX_EVENT_B + ); + OffsetDateTime nextAttemptAt = migrationJdbc.queryForObject( + """ + SELECT next_attempt_at + FROM event_publication + WHERE event_id = ? + """, + OffsetDateTime.class, + OUTBOX_EVENT_B + ); + assertThat(retryStatus).isEqualTo("RETRY_WAIT"); + assertThat(retryUpdatedAt.toInstant()) + .isBetween( + beforeLifecycle.toInstant(), + afterLifecycle.toInstant() + ); + assertThat(nextAttemptAt.toInstant()) + .isBetween( + beforeLifecycle.toInstant().plusSeconds(1), + afterLifecycle.toInstant().plusSeconds(1) + ); + } + private void assertOutboxProbeRemainsUnclaimed(UUID eventId) { Map state = migrationJdbc.queryForMap( """ @@ -525,7 +649,10 @@ private ConfigurableApplicationContext startRestrictedRuntimeApplication() { new MapPropertySource("postgresql-runtime-role-test", properties) ); - SpringApplication application = new SpringApplication(ServerApplication.class); + SpringApplication application = new SpringApplication( + ServerApplication.class, + SkewedClockConfiguration.class + ); application.setEnvironment(environment); application.setWebApplicationType(WebApplicationType.SERVLET); return application.run(); @@ -674,6 +801,37 @@ private record RoleAttributes( private record ContextProbe(Integer backendPid, String companyId) { } + @Configuration(proxyBeanMethods = false) + static class SkewedClockConfiguration { + + @Bean + @Primary + Clock skewedApplicationClock() { + return Clock.fixed( + Instant.parse("2099-01-01T00:00:00Z"), + ZoneOffset.UTC + ); + } + } + + private static final class NoOpProbeHandler implements DomainEventHandler { + + @Override + public String handlerName() { + return "rls-outbox-probe-handler"; + } + + @Override + public boolean supports(String eventType) { + return "RlsOutboxProbe".equals(eventType); + } + + @Override + public void handle(DomainEventEnvelope event) { + // No-op: successful delivery is enough to verify lease validation. + } + } + private static final class ExpectedTransactionFailure extends RuntimeException { } } diff --git a/src/test/java/com/fowoco/server/reliability/config/OutboxPropertiesTest.java b/src/test/java/com/fowoco/server/reliability/config/OutboxPropertiesTest.java index 912b7cd..977dd92 100644 --- a/src/test/java/com/fowoco/server/reliability/config/OutboxPropertiesTest.java +++ b/src/test/java/com/fowoco/server/reliability/config/OutboxPropertiesTest.java @@ -12,9 +12,15 @@ class OutboxPropertiesTest { void leaseDurationCannotExceedDatabaseFunctionBoundary() { OutboxProperties properties = new OutboxProperties(); + properties.setLeaseDuration(Duration.ofMillis(1)); properties.setLeaseDuration(Duration.ofDays(1)); assertThat(properties.getLeaseDuration()).isEqualTo(Duration.ofDays(1)); + assertThatThrownBy( + () -> properties.setLeaseDuration(Duration.ofNanos(999_999)) + ) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1 millisecond"); assertThatThrownBy( () -> properties.setLeaseDuration(Duration.ofDays(1).plusMillis(1)) ) From 3450f4a428bdaf7cfdd16bdb00c2b77450590234 Mon Sep 17 00:00:00 2001 From: krestar Date: Mon, 27 Jul 2026 14:38:53 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix(reliability):=20Outbox=20lease=20?= =?UTF-8?q?=EB=B0=80=EB=A6=AC=EC=B4=88=20=EC=A0=95=EB=A0=AC=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PostgreSQL의 millisecond 단위 lease 계약과 Java 설정을 일치 - toMillis() 변환에서 나노초가 조용히 절삭되는 설정을 거부 - 1ms + 1ns 입력에 대한 회귀 테스트를 추가 --- .../server/reliability/config/OutboxProperties.java | 6 ++++-- .../reliability/config/OutboxPropertiesTest.java | 13 +++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/fowoco/server/reliability/config/OutboxProperties.java b/src/main/java/com/fowoco/server/reliability/config/OutboxProperties.java index 4b5daf7..9151558 100644 --- a/src/main/java/com/fowoco/server/reliability/config/OutboxProperties.java +++ b/src/main/java/com/fowoco/server/reliability/config/OutboxProperties.java @@ -51,9 +51,11 @@ public Duration getLeaseDuration() { public void setLeaseDuration(Duration leaseDuration) { if (leaseDuration == null || leaseDuration.compareTo(MIN_LEASE_DURATION) < 0 - || leaseDuration.compareTo(MAX_LEASE_DURATION) > 0) { + || leaseDuration.compareTo(MAX_LEASE_DURATION) > 0 + || leaseDuration.getNano() % 1_000_000 != 0) { throw new IllegalArgumentException( - "leaseDuration must be between 1 millisecond and 1 day" + "leaseDuration must be between 1 millisecond and 1 day " + + "and aligned to whole milliseconds" ); } this.leaseDuration = leaseDuration; diff --git a/src/test/java/com/fowoco/server/reliability/config/OutboxPropertiesTest.java b/src/test/java/com/fowoco/server/reliability/config/OutboxPropertiesTest.java index 977dd92..2e2c446 100644 --- a/src/test/java/com/fowoco/server/reliability/config/OutboxPropertiesTest.java +++ b/src/test/java/com/fowoco/server/reliability/config/OutboxPropertiesTest.java @@ -27,4 +27,17 @@ void leaseDurationCannotExceedDatabaseFunctionBoundary() { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("1 day"); } + + @Test + void leaseDurationMustBeAlignedToDatabaseMillisecondPrecision() { + OutboxProperties properties = new OutboxProperties(); + + assertThatThrownBy( + () -> properties.setLeaseDuration( + Duration.ofMillis(1).plusNanos(1) + ) + ) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("whole milliseconds"); + } }