Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/database-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
6 changes: 4 additions & 2 deletions docs/database-documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ PostgreSQL에 처음부터 적용한 결과로 생성합니다.

- 팀 공유 사이트: <https://fowoco.github.io/server/>
- API Swagger 문서: <https://fowoco.github.io/server/api/>
- 변경의 원본: `src/main/resources/db/migration`
- 공통 변경의 원본: `src/main/resources/db/migration`
- PostgreSQL 전용 변경의 원본: `src/main/resources/db/migration-postgresql`
- 구조 결정의 원본: `docs/adr`

문서는 구조를 쉽게 찾기 위한 보조 수단입니다. 문서 화면에서 DB를 변경할 수
Expand All @@ -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
```
Expand All @@ -37,7 +39,7 @@ Workflow는 다음 순서로 동작합니다.

```text
빈 PostgreSQL 시작
→ Flyway migrate
공통·PostgreSQL 전용 Flyway migrate
→ Flyway validate
→ SchemaSpy HTML 생성
→ Migration 이력 페이지 생성
Expand Down
42 changes: 30 additions & 12 deletions docs/database/postgresql-rls-rollout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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는 해당 기능이 구현된 뒤
같은 기준으로 확장합니다.
Comment thread
chaeliki marked this conversation as resolved.

현재 `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하지 못할 수
Expand Down Expand Up @@ -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

Expand Down
19 changes: 15 additions & 4 deletions scripts/db-docs/generate-site.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'), '<html>SchemaSpy</html>')
await writeFile(infoFile, JSON.stringify({
schemaVersion: '5',
schemaVersion: '10',
schemaName: 'public',
flywayVersion: '12.4.0',
migrations: [
Expand All @@ -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',
},
],
}))

Expand All @@ -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 &lt;safe&gt;/)
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,
})
Expand Down
19 changes: 14 additions & 5 deletions scripts/db-docs/generate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -92,22 +101,22 @@ 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

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 \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -53,6 +55,7 @@ public class ApprovalService implements ApprovalControlPort {

public ApprovalService(
ActorAuthorizer actorAuthorizer,
TenantDatabaseContext tenantDatabaseContext,
TaskRepository taskRepository,
TaskTransitionRecorder transitionRecorder,
TaskReadinessChecker taskReadinessChecker,
Expand All @@ -65,6 +68,7 @@ public ApprovalService(
Clock clock
) {
this.actorAuthorizer = actorAuthorizer;
this.tenantDatabaseContext = tenantDatabaseContext;
this.taskRepository = taskRepository;
this.transitionRecorder = transitionRecorder;
this.taskReadinessChecker = taskReadinessChecker;
Expand All @@ -84,6 +88,7 @@ public ApprovalResult requestApproval(
ActorContext actor,
RequestMetadata metadata
) {
bindTenant(actor);
actorAuthorizer.requireHrWrite(actor);
approvalRepository.findPendingByTaskIdAndCompanyId(taskId, actor.companyId())
.ifPresent(ignored -> {
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -207,6 +214,7 @@ public TaskActionResult recordExternalSubmission(
ActorContext actor,
RequestMetadata metadata
) {
bindTenant(actor);
actorAuthorizer.requireHrWrite(actor);
Task task = requireTask(taskId, actor.companyId());
requireValidApproval(task);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Expand All @@ -355,6 +366,7 @@ public void invalidateForCriticalChange(
Instant occurredAt,
RequestMetadata metadata
) {
bindTenant(actor);
actorAuthorizer.requireHrWrite(actor);
Task task = requireTask(taskId, actor.companyId());
List<ApprovalRequest> active = invalidateActiveApprovals(
Expand Down Expand Up @@ -384,6 +396,7 @@ public Task replaceReviewAfterCriticalChange(
Instant occurredAt,
RequestMetadata metadata
) {
bindTenant(actor);
actorAuthorizer.requireHrWrite(actor);
Task task = requireTask(taskId, actor.companyId());
List<ApprovalRequest> invalidated = invalidateActiveApprovals(
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,24 +26,28 @@ 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;
}

@Transactional(readOnly = true)
public List<AuditEventView> 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));
Expand All @@ -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);
Expand Down Expand Up @@ -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());
}
}
Loading
Loading