Skip to content

feat: PostgreSQL RLS 기반 사업장 2차 격리 활성화 - #135

Merged
krestar merged 12 commits into
mainfrom
feat/34-enable-psql-rls
Aug 18, 2026
Merged

feat: PostgreSQL RLS 기반 사업장 2차 격리 활성화#135
krestar merged 12 commits into
mainfrom
feat/34-enable-psql-rls

Conversation

@krestar

@krestar krestar commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

현재 상태: ** merge 대기 중**

merge 가능한 상태지만,
서버에 부담이 큰 작업일 수 있어 일정 조율 후 진행합니다.

왜 필요한가요?

현재의 ActorContext + company_id 범위 Repository + tenant-aware DB 제약을 유지하면서,
PostgreSQL이 다른 사업장의 행을 한 번 더 차단하도록 준비된 RLS 정책을 실제로 활성화합니다.

이 PR은 RLS가 애플리케이션 권한 검사나 Repository의 company_id 조건을 대신하게 하지 않습니다.
애플리케이션 코드에 범위 조건이 누락되더라도 runtime DB role에서 다른 사업장 행을 조회·생성·수정·삭제할 수 없도록 DB 차단 계층을 추가합니다.

main merge 즉시 배포와 수동 Smoke Test가 이어지므로, PR merge만으로 Issue를 자동 종료하지 않습니다.
live 검증까지 끝난 후 #34를 수동으로 닫습니다.

무엇이 바뀌나요?

DB migration

  • PostgreSQL 전용 V58__enable_postgresql_rls.sql을 추가합니다.
  • tenant 정책이 준비된 40개 테이블ENABLE ROW LEVEL SECURITY를 적용합니다.
  • FORCE ROW LEVEL SECURITY는 적용하지 않습니다.
    • 애플리케이션 runtime role은 table owner가 아니며 RLS 적용 대상입니다.
    • Flyway migration role과 table owner의 migration 수행 경계는 유지합니다.
  • migration transaction에 다음 안전 제한을 둡니다.
    • lock_timeout = 5s
    • statement_timeout = 30s
  • 컬럼·데이터·API 계약·production Java 코드는 변경하지 않습니다.

테스트 계약

  • PostgreSQL schema 테스트에서 다음을 정확히 검증합니다.
    • 정책이 존재하는 테이블: 40개
    • relrowsecurity = true인 테이블: 동일한 40개
    • relforcerowsecurity = true인 테이블: 0개
  • 기존 notification에 더해 최신 main에서 추가된 stay_verification_case, worker_archive까지 RLS 활성화 대상에 포함합니다.
  • RLS 활성화 이후 timeout 테스트가 tenant context 없이 UPDATE 0 rows로 통과하는 false positive를 막습니다.
    • runtime transaction마다 fixture company를 transaction-local tenant context로 설정합니다.
    • 실제 update affected row가 1인지 검증합니다.

영향받는 테이블

40개입니다.

  • 인증·회사: company, company_settings, user_account, refresh_token, user_agreement_consent, password_reset_token
  • 근로자·업무·문서: worker, worker_document, stored_file, task, task_checklist_item, task_transition_history, document_request_draft, document_request_draft_type, approval_request, external_submission, task_evidence, audit_event, workflow_case, document_ocr_run, notification, stay_verification_case, worker_archive
  • Worker Link·Import: worker_link, worker_response, worker_response_upload, worker_document_upload_idempotency, worker_import_job, worker_import_row, worker_import_commit_idempotency
  • AI: ai_run, ai_attempt, ai_question, ai_candidate, ai_candidate_decision_batch, ai_candidate_decision, ai_candidate_decision_task
  • Background/Outbox: event_publication, event_consumption, outbox_manual_retry

어떻게 검증했나요?

기존 자동 테스트 기록

PostgreSQL 16.14를 사용한 기존 로컬 test report에서는 다음 결과를 확인했습니다.

test suites: 107
tests: 485
failures: 0
errors: 0
skipped: 0

실행 명령:

$env:POSTGRES_TEST_ENABLED = "true"
$env:POSTGRES_TEST_URL = "jdbc:postgresql://localhost:5432/fowoco_test"
$env:POSTGRES_TEST_USERNAME = "postgres"
$env:POSTGRES_TEST_PASSWORD = "<local-test-password>"
./gradlew.bat clean test

주요 PostgreSQL 검증 결과:

  • PostgreSqlMigrationTests: 1/1 통과
  • PostgreSqlRuntimeTimeoutBehaviorIntegrationTest: 3/3 통과
  • PostgreSqlRestrictedRoleHttpE2ETest: 7/7 통과
  • PostgreSqlRlsIsolationTest: 1/1 통과
  • PostgreSqlTenantDatabaseContextTest: 7/7 통과
  • OutboxIntegrationTest: 6/6 통과
  • AuthRefreshPostgreSqlConcurrencyTest: 1/1 통과

restricted-role negative probe에서 기록되는 SQLSTATE 42501은 의도한 access-denied 결과입니다.

위 수치는 기존 검증 기록입니다. 현재 migration 번호는 V58이며 최신 main에는 V57까지의 migration과 40개 RLS 대상이 준비되어 있습니다.
#204가 merge된 최신 main을 반영한 뒤 새 PostgreSQL DB/schema에서 전체 Flyway migration, validate, 제한 Runtime Role, Demo Seed 재기동 및 전체 테스트를 다시 검증해야 합니다.
Gradle clean은 외부 PostgreSQL DB를 초기화하지 않습니다.

기존 수동 RLS 검증에서 확인한 Seed 충돌

과거 38개 정책 대상 상태에서 동일한 RLS 활성화 SQL을 수동 적용해 Seed/RLS 충돌과 runtime 동작을 확인했습니다.
이 기록은 실패 원인에 대한 근거로 유지하되, 현재 V58/40개 대상의 최종 migration 검증을 대신하지 않습니다.

  1. PostgreSQL 16 컨테이너에 fowoco_migrationfowoco_runtime을 분리해 생성했습니다.
  2. RLS 비활성 상태에서 DEMO_SEED_ENABLED=true로 기존 Demo/Test fixture를 생성했습니다.
  3. RLS 적용 전 policy가 준비되어 있고 RLS enabled table은 0개임을 확인했습니다.
  4. runtime에 필요한 bootstrap 함수 EXECUTE 권한을 부여했습니다.
  5. 같은 DB에 RLS 활성화 SQL을 적용했습니다.
  6. DEMO_SEED_ENABLED=true 재기동이 아래 원인으로 실패함을 확인했습니다.
    • tenant context 없는 seed 조회가 기존 company 행을 보지 못함
    • seed가 신규 company insert를 시도함
    • RLS WITH CHECK가 SQLSTATE 42501로 차단함
  7. 같은 RLS 활성 DB에서 DEMO_SEED_ENABLED=false, outbox enabled 상태로 정상 기동했습니다.

이 실패를 근본적으로 해결하는 선행 작업이 #204입니다.

수동 runtime Smoke Test

  • Demo/Test 회사 계정으로 각각 login 및 /api/v1/auth/me 성공
  • Refresh Token 재발급과 logout 성공
  • 신규 signup 성공
  • Swagger UI에서 인증 흐름 확인
  • Client /documents에서 Demo/Test 계정별 worker/task 격리 확인
  • runtime role의 context 없는 company 조회가 0건임을 확인
  • Demo company context에서는 Demo company 행만 조회됨을 확인
  • Test company context에서는 Test company 행만 조회됨을 확인
  • bootstrap_company_id_by_normalized_email, bootstrap_company_id_by_refresh_token_hash, bootstrap_claim_event_publications 호출에 permission/RLS 오류가 없음을 확인
  • live catalog query와 동일한 방식으로 필요한 bootstrap 함수의 runtime EXECUTE 권한 확인
  • outbox claim scheduler의 반복 호출에 permission denied, SQLSTATE 42501, scheduled-task 오류가 없음을 확인

현재 수동 로그의 outbox 검증은 빈 polling 경로까지 확인했습니다.
Merge 전 격리 환경에서는 실제 event 1건의 claim → tenant context 설정 → handler → complete까지 추가로 확인합니다.

별도 관찰 사항

GET /api/v1/notifications를 cursor 없이 호출할 때 PostgreSQL이 nullable cursor placeholder 타입을 추론하지 못하는
SQLSTATE 42P18이 관찰됐습니다.
RLS, bootstrap 권한, outbox와 독립적인 notification 조회 쿼리 문제이며 이 PR에서는 수정하지 않습니다.

Demo Seed 선행 작업 — #204

현재 dev 배포 환경은 DEMO_SEED_ENABLED=true이며 기존 PostgreSQL PVC에 Demo/Test fixture가 저장되어 있습니다.
현재 main의 Demo Seed runner는 RLS 활성 상태의 제한 Runtime Role과 호환되지 않기 때문에 #204를 이 PR보다 먼저 개발·배포·검증합니다.

