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

46 KiB
Raw Blame History

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) 비밀은 참조로만 다룬다.

// 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.resolvechar[]을 반환하고 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.

두 개의 서로 다른 결함이 한 경합에서 나왔다.

  1. 진 쪽의 교체본이 맵에서 사라지고 clear()도 안 됨 → 소유자 없는 비밀이 힙에 남음
  2. 진 쪽이 이긴 쪽이 쓰고 있는 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-apiSchemaCompatibilityValidator가 허용목록이고 AvroCompatibilityGate가 거부목록인 것(그쪽 §12.3)과 같은 축의 판단이며, 여기서는 허용목록을 고른 이유가 명시돼 있다.

네 번째 검사에 순서 문제가 있다. TLS_PROTOCOL_UNSPECIFIEDTLS_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, PURGEdestructive=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/missingSet<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) 호출

발행 권한: DefaultMessagePublisheraccess.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-observabilityCardinalityGuard처럼 동명 클래스가 있는 경우를 배제하기 위해서다.

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-apiBrokerTopologyInspector가 후보이지만 이 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-policyRetryDecisionEngine(그쪽 §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에서만 요구하고, BrokerTlsPolicyTLS가 켜져 있으면 언제나 요구한다. 즉 비운영에서 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을 갖는 런타임 상태다 — 관심사 분리로는 말이 되지만, 술어가 복제된 채로 한쪽만 쓰인다.

CredentialRotationContractTestCredentialRotationPlan을 테스트한다. 즉 쓰이지 않는 쪽이 테스트되고 쓰이는 쪽의 같은 술어는 그 테스트가 덮지 않는다. (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-apiBrokerTopologyInspector가 후보다.
  • compute 안에서 provider.resolve가 실제 저장소를 호출할 때의 지연. 구현이 없어 관측할 수 없다.
  • CredentialRuntime.material 필드의 가시성 문제가 실제로 발생하는지 — 현재 경로에서는 창이 좁다.
  • BrokerAclManifest.Grantpattern 정확 일치가 실제 브로커 표현과 맞는지.

17. 손볼 것

P2 — 같은 TLS posture를 두 클래스가 다른 엄격도로 검사한다

  • 사실. MessageSecurityValidator는 hostname 검증을 production && !hostnameVerification일 때만 요구하고, BrokerTlsPolicytlsEnabled && !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) MessageSecurityValidatorBrokerTlsPolicy에 위임한다. (b) 두 클래스의 책임을 나눈다 — TLS는 후자, 자격증명 분리는 전자.
  • 다음 단계. CASE 후보 + REFERENCE 후보. "같은 불변식을 두 곳에서 검사하면 느슨한 쪽이 통과 경로가 된다"가 재사용 가능한 기준이다.

P2 — 권한 거부가 AUTHORIZATION이 아니라 CONFIGURATION으로 기록된다

  • 사실. DestinationAccessValidator.requirePublishMessageAuthorizationException("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 카테고리를 쓰는 유일한 코드가 미사용 클래스에 있다.
  • 확인 방법. MessageAuthorizationExceptionCATEGORY 상수와 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/isExpiredCredentialRuntime.isDueForRotation/isExpired가 글자까지 같다. 전자는 소비자 0이고 전용 테스트(CredentialRotationContractTest, 4개)가 있다.
  • 근거. evidence/raw/287 §E.
  • 왜 문제인가. 테스트가 고정하는 것과 실행되는 것이 다른 객체다. 한쪽만 고치면 다른 쪽은 조용히 다른 시점에 회전한다.
  • 확인 방법. 두 메서드 본문 대조.
  • 후보. CredentialRuntimeCredentialRotationPlan을 필드로 갖고 위임하거나, 계획 record를 제거한다.
  • 다음 단계. REFERENCE 후보(같은 술어가 두 타입에 있으면 하나가 다른 하나를 부른다).

P3 — 자격증명 해석이 맵 bin 락 안에서 외부 I/O를 한다

  • 사실. resolveresolved.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[] materialvolatile이 아니고 clear()가 그것을 교체한다. clearAll()은 락 없이 순회한다.
  • 근거. CredentialRuntime.java:29,129-132, CredentialRuntimeRegistry.java:129-132.
  • 왜 문제인가. 정상 경로(compute 안 소거)에서는 ConcurrentHashMap이 happens-before를 준다. clearAll() 경로에는 그 보장이 없다 — 다른 스레드가 소거된 배열의 옛 참조를 보고 이미 지워진 material을 읽을 수 있다(0으로 채워진 값). 실질 위험은 낮고 방향도 안전(비밀 유출이 아니라 잘못된 값)하다.
  • 확인 방법. 필드 선언 확인.
  • 후보. materialvolatile로 하거나 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개 타입 미검증