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
12 changes: 8 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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=
Expand Down
47 changes: 32 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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` |
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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 경계 검증을 둡니다.

## 어디서 무엇을 찾나요?
Expand All @@ -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) |

## 변하지 않는 보안 원칙
Expand Down
101 changes: 101 additions & 0 deletions docs/database/postgresql-rls-rollout.md
Original file line number Diff line number Diff line change
@@ -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`는 사용하지 않습니다.
Original file line number Diff line number Diff line change
@@ -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.");
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
26 changes: 22 additions & 4 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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:
Expand All @@ -153,6 +169,8 @@ springdoc:
swagger-ui:
enabled: false
app:
demo-seed:
enabled: false
workflow:
catalog:
location: ${WORKFLOW_CATALOG_LOCATION}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading