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
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,17 @@ REFRESH_TOKEN_COOKIE_SAME_SITE=Strict
# local=false, dev/prod=true가 기본입니다. HTTPS 정책을 바꿀 때만 명시하세요.
# REFRESH_TOKEN_COOKIE_SECURE=true

# 계정 연락처 암호화입니다. 실제 키는 Git이 아닌 배포 Secret으로만 주입합니다.
# PII_ENCRYPTION_ENABLED=true
# PII_ENCRYPTION_KEY_BASE64=
PII_ENCRYPTION_KEY_VERSION=local-pii-v1
# 키 교체 중에만 이전 키를 version=base64 형식으로 추가합니다.
# PII_DECRYPTION_KEYS=local-pii-v0=...
# 아래 명령은 one-off 유지보수 프로세스 전용입니다. 일반 Server는 항상 none입니다.
# migrate | verify | restore-plaintext
PII_MAINTENANCE_COMMAND=none
PII_MAINTENANCE_BATCH_SIZE=100

# 비밀번호 재설정 token은 알림 Provider에만 원문을 전달하고 DB에는 SHA-256 hash만 저장합니다.
PASSWORD_RESET_TTL=30m
PASSWORD_RESET_COOLDOWN=1m
Expand Down
6 changes: 6 additions & 0 deletions compose.demo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ services:
DOCUMENT_OCR_ENABLED: ${DOCUMENT_OCR_ENABLED:-false}
OCR_RESULT_ENCRYPTION_KEY_BASE64: ${OCR_RESULT_ENCRYPTION_KEY_BASE64:-}
OCR_RESULT_KEY_VERSION: ${OCR_RESULT_KEY_VERSION:-demo-v1}
PII_ENCRYPTION_ENABLED: ${PII_ENCRYPTION_ENABLED:-false}
PII_ENCRYPTION_KEY_BASE64: ${PII_ENCRYPTION_KEY_BASE64:-}
PII_ENCRYPTION_KEY_VERSION: ${PII_ENCRYPTION_KEY_VERSION:-demo-v1}
PII_DECRYPTION_KEYS: ${PII_DECRYPTION_KEYS:-}
PII_MAINTENANCE_COMMAND: ${PII_MAINTENANCE_COMMAND:-none}
PII_MAINTENANCE_BATCH_SIZE: ${PII_MAINTENANCE_BATCH_SIZE:-100}
FILE_STORAGE_LOCAL_PATH: /app/data/files
DEMO_SEED_ENABLED: ${DEMO_SEED_ENABLED:-false}
DEMO_SEED_ADMIN_PASSWORD: ${DEMO_SEED_ADMIN_PASSWORD:-}
Expand Down
82 changes: 82 additions & 0 deletions docs/deployment-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ Secret은 Git과 Actions 로그에 값을 남기지 않고 `kubectl create secre
| DB | `DB_RUNTIME_USERNAME`, `DB_RUNTIME_PASSWORD` | 애플리케이션 실행 계정 |
| DB | `DB_MIGRATION_USERNAME`, `DB_MIGRATION_PASSWORD` | Flyway 전용 계정 |
| Auth | `JWT_SECRET_BASE64`, `JWT_ISSUER`, `JWT_AUDIENCE` | Access Token 발급·검증 |
| PII | `PII_ENCRYPTION_ENABLED=true` | 계정 연락처 AES-256-GCM 암호화 활성화 |
| PII | `PII_ENCRYPTION_KEY_BASE64` | 현재 32바이트 연락처 암호화 키의 Base64 |
| PII | `PII_ENCRYPTION_KEY_VERSION` | 현재 키 식별 version, 예: `pii-2026-08-v1` |
| PII | `PII_DECRYPTION_KEYS` | 회전 이전 키 목록, `version=base64`를 쉼표로 구분 |
| PII | `PII_MAINTENANCE_COMMAND=none` | 일반 Server의 고정값. 일회성 작업에서만 변경 |
| PII | `PII_MAINTENANCE_BATCH_SIZE=100` | 연락처 전환 작업의 transaction당 처리 건수 |
| Web | `CORS_ALLOWED_ORIGINS` | 실제 Client HTTPS origin만 허용 |
| Catalog | `WORKFLOW_CATALOG_LOCATION` | 검증된 `RELEASED` projection 위치 |
| AI | `AI_RUNTIME_ENABLED=true` | 실제 Runtime 연동 활성화 |
Expand All @@ -53,6 +59,79 @@ Secret은 Git과 Actions 로그에 값을 남기지 않고 `kubectl create secre
| OCR | `OCR_RESULT_ENCRYPTION_KEY_BASE64` | 32바이트 OCR 결과 암호화 키의 Base64 |
| OCR | `OCR_RESULT_KEY_VERSION` | 암호화 키 식별 version |

