Skip to content
Merged
35 changes: 30 additions & 5 deletions docs/database/postgresql-rls-rollout.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,19 @@ transaction-local tenant context와 connection pool 비누수 테스트를 준
JWT로 인증된 Worker·Task·Approval·Audit 업무 transaction은 요청 값이 아니라
`ActorContext.companyId`를 transaction-local context의 신뢰 원본으로 사용합니다.
H2는 PostgreSQL custom setting을 흉내 내지 않고 transaction 경계만 검증합니다.
`V10`에서 bootstrap 함수와 tenant 테이블 RLS policy를 생성했으며, RLS는 아직 활성화하지 않았습니다.
`V10`에서 공통 bootstrap 함수와 기존 tenant 테이블 policy를, `V13`에서 Worker Link
bootstrap 함수와 policy를, `V14`에서 AI 실행 테이블 policy를 생성했습니다. `V21`은
V20에서 추가한 직접 tenant 컬럼에 맞춰 Worker Link 업로드 policy를 단순화하고 업로드
멱등성 테이블 policy를 추가합니다. Worker Link bootstrap도 ACTIVE이면서 DB 시각 기준
미만료인 링크만 사업장을 반환하도록 제한합니다. RLS는 아직 활성화하지 않았습니다.

로그인·Refresh Token·Logout은 tenant context가 생기기 전 최소 bootstrap 조회가
필요합니다. Issue #34 작성 뒤 추가된 사업장 회원가입도 새 tenant 행을 처음 만드는
별도 bootstrap 흐름으로 함께 검토해야 합니다. Worker Link는 해당 기능이 구현된 뒤
같은 기준으로 확장합니다.
별도 bootstrap 흐름으로 함께 검토해야 합니다. Worker Link는 `V13`에서 같은 기준으로
확장했습니다.

현재 `main`의 V1~V9에는 `company_id`를 직접 보유한 아래 16개 tenant table과,
부모 초안의 tenant를 따르는 `document_request_draft_type`이 존재합니다. 기반 단계의
`V20` 적용 후에는 `company_id`를 직접 보유한 아래 tenant table과, 부모 초안의 tenant를
따르는 `document_request_draft_type`이 존재합니다. 기반 단계의
제한 role 테스트는 이 전체 범위에 업무 DML만 허용하고, table owner·DDL·`TRUNCATE`·
`REFERENCES` 권한과 RLS 우회 권한이 없음을 확인합니다.

Expand All @@ -38,6 +42,27 @@ H2는 PostgreSQL custom setting을 흉내 내지 않고 transaction 경계만
- `approval_request`, `external_submission`, `task_evidence`, `audit_event`
- `event_publication`, `event_consumption`
- `document_request_draft`, `document_request_draft_type`
- `worker_link`, `worker_response`, `worker_response_upload`
- `worker_document_upload_idempotency`
- `ai_run`, `ai_attempt`, `ai_question`, `ai_candidate`

### V20 최초 배포 전제

`V20`은 Worker Link 자식 테이블의 `company_id`를 backfill하고 `NOT NULL`, 복합
`UNIQUE`, tenant-aware 복합 FK를 한 번에 적용합니다. 또한 `worker_document`의 Task
참조를 `(task_id, worker_id, company_id)` 복합 FK로 전환합니다.

이 migration은 pre-V20 애플리케이션이 같은 DB에 계속 쓰는 상황과
backward-compatible하지 않습니다. 현재는 운영 DB·운영 트래픽·구버전 Pod가 없는 최초
배포 전이므로 이 전제를 충족하며 expand-contract migration을 적용하지 않습니다. 이
전제를 충족하지 않는 환경에 적용할 때는 쓰기 중단 또는 expand-contract 절차를 먼저
설계해야 합니다.

기존 개발·테스트 데이터는 신뢰할 수 있는 부모 관계에서 사업장을 복원합니다.
`worker_response_upload`는 `worker_response`,
`worker_document_upload_idempotency`는 `worker_link`를 기준으로 backfill합니다. 복원한
사업장이 `stored_file.company_id`와 다르거나 NULL·orphan·교차 tenant 관계가 남으면
migration을 실패시키며, 임의 사업장으로 보정하거나 행을 삭제하지 않습니다.

