# messaging-security 완전 해부 > 상태: COMPLETE > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분석 범위: `src/messaging/messaging-security` > SSOT owner: `messaging-security` > integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY) --- ## 0. SSOT identity / 커버리지와 숫자 지도 - registered leaf id: `messaging-security` - canonical state `analysisFile`: `analysis/messaging/messaging-security.md` - source path: `src/messaging/messaging-security` - registry `allowed_dependencies`: `["messaging-core-api"]` - registry `runtime_memberships`: `["app-bootstrap"]` ### 숫자 | 항목 | 수 | |---|---:| | production Java 파일 | 12 | | production LOC | 954 | | 패키지 | 1 (`dev.caskeleton.messaging.security`) | | test 파일 | 3 | | test 메서드(실행 확인) | 24 | | 외부(비프로젝트) 의존성 | **0** | 12개 타입을 세 축으로: | 축 | 타입 | leaf 밖 소비 파일 | |---|---|---:| | **자격증명 수명주기** | `CredentialProvider` · `CredentialRuntime` · `CredentialRuntimeRegistry` · `CredentialRotationPlan` · `CredentialIds`(package-private) | 4 · 2 · 6 · **0** · 0 | | **연결 posture** | `BrokerSecurityProfile` · `BrokerCredentialProfile` · `BrokerTlsPolicy` · `MessageSecurityValidator` | 8 · 5 · 6 · 1 | | **권한** | `DestinationAccessPolicy` · `DestinationAccessValidator` · `BrokerAclManifest` | 7 · **0** · **0** | ### Coverage ledger | scope/file group | count | disposition | reason | |---|---:|---|---| | `src/main/java/**` (12) | 12 | `FULL_READ` | 전 파일 본문 확인 | | `src/test/java/**` (3) | 3 | `FULL_READ` | 테스트명·단언 전수 확인 | | `build.gradle` | 1 | `FULL_READ` | 5줄 | | `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 | | `build/**` | — | `EXCLUDED` | 빌드 산출물 | `UNCLASSIFIED` 0. --- ## 1. 모듈의 정체와 경계 이 leaf는 **"브로커에 연결하기 전에 무엇이 참이어야 하는가"**를 소유한다. 벤더 의존성이 0이고 브로커를 만지지 않는다 — 어댑터의 security configurer가 이 leaf의 타입을 받아 실제 클라이언트 설정을 만든다. 세 가지 원칙이 코드 전반에 반복된다. **(a) 비밀은 참조로만 다룬다.** ```java // BrokerCredentialProfile.java:5-8 *

No variant carries a secret. The platform stores an identifier and resolves the material * through a {@link CredentialProvider} at connect time, so a rotation is a provider concern and a * heap dump or configuration print never yields a usable credential. ``` `BrokerCredentialProfile`의 다섯 변형 전부가 `credentialId` 하나만 갖는다 — `SaslScram`, `OAuth2`, `MutualTls`, `UsernamePassword`, `Nkey`. sealed interface이므로 여섯 번째를 만들려면 이 파일을 고쳐야 한다. **(b) 타입이 통제의 일부다.** ```java // CredentialRuntime.java:13-15 *