`PII_ENCRYPTION_KEY_BASE64`와 `PII_DECRYPTION_KEYS`는 Git, DB, 이미지, Issue, 로그에
기록하지 않고 `server-env` Secret으로 주입합니다. 운영에서는 `prod` profile이 PII 암호화를
기본 활성화하므로 현재 키가 없으면 Server가 기동하지 않습니다. 현재 Infra는 Kubernetes
Secret 주입까지 지원하며 AWS KMS·Secrets Manager 자동 동기화는 별도 고도화 범위입니다.

로그인·프로필 수정 시 평문 또는 이전 키 암호문을 현재 키로 다시 암호화하는 방어 로직이
있지만, 계정 접근 빈도에 의존하는 점진 전환을 배포 완료 기준으로 사용하지 않습니다. 초기
전환과 키 회전은 아래 일회성 유지보수 명령으로 모든 행을 명시적으로 처리합니다.

| 명령 | 목적 | 완료 조건 |
| --- | --- | --- |
| `migrate` | 평문과 이전 키 암호문을 현재 키로 전환 | 처리 후 오류 없이 종료 |
| `verify` | 전환 완료 여부 검사 | 평문 0건, 이전 키 0건 |
| `restore-plaintext` | 구버전 애플리케이션 롤백 전 평문 복원 | 암호문 0건 |

유지보수 명령은 PostgreSQL RLS를 우회해야 하므로 일반 Runtime 계정으로 실행되지 않습니다.
`user_account` 소유자, `BYPASSRLS` 또는 Superuser 권한을 가진 **Flyway 전용 계정**을 일회성
프로세스에만 주입합니다. 정상 Deployment의 `PII_MAINTENANCE_COMMAND`는 항상 `none`입니다.

### 최초 암호화 전환

1. DB 백업과 복구 절차를 확인합니다.
2. 새 컬럼과 암호화 코드를 먼저 배포하고 현재 키를 Secret으로 주입합니다.
3. 쓰기 트래픽을 통제한 뒤 일회성 프로세스에서 `migrate`를 실행합니다.
4. 같은 키로 `verify`를 실행해 평문과 이전 키 잔여 건수가 0인지 확인합니다.
5. 정상 Server의 로그인·프로필 조회 Smoke를 수행합니다.

```bash
export SPRING_MAIN_WEB_APPLICATION_TYPE=none
export PII_MAINTENANCE_COMMAND=migrate
export PII_MAINTENANCE_BATCH_SIZE=100
export DB_RUNTIME_USERNAME="$DB_MIGRATION_USERNAME"
export DB_RUNTIME_PASSWORD="$DB_MIGRATION_PASSWORD"
java -jar server.jar

export PII_MAINTENANCE_COMMAND=verify
java -jar server.jar
```

Kubernetes에서는 동일 환경변수를 가진 일회성 Job으로 실행합니다. 일반 Deployment의 Secret을
`migrate`로 바꾸지 않으며, 로그에는 원문·암호문 대신 처리 건수와 키 version만 남습니다.

### 키 회전

1. 새 키와 새 version을 현재 값으로 설정합니다.
2. 직전 키를 `PII_DECRYPTION_KEYS=old-version=old-base64`에 유지합니다.
3. 새 설정을 배포한 뒤 `migrate`, `verify`를 순서대로 실행합니다.
4. DB와 애플리케이션 Smoke를 확인한 뒤 이전 version 잔여 건수가 0일 때만 이전 키를 제거합니다.

