{ "schema_version": "1.0", "document": "/home/donghyeon/workspace/chat-gpt-container/document-haness/docs/clean-architecture-backend-template/final/document.md", "document_sha256": "8071fe71b3359d9cf60b95909c26c7b50653ce2f22bbc5fcf6988719bb91236d", "line_count": 47035, "line_number_space": "canonical-source-with-managed-blocks-collapsed", "anchor": { "kind": "line", "value": 37688, "line": 37688 }, "current_section": { "heading": { "line": 37688, "level": 3, "text": "messaging-security 완전 해부" }, "start_line": 37688, "end_line": 38431, "text": "### messaging-security 완전 해부\n\n> 상태: COMPLETE\n> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`\n> 분석 범위: `src/messaging/messaging-security`\n> SSOT owner: `messaging-security`\n> integration/family document: §A19 (secondary, INTEGRATION_ONLY)\n\n---\n\n#### 0. SSOT identity / 커버리지와 숫자 지도\n\n- registered leaf id: `messaging-security`\n- canonical state `analysisFile`: §A19-MESSAGING-SECURITY\n- source path: `src/messaging/messaging-security`\n- registry `allowed_dependencies`: `[\"messaging-core-api\"]`\n- registry `runtime_memberships`: `[\"app-bootstrap\"]`\n\n##### 숫자\n\n| 항목 | 수 |\n|---|---:|\n| production Java 파일 | 12 |\n| production LOC | 954 |\n| 패키지 | 1 (`dev.caskeleton.messaging.security`) |\n| test 파일 | 3 |\n| test 메서드(실행 확인) | 24 |\n| 외부(비프로젝트) 의존성 | **0** |\n\n12개 타입을 세 축으로:\n\n| 축 | 타입 | leaf 밖 소비 파일 |\n|---|---|---:|\n| **자격증명 수명주기** | `CredentialProvider` · `CredentialRuntime` · `CredentialRuntimeRegistry` · `CredentialRotationPlan` · `CredentialIds`(package-private) | 4 · 2 · 6 · **0** · 0 |\n| **연결 posture** | `BrokerSecurityProfile` · `BrokerCredentialProfile` · `BrokerTlsPolicy` · `MessageSecurityValidator` | 8 · 5 · 6 · 1 |\n| **권한** | `DestinationAccessPolicy` · `DestinationAccessValidator` · `BrokerAclManifest` | 7 · **0** · **0** |\n\n##### Coverage ledger\n\n| scope/file group | count | disposition | reason |\n|---|---:|---|---|\n| `src/main/java/**` (12) | 12 | `FULL_READ` | 전 파일 본문 확인 |\n| `src/test/java/**` (3) | 3 | `FULL_READ` | 테스트명·단언 전수 확인 |\n| `build.gradle` | 1 | `FULL_READ` | 5줄 |\n| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |\n| `build/**` | — | `EXCLUDED` | 빌드 산출물 |\n\n`UNCLASSIFIED` 0.\n\n---\n\n#### 1. 모듈의 정체와 경계\n\n이 leaf는 **\"브로커에 연결하기 전에 무엇이 참이어야 하는가\"**를 소유한다. 벤더 의존성이 0이고 브로커를 만지지 않는다 — 어댑터의 security configurer가 이 leaf의 타입을 받아 실제 클라이언트 설정을 만든다.\n\n세 가지 원칙이 코드 전반에 반복된다.\n\n**(a) 비밀은 참조로만 다룬다.**\n\n```java\n// BrokerCredentialProfile.java:5-8\n *
No variant carries a secret. The platform stores an identifier and resolves the material\n * through a {@link CredentialProvider} at connect time, so a rotation is a provider concern and a\n * heap dump or configuration print never yields a usable credential.\n```\n\n`BrokerCredentialProfile`의 다섯 변형 전부가 `credentialId` 하나만 갖는다 — `SaslScram`, `OAuth2`, `MutualTls`, `UsernamePassword`, `Nkey`. sealed interface이므로 여섯 번째를 만들려면 이 파일을 고쳐야 한다.\n\n**(b) 타입이 통제의 일부다.**\n\n```java\n// CredentialRuntime.java:13-15\n *
Holds the material in a {@code char[]} that {@link #clear()} overwrites. A {@code String}\n * cannot be erased — it stays in the constant pool and in every heap dump taken until the next GC\n * decides otherwise — so the type of the field is itself part of the control.\n```\n\n`CredentialProvider.resolve`가 `char[]`을 반환하고 `CredentialRuntime`이 그것을 참조로 보관하며 `clear()`가 `Arrays.fill(material, '\\0')` 후 빈 배열로 교체한다.\n\n**(c) 역할 분리가 강제된다.** `BrokerSecurityProfile`이 producer·consumer·admin 세 자격증명을 **별도 필드**로 갖는다.\n\n```java\n// BrokerSecurityProfile.java:9-11\n *
Producer, consumer, and admin credentials are separate fields rather than one connection\n * credential. That separation is what makes \"an application cannot purge a topic\" enforceable: the\n * runtime never holds admin material, so a compromised handler has nothing to escalate with.\n```\n\n---\n\n#### 2. 의존성과 런타임 배선\n\n들어오는 것: `messaging-core-api`(api) 하나.\n\n나가는 것: `messaging-runtime-core`, `messaging-kafka`, `messaging-rabbit`, `messaging-admin-runtime`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-boot-starter`.\n\n**이 leaf는 messaging family에서 배선이 가장 잘 된 축에 속한다.** 어댑터 두 곳이 직접 소비한다.\n\n| 소비자 | 무엇을 쓰는가 |\n|---|---|\n| `messaging-kafka/KafkaSecurityConfigurer` | `BrokerTlsPolicy`, `CredentialRuntimeRegistry`, `CredentialProvider` |\n| `messaging-rabbit/RabbitSecurityConfigurer` | `BrokerTlsPolicy`, `CredentialRuntimeRegistry` |\n| `messaging-runtime-core/DefaultMessagePublisher` | `DestinationAccessPolicy` |\n| `messaging-runtime-core/DeclaredDestinationAccess` | `DestinationAccessPolicy` |\n| starter `MessagingCoreAutoConfiguration` | `MessageSecurityValidator`·`BrokerTlsPolicy`·`CredentialRuntimeRegistry` bean |\n| starter `MessagingCredentialRequirementValidator` | `CredentialProvider` |\n| starter `Kafka/RabbitMessagingAutoConfiguration` | `BrokerTlsPolicy`, `CredentialRuntimeRegistry` |\n\n이 leaf 자체는 Spring 주석을 갖지 않는다.\n\n---\n\n#### 3. 패키지/컴포넌트 지도\n\n```\n자격증명 수명주기\n CredentialProvider (port)\n ↓ resolve(id) → char[] / expiresAt(id) → Optional Rotation is driven from the expiry, ahead of it. Waiting for the broker to start refusing\n * connections turns a scheduled, invisible rotation into an outage.\n```\n\n`DEFAULT_ROTATION_LEAD = 30분`. `isDueForRotation(now)`가 `!now.isBefore(expiry.minus(rotationLead))`다 — 만료 30분 전부터 참이고 만료 후에도 참이다.\n\n`expiresAt`이 비어 있으면 **둘 다 false**다 — 만료를 모르는 자격증명은 회전 대상도 만료 대상도 아니다. `orElse(false)`가 그 선택을 명시한다.\n\n##### 4.4 `BrokerTlsPolicy` — 허용목록과 두 단계 실패\n\n```java\n// :15-17\n * Disabling hostname verification is treated as a separate, worse failure than disabling TLS.\n * Plaintext is at least obviously insecure, whereas TLS without hostname verification looks\n * encrypted in every dashboard while accepting any certificate a man in the middle presents.\n```\n\n네 가지 거절:\n\n| 코드 | 조건 |\n|---|---|\n| `TLS_REQUIRED` | TLS 꺼짐 && (production \\|\\| 평문 비허용) |\n| `HOSTNAME_VERIFICATION_REQUIRED` | TLS 켜짐 && hostname 검증 꺼짐 |\n| `TLS_PROTOCOL_NOT_ACCEPTED` | 프로토콜이 `{TLSv1.2, TLSv1.3}` 밖 |\n| `TLS_PROTOCOL_UNSPECIFIED` | TLS 켜짐인데 프로토콜 목록이 비어 있음 |\n\n**허용목록을 고른 이유가 적혀 있다.**\n\n```java\n// :72-78\n// An allowlist, not a denylist.\n//\n// The denylist named the old versions somebody thought of, so `SSL`, `TLSv0.9`, `PLAINTEXT`\n// and any typo passed — and a protocol string the JVM does not recognise is negotiated as\n// whatever the JVM defaults to, which is the outcome this policy exists to prevent. Naming the\n// two acceptable versions means an unknown string fails here rather than at connect time on a\n// production broker.\n```\n\n`messaging-schema-api`의 `SchemaCompatibilityValidator`가 허용목록이고 `AvroCompatibilityGate`가 거부목록인 것(그쪽 §12.3)과 같은 축의 판단이며, 여기서는 허용목록을 고른 이유가 명시돼 있다.\n\n**네 번째 검사에 순서 문제가 있다.** `TLS_PROTOCOL_UNSPECIFIED`가 `TLS_PROTOCOL_NOT_ACCEPTED` **뒤에** 있는데, 빈 목록은 `filter`를 통과하는 요소가 없으므로 `unsupported`가 비어 있어 앞 검사를 지나간다. 결과적으로 빈 목록은 네 번째에서 잡힌다 — 동작은 맞다. 다만 읽는 순서와 논리 순서가 다르다.\n\n##### 4.5 `MessageSecurityValidator` — 시작 시 네 가지\n\n```java\n// :9-12\n * These checks are boot failures rather than warnings. An unencrypted production broker\n * connection or a shared producer/admin credential is not a degraded mode the platform can run in\n * safely; both are the kind of misconfiguration that stays invisible until it is exploited.\n```\n\n| # | 거절 조건 |\n|---:|---|\n| 1 | production && TLS 꺼짐 |\n| 2 | production && hostname 검증 꺼짐 |\n| 3 | producer와 consumer가 같은 credential id |\n| 4 | admin이 producer/consumer와 같은 credential id |\n| 5 | production && admin 존재 |\n\n3·4번을 `LinkedHashSet.add`의 반환값으로 구현한다 — 추가에 실패하면 중복이다. 간결하고 정확하다.\n\n5번이 (c) 원칙을 강제하는 지점이다 — **운영 런타임은 admin 자격증명을 아예 갖지 못한다.**\n\n1·2번이 `BrokerTlsPolicy`와 겹친다(§12.3).\n\n##### 4.6 `BrokerAclManifest` — 초과가 발견이다\n\n```java\n// :113-118\n * Excess is the finding, not the shortfall: a missing grant fails loudly on first use, while\n * an undeclared extra one sits unnoticed until it is abused.\n```\n\n`undeclared(observed)`가 관측 − 선언, `missing(observed)`가 선언 − 관측이다. 두 방향을 모두 계산하지만 javadoc이 어느 쪽이 발견인지 정한다.\n\n`Operation` enum이 파괴적 여부를 상수에 담는다 — `ALTER`, `DELETE`, `PURGE`가 `destructive=true`.\n\n```java\n// :17-20\n * Destructive permissions are named separately from ordinary ones. {@code DELETE_TOPIC} and\n * {@code PURGE} are not \"write, but more\"; they destroy data an application can never restore, so\n * an application runtime declaring one is rejected outright.\n```\n\n`requireApplicationRuntime()`이 파괴적 grant가 하나라도 있으면 `MessagingConfigurationException(\"APPLICATION_HOLDS_DESTRUCTIVE_GRANT\")`을 던진다.\n\n**이 클래스 전체가 소비자 0이다**(§12.1).\n\n`undeclared`/`missing`이 `Set The bounded slug pattern is not cosmetic. Credential ids reach log lines and metric tags, so\n * an unbounded id is a cardinality problem, and an id that looks like a secret is a leak. The\n * heuristic check rejects the most common accident: pasting the secret itself where the reference\n * belongs.\n```\n\n패턴 `[a-z0-9][a-z0-9._-]{1,63}` — 최소 2자, 최대 64자.\n\n휴리스틱 접두사 다섯: `bearer `, `basic `, `sk-`, `-----begin`, `eyj`. 각각 HTTP Authorization, OpenAI 키, PEM 블록, base64 JWT 헤더(`{\"` → `eyJ`)를 노린다.\n\n**패턴이 이미 대부분을 막는다.** `[a-z0-9._-]`만 허용하므로 공백이 있는 `bearer `·`basic `는 패턴에서 이미 거절되고, `-----begin`은 첫 글자가 `-`라 거절된다. 실제로 휴리스틱만이 잡는 것은 `sk-`와 `eyj`뿐이다. 중복 방어이고 해롭지 않다.\n\n##### 4.8 `DestinationAccessPolicy` — 세 역할, 세 집합\n\n`publishable`/`consumable`/`administrable` 셋이 전부 `Set.copyOf`로 불변화된다. `denyAll()`이 세 빈 집합이다.\n\n```java\n// :10-12\n * The platform checks this before the broker does. Relying only on broker ACLs means an\n * accidental publish surfaces as a generic authorization error at runtime, in the adapter, with no\n * record of which application module attempted it.\n```\n\n`DestinationAccessValidator`가 세 `require*` 메서드로 그 검사를 예외로 바꾼다 — 그리고 소비자가 0이다(§12.1).\n\n---\n\n#### 5. 주요 실행 경로\n\n**자격증명 해석:** 어댑터의 security configurer → `registry.resolve(credentialId, now)` → 캐시 유효하면 반환 → 아니면 `compute` 안에서 `provider.resolve` + `provider.expiresAt` → 새 `CredentialRuntime` 설치 → 옛 것 `clear()`\n\n**시작 검증(1):** starter가 `MessageSecurityValidator` bean 생성 → `validate(profile)` 호출 지점은 starter가 소유\n\n**시작 검증(2):** 어댑터 configurer가 `BrokerTlsPolicy.validate(profile, enabledProtocols)` 호출\n\n**발행 권한:** `DefaultMessagePublisher` → `access.mayPublish(name)` → false면 `PublishResult(REJECTED, PUBLISH_FORBIDDEN)`\n\n---\n\n#### 6. 실패 경로와 복구/번역\n\n| 코드 | 예외 | 위치 |\n|---|---|---|\n| `TLS_REQUIRED` | `MessagingConfigurationException` | `BrokerTlsPolicy` |\n| `HOSTNAME_VERIFICATION_REQUIRED` | `MessagingConfigurationException` | 같음 |\n| `TLS_PROTOCOL_NOT_ACCEPTED` | `MessagingConfigurationException` | 같음 |\n| `TLS_PROTOCOL_UNSPECIFIED` | `MessagingConfigurationException` | 같음 |\n| `APPLICATION_HOLDS_DESTRUCTIVE_GRANT` | `MessagingConfigurationException` | `BrokerAclManifest`(미사용) |\n| `DESTINATION_PUBLISH_DENIED` | `MessageAuthorizationException` | `DestinationAccessValidator`(미사용) |\n| `DESTINATION_CONSUME_DENIED` | `MessageAuthorizationException` | 같음(미사용) |\n| `DESTINATION_ADMIN_DENIED` | `MessageAuthorizationException` | 같음(미사용) |\n| (코드 없음) | `IllegalArgumentException` × 5 | `MessageSecurityValidator` |\n| (코드 없음) | `IllegalArgumentException` | `CredentialIds`, 각 생성자 |\n| (코드 없음) | `IllegalStateException` | `CredentialRuntime.material()` 소거 후 |\n\n**보안 판정이 두 예외 계층으로 나뉜다.** `BrokerTlsPolicy`는 안정 코드가 붙은 `MessagingConfigurationException`을 쓰고, `MessageSecurityValidator`는 코드 없는 `IllegalArgumentException`을 쓴다. 둘이 같은 두 검사(TLS·hostname)를 공유하는데도 그렇다 — §12.3, §17.\n\n---\n\n#### 7. 트랜잭션·동시성·수명주기\n\n트랜잭션 없음.\n\n| 지점 | 도구 | 보호 |\n|---|---|---|\n| `CredentialRuntimeRegistry.resolved` | `ConcurrentHashMap` | 맵 자체 |\n| `resolve` | `compute`(bin 락) | key별 single-flight, fetch 정확히 한 번 |\n| `CredentialRuntime.material` | **동기화 없음** | (§17) |\n\n레코드 여섯(`BrokerSecurityProfile`, `BrokerCredentialProfile` 5변형, `DestinationAccessPolicy`, `BrokerAclManifest`, `CredentialRotationPlan`)은 전부 불변이다. `BrokerTlsPolicy`·`MessageSecurityValidator`·`DestinationAccessValidator`는 상태가 없거나 불변 참조만 갖는다.\n\n수명주기 참여는 `clearAll()`뿐이고 \"for shutdown\"이라고 javadoc이 적는다. **그것을 부르는 코드가 저장소에 없다** — 종료 시 자격증명이 소거되지 않는다. §17.\n\n---\n\n#### 8. 설정·기능 플래그·환경 차이\n\n| 상수/기본값 | 값 | 위치 |\n|---|---|---|\n| `CredentialRuntime.DEFAULT_ROTATION_LEAD` | 30분 | public |\n| `BrokerTlsPolicy.MINIMUM_PROTOCOL` | `\"TLSv1.2\"` | public |\n| `BrokerTlsPolicy.ACCEPTED_PROTOCOLS` | `{TLSv1.2, TLSv1.3}` | private |\n| `BrokerTlsPolicy()` 기본 | `allowPlaintextOutsideProduction = true` | — |\n| `CredentialIds.VALID` | `[a-z0-9][a-z0-9._-]{1,63}` | private |\n\n`production` 플래그가 세 클래스의 분기 조건이다 — `BrokerTlsPolicy`, `MessageSecurityValidator`, 그리고 `BrokerSecurityProfile`의 필드. 그 값을 정하는 곳은 이 leaf 밖이다.\n\n**`MINIMUM_PROTOCOL`이 public이고 아무도 쓰지 않는다.** `ACCEPTED_PROTOCOLS`가 private이므로 외부에서 허용 집합을 알려면 `isAcceptable(String)`을 부르거나 이 상수를 보는데, 상수는 최소값만 알려준다.\n\n---\n\n#### 9. 퍼시스턴스/외부 시스템 세부\n\n없다. `CredentialProvider`가 외부 비밀 저장소를 가리킬 수 있는 port이고 이 leaf에 구현이 없다.\n\n---\n\n#### 10. 테스트 레인과 실제 증명 범위\n\n레인: `./gradlew :messaging:messaging-security:test`. **BUILD SUCCESSFUL, 24 tests, 0 skipped, 0 failures**.\n\n| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |\n|---|---:|---|---|\n| `CredentialRuntimeRegistryTest` | 13 | 해석·캐시·회전·소거·경합 하 single-flight | 실제 비밀 저장소 |\n| `MessageSecurityValidatorTest` | 7 | 다섯 거절 조건 | 실제 부팅에서 호출되는지(→ starter가 bean 생성) |\n| `CredentialRotationContractTest` | 4 | 회전 시점 술어 | **`CredentialRotationPlan`이 쓰이는지** |\n\n**커버리지 공백 셋.**\n\n- `BrokerTlsPolicy`를 겨냥한 테스트 클래스가 **없다.** 네 거절 조건과 허용목록 판정이 이 leaf의 테스트로 검증되지 않는다. 어댑터 쪽 `KafkaSecurityConfigurerTest`가 간접적으로 지나갈 수 있으나 그것은 다른 leaf의 레인이고 다른 것을 목표로 한다.\n- `BrokerAclManifest`를 겨냥한 테스트가 **없다.** `undeclared`/`missing`/`requireApplicationRuntime` 셋 다 미검증이다.\n- `DestinationAccessValidator`·`DestinationAccessPolicy`를 겨냥한 테스트가 **없다.**\n\n즉 **12개 타입 중 5개가 이 leaf의 테스트에 등장하지 않는다.** 그리고 그중 셋은 §12.1의 소비자 0 목록과 겹친다 — 쓰이지도 않고 테스트되지도 않는다.\n\n---\n\n#### 11. 빌드/ArchUnit/CI 강제 지점\n\n| 게이트 | 이 leaf에 대해 |\n|---|---|\n| `verifyCleanArchitectureDependencies` | `[\"messaging-core-api\"]` |\n| `verifyRuntimeModuleMembership` | `[\"app-bootstrap\"]` |\n| vendor `api` 규칙 | 벤더 의존성 0 |\n| `SecretLeakStaticScanTest`(observability leaf) | **이 leaf의 소스도 스캔 대상** — 콘솔 출력·민감 식별자 문자열 연결 금지 |\n| ArchUnit | 전용 규칙 없음 |\n\n네 번째가 이 leaf에 실질적이다. `CredentialRuntime.toString()`이 material을 빼고 id와 expiry만 담는 것, `MessagingRedactor`가 credential 키를 지우는 것과 함께 **세 층의 방어**를 이룬다 — 타입(`char[]`), 표현(`toString`), 정적 스캔.\n\n---\n\n#### 12. 실제 사용 여부와 negative-space probes\n\n원시 증거: `evidence/raw/287-messaging-security-duplicate-checks.txt`.\n\n> **방법.** 정규화된 이름(`import dev.caskeleton.messaging.security.