diff --git a/.github/workflows/database-docs.yml b/.github/workflows/database-docs.yml index 95ba84d..67cf4d4 100644 --- a/.github/workflows/database-docs.yml +++ b/.github/workflows/database-docs.yml @@ -8,6 +8,7 @@ on: - 'src/main/java/**' - 'src/main/resources/application.yaml' - 'src/main/resources/db/migration/**' + - 'src/main/resources/db/migration-postgresql/**' - 'scripts/api-docs/**' - 'scripts/db-docs/**' - '.github/workflows/database-docs.yml' @@ -18,6 +19,7 @@ on: - 'src/main/java/**' - 'src/main/resources/application.yaml' - 'src/main/resources/db/migration/**' + - 'src/main/resources/db/migration-postgresql/**' - 'scripts/api-docs/**' - 'scripts/db-docs/**' - '.github/workflows/database-docs.yml' diff --git a/docs/database-documentation.md b/docs/database-documentation.md index dc95e72..6fe99f4 100644 --- a/docs/database-documentation.md +++ b/docs/database-documentation.md @@ -5,7 +5,8 @@ PostgreSQL에 처음부터 적용한 결과로 생성합니다. - 팀 공유 사이트: - API Swagger 문서: -- 변경의 원본: `src/main/resources/db/migration` +- 공통 변경의 원본: `src/main/resources/db/migration` +- PostgreSQL 전용 변경의 원본: `src/main/resources/db/migration-postgresql` - 구조 결정의 원본: `docs/adr` 문서는 구조를 쉽게 찾기 위한 보조 수단입니다. 문서 화면에서 DB를 변경할 수 @@ -29,6 +30,7 @@ PostgreSQL에 처음부터 적용한 결과로 생성합니다. ```text src/main/resources/db/migration/** +src/main/resources/db/migration-postgresql/** scripts/db-docs/** .github/workflows/database-docs.yml ``` @@ -37,7 +39,7 @@ Workflow는 다음 순서로 동작합니다. ```text 빈 PostgreSQL 시작 -→ Flyway migrate +→ 공통·PostgreSQL 전용 Flyway migrate → Flyway validate → SchemaSpy HTML 생성 → Migration 이력 페이지 생성 diff --git a/docs/database/postgresql-rls-rollout.md b/docs/database/postgresql-rls-rollout.md index 76dcc24..2c25ba2 100644 --- a/docs/database/postgresql-rls-rollout.md +++ b/docs/database/postgresql-rls-rollout.md @@ -16,18 +16,33 @@ RLS는 기존 `ActorContext`, Repository의 `company_id` 조건, tenant-aware DB placeholder를 만들지 않습니다. 현재 기반 단계에서는 runtime/Flyway 설정 경계, PostgreSQL 전용 Flyway location, -transaction-local tenant context와 connection pool 비누수 테스트만 준비합니다. -아직 policy를 만들거나 RLS를 활성화하지 않습니다. - -현재 `main`의 V1~V7에는 아래 14개 tenant table이 존재합니다. 기반 단계의 제한 -role 테스트는 이 전체 범위에 업무 DML만 허용하고, table owner·DDL·`TRUNCATE`· +transaction-local tenant context와 connection pool 비누수 테스트를 준비했습니다. +JWT로 인증된 Worker·Task·Approval·Audit 업무 transaction은 요청 값이 아니라 +`ActorContext.companyId`를 transaction-local context의 신뢰 원본으로 사용합니다. +H2는 PostgreSQL custom setting을 흉내 내지 않고 transaction 경계만 검증합니다. +`V10`에서 bootstrap 함수와 tenant 테이블 RLS policy를 생성했으며, RLS는 아직 활성화하지 않았습니다. + +로그인·Refresh Token·Logout은 tenant context가 생기기 전 최소 bootstrap 조회가 +필요합니다. Issue #34 작성 뒤 추가된 사업장 회원가입도 새 tenant 행을 처음 만드는 +별도 bootstrap 흐름으로 함께 검토해야 합니다. Worker Link는 해당 기능이 구현된 뒤 +같은 기준으로 확장합니다. + +현재 `main`의 V1~V9에는 `company_id`를 직접 보유한 아래 16개 tenant table과, +부모 초안의 tenant를 따르는 `document_request_draft_type`이 존재합니다. 기반 단계의 +제한 role 테스트는 이 전체 범위에 업무 DML만 허용하고, table owner·DDL·`TRUNCATE`· `REFERENCES` 권한과 RLS 우회 권한이 없음을 확인합니다. - `company`, `user_account`, `refresh_token` -- `worker`, `worker_document` +- `worker`, `worker_document`, `stored_file` - `task`, `task_checklist_item`, `task_transition_history` - `approval_request`, `external_submission`, `task_evidence`, `audit_event` - `event_publication`, `event_consumption` +- `document_request_draft`, `document_request_draft_type` + +`document_request_draft_type`에는 `company_id`가 없으므로 부모 +`document_request_draft`의 `draft_id`와 현재 tenant context를 확인하는 `EXISTS` +policy를 사용합니다. 이 예외는 #57의 스키마와 JPA collection-table 계약을 유지하면서 +자식 테이블 직접 접근도 격리하기 위한 것입니다. `event_publication`은 여러 tenant의 미완료 row를 찾는 background queue이므로 일반 요청 table과 같은 policy를 바로 활성화하면 worker가 아무 이벤트도 claim하지 못할 수 @@ -60,13 +75,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·Outbox bootstrap 함수와 tenant 테이블 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/scripts/db-docs/generate-site.test.mjs b/scripts/db-docs/generate-site.test.mjs index 47b65f2..f0bb7eb 100644 --- a/scripts/db-docs/generate-site.test.mjs +++ b/scripts/db-docs/generate-site.test.mjs @@ -15,7 +15,7 @@ test('Flyway JSON을 안전한 DB 문서 사이트로 변환한다', async () => await mkdir(path.join(output, 'schema'), { recursive: true }) await writeFile(path.join(output, 'schema', 'index.html'), 'SchemaSpy') await writeFile(infoFile, JSON.stringify({ - schemaVersion: '5', + schemaVersion: '10', schemaName: 'public', flywayVersion: '12.4.0', migrations: [ @@ -37,6 +37,15 @@ test('Flyway JSON을 안전한 DB 문서 사이트로 변환한다', async () => executionTime: 0, filepath: '/private/path/V6__next.sql', }, + { + version: '10', + description: 'prepare postgresql rls', + type: 'SQL', + state: 'Success', + installedOnUTC: '2026-07-29T00:00:00Z', + executionTime: 21, + filepath: '/flyway/sql/postgresql/V10__prepare_postgresql_rls.sql', + }, ], })) @@ -55,12 +64,14 @@ test('Flyway JSON을 안전한 DB 문서 사이트로 변환한다', async () => const metadata = JSON.parse(await readFile(path.join(output, 'metadata.json'), 'utf8')) assert.match(index, /현재 Schema Version/) - assert.match(index, /성공 1개 · 대기 1개/) + assert.match(index, /성공 2개 · 대기 1개/) assert.match(migrations, /baseline <safe>/) + assert.match(migrations, /prepare postgresql rls/) assert.doesNotMatch(migrations, /private\/path/) - assert.equal(metadata.schema_version, '5') + assert.doesNotMatch(migrations, /flyway\/sql\/postgresql/) + assert.equal(metadata.schema_version, '10') assert.deepEqual(metadata.migration_counts, { - success: 1, + success: 2, pending: 1, attention_required: 0, }) diff --git a/scripts/db-docs/generate.sh b/scripts/db-docs/generate.sh index c0e49b4..3b4c6da 100755 --- a/scripts/db-docs/generate.sh +++ b/scripts/db-docs/generate.sh @@ -4,7 +4,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" OUTPUT_ROOT="${REPOSITORY_ROOT}/build/db-docs" -MIGRATION_DIRECTORY="${REPOSITORY_ROOT}/src/main/resources/db/migration" +COMMON_MIGRATION_DIRECTORY="${REPOSITORY_ROOT}/src/main/resources/db/migration" +POSTGRESQL_MIGRATION_DIRECTORY="${REPOSITORY_ROOT}/src/main/resources/db/migration-postgresql" FLYWAY_IMAGE="${DB_DOCS_FLYWAY_IMAGE:-flyway/flyway:12.4.0}" SCHEMASPY_IMAGE="${DB_DOCS_SCHEMASPY_IMAGE:-schemaspy/schemaspy:7.0.2}" @@ -31,6 +32,10 @@ if ! command -v node >/dev/null 2>&1; then echo "[db-docs] Node.js를 찾지 못했습니다. Node.js 24 이상을 설치해 주세요." >&2 exit 1 fi +if [[ ! -d "${COMMON_MIGRATION_DIRECTORY}" || ! -d "${POSTGRESQL_MIGRATION_DIRECTORY}" ]]; then + echo "[db-docs] 공통·PostgreSQL 전용 Migration 경로가 모두 필요합니다." >&2 + exit 1 +fi if ! docker info >/dev/null 2>&1; then echo "[db-docs] Docker가 실행 중이 아닙니다." >&2 exit 1 @@ -79,11 +84,15 @@ mkdir -p "${OUTPUT_ROOT}/site/schema" chmod 0777 "${OUTPUT_ROOT}/site/schema" JDBC_URL="jdbc:postgresql://${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}" +FLYWAY_MOUNT_ARGUMENTS=( + -v "${COMMON_MIGRATION_DIRECTORY}:/flyway/sql/common:ro" + -v "${POSTGRESQL_MIGRATION_DIRECTORY}:/flyway/sql/postgresql:ro" +) FLYWAY_ARGUMENTS=( "-url=${JDBC_URL}" "-user=${DATABASE_USER}" "-password=${DB_DOCS_PASSWORD}" - "-locations=filesystem:/flyway/sql" + "-locations=filesystem:/flyway/sql/common,filesystem:/flyway/sql/postgresql" "-defaultSchema=public" "-schemas=public" "-connectRetries=20" @@ -92,7 +101,7 @@ FLYWAY_ARGUMENTS=( echo "[db-docs] 빈 PostgreSQL에 Flyway Migration을 적용합니다." docker run --rm \ "${NETWORK_ARGUMENTS[@]}" \ - -v "${MIGRATION_DIRECTORY}:/flyway/sql:ro" \ + "${FLYWAY_MOUNT_ARGUMENTS[@]}" \ "${FLYWAY_IMAGE}" \ "${FLYWAY_ARGUMENTS[@]}" \ migrate @@ -100,14 +109,14 @@ docker run --rm \ echo "[db-docs] 적용된 Migration과 저장소 checksum을 검증합니다." docker run --rm \ "${NETWORK_ARGUMENTS[@]}" \ - -v "${MIGRATION_DIRECTORY}:/flyway/sql:ro" \ + "${FLYWAY_MOUNT_ARGUMENTS[@]}" \ "${FLYWAY_IMAGE}" \ "${FLYWAY_ARGUMENTS[@]}" \ validate docker run --rm \ "${NETWORK_ARGUMENTS[@]}" \ - -v "${MIGRATION_DIRECTORY}:/flyway/sql:ro" \ + "${FLYWAY_MOUNT_ARGUMENTS[@]}" \ "${FLYWAY_IMAGE}" \ "${FLYWAY_ARGUMENTS[@]}" \ -outputType=json \ 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/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/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/document/api/DocumentController.java b/src/main/java/com/fowoco/server/document/api/DocumentController.java index fd308ee..0bd8be4 100644 --- a/src/main/java/com/fowoco/server/document/api/DocumentController.java +++ b/src/main/java/com/fowoco/server/document/api/DocumentController.java @@ -1,5 +1,6 @@ package com.fowoco.server.document.api; +import com.fowoco.server.auth.application.ActorContext; import com.fowoco.server.auth.application.port.ActorContextProvider; import com.fowoco.server.document.application.DocumentPageResult; import com.fowoco.server.document.application.DocumentService; @@ -75,7 +76,7 @@ public DocumentPageResponse list( @Parameter(description = "페이지당 항목 수 (1~100)") @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size ) { - UUID companyId = actorContextProvider.requireCurrentActor().companyId(); + ActorContext actor = actorContextProvider.requireCurrentActor(); WorkerDocumentSearchQuery query = new WorkerDocumentSearchQuery( workerId, documentType, @@ -84,7 +85,7 @@ public DocumentPageResponse list( page, size ); - DocumentPageResult result = documentService.findPage(companyId, query); + DocumentPageResult result = documentService.findPage(actor, query); List items = result.items().stream() .map(document -> DocumentItemResponse.from( document, diff --git a/src/main/java/com/fowoco/server/document/api/DocumentReadinessController.java b/src/main/java/com/fowoco/server/document/api/DocumentReadinessController.java index 718b125..dd8630e 100644 --- a/src/main/java/com/fowoco/server/document/api/DocumentReadinessController.java +++ b/src/main/java/com/fowoco/server/document/api/DocumentReadinessController.java @@ -1,5 +1,6 @@ package com.fowoco.server.document.api; +import com.fowoco.server.auth.application.ActorContext; import com.fowoco.server.auth.application.port.ActorContextProvider; import com.fowoco.server.document.application.DocumentReadinessResult; import com.fowoco.server.document.application.DocumentReadinessService; @@ -60,8 +61,8 @@ public DocumentReadinessController( public DocumentReadinessResponse get( @Parameter(description = "업무 ID") @PathVariable UUID taskId ) { - UUID companyId = actorContextProvider.requireCurrentActor().companyId(); - DocumentReadinessResult result = documentReadinessService.calculate(taskId, companyId); + ActorContext actor = actorContextProvider.requireCurrentActor(); + DocumentReadinessResult result = documentReadinessService.calculate(taskId, actor); return new DocumentReadinessResponse( result.required(), result.available(), diff --git a/src/main/java/com/fowoco/server/document/application/DocumentReadinessService.java b/src/main/java/com/fowoco/server/document/application/DocumentReadinessService.java index 84c7830..88bcb4d 100644 --- a/src/main/java/com/fowoco/server/document/application/DocumentReadinessService.java +++ b/src/main/java/com/fowoco/server/document/application/DocumentReadinessService.java @@ -1,6 +1,8 @@ package com.fowoco.server.document.application; +import com.fowoco.server.auth.application.ActorContext; import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.document.domain.ChecklistItemDocumentMapper; import com.fowoco.server.task.application.error.TaskErrorCode; import com.fowoco.server.task.application.port.TaskChecklistRepository; @@ -26,22 +28,27 @@ public class DocumentReadinessService { private final TaskRepository taskRepository; private final TaskChecklistRepository taskChecklistRepository; private final WorkerDocumentRepository workerDocumentRepository; + private final TenantDatabaseContext tenantDatabaseContext; private final Clock clock; public DocumentReadinessService( TaskRepository taskRepository, TaskChecklistRepository taskChecklistRepository, WorkerDocumentRepository workerDocumentRepository, + TenantDatabaseContext tenantDatabaseContext, Clock clock ) { this.taskRepository = taskRepository; this.taskChecklistRepository = taskChecklistRepository; this.workerDocumentRepository = workerDocumentRepository; + this.tenantDatabaseContext = tenantDatabaseContext; this.clock = clock; } @Transactional(readOnly = true) - public DocumentReadinessResult calculate(UUID taskId, UUID companyId) { + public DocumentReadinessResult calculate(UUID taskId, ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + UUID companyId = actor.companyId(); Task task = taskRepository.findByIdAndCompanyId(taskId, companyId) .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); diff --git a/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java b/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java index dcc6fc5..4fa3804 100644 --- a/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java +++ b/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java @@ -9,6 +9,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.document.application.error.DocumentErrorCode; import com.fowoco.server.document.application.port.DocumentRequestDraftRepository; @@ -30,6 +31,7 @@ public class DocumentRequestDraftService { private final TaskRepository taskRepository; private final DocumentRequestDraftRepository documentRequestDraftRepository; private final AuditEventRepository auditRepository; + private final TenantDatabaseContext tenantDatabaseContext; private final UuidGenerator uuidGenerator; private final Clock clock; @@ -37,12 +39,14 @@ public DocumentRequestDraftService( TaskRepository taskRepository, DocumentRequestDraftRepository documentRequestDraftRepository, AuditEventRepository auditRepository, + TenantDatabaseContext tenantDatabaseContext, UuidGenerator uuidGenerator, Clock clock ) { this.taskRepository = taskRepository; this.documentRequestDraftRepository = documentRequestDraftRepository; this.auditRepository = auditRepository; + this.tenantDatabaseContext = tenantDatabaseContext; this.uuidGenerator = uuidGenerator; this.clock = clock; } @@ -53,11 +57,12 @@ public DocumentRequestDraft upsert( ActorContext actor, RequestMetadata metadata ) { - taskRepository.findByIdAndCompanyId(command.taskId(), command.companyId()) + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + taskRepository.findByIdAndCompanyId(command.taskId(), actor.companyId()) .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); Optional existing = documentRequestDraftRepository - .findByTaskIdAndCompanyId(command.taskId(), command.companyId()); + .findByTaskIdAndCompanyId(command.taskId(), actor.companyId()); Instant now = clock.instant(); DocumentRequestDraft saved; @@ -66,7 +71,7 @@ public DocumentRequestDraft upsert( DocumentRequestDraft draft = DocumentRequestDraft.create( uuidGenerator.generate(), command.taskId(), - command.companyId(), + actor.companyId(), command.language(), command.documentTypes(), command.message(), diff --git a/src/main/java/com/fowoco/server/document/application/DocumentService.java b/src/main/java/com/fowoco/server/document/application/DocumentService.java index 9d592c9..93c13b6 100644 --- a/src/main/java/com/fowoco/server/document/application/DocumentService.java +++ b/src/main/java/com/fowoco/server/document/application/DocumentService.java @@ -1,5 +1,7 @@ package com.fowoco.server.document.application; +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.worker.application.WorkerDocumentSearchQuery; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; import com.fowoco.server.worker.application.port.WorkerRepository; @@ -19,17 +21,22 @@ public class DocumentService { private final WorkerDocumentRepository workerDocumentRepository; private final WorkerRepository workerRepository; + private final TenantDatabaseContext tenantDatabaseContext; public DocumentService( WorkerDocumentRepository workerDocumentRepository, - WorkerRepository workerRepository + WorkerRepository workerRepository, + TenantDatabaseContext tenantDatabaseContext ) { this.workerDocumentRepository = workerDocumentRepository; this.workerRepository = workerRepository; + this.tenantDatabaseContext = tenantDatabaseContext; } @Transactional(readOnly = true) - public DocumentPageResult findPage(UUID companyId, WorkerDocumentSearchQuery query) { + public DocumentPageResult findPage(ActorContext actor, WorkerDocumentSearchQuery query) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + UUID companyId = actor.companyId(); List items = workerDocumentRepository.findPage(companyId, query); long totalElements = workerDocumentRepository.countPage(companyId, query); diff --git a/src/main/java/com/fowoco/server/file/application/FileService.java b/src/main/java/com/fowoco/server/file/application/FileService.java index 61fdfd4..1a9b0b1 100644 --- a/src/main/java/com/fowoco/server/file/application/FileService.java +++ b/src/main/java/com/fowoco/server/file/application/FileService.java @@ -9,6 +9,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.file.application.error.FileErrorCode; import com.fowoco.server.file.application.port.FileStorage; @@ -48,6 +49,7 @@ public class FileService { private final TaskRepository taskRepository; private final WorkerRepository workerRepository; private final AuditEventRepository auditRepository; + private final TenantDatabaseContext tenantDatabaseContext; private final UuidGenerator uuidGenerator; private final Clock clock; @@ -57,6 +59,7 @@ public FileService( TaskRepository taskRepository, WorkerRepository workerRepository, AuditEventRepository auditRepository, + TenantDatabaseContext tenantDatabaseContext, UuidGenerator uuidGenerator, Clock clock ) { @@ -65,12 +68,15 @@ public FileService( this.taskRepository = taskRepository; this.workerRepository = workerRepository; this.auditRepository = auditRepository; + this.tenantDatabaseContext = tenantDatabaseContext; this.uuidGenerator = uuidGenerator; this.clock = clock; } @Transactional public StoredFile upload(FileCreateCommand command, ActorContext actor, RequestMetadata metadata) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + UUID companyId = actor.companyId(); if (command.size() > MAX_FILE_SIZE_BYTES) { throw new ApiException(FileErrorCode.FILE_TOO_LARGE); } @@ -78,11 +84,11 @@ public StoredFile upload(FileCreateCommand command, ActorContext actor, RequestM throw new ApiException(FileErrorCode.UNSUPPORTED_FILE_TYPE); } if (command.taskId() != null) { - taskRepository.findByIdAndCompanyId(command.taskId(), command.companyId()) + taskRepository.findByIdAndCompanyId(command.taskId(), companyId) .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); } if (command.workerId() != null) { - workerRepository.findByWorkerIdAndCompanyId(command.workerId(), command.companyId()) + workerRepository.findByWorkerIdAndCompanyId(command.workerId(), companyId) .orElseThrow(() -> new ApiException(WorkerErrorCode.WORKER_NOT_FOUND)); } @@ -92,7 +98,7 @@ public StoredFile upload(FileCreateCommand command, ActorContext actor, RequestM StoredFile storedFile = StoredFile.create( storedFileId, - command.companyId(), + companyId, command.name(), command.mimeType(), command.size(), 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..1c953fb 100644 --- a/src/main/java/com/fowoco/server/reliability/application/OutboxClaimService.java +++ b/src/main/java/com/fowoco/server/reliability/application/OutboxClaimService.java @@ -1,10 +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; import java.util.List; import java.util.UUID; @@ -14,39 +12,45 @@ @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 + OutboxMetrics metrics ) { - this.repository = repository; + this.claimBootstrap = claimBootstrap; this.properties = properties; this.metrics = metrics; - this.clock = clock; } @Transactional - 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); + public List claimBatch(String owner) { + List results = claimBootstrap.claim( + owner, + 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..3568033 100644 --- a/src/main/java/com/fowoco/server/reliability/application/OutboxCompletionTransaction.java +++ b/src/main/java/com/fowoco/server/reliability/application/OutboxCompletionTransaction.java @@ -1,8 +1,9 @@ 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.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; @@ -13,24 +14,29 @@ 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, String owner) { - Instant now = clock.instant(); - EventPublication publication = repository.findByIdForUpdate(eventId) + public void complete(UUID eventId, UUID companyId, String owner) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + 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 558edb4..0113a96 100644 --- a/src/main/java/com/fowoco/server/reliability/application/OutboxFailureTransaction.java +++ b/src/main/java/com/fowoco/server/reliability/application/OutboxFailureTransaction.java @@ -1,10 +1,11 @@ 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.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; @@ -15,37 +16,43 @@ public class OutboxFailureTransaction { private final EventPublicationRepository repository; + private final TenantDatabaseContext tenantDatabaseContext; private final OutboxFailureClassifier classifier; private final OutboxBackoffPolicy backoffPolicy; private final OutboxProperties properties; private final OutboxMetrics metrics; - private final Clock clock; + private final OutboxTimeSource timeSource; public OutboxFailureTransaction( EventPublicationRepository repository, + TenantDatabaseContext tenantDatabaseContext, OutboxFailureClassifier classifier, OutboxBackoffPolicy backoffPolicy, OutboxProperties properties, OutboxMetrics metrics, - Clock clock + OutboxTimeSource timeSource ) { this.repository = repository; + this.tenantDatabaseContext = tenantDatabaseContext; this.classifier = classifier; this.backoffPolicy = backoffPolicy; this.properties = properties; this.metrics = metrics; - this.clock = clock; + this.timeSource = timeSource; } @Transactional(propagation = Propagation.REQUIRES_NEW) public FailureOutcome recordFailure( UUID eventId, + UUID companyId, String owner, Throwable failure ) { - Instant now = clock.instant(); - EventPublication publication = repository.findByIdForUpdate(eventId) + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + 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 5996ed5..5ba776a 100644 --- a/src/main/java/com/fowoco/server/reliability/application/OutboxHandlerTransaction.java +++ b/src/main/java/com/fowoco/server/reliability/application/OutboxHandlerTransaction.java @@ -1,14 +1,15 @@ 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; +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; @@ -20,33 +21,39 @@ 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; + private final OutboxTimeSource timeSource; public OutboxHandlerTransaction( EventPublicationRepository publicationRepository, EventConsumptionRepository consumptionRepository, + 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) public boolean deliver( UUID eventId, + UUID companyId, String owner, DomainEventHandler handler ) { - Instant now = clock.instant(); - EventPublication publication = publicationRepository.findByIdForUpdate(eventId) + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + 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/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..c78ffcf --- /dev/null +++ b/src/main/java/com/fowoco/server/reliability/application/port/OutboxClaimBootstrap.java @@ -0,0 +1,27 @@ +package com.fowoco.server.reliability.application.port; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; + +/** + * Claims cross-tenant outbox work without exposing event payloads. + */ +public interface OutboxClaimBootstrap { + + List claim( + String owner, + 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/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 313fd9a..9151558 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,9 @@ @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; private Duration pollInterval = Duration.ofSeconds(1); private int batchSize = 20; @@ -46,7 +49,16 @@ public Duration getLeaseDuration() { } public void setLeaseDuration(Duration leaseDuration) { - this.leaseDuration = requirePositive(leaseDuration, "leaseDuration"); + if (leaseDuration == null + || leaseDuration.compareTo(MIN_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 " + + "and aligned to whole milliseconds" + ); + } + 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/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..3038244 --- /dev/null +++ b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/JpaOutboxClaimBootstrap.java @@ -0,0 +1,63 @@ +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.application.port.OutboxTimeSource; +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; + private final OutboxTimeSource timeSource; + + public JpaOutboxClaimBootstrap( + EventPublicationRepository repository, + OutboxTimeSource timeSource + ) { + this.repository = repository; + this.timeSource = timeSource; + } + + @Override + public List claim( + String owner, + Duration leaseDuration, + int batchSize, + int maxAttempts + ) { + Instant now = timeSource.now(); + 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..a87b4b9 --- /dev/null +++ b/src/main/java/com/fowoco/server/reliability/infrastructure/persistence/PostgreSqlOutboxClaimBootstrap.java @@ -0,0 +1,61 @@ +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.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) + """; + + private final EntityManager entityManager; + + public PostgreSqlOutboxClaimBootstrap(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public List claim( + String owner, + Duration leaseDuration, + int batchSize, + int maxAttempts + ) { + @SuppressWarnings("unchecked") + List rows = entityManager.createNativeQuery(CLAIM_SQL) + .setParameter(1, owner) + .setParameter(2, leaseDuration.toMillis()) + .setParameter(3, batchSize) + .setParameter(4, 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/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/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/java/com/fowoco/server/task/application/TaskWorkflowService.java b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java index 00248e2..d5108a6 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.reliability.application.port.DomainEventPublisher; import com.fowoco.server.task.application.TaskContentCodec.EncodedTaskContent; @@ -45,6 +46,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; @@ -59,6 +61,7 @@ public class TaskWorkflowService { public TaskWorkflowService( ActorAuthorizer actorAuthorizer, + TenantDatabaseContext tenantDatabaseContext, TaskRepository taskRepository, TaskChecklistRepository checklistRepository, TaskTransitionRecorder transitionRecorder, @@ -72,6 +75,7 @@ public TaskWorkflowService( Clock clock ) { this.actorAuthorizer = actorAuthorizer; + this.tenantDatabaseContext = tenantDatabaseContext; this.taskRepository = taskRepository; this.checklistRepository = checklistRepository; this.transitionRecorder = transitionRecorder; @@ -91,6 +95,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())) { @@ -180,6 +185,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); @@ -206,6 +212,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( @@ -223,6 +230,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()); @@ -315,6 +323,7 @@ public TaskResult updateChecklistItem( ActorContext actor, RequestMetadata metadata ) { + bindTenant(actor); actorAuthorizer.requireHrWrite(actor); Task task = requireTask(taskId, actor.companyId()); TaskChecklistItem item = checklistRepository @@ -390,6 +399,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); @@ -552,6 +562,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 006dca6..88d793e 100644 --- a/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java +++ b/src/main/java/com/fowoco/server/worker/api/WorkerDocumentController.java @@ -71,17 +71,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)); } @@ -127,11 +126,9 @@ public WorkerDocumentResponse patch( HttpServletRequest servletRequest ) { ActorContext actor = actorContextProvider.requireCurrentActor(); - UUID companyId = actor.companyId(); WorkerDocumentPatchCommand command = new WorkerDocumentPatchCommand( documentId, workerId, - companyId, request.getDocumentType(), request.getSubmissionStatus(), request.getExpiryDate(), 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 e66b361..940393d 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; @@ -21,7 +20,6 @@ public final class WorkerDocumentPatchCommand { public WorkerDocumentPatchCommand( UUID workerDocumentId, UUID workerId, - UUID companyId, DocumentType documentType, SubmissionStatus submissionStatus, LocalDate expiryDate, @@ -32,7 +30,6 @@ public WorkerDocumentPatchCommand( ) { this.workerDocumentId = workerDocumentId; this.workerId = workerId; - this.companyId = companyId; this.documentType = documentType; this.submissionStatus = submissionStatus; this.expiryDate = expiryDate; @@ -50,10 +47,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 d7e60ed..f6f516b 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java @@ -9,6 +9,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.time.DatabaseTimestamp; import com.fowoco.server.common.web.RequestMetadata; import com.fowoco.server.file.application.port.StoredFileRepository; @@ -19,7 +20,6 @@ import java.time.Clock; import java.time.Instant; import java.util.Comparator; -import java.util.Objects; import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,6 +33,7 @@ public class WorkerDocumentService { private final WorkerRepository workerRepository; private final StoredFileRepository storedFileRepository; private final AuditEventRepository auditRepository; + private final TenantDatabaseContext tenantDatabaseContext; private final UuidGenerator uuidGenerator; private final Clock clock; @@ -41,6 +42,7 @@ public WorkerDocumentService( WorkerRepository workerRepository, StoredFileRepository storedFileRepository, AuditEventRepository auditRepository, + TenantDatabaseContext tenantDatabaseContext, UuidGenerator uuidGenerator, Clock clock ) { @@ -48,19 +50,21 @@ public WorkerDocumentService( this.workerRepository = workerRepository; this.storedFileRepository = storedFileRepository; this.auditRepository = auditRepository; + this.tenantDatabaseContext = tenantDatabaseContext; this.uuidGenerator = uuidGenerator; this.clock = clock; } @Transactional - public WorkerDocument register(WorkerDocumentCreateCommand command) { - workerRepository.findByWorkerIdAndCompanyId(command.workerId(), command.companyId()) + public WorkerDocument register(WorkerDocumentCreateCommand command, ActorContext actor) { + bindTenant(actor); + workerRepository.findByWorkerIdAndCompanyId(command.workerId(), actor.companyId()) .orElseThrow(() -> new ApiException(WorkerErrorCode.WORKER_NOT_FOUND)); WorkerDocument document = WorkerDocument.create( uuidGenerator.generate(), command.workerId(), - command.companyId(), + actor.companyId(), command.documentType(), command.submissionStatus(), command.expiryDate(), @@ -73,23 +77,37 @@ 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, ActorContext actor, RequestMetadata metadata) { + public WorkerDocument patch( + WorkerDocumentPatchCommand command, + ActorContext actor, + RequestMetadata metadata + ) { + 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); } - UUID resolvedFileId = resolveFileId(command.fileId(), command.companyId(), existing.fileId()); + UUID resolvedFileId = resolveFileId(command.fileId(), actor.companyId(), existing.fileId()); boolean fileNewlyLinked = command.fileId() != null && !command.fileId().equals(existing.fileId()); Instant now = DatabaseTimestamp.nowNotBefore(clock, existing.createdAt()); @@ -177,4 +195,8 @@ private int rolePriority(UserRole role) { private static T orElseKeep(T newValue, T existingValue) { return newValue != null ? newValue : existingValue; } + + private void bindTenant(ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + } } 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 b3deeb1..307721e 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerService.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerService.java @@ -1,7 +1,9 @@ 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.common.time.DatabaseTimestamp; import com.fowoco.server.worker.application.error.WorkerErrorCode; import com.fowoco.server.worker.application.port.WorkerRepository; @@ -16,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(), @@ -47,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); } @@ -87,4 +96,8 @@ 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()); + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index ab58411..677875f 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -41,6 +41,8 @@ springdoc: path: /swagger-ui.html app: + database: + tenant-context-mode: transaction-only reliability: outbox: enabled: ${OUTBOX_ENABLED:true} @@ -125,6 +127,8 @@ spring: jpa: show-sql: true app: + database: + tenant-context-mode: postgresql demo-seed: enabled: false auth: @@ -187,6 +191,8 @@ springdoc: swagger-ui: enabled: false app: + database: + tenant-context-mode: postgresql demo-seed: enabled: false workflow: diff --git a/src/main/resources/db/migration-postgresql/V10__prepare_postgresql_rls.sql b/src/main/resources/db/migration-postgresql/V10__prepare_postgresql_rls.sql new file mode 100644 index 0000000..06b12b7 --- /dev/null +++ b/src/main/resources/db/migration-postgresql/V10__prepare_postgresql_rls.sql @@ -0,0 +1,424 @@ +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_lease_duration_millis BIGINT, + p_batch_size INTEGER, + p_max_attempts INTEGER +) +RETURNS TABLE ( + event_id UUID, + company_id UUID, + review_required BOOLEAN +) +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 := 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'; + 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 ( + ( + publication.status IN ('PENDING', 'RETRY_WAIT') + AND publication.next_attempt_at <= v_now + ) + OR ( + publication.status = 'PROCESSING' + AND publication.lease_expires_at <= v_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 v_owner + END, + lease_expires_at = CASE + WHEN publication.attempt_count + 1 > p_max_attempts + THEN NULL + 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, v_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 + ; +END; +$$; + +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; +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, + BIGINT, + INTEGER, + 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 + 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_stored_file_tenant_isolation + ON public.stored_file + 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_document_request_draft_tenant_isolation + ON public.document_request_draft + 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_document_request_draft_type_tenant_isolation + ON public.document_request_draft_type + FOR ALL + TO PUBLIC + USING ( + EXISTS ( + SELECT 1 + FROM public.document_request_draft AS draft + WHERE draft.draft_id = document_request_draft_type.draft_id + AND draft.company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 + FROM public.document_request_draft AS draft + WHERE draft.draft_id = document_request_draft_type.draft_id + AND draft.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..67b839c 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -67,6 +67,7 @@ private void assertSchemaContract(Connection connection) throws SQLException { "refresh_token", "worker", "worker_document", + "stored_file", "task", "task_checklist_item", "task_transition_history", @@ -75,7 +76,9 @@ private void assertSchemaContract(Connection connection) throws SQLException { "task_evidence", "audit_event", "event_publication", - "event_consumption" + "event_consumption", + "document_request_draft", + "document_request_draft_type" ); assertThat(columnSpecs(connection, "company")) @@ -116,6 +119,13 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsEntry("document_type", new ColumnSpec("varchar", false)) .containsEntry("submission_status", new ColumnSpec("varchar", false)) .containsEntry("version", new ColumnSpec("int8", false)); + assertThat(columnSpecs(connection, "stored_file")) + .containsEntry("stored_file_id", new ColumnSpec("uuid", false)) + .containsEntry("company_id", new ColumnSpec("uuid", false)) + .containsEntry("task_id", new ColumnSpec("uuid", true)) + .containsEntry("worker_id", new ColumnSpec("uuid", true)) + .containsEntry("storage_key", new ColumnSpec("varchar", false)) + .containsEntry("scan_status", new ColumnSpec("varchar", false)); assertThat(columnSpecs(connection, "task")) .containsEntry("task_id", new ColumnSpec("uuid", false)) .containsEntry("company_id", new ColumnSpec("uuid", false)) @@ -152,6 +162,16 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsEntry("company_id", new ColumnSpec("uuid", false)) .containsEntry("handler_name", new ColumnSpec("varchar", false)) .containsEntry("completed_at", new ColumnSpec("timestamptz", false)); + assertThat(columnSpecs(connection, "document_request_draft")) + .containsEntry("draft_id", new ColumnSpec("uuid", false)) + .containsEntry("task_id", new ColumnSpec("uuid", false)) + .containsEntry("company_id", new ColumnSpec("uuid", false)) + .containsEntry("review_status", new ColumnSpec("varchar", false)) + .containsEntry("version", new ColumnSpec("int8", false)); + assertThat(columnSpecs(connection, "document_request_draft_type")) + .containsEntry("draft_id", new ColumnSpec("uuid", false)) + .containsEntry("document_type", new ColumnSpec("varchar", false)) + .doesNotContainKey("company_id"); assertThat(constraintNames(connection)) .contains( @@ -165,6 +185,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "fk_refresh_token_user_company", "fk_worker_company", "fk_worker_document_worker", + "pk_stored_file", + "fk_stored_file_company", "fk_task_worker_company", "fk_task_created_by_company", "fk_approval_request_task_company", @@ -175,7 +197,10 @@ private void assertSchemaContract(Connection connection) throws SQLException { "fk_event_publication_company", "pk_event_consumption", "uq_event_consumption_event_handler", - "fk_event_consumption_publication" + "fk_event_consumption_publication", + "pk_document_request_draft", + "fk_document_request_draft_task_company", + "fk_document_request_draft_type_draft" ); assertThat(indexNames(connection)) .contains( @@ -185,12 +210,51 @@ private void assertSchemaContract(Connection connection) throws SQLException { "idx_refresh_token_expires_at", "idx_worker_company", "idx_worker_document_company_status", + "idx_stored_file_company", "idx_task_company_status_due", "idx_approval_request_task_status", "idx_audit_event_company_time", "idx_event_publication_claim", "idx_event_publication_company_time", - "idx_event_consumption_company_event" + "idx_event_consumption_company_event", + "idx_document_request_draft_company" + ); + 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_stored_file_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", + "pl_document_request_draft_tenant_isolation", + "pl_document_request_draft_type_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", + "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_count_outstanding_event_publications", + "bootstrap_oldest_outstanding_event_occurred_at" ); } @@ -278,6 +342,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 +583,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 +659,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/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 -> { 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..8494fa6 --- /dev/null +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlRlsIsolationTest.java @@ -0,0 +1,494 @@ +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"); + private static final UUID USER_A = + UUID.fromString("a3000000-0000-0000-0000-000000000001"); + private static final UUID USER_B = + UUID.fromString("b3000000-0000-0000-0000-000000000002"); + private static final UUID TASK_A = + UUID.fromString("a4000000-0000-0000-0000-000000000001"); + private static final UUID TASK_B = + UUID.fromString("b4000000-0000-0000-0000-000000000002"); + private static final UUID STORED_FILE_A = + UUID.fromString("a5000000-0000-0000-0000-000000000001"); + private static final UUID STORED_FILE_B = + UUID.fromString("b5000000-0000-0000-0000-000000000002"); + private static final UUID DRAFT_A = + UUID.fromString("a6000000-0000-0000-0000-000000000001"); + private static final UUID DRAFT_B = + UUID.fromString("b6000000-0000-0000-0000-000000000002"); + + @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, " + + "public.stored_file, public.document_request_draft, " + + "public.document_request_draft_type 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(""" + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, + password_hash, role, status + ) VALUES + ('%s', '%s', 'rls-a@example.com', 'rls-a@example.com', + 'test-password-hash-a', 'ADMIN', 'ACTIVE'), + ('%s', '%s', 'rls-b@example.com', 'rls-b@example.com', + 'test-password-hash-b', 'ADMIN', 'ACTIVE') + """.formatted(USER_A, COMPANY_A, USER_B, COMPANY_B)); + statement.execute(""" + INSERT INTO task ( + task_id, company_id, worker_id, case_id, task_type, + workflow_id, workflow_catalog_version, title, + business_data_json, critical_fingerprint, content_revision, + source, status, created_by, updated_by, created_at, updated_at + ) VALUES + ('%s', '%s', '%s', 'a4100000-0000-0000-0000-000000000001', + 'RECONTRACT', 'e9-recontract', '2026.07', 'RLS Task A', + '{}', repeat('a', 64), 0, 'MANUAL', 'DRAFT', + '%s', '%s', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('%s', '%s', '%s', 'b4100000-0000-0000-0000-000000000002', + 'RECONTRACT', 'e9-recontract', '2026.07', 'RLS Task B', + '{}', repeat('b', 64), 0, 'MANUAL', 'DRAFT', + '%s', '%s', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """.formatted( + TASK_A, COMPANY_A, WORKER_A, USER_A, USER_A, + TASK_B, COMPANY_B, WORKER_B, USER_B, USER_B + )); + statement.execute(""" + INSERT INTO stored_file ( + stored_file_id, company_id, name, mime_type, size, purpose, + storage_key, scan_status + ) VALUES + ('%s', '%s', 'tenant-a.pdf', 'application/pdf', 1, + 'RLS_TEST', 'rls-tenant-a', 'NOT_SCANNED'), + ('%s', '%s', 'tenant-b.pdf', 'application/pdf', 1, + 'RLS_TEST', 'rls-tenant-b', 'NOT_SCANNED') + """.formatted(STORED_FILE_A, COMPANY_A, STORED_FILE_B, COMPANY_B)); + statement.execute(""" + INSERT INTO document_request_draft ( + draft_id, task_id, company_id, language, message, review_status + ) VALUES + ('%s', '%s', '%s', 'ko', 'Tenant A draft', 'DRAFT'), + ('%s', '%s', '%s', 'ko', 'Tenant B draft', 'DRAFT') + """.formatted(DRAFT_A, TASK_A, COMPANY_A, DRAFT_B, TASK_B, COMPANY_B)); + statement.execute(""" + INSERT INTO document_request_draft_type (draft_id, document_type) + VALUES + ('%s', 'PASSPORT_COPY'), + ('%s', 'ARC') + """.formatted(DRAFT_A, DRAFT_B)); + + statement.execute("ALTER TABLE public.company ENABLE ROW LEVEL SECURITY"); + statement.execute("ALTER TABLE public.worker ENABLE ROW LEVEL SECURITY"); + statement.execute("ALTER TABLE public.stored_file ENABLE ROW LEVEL SECURITY"); + statement.execute("ALTER TABLE public.document_request_draft ENABLE ROW LEVEL SECURITY"); + statement.execute( + "ALTER TABLE public.document_request_draft_type ENABLE ROW LEVEL SECURITY" + ); + } + } + + private void assertMissingAndInvalidContextFailClosed(Connection connection) + throws SQLException { + connection.setAutoCommit(false); + try { + assertThat(workerCount(connection)).isZero(); + assertThat(tableCount(connection, "stored_file")).isZero(); + assertThat(tableCount(connection, "document_request_draft")).isZero(); + assertThat(tableCount(connection, "document_request_draft_type")).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(uuidValues( + connection, + "SELECT stored_file_id FROM public.stored_file ORDER BY stored_file_id" + )).containsExactly(STORED_FILE_A); + assertThat(uuidValues( + connection, + "SELECT draft_id FROM public.document_request_draft ORDER BY draft_id" + )).containsExactly(DRAFT_A); + assertThat(stringValues( + connection, + "SELECT document_type FROM public.document_request_draft_type " + + "ORDER BY document_type" + )).containsExactly("PASSPORT_COPY"); + 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) + ); + assertSqlState( + connection, + "42501", + """ + INSERT INTO stored_file ( + stored_file_id, company_id, name, mime_type, size, purpose, + storage_key, scan_status + ) VALUES ( + 'b5000000-0000-0000-0000-000000000099', + '%s', 'forbidden.pdf', 'application/pdf', 1, + 'RLS_TEST', 'rls-forbidden-b', 'NOT_SCANNED' + ) + """.formatted(COMPANY_B) + ); + assertSqlState( + connection, + "42501", + """ + INSERT INTO document_request_draft_type (draft_id, document_type) + VALUES ('%s', 'CONTRACT') + """.formatted(DRAFT_B) + ); + + 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.document_request_draft_type DISABLE ROW LEVEL SECURITY" + ); + statement.execute( + "ALTER TABLE public.document_request_draft DISABLE ROW LEVEL SECURITY" + ); + statement.execute("ALTER TABLE public.stored_file DISABLE ROW LEVEL SECURITY"); + 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 document_request_draft_type + WHERE draft_id IN ('%s', '%s') + """.formatted(DRAFT_A, DRAFT_B)); + statement.execute(""" + DELETE FROM document_request_draft + WHERE draft_id IN ('%s', '%s') + """.formatted(DRAFT_A, DRAFT_B)); + statement.execute(""" + DELETE FROM stored_file + WHERE stored_file_id IN ('%s', '%s') + OR storage_key = 'rls-forbidden-b' + """.formatted(STORED_FILE_A, STORED_FILE_B)); + statement.execute(""" + DELETE FROM task + WHERE task_id IN ('%s', '%s') + """.formatted(TASK_A, TASK_B)); + 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 user_account + WHERE user_id IN ('%s', '%s') + """.formatted(USER_A, USER_B)); + 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 int tableCount(Connection connection, String tableName) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery( + "SELECT COUNT(*) FROM public." + tableName + )) { + 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 java.util.List uuidValues(Connection connection, String sql) + throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(sql)) { + java.util.List values = new java.util.ArrayList<>(); + while (resultSet.next()) { + values.add(resultSet.getObject(1, UUID.class)); + } + return java.util.List.copyOf(values); + } + } + + private java.util.List stringValues(Connection connection, String sql) + throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(sql)) { + java.util.List values = new java.util.ArrayList<>(); + while (resultSet.next()) { + values.add(resultSet.getString(1)); + } + return java.util.List.copyOf(values); + } + } + + 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; + } +} 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 f3257b9..7ef2d48 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java @@ -1,15 +1,30 @@ 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; import java.sql.DriverManager; 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; @@ -23,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; @@ -41,12 +59,17 @@ 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", "refresh_token", "worker", "worker_document", + "stored_file", "task", "task_checklist_item", "task_transition_history", @@ -55,7 +78,9 @@ class PostgreSqlTenantDatabaseContextTest { "task_evidence", "audit_event", "event_publication", - "event_consumption" + "event_consumption", + "document_request_draft", + "document_request_draft_type" }; private static final String TENANT_TABLE_SQL = "public." + String.join(", public.", TENANT_TABLES); @@ -238,6 +263,28 @@ 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,bigint,integer,integer" + + ")", + "EXECUTE" + )).isFalse(); + assertThat(hasFunctionPrivilege( + "bootstrap_count_outstanding_event_publications()", + "EXECUTE" + )).isFalse(); + assertThat(hasFunctionPrivilege( + "bootstrap_oldest_outstanding_event_occurred_at()", + "EXECUTE" + )).isFalse(); } @Test @@ -319,6 +366,266 @@ void springJpaTransactionUsesTheRestrictedRuntimeConnection() { assertThat(contextProbe.companyId()).isEqualTo(COMPANY_A.toString()); } + @Test + void outboxLifecycleUsesDatabaseClockAndScopedReadsRejectMismatches() { + String function = "public.bootstrap_claim_event_publications(" + + "text,bigint,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, "{}"); + insertOutboxProbe(COMPANY_B, OUTBOX_EVENT_B, "{}"); + 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( + OUTBOX_EVENT_A, + COMPANY_A + ), + new OutboxClaimService.ClaimedEvent( + OUTBOX_EVENT_B, + 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); + 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("{}"); + + assertOutboxLifecycleIgnoresSkewedApplicationClock(); + + OutboxBacklogReader backlogReader = + applicationContext.getBean(OutboxBacklogReader.class); + assertThat(backlogReader.countOutstanding()).isEqualTo(1); + 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 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) { + Throwable thrown = catchThrowable(() -> runtimeJdbc.queryForList( + """ + SELECT * + FROM public.bootstrap_claim_event_publications( + CAST(? AS TEXT), + CAST(? AS BIGINT), + CAST(? AS INTEGER), + CAST(? AS INTEGER) + ) + """, + arguments + )); + 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( + """ + 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); @@ -336,6 +643,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(); @@ -344,12 +652,48 @@ 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(); } + 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); @@ -397,6 +741,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, @@ -444,6 +804,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/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(); + } +} diff --git a/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java index 5c10efb..5de3af7 100644 --- a/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java @@ -164,7 +164,7 @@ private HttpResponse uploadFileWithTask( ) throws Exception { return uploadFile(token, filename, mimeType, content, "TASK_EVIDENCE", taskId); } - + private void writePart( java.io.ByteArrayOutputStream out, String name, String filename, String mimeType, byte[] content 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( 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..2e2c446 --- /dev/null +++ b/src/test/java/com/fowoco/server/reliability/config/OutboxPropertiesTest.java @@ -0,0 +1,43 @@ +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.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)) + ) + .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"); + } +}