### 구버전 애플리케이션 롤백

암호화 도입 이전 버전은 `phone_ciphertext`를 읽지 못하므로 이미지를 먼저 되돌리면 연락처가
빈 값으로 보입니다. 반드시 모든 복호화 키를 유지한 상태에서 쓰기 트래픽을 통제하고
`restore-plaintext`를 먼저 실행합니다. 아래 조회에서 암호문 0건을 확인한 다음에만 구버전
이미지를 배포합니다. 장애 수정 후에는 다시 `migrate`, `verify`를 수행하는 전진 복구를
우선합니다.

```sql
SELECT COUNT(*) AS legacy_plaintext_phone_count
FROM user_account
WHERE phone IS NOT NULL;

SELECT phone_key_version, COUNT(*) AS encrypted_phone_count
FROM user_account
WHERE phone_ciphertext IS NOT NULL
GROUP BY phone_key_version
ORDER BY phone_key_version;

SELECT COUNT(*) AS remaining_encrypted_phone_count
FROM user_account
WHERE phone_ciphertext IS NOT NULL;
```

비밀번호 재설정 메일을 실제로 발송할 때만 다음 값을 `server-env`에 추가합니다. 기본
`PASSWORD_RESET_NOTIFICATION_PROVIDER=none`에서는 메일을 발송하지 않습니다.