#204는 다음을 목표로 합니다.

  • Demo/Test 회사별 transaction 분리
  • transaction 시작 직후 기존 TenantDatabaseContext를 통한 app.company_id 설정
  • transaction-local context로 connection pool 누출 방지
  • 기존 fixture와 DB/PVC를 유지한 동일 DB 재기동 멱등성 보장
  • Migration Role과 제한 Runtime Role을 실제 credential 수준에서 분리한 PostgreSQL 검증
  • RLS OFF 기존 fixture → RLS ON + 제한 Runtime Role → 같은 DB 재기동 전환 시나리오 검증

현재 선택한 배포 경로는 다음과 같습니다.

  1. [Demo][Reliability] RLS 환경에서 Demo Seed tenant context·transaction 경계 호환 #204 구현 PR을 먼저 merge합니다.
  2. RLS가 아직 비활성인 현재 dev DB/PVC에 #204를 먼저 배포합니다.
  3. DEMO_SEED_ENABLED=true 상태에서 기존 DB/PVC 재기동 회귀와 멱등성을 확인합니다.
  4. 최신 main + [Demo][Reliability] RLS 환경에서 Demo Seed tenant context·transaction 경계 호환 #204 + feat: PostgreSQL RLS 기반 사업장 2차 격리 활성화 #135 조합을 격리된 PostgreSQL DB에서 리허설합니다.
  5. DEMO_SEED_ENABLED=true + 제한 Runtime Role + RLS enabled 최초/재기동, tenant 격리, fixture 멱등성을 확인합니다.
  6. 위 검증이 통과한 경우에만 #135를 merge합니다.

#204가 배포·검증되기 전에 #135를 먼저 배포해야 하는 긴급 상황에서는 DEMO_SEED_ENABLED=false를 임시 우회책으로 사용할 수 있습니다.
이 경우 기존 PVC 데이터는 유지되지만 fixture 자동 복구·보충은 중단되며, #204 검증 전에는 Seed를 다시 true로 전환하지 않습니다.

현재 migration 순서와 선행 관계

Merge 조건

아래 항목을 모두 충족하기 전에는 merge하지 않습니다.

코드·migration

Seed

  • #204가 현재 dev 환경에 먼저 배포됨
  • RLS 비활성 기존 DB/PVC에서 DEMO_SEED_ENABLED=true 재기동 회귀 성공
  • 격리 PostgreSQL에서 RLS OFF 기존 fixture → RLS ON + 제한 Runtime Role 전환 후 Seed 재기동 성공
  • 동일 RLS DB의 추가 재기동 성공 및 fixture 중복 0건
  • Demo/Test tenant 격리와 transaction-local context 누출 없음
  • 기존 파일 fixture의 재기동 회귀 없음

live DB preflight

  • live flyway_schema_history에서 현재 main의 migration 성공 상태와 V58 미적용 확인
  • 애플리케이션 pool 연결이 실제 제한 Runtime Role을 사용하는지 current_user로 확인
  • runtime role: rolsuper=false, rolbypassrls=false, rolcreaterole=false, rolcreatedb=false
  • runtime role이 40개 보호 테이블의 owner가 아님
  • runtime role이 migration role member가 아님
  • runtime role에 필요한 table DML·sequence·bootstrap 함수 권한만 있고 TRUNCATE, REFERENCES, DDL 권한이 없음
  • 필요한 bootstrap 함수의 runtime EXECUTE=true
  • 기존 PVC에서는 initdb/bootstrap이 다시 실행되지 않으므로 live catalog 결과로 실제 권한 확인
  • V58 적용 전 policy table 40개, RLS enabled table 0개 확인

rollout 준비

  • 배포 담당자와 낮은 트래픽 시간대 확정
  • rollout 시간 동안 다른 main merge 중지
  • DB snapshot/복구 지점 확인
  • V58과 동일한 40개 테이블을 DISABLE ROW LEVEL SECURITY하는 forward rollback patch 준비 및 리뷰
  • rollback migration 번호는 merge 직전 다음 사용 가능한 Flyway version으로 재확인
  • health endpoint만이 아니라 아래 수동 Smoke Test 담당자 배정
  • 실제 outbox event 1건의 claim → handler → complete 검증 방법 준비

배포 절차