`document_request_draft_type`에는 `company_id`가 없으므로 부모
`document_request_draft`의 `draft_id`와 현재 tenant context를 확인하는 `EXISTS`
Expand Down
8 changes: 5 additions & 3 deletions docs/demo-seed-fixture-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,11 @@ PDF는 모두 합성 데이터이며 `DEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSIO
검증한다.

Demo Seed가 활성화되면 classpath PDF를 `app.file-storage.local-path`에 설치한다.
동일한 storage key의 파일은 크기와 SHA-256이 같을 때만 재사용하며, 다른 파일은
덮어쓰지 않고 시작을 중단한다. 파일만 남거나 DB row만 남은 상태는 동일 fixture인
경우 복구한다. 현재 installer는 `LocalFileStorage` 구성만 지원한다.
동일한 storage key의 일반 파일은 크기와 SHA-256이 같으면 재사용하고, classpath
fixture가 변경되었으면 현재 내용으로 원자적으로 교체한다. 디렉터리나 심볼릭 링크처럼
일반 파일이 아닌 경로는 덮어쓰지 않고 시작을 중단한다. DB row의 예약 ID·소유권·메타데이터
충돌도 기존 값을 덮어쓰지 않고 시작을 중단한다. 파일만 남거나 DB row만 남은 상태는
동일 fixture인 경우 복구한다. 현재 installer는 `LocalFileStorage` 구성만 지원한다.

## 근로자와 지원 언어

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
Expand All @@ -31,16 +31,16 @@ void install(StoredFileSeed seed) {
Path target = target(seed.storageKey());
try {
Files.createDirectories(rootDirectory);
if (Files.exists(target)) {
verify(target, expected, seed);
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
requireRegularFile(target, seed);
}
if (matches(target, expected)) {
return;
}
Path temporary = Files.createTempFile(rootDirectory, ".demo-fixture-", ".tmp");
try {
Files.write(temporary, expected);
moveWithoutOverwrite(temporary, target);
} catch (FileAlreadyExistsException exception) {
verify(target, expected, seed);
moveReplacing(temporary, target);
} finally {
Files.deleteIfExists(temporary);
}
Expand Down Expand Up @@ -76,24 +76,41 @@ private Path target(String storageKey) {
return target;
}

private void moveWithoutOverwrite(Path source, Path target) throws IOException {
private void moveReplacing(Path source, Path target) throws IOException {
try {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
Files.move(
source,
target,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING
);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(source, target);
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
}
}

private void verify(Path target, byte[] expected, StoredFileSeed seed) throws IOException {
if (!Files.isRegularFile(target)
|| Files.size(target) != expected.length
|| !Arrays.equals(sha256(Files.readAllBytes(target)), sha256(expected))) {
if (!matches(target, expected)) {
throw new IllegalStateException(
"a demo file storage key already contains different content: " + seed.storageKey()
);
}
}

private boolean matches(Path target, byte[] expected) throws IOException {
return Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)
&& Files.size(target) == expected.length
&& Arrays.equals(sha256(Files.readAllBytes(target)), sha256(expected));
}

private void requireRegularFile(Path target, StoredFileSeed seed) {
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
throw new IllegalStateException(
"a demo file storage key is not a regular file: " + seed.storageKey()
);
}
}

private byte[] sha256(byte[] content) {
try {
return MessageDigest.getInstance("SHA-256").digest(content);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
import com.fowoco.server.common.time.DatabaseTimestamp;
import com.fowoco.server.common.web.RequestMetadata;
import com.fowoco.server.file.application.port.StoredFileRepository;
import com.fowoco.server.task.application.error.TaskErrorCode;
import com.fowoco.server.task.application.port.TaskRepository;
import com.fowoco.server.task.domain.Task;
import com.fowoco.server.worker.application.error.WorkerErrorCode;
import com.fowoco.server.worker.application.port.WorkerDocumentRepository;
import com.fowoco.server.worker.application.port.WorkerRepository;
Expand All @@ -31,6 +34,7 @@ public class WorkerDocumentService {

private final WorkerDocumentRepository workerDocumentRepository;
private final WorkerRepository workerRepository;
private final TaskRepository taskRepository;
private final StoredFileRepository storedFileRepository;
private final AuditEventRepository auditRepository;
private final TenantDatabaseContext tenantDatabaseContext;
Expand All @@ -40,6 +44,7 @@ public class WorkerDocumentService {
public WorkerDocumentService(
WorkerDocumentRepository workerDocumentRepository,
WorkerRepository workerRepository,
TaskRepository taskRepository,
StoredFileRepository storedFileRepository,
AuditEventRepository auditRepository,
TenantDatabaseContext tenantDatabaseContext,
Expand All @@ -48,6 +53,7 @@ public WorkerDocumentService(
) {
this.workerDocumentRepository = workerDocumentRepository;
this.workerRepository = workerRepository;
this.taskRepository = taskRepository;
this.storedFileRepository = storedFileRepository;
this.auditRepository = auditRepository;
this.tenantDatabaseContext = tenantDatabaseContext;
Expand All @@ -60,6 +66,7 @@ public WorkerDocument register(WorkerDocumentCreateCommand command, ActorContext
bindTenant(actor);
workerRepository.findByWorkerIdAndCompanyId(command.workerId(), actor.companyId())
.orElseThrow(() -> new ApiException(WorkerErrorCode.WORKER_NOT_FOUND));
validateTaskAssignment(command, actor.companyId());

WorkerDocument document = WorkerDocument.create(
uuidGenerator.generate(),
Expand Down Expand Up @@ -154,6 +161,17 @@ private UUID resolveFileId(UUID requestedFileId, UUID companyId, UUID existingFi
return requestedFileId;
}

private void validateTaskAssignment(WorkerDocumentCreateCommand command, UUID companyId) {
if (command.taskId() == null) {
return;
}
Task task = taskRepository.findByIdAndCompanyId(command.taskId(), companyId)
.orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND));
if (!task.workerId().equals(command.workerId())) {
throw new ApiException(WorkerErrorCode.WORKER_DOCUMENT_TASK_WORKER_MISMATCH);
}
}

private void appendAudit(
ActorContext actor,
AuditAction action,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ public enum WorkerErrorCode implements ApiErrorCode {
WORKER_VERSION_CONFLICT(HttpStatus.CONFLICT, "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요."),
WORKER_DOCUMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "서류를 찾을 수 없습니다."),
WORKER_DOCUMENT_VERSION_CONFLICT(HttpStatus.CONFLICT, "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요."),
WORKER_DOCUMENT_FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "연결할 파일을 찾을 수 없습니다.");
WORKER_DOCUMENT_FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "연결할 파일을 찾을 수 없습니다."),
WORKER_DOCUMENT_TASK_WORKER_MISMATCH(
HttpStatus.UNPROCESSABLE_CONTENT,
"업무카드의 근로자와 서류의 근로자가 일치하지 않습니다."
);

private final HttpStatus status;
private final String defaultMessage;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,11 @@ public ResponseEntity<WorkerLinkIssueResponse> issue(
ActorContext actor = actorContextProvider.requireCurrentActor();
WorkerLinkIssueCommand command = new WorkerLinkIssueCommand(
taskId,
actor.companyId(),
request.getExpiresInHours(),
request.isRotateExisting(),
actor.actorId(),
idempotencyKey
);
WorkerLinkIssueResult result = workerLinkService.issue(command);
WorkerLinkIssueResult result = workerLinkService.issue(command, actor);
return ResponseEntity.status(HttpStatus.CREATED).body(WorkerLinkIssueResponse.from(result));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ public WorkerLinkDocumentUploadResult upload(WorkerLinkDocumentUploadCommand com
}

Optional<UUID> existingStoredFileId = uploadIdempotencyRepository
.findStoredFileId(link.workerLinkId(), command.clientRequestId());
.findStoredFileId(link.workerLinkId(), companyId, command.clientRequestId());
if (existingStoredFileId.isPresent()) {
StoredFile existingFile = storedFileRepository.findByIdAndCompanyId(existingStoredFileId.get(), companyId)
.orElseThrow(() -> new ApiException(WorkerLinkErrorCode.UPLOAD_NOT_AVAILABLE));
Expand Down Expand Up @@ -130,7 +130,12 @@ public WorkerLinkDocumentUploadResult upload(WorkerLinkDocumentUploadCommand com

fileStorage.store(storageKey, command.content(), command.size(), command.mimeType());
storedFileRepository.insert(verifiedFile);
uploadIdempotencyRepository.save(link.workerLinkId(), command.clientRequestId(), storedFileId);
uploadIdempotencyRepository.save(
link.workerLinkId(),
companyId,
command.clientRequestId(),
storedFileId
);

auditRepository.append(new AuditEvent(
uuidGenerator.generate(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,36 +5,26 @@
public final class WorkerLinkIssueCommand {

private final UUID taskId;
private final UUID companyId;
private final Long expiresInHours;
private final boolean rotateExisting;
private final UUID issuedBy;
private final String idempotencyKey;

public WorkerLinkIssueCommand(
UUID taskId,
UUID companyId,
Long expiresInHours,
boolean rotateExisting,
UUID issuedBy,
String idempotencyKey
) {
this.taskId = taskId;
this.companyId = companyId;
this.expiresInHours = expiresInHours;
this.rotateExisting = rotateExisting;
this.issuedBy = issuedBy;
this.idempotencyKey = idempotencyKey;
}

public UUID taskId() {
return taskId;
}

public UUID companyId() {
return companyId;
}

public Long expiresInHours() {
return expiresInHours;
}
Expand All @@ -43,10 +33,6 @@ public boolean rotateExisting() {
return rotateExisting;
}

public UUID issuedBy() {
return issuedBy;
}

public String idempotencyKey() {
return idempotencyKey;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import com.fowoco.server.approval.application.port.ApprovalRequestRepository;
import com.fowoco.server.approval.domain.ApprovalRequest;
import com.fowoco.server.auth.application.ActorContext;
import com.fowoco.server.common.error.ApiException;
import com.fowoco.server.common.id.UuidGenerator;
import com.fowoco.server.task.application.error.TaskErrorCode;
import com.fowoco.server.common.security.TenantDatabaseContext;
import com.fowoco.server.task.application.port.TaskRepository;
import com.fowoco.server.task.domain.Task;
import com.fowoco.server.workerlink.application.error.WorkerLinkErrorCode;
Expand All @@ -29,6 +30,7 @@ public class WorkerLinkService {
private final WorkerLinkRepository workerLinkRepository;
private final WorkerLinkGenerator workerLinkGenerator;
private final WorkerLinkHasher workerLinkHasher;
private final TenantDatabaseContext tenantDatabaseContext;
private final UuidGenerator uuidGenerator;
private final Clock clock;

Expand All @@ -38,6 +40,7 @@ public WorkerLinkService(
WorkerLinkRepository workerLinkRepository,
WorkerLinkGenerator workerLinkGenerator,
WorkerLinkHasher workerLinkHasher,
TenantDatabaseContext tenantDatabaseContext,
UuidGenerator uuidGenerator,
Clock clock
) {
Expand All @@ -46,17 +49,20 @@ public WorkerLinkService(
this.workerLinkRepository = workerLinkRepository;
this.workerLinkGenerator = workerLinkGenerator;
this.workerLinkHasher = workerLinkHasher;
this.tenantDatabaseContext = tenantDatabaseContext;
this.uuidGenerator = uuidGenerator;
this.clock = clock;
}

@Transactional
public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command) {
Task task = taskRepository.findByIdAndCompanyId(command.taskId(), command.companyId())
public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command, ActorContext actor) {
tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId());

Task task = taskRepository.findByIdAndCompanyId(command.taskId(), actor.companyId())
.orElseThrow(() -> new ApiException(WorkerLinkErrorCode.TASK_NOT_FOUND));

ApprovalRequest approval = approvalRequestRepository
.findLatestApprovedByTaskIdAndCompanyId(command.taskId(), command.companyId())
.findLatestApprovedByTaskIdAndCompanyId(command.taskId(), actor.companyId())
.orElseThrow(() -> new ApiException(WorkerLinkErrorCode.TASK_NOT_APPROVED));

if (!approval.isValidFor(task.contentRevision(), task.criticalFingerprint())) {
Expand All @@ -73,7 +79,7 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command) {

Instant now = clock.instant();
Optional<WorkerLink> existingActive = workerLinkRepository
.findActiveByTaskIdAndCompanyId(command.taskId(), command.companyId());
.findActiveByTaskIdAndCompanyId(command.taskId(), actor.companyId());

WorkerLink previousLink = null;
if (existingActive.isPresent()) {
Expand All @@ -92,10 +98,10 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command) {
WorkerLink workerLink = WorkerLink.issue(
uuidGenerator.generate(),
command.taskId(),
command.companyId(),
actor.companyId(),
generated.tokenHash(),
expiresAt,
command.issuedBy(),
actor.actorId(),
previousLink != null ? previousLink.workerLinkId() : null,
idempotencyKeyHash,
now
Expand Down
Loading
Loading