Expand Down Expand Up @@ -116,6 +195,9 @@ Swagger를 읽기 전용으로 유지합니다. HTTP 주소를 임시로 넣어
```bash
export DEMO_DB_PASSWORD='local-demo-password'
export JWT_SECRET_BASE64="$(openssl rand -base64 32)"
export PII_ENCRYPTION_ENABLED=true
export PII_ENCRYPTION_KEY_BASE64="$(openssl rand -base64 32)"
export PII_ENCRYPTION_KEY_VERSION='local-pii-v1'
export DEMO_SEED_ENABLED=true
export DEMO_SEED_ADMIN_PASSWORD='로컬 전용 12자 이상 값'
docker compose -f compose.demo.yml up --build
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.fowoco.server.auth.infrastructure.crypto;

import java.util.UUID;

public interface AccountPiiCipher {

boolean isAvailable();

String currentKeyVersion();

default boolean requiresReEncryption(String keyVersion) {
return isAvailable() && !currentKeyVersion().equals(keyVersion);
}

EncryptedValue encrypt(String plaintext, UUID companyId, UUID userId, String fieldName);

String decrypt(
String ciphertext,
String keyVersion,
UUID companyId,
UUID userId,
String fieldName
);

record EncryptedValue(String ciphertext, String keyVersion) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.fowoco.server.auth.infrastructure.crypto;

import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(AccountPiiProperties.class)
public class AccountPiiCryptoConfiguration {

@Bean
public AccountPiiCipher accountPiiCipher(AccountPiiProperties properties) {
if (!properties.isEnabled()) {
return new DisabledAccountPiiCipher();
}
properties.validateEnabledConfiguration();
Map<String, byte[]> keys = parsePreviousKeys(properties.getDecryptionKeys());
String currentVersion = properties.getCurrentKeyVersion().strip();
byte[] currentKey = decodeKey(properties.getCurrentKeyBase64(), "PII_ENCRYPTION_KEY_BASE64");
byte[] conflictingKey = keys.put(currentVersion, currentKey);
if (conflictingKey != null && !Arrays.equals(conflictingKey, currentKey)) {
throw new IllegalStateException("current PII key version conflicts with PII_DECRYPTION_KEYS");
}
return new AesGcmAccountPiiCipher(keys, currentVersion, new SecureRandom());
}

private Map<String, byte[]> parsePreviousKeys(String configuredKeys) {
Map<String, byte[]> keys = new LinkedHashMap<>();
if (configuredKeys == null || configuredKeys.isBlank()) {
return keys;
}
for (String entry : configuredKeys.split(",")) {
String[] pair = entry.strip().split("=", 2);
if (pair.length != 2) {
throw new IllegalStateException(
"PII_DECRYPTION_KEYS must use version=base64 entries separated by commas"
);
}
String version = pair[0].strip();
AccountPiiProperties.validateKeyVersion(version, "PII_DECRYPTION_KEYS version");
byte[] previous = keys.put(version, decodeKey(pair[1], "PII_DECRYPTION_KEYS"));
if (previous != null) {
throw new IllegalStateException("PII_DECRYPTION_KEYS contains a duplicate version");
}
}
return keys;
}

private byte[] decodeKey(String value, String fieldName) {
try {
byte[] decoded = Base64.getDecoder().decode(value.strip());
if (decoded.length != 32) {
throw new IllegalStateException(fieldName + " must decode to 32 bytes");
}
return decoded;
} catch (IllegalArgumentException exception) {
throw new IllegalStateException(fieldName + " is not valid Base64", exception);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.fowoco.server.auth.infrastructure.crypto;

import com.fowoco.server.auth.infrastructure.crypto.AccountPiiMaintenanceService.EncryptionInventory;
import com.fowoco.server.auth.infrastructure.crypto.AccountPiiMaintenanceService.MaintenanceResult;
import java.util.Locale;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
final class AccountPiiMaintenanceCommandRunner implements ApplicationRunner {

private static final Logger LOGGER = LoggerFactory.getLogger(
AccountPiiMaintenanceCommandRunner.class
);

private final AccountPiiMaintenanceService service;
private final ConfigurableApplicationContext applicationContext;
private final String command;
private final int batchSize;

AccountPiiMaintenanceCommandRunner(
AccountPiiMaintenanceService service,
ConfigurableApplicationContext applicationContext,
@Value("${app.auth.pii.maintenance-command:none}") String command,
@Value("${app.auth.pii.maintenance-batch-size:100}") int batchSize
) {
this.service = Objects.requireNonNull(service, "service must not be null");
this.applicationContext = Objects.requireNonNull(
applicationContext,
"applicationContext must not be null"
);
this.command = Objects.requireNonNull(command, "command must not be null");
this.batchSize = batchSize;
}

@Override
public void run(ApplicationArguments arguments) {
Command parsed = Command.parse(command);
if (parsed == Command.NONE) {
return;
}
switch (parsed) {
case MIGRATE -> logResult(parsed, service.migrateToCurrentKey(batchSize));
case VERIFY -> logInventory(parsed, service.verifyCurrentKey(), 0);
case RESTORE_PLAINTEXT -> logResult(parsed, service.restorePlaintext(batchSize));
case NONE -> throw new IllegalStateException("unreachable account PII command");
}
applicationContext.close();
}

private void logResult(Command command, MaintenanceResult result) {
logInventory(command, result.inventory(), result.processedCount());
}

private void logInventory(
Command command,
EncryptionInventory inventory,
int processedCount
) {
LOGGER.info(
"account_pii_maintenance command={} processed_count={} account_count={} "
+ "plaintext_count={} encrypted_count={} current_key_count={} "
+ "stale_key_count={} current_key_version={}",
command.externalName(),
processedCount,
inventory.accountCount(),
inventory.plaintextCount(),
inventory.encryptedCount(),
inventory.currentKeyCount(),
inventory.staleKeyCount(),
inventory.currentKeyVersion()
);
}

private enum Command {
NONE("none"),
MIGRATE("migrate"),
VERIFY("verify"),
RESTORE_PLAINTEXT("restore-plaintext");

private final String externalName;

Command(String externalName) {
this.externalName = externalName;
}

String externalName() {
return externalName;
}

static Command parse(String value) {
String normalized = value.strip().toLowerCase(Locale.ROOT);
for (Command command : values()) {
if (command.externalName.equals(normalized)) {
return command;
}
}
throw new IllegalStateException(
"app.auth.pii.maintenance-command must be one of "
+ "none, migrate, verify, restore-plaintext"
);
}
}
}
Loading
Loading