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>
46 KiB
messaging-security 완전 해부
상태: COMPLETE 기준 revision:
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/messaging/messaging-securitySSOT owner:messaging-securityintegration/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) 비밀은 참조로만 다룬다.
// 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) 타입이 통제의 일부다.
// 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 세 자격증명을 별도 필드로 갖는다.
// 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에서 가장 조밀한 동시성 코드이고, 이전 결함이 주석에 통째로 남아 있다.
// :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.
두 개의 서로 다른 결함이 한 경합에서 나왔다.
- 진 쪽의 교체본이 맵에서 사라지고
clear()도 안 됨 → 소유자 없는 비밀이 힙에 남음 - 진 쪽이 이긴 쪽이 쓰고 있는 material을
clear()할 수 있음 → 사용 중인 자격증명이 지워짐
현재 구현:
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 회전 시점 — 만료가 아니라 만료 이전
// 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 — 허용목록과 두 단계 실패
// :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 켜짐인데 프로토콜 목록이 비어 있음 |
허용목록을 고른 이유가 적혀 있다.
// :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 — 시작 시 네 가지
// :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 — 초과가 발견이다
// :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.
// :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 — 참조 자리에 비밀을 붙여넣는 사고
// :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()이 세 빈 집합이다.
// :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는 예외를 던진다.
if (!policy.mayPublish(destination)) {
throw new MessageAuthorizationException(
"DESTINATION_PUBLISH_DENIED",
"the producer credential may not publish to " + destination.value());
}
발행 경로는 정책을 직접 묻고 결과를 반환한다.
// 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) 회전 술어가 두 번 구현돼 있다
// 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도 같다.
// 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. 명시적 설계 이유와 추론을 구분한 정리
명시적
- 어떤 변형도 비밀을 담지 않는 이유 —
BrokerCredentialProfilejavadoc char[]이 타입 수준 통제인 이유 —CredentialRuntimejavadoc- material을 복사해 반환하는 이유 —
material()javadoc - 만료 이전에 회전하는 이유 —
CredentialRuntime·CredentialRotationPlanjavadoc - single-flight가 필요한 이유와 두 개의 이전 결함 —
resolve주석 - 소거 순서(설치 후, 교체한 스레드가) 이유 — 같은 주석
- hostname 검증 부재가 평문보다 나쁜 이유 —
BrokerTlsPolicyjavadoc - 허용목록을 고른 이유와 거부목록이 뚫린 기록 — 같은 파일 주석
- 보안 검사가 경고가 아니라 부팅 실패인 이유 —
MessageSecurityValidatorjavadoc - 세 자격증명을 분리하는 이유 —
BrokerSecurityProfilejavadoc - 초과 권한이 발견인 이유 —
BrokerAclManifestjavadoc - 파괴적 연산을 따로 이름 붙인 이유 — 같은 javadoc
- credential id를 슬러그로 제한하는 이유 —
CredentialIdsjavadoc - 플랫폼이 브로커보다 먼저 검사하는 이유 —
DestinationAccessPolicy·DestinationAccessValidatorjavadoc
추론
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-coreleaf와 공동 소유.
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호출 위치가 람다 안임을 확인. - 후보. 현 구조를 유지하되
CredentialProviderjavadoc에 "구현은 유한 시간 안에 반환해야 한다"를 명시한다. - 다음 단계. 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개 타입 미검증 |