server/.github/workflows/deploy.ymlmain push마다 자동 배포합니다.
따라서 merge가 곧 live DB migration 시작 버튼입니다.

1. Merge 전

  1. #204가 먼저 merge·배포되고 현재 dev DB/PVC에서 DEMO_SEED_ENABLED=true 재기동 회귀가 통과했는지 확인합니다.
  2. 최신 main + [Demo][Reliability] RLS 환경에서 Demo Seed tenant context·transaction 경계 호환 #204 + feat: PostgreSQL RLS 기반 사업장 2차 격리 활성화 #135 조합의 격리 PostgreSQL 리허설이 통과했는지 확인합니다.
  3. 다른 main merge를 중지합니다.
  4. DB snapshot 또는 복구 지점을 확인합니다.
  5. live catalog preflight 결과를 PR 또는 배포 기록에 남깁니다. Secret 값과 credential은 기록하지 않습니다.
  6. 배포 담당자, Smoke Test 담당자, rollback 담당자가 모두 준비된 뒤 PR을 merge합니다.

긴급 우회로 Seed를 비활성화해야 하는 경우에는 Secret 변경 후 기존 Pod를 먼저 재시작해 실제 환경변수 적용과 기존 Demo/Test 데이터 보존을 확인합니다.

kubectl -n fowoco patch secret server-env \
  --type merge \
  -p '{"stringData":{"DEMO_SEED_ENABLED":"false"}}'

kubectl -n fowoco rollout restart deployment/server
kubectl -n fowoco rollout status deployment/server --timeout=180s
kubectl -n fowoco exec deployment/server -- printenv DEMO_SEED_ENABLED

2. Merge 및 V58 적용

  1. PR merge 직후 deploy workflow를 단독으로 모니터링합니다.
  2. 동일 시간대에 다른 workflow 재실행이나 main merge를 하지 않습니다.
  3. 새 Pod의 Flyway 로그에서 현재 schema version과 V58 적용 성공을 확인합니다.
  4. migration은 40개 table에 lock을 요청합니다. lock_timeout=5s 또는 statement_timeout=30s로 실패하면 원인을 확인한 뒤 별도 시간대에 재시도합니다.
  5. migration transaction이 실패했을 때 부분 활성화가 남지 않았는지 catalog로 확인합니다.

3. 배포 후 Smoke Test

자동 health check 외에 다음을 제한 Runtime Role로 확인합니다.

  • readiness와 /health 200
  • signup
  • Demo/Test login
  • /api/v1/auth/me
  • refresh 및 logout
  • Demo/Test tenant A/B worker/task/document 격리
  • Worker Link bootstrap·조회·저장
  • context 없는 보호 table 접근 fail-closed
  • actual outbox event claim → handler → complete
  • permission denied, 예상하지 않은 SQLSTATE 42501, RLS policy violation, scheduled-task 오류 없음
  • Flyway version V58, policy table 40, RLS enabled table 40, FORCE table 0
  • DEMO_SEED_ENABLED=true 상태의 동일 DB 재기동 성공
  • 재기동 후 Demo/Test 주요 fixture 중복 없음

장애 대응·rollback

  • Seed 관련 기동 실패면 먼저 DEMO_SEED_ENABLED의 실제 새 Pod 값을 확인합니다.
  • 이미 적용된 V58 파일을 수정하거나 checksum을 바꾸지 않습니다.
  • 공유 DB에서 flyway clean, schema history 수동 수정, flyway repair로 되돌리지 않습니다.
  • RLS 때문에 핵심 흐름이 중단되면 다음 사용 가능한 Flyway version으로 준비한 forward rollback migration을 배포합니다.
    • V58과 동일한 40개 테이블에 DISABLE ROW LEVEL SECURITY를 적용합니다.
    • migration 번호는 merge 직전 최신 migration 상태를 기준으로 결정합니다.
    • Repository의 company_id 범위와 tenant-aware 제약은 그대로 유지합니다.
  • 긴급 수동 DISABLE ROW LEVEL SECURITY는 배포 담당자 승인과 실행 기록이 있는 최후 수단으로만 사용하고,
    이후 동일 상태를 표현하는 forward migration을 반드시 추가합니다.
  • DB에 V58이 적용된 뒤에는 서버 image만 이전 버전으로 되돌리는 것을 RLS rollback으로 간주하지 않습니다.
  • rollback 후 health, login/refresh, tenant A/B, outbox, Demo Seed 재기동 Smoke Test를 다시 수행합니다.

보안·개인정보

  • API response, DTO, 로그에 credential·JWT·원본 token·비밀번호를 추가하지 않음
  • runtime role은 RLS 우회 권한이나 table ownership을 전제로 하지 않음
  • tenant context 누락 시 fail-closed
  • 기존 Repository company_id 조건과 tenant-aware FK/UNIQUE를 유지
  • Accepted ADR-0004의 migration/runtime role 분리 원칙 유지
  • API·OpenAPI 계약 변경 없음

API·DB·운영 영향

- #125의 V40/V41 다음 V42 migration으로 tenant 보호 테이블 38개의 RLS를 활성화
- migration의 lock timeout과 statement timeout을 설정
- 정책 테이블, RLS 활성 테이블, FORCE 미적용 상태를 schema 테스트로 검증
- RLS 환경에서 runtime timeout 테스트가 실제 tenant context로 UPDATE를 수행하도록 보정
@krestar krestar added the area:infra Server Dockerfile·DB 설정·CI hook·배포 가능성 영역; 통합 인프라 운영은 infra 저장소와 조율 label Aug 11, 2026
@krestar
krestar marked this pull request as draft August 11, 2026 05:10
@BcKmini
BcKmini requested review from BcKmini and removed request for BcKmini August 11, 2026 07:16
@hywznn

hywznn commented Aug 12, 2026

Copy link
Copy Markdown
Member

Migration 번호 조율 공유드립니다.

제가 진행 중인 담당자 변경 PR #143의 공통 Migration에서 V42를 사용하기로 했습니다. 이 PR의 PostgreSQL 전용 V42__enable_postgresql_rls.sql은 **V43__enable_postgresql_rls.sql**로 변경 부탁드립니다.

권장 병합 순서는 다음과 같습니다.

  1. #143의 공통 V42 병합
  2. 이 브랜치에 최신 main 반영
  3. RLS Migration을 V43으로 변경
  4. 전체 테스트와 PostgreSQL Migration·RLS 테스트 확인 후 feat: PostgreSQL RLS 기반 사업장 2차 격리 활성화 #135 병합

서로 다른 migration 경로라도 Flyway version은 함께 비교되므로 V42가 겹치지 않도록 조정이 필요합니다.

@krestar

krestar commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

RLS 활성화 시점 가이드

  1. main repo 기준으로 가장 최신 다음 버전의 migration 버전을 사용합니다.
    2.활성화 전에, RLS 활성화가 필요한 테이블이 전부 반영됐는지 다시 확인합니다.
    3.배포 환경에서 데모 시드를 꺼본 후 재부팅합니다. 그래도 데모 시드들이 잘 남아있나 확인합니다.
    4.해당 준비들이 완료되면, PR 본문에 작성된대로 수행해봅니다.
    5.배포 후에도 테이블과 관련된 작업들이 잘 작동하는지 확인해봅니다.

@hywznn

hywznn commented Aug 16, 2026

Copy link
Copy Markdown
Member

Server #198에서 tenant table stay_verification_casepl_stay_verification_tenant_isolation policy가 추가됩니다. 이 PR을 최신 main에 갱신할 때 RLS 활성화 대상과 PostgreSQL 격리 테스트 목록에도 stay_verification_case를 포함해 주세요. #198 자체는 policy를 준비하지만 현재 main 운영 원칙대로 RLS를 선행 활성화하지 않습니다.

@hywznn

hywznn commented Aug 16, 2026

Copy link
Copy Markdown
Member

#199가 추가한 worker_archive도 RLS 활성화 대상에 포함해 주세요. 준비된 정책명은 pl_worker_archive_tenant_isolation이며, #198의 stay_verification_case 다음에 함께 활성화·격리 테스트하면 됩니다.

# Conflicts:
#	src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java
- RLS 활성화 migration을 최신 main 다음 버전인 V58로 재번호화
- stay_verification_case와 worker_archive를 RLS 활성화 대상에 추가
- 제한 runtime role 격리 테스트에 두 테이블의 fixture, 조회 격리, 쓰기 차단, cleanup 검증 추가
@krestar
krestar marked this pull request as ready for review August 17, 2026 10:31
@krestar
krestar merged commit 3a3ed9c into main Aug 18, 2026
4 checks passed
@krestar
krestar deleted the feat/34-enable-psql-rls branch August 18, 2026 04:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:infra Server Dockerfile·DB 설정·CI hook·배포 가능성 영역; 통합 인프라 운영은 infra 저장소와 조율

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants