Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/notification-and-delivery/case/case-a13-f003-accesscontext.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

13 KiB

kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
kind slug title topic project status sourceRevision rootTreeNode evidenceCapturedOn body assets evidence source
CASE a13-f003-accesscontext 정책을 담은 필드가 참이 된 적도 읽힌 적도 없다 notification-and-delivery clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 case:a13-f003-accesscontext 2026-09-02 case-a13-f003-accesscontext.body.md
key file
a13-f003-accesscontext ../../../final/evidence/rendered/a13-f003-accesscontext.svg
key file
a13-f003-accesscontext-reveal ../../../final/evidence/rendered/a13-f003-accesscontext-reveal.svg
../../../final/evidence/raw/a13-f003-accesscontext.txt
../../../final/evidence/raw/a13-f003-accesscontext-reveal.txt
원본 분석 절은 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. 감사 포트와 그 구현이 저장소에 있는지 확인한다.

본문

평문 연락처를 복호화하는 포트가 접근 문맥을 함께 받는다.

두 번 선언되는 성질

:::evidence key="a13-f003-accesscontext" alt="접근 문맥 레코드 전체와 그것을 받는 포트의 자바독, 유일한 구현의 복호화 메서드 전체, 저장소 전체에서 이 레코드를 만드는 곳, 세 필드 접근자의 호출 수와 그중 잡히는 곳의 목록, 그리고 감사 포트와 그 구현이 저장소에 있다는 것을 출력한 터미널 기록." caption="레코드 자바독이 모든 노출은 감사 가능하다고 적고 포트 자바독도 같은 말을 한다 · 복호화 메서드에서 문맥은 널 검사에만 나온다 · 레코드를 만드는 길은 감사 필요를 거짓으로 고정한 팩토리 하나뿐 · 목적 코드와 감사 필요 접근자 호출은 0 — 63줄 · exit 0" zoom="true" :::

5:/** Why a plaintext contact point is being revealed. Every reveal is auditable. */
6:public record AccessContext(String purposeCode, String actorRef, boolean auditRequired) {
11:  /** Decrypt a value for an audited purpose. */
12:  ContactPointValue reveal(ProtectedContactPoint protectedValue, AccessContext context);

유일한 구현이 그것을 버린다

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" :::

[문맥을 바꿔 가며 같은 값을 복호화한다]
  purposeCode=DISPATCH       auditRequired=false -> EmailAddress
  purposeCode=BREAK_GLASS    auditRequired=true  -> EmailAddress
  purposeCode=SUPPORT_LOOKUP auditRequired=true  -> EmailAddress

감사 필요를 참으로 두어도 달라지는 것이 없다. 보낼 곳이 없기 때문이다.

[복호화기가 들고 있는 협력자]
  생성자 인자 : [SecretMaterialProvider]
  생성자 인자 : [SecretMaterialProvider, SecureRandom]
  필드 CIPHER     : String
  필드 HMAC       : String
  필드 KEY_ALGORITHM : String
  필드 keys       : SecretMaterialProvider
  필드 random     : SecureRandom

참을 넣는 코드가 없다

    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(...) 하나뿐이다. 나머지 아홉 건은 전부 그 팩토리 호출이고, 그중 셋은 테스트다.

[dispatch 팩토리가 정하는 값]
  purposeCode=DISPATCH  actorRef=ses-primary  auditRequired=false

읽는 코드도 없다

    purposeCode    : 0
    actorRef       : 7
    auditRequired  : 0

행위자 참조가 잡히는 일곱 건은 전부 다른 타입이다.

      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(),

관리자 행위자와 알림 감사 사건이다. 접근 문맥의 것이 아니다.

감사 포트와 기록 구현은 이미 있다

    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 가 그것을 주입받아 회전 사건을 남긴다. 빠진 것은 노출 경로의 연결 하나다.

확인하지 못한 것

과거에 그 문맥을 읽는 코드가 있었는지 이력에서 확인하지 않았다.