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>
742 lines
46 KiB
Markdown
742 lines
46 KiB
Markdown
# 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개 타입 미검증 |
|