Holds the material in a {@code char[]} that {@link #clear()} overwrites. A {@code String} * cannot be erased — it stays in the constant pool and in every heap dump taken until the next GC * decides otherwise — so the type of the field is itself part of the control. ``` `CredentialProvider.resolve`가 `char[]`을 반환하고 `CredentialRuntime`이 그것을 참조로 보관하며 `clear()`가 `Arrays.fill(material, '\0')` 후 빈 배열로 교체한다. **(c) 역할 분리가 강제된다.** `BrokerSecurityProfile`이 producer·consumer·admin 세 자격증명을 **별도 필드**로 갖는다. ```java // BrokerSecurityProfile.java:9-11 *

Producer, consumer, and admin credentials are separate fields rather than one connection * credential. That separation is what makes "an application cannot purge a topic" enforceable: the * runtime never holds admin material, so a compromised handler has nothing to escalate with. ``` --- ## 2. 의존성과 런타임 배선 들어오는 것: `messaging-core-api`(api) 하나. 나가는 것: `messaging-runtime-core`, `messaging-kafka`, `messaging-rabbit`, `messaging-admin-runtime`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-boot-starter`. **이 leaf는 messaging family에서 배선이 가장 잘 된 축에 속한다.** 어댑터 두 곳이 직접 소비한다. | 소비자 | 무엇을 쓰는가 | |---|---| | `messaging-kafka/KafkaSecurityConfigurer` | `BrokerTlsPolicy`, `CredentialRuntimeRegistry`, `CredentialProvider` | | `messaging-rabbit/RabbitSecurityConfigurer` | `BrokerTlsPolicy`, `CredentialRuntimeRegistry` | | `messaging-runtime-core/DefaultMessagePublisher` | `DestinationAccessPolicy` | | `messaging-runtime-core/DeclaredDestinationAccess` | `DestinationAccessPolicy` | | starter `MessagingCoreAutoConfiguration` | `MessageSecurityValidator`·`BrokerTlsPolicy`·`CredentialRuntimeRegistry` bean | | starter `MessagingCredentialRequirementValidator` | `CredentialProvider` | | starter `Kafka/RabbitMessagingAutoConfiguration` | `BrokerTlsPolicy`, `CredentialRuntimeRegistry` | 이 leaf 자체는 Spring 주석을 갖지 않는다. --- ## 3. 패키지/컴포넌트 지도 ``` 자격증명 수명주기 CredentialProvider (port) ↓ resolve(id) → char[] / expiresAt(id) → Optional CredentialRuntimeRegistry ──compute(single-flight)──> CredentialRuntime │ ├── material() → clone │ ├── isDueForRotation(now) │ ├── isExpired(now) └── dueForRotation / expired / clearAll └── clear() → 덮어쓰기 CredentialRotationPlan ← 같은 술어를 다시 구현, 소비자 0 (§12.3) 연결 posture BrokerSecurityProfile ─┬─ BrokerCredentialProfile (sealed, 5변형) ── CredentialIds └─ DestinationAccessPolicy BrokerTlsPolicy.validate(profile, protocols) ← 어댑터가 호출 MessageSecurityValidator.validate(profile) ← starter bean, 검사 범위가 겹침 (§12.3) 권한 DestinationAccessPolicy (publishable / consumable / administrable) DestinationAccessValidator ← 소비자 0 (§12.1) BrokerAclManifest ← 소비자 0 (§12.1) ``` --- ## 4. 계약·불변식·상태 모델 ### 4.1 `CredentialRuntimeRegistry.resolve` — key별 single-flight 이 leaf에서 가장 조밀한 동시성 코드이고, 이전 결함이 주석에 통째로 남아 있다. ```java // :62-70 // Single-flight, keyed by credential id. // // get → fetch → put → clear had no synchronization at all. Two callers rotating the same // credential both read the same old runtime and both fetched a replacement: one replacement // was lost from the map without ever being cleared — a secret left in memory that nothing owns // — and the caller that lost the race could clear material the winner was still using. // // compute holds the bin lock for this key, so exactly one fetch publishes and the previous // generation is retired by that same caller. ``` **두 개의 서로 다른 결함이 한 경합에서 나왔다.** 1. 진 쪽의 교체본이 맵에서 사라지고 `clear()`도 안 됨 → **소유자 없는 비밀이 힙에 남음** 2. 진 쪽이 이긴 쪽이 쓰고 있는 material을 `clear()`할 수 있음 → **사용 중인 자격증명이 지워짐** 현재 구현: ```java CredentialRuntime current = resolved.get(credentialId); if (current != null && !current.isDueForRotation(now)) { return current; // 락 없는 빠른 경로 } return resolved.compute(credentialId, (key, existing) -> { if (existing != null && !existing.isDueForRotation(now)) { return existing; // 대기 중 다른 스레드가 회전함 } CredentialRuntime replacement = fetch(key, now); if (existing != null) { existing.clear(); // 설치 후에만, 그리고 교체한 스레드만 } return replacement; }); ``` `ConcurrentHashMap.compute`가 해당 bin의 락을 잡으므로 fetch가 정확히 한 번 일어난다. 그리고 **`clear()`가 `replacement` 생성 후에 온다** — 주석이 그 순서의 이유를 적는다: "no reader sees a window with no usable credential — and only by the thread that replaced it, so the material a concurrent reader holds is never wiped underneath it." **대가.** `compute`의 람다 안에서 `provider.resolve(...)`가 호출된다. 즉 **외부 I/O가 맵 bin 락을 잡은 채로 일어난다.** 같은 credential id를 요청하는 다른 스레드는 그 동안 막히고, `ConcurrentHashMap` 문서는 compute 람다 안에서 같은 맵을 갱신하지 말라고 요구한다(여기서는 지켜진다). 다른 키는 다른 bin이면 막히지 않지만 해시 충돌 시 같은 bin이면 막힌다. §17. ### 4.2 `CredentialRuntime` — material의 세 가지 통제 | 통제 | 구현 | |---|---| | 저장 | `char[]`, `String` 아님 | | 반환 | `material.clone()` — "A copy, so a caller that clears its own array cannot blind every other holder" | | 소거 | `Arrays.fill(material, '\0')` 후 `material = new char[0]` | | 소거 후 접근 | `IllegalStateException("credential X has already been cleared")` | | 표현 | `toString()`이 id와 expiry만 — material 없음 | 소거 판정이 `material.length == 0`이다. 생성자가 빈 배열을 거절하므로(`"credential material must not be empty"`) 길이 0은 소거된 상태를 뜻한다 — 별도 플래그 없이 같은 필드로 상태를 표현한다. **`material` 필드가 `volatile`이 아니다.** `clear()`가 다른 스레드에서 호출되면 `material()`이 옛 참조를 볼 수 있다. 실제 경로에서는 `compute` 안에서만 `clear()`가 불리고 그 전에 `replacement`가 맵에 들어가므로 위험이 낮지만, `clearAll()`은 락 없이 순회한다. §17. ### 4.3 회전 시점 — 만료가 아니라 만료 이전 ```java // CredentialRuntime.java:17-18 *

Rotation is driven from the expiry, ahead of it. Waiting for the broker to start refusing * connections turns a scheduled, invisible rotation into an outage. ``` `DEFAULT_ROTATION_LEAD = 30분`. `isDueForRotation(now)`가 `!now.isBefore(expiry.minus(rotationLead))`다 — 만료 30분 전부터 참이고 만료 후에도 참이다. `expiresAt`이 비어 있으면 **둘 다 false**다 — 만료를 모르는 자격증명은 회전 대상도 만료 대상도 아니다. `orElse(false)`가 그 선택을 명시한다. ### 4.4 `BrokerTlsPolicy` — 허용목록과 두 단계 실패 ```java // :15-17 *

Disabling hostname verification is treated as a separate, worse failure than disabling TLS. * Plaintext is at least obviously insecure, whereas TLS without hostname verification looks * encrypted in every dashboard while accepting any certificate a man in the middle presents. ``` 네 가지 거절: | 코드 | 조건 | |---|---| | `TLS_REQUIRED` | TLS 꺼짐 && (production \|\| 평문 비허용) | | `HOSTNAME_VERIFICATION_REQUIRED` | TLS 켜짐 && hostname 검증 꺼짐 | | `TLS_PROTOCOL_NOT_ACCEPTED` | 프로토콜이 `{TLSv1.2, TLSv1.3}` 밖 | | `TLS_PROTOCOL_UNSPECIFIED` | TLS 켜짐인데 프로토콜 목록이 비어 있음 | **허용목록을 고른 이유가 적혀 있다.** ```java // :72-78 // An allowlist, not a denylist. // // The denylist named the old versions somebody thought of, so `SSL`, `TLSv0.9`, `PLAINTEXT` // and any typo passed — and a protocol string the JVM does not recognise is negotiated as // whatever the JVM defaults to, which is the outcome this policy exists to prevent. Naming the // two acceptable versions means an unknown string fails here rather than at connect time on a // production broker. ``` `messaging-schema-api`의 `SchemaCompatibilityValidator`가 허용목록이고 `AvroCompatibilityGate`가 거부목록인 것(그쪽 §12.3)과 같은 축의 판단이며, 여기서는 허용목록을 고른 이유가 명시돼 있다. **네 번째 검사에 순서 문제가 있다.** `TLS_PROTOCOL_UNSPECIFIED`가 `TLS_PROTOCOL_NOT_ACCEPTED` **뒤에** 있는데, 빈 목록은 `filter`를 통과하는 요소가 없으므로 `unsupported`가 비어 있어 앞 검사를 지나간다. 결과적으로 빈 목록은 네 번째에서 잡힌다 — 동작은 맞다. 다만 읽는 순서와 논리 순서가 다르다. ### 4.5 `MessageSecurityValidator` — 시작 시 네 가지 ```java // :9-12 *

These checks are boot failures rather than warnings. An unencrypted production broker * connection or a shared producer/admin credential is not a degraded mode the platform can run in * safely; both are the kind of misconfiguration that stays invisible until it is exploited. ``` | # | 거절 조건 | |---:|---| | 1 | production && TLS 꺼짐 | | 2 | production && hostname 검증 꺼짐 | | 3 | producer와 consumer가 같은 credential id | | 4 | admin이 producer/consumer와 같은 credential id | | 5 | production && admin 존재 | 3·4번을 `LinkedHashSet.add`의 반환값으로 구현한다 — 추가에 실패하면 중복이다. 간결하고 정확하다. 5번이 (c) 원칙을 강제하는 지점이다 — **운영 런타임은 admin 자격증명을 아예 갖지 못한다.** 1·2번이 `BrokerTlsPolicy`와 겹친다(§12.3). ### 4.6 `BrokerAclManifest` — 초과가 발견이다 ```java // :113-118 *

Excess is the finding, not the shortfall: a missing grant fails loudly on first use, while * an undeclared extra one sits unnoticed until it is abused. ``` `undeclared(observed)`가 관측 − 선언, `missing(observed)`가 선언 − 관측이다. 두 방향을 모두 계산하지만 javadoc이 어느 쪽이 발견인지 정한다. `Operation` enum이 파괴적 여부를 상수에 담는다 — `ALTER`, `DELETE`, `PURGE`가 `destructive=true`. ```java // :17-20 *

Destructive permissions are named separately from ordinary ones. {@code DELETE_TOPIC} and * {@code PURGE} are not "write, but more"; they destroy data an application can never restore, so * an application runtime declaring one is rejected outright. ``` `requireApplicationRuntime()`이 파괴적 grant가 하나라도 있으면 `MessagingConfigurationException("APPLICATION_HOLDS_DESTRUCTIVE_GRANT")`을 던진다. **이 클래스 전체가 소비자 0이다**(§12.1). `undeclared`/`missing`이 `Set`를 받는데, `Grant`는 record이므로 equals가 세 필드 전부를 비교한다. 즉 `pattern`이 문자열 정확 일치여야 한다 — 와일드카드 패턴(`orders.*`)을 브로커가 다르게 표현하면 오탐이 난다. javadoc에 언급 없음. ### 4.7 `CredentialIds` — 참조 자리에 비밀을 붙여넣는 사고 ```java // :9-13 *

The bounded slug pattern is not cosmetic. Credential ids reach log lines and metric tags, so * an unbounded id is a cardinality problem, and an id that looks like a secret is a leak. The * heuristic check rejects the most common accident: pasting the secret itself where the reference * belongs. ``` 패턴 `[a-z0-9][a-z0-9._-]{1,63}` — 최소 2자, 최대 64자. 휴리스틱 접두사 다섯: `bearer `, `basic `, `sk-`, `-----begin`, `eyj`. 각각 HTTP Authorization, OpenAI 키, PEM 블록, base64 JWT 헤더(`{"` → `eyJ`)를 노린다. **패턴이 이미 대부분을 막는다.** `[a-z0-9._-]`만 허용하므로 공백이 있는 `bearer `·`basic `는 패턴에서 이미 거절되고, `-----begin`은 첫 글자가 `-`라 거절된다. 실제로 휴리스틱만이 잡는 것은 `sk-`와 `eyj`뿐이다. 중복 방어이고 해롭지 않다. ### 4.8 `DestinationAccessPolicy` — 세 역할, 세 집합 `publishable`/`consumable`/`administrable` 셋이 전부 `Set.copyOf`로 불변화된다. `denyAll()`이 세 빈 집합이다. ```java // :10-12 *

The platform checks this before the broker does. Relying only on broker ACLs means an * accidental publish surfaces as a generic authorization error at runtime, in the adapter, with no * record of which application module attempted it. ``` `DestinationAccessValidator`가 세 `require*` 메서드로 그 검사를 예외로 바꾼다 — 그리고 소비자가 0이다(§12.1). --- ## 5. 주요 실행 경로 **자격증명 해석:** 어댑터의 security configurer → `registry.resolve(credentialId, now)` → 캐시 유효하면 반환 → 아니면 `compute` 안에서 `provider.resolve` + `provider.expiresAt` → 새 `CredentialRuntime` 설치 → 옛 것 `clear()` **시작 검증(1):** starter가 `MessageSecurityValidator` bean 생성 → `validate(profile)` 호출 지점은 starter가 소유 **시작 검증(2):** 어댑터 configurer가 `BrokerTlsPolicy.validate(profile, enabledProtocols)` 호출 **발행 권한:** `DefaultMessagePublisher` → `access.mayPublish(name)` → false면 `PublishResult(REJECTED, PUBLISH_FORBIDDEN)` --- ## 6. 실패 경로와 복구/번역 | 코드 | 예외 | 위치 | |---|---|---| | `TLS_REQUIRED` | `MessagingConfigurationException` | `BrokerTlsPolicy` | | `HOSTNAME_VERIFICATION_REQUIRED` | `MessagingConfigurationException` | 같음 | | `TLS_PROTOCOL_NOT_ACCEPTED` | `MessagingConfigurationException` | 같음 | | `TLS_PROTOCOL_UNSPECIFIED` | `MessagingConfigurationException` | 같음 | | `APPLICATION_HOLDS_DESTRUCTIVE_GRANT` | `MessagingConfigurationException` | `BrokerAclManifest`(미사용) | | `DESTINATION_PUBLISH_DENIED` | `MessageAuthorizationException` | `DestinationAccessValidator`(미사용) | | `DESTINATION_CONSUME_DENIED` | `MessageAuthorizationException` | 같음(미사용) | | `DESTINATION_ADMIN_DENIED` | `MessageAuthorizationException` | 같음(미사용) | | (코드 없음) | `IllegalArgumentException` × 5 | `MessageSecurityValidator` | | (코드 없음) | `IllegalArgumentException` | `CredentialIds`, 각 생성자 | | (코드 없음) | `IllegalStateException` | `CredentialRuntime.material()` 소거 후 | **보안 판정이 두 예외 계층으로 나뉜다.** `BrokerTlsPolicy`는 안정 코드가 붙은 `MessagingConfigurationException`을 쓰고, `MessageSecurityValidator`는 코드 없는 `IllegalArgumentException`을 쓴다. 둘이 같은 두 검사(TLS·hostname)를 공유하는데도 그렇다 — §12.3, §17. --- ## 7. 트랜잭션·동시성·수명주기 트랜잭션 없음. | 지점 | 도구 | 보호 | |---|---|---| | `CredentialRuntimeRegistry.resolved` | `ConcurrentHashMap` | 맵 자체 | | `resolve` | `compute`(bin 락) | key별 single-flight, fetch 정확히 한 번 | | `CredentialRuntime.material` | **동기화 없음** | (§17) | 레코드 여섯(`BrokerSecurityProfile`, `BrokerCredentialProfile` 5변형, `DestinationAccessPolicy`, `BrokerAclManifest`, `CredentialRotationPlan`)은 전부 불변이다. `BrokerTlsPolicy`·`MessageSecurityValidator`·`DestinationAccessValidator`는 상태가 없거나 불변 참조만 갖는다. 수명주기 참여는 `clearAll()`뿐이고 "for shutdown"이라고 javadoc이 적는다. **그것을 부르는 코드가 저장소에 없다** — 종료 시 자격증명이 소거되지 않는다. §17. --- ## 8. 설정·기능 플래그·환경 차이 | 상수/기본값 | 값 | 위치 | |---|---|---| | `CredentialRuntime.DEFAULT_ROTATION_LEAD` | 30분 | public | | `BrokerTlsPolicy.MINIMUM_PROTOCOL` | `"TLSv1.2"` | public | | `BrokerTlsPolicy.ACCEPTED_PROTOCOLS` | `{TLSv1.2, TLSv1.3}` | private | | `BrokerTlsPolicy()` 기본 | `allowPlaintextOutsideProduction = true` | — | | `CredentialIds.VALID` | `[a-z0-9][a-z0-9._-]{1,63}` | private | `production` 플래그가 세 클래스의 분기 조건이다 — `BrokerTlsPolicy`, `MessageSecurityValidator`, 그리고 `BrokerSecurityProfile`의 필드. 그 값을 정하는 곳은 이 leaf 밖이다. **`MINIMUM_PROTOCOL`이 public이고 아무도 쓰지 않는다.** `ACCEPTED_PROTOCOLS`가 private이므로 외부에서 허용 집합을 알려면 `isAcceptable(String)`을 부르거나 이 상수를 보는데, 상수는 최소값만 알려준다. --- ## 9. 퍼시스턴스/외부 시스템 세부 없다. `CredentialProvider`가 외부 비밀 저장소를 가리킬 수 있는 port이고 이 leaf에 구현이 없다. --- ## 10. 테스트 레인과 실제 증명 범위 레인: `./gradlew :messaging:messaging-security:test`. **BUILD SUCCESSFUL, 24 tests, 0 skipped, 0 failures**. | 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 | |---|---:|---|---| | `CredentialRuntimeRegistryTest` | 13 | 해석·캐시·회전·소거·경합 하 single-flight | 실제 비밀 저장소 | | `MessageSecurityValidatorTest` | 7 | 다섯 거절 조건 | 실제 부팅에서 호출되는지(→ starter가 bean 생성) | | `CredentialRotationContractTest` | 4 | 회전 시점 술어 | **`CredentialRotationPlan`이 쓰이는지** | **커버리지 공백 셋.** - `BrokerTlsPolicy`를 겨냥한 테스트 클래스가 **없다.** 네 거절 조건과 허용목록 판정이 이 leaf의 테스트로 검증되지 않는다. 어댑터 쪽 `KafkaSecurityConfigurerTest`가 간접적으로 지나갈 수 있으나 그것은 다른 leaf의 레인이고 다른 것을 목표로 한다. - `BrokerAclManifest`를 겨냥한 테스트가 **없다.** `undeclared`/`missing`/`requireApplicationRuntime` 셋 다 미검증이다. - `DestinationAccessValidator`·`DestinationAccessPolicy`를 겨냥한 테스트가 **없다.** 즉 **12개 타입 중 5개가 이 leaf의 테스트에 등장하지 않는다.** 그리고 그중 셋은 §12.1의 소비자 0 목록과 겹친다 — 쓰이지도 않고 테스트되지도 않는다. --- ## 11. 빌드/ArchUnit/CI 강제 지점 | 게이트 | 이 leaf에 대해 | |---|---| | `verifyCleanArchitectureDependencies` | `["messaging-core-api"]` | | `verifyRuntimeModuleMembership` | `["app-bootstrap"]` | | vendor `api` 규칙 | 벤더 의존성 0 | | `SecretLeakStaticScanTest`(observability leaf) | **이 leaf의 소스도 스캔 대상** — 콘솔 출력·민감 식별자 문자열 연결 금지 | | ArchUnit | 전용 규칙 없음 | 네 번째가 이 leaf에 실질적이다. `CredentialRuntime.toString()`이 material을 빼고 id와 expiry만 담는 것, `MessagingRedactor`가 credential 키를 지우는 것과 함께 **세 층의 방어**를 이룬다 — 타입(`char[]`), 표현(`toString`), 정적 스캔. --- ## 12. 실제 사용 여부와 negative-space probes 원시 증거: `evidence/raw/287-messaging-security-duplicate-checks.txt`. > **방법.** 정규화된 이름(`import dev.caskeleton.messaging.security.;` 또는 `dev.caskeleton.messaging.security.`)으로 측정했다. `messaging-observability`의 `CardinalityGuard`처럼 동명 클래스가 있는 경우를 배제하기 위해서다. ### 12.1 Public surface reachability | 타입 | leaf 밖 파일 | 판정 | |---|---:|---| | `BrokerSecurityProfile` | 8 | 활발 | | `DestinationAccessPolicy` | 7 | 활발 | | `BrokerTlsPolicy` | 6 | 어댑터 둘 + starter 셋 | | `CredentialRuntimeRegistry` | 6 | 같음 | | `BrokerCredentialProfile` | 5 | 활발 | | `CredentialProvider` | 4 | 활발 | | `CredentialRuntime` | 2 | | | `MessageSecurityValidator` | 1 | starter bean | | **`DestinationAccessValidator`** | **0** | | | **`BrokerAclManifest`** | **0** | | | **`CredentialRotationPlan`** | **0** | | | `CredentialIds` | 0 | package-private — 구조상 내부. 결함 아님 | **(a) 접근 검증기가 쓰이지 않고, 같은 검사가 다른 형태로 인라인돼 있다** `DestinationAccessValidator.requirePublish`는 예외를 던진다. ```java if (!policy.mayPublish(destination)) { throw new MessageAuthorizationException( "DESTINATION_PUBLISH_DENIED", "the producer credential may not publish to " + destination.value()); } ``` 발행 경로는 정책을 직접 묻고 결과를 반환한다. ```java // DefaultMessagePublisher.java:170-176 if (!access.mayPublish(destination.name())) { return rejected( "PUBLISH_FORBIDDEN", "this application may not publish to '" + destination.name().value() + '\'', startedAt); } ``` **같은 판단, 다른 코드, 다른 실패 형태.** `DESTINATION_PUBLISH_DENIED`(`AUTHORIZATION` 카테고리, 예외) vs `PUBLISH_FORBIDDEN`(`CONFIGURATION` 카테고리, `PublishResult`). 대시보드가 권한 거부를 세려면 두 어휘를 모두 알아야 하는데, 실제로 발생하는 것은 후자뿐이다. 그리고 **`FailureCategory`가 다르다** — 권한 거부가 `AUTHORIZATION`이 아니라 `CONFIGURATION`으로 기록된다. `requireConsume`/`requireAdminister`도 소비자가 없다 — 소비 경로가 조립되지 않고(`analysis/messaging/messaging-policy.md` §17) admin 경로는 `messaging-admin-runtime`이 자체 검사를 할 수 있다. **(b) ACL 매니페스트 전체가 미사용이다** `BrokerAclManifest`는 선언·비교·거절 셋을 모두 갖춘 메커니즘이다 — `requireApplicationRuntime()`이 파괴적 grant를 가진 애플리케이션을 거절하고, `undeclared(observed)`가 브로커가 실제로 준 초과 권한을 찾는다. javadoc이 그 목적을 "The manifest is what the platform checks itself against at startup"이라고 적는다. 그 startup 검사를 하는 코드가 없다. 그리고 `observed` 집합을 만들려면 브로커에서 ACL을 읽어야 하는데, 그 읽기를 하는 코드도 없다 — `messaging-admin-api`의 `BrokerTopologyInspector`가 후보이지만 이 leaf와 연결되지 않는다. 즉 **미사용의 이유가 단순한 배선 누락이 아니라 관측 소스의 부재**일 수 있다. 그것은 admin leaf가 답한다. **(c) 회전 계획 record가 미사용이고 그 술어가 다른 곳에 복제돼 있다** — §12.3. ### 12.2 Conditional sibling comparison 이 leaf에 bean은 없다. starter 쪽 sibling 셋의 조건은 동일(`@ConditionalOnMissingBean`)하고 소비가 다르다. | bean | 주입처 | |---|---| | `BrokerTlsPolicy` | `KafkaMessagingAutoConfiguration`, `RabbitMessagingAutoConfiguration` | | `CredentialRuntimeRegistry` | 같음 | | `MessageSecurityValidator` | **없음** — bean만 존재 | 세 번째가 `messaging-policy`의 `RetryDecisionEngine`(그쪽 §12.1)과 같은 형태다. 다만 차이가 있다 — `MessageSecurityValidator`는 **직접 호출** 지점이 있을 수 있다(starter가 bean을 만들면서 같은 파일에서 부를 수 있다). 그 확인은 starter leaf가 소유한다. ### 12.3 Duplicate mechanism sweep **(a) TLS posture 검사가 두 클래스에 있고 엄격도가 다르다** | | `MessageSecurityValidator` | `BrokerTlsPolicy` | |---|---|---| | TLS 필수 | `production && !tlsEnabled` | `!tlsEnabled && (production \|\| !allowPlaintextOutsideProduction)` | | hostname 검증 | **`production && !hostnameVerification`** | **`tlsEnabled && !hostnameVerification`** | | 프로토콜 버전 | 없음 | 허용목록 + 빈 목록 거절 | | 예외 | `IllegalArgumentException` | `MessagingConfigurationException` | | 안정 코드 | 없음 | 4개 | | 호출자 | starter bean(주입처 없음) | 어댑터 둘 | **hostname 검증의 조건이 다르다.** `MessageSecurityValidator`는 production에서만 요구하고, `BrokerTlsPolicy`는 **TLS가 켜져 있으면 언제나** 요구한다. 즉 비운영에서 TLS를 켜고 hostname 검증을 끈 구성은 후자가 거절하고 전자는 통과시킨다. 후자가 더 엄격하고, 후자가 실제로 호출되는 쪽이다. 두 클래스가 같은 `BrokerSecurityProfile`을 받는다. 어느 쪽이 정본인지 코드가 말하지 않는다. **(b) 회전 술어가 두 번 구현돼 있다** ```java // CredentialRotationPlan.isDue(now) — 소비자 0 return expiresAt.map(expiry -> !now.isBefore(expiry.minus(rotateBefore))).orElse(false); // CredentialRuntime.isDueForRotation(now) — 사용됨 return expiresAt.map(expiry -> !now.isBefore(expiry.minus(rotationLead))).orElse(false); ``` `isExpired`도 같다. ```java // CredentialRotationPlan.isExpired(now) return expiresAt.map(expiry -> !now.isBefore(expiry)).orElse(false); // CredentialRuntime.isExpired(now) return expiresAt.map(expiry -> !now.isBefore(expiry)).orElse(false); ``` **글자까지 동일하다.** 필드 이름만 `rotateBefore` vs `rotationLead`로 다르다. `CredentialRotationPlan`은 material을 갖지 않는 순수 계획 record이고 `CredentialRuntime`은 material을 갖는 런타임 상태다 — 관심사 분리로는 말이 되지만, 술어가 복제된 채로 한쪽만 쓰인다. `CredentialRotationContractTest`가 `CredentialRotationPlan`을 테스트한다. 즉 **쓰이지 않는 쪽이 테스트되고 쓰이는 쪽의 같은 술어는 그 테스트가 덮지 않는다.** (`CredentialRuntimeRegistryTest`가 간접적으로 덮는다.) **(c) 권한 검사 두 형태** — §12.1(a). **(d) 자격증명 참조 검증이 다른 family에도 있는가** `git grep`으로 credential id 패턴 검증을 저장소 전역에서 찾으면 이 leaf의 `CredentialIds`가 유일하다. notification·grpc family는 자기 자격증명 모델을 갖지만 messaging의 것을 쓰지 않는다 — 경계가 분명하므로 중복 경쟁이 아니다. ### 12.4 Documentation / measured-count drift | 문서 주장 | 재측정 | 결과 | |---|---|---| | `BrokerCredentialProfile` javadoc: 어떤 변형도 비밀을 담지 않음 | 다섯 record 전부 `credentialId` 하나 | **일치** | | `CredentialRuntime` javadoc: `char[]`로 보관하고 `clear()`가 덮어씀 | 확인 | **일치** | | `BrokerAclManifest` javadoc: "what the platform checks itself against at startup" | 호출자 0 | **불일치** | | `DestinationAccessPolicy` javadoc: "The platform checks this before the broker does" | `mayPublish`가 발행 경로에서 호출됨 | **일치**(다만 validator 경유 아님) | | `CredentialRuntimeRegistry.clearAll` javadoc: "for shutdown" | 호출자 0 | **불일치** | | `MessageSecurityValidator` javadoc: "boot failures rather than warnings" | starter가 bean 생성. 호출 지점은 starter가 소유 | **미확인** | | `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) | --- ## 13. Git/설계 문서에서 확인한 변화와 실패 기록 | 위치 | 이전 상태 | 그것이 만든 실패 | |---|---|---| | `CredentialRuntimeRegistry.resolve` 주석 | `get → fetch → put → clear`, 동기화 없음 | 두 스레드가 같은 자격증명을 회전 → **진 쪽 교체본이 맵에서 사라지고 소거도 안 됨(소유자 없는 비밀이 힙에 잔류)**, 그리고 **진 쪽이 이긴 쪽이 사용 중인 material을 소거** | | `BrokerTlsPolicy` 프로토콜 검사 주석 | 거부목록 | `SSL`·`TLSv0.9`·`PLAINTEXT`·오타가 전부 통과 → JVM이 인식 못 하는 문자열은 **JVM 기본값으로 협상**, 즉 이 정책이 막으려던 결과 | 두 번째가 `messaging-schema-api` §12.3의 허용목록/거부목록 축과 같은 주제이고, 여기서는 **거부목록이 실제로 뚫린 기록**이 남아 있다. 첫 번째는 이 저장소가 반복하는 "정확히 한 번" 주제의 보안 판본이다 — `messaging-transport-spi`의 세대 close, `messaging-policy`의 permit 반납과 같은 계열이며, 여기서는 실패의 결과가 **비밀 잔류**다. --- ## 14. 런타임·터미널 Evidence | id | 종류 | 파일 | 무엇을 보여주는가 | 한계 | |---|---|---|---|---| | EVD-287 | command | `evidence/raw/287-messaging-security-duplicate-checks.txt` | 12타입 정규화 이름 기준 참조 수, 소비자 0인 넷, 접근 검사 두 형태 나란히, TLS 검사 두 클래스의 조건 차이, 회전 술어 두 복사본, 실제 소비자 목록 | 정적 검색. 리플렉션·파생 프로젝트 미포함 | | EVD-288 | command | `./gradlew :messaging:messaging-security:test --rerun-tasks` | BUILD SUCCESSFUL, 24 / 0 / 0 | `BrokerTlsPolicy`·`BrokerAclManifest`·접근 정책 미검증 | --- ## 15. 명시적 설계 이유와 추론을 구분한 정리 **명시적** - 어떤 변형도 비밀을 담지 않는 이유 — `BrokerCredentialProfile` javadoc - `char[]`이 타입 수준 통제인 이유 — `CredentialRuntime` javadoc - material을 복사해 반환하는 이유 — `material()` javadoc - 만료 이전에 회전하는 이유 — `CredentialRuntime`·`CredentialRotationPlan` javadoc - single-flight가 필요한 이유와 두 개의 이전 결함 — `resolve` 주석 - 소거 순서(설치 후, 교체한 스레드가) 이유 — 같은 주석 - hostname 검증 부재가 평문보다 나쁜 이유 — `BrokerTlsPolicy` javadoc - 허용목록을 고른 이유와 거부목록이 뚫린 기록 — 같은 파일 주석 - 보안 검사가 경고가 아니라 부팅 실패인 이유 — `MessageSecurityValidator` javadoc - 세 자격증명을 분리하는 이유 — `BrokerSecurityProfile` javadoc - 초과 권한이 발견인 이유 — `BrokerAclManifest` javadoc - 파괴적 연산을 따로 이름 붙인 이유 — 같은 javadoc - credential id를 슬러그로 제한하는 이유 — `CredentialIds` javadoc - 플랫폼이 브로커보다 먼저 검사하는 이유 — `DestinationAccessPolicy`·`DestinationAccessValidator` javadoc **추론** - `DestinationAccessValidator`가 미사용인 것은 발행 경로가 예외 대신 `PublishResult`를 반환하기로 했기 때문이다 → **추론**. 두 형태의 존재는 관측이고 인과는 추론이다. - `BrokerAclManifest`가 미사용인 것은 브로커에서 ACL을 읽는 코드가 없기 때문이다 → **추론**. 읽기 코드 부재는 관측이다. - `CredentialRotationPlan`이 미사용인 것이 `CredentialRuntime`으로 흡수된 결과인지 → **미상**. --- ## 16. 확인한 것 / 확인하지 못한 것 **확인한 것** - 12개 타입 954줄 전문의 계약 - 24개 테스트가 통과하고 무엇을 단언하는지, 그리고 5개 타입이 테스트에 등장하지 않는다는 것 - 정규화 이름 기준 참조 수와, 소비자 0인 셋(+package-private 하나) - 어댑터 둘이 `BrokerTlsPolicy`·`CredentialRuntimeRegistry`를 실제로 쓴다는 것 - 같은 판단이 두 형태로 존재하는 세 쌍(접근 검사, TLS posture, 회전 술어)과 그중 TLS는 **엄격도가 실제로 다르다**는 것 - `clearAll()`의 호출자가 없다는 것 **확인하지 못한 것** - **`MessageSecurityValidator.validate`가 실제로 호출되는지.** starter가 bean을 만들고, 같은 파일에서 직접 호출할 가능성이 있다. starter leaf가 답한다. - 브로커에서 ACL을 읽는 경로가 존재하는지 — `messaging-admin-api`의 `BrokerTopologyInspector`가 후보다. - `compute` 안에서 `provider.resolve`가 실제 저장소를 호출할 때의 지연. 구현이 없어 관측할 수 없다. - `CredentialRuntime.material` 필드의 가시성 문제가 실제로 발생하는지 — 현재 경로에서는 창이 좁다. - `BrokerAclManifest.Grant`의 `pattern` 정확 일치가 실제 브로커 표현과 맞는지. --- ## 17. 손볼 것 ### P2 — 같은 TLS posture를 두 클래스가 다른 엄격도로 검사한다 - **사실.** `MessageSecurityValidator`는 hostname 검증을 `production && !hostnameVerification`일 때만 요구하고, `BrokerTlsPolicy`는 `tlsEnabled && !hostnameVerification`일 때 요구한다. 전자는 코드 없는 `IllegalArgumentException`, 후자는 안정 코드가 붙은 `MessagingConfigurationException`을 던진다. 둘 다 같은 `BrokerSecurityProfile`을 받고, 후자만 어댑터에서 실제로 호출된다. - **근거.** `evidence/raw/287` §D. - **왜 문제인가.** 비운영에서 TLS를 켜고 hostname 검증을 끈 구성을 두 검사가 다르게 판정한다. 그리고 이 leaf 자신의 javadoc이 그 구성을 "looks encrypted in every dashboard while accepting any certificate a man in the middle presents"라고 부른다 — 즉 더 느슨한 쪽이 그 위험을 통과시킨다. 실패 형태도 달라서 운영자가 두 어휘를 알아야 한다. - **확인 방법.** `evidence/raw/287` §D 재실행. 또는 두 `validate` 메서드 대조. - **후보.** (a) `MessageSecurityValidator`가 `BrokerTlsPolicy`에 위임한다. (b) 두 클래스의 책임을 나눈다 — TLS는 후자, 자격증명 분리는 전자. - **다음 단계.** **CASE 후보 + REFERENCE 후보.** "같은 불변식을 두 곳에서 검사하면 느슨한 쪽이 통과 경로가 된다"가 재사용 가능한 기준이다. ### P2 — 권한 거부가 `AUTHORIZATION`이 아니라 `CONFIGURATION`으로 기록된다 - **사실.** `DestinationAccessValidator.requirePublish`는 `MessageAuthorizationException("DESTINATION_PUBLISH_DENIED")`을 던지고 그 카테고리는 `AUTHORIZATION`이다. 소비자가 0이다. 실제 발행 경로는 `access.mayPublish`를 직접 묻고 `rejected("PUBLISH_FORBIDDEN", ...)`을 반환하는데, `rejected(...)`는 `FailureCategory.CONFIGURATION`을 붙인다. - **근거.** `evidence/raw/287` §C. `DefaultMessagePublisher.java:104-116`(`rejected`의 카테고리). - **왜 문제인가.** `FailureCategory`는 "stable classification a retry engine, DLQ router, and dashboard all agree on"이다(`messaging-core-api` §4.12). 권한 거부가 구성 오류로 분류되면 보안 대시보드가 그것을 보지 못하고, 구성 오류 알림이 권한 거부로 오염된다. 그리고 `AUTHORIZATION` 카테고리를 쓰는 유일한 코드가 미사용 클래스에 있다. - **확인 방법.** `MessageAuthorizationException`의 `CATEGORY` 상수와 `DefaultMessagePublisher.rejected`의 카테고리 대조. - **후보.** 발행 경로가 권한 거부에 `AUTHORIZATION` 카테고리를 붙이거나, `DestinationAccessValidator`를 쓰고 예외를 `PublishResult`로 번역한다. - **다음 단계.** **CASE 후보.** `messaging-runtime-core` leaf와 공동 소유. ### P3 — ACL 매니페스트 전체가 쓰이지 않는다 - **사실.** `BrokerAclManifest`의 세 메서드(`requireApplicationRuntime`, `undeclared`, `missing`)와 두 enum이 소비자 0이다. javadoc은 "The manifest is what the platform checks itself against at startup"이라고 한다. - **근거.** `evidence/raw/287` §A·§B. - **왜 문제인가.** "애플리케이션 런타임은 파괴적 권한을 갖지 않는다"는 이 leaf의 핵심 원칙 중 하나이고, `MessageSecurityValidator`가 admin **자격증명**의 부재만 검사한다. 브로커가 producer 자격증명에 `DELETE`를 준 경우는 아무도 보지 않는다. - **확인 방법.** `git grep -l 'BrokerAclManifest' -- src ':!src/messaging/messaging-security'` → 없음. - **후보.** startup 검사에 배선하거나, 브로커 ACL 읽기가 없으면 그 사실을 javadoc에 적는다. - **다음 단계.** **OPEN QUESTION 후보.** 판정이 "브로커 ACL을 읽는 경로가 있는가"에 걸리고, 그것은 `messaging-admin-api`가 답한다. ### P3 — 종료 시 자격증명 소거가 호출되지 않는다 - **사실.** `CredentialRuntimeRegistry.clearAll()`의 javadoc이 "Clears every held credential, for shutdown"이라고 하고, 호출자가 저장소에 없다. - **근거.** `git grep -n 'clearAll' -- src`. - **왜 문제인가.** 이 leaf 전체가 "비밀이 힙에 남지 않게 한다"를 목적으로 하고(`char[]`, `clear()`, 회전 시 즉시 소거), 종료 경로에서 그 마지막 단계가 빠져 있다. 프로세스가 끝나면 힙도 사라지지만, 종료가 느리거나 힙 덤프가 뜨는 경우가 정확히 이 통제가 노리는 상황이다. - **확인 방법.** `git grep -n 'clearAll' -- src` → 선언과 테스트만. - **후보.** `MessagingShutdownLifecycle`이나 `DisposableBean`에 연결한다. - **다음 단계.** **CASE 후보.** `messaging-transport-spi` §12.1의 8단계 종료 계약과 같은 맥락이다. ### P3 — 회전 술어가 두 번 구현돼 있고, 쓰이지 않는 쪽이 테스트된다 - **사실.** `CredentialRotationPlan.isDue`/`isExpired`와 `CredentialRuntime.isDueForRotation`/`isExpired`가 글자까지 같다. 전자는 소비자 0이고 전용 테스트(`CredentialRotationContractTest`, 4개)가 있다. - **근거.** `evidence/raw/287` §E. - **왜 문제인가.** 테스트가 고정하는 것과 실행되는 것이 다른 객체다. 한쪽만 고치면 다른 쪽은 조용히 다른 시점에 회전한다. - **확인 방법.** 두 메서드 본문 대조. - **후보.** `CredentialRuntime`이 `CredentialRotationPlan`을 필드로 갖고 위임하거나, 계획 record를 제거한다. - **다음 단계.** **REFERENCE 후보**(같은 술어가 두 타입에 있으면 하나가 다른 하나를 부른다). ### P3 — 자격증명 해석이 맵 bin 락 안에서 외부 I/O를 한다 - **사실.** `resolve`가 `resolved.compute(credentialId, (key, existing) -> { ... provider.resolve(key) ... })` 형태다. `CredentialProvider.resolve`는 외부 비밀 저장소를 호출할 수 있는 port다. - **근거.** `CredentialRuntimeRegistry.java:71-86`. - **왜 문제인가.** single-flight를 얻은 대가다 — 같은 credential id를 요청하는 다른 스레드는 저장소 왕복 동안 막힌다. 그것이 의도이고 옳다. 다만 **`ConcurrentHashMap`의 bin은 키가 공유하므로** 해시가 충돌하는 다른 credential id도 함께 막힌다. 그리고 저장소가 느려지면 그 지연이 발행 경로로 전파된다 — 타임아웃이 없다. - **확인 방법.** `provider.resolve` 호출 위치가 람다 안임을 확인. - **후보.** 현 구조를 유지하되 `CredentialProvider` javadoc에 "구현은 유한 시간 안에 반환해야 한다"를 명시한다. - **다음 단계.** **REFERENCE 후보**(맵 갱신 함수 안에서 I/O를 하면 그 지연이 락 범위가 된다). ### P3 — 다섯 타입이 이 leaf의 테스트에 등장하지 않는다 - **사실.** `BrokerTlsPolicy`·`BrokerAclManifest`·`DestinationAccessPolicy`·`DestinationAccessValidator`·`BrokerCredentialProfile`을 겨냥한 테스트가 없다. - **근거.** 세 테스트 클래스 전수. - **왜 문제인가.** `BrokerTlsPolicy`는 **실제로 배선된** 클래스다 — 어댑터 둘이 호출한다. 네 거절 조건과 허용목록 판정이 이 leaf의 레인에서 검증되지 않는다. 어댑터 테스트가 간접적으로 지나가더라도 그것은 다른 목표를 가진 레인이다. - **확인 방법.** `find src/test -name '*Test.java'` → 셋. - **후보.** `BrokerTlsPolicy`의 네 거절 조건과 허용/거부 경계를 겨냥한 테스트를 추가한다. - **다음 단계.** **REFERENCE 후보**(배선된 게이트는 자기 leaf 레인에서 검증한다). ### P3 — `CredentialRuntime.material`이 동기화되지 않는다 - **사실.** `private char[] material`이 `volatile`이 아니고 `clear()`가 그것을 교체한다. `clearAll()`은 락 없이 순회한다. - **근거.** `CredentialRuntime.java:29,129-132`, `CredentialRuntimeRegistry.java:129-132`. - **왜 문제인가.** 정상 경로(`compute` 안 소거)에서는 `ConcurrentHashMap`이 happens-before를 준다. `clearAll()` 경로에는 그 보장이 없다 — 다른 스레드가 소거된 배열의 옛 참조를 보고 이미 지워진 material을 읽을 수 있다(0으로 채워진 값). 실질 위험은 낮고 방향도 안전(비밀 유출이 아니라 잘못된 값)하다. - **확인 방법.** 필드 선언 확인. - **후보.** `material`을 `volatile`로 하거나 `clearAll()`을 `compute` 기반으로 바꾼다. - **다음 단계.** **REFERENCE 후보**(가변 필드로 상태 전이를 표현하면 가시성을 함께 정한다). ### 확인된 설계(문제 아님) - 어떤 자격증명 프로파일 변형도 비밀을 담지 않고 참조만 갖는 것, 그리고 sealed로 닫은 것 - material을 `char[]`로 보관하고 반환 시 복사하며 소거 시 덮어쓰는 세 통제 - `toString()`이 material을 담지 않는 것 - key별 single-flight와 "설치 후 소거, 교체한 스레드만" 순서 - 만료가 아니라 만료 이전에 회전하는 것, 만료를 모르면 회전 대상이 아닌 것 - TLS 프로토콜을 허용목록으로 판정한 것과 그 이유가 실패 이력으로 남은 것 - hostname 검증 부재를 평문보다 나쁜 실패로 분류한 것 - producer·consumer·admin 자격증명 분리와 운영에서 admin 금지 - credential id 슬러그 제한과 비밀-모양 접두사 휴리스틱 - 파괴적 연산을 enum 상수에 표시한 것 --- ## Source anchors | id | kind | path | revision | what it proves | limitations | |---|---|---|---|---|---| | MSC-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 1개, memberships `["app-bootstrap"]` | 선언 | | MSC-002 | build | `messaging-security/build.gradle` | same | 벤더 의존성 0 | — | | MSC-003 | code | `.../security/CredentialRuntime.java` 전문 | same | §4.2 세 통제, 회전 술어 | `material` 미동기화(§17) | | MSC-004 | code | `.../security/CredentialRuntimeRegistry.java` 전문 | same | §4.1 single-flight와 두 이전 결함 | `clearAll` 호출자 없음 | | MSC-005 | code | `.../security/BrokerTlsPolicy.java` 전문 | same | §4.4 네 거절과 허용목록 이력 | 전용 테스트 없음 | | MSC-006 | code | `.../security/MessageSecurityValidator.java` | same | §4.5 다섯 거절 | TLS 검사가 §4.4와 겹침 | | MSC-007 | code | `.../security/BrokerAclManifest.java` | same | §4.6 초과=발견, 파괴적 연산 분리 | 소비자 0 | | MSC-008 | code | `.../security/{DestinationAccessPolicy,DestinationAccessValidator}.java` | same | §4.8 세 역할, 검증기의 세 코드 | 검증기 소비자 0 | | MSC-009 | code | `.../security/{BrokerSecurityProfile,BrokerCredentialProfile,CredentialIds,CredentialProvider,CredentialRotationPlan}.java` | same | 역할 분리, sealed 5변형, id 검증, port | 계획 record 소비자 0 | | MSC-010 | test | `CredentialRuntimeRegistryTest` (13) | same | 해석·회전·소거·경합 | 실제 저장소 없음 | | MSC-011 | test | `MessageSecurityValidatorTest` (7) | same | 다섯 거절 조건 | — | | MSC-012 | test | `CredentialRotationContractTest` (4) | same | 회전 시점 술어 | **미사용 타입을 테스트** | | MSC-013 | cross-leaf code | `messaging-kafka/.../KafkaSecurityConfigurer.java`, `messaging-rabbit/.../RabbitSecurityConfigurer.java` | same | `BrokerTlsPolicy`·`CredentialRuntimeRegistry`의 실제 소비 | 각 leaf SSOT가 소유 | | MSC-014 | cross-leaf code | `messaging-runtime-core/.../DefaultMessagePublisher.java:170-176` | same | 인라인 권한 검사와 그 코드·카테고리 | 해당 leaf SSOT가 소유 | | MSC-015 | assembly | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java` | same | 세 bean 생성 | 해당 leaf SSOT가 소유 | | EVD-287 | command | `evidence/raw/287-messaging-security-duplicate-checks.txt` | same | §12.1·§12.3 전부 | 정적 검색 | | EVD-288 | command | `./gradlew :messaging:messaging-security:test --rerun-tasks` | same | 24 / 0 / 0 | 5개 타입 미검증 |