From 1a56756f929e0411db882d4dbd46b1e869d566a6 Mon Sep 17 00:00:00 2001 From: krestar Date: Fri, 24 Jul 2026 13:29:07 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(db):=20PostgreSQL=20RLS=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runtime과 Flyway 데이터베이스 연결 계정을 분리 - PostgreSQL 전용 migration location 설정 - 트랜잭션 단위 tenant context 어댑터 추가 - connection pool tenant context 비누수 테스트 추가 - RLS 단계적 도입 및 복구 절차 문서화 --- .env.example | 12 +- README.md | 39 +- docs/database/postgresql-rls-rollout.md | 92 ++++ .../PostgreSqlTenantDatabaseContext.java | 62 +++ .../security/TenantDatabaseContext.java | 16 + src/main/resources/application.yaml | 26 +- .../server/PostgreSqlMigrationTests.java | 5 +- .../AuthRefreshPostgreSqlConcurrencyTest.java | 4 + .../PostgreSqlTenantDatabaseContextTest.java | 431 ++++++++++++++++++ 9 files changed, 666 insertions(+), 21 deletions(-) create mode 100644 docs/database/postgresql-rls-rollout.md create mode 100644 src/main/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContext.java create mode 100644 src/main/java/com/fowoco/server/common/security/TenantDatabaseContext.java create mode 100644 src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java diff --git a/.env.example b/.env.example index f7a117b..19d6eb1 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,13 @@ # 기본값은 local(H2)입니다. PostgreSQL은 dev 또는 prod로 바꿉니다. SPRING_PROFILES_ACTIVE=local -# PostgreSQL(dev/prod)에서만 사용합니다. +# PostgreSQL(dev/prod)에서만 사용합니다. URL은 같아도 runtime과 migration 계정은 분리합니다. +# 실제 계정과 비밀번호는 #9의 배포 환경 Secret으로 주입하고 Git에 기록하지 않습니다. DB_URL=jdbc:postgresql://localhost:5432/fowoco -DB_USERNAME=postgres -DB_PASSWORD= +DB_RUNTIME_USERNAME= +DB_RUNTIME_PASSWORD= +DB_MIGRATION_USERNAME= +DB_MIGRATION_PASSWORD= # React 개발 서버 또는 배포 Client 주소를 쉼표로 구분합니다. CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 @@ -25,8 +28,9 @@ REFRESH_TOKEN_COOKIE_SAME_SITE=Strict # local=false, dev/prod=true가 기본입니다. HTTPS 정책을 바꿀 때만 명시하세요. # REFRESH_TOKEN_COOKIE_SECURE=true -# 선택 사항: local/dev/demo 환경에서 로그인 가능한 초기 ADMIN 계정을 한 번 생성합니다. +# 선택 사항: local(H2)에서 로그인 가능한 초기 ADMIN 계정을 한 번 생성합니다. # 기본값은 false이며, true로 켤 때 비밀번호를 반드시 실행 환경의 Secret으로 넣습니다. +# PostgreSQL dev/prod에서는 사용하지 않고 #9 provisioning 단계에서 계정을 준비합니다. # 실제 비밀번호를 이 파일이나 Git에 커밋하지 않습니다. DEMO_SEED_ENABLED=false # DEMO_SEED_ADMIN_PASSWORD= diff --git a/README.md b/README.md index 4b1bd25..3bc7a84 100644 --- a/README.md +++ b/README.md @@ -74,19 +74,26 @@ local은 기본 Profile이라 별도 데이터베이스가 필요하지 않습 ```bash export DB_URL=jdbc:postgresql://localhost:5432/fowoco -export DB_USERNAME=postgres -export DB_PASSWORD='로컬에서만 사용하는 값' +export DB_RUNTIME_USERNAME='제한된 애플리케이션 계정' +export DB_RUNTIME_PASSWORD='로컬 Secret' +export DB_MIGRATION_USERNAME='Flyway 전용 계정' +export DB_MIGRATION_PASSWORD='로컬 Secret' export SPRING_PROFILES_ACTIVE=dev ./gradlew bootRun ``` `.env.example`은 필요한 변수의 예시이며 Spring Boot가 자동으로 읽지는 않습니다. 위처럼 환경변수로 내보내거나 IDE 실행 설정에 등록하세요. -실제 비밀번호·API Key·토큰은 Git, Issue, Discussion, 로그에 올리지 않습니다. +runtime 계정은 일반 업무 DML만 수행하고, Flyway 계정은 migration을 적용합니다. +환경별 role 생성과 Secret 주입은 배포 작업에서 수행합니다. 실제 비밀번호·API +Key·토큰은 Git, Issue, Discussion, 로그에 올리지 않습니다. -### 선택 사항: 데모 로그인 계정 만들기 +### 선택 사항: local(H2) 데모 로그인 계정 만들기 -빈 DB에 데모용 사업장과 `ADMIN` 계정이 필요할 때만 Seed를 명시적으로 켭니다. 기본값은 꺼짐이며 비밀번호 기본값도 없습니다. +local(H2)에 데모용 사업장과 `ADMIN` 계정이 필요할 때만 Seed를 명시적으로 켭니다. +기본값은 꺼짐이며 비밀번호 기본값도 없습니다. PostgreSQL `dev`·`prod`에서는 runtime +role에 전체 tenant 생성 권한을 주지 않으므로 이 Seed를 실행하지 않고, #9의 +provisioning 단계에서 migration/provisioning credential로 초기 계정을 준비합니다. ```bash export DEMO_SEED_ENABLED=true @@ -96,14 +103,18 @@ export DEMO_SEED_ADMIN_PASSWORD='로컬 또는 배포 Secret의 12자 이상 값 서버는 Flyway 적용 뒤 사업장과 계정을 한 번만 만들고, 비밀번호 원문이 아니라 BCrypt hash만 저장합니다. 같은 설정으로 다시 실행해도 중복 생성하지 않습니다. 같은 이메일이 다른 사업장·사용자·역할로 이미 존재하면 덮어쓰지 않고 시작을 중단합니다. -이 값은 개인 `.env`나 배포 환경의 Secret에만 보관하고 `.env.example`, GitHub, 로그에 실제 비밀번호를 넣지 않습니다. ID·이메일·사업장 이름을 바꿔야 하면 `DEMO_SEED_COMPANY_ID`, `DEMO_SEED_ADMIN_USER_ID`, `DEMO_SEED_ADMIN_EMAIL`, `DEMO_SEED_COMPANY_NAME`을 함께 설정할 수 있습니다. 최초 계정을 확인한 뒤에는 `DEMO_SEED_ENABLED=false`로 되돌려 의도하지 않은 Seed 실행을 막습니다. +이 값은 개인 `.env`에만 보관하고 `.env.example`, GitHub, 로그에 실제 비밀번호를 +넣지 않습니다. ID·이메일·사업장 이름을 바꿔야 하면 +`DEMO_SEED_COMPANY_ID`, `DEMO_SEED_ADMIN_USER_ID`, `DEMO_SEED_ADMIN_EMAIL`, +`DEMO_SEED_COMPANY_NAME`을 함께 설정할 수 있습니다. 최초 계정을 확인한 뒤에는 +`DEMO_SEED_ENABLED=false`로 되돌려 의도하지 않은 Seed 실행을 막습니다. ## 개발 기반은 어떻게 동작하나요? | 구성 | 초보자를 위한 설명 | 구현 위치 | | --- | --- | --- | | Profile | `local`은 H2, `dev`·`prod`는 PostgreSQL을 사용합니다. | `application.yaml` | -| Flyway | 서버 시작 시 적용하지 않은 DB 변경 파일을 순서대로 실행합니다. | `db/migration` | +| Flyway | H2는 공통 migration만, PostgreSQL은 공통 및 PostgreSQL 전용 migration을 순서대로 실행합니다. | `db/migration`, `db/migration-postgresql` | | Security | JWT에서 ActorContext와 역할을 만들고 VIEWER의 쓰기 요청을 기본 차단합니다. | `SecurityConfig` | | Swagger | Controller의 API 설명을 브라우저 문서로 보여줍니다. | `OpenApiConfig` | | 공통 오류 | 모든 실패를 같은 JSON 구조로 반환합니다. | `common/error` | @@ -222,10 +233,11 @@ server/ │ │ └── reliability/ # Outbox와 event 복구 │ └── resources/ │ ├── application.yaml - │ └── db/migration/ - │ ├── V1__baseline.sql - │ ├── V2__create_auth_company.sql # Auth·Company·Refresh Token schema - │ └── V3__create_worker_document.sql # #5 구현 시 추가 예정 + │ └── db/ + │ ├── migration/ + │ │ ├── V1__baseline.sql + │ │ └── V2__create_auth_company.sql # Auth·Company·Refresh Token schema + │ └── migration-postgresql/ # RLS 등 PostgreSQL 전용 migration └── test/ └── java/com/fowoco/server/ ├── architecture/ @@ -251,7 +263,9 @@ server/ - `task` 이외의 기능은 Task 상태를 직접 변경하지 않습니다. - `aiintegration`은 AI Runtime 연결만 담당하며 Prompt와 Provider SDK는 `ai` 저장소에 둡니다. - 최상위 `package-info.java`는 기능 경계와 책임을 Git에 남기기 위한 뼈대입니다. 빈 하위 패키지는 미리 만들지 않고 실제 코드가 추가될 때 생성합니다. -- Flyway migration은 적용 후 수정할 수 없으므로 `V2`와 `V3` 빈 파일을 미리 만들지 않습니다. 각각 #4와 #5의 실제 스키마와 함께 추가합니다. +- Flyway migration은 적용 후 수정할 수 없습니다. 후행 migration은 의존하는 + 선행 schema가 `main`에 병합된 뒤 다음 사용 가능한 번호로 만들며, 번호 예약용 빈 + 파일을 추가하지 않습니다. - 테스트 패키지는 구현 패키지를 따라가고, `architecture`에는 향후 ArchUnit 또는 Spring Modulith 경계 검증을 둡니다. ## 어디서 무엇을 찾나요? @@ -270,6 +284,7 @@ server/ | 서버 Issue·PR 로드맵 | [Server Roadmap · 팀원 전용](https://github.com/orgs/fowoco/projects/3) | | 팀 전체 진행 상태 | [Project · 팀원 전용](https://github.com/orgs/fowoco/projects/1) | | 아키텍처·보안·배포 설명 | [Server Wiki](https://github.com/fowoco/server/wiki) | +| PostgreSQL RLS 적용·복구 순서 | [RLS 단계적 도입 가이드](docs/database/postgresql-rls-rollout.md) | | 저장소 경계 설명 mirror | [Wiki 저장소 경계와 계약](https://github.com/fowoco/server/wiki/Repository-Boundaries-and-Contracts) | ## 변하지 않는 보안 원칙 diff --git a/docs/database/postgresql-rls-rollout.md b/docs/database/postgresql-rls-rollout.md new file mode 100644 index 0000000..164ad03 --- /dev/null +++ b/docs/database/postgresql-rls-rollout.md @@ -0,0 +1,92 @@ +# PostgreSQL RLS 단계적 도입 가이드 + +이 문서는 [Issue #34](https://github.com/fowoco/server/issues/34)와 +[ADR-0004](../adr/0004-postgresql-rls-tenant-isolation.md)의 실행 순서를 정리합니다. +RLS는 기존 `ActorContext`, Repository의 `company_id` 조건, tenant-aware DB 제약을 +대체하지 않고 그 위에 DB 차단 계층을 추가합니다. + +## 책임과 현재 범위 + +- #34는 tenant context adapter, PostgreSQL 전용 policy migration, 제한 role 격리 + 테스트를 담당합니다. +- #9는 환경별 role 생성, 최소 GRANT, credential 발급·Secret 주입을 담당합니다. +- versioned migration에는 `CREATE ROLE`, 비밀번호, 환경별 실제 role 이름을 넣지 + 않습니다. +- 대상 기능의 schema가 `main`에 병합되기 전에는 RLS migration 번호나 빈 + placeholder를 만들지 않습니다. + +현재 기반 단계에서는 runtime/Flyway 설정 경계, PostgreSQL 전용 Flyway location, +transaction-local tenant context와 connection pool 비누수 테스트만 준비합니다. +아직 policy를 만들거나 RLS를 활성화하지 않습니다. + +## 설정 계약 + +PostgreSQL `dev`·`prod` Profile은 같은 DB에 서로 다른 계정으로 연결합니다. + +| 환경변수 | 용도 | +| --- | --- | +| `DB_URL` | 공통 PostgreSQL JDBC URL | +| `DB_RUNTIME_USERNAME`, `DB_RUNTIME_PASSWORD` | Spring Boot 업무 transaction | +| `DB_MIGRATION_USERNAME`, `DB_MIGRATION_PASSWORD` | Flyway DDL·policy migration | + +runtime role은 `SUPERUSER`, `BYPASSRLS`, table owner, migration role membership, +DDL, `TRUNCATE`, `REFERENCES` 권한을 갖지 않습니다. 실제 값은 배포 환경 Secret에만 +보관합니다. + +여기서 DDL 차단은 공용·업무 schema를 변경할 수 없다는 뜻입니다. PostgreSQL의 +기본 `PUBLIC` 권한으로 session-local 임시 table이 허용되는 환경에서는 +`SECURITY DEFINER` 함수의 `search_path`를 신뢰하는 schema로 고정하고 `pg_temp`를 +마지막에 둡니다. 임시 table 권한 자체를 회수할지는 #9의 database-level GRANT +정책에서 결정합니다. + +## 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 회귀 테스트를 + 실행합니다. +6. 별도 forward migration으로 `ENABLE ROW LEVEL SECURITY`를 적용합니다. +7. 제한된 runtime role로 Smoke Test를 실행합니다. + +## Smoke Test + +- Flyway `migrate`·`validate`가 성공하고 pending migration이 없습니다. +- runtime role은 `rolsuper = false`, `rolbypassrls = false`이며 대상 table의 + owner가 아닙니다. +- tenant context가 없거나 비어 있거나 UUID가 잘못되면 보호 table 접근이 + fail-closed 됩니다. +- A context에서 B 행의 조회·생성·수정·삭제가 차단됩니다. +- commit, rollback, 예외, timeout 뒤 같은 physical connection을 재사용해도 이전 + context가 남지 않습니다. +- Login·Refresh와 구현된 Worker Link 정상 흐름이 유지됩니다. +- 오류 응답과 일반 로그에 SQL, JWT, token, email, 개인정보가 노출되지 않습니다. + +로컬 또는 CI PostgreSQL 기반 검증은 다음 환경변수를 사용합니다. + +```text +POSTGRES_TEST_ENABLED=true +POSTGRES_TEST_URL=... +POSTGRES_TEST_USERNAME=... +POSTGRES_TEST_PASSWORD=... +``` + +통합 테스트는 이 계정으로 migration을 적용하고 테스트 수명 동안만 무작위 제한 +role을 생성합니다. 따라서 격리 테스트 DB의 계정에는 role 생성 권한이 필요합니다. +테스트 role과 임시 비밀번호는 테스트 종료 시 제거되며 versioned migration이나 +저장소에 남지 않습니다. + +## 장애와 forward-only 복구 + +1. 배포 진행을 중단하고 `request_id`로 영향 범위를 확인합니다. +2. policy 오류는 기존 migration을 수정하지 않고 새 forward migration으로 + 교정합니다. +3. 전체 업무가 중단되는 긴급 상황에서만 승인된 담당자가 새 migration으로 대상 + table의 RLS를 일시 비활성화합니다. +4. 비활성화 중에도 Repository의 `company_id` 조건과 tenant-aware DB 제약은 + 유지합니다. +5. 원인을 수정한 뒤 새 migration으로 RLS를 재활성화하고 Smoke Test를 반복합니다. + +공유 DB에서 `flywayClean`, schema history 수동 조작, 적용된 migration 수정 또는 +checksum 은폐 목적의 `flyway repair`는 사용하지 않습니다. diff --git a/src/main/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContext.java b/src/main/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContext.java new file mode 100644 index 0000000..6821539 --- /dev/null +++ b/src/main/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContext.java @@ -0,0 +1,62 @@ +package com.fowoco.server.common.security; + +import jakarta.persistence.EntityManager; +import java.util.Objects; +import java.util.UUID; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * PostgreSQL tenant context backed by a transaction-local custom setting. + */ +@Component +public final class PostgreSqlTenantDatabaseContext implements TenantDatabaseContext { + + private static final String READ_COMPANY_ID_SQL = """ + SELECT NULLIF(pg_catalog.current_setting('app.company_id', true), '') + """; + private static final String SET_COMPANY_ID_SQL = """ + SELECT pg_catalog.set_config('app.company_id', ?1, true) + """; + + private final EntityManager entityManager; + + public PostgreSqlTenantDatabaseContext(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @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." + ); + } + if (!entityManager.isJoinedToTransaction()) { + throw new IllegalStateException( + "Tenant database context requires a transaction-bound database connection." + ); + } + + String requestedCompanyId = companyId.toString(); + Object currentCompanyValue = entityManager.createNativeQuery(READ_COMPANY_ID_SQL) + .getSingleResult(); + String currentCompanyId = currentCompanyValue == null + ? null + : currentCompanyValue.toString(); + if (currentCompanyId != null && !currentCompanyId.equals(requestedCompanyId)) { + throw new IllegalStateException( + "Tenant database context cannot change within a transaction." + ); + } + + String appliedCompanyId = entityManager.createNativeQuery(SET_COMPANY_ID_SQL) + .setParameter(1, requestedCompanyId) + .getSingleResult() + .toString(); + if (!requestedCompanyId.equals(appliedCompanyId)) { + throw new IllegalStateException("Tenant database context was not applied."); + } + } +} diff --git a/src/main/java/com/fowoco/server/common/security/TenantDatabaseContext.java b/src/main/java/com/fowoco/server/common/security/TenantDatabaseContext.java new file mode 100644 index 0000000..d6288cf --- /dev/null +++ b/src/main/java/com/fowoco/server/common/security/TenantDatabaseContext.java @@ -0,0 +1,16 @@ +package com.fowoco.server.common.security; + +import java.util.UUID; + +/** + * Binds a trusted tenant identifier to the current database transaction. + */ +public interface TenantDatabaseContext { + + /** + * Sets the company visible to tenant-protected database operations in the current transaction. + * + * @param companyId a company identifier obtained from a trusted authentication or bootstrap flow + */ + void setCompanyIdForCurrentTransaction(UUID companyId); +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8b978b2..7316483 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -93,11 +93,20 @@ spring: on-profile: dev datasource: url: ${DB_URL:jdbc:postgresql://localhost:5432/fowoco} - username: ${DB_USERNAME:postgres} - password: ${DB_PASSWORD} + username: ${DB_RUNTIME_USERNAME} + password: ${DB_RUNTIME_PASSWORD} + flyway: + url: ${DB_URL:jdbc:postgresql://localhost:5432/fowoco} + user: ${DB_MIGRATION_USERNAME} + password: ${DB_MIGRATION_PASSWORD} + locations: + - classpath:db/migration + - classpath:db/migration-postgresql jpa: show-sql: true app: + demo-seed: + enabled: false auth: jwt: issuer: ${JWT_ISSUER:fowoco-server-dev} @@ -135,8 +144,15 @@ spring: on-profile: prod datasource: url: ${DB_URL} - username: ${DB_USERNAME} - password: ${DB_PASSWORD} + username: ${DB_RUNTIME_USERNAME} + password: ${DB_RUNTIME_PASSWORD} + flyway: + url: ${DB_URL} + user: ${DB_MIGRATION_USERNAME} + password: ${DB_MIGRATION_PASSWORD} + locations: + - classpath:db/migration + - classpath:db/migration-postgresql jpa: show-sql: false h2: @@ -148,6 +164,8 @@ springdoc: swagger-ui: enabled: false app: + demo-seed: + enabled: false cors: allowed-origins: ${CORS_ALLOWED_ORIGINS} auth: diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 2110613..7f6a490 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -33,7 +33,10 @@ void migrationsApplyCanonicalAuthSchemaOnPostgreSql() throws SQLException { String password = requiredEnvironmentVariable("POSTGRES_TEST_PASSWORD"); Flyway flyway = Flyway.configure() .dataSource(url, username, password) - .locations("classpath:db/migration") + .locations( + "classpath:db/migration", + "classpath:db/migration-postgresql" + ) .load(); flyway.migrate(); diff --git a/src/test/java/com/fowoco/server/auth/AuthRefreshPostgreSqlConcurrencyTest.java b/src/test/java/com/fowoco/server/auth/AuthRefreshPostgreSqlConcurrencyTest.java index 80bcfcc..ed69621 100644 --- a/src/test/java/com/fowoco/server/auth/AuthRefreshPostgreSqlConcurrencyTest.java +++ b/src/test/java/com/fowoco/server/auth/AuthRefreshPostgreSqlConcurrencyTest.java @@ -85,6 +85,10 @@ static void usePostgreSql(DynamicPropertyRegistry registry) { () -> requiredEnvironmentVariable("POSTGRES_TEST_PASSWORD") ); registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add( + "spring.flyway.locations", + () -> "classpath:db/migration,classpath:db/migration-postgresql" + ); } @BeforeEach diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java new file mode 100644 index 0000000..b4c9946 --- /dev/null +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java @@ -0,0 +1,431 @@ +package com.fowoco.server.common.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.ServerApplication; +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.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.WebApplicationType; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +@EnabledIfEnvironmentVariable(named = "POSTGRES_TEST_ENABLED", matches = "true") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class PostgreSqlTenantDatabaseContextTest { + + private static final UUID COMPANY_A = + UUID.fromString("a0000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = + UUID.fromString("b0000000-0000-0000-0000-000000000002"); + + private String migrationUrl; + private String migrationUsername; + private String migrationPassword; + private String runtimeRole; + private String runtimePassword; + private ConfigurableApplicationContext applicationContext; + private DriverManagerDataSource migrationDataSource; + private JdbcTemplate migrationJdbc; + private JdbcTemplate runtimeJdbc; + private EntityManager entityManager; + private PlatformTransactionManager transactionManager; + private TransactionTemplate transactionTemplate; + private TenantDatabaseContext tenantDatabaseContext; + + @BeforeAll + void setUpRestrictedRuntimeConnection() throws SQLException { + migrationUrl = requiredEnvironmentVariable("POSTGRES_TEST_URL"); + migrationUsername = requiredEnvironmentVariable("POSTGRES_TEST_USERNAME"); + migrationPassword = requiredEnvironmentVariable("POSTGRES_TEST_PASSWORD"); + + Flyway.configure() + .dataSource(migrationUrl, migrationUsername, migrationPassword) + .locations( + "classpath:db/migration", + "classpath:db/migration-postgresql" + ) + .load() + .migrate(); + + runtimeRole = "rls_runtime_test_" + + UUID.randomUUID().toString().replace("-", "").substring(0, 12); + runtimePassword = "Rls-test-" + UUID.randomUUID(); + + try (Connection connection = migrationConnection(); + 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.user_account, public.refresh_token + TO %s + """.formatted(quotedRole)); + } + + migrationDataSource = new DriverManagerDataSource(); + migrationDataSource.setUrl(migrationUrl); + migrationDataSource.setUsername(migrationUsername); + migrationDataSource.setPassword(migrationPassword); + migrationJdbc = new JdbcTemplate(migrationDataSource); + + applicationContext = startRestrictedRuntimeApplication(); + DataSource runtimeDataSource = applicationContext.getBean(DataSource.class); + runtimeJdbc = new JdbcTemplate(runtimeDataSource); + entityManager = applicationContext.getBean(EntityManager.class); + transactionManager = applicationContext.getBean(PlatformTransactionManager.class); + transactionTemplate = new TransactionTemplate(transactionManager); + tenantDatabaseContext = applicationContext.getBean(TenantDatabaseContext.class); + } + + @AfterAll + void removeRestrictedRuntimeConnection() throws SQLException { + if (applicationContext != null) { + applicationContext.close(); + } + if (runtimeRole == null) { + return; + } + + try (Connection connection = migrationConnection(); + Statement statement = connection.createStatement()) { + if (roleExists(statement, runtimeRole)) { + String quotedRole = quoteIdentifier(runtimeRole); + statement.execute("DROP OWNED BY " + quotedRole); + statement.execute("DROP ROLE " + quotedRole); + } + } + } + + @Test + void runtimeRoleCannotBypassRlsOrModifyPersistentSchema() { + RoleAttributes attributes = migrationJdbc.queryForObject( + """ + SELECT + rolsuper, + rolcreatedb, + rolcreaterole, + rolinherit, + rolreplication, + rolbypassrls + FROM pg_catalog.pg_roles + WHERE rolname = ? + """, + (resultSet, rowNumber) -> new RoleAttributes( + resultSet.getBoolean("rolsuper"), + resultSet.getBoolean("rolcreatedb"), + resultSet.getBoolean("rolcreaterole"), + resultSet.getBoolean("rolinherit"), + resultSet.getBoolean("rolreplication"), + resultSet.getBoolean("rolbypassrls") + ), + runtimeRole + ); + + assertThat(attributes).isEqualTo( + new RoleAttributes(false, false, false, false, false, false) + ); + assertThat(migrationJdbc.queryForObject( + """ + SELECT COUNT(*) + FROM pg_catalog.pg_auth_members membership + JOIN pg_catalog.pg_roles member_role + ON member_role.oid = membership.member + WHERE member_role.rolname = ? + """, + Integer.class, + runtimeRole + )).isZero(); + assertThat(migrationJdbc.queryForObject( + """ + SELECT COUNT(*) + FROM pg_catalog.pg_tables + WHERE schemaname = 'public' + AND tableowner = ? + """, + Integer.class, + runtimeRole + )).isZero(); + + assertThat(runtimeJdbc.queryForObject( + "SELECT CURRENT_USER", + String.class + )).isEqualTo(runtimeRole); + assertThat(runtimeJdbc.queryForObject( + """ + SELECT pg_catalog.has_schema_privilege( + CURRENT_USER, 'public', 'CREATE' + ) + """, + Boolean.class + )).isFalse(); + assertThat(runtimeJdbc.queryForObject( + """ + SELECT pg_catalog.has_database_privilege( + CURRENT_USER, pg_catalog.current_database(), 'CREATE' + ) + """, + Boolean.class + )).isFalse(); + + for (String table : new String[]{"company", "user_account", "refresh_token"}) { + assertThat(hasTablePrivilege(table, "SELECT")).isTrue(); + assertThat(hasTablePrivilege(table, "INSERT")).isTrue(); + assertThat(hasTablePrivilege(table, "UPDATE")).isTrue(); + assertThat(hasTablePrivilege(table, "DELETE")).isTrue(); + assertThat(hasTablePrivilege(table, "TRUNCATE")).isFalse(); + assertThat(hasTablePrivilege(table, "REFERENCES")).isFalse(); + } + assertThat(hasTablePrivilege("flyway_schema_history", "SELECT")).isFalse(); + assertThat(hasTablePrivilege("flyway_schema_history", "INSERT")).isFalse(); + assertThat(hasTablePrivilege("flyway_schema_history", "UPDATE")).isFalse(); + assertThat(hasTablePrivilege("flyway_schema_history", "DELETE")).isFalse(); + } + + @Test + void committedTransactionsReuseAConnectionWithoutLeakingTenantContext() { + assertThatThrownBy( + () -> tenantDatabaseContext.setCompanyIdForCurrentTransaction(COMPANY_A) + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("active transaction"); + + ContextProbe companyA = bindAndRead(COMPANY_A); + ContextProbe cleared = readWithoutBinding(); + ContextProbe companyB = bindAndRead(COMPANY_B); + + assertThat(companyA.companyId()).isEqualTo(COMPANY_A.toString()); + assertThat(cleared.companyId()).isNull(); + assertThat(companyB.companyId()).isEqualTo(COMPANY_B.toString()); + assertThat(cleared.backendPid()).isEqualTo(companyA.backendPid()); + assertThat(companyB.backendPid()).isEqualTo(companyA.backendPid()); + } + + @Test + void rollbackExceptionAndTimeoutDoNotLeakTenantContext() { + transactionTemplate.executeWithoutResult(status -> { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(COMPANY_A); + assertThat(currentCompanyId()).isEqualTo(COMPANY_A.toString()); + status.setRollbackOnly(); + }); + assertThat(readWithoutBinding().companyId()).isNull(); + + assertThatThrownBy(() -> transactionTemplate.executeWithoutResult(status -> { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(COMPANY_A); + throw new ExpectedTransactionFailure(); + })).isInstanceOf(ExpectedTransactionFailure.class); + assertThat(readWithoutBinding().companyId()).isNull(); + + TransactionTemplate timedTransaction = new TransactionTemplate(transactionManager); + timedTransaction.setTimeout(1); + assertThatThrownBy(() -> timedTransaction.executeWithoutResult(status -> { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(COMPANY_A); + runtimeJdbc.execute("SELECT pg_catalog.pg_sleep(2)"); + })).isInstanceOf(DataAccessException.class); + assertThat(readWithoutBinding().companyId()).isNull(); + } + + @Test + void transactionCannotBeReboundToAnotherCompany() { + transactionTemplate.executeWithoutResult(status -> { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(COMPANY_A); + + assertThatThrownBy( + () -> tenantDatabaseContext.setCompanyIdForCurrentTransaction(COMPANY_B) + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cannot change"); + assertThat(currentCompanyId()).isEqualTo(COMPANY_A.toString()); + }); + } + + @Test + void transactionOnAnotherDataSourceDoesNotSatisfyTheContextRequirement() { + TransactionTemplate unrelatedTransaction = new TransactionTemplate( + new DataSourceTransactionManager(migrationDataSource) + ); + + unrelatedTransaction.executeWithoutResult(status -> assertThatThrownBy( + () -> tenantDatabaseContext.setCompanyIdForCurrentTransaction(COMPANY_A) + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("transaction-bound database connection")); + } + + @Test + void springJpaTransactionUsesTheRestrictedRuntimeConnection() { + assertThat(transactionManager).isInstanceOf(JpaTransactionManager.class); + + ContextProbe contextProbe = bindAndRead(COMPANY_A); + + assertThat(contextProbe.companyId()).isEqualTo(COMPANY_A.toString()); + } + + private ConfigurableApplicationContext startRestrictedRuntimeApplication() { + Map properties = new LinkedHashMap<>(); + properties.put("spring.datasource.url", migrationUrl); + properties.put("spring.datasource.username", runtimeRole); + properties.put("spring.datasource.password", runtimePassword); + properties.put("spring.datasource.driver-class-name", "org.postgresql.Driver"); + properties.put("spring.datasource.hikari.maximum-pool-size", "1"); + properties.put("spring.datasource.hikari.minimum-idle", "1"); + properties.put("spring.datasource.hikari.pool-name", "rls-runtime-test-pool"); + properties.put("spring.flyway.url", migrationUrl); + properties.put("spring.flyway.user", migrationUsername); + properties.put("spring.flyway.password", migrationPassword); + properties.put( + "spring.flyway.locations", + "classpath:db/migration,classpath:db/migration-postgresql" + ); + properties.put("app.demo-seed.enabled", "false"); + properties.put("server.port", "0"); + + StandardEnvironment environment = new StandardEnvironment(); + environment.setActiveProfiles("test"); + environment.getPropertySources().addFirst( + new MapPropertySource("postgresql-runtime-role-test", properties) + ); + + SpringApplication application = new SpringApplication(ServerApplication.class); + application.setEnvironment(environment); + application.setWebApplicationType(WebApplicationType.SERVLET); + return application.run(); + } + + private ContextProbe bindAndRead(UUID companyId) { + return transactionTemplate.execute(status -> { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + return new ContextProbe(backendPid(), currentCompanyId()); + }); + } + + private ContextProbe readWithoutBinding() { + return transactionTemplate.execute( + status -> new ContextProbe(backendPid(), currentCompanyId()) + ); + } + + private Integer backendPid() { + return ((Number) entityManager.createNativeQuery( + "SELECT pg_catalog.pg_backend_pid()" + ).getSingleResult()).intValue(); + } + + private String currentCompanyId() { + Object currentCompanyValue = entityManager.createNativeQuery( + """ + SELECT NULLIF( + pg_catalog.current_setting('app.company_id', true), + '' + ) + """ + ).getSingleResult(); + return currentCompanyValue == null ? null : currentCompanyValue.toString(); + } + + private boolean hasTablePrivilege(String table, String privileges) { + Boolean allowed = runtimeJdbc.queryForObject( + """ + SELECT pg_catalog.has_table_privilege( + CURRENT_USER, + ?, + ? + ) + """, + Boolean.class, + "public." + table, + privileges + ); + return Boolean.TRUE.equals(allowed); + } + + private Connection migrationConnection() throws SQLException { + return DriverManager.getConnection( + migrationUrl, + migrationUsername, + migrationPassword + ); + } + + private static boolean roleExists(Statement statement, String roleName) + throws SQLException { + try (ResultSet resultSet = statement.executeQuery( + "SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = " + + quoteLiteral(roleName) + )) { + return resultSet.next(); + } + } + + 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; + } + + private record RoleAttributes( + boolean superuser, + boolean createDatabase, + boolean createRole, + boolean inherit, + boolean replication, + boolean bypassRls + ) { + } + + private record ContextProbe(Integer backendPid, String companyId) { + } + + private static final class ExpectedTransactionFailure extends RuntimeException { + } +} From 2e9a914cfd4f47e40c9e7f776672af63bc0799dd Mon Sep 17 00:00:00 2001 From: krestar Date: Fri, 24 Jul 2026 14:08:37 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test(db):=20=EC=A0=9C=ED=95=9C=20role=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=A0=84=EC=B2=B4=20tenant=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EB=A1=9C=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V1~V5의 12개 tenant 테이블에 runtime DML 권한 검증 적용 - TRUNCATE, REFERENCES, DDL 및 RLS 우회 권한 차단 확인 - RLS 단계적 도입 문서에 현재 검증 대상 테이블 반영 --- docs/database/postgresql-rls-rollout.md | 9 ++++++++ .../PostgreSqlTenantDatabaseContextTest.java | 22 ++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/database/postgresql-rls-rollout.md b/docs/database/postgresql-rls-rollout.md index 164ad03..4a29365 100644 --- a/docs/database/postgresql-rls-rollout.md +++ b/docs/database/postgresql-rls-rollout.md @@ -19,6 +19,15 @@ RLS는 기존 `ActorContext`, Repository의 `company_id` 조건, tenant-aware DB transaction-local tenant context와 connection pool 비누수 테스트만 준비합니다. 아직 policy를 만들거나 RLS를 활성화하지 않습니다. +현재 `main`의 V1~V5에는 아래 12개 tenant table이 존재합니다. 기반 단계의 제한 +role 테스트는 이 전체 범위에 업무 DML만 허용하고, table owner·DDL·`TRUNCATE`· +`REFERENCES` 권한과 RLS 우회 권한이 없음을 확인합니다. + +- `company`, `user_account`, `refresh_token` +- `worker`, `worker_document` +- `task`, `task_checklist_item`, `task_transition_history` +- `approval_request`, `external_submission`, `task_evidence`, `audit_event` + ## 설정 계약 PostgreSQL `dev`·`prod` Profile은 같은 DB에 서로 다른 계정으로 연결합니다. 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 b4c9946..b92a64a 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java @@ -41,6 +41,22 @@ 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 String[] TENANT_TABLES = { + "company", + "user_account", + "refresh_token", + "worker", + "worker_document", + "task", + "task_checklist_item", + "task_transition_history", + "approval_request", + "external_submission", + "task_evidence", + "audit_event" + }; + private static final String TENANT_TABLE_SQL = + "public." + String.join(", public.", TENANT_TABLES); private String migrationUrl; private String migrationUsername; @@ -98,9 +114,9 @@ void setUpRestrictedRuntimeConnection() throws SQLException { statement.execute("GRANT USAGE ON SCHEMA public TO " + quotedRole); statement.execute(""" GRANT SELECT, INSERT, UPDATE, DELETE - ON TABLE public.company, public.user_account, public.refresh_token + ON TABLE %s TO %s - """.formatted(quotedRole)); + """.formatted(TENANT_TABLE_SQL, quotedRole)); } migrationDataSource = new DriverManagerDataSource(); @@ -208,7 +224,7 @@ SELECT COUNT(*) Boolean.class )).isFalse(); - for (String table : new String[]{"company", "user_account", "refresh_token"}) { + for (String table : TENANT_TABLES) { assertThat(hasTablePrivilege(table, "SELECT")).isTrue(); assertThat(hasTablePrivilege(table, "INSERT")).isTrue(); assertThat(hasTablePrivilege(table, "UPDATE")).isTrue(); From 08697fff0f0d1967989f1812c1bdf1df3f75c405 Mon Sep 17 00:00:00 2001 From: krestar Date: Fri, 24 Jul 2026 15:23:56 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs(db):=20RLS=20rollout=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=EB=A5=BC=20V6=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 최신 공통 migration 범위를 V1~V6로 수정 --- docs/database/postgresql-rls-rollout.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/database/postgresql-rls-rollout.md b/docs/database/postgresql-rls-rollout.md index 4a29365..91051f4 100644 --- a/docs/database/postgresql-rls-rollout.md +++ b/docs/database/postgresql-rls-rollout.md @@ -19,7 +19,7 @@ RLS는 기존 `ActorContext`, Repository의 `company_id` 조건, tenant-aware DB transaction-local tenant context와 connection pool 비누수 테스트만 준비합니다. 아직 policy를 만들거나 RLS를 활성화하지 않습니다. -현재 `main`의 V1~V5에는 아래 12개 tenant table이 존재합니다. 기반 단계의 제한 +현재 `main`의 V1~V6에는 아래 12개 tenant table이 존재합니다. 기반 단계의 제한 role 테스트는 이 전체 범위에 업무 DML만 허용하고, table owner·DDL·`TRUNCATE`· `REFERENCES` 권한과 RLS 우회 권한이 없음을 확인합니다.