Files
document-haness/docs/clean-architecture-backend-template/analysis/messaging/messaging-security.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:59 +09:00

742 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
* <p>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
* <p>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
* <p>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<Instant>
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
* <p>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
* <p>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
* <p>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
* <p>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
* <p>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>`를 받는데, `Grant`는 record이므로 equals가 세 필드 전부를 비교한다. 즉 `pattern`이 문자열 정확 일치여야 한다 — 와일드카드 패턴(`orders.*`)을 브로커가 다르게 표현하면 오탐이 난다. javadoc에 언급 없음.
### 4.7 `CredentialIds` — 참조 자리에 비밀을 붙여넣는 사고
```java
// :9-13
* <p>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
* <p>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.<Type>;` 또는 `dev.caskeleton.messaging.security.<Type>`)으로 측정했다. `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개 타입 미검증 |