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>
206 lines
13 KiB
Markdown
206 lines
13 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: a13-f003-accesscontext
|
|
title: 정책을 담은 필드가 참이 된 적도 읽힌 적도 없다
|
|
topic: notification-and-delivery
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:a13-f003-accesscontext
|
|
evidenceCapturedOn: 2026-09-02
|
|
body: case-a13-f003-accesscontext.body.md
|
|
assets:
|
|
- key: a13-f003-accesscontext
|
|
file: ../../../final/evidence/rendered/a13-f003-accesscontext.svg
|
|
- key: a13-f003-accesscontext-reveal
|
|
file: ../../../final/evidence/rendered/a13-f003-accesscontext-reveal.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/a13-f003-accesscontext.txt
|
|
- ../../../final/evidence/raw/a13-f003-accesscontext-reveal.txt
|
|
source:
|
|
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L430 이다. 등급은 P2 이다. 레코드와 포트가 성질을 두 번 선언한다는 인용, 유일한 구현이 문맥을 널 검사만 하고 버린다는 관찰, 세 필드 접근자 계수와 동명 접근자가 다른 타입의 것이라는 확인, 감사 설비는 있는데 이 경로에만 연결되지 않았다는 판정, 프로덕션 reveal 호출자 여섯 곳이 전부 그 팩토리를 넘기고 그것이 감사 필요를 거짓으로 고정한다는 관찰, 참을 넘겨도 결과가 같다는 단정, 그리고 수정 방향이 그 절에 있다.
|
|
- 이 기록이 더한 것은 둘이다. 원본 절은 프로덕션 호출자만 셌지만, 이 레코드를 만드는 표현은 저장소 전체를 통틀어 팩토리 안의 것 하나뿐이고 테스트를 포함한 나머지 아홉 건이 전부 그 팩토리 호출이다 — 참을 넣는 표현이 어디에도 없다. 그리고 원본이 코드 읽기로 단정한 참을 넘겨도 결과가 같다는 것을, 문맥 셋으로 실제 복호화해 실행으로 확인했다.
|
|
---
|
|
|
|
# 정책을 담은 필드가 참이 된 적도 읽힌 적도 없다
|
|
|
|
접근 문맥 레코드와 그것을 받는 포트가 모든 평문 노출이 감사 가능하다고 두 번 선언한다. 저장소에서 그 레코드를 만드는 길은 감사 필요 여부를 거짓으로 고정한 팩토리 하나뿐이고, 세 필드 중 어느 것도 읽는 코드가 없다.
|
|
|
|
## 관계
|
|
|
|
- **소비자가 없는 fixture 셋**
|
|
이쪽도 선언된 타입의 프로덕션 호출자를 세어 0 을 확인했다.
|
|
- **타입이 문서화한 불변식은 타입이 강제한다**
|
|
이 타입도 javadoc 으로 불변식을 적어 두고 그것을 강제하지 않는다.
|
|
- **실패 사유가 저널에도 감사 이벤트에도 남지 않는다**
|
|
기록할 설비는 있는데 이 경로에서만 아무것도 남지 않는다.
|
|
|
|
## 문제
|
|
|
|
복호화 포트가 값과 함께 접근 문맥을 받는다.
|
|
|
|
문맥 레코드의 자바독은 평문 연락처가 왜 노출되는지를 담으며 모든 노출이 감사 가능하다고 적는다. 포트의 자바독은 감사되는 목적을 위해 값을 복호화한다고 적는다.
|
|
|
|
## 결론
|
|
|
|
유일한 구현은 문맥을 널 검사만 하고 버린다.
|
|
|
|
복호화 메서드에서 문맥을 언급하는 줄은 서명과 널 검사 두 개다. 나머지는 키를 찾고 복호화하고 파싱한다.
|
|
|
|
문맥을 바꿔 가며 같은 값을 복호화해 봤다. 감사 필요를 참으로 두고 목적 코드를 바꿔도 돌아오는 것이 같다. 복호화 메서드 본문에 감사를 부르는 호출이 하나도 없고, 복호화기가 들고 있는 것도 키 제공자와 난수뿐이다.
|
|
|
|
접근자 계수는 다른 각도에서 같은 자리를 짚는다. 목적 코드와 감사 필요 여부는 접근자 호출이 각각 0 이다. 행위자 참조 접근자는 일곱 건 잡히지만 전부 다른 타입의 동명 접근자다. 관리자 행위자 쪽과 감사 사건 쪽이 각각 자기 필드를 읽은 것이다.
|
|
|
|
읽히지 않는 것만이 아니다. 이 레코드를 만드는 길이 하나뿐이고, 그 팩토리가 감사 필요 여부를 거짓으로 고정한다. 프로덕션의 여섯 제공자 어댑터가 전부 그 팩토리를 부른다. 참을 넣는 코드가 저장소에 없다.
|
|
|
|
사고 조사가 누가 어떤 목적으로 어떤 접촉점의 평문을 열었는지 물으면, 이메일 주소와 전화번호와 디바이스 토큰의 복호화는 아무 흔적도 내놓지 못한다. 타입 서명만 흔적이 남는 것처럼 읽힌다.
|
|
|
|
감사 설비가 없어서가 아니다. 포트도 기록 구현도 저장소에 있고, 같은 리프의 회전기가 그 포트를 주입받아 회전 사건을 남긴다. 관리자 평면도 쓴다.
|
|
|
|
판정은 P2 다.
|
|
|
|
수정은 복호화 구현에 감사 포트를 주입하고 노출 시점에 기록하는 것이다. 사건에는 목적 코드와 행위자 참조와 키 식별자와 유형만 넣고 평문과 지문은 넣지 않는다. 감사 포트 자바독이 원시 주소를 절대 싣지 않는다고 적어 둔 그대로다. 감사 필요가 거짓인 배달 경로까지 표본으로 남길지는 그다음 결정이다.
|
|
|
|
## 검증 환경
|
|
|
|
OpenJDK : 21.0.12
|
|
확인 방식 : 구현 본문 확인, 생성 지점과 접근자 전수 계수, 실행 탐침
|
|
소스 수정 : x
|
|
|
|
## 재현 조건
|
|
|
|
1. 접근 문맥 레코드와 포트의 자바독을 읽는다.
|
|
2. 유일한 구현의 복호화 메서드를 읽고, 문맥을 언급하는 줄을 센다.
|
|
3. 저장소 전체에서 이 레코드를 만드는 곳을 센다.
|
|
4. 그 팩토리가 세 필드에 무엇을 넣는지 확인한다.
|
|
5. 세 필드 접근자를 저장소 전체에서 검색하고, 동명 접근자가 다른 타입의 것인지 확인한다.
|
|
6. 복호화기를 만들어 문맥을 바꿔 가며 같은 값을 복호화한다.
|
|
7. 복호화기가 어떤 협력자를 들고 있는지 반사로 확인한다.
|
|
8. 감사 포트와 그 구현이 저장소에 있는지 확인한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
평문 연락처를 복호화하는 포트가 접근 문맥을 함께 받는다.
|
|
|
|
## 두 번 선언되는 성질
|
|
|
|
:::evidence key="a13-f003-accesscontext" alt="접근 문맥 레코드 전체와 그것을 받는 포트의 자바독, 유일한 구현의 복호화 메서드 전체, 저장소 전체에서 이 레코드를 만드는 곳, 세 필드 접근자의 호출 수와 그중 잡히는 곳의 목록, 그리고 감사 포트와 그 구현이 저장소에 있다는 것을 출력한 터미널 기록." caption="레코드 자바독이 모든 노출은 감사 가능하다고 적고 포트 자바독도 같은 말을 한다 · 복호화 메서드에서 문맥은 널 검사에만 나온다 · 레코드를 만드는 길은 감사 필요를 거짓으로 고정한 팩토리 하나뿐 · 목적 코드와 감사 필요 접근자 호출은 0 — 63줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
```java
|
|
5:/** Why a plaintext contact point is being revealed. Every reveal is auditable. */
|
|
6:public record AccessContext(String purposeCode, String actorRef, boolean auditRequired) {
|
|
```
|
|
|
|
```java
|
|
11: /** Decrypt a value for an audited purpose. */
|
|
12: ContactPointValue reveal(ProtectedContactPoint protectedValue, AccessContext context);
|
|
```
|
|
|
|
## 유일한 구현이 그것을 버린다
|
|
|
|
```java
|
|
86: public ContactPointValue reveal(ProtectedContactPoint protectedValue, AccessContext context) {
|
|
87: Objects.requireNonNull(protectedValue, "protectedValue");
|
|
88: Objects.requireNonNull(context, "context");
|
|
89: SecretKeyMaterial key = keys.keyById(protectedValue.keyId());
|
|
90: byte[] plaintext =
|
|
91: decrypt(
|
|
92: key,
|
|
93: protectedValue.nonce(),
|
|
94: associatedData(protectedValue.type()),
|
|
95: protectedValue.ciphertext());
|
|
96: return parse(protectedValue.type(), new String(plaintext, StandardCharsets.UTF_8));
|
|
97: }
|
|
```
|
|
|
|
문맥이 마지막으로 나오는 줄은 88 이다.
|
|
|
|
## 문맥을 바꿔 넣어 보면
|
|
|
|
:::evidence key="a13-f003-accesscontext-reveal" alt="복호화기를 만들어 접근 문맥을 셋으로 바꿔 가며 같은 값을 복호화한 결과, 복호화기의 생성자 인자와 필드를 반사로 뽑은 목록, 그리고 dispatch 팩토리가 세 필드에 넣는 값을 출력한 터미널 기록." caption="감사 필요를 참으로 두고 목적 코드를 바꿔도 돌아오는 값이 같다 · 복호화기가 들고 있는 것은 키 제공자와 난수뿐이다 · dispatch 팩토리는 감사 필요를 거짓으로 고정한다 — 17줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
```text
|
|
[문맥을 바꿔 가며 같은 값을 복호화한다]
|
|
purposeCode=DISPATCH auditRequired=false -> EmailAddress
|
|
purposeCode=BREAK_GLASS auditRequired=true -> EmailAddress
|
|
purposeCode=SUPPORT_LOOKUP auditRequired=true -> EmailAddress
|
|
```
|
|
|
|
감사 필요를 참으로 두어도 달라지는 것이 없다. 보낼 곳이 없기 때문이다.
|
|
|
|
```text
|
|
[복호화기가 들고 있는 협력자]
|
|
생성자 인자 : [SecretMaterialProvider]
|
|
생성자 인자 : [SecretMaterialProvider, SecureRandom]
|
|
필드 CIPHER : String
|
|
필드 HMAC : String
|
|
필드 KEY_ALGORITHM : String
|
|
필드 keys : SecretMaterialProvider
|
|
필드 random : SecureRandom
|
|
```
|
|
|
|
## 참을 넣는 코드가 없다
|
|
|
|
```text
|
|
application/notification/platform/security/AccessContext.java:17: public static AccessContext dispatch(String providerProfileId) {
|
|
application/notification/platform/security/AccessContext.java:18: return new AccessContext("DISPATCH", providerProfileId, false);
|
|
adapter/outbound/notification/platform/security/AesGcmContactPointProtectorTest.java:27: assertThat(protector.reveal(first, AccessContext.dispatch("ses-primary"))).isEqualTo(value);
|
|
adapter/outbound/notification/platform/security/AesGcmContactPointProtectorTest.java:65: assertThatThrownBy(() -> protector.reveal(moved, AccessContext.dispatch("ses-primary")))
|
|
adapter/outbound/notification/platform/security/AesGcmContactPointProtectorTest.java:86: assertThatThrownBy(() -> protector.reveal(rotated, AccessContext.dispatch("ses-primary")))
|
|
adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapter.java:115: AccessContext.dispatch(submission.profile().profileId().value()));
|
|
adapter/outbound/notification/platform/provider/webpush/WebPushNotificationProviderAdapter.java:90: AccessContext.dispatch(submission.profile().profileId().value()));
|
|
adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapter.java:90: AccessContext.dispatch(submission.profile().profileId().value()));
|
|
adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapter.java:86: AccessContext.dispatch(submission.profile().profileId().value()));
|
|
adapter/outbound/notification/platform/provider/fcm/FcmBatchCoordinator.java:70: AccessContext.dispatch(submission.profile().profileId().value()));
|
|
adapter/outbound/notification/platform/provider/apns/ApnsNotificationProviderAdapter.java:84: AccessContext.dispatch(submission.profile().profileId().value()));
|
|
```
|
|
|
|
이 레코드를 만드는 표현은 팩토리 안의 `new AccessContext(...)` 하나뿐이다. 나머지 아홉 건은 전부 그 팩토리 호출이고, 그중 셋은 테스트다.
|
|
|
|
```text
|
|
[dispatch 팩토리가 정하는 값]
|
|
purposeCode=DISPATCH actorRef=ses-primary auditRequired=false
|
|
```
|
|
|
|
## 읽는 코드도 없다
|
|
|
|
```text
|
|
purposeCode : 0
|
|
actorRef : 7
|
|
auditRequired : 0
|
|
```
|
|
|
|
행위자 참조가 잡히는 일곱 건은 전부 다른 타입이다.
|
|
|
|
```text
|
|
application/notification/platform/admin/NotificationAdminApplicationService.java:138: actor.actorRef(),
|
|
application/notification/platform/admin/NotificationAdminApplicationService.java:189: actor.actorRef(),
|
|
application/notification/platform/admin/NotificationAdminApplicationService.java:267: actor.actorRef(),
|
|
application/notification/platform/admin/NotificationAdminApplicationService.java:321: actor.actorRef(),
|
|
adapter/outbound/notification/platform/observation/LoggingNotificationAudit.java:34: event.actorRef(),
|
|
adapter/outbound/persistence/notification/platform/JpaAdminOperationStore.java:44: actor.actorRef(),
|
|
adapter/outbound/persistence/notification/platform/JpaAdminOperationStore.java:101: actor.actorRef(),
|
|
```
|
|
|
|
관리자 행위자와 알림 감사 사건이다. 접근 문맥의 것이 아니다.
|
|
|
|
## 감사 포트와 기록 구현은 이미 있다
|
|
|
|
```text
|
|
application/notification/platform/observation/NotificationAuditPort.java:4:public interface NotificationAuditPort {
|
|
adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistryTest.java:183: private static final class RecordingAudit implements NotificationAuditPort {
|
|
adapter/outbound/notification/platform/observation/LoggingNotificationAudit.java:23: implements NotificationAuditPort, NotificationSecurityAuditPort {
|
|
```
|
|
|
|
포트와 기록 구현이 있고, 같은 리프의 `ProviderRuntimeRotator` 가 그것을 주입받아 회전 사건을 남긴다. 빠진 것은 노출 경로의 연결 하나다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
과거에 그 문맥을 읽는 코드가 있었는지 이력에서 확인하지 않았다.
|
|
|
|
<!-- body:end -->
|