Skip to content

feat(security): PostgreSQL RLS tenant 격리 기반 구축 - #65

Merged
krestar merged 11 commits into
mainfrom
feat/34-rls-bootstrap-context
Jul 29, 2026
Merged

feat(security): PostgreSQL RLS tenant 격리 기반 구축#65
krestar merged 11 commits into
mainfrom
feat/34-rls-bootstrap-context

Conversation

@krestar

@krestar krestar commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

왜 필요한가요?

PostgreSQL RLS를 안전하게 도입하려면 인증된 업무 트랜잭션에 tenant context를 전달하고, 로그인·refresh·Outbox처럼 일반 tenant context를 바로 적용할 수 없는 경계를 제한된 bootstrap 함수로 분리해야 합니다.
또한 실제 활성화 전에 제한된 runtime role에서 회사 간 접근 차단과 connection pool 비누수를 검증해야 합니다.
Outbox claim부터 handler 실행, 완료 및 실패 처리까지 lease 판단에 동일한 시간 기준을 사용해야 합니다.
PostgreSQL 환경에서는 JVM clock skew의 영향을 받지 않도록 Outbox lifecycle 전체의 기준 시각을 DB statement_timestamp()로 통일합니다.

무엇이 바뀌나요?

  • API·도메인·DB 변경:

    • API 및 도메인 계약 변경은 없습니다.
    • PostgreSQL 전용 V10__prepare_postgresql_rls.sql migration을 추가했습니다.
    • 17개 tenant 테이블의 RLS policy를 정의하되 실제 RLS는 활성화하지 않았습니다.
    • 로그인·refresh token·Outbox claim 및 backlog 조회에 필요한 payload-free bootstrap 함수를 추가했습니다.
    • bootstrap 함수의 PUBLIC 실행 권한을 제거했습니다.
    • Outbox claim 함수가 애플리케이션에서 전달한 현재 시각을 신뢰하지 않고, DB statement_timestamp()로 claim 가능 여부와 lease 만료 시각을 계산하도록 변경했습니다.
    • claim owner, lease duration, batch size, max attempts를 DB 함수 경계에서 검증하며 잘못된 입력은 SQLSTATE 22023으로 거부합니다.
    • lease duration은 애플리케이션과 DB 양쪽에서 1ms 이상 1일 이하로 제한합니다.
  • 권한·Workflow 변경:

    • 인증된 업무 요청의 트랜잭션 시작 시 JWT의 company_id를 transaction-local tenant context로 설정합니다.
    • 로그인과 refresh처럼 tenant context가 만들어지기 전의 인증 흐름을 제한된 bootstrap 함수에 연결했습니다.
    • Outbox claim 이후 확인된 company_id를 transaction-local tenant context로 설정하고 해당 회사 범위 안에서 처리하도록 변경했습니다.
    • PostgreSQL에서는 claim, handler, completion, failure의 lease 판단 및 상태 변경 시각을 DB statement_timestamp()로 통일했습니다.
    • handler, completion, failure 처리에서는 tenant 행 잠금을 획득한 뒤 현재 DB 시각을 조회하여 lock 대기 중 발생할 수 있는 시간 오차를 방지합니다.
    • 로컬 및 H2 환경에서는 기존 Clock 기반 시간 소스를 유지합니다.
    • tenant context가 없거나 유효하지 않은 경우 fail-closed하도록 구성했습니다.
    • 전체 17개 policy의 생성 계약을 검증하고, 대표적인 직접 tenant 테이블과 부모 tenant를 따르는 자식 테이블 5개에 대해 제한된 runtime role 기반 CRUD 격리를 검증했습니다.
    • Workflow 상태 및 전이 규칙 변경은 없습니다.
  • AI·외부 연동 변경:

    • 변경 없습니다.
  • 문서·배포 변경:

    • RLS rollout 및 DB 문서화 문서를 갱신했습니다. PostgreSQL 전용 migration 경로를 DB 문서화 workflow와 생성 스크립트에 추가했습니다.
    • 실제 DB role 생성, Secret 주입 및 운영 RLS 활성화는 이번 PR 범위에 포함하지 않습니다.

어떻게 검증했나요?

  • ./gradlew clean test
  • ./gradlew build
  • /health와 Swagger UI 확인
  • 정상 요청
  • 잘못된 입력
  • 권한 부족
  • 다른 사업장 접근 차단
  • Outbox claim·handler·completion·retry 상태 전이
  • 필요한 dempotency

PostgreSQL 17 Docker 환경에서 다음 테스트를 개별 실행한 후 전체 테스트를 실행했습니다.

  • PostgreSqlMigrationTests
  • PostgreSqlTenantDatabaseContextTest
  • PostgreSqlRlsIsolationTest
  • .\gradlew.bat clean test

결과: BUILD SUCCESSFUL

PostgreSQL 전용 테스트는 다음 환경변수가 설정된 경우 실행됩니다.

  • POSTGRES_TEST_ENABLED=true
  • POSTGRES_TEST_URL
  • POSTGRES_TEST_USERNAME
  • POSTGRES_TEST_PASSWORD

테스트에서는 RLS를 임시 활성화하여 다음을 확인하고, 종료 후 다시 비활성 상태로 복구합니다.

  • tenant context 미설정 시 fail-closed
  • A 회사 context에서 B 회사 데이터 조회·수정·삭제 차단
  • 다른 회사 데이터 생성 및 company_id 변경 차단
  • 트랜잭션 종료 후 tenant context 비누수
  • 제한된 runtime role의 RLS 우회 불가
  • Outbox claim owner, lease duration, batch size, max attempts 경계 검증
  • 잘못된 claim 입력의 SQLSTATE 22023 및 publication 비변경 검증
  • DB 시각 기준 claim 및 lease 만료 시각 검증
  • JVM Clock을 2099년으로 고정한 상태에서도 handler·completion·retry가 DB 시각으로 처리되는지 검증
  • 공백 문자로만 구성된 owner와 128자 초과 owner 거부
  • 1ms 미만 및 1일 초과 lease duration 거부

보안·개인정보

  • DTO·로그·AI 입력에 불필요한 개인정보가 없습니다.
  • JWT, Worker Link 원본 토큰, API Key, 비밀번호가 없습니다.
  • 모든 사업장 데이터 접근에 company_id 범위를 검사합니다.
  • AI 결과가 자동 승인·발송되지 않습니다.
  • 중요한 변경이 AuditLog와 request_id로 추적됩니다.
  • 관련 Accepted ADR을 지켰거나 필요한 새 ADR을 이 PR에서 Proposed로 작성했습니다.
  • Server에 Prompt Builder·Provider SDK·모델 routing을 추가하지 않았습니다.

ADR-0004의 transaction-local tenant context, 제한된 bootstrap 경계 및 단계적 RLS 활성화 결정을 따릅니다.

이번 PR에서 정책과 격리 테스트는 준비했지만 운영 RLS는 아직 활성화하지 않으므로, 전체 사업장 접근 검사 항목은 최종 활성화 단계에서 완료합니다. 새로운 업무 상태 변경을 추가하지 않아 AuditLog 이벤트도 추가하지 않았습니다.

API·DB·운영 영향

  • Swagger/OpenAPI와 Notion 계약을 갱신했습니다.
  • Client에 알려야 할 호환성 변경을 적었습니다.
  • DB 변경에 Flyway migration이 있습니다.
  • migration 번호와 소유 Issue를 확인했고 다른 기능의 테이블을 미리 만들지 않았습니다.
  • 환경변수는 이름만 .env.example에 적었습니다.
  • 배포 후 Smoke Test와 롤백 방법을 적었습니다.

API 및 Client 호환성 변경은 없습니다.

PostgreSQL 전용 V10 migration은 RLS policy와 bootstrap 함수만 준비하며 ENABLE ROW LEVEL SECURITY는 수행하지 않습니다. Flyway migration은 수정·삭제하지 않는 forward-only 원칙을 따릅니다.

Outbox bootstrap claim 함수는 호출자가 nowlease_expires_at을 전달하던 방식에서 lease_duration_millis만 전달하는 방식으로 변경했습니다.
현재 시각과 lease 만료 시각은 PostgreSQL 내부에서 계산합니다.
외부 API 계약 변경은 없으며 애플리케이션 내부 DB 함수 계약만 변경됩니다.

실제 DB role 생성, 최소 권한 부여와 Secret 주입은 관련 배포 작업과 조율하고, 운영 RLS 활성화 및 Smoke Test는 후속 단계에서 진행합니다.

화면 또는 응답 예시

화면 및 API 응답 변경은 없습니다.

리뷰할 부분

통합 문서함·준비도
DocumentController.java
companyId만 넘기던 방식에서 전체 ActorContext를 서비스에 전달하도록 변경

DocumentReadinessController.java
준비도 계산 서비스에 ActorContext 전달

DocumentService.java
조회 트랜잭션 시작 시 actor.companyId()를 tenant DB context에 설정

DocumentReadinessService.java
준비도 조회 전에 tenant context 설정

DocumentRequestDraftService.java
기존 ActorContext를 tenant context에도 사용

파일 업로드
FileService.java
업로드 트랜잭션에 tenant context 설정

근로자 서류와 파일 연결
WorkerDocumentController.java
companyId를 Command에 복사하지 않고 ActorContext를 서비스에 전달

WorkerDocumentCreateCommand.java
내부 companyId 필드 제거
tenant는 서비스가 actor.companyId()에서 결정

WorkerDocumentPatchCommand.java
내부 companyId 필드만 제거

WorkerDocumentService.java
모든 public transactional 메서드에 tenant context 설정

krestar and others added 11 commits July 24, 2026 22:00
- ActorContext의 companyId를 transaction-local DB context로 설정
- Worker·Task·Approval·Audit 업무 흐름에 tenant context 연결
- H2와 PostgreSQL 환경별 tenant context 구현 분리
- transaction 외부 접근과 PostgreSQL context 설정 검증 보강
- Login과 Refresh Token의 최소 tenant bootstrap 함수를 추가
- payload를 노출하지 않는 tenant-safe Outbox claim 함수를 추가
- 현재 14개 tenant 테이블의 RLS policy를 비활성 상태로 준비
- SECURITY DEFINER 함수와 policy 카탈로그 및 권한 경계를 검증
- Login과 Signup에서 email 기반 tenant bootstrap을 적용
- Refresh와 Logout에서 token hash 기반 tenant bootstrap을 적용
- PostgreSQL SECURITY DEFINER 함수와 H2 조회 adapter를 분리
- tenant 확정 후 기존 Repository와 token family lock을 수행
- Outbox claim 단계에서 tenant 좌표만 반환하도록 bootstrap adapter 적용
- handler, 완료 및 실패 transaction에 tenant context 연결
- EventPublication 조회를 event_id와 company_id 복합 조건으로 제한
- RLS 적용 후에도 backlog metric이 동작하도록 payload-free 집계 함수 추가
- 제한 runtime role의 claim 권한과 교차 tenant payload 차단 검증
제한된 runtime role로 RLS를 임시 활성화해 회사별 CRUD 차단과
transaction-local tenant context 비누수를 검증합니다.
V8에서 bootstrap 함수와 RLS policy가 생성된 상태를 반영하고,
RLS는 아직 비활성 상태임을 명확히 합니다.
- PostgreSQL statement timestamp를 claim과 lease의 기준 시각으로 사용
- owner, lease, batch size, max attempts를 DB 함수 경계에서 검증
- H2/local Clock 동작과 제한 role PostgreSQL 회귀 테스트를 유지·보강
- PostgreSQL Outbox 시간 소스로 statement_timestamp()를 사용
- claim, handler, completion, failure의 lease 판단 기준을 DB 시각으로 통일
- 행 잠금 획득 후 현재 시각을 조회해 대기 시간에 따른 시각 오차를 방지
- 로컬 및 H2 환경에서는 기존 Clock 기반 시간 소스를 유지
- lease duration을 1ms 이상 1일 이하로 제한
- 공백 문자로만 구성되거나 128자를 초과하는 claim owner를 거부
- 잘못된 claim 인자의 SQLSTATE 22023을 검증
- skewed JVM Clock 환경에서도 Outbox lifecycle이 정상 동작하는 회귀 테스트를 추가
- PostgreSQL의 millisecond 단위 lease 계약과 Java 설정을 일치
- toMillis() 변환에서 나노초가 조용히 절삭되는 설정을 거부
- 1ms + 1ns 입력에 대한 회귀 테스트를 추가
@krestar
krestar requested review from chaeliki, Copilot and hywznn July 29, 2026 06:10

This comment was marked as resolved.

@chaeliki

Copy link
Copy Markdown
Contributor

stored_file, document_request_draft/_type이 tenant table 목록에 반영된 것
확인했습니다, 감사합니다!

Comment thread docs/database/postgresql-rls-rollout.md
@chaeliki
chaeliki self-requested a review July 29, 2026 07:29
@krestar
krestar merged commit 6b5071b into main Jul 29, 2026
4 checks passed
@krestar
krestar deleted the feat/34-rls-bootstrap-context branch July 29, 2026 07:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants