diff --git a/.env.example b/.env.example index 5c9357d..e4662ff 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 @@ -32,8 +35,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 0cc0534..70e315a 100644 --- a/README.md +++ b/README.md @@ -160,19 +160,26 @@ Server는 AI Runtime에 보낼 수 있는 field를 typed DTO로 제한하고, ```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 @@ -182,14 +189,19 @@ 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_ADMIN_DISPLAY_NAME`, `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_ADMIN_DISPLAY_NAME`, `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` | | Workflow Catalog | Knowledge release의 Server용 read-only projection을 시작 시 검증합니다. | `workflow/` | | AI Runtime 계약 | 외부 AI 요청·응답을 allow-list와 version으로 다시 검증합니다. | `aiintegration/` | | Security | JWT에서 ActorContext와 역할을 만들고 VIEWER의 쓰기 요청을 기본 차단합니다. | `SecurityConfig` | @@ -312,13 +324,15 @@ server/ │ ├── application.yaml │ ├── workflow/ │ │ └── catalog-projection.local.json # 개발용 Knowledge projection - │ └── db/migration/ - │ ├── V1__baseline.sql - │ ├── V2__create_auth_company.sql # Auth·Company·Refresh Token - │ ├── V3__create_worker_document.sql # Worker·Document metadata - │ ├── V4__create_task_workflow_core.sql # Task·Checklist·전이 이력 - │ ├── V5__create_approval_audit.sql # 승인·제출·증빙·감사 - │ └── V6__add_user_display_name.sql # 회원가입 담당자 표시 이름 + │ └── db/ + │ ├── migration/ + │ │ ├── V1__baseline.sql + │ │ ├── V2__create_auth_company.sql # Auth·Company·Refresh Token + │ │ ├── V3__create_worker_document.sql # Worker·Document metadata + │ │ ├── V4__create_task_workflow_core.sql # Task·Checklist·전이 이력 + │ │ ├── V5__create_approval_audit.sql # 승인·제출·증빙·감사 + │ │ └── V6__add_user_display_name.sql # 회원가입 담당자 표시 이름 + │ └── migration-postgresql/ # RLS 등 PostgreSQL 전용 migration └── test/ └── java/com/fowoco/server/ ├── architecture/ @@ -347,7 +361,9 @@ server/ - `workflow`은 Knowledge가 배포한 projection을 읽을 뿐 원본 Workflow 정의를 수정하지 않습니다. - `worker`의 `WorkerTaskContextReader`는 #6이 Worker API·도메인을 대신 구현하지 않고 Task 판단에 필요한 최소 상태·날짜만 읽는 내부 경계입니다. - 최상위 `package-info.java`는 기능 경계와 책임을 Git에 남기기 위한 뼈대입니다. 빈 하위 패키지는 미리 만들지 않고 실제 코드가 추가될 때 생성합니다. -- Flyway migration은 적용 후 수정할 수 없으므로 `V2`와 `V3` 빈 파일을 미리 만들지 않습니다. 각각 #4와 #5의 실제 스키마와 함께 추가합니다. +- Flyway migration은 적용 후 수정할 수 없습니다. 후행 migration은 의존하는 + 선행 schema가 `main`에 병합된 뒤 다음 사용 가능한 번호로 만들며, 번호 예약용 빈 + 파일을 추가하지 않습니다. - 테스트 패키지는 구현 패키지를 따라가고, `architecture`에는 향후 ArchUnit 또는 Spring Modulith 경계 검증을 둡니다. ## 어디서 무엇을 찾나요? @@ -367,6 +383,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..91051f4 --- /dev/null +++ b/docs/database/postgresql-rls-rollout.md @@ -0,0 +1,101 @@ +# 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를 활성화하지 않습니다. + +현재 `main`의 V1~V6에는 아래 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에 서로 다른 계정으로 연결합니다. + +| 환경변수 | 용도 | +| --- | --- | +| `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 d51a5a9..5be0ad9 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -98,11 +98,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} @@ -140,8 +149,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: @@ -153,6 +169,8 @@ springdoc: swagger-ui: enabled: false app: + demo-seed: + enabled: false workflow: catalog: location: ${WORKFLOW_CATALOG_LOCATION} diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 8608da9..6996592 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -35,7 +35,10 @@ void migrationsApplyCanonicalServerSchemaOnPostgreSql() 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..b92a64a --- /dev/null +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlTenantDatabaseContextTest.java @@ -0,0 +1,447 @@ +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 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; + 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 %s + TO %s + """.formatted(TENANT_TABLE_SQL, 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 : TENANT_TABLES) { + 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 { + } +}