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>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,199 @@
---
kind: CASE
slug: a06-f020-tls-stable
title: 프로파일이 선언한 여섯 값이 드라이버에 하나도 도달하지 않는다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a06-f020-tls-stable
evidenceCapturedOn: 2026-09-02
body: case-a06-f020-tls-stable.body.md
assets:
- key: a06-f020-tls-stable
file: ../../../final/evidence/rendered/a06-f020-tls-stable.svg
- key: a06-f020-tls-stable-probe
file: ../../../final/evidence/rendered/a06-f020-tls-stable-probe.svg
evidence:
- ../../../final/evidence/raw/a06-f020-tls-stable.txt
- ../../../final/evidence/raw/a06-f020-tls-stable-probe.txt
source:
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md#L1161 이다. 등급은 P1 이다. 팩토리의 javadoc, 호출자가 없다는 계수, 자격증명 해석기의 미도달, 실제 클라이언트를 Boot 가 만든다는 사실, 탐침이 잰 값들, 이미 고쳐진 형제, 그리고 기존 시험 둘이 이 경계를 보지 못한다는 판정이 그 절에 있다.
- 팩토리가 만들었을 설정의 여섯 값, 커스터마이저를 적용한 뒤에도 실제값이 그대로라는 것, ssl 속성이 두 번째 경로라는 것, README 의 속성 키가 이 Boot 버전에서 바인딩되지 않는다는 것, 그리고 프로파일을 선언하려면 토폴로지 프로브가 함께 있어야 한다는 것은 이 기록에서 확인했다.
---
# 프로파일이 선언한 여섯 값이 드라이버에 하나도 도달하지 않는다
프로파일의 값을 드라이버 설정으로 옮기는 클래스가 있고, 저장소 어디에도 그것을 부르는 코드가 없다. Boot 가 클라이언트를 만들 때 쓰는 설정은 TLS 가 꺼져 있고 타임아웃과 풀과 서버 API 와 UUID 표현이 전부 드라이버 기본값이다.
## 관계
- **검증기가 운영에 TLS를 요구하고, 실제로 조립되는 생산자에는 그 설정이 없다**
다른 리프에서 같은 형태가 난 사례다.
- **@Bean이 있다는 것은 조립 증거가 아니다**
수리 코드가 배선되지 않은 형태다.
- **조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다**
이 사례를 찾는 방법이다.
## 문제
값을 옮기는 클래스는 존재하고, 자기 javadoc 에 왜 만들어졌는지 적어 두었다. 프로파일과 자격증명 해석기와 TLS 와 Stable API 플래그와 풀과 타임아웃 정책이 전부 있었고 전부 단위 시험돼 있었는데 그중 어느 것도 설정에 도달하지 않았다는 것이다.
같은 형태가 한 단계 뒤에서 반복된다. 그 클래스는 작성됐고, 그것을 부르는 배선은 없다.
## 결론
참조가 자기 파일과 자기 시험 밖으로 나가지 않는다. 저장소 전체에서 남는 언급 넷은 안내 문서, 옛 리뷰, 생성된 API 표면 기준선이며 호출은 하나도 없다. 유일하게 그 클래스만 쓰는 자격증명 해석기도 같다.
Boot 가 클라이언트를 만들 때 쓰는 설정을 재었다. 기본 빈에 등록된 커스터마이저 둘을 적용한 결과가 그 설정이다. 프로덕션 프로파일은 TLS 요구, 엄격한 서버 API 고정, 연결 2초, 서버 선택 3초, 풀 상한 40, 고정 UUID 표현을 말한다. 실제 설정은 TLS 꺼짐, 연결 10초, 서버 선택 30초, 풀 상한 100, 서버 API 없음, UUID 표현 미지정이다. 여섯이 전부 다르다. 같은 프로파일로 팩토리를 부르면 여섯이 전부 선언대로 나온다.
첫 값만 성질이 다르다. 타임아웃과 풀 상한이 성능 문제라면 TLS 는 노출 문제다.
TLS 를 켜는 경로는 둘이다. 배포가 URI 에 그 옵션을 넣거나, spring.mongodb.ssl 속성을 켜는 것이다. 어느 쪽이든 프로파일이 선언한 값과는 무관하게 배포가 한 번 더 말해야 한다.
이 선언이 실제로 검증되는 배포는 아직 없다. 이 모듈 아래 출하 설정이 여는 키는 둘 — 활성화 여부와 활성 프로파일 이름이다. 프로파일 본체를 채워 주는 자리는 없다. 넣으려면 포크가 토폴로지 프로브 빈까지 함께 공급해야 하고, 넣지 않으면 기동이 그 사실을 말하며 거부한다. 지금 도는 배포에서 터지는 결함이 아니다. 프로파일이 선언되는 순간부터 성립한다.
이 경계를 보는 시험은 없다. 팩토리 시험은 팩토리를 직접 만들어 그 출력이 프로파일대로인지 확인한다. 그 시험의 javadoc 은 타이핑된 프로파일이 드라이버가 실제로 만들어지는 설정에 도달한다고 적는데, 확인하는 것은 팩토리의 출력이고 그 팩토리는 아무도 부르지 않는다. TLS 레인 시험은 ssl.enabled(true) 를 손수 붙인 클라이언트로 서버가 TLS 를 강제하는지 확인한다.
수리 수단은 이 리프에 이미 있다. 관측 쪽에도 설정 빌더 적용 지점에 호출자가 없던 시기가 있었다. 관측 쪽은 자동 구성이 Boot 커스터마이저를 빈으로 올려 해결했다. 프로덕션에서 그 수단이 등록되는 자리는 하나다.
## 검증 환경
OpenJDK : 21.0.12
Spring Boot : 4.0.8
확인 방식 : 참조 전수 계수, 실제 자동 구성을 올린 컨텍스트에서 커스터마이저 적용 후 설정 값 비교
소스 수정 : x
## 재현 조건
1. 클라이언트 설정 팩토리의 javadoc 을 읽는다.
2. 그 타입과 자격증명 해석기를 언급하는 곳을 저장소 전체에서 센다.
3. 프로파일 검증이 production 에 TLS 를 요구하는 자리와, 그 검증이 켜지려면 무엇이 있어야 하는지를 읽는다.
4. 출하 설정이 이 모듈 아래 노출하는 키와 프로파일을 넣는 곳의 수를 센다.
5. 이 리프의 자동 구성과 Boot 의 Mongo 자동 구성을 올린 컨텍스트를 띄운다.
6. 기본 설정 빈에 등록된 커스터마이저를 전부 적용하고 값을 읽는다.
7. URI 를 건드리지 않고 spring.mongodb.ssl.enabled 만 켜서 같은 값을 읽는다.
8. 같은 프로파일로 팩토리를 불러 만들어졌을 설정의 값을 읽는다.
9. 이 경계를 보는 시험이 있는지 확인한다.
## 본문
<!-- body:start -->
값을 옮기는 클래스는 있다. 자기 javadoc 에 왜 만들어졌는지도 적혀 있다.
## 설정을 만드는 팩토리
:::evidence key="a06-f020-tls-stable" alt="클라이언트 설정 팩토리의 javadoc, 저장소 전체에서 그 타입과 자격증명 해석기를 언급하는 곳 전수, 프로파일 검증이 production 에 TLS 를 요구하는 자리, 그 검증이 켜지려면 토폴로지 프로브가 필요하다는 기동 가드, 출하 설정이 이 모듈 아래 노출하는 키와 프로파일을 넣는 곳의 수, 이 리프가 관측 쪽에 쓴 Boot 빌더 커스터마이저 등록과 그 수단의 프로덕션 등록 수, 그리고 팩토리 시험의 javadoc 과 TLS 레인 시험이 쓰는 손수 만든 클라이언트를 출력한 터미널 기록." caption="팩토리와 해석기를 언급하는 곳은 문서 넷뿐이고 호출은 없음 · 검증은 프로파일이 선언됐을 때만 켜지고 토폴로지 프로브가 없으면 기동이 거부 · 출하 키는 enabled 와 active-profile 둘, 프로파일을 넣는 곳 0 · 빌더 커스터마이저의 프로덕션 등록은 1 · 두 시험 다 팩토리 출력이나 손수 만든 설정을 본다 — 69줄 · exit 0" zoom="true"
:::
```java
* <p>The profile, the credential resolver, the TLS and Stable-API flags and the pool and timeout
* policy all existed and were all unit-tested. None of them reached a {@link MongoClientSettings}:
* the values were checked as intermediate objects and whatever the driver ended up configured with
* was decided elsewhere, by defaults nobody had chosen. A policy that nothing applies reads exactly
* like a policy that is applied the tests pass, the record is populated, and the client connects
* with a three-second timeout it inherited from the driver rather than the two the profile states.
```
이 클래스를 참조하는 곳은 자기 파일과 자기 시험뿐이다. 저장소 전체를 훑으면 안내 문서와 옛 리뷰 문서와 생성된 API 표면 기준선의 언급 넷이 남고, 어느 것도 호출이 아니다.
## 선언값과 실제값
:::evidence key="a06-f020-tls-stable-probe" alt="같은 URI 를 두 속성 키로 각각 넣어 얻은 접속 호스트, 프로덕션 프로파일이 선언하는 TLS 요구와 서버 API 고정과 두 타임아웃과 풀 상한과 UUID 표현, 그 선언을 설정 검증이 통과시키는 결과, 이 리프와 Boot 의 자동 구성을 올린 컨텍스트에서 커스터마이저를 전부 적용한 뒤 읽은 같은 값들, URI 를 건드리지 않고 ssl 속성만 켰을 때의 결과, 그리고 같은 프로파일로 팩토리를 불러 만들어졌을 설정의 같은 값들을 출력한 터미널 기록." caption="README 가 보여 주는 키는 호스트를 바꾸지 못하고 다른 키만 바꾼다 · 선언은 TLS 요구·엄격 서버 API·2초·3초·40·STANDARD · 커스터마이저 둘을 적용한 실제 설정은 TLS 꺼짐·10초·30초·100·없음·UNSPECIFIED · ssl 속성만 켜도 TLS 는 켜짐 · 팩토리가 만들었을 설정은 여섯이 전부 선언대로 — 21줄 · exit 0" zoom="true"
:::
이 리프의 자동 구성과 Boot 의 Mongo 자동 구성을 올리고, 기본 설정 빈에 등록된 커스터마이저를 Boot 가 하는 대로 전부 적용한 뒤 값을 읽었다.
```text
[선언] production 프로파일이 말하는 값
tlsRequired=true stableApiStrict=true connectTimeout=2000ms serverSelection=3000ms poolMax=40 uuid=STANDARD
이 선언을 설정 검증이 통과시킨다
[실제] Boot 가 클라이언트를 만들 때 쓰는 설정 (커스터마이저 적용 후)
커스터마이저 2개 적용
sslEnabled=false
connectTimeoutMs=10000 serverSelectionTimeoutMs=30000
poolMaxSize=100 serverApi=null uuidRepresentation=UNSPECIFIED
[미도달] 팩토리가 만들었을 설정
sslEnabled=true
connectTimeoutMs=2000 serverSelectionTimeoutMs=3000
poolMaxSize=40 serverApi=ServerApi{version=V1, deprecationErrors=true, strict=true} uuidRepresentation=STANDARD
```
여섯이 전부 다르다. 관측 커스터마이저가 등록된 상태에서도 같다 — 그 커스터마이저는 리스너만 붙이고 나머지에 손대지 않는다. `serverApi=null` 은 엄격한 서버 API 고정이 없다는 뜻이고, `uuidRepresentation=UNSPECIFIED` 는 팩토리가 저장된 문서 아래서 값이 움직이는 것을 아무도 쓰지 않은 마이그레이션이라 부르며 고정하려던 값이다.
첫 값은 나머지와 성질이 다르다. 타임아웃과 풀 상한은 성능이고, TLS 는 노출이다.
## TLS 를 켜는 경로는 프로파일이 아니다
```text
spring.mongodb.ssl.enabled=true -> sslEnabled=true
```
URI 를 건드리지 않고 속성만 켜도 TLS 가 켜진다. URI 에 옵션을 직접 넣는 경로도 있다. 어느 쪽이든 배포가 프로파일과 별개로 한 번 더 말해야 한다.
덧붙여, 모듈 README 가 보여 주는 속성 키는 이 Boot 버전에서 바인딩되지 않는다. 같은 URI 를 두 키로 넣어 접속 호스트를 읽으면 갈린다.
```text
spring.mongodb.uri -> [canary-host:31337]
spring.data.mongodb.uri -> [localhost:27017]
```
README 의 키를 그대로 넣은 배포는 기본 호스트로 뜬다.
## 이 선언이 검증되는 배포는 아직 없다
프로파일 검증은 프로덕션 프로파일이 TLS 를 요구하지 않으면 거부하고, 그 메시지가 같은 결함의 이전 판을 적고 있다.
```java
"a production MongoDB profile requires TLS; the flag existed and nothing checked it, so a"
+ " deployment could carry tls-required=false and still be called production"
```
다만 그 검증은 프로파일이 선언됐을 때만 켜진다. 출하 설정이 이 모듈 아래 노출하는 키는 활성화 여부와 활성 프로파일 이름 둘뿐이고, 프로파일 자체를 넣는 곳은 저장소에 없다. 넣으려면 토폴로지 프로브 빈이 함께 있어야 한다.
```java
if (probe.getIfAvailable() == null) {
throw new IllegalStateException(
"the Mongo platform has configured profiles but no MongoTopologyProbe bean, so the "
+ "startup validator has nothing to ask about the server: …
```
그래서 이것은 지금 도는 배포의 결함이 아니라 조립하는 쪽이 프로파일을 선언하는 순간 성립하는 결함이다.
## 이 경계를 보는 시험이 없다
```java
/**
* The typed profile reaches the settings the driver is actually built from (MNG-INT-002).
```
팩토리 시험의 javadoc 이다. 확인하는 것은 팩토리의 출력이고, 그 팩토리는 아무도 부르지 않는다. TLS 레인 시험은 반대쪽에서 같은 자리를 비껴간다.
```java
MongoClientSettings.builder()
.applyConnectionString(new ConnectionString(connectionString))
.applyToSslSettings(ssl -> ssl.enabled(true).context(trustOnly(authorityPem)))
```
손수 붙인 설정으로 서버가 TLS 를 강제하는지 확인한다. 어느 쪽도 프로파일의 선언이 실제 연결을 TLS 로 만드는지 묻지 않는다.
## 이 리프가 이미 쓰는 배선 수단
```java
/** The customizer Boot applies when it builds the client. */
@Bean
@ConditionalOnBean(MeterRegistry.class)
@ConditionalOnMissingBean(name = "mongoDriverObservabilityCustomizer")
public MongoClientSettingsBuilderCustomizer mongoDriverObservabilityCustomizer(
```
관측 쪽도 설정 빌더에 적용하는 자리에 호출자가 없는 같은 형태였고, 자동 구성이 Boot 의 빌더 커스터마이저를 등록해 고쳤다. 프로덕션에서 그 수단을 등록하는 곳은 이 한 자리뿐이다. 설정 쪽에는 쓰이지 않았다.
## 확인하지 못한 것
실제 서버에 평문으로 연결되는 것을 관측하지 않았다. 확인한 것은 Boot 가 클라이언트를 만들 때 넘기는 설정 객체의 값이다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: adapter-inbound-graphql-c01
title: off 계약의 두 절반과 리터럴 404를 피한 단언
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-graphql-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-graphql-c01
file: ../../../final/evidence/rendered/adapter-inbound-graphql-c01.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-graphql-c01.txt
source:
- 원본 분석 절은 analysis/16-adapter-inbound-graphql.md#L130 이다.
module: adapter-inbound-graphql
---
# off 계약의 두 절반과 리터럴 404를 피한 단언
꺼짐 계약이 두 절반으로 나뉜다 — 이 leaf의 빈을 막는 절반과 프레임워크가 발행하는 라우트를 막는 절반. 뒤쪽 테스트는 리터럴 404가 아니라 매핑된 적 없는 경로의 상태 코드와 같은지를 본다.
## 본문
<!-- body:start -->
꺼짐 계약이 두 절반으로 나뉜다.
| 절반 | 무엇을 막는가 | 검증 |
|---|---|---|
| `GraphQlRootAutoConfiguration``@ConditionalOnProperty` | 이 leaf의 39개 빈 | `GraphQlShippedAndGatedTest.graphQlOffHoldsNothing` — 빈 인벤토리 |
| `GraphQlOffAutoConfigurationImportFilter` | 프레임워크가 발행하는 `/graphql` 라우트 | `GraphQlShippedAndGatedTest.graphQlOffPublishesNoEndpoint` — 실제 포트 |
## 리터럴 404를 단언하지 않는 이유
두 번째 테스트가 특히 정교하다. 리터럴 404를 단언하지 않고, 매핑된 적 없는 경로의 상태 코드와 **같은지**를 본다 — 주석이 그 이유를 적는다: "Asserting a literal 404 would have been wrong: the security filter chain runs before ...".
## 그 테스트가 app-bootstrap에 있는 이유
그리고 그 테스트는 이 leaf가 아니라 app-bootstrap에 있다. off 계약은 출하 조립에서만 검증할 수 있으므로 옳은 위치다.
## 분석 원문의 두 절반 표
:::evidence key="adapter-inbound-graphql-c01" alt="분석 문서 analysis/16-adapter-inbound-graphql.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/16-adapter-inbound-graphql.md 발췌 — 15줄" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,58 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c03
title: security 패키지가 자기 안에서만 서로를 부른다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c03
file: ../../../final/evidence/rendered/adapter-inbound-web-c03.svg
- key: adapter-inbound-web-c03-diagram
file: ../../../final/assets/diagrams/adapter-inbound-web-c03.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c03.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L426 이다.
module: adapter-inbound-web
---
# security 패키지가 자기 안에서만 서로를 부른다
신원 모델 타입들의 프로덕션 참조를 전수 세면 서로를 가리키는 것뿐이고 패키지 바깥에서 들어오는 화살표가 없다. 교차 테넌트 가드 `rejectTenantInput`도 그 섬 안에만 있다.
## 본문
<!-- body:start -->
신원 모델 타입들의 프로덕션 참조를 전수 세면 이렇다.
```text
WebSecurityContextBridge : main_refs=0 test_refs=1
WebActorContextResolver : main_refs=1 test_refs=0 ← 참조자는 WebSecurityContextBridge 하나
WebTenantContextResolver : main_refs=1 test_refs=1 ← 같음
AuthenticationView : main_refs=3 test_refs=1 ← 전부 위 세 파일
SecurityIdentity : main_refs=1 test_refs=1
rejectTenantInput : main_refs=2 test_refs=1 ← 선언 + 브리지 오버로드. 세 번째 호출자 없음
WebCorsPolicyValidator : main_refs=0 test_refs=1
WebCsrfPolicyResolver : main_refs=0 test_refs=1
```
`AuthenticationView`를 만드는 코드도 테스트뿐이다 — `WebSecurityContextBridgeTest`의 다섯 줄이 전부다.
## 신원 모델이 닿는 범위
:::evidence key="adapter-inbound-web-c03-diagram" alt="security 패키지 경계 안에 세 타입이 들어 있고 바깥 프로덕션 호출자 상자가 빗금으로 경계 밖에 놓인 구조" caption="신원 모델이 닿는 범위" zoom="false"
:::
`security` 패키지 전체가 **자기 안에서만 서로를 부르는 닫힌 섬**이고, 바깥에서 들어오는 화살표가 없다. 교차 테넌트 가드 `rejectTenantInput`은 그 섬 안에만 있다.
## AuthenticationView 참조 위치
:::evidence key="adapter-inbound-web-c03" alt="코드베이스에서 AuthenticationView 를 검색한 출력 21줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="AuthenticationView 코드베이스 검색 — 21줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,60 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c04
title: publicPaths가 먼저 등록되어 제한 경로 규칙을 덮는다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c04
file: ../../../final/evidence/rendered/adapter-inbound-web-c04.svg
- key: adapter-inbound-web-c04-diagram
file: ../../../final/assets/diagrams/adapter-inbound-web-c04.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c04.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L473 이다.
module: adapter-inbound-web
---
# publicPaths가 먼저 등록되어 제한 경로 규칙을 덮는다
`SecurityConfig``publicPaths``RestrictedPathRule`보다 먼저 등록한다. Spring Security는 첫 일치가 이기므로, 주석이 말하는 순서는 `anyRequest()`에 대해서만 성립하고 `publicPaths`에 대해서는 반대다.
## 본문
<!-- body:start -->
매처가 등록되는 순서는 이렇다.
```java
// SecurityConfig.java:83-94
.authorizeHttpRequests(auth -> {
if (publicPaths.length > 0) { auth.requestMatchers(publicPaths).permitAll(); } // ← 먼저
for (RestrictedPathRule rule : restricted) { // ← 나중
auth.requestMatchers(rule.pathPattern()).hasAnyAuthority(rule.authorities());
}
auth.anyRequest().authenticated();
})
```
## 매처가 등록되는 순서
:::evidence key="adapter-inbound-web-c04-diagram" alt="공개 경로 매처와 제한 경로 규칙과 인증 요구 매처가 왼쪽에서 오른쪽으로 이어지고 화살표에 등록 순서가 붙은 구조" caption="매처가 등록되는 순서" zoom="false"
:::
Spring Security는 첫 일치가 이긴다. 주석은 "Ordered before the authenticated catch-all: **a management path must be refused at the transport**"라고 하는데, 그 순서는 `anyRequest()`에 대해서만 성립하고 `publicPaths`에 대해서는 반대다. §12.3.
## RestrictedPathRule 참조 위치
:::evidence key="adapter-inbound-web-c04" alt="코드베이스에서 RestrictedPathRule 를 검색한 출력 8줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RestrictedPathRule 코드베이스 검색 — 8줄 · exit 0" zoom="true"
:::
## 실제로 등록되는 값
프로덕션 `RestrictedPathRule` 생산자는 하나다 — `FileserverAdminPlaneConfiguration:36`이 fileserver 관리 경로를 등록한다. `publicPaths`의 기본값은 `${SECURITY_PUBLIC_PATHS:${PRESENTATION_API_BASE_PATH:/v1}/healthcheck}`로, 환경변수 하나로 전체가 대체된다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c07
title: 지문이 길이 프레이밍 없이 구분자로만 만들어진다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c07
file: ../../../final/evidence/rendered/adapter-inbound-web-c07.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c07.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L785 이다.
module: adapter-inbound-web
---
# 지문이 길이 프레이밍 없이 구분자로만 만들어진다
`SemanticRequestFingerprintFactory`는 U+001F 한 글자를 구분자로 쓰고 값에 이스케이프나 길이 접두사를 붙이지 않는다. 같은 저장소의 다른 다이제스트들은 4바이트 길이 프레이밍을 쓴다.
## 본문
<!-- body:start -->
`SemanticRequestFingerprintFactory`는 U+001F 한 글자를 구분자로 쓰고, 경로 변수는 `SEP + name + "=" + value`, 헤더는 `SEP + name + ":" + value`로 이어붙인다. 값에 대한 이스케이프나 길이 접두사가 없다.
## SemanticRequestFingerprintFactory 참조 위치
:::evidence key="adapter-inbound-web-c07" alt="코드베이스에서 SemanticRequestFingerprintFactory 를 검색한 출력 18줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="SemanticRequestFingerprintFactory 코드베이스 검색 — 18줄 · exit 0" zoom="true"
:::
## 같은 저장소의 다른 다이제스트는 길이 프레이밍을 쓴다
notification `NotificationCatalogException.update`, messaging의 도메인 분리 상수는 **4바이트 길이 프레이밍**을 쓰고, 그 이유를 "인접 필드 연결로 인한 충돌이 구조적으로 불가능"으로 적는다. §20.3.
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-httpclient-c07
title: 절대 URI를 정화하지 않고 거부한다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-httpclient-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-httpclient-c07
file: ../../../final/evidence/rendered/adapter-outbound-httpclient-c07.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-httpclient-c07.txt
source:
- 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#L474 이다.
module: adapter-outbound-httpclient
---
# 절대 URI를 정화하지 않고 거부한다
`TrustedTargetPolicy`는 절대 URI를 정화하는 대신 거부한다. 목적지를 바꾸는 것은 다른 계층의 일이고 그 계층은 자기 정책·자격증명·DNS 검증을 갖는다.
## 본문
<!-- body:start -->
`TrustedTargetPolicy`의 규칙 — "An absolute URI is **rejected here rather than sanitised**: H2 exists to vary method, relative path, query, approved headers, and body — not the destination. Changing the destination is what H3 is for, and H3 has its own policy, credentials, and DNS validation."
## 템플릿이 거부되는 조건
`requireRelativeTemplate`가 빈 템플릿, `//` 시작, `://` 포함, `/`로 시작하지 않음을 거부한다. 확장은 문자열 연결이 아니라 Spring `DefaultUriBuilderFactory``TEMPLATE_AND_VALUES` 인코딩이라 "a value containing `/`, `?`, or `#` cannot change the shape of the request." 그리고 확장 **후에** `requireAllowedOrigin`이 host/port allowlist를 다시 본다.
## TrustedTargetPolicy 참조 위치
:::evidence key="adapter-outbound-httpclient-c07" alt="코드베이스에서 TrustedTargetPolicy 를 검색한 출력 12줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="TrustedTargetPolicy 코드베이스 검색 — 12줄 · exit 0" zoom="true"
:::
## 리다이렉트 hop에 allowlist를 다시 적용하는 이유
멱등성 키 처리에 수정 이력 둘이 붙어 있다. 그리고 리다이렉트 hop에 allowlist를 다시 적용하는 `requireAllowedTarget`이 public인 이유도 적혀 있다 — 조정자가 이전에는 리다이렉트 정책만 보고 프로파일 allowlist를 보지 않아 "An upstream could therefore redirect a trusted profile to any origin the redirect policy tolerated, including one the operator had explicitly excluded."
<!-- body:end -->
@@ -0,0 +1,59 @@
---
kind: CONCEPT
slug: adapter-outbound-httpclient-c09
title: TLS 실패가 CONNECT로 분류된 원인은 픽스처의 듀얼스택 호스트명이다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-httpclient-c09
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-httpclient-c09
file: ../../../final/evidence/rendered/adapter-outbound-httpclient-c09.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-httpclient-c09.txt
source:
- 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#L627 이다.
module: adapter-outbound-httpclient
---
# TLS 실패가 CONNECT로 분류된 원인은 픽스처의 듀얼스택 호스트명이다
이 절은 이전 사이클이 여기에 적었던 P1 진단을 철회하고 교체한다. 관측된 실패는 그대로 재현되지만, 원인으로 지목했던 기전은 측정으로 반증되었다. 근거는 `EVD-332`다.
## 본문
<!-- body:start -->
이 절은 이전 사이클이 여기에 적었던 **P1 진단을 철회하고 교체한다**. 관측된 실패는 그대로 재현되지만, 그 원인으로 지목했던 기전은 측정으로 반증되었다. 근거는 `EVD-332`다.
## 관측은 그대로다
`:adapter:outbound:httpclient:test` 는 HEAD 에서도 **283 중 3건 실패**한다. 세 건 모두 `MutualTlsHandshakeContractTest.java:168``assertThat(classified.stage()).isEqualTo(TLS_HANDSHAKE)` 다. 바로 앞줄인 167행(`evidence == NOT_SENT`)은 통과한다.
## 철회하는 진단
이전 진단은 `ApacheFailureClassifier.recognize`의 분기 순서(pool → DNS → CONNECT → TLS)를 읽고 사슬의 모양을 추론한 것이지, 사슬을 실제로 떠본 것이 아니다. 잡힌 예외를 그대로 출력하면 사슬에 `SSLHandshakeException`**없다**. 그림자에 가려진 것이 아니라 애초에 도착하지 않았다. 분류기는 자기가 받은 것을 정확히 분류했다.
## ApacheFailureClassifier 참조 위치
:::evidence key="adapter-outbound-httpclient-c09" alt="코드베이스에서 ApacheFailureClassifier 를 검색한 출력 5줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ApacheFailureClassifier 코드베이스 검색 — 5줄 · exit 0" zoom="true"
:::
## 두 주소 중 마지막 것만 호출자에게 도달한다
동일한 서버 객체, 동일한 클라이언트 신뢰재료. `baseUrl`의 호스트 문자열만 바꿨다. 이 컨테이너의 `/etc/hosts``localhost`를 두 패밀리에 준다. `MockWebServer`는 IPv4 루프백에만 바인딩하고, `MockHttpServer.uri()`는 호스트명 `localhost`를 돌려준다 (`MockHttpServer.java:70-72`).
```text
127.0.0.1 → TCP 성공 → TLS 핸드셰이크 실패(진짜 실패) → 삼켜짐
::1 → TCP 거부(듣는 소켓 없음) → 마지막 주소 → HttpHostConnectException 으로 승격
```
Apache HttpClient 5의 연결 오퍼레이터는 해석된 주소를 순회하면서 **마지막이 아닌 주소의 실패를 삼킨다**. 호출자에게 도달하는 유일한 예외는 두 번째 주소의 연결 거부다.
## 성공하는 테스트가 통과하는 이유도 같은 루프다
127.0.0.1 에서 성공하면 루프가 즉시 반환하므로 `::1`을 시도하지 않는다. 따라서 처음 눈에 띄었던 `startTls(..., true/false)` 차이는 원인이 아니라 상관관계였다 — 실패하는 케이스가 곧 두 번째 주소까지 가는 케이스다. `startTls(..., false)`에서 서버가 뜨지 않는다는 가설도 함께 기각했다. 두 경우 모두 원시 소켓 접속이 성공한다(`raw 127.0.0.1: OK`, `raw localhost: OK`).
<!-- body:end -->
@@ -0,0 +1,51 @@
---
kind: CONCEPT
slug: adapter-outbound-notification-c04
title: unhealthy 조건 넷에 DRAINING이 없다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-notification-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-notification-c04
file: ../../../final/evidence/rendered/adapter-outbound-notification-c04.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-notification-c04.txt
source:
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L619 이다.
module: adapter-outbound-notification
---
# unhealthy 조건 넷에 DRAINING이 없다
`NotificationHealthReporter.snapshot()``healthy = false`로 넘어가는 조건은 넷이고, `DRAINING`은 그 목록에 없다. 로테이션 중 드레인은 정상 운영이므로 그 자체로는 옳다.
## 본문
<!-- body:start -->
`NotificationHealthReporter.snapshot()``healthy = false`로 넘어가는 조건은 넷이다.
| 조건 | 위치 |
|---|---|
| 감시 대상 프로파일이 레지스트리에 없음 (`UNREGISTERED`) | `:57-62` |
| 상태가 `AUTHENTICATION_FAILED` 또는 `DISABLED` | `:64-67` |
| provider는 있는데 라우팅된 채널이 하나도 없음 | `:78-80` |
| 서빙 상태가 선언된 임계치를 넘음 | `:83-85` |
## 세 번째 조건에 붙은 자기고발
"A platform with providers but no route accepts every request and delivers none. It was reported healthy because every runtime was healthy — **which was true and beside the point**."
## 분석 원문의 조건 표
:::evidence key="adapter-outbound-notification-c04" alt="분석 문서 analysis/13-adapter-outbound-notification.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/13-adapter-outbound-notification.md 발췌 — 15줄" zoom="true"
:::
## DRAINING이 목록에 없다
여기서 눈에 띄는 것은 **`DRAINING`이 목록에 없다**는 점이다. 로테이션 중 드레인은 정상 운영이므로 그 자체로는 옳다. 그러나 §13의 P2와 겹치면 부작용이 하나 더 생긴다 — 아래 §21.2.
<!-- body:end -->
@@ -0,0 +1,42 @@
---
kind: CONCEPT
slug: adapter-outbound-objectstorage-c03
title: ObjectReference가 보장하는 route 구획
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-objectstorage-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-objectstorage-c03
file: ../../../final/evidence/rendered/adapter-outbound-objectstorage-c03.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-objectstorage-c03.txt
source:
- 원본 분석 절은 analysis/09-adapter-outbound-objectstorage.md#L135 이다.
module: adapter-outbound-objectstorage
---
# ObjectReference가 보장하는 route 구획
`RoutingObjectReadAdapter.load``canonicalText`를 분리한 뒤 두 번째 구획을 직접 읽는다. 별도 인덱스 검사는 없지만 `ObjectReference` 생성자가 `ObjectIdentitySupport.requireRouted(canonicalText, "osr1")`로 형식을 강제하므로, 유효한 참조에는 route 구획이 항상 존재한다.
## 본문
<!-- body:start -->
## 직접 인덱싱과 입력 불변식
`RoutingObjectReadAdapter.load``reference.canonicalText().split("\\.", -1)[1]`로 route token을 읽는다. 이 코드만 보면 두 번째 구획이 없을 때 `ArrayIndexOutOfBoundsException`이 발생할 수 있다.
## RoutingObjectReadAdapter 참조 위치
:::evidence key="adapter-outbound-objectstorage-c03" alt="코드베이스에서 RoutingObjectReadAdapter 를 검색한 출력 5줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RoutingObjectReadAdapter 코드베이스 검색 — 5줄 · exit 0" zoom="true"
:::
## ObjectReference가 보장하는 형식
`ObjectReference` 생성자는 `ObjectIdentitySupport.requireRouted(canonicalText, "osr1")`를 통해 routed 형식을 먼저 검증한다. 따라서 정상적으로 생성된 `ObjectReference``load`에 전달되는 경로에서는 두 번째 구획이 값 타입의 불변식으로 보장된다. 현재 계약에서는 이 split 자체를 결함으로 분류할 근거가 없다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c53
title: 가드의 존재가 곧 테넌트 격리 보장은 아니다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c53
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c53
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c53.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c53.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L3784 이다.
module: adapter-outbound-persistence-jpa
---
# 가드의 존재가 곧 테넌트 격리 보장은 아니다
`TenantAwareRepositoryGuard``TenantEntityListenerGuard`의 local behavior는 fail-closed다. 그러나 현재 production repository/entity에 연결된 caller/listener registration이 없다.
## 본문
<!-- body:start -->
`TenantAwareRepositoryGuard``TenantEntityListenerGuard`의 local behavior는 fail-closed다.
## TenantAwareRepositoryGuard 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c53" alt="코드베이스에서 TenantAwareRepositoryGuard 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="TenantAwareRepositoryGuard 코드베이스 검색 — 3줄 · exit 0" zoom="true"
:::
## 존재만으로 격리를 주장하지 않는 이유
현재 production repository/entity에 연결된 caller/listener registration은 없다. 따라서 이 type들이 존재한다는 이유만으로 현재 application의 tenant isolation이 보장된다고 쓰지 않는다.
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-support-c03
title: 로그 마스킹은 PII 차단 보장을 복구하지 않는다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-support-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-support-c03
file: ../../../final/evidence/rendered/adapter-outbound-support-c03.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-support-c03.txt
source:
- 원본 분석 절은 analysis/04-adapter-outbound-support.md#L207 이다.
module: adapter-outbound-support
---
# 로그 마스킹은 PII 차단 보장을 복구하지 않는다
`app-bootstrap``LogMaskingPatterns`는 방어 심층화로 몇 가지 secret 형태를 mask하지만 arbitrary email address나 free-form body PII를 일반적으로 제거하는 규칙은 없다.
## 본문
<!-- body:start -->
`app-bootstrap``LogMaskingPatterns`는 방어 심층화로 다음과 같은 secret 형태를 mask한다.
- password/secret/token/api-key 계열 key=value
- Authorization credentials
- standalone Bearer token
## LogMaskingPatterns 참조 위치
:::evidence key="adapter-outbound-support-c03" alt="코드베이스에서 LogMaskingPatterns 를 검색한 출력 29줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="LogMaskingPatterns 코드베이스 검색 — 29줄 · exit 0" zoom="true"
:::
## 마스킹 규칙이 덮지 않는 것
arbitrary email address나 free-form body PII를 일반적으로 제거하는 규칙은 없다. app-bootstrap README 자체도 regex masking을 **보증이 아니라 defence-in-depth**라고 설명한다. 따라서 현재 "logger signature 때문에 PII가 들어올 수 없다"는 1차 방어선 설명은 사실과 맞지 않는다.
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: adapter-outbound-support-c05
title: outbound 어댑터끼리는 support를 통해서만 공유한다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-support-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-support-c05
file: ../../../final/evidence/rendered/adapter-outbound-support-c05.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-support-c05.txt
source:
- 원본 분석 절은 analysis/04-adapter-outbound-support.md#L368 이다.
module: adapter-outbound-support
---
# outbound 어댑터끼리는 support를 통해서만 공유한다
`CleanArchitectureTest.OUTBOUND_ADAPTERS_ARE_PEERS_SHARING_ONLY_SUPPORT`가 outbound adapter family를 slice로 나누고 서로 직접 의존하지 못하게 한다. 유일한 shared-code 예외는 target package가 `..adapter.outbound.support..`인 dependency다.
## 본문
<!-- body:start -->
`CleanArchitectureTest.OUTBOUND_ADAPTERS_ARE_PEERS_SHARING_ONLY_SUPPORT`는 outbound adapter family를 slice로 나누고 서로 직접 의존하지 못하게 한다. 유일한 shared-code 예외는 target package가 이것인 dependency다.
```text
..adapter.outbound.support..
```
## 규칙이 허용하는 것과 금지하는 것
따라서 messaging → notification 같은 peer coupling은 금지하지만 messaging → support는 허용한다. fresh `CleanArchitectureTest --rerun-tasks`도 통과했다.
## 분석 원문의 규칙 서술
:::evidence key="adapter-outbound-support-c05" alt="분석 문서 analysis/04-adapter-outbound-support.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/04-adapter-outbound-support.md 발췌 — 15줄" zoom="true"
:::
## 이 규칙이 고정하는 것
support 모듈이 단순 편의 library가 아니라 **outbound family에서 sanctioned shared dependency point**라는 점을 build-time fitness function으로 고정한다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: app-bootstrap-c05
title: 레지스트리 계약 테스트가 실제 파일을 읽어 대조한다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:app-bootstrap-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: app-bootstrap-c05
file: ../../../final/evidence/rendered/app-bootstrap-c05.svg
evidence:
- ../../../final/evidence/raw/app-bootstrap-c05.txt
source:
- 원본 분석 절은 analysis/18-app-bootstrap.md#L460 이다.
module: app-bootstrap
---
# 레지스트리 계약 테스트가 실제 파일을 읽어 대조한다
`MasterSwitchRegistryContractTest``docs/registries/env-keys.yaml``src/app-bootstrap/src/main/resources/application.yml`을 실제로 읽어 대조한다. 파일 기반 SSOT가 테스트로 고정돼 있다.
## 본문
<!-- body:start -->
`MasterSwitchRegistryContractTest``docs/registries/env-keys.yaml``src/app-bootstrap/src/main/resources/application.yml`을 실제로 읽어 대조한다(§2). `ErrorCodeRegistryMappingTest`·`SecretsClassificationRegistryTest``docs/registries/` 아래 파일을 읽는다.
## MasterSwitchRegistryContractTest 참조 위치
:::evidence key="app-bootstrap-c05" alt="코드베이스에서 MasterSwitchRegistryContractTest 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MasterSwitchRegistryContractTest 코드베이스 검색 — 3줄 · exit 0" zoom="true"
:::
## 파일 기반 SSOT가 테스트로 고정돼 있다
레지스트리 파일이 문서로만 존재하지 않고, 그 내용과 실제 설정 파일이 어긋나면 테스트가 먼저 깨진다.
<!-- body:end -->
@@ -0,0 +1,58 @@
---
kind: CONCEPT
slug: application-core-c02
title: 권한 판정과 객체 접근 판정을 분리한다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:application-core-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: application-core-c02
file: ../../../final/evidence/rendered/application-core-c02.svg
- key: application-core-c02-diagram
file: ../../../final/assets/diagrams/application-core-c02.svg
evidence:
- ../../../final/evidence/raw/application-core-c02.txt
source:
- 원본 분석 절은 analysis/03-application-core.md#L78 이다.
module: application-core
---
# 권한 판정과 객체 접근 판정을 분리한다
`AuthorizationPort`는 "이 종류의 작업을 수행할 수 있는가"를 판정하고, object-level access는 별도 `ObjectAccessPolicy`가 담당한다. 같은 permission을 가진 사용자라도 ownership, membership, workflow state에 따라 결과가 달라질 수 있기 때문이다.
## 관계
- **legacy storage/notification compatibility surface의 제거 조건 추적**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
`AuthorizationPort`는 principal의 raw role/permission을 기준으로 "이 종류의 작업을 수행할 수 있는가"를 판정하는 framework-free PEP다. `AuthorizationPrincipal`은 role set을 defensive copy + unmodifiable로 만들고 null roles는 empty set으로 정규화한다. `AuthorizationDeniedException`은 Spring `AccessDeniedException` 대신 application-owned failure를 사용한다.
## 두 판정이 갈리는 자리
:::evidence key="application-core-c02-diagram" alt="요청에서 권한 포트와 객체 접근 정책으로 각각 화살표가 나가고 화살표에 작업 종류와 개별 객체가 붙은 구조" caption="두 판정이 갈리는 자리" zoom="false"
:::
object-level access는 별도 `ObjectAccessPolicy`가 담당한다. 같은 permission을 가진 사용자라도 ownership, membership, workflow state에 따라 특정 object 접근 결과가 달라질 수 있기 때문이다. `ObjectAccessDecision`은 denial에 stable code를 요구하고 `hideExistence`를 별도 boolean으로 보존해 transport가 403/404 disclosure 정책을 추측하지 않게 한다.
## AuthorizationPort 참조 위치
:::evidence key="application-core-c02" alt="코드베이스에서 AuthorizationPort 를 검색한 출력 28줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="AuthorizationPort 코드베이스 검색 — 28줄 · exit 0" zoom="true"
:::
## 이 계약이 adapter에서 core로 옮겨 온 이력
`ObjectAccessPolicyTest`에는 이 계약이 과거 inbound GraphQL adapter에 있었고 GraphQL request context를 signature에 포함해 application-core가 구현하려면 transport에 역의존해야 했던 문제가 기록돼 있다. 현재 regression test는 policy/request/decision signature에 `dev.caskeleton.adapter.*` 타입이 다시 등장하면 실패한다. 이 프로젝트에서 "여러 호출자가 공유해야 하는 계약을 inbound adapter가 소유하면 Core가 Adapter에 의존하게 된다"는 문제가 실제로 있었던 근거다.
## 계약에 명시된 한계
`decideAll()`의 default는 요청 순서를 보존하지만 object마다 `decide()`를 호출한다. set-based authorization을 제공하는 구현체가 override하지 않으면 batched loading 안에서 authorization N+1을 다시 만들 수 있다는 제한도 계약에 명시돼 있다.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: grpc-advanced-bootstrap-c03
title: 승격 게이트는 능력마다 따로 기록된 증거를 본다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-advanced-bootstrap-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-bootstrap-c03
file: ../../../final/evidence/rendered/grpc-advanced-bootstrap-c03.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-bootstrap-c03.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-bootstrap.md#L89 이다.
module: grpc-advanced-bootstrap
---
# 승격 게이트는 능력마다 따로 기록된 증거를 본다
증거를 능력마다 따로 기록하는 이유가 적혀 있다 — 공유 기록은 하나를 승격할 때 같은 시점에 측정된 다른 것들까지 함께 승격시킨다.
## 본문
<!-- body:start -->
증거는 능력마다 따로 기록된다.
> "a shared record makes promoting one of them promote whichever others happened to be measured at the same time."
일곱 항목(호환성·보안 검토·고장·성능·ADR·런북·실환경 테스트)과 담금 기간을 본다.
## 담금 기간이 둘인 이유
```text
ADVANCED_STABLE_SOAK = 7일
STABLE_DEFAULT_SOAK = 30일
```
> "becoming a Stable default means every deployment gets it, which additionally puts its dependencies on every classpath and its failure modes in every on-call rotation."
## GrpcAdvancedSupportMatrix 참조 위치
:::evidence key="grpc-advanced-bootstrap-c03" alt="코드베이스에서 GrpcAdvancedSupportMatrix 를 검색한 출력 6줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcAdvancedSupportMatrix 코드베이스 검색 — 6줄 · exit 0" zoom="true"
:::
## 시작 등급을 다시 확인하는 이유
그리고 `WATCH``EXPERIMENTAL` 을 먼저 거쳐야 한다. `GrpcAdvancedSupportMatrix.apply` 는 결정의 시작 등급이 현재 등급과 다르면 거부한다 — 두 승격이 경합했거나 하나가 재생된 경우다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: grpc-advanced-diagnostics-c01
title: Channelz는 유용해서 위험한 표면이다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-advanced-diagnostics-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-diagnostics-c01
file: ../../../final/evidence/rendered/grpc-advanced-diagnostics-c01.svg
- key: grpc-advanced-diagnostics-c01-diagram
file: ../../../final/assets/diagrams/grpc-advanced-diagnostics-c01.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-diagnostics-c01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-diagnostics.md#L41 이다.
module: grpc-advanced-diagnostics
---
# Channelz는 유용해서 위험한 표면이다
이 리프는 진단 표면(Channelz·CSDS)과 그것을 게시 가능하게 만드는 편집기, 그리고 고급 능력의 검증 대상을 이름 짓는 테스트킷 계약을 담는다. 편집기가 필요한 이유가 편집기 자신의 javadoc에 적혀 있다.
## 본문
<!-- body:start -->
진단 표면(Channelz·CSDS)과 그것을 게시 가능하게 만드는 편집기, 그리고 고급 능력이 무엇을 상대로 검증되어야 하는지를 이름 짓는 테스트킷 계약을 담는다.
## 이 리프가 담는 것
:::evidence key="grpc-advanced-diagnostics-c01-diagram" alt="리프 경계 안에 진단 표면과 편집기와 테스트킷 계약 세 상자가 나란히 들어 있는 구조" caption="이 리프가 담는 것" zoom="false"
:::
## 편집기가 필요한 이유
편집기 javadoc 이 왜 이것이 필요한지 적는다.
> "Channelz is unusually dangerous to expose because it is genuinely useful: it holds every socket's local and remote address, the security details of each connection, and per-call state… once the endpoint exists the whole of it is one authorization mistake away from being readable."
## 이 기록이 다루는 파일 범위
:::evidence key="grpc-advanced-diagnostics-c01" alt="코드베이스에서 파일 목록을 만든 출력 4줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 4줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,54 @@
---
kind: CONCEPT
slug: grpc-observability-c01
title: 논리 RPC와 물리 시도와 스트림 수명주기를 구별한다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-observability-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-observability-c01
file: ../../../final/evidence/rendered/grpc-observability-c01.svg
- key: grpc-observability-c01-diagram
file: ../../../final/assets/diagrams/grpc-observability-c01.svg
evidence:
- ../../../final/evidence/raw/grpc-observability-c01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-observability.md#L46 이다.
module: grpc-observability
---
# 논리 RPC와 물리 시도와 스트림 수명주기를 구별한다
관측을 세 층위로 나눈다. Micrometer를 `api`로 노출하는 이유도 build.gradle에 적혀 있고 코드와 일치한다.
## 본문
<!-- body:start -->
build.gradle 주석이 이 리프의 범위를 적는다.
```groovy
// build.gradle:3-5
// Bounded observability: logical RPC vs physical attempt vs stream lifecycle, with a cardinality
// policy that refuses payload, raw metadata and any actor/tenant/object/stream/idempotency
// identifier as a tag.
```
## 구별하는 세 층위
:::evidence key="grpc-observability-c01-diagram" alt="리프 경계 안에 논리 RPC 와 물리 시도와 스트림 수명주기 세 상자가 나란히 들어 있는 구조" caption="구별하는 세 층위" zoom="false"
:::
## Micrometer를 api로 노출하는 이유
build.gradle 에 적혀 있다 — "the observation convention's public signatures name Micrometer types, so wiring it requires naming them." 실제로 `GrpcObservationConvention` 의 생성자와 `boundedTags` 반환형이 Micrometer 타입(`MeterRegistry`, `Tags`)이므로 그 서술은 코드와 일치한다.
## GrpcObservationConvention 참조 위치
:::evidence key="grpc-observability-c01" alt="코드베이스에서 GrpcObservationConvention 를 검색한 출력 12줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcObservationConvention 코드베이스 검색 — 12줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,55 @@
---
kind: CONCEPT
slug: grpc-spring-boot-starter-c01
title: Advanced 의존 금지를 세 겹으로 강제한다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-spring-boot-starter-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-spring-boot-starter-c01
file: ../../../final/evidence/rendered/grpc-spring-boot-starter-c01.svg
- key: grpc-spring-boot-starter-c01-diagram
file: ../../../final/assets/diagrams/grpc-spring-boot-starter-c01.svg
evidence:
- ../../../final/evidence/raw/grpc-spring-boot-starter-c01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-spring-boot-starter.md#L42 이다.
module: grpc-spring-boot-starter
---
# Advanced 의존 금지를 세 겹으로 강제한다
이 리프가 `:grpc-advanced:*`에 닿으면 안 된다는 규칙이 주석이 아니라 세 지점에서 각각 강제된다 — 레지스트리, 빌드 검증 태스크, 자바 쪽 단언.
## 본문
<!-- body:start -->
build.gradle 주석이 이 리프의 경계를 적는다.
```groovy
// build.gradle:3-7
// The platform's composition boundary: typed properties, auto-configuration and the startup
// validator that refuses a deployment whose configuration contradicts a Stable invariant.
//
// It must never reach `:grpc-advanced:*`. That is not a comment — the registry's
// allowed_dependencies for this leaf omits every advanced id, `verifyCleanArchitectureDependencies`
// enforces it, and GrpcPlatformStartupValidatorTest asserts the same rule from the Java side.
```
## 한 규칙을 지키는 세 지점
:::evidence key="grpc-spring-boot-starter-c01-diagram" alt="Advanced 의존 금지 규칙에서 레지스트리와 빌드 검증 태스크와 자바 단언 세 상자로 화살표가 나가는 구조" caption="한 규칙을 지키는 세 지점" zoom="false"
:::
격리 규칙은 세 겹이다 — 레지스트리, 빌드 검증 태스크, 그리고 자바 쪽 단언. 세 번째는 `validateAdvancedIsolation``GrpcStableBuildInvariant.requireNoAdvancedDependency` 를 부르는 형태다.
## GrpcStableBuildInvariant 참조 위치
:::evidence key="grpc-spring-boot-starter-c01" alt="코드베이스에서 GrpcStableBuildInvariant 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcStableBuildInvariant 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: messaging-admin-api-c02
title: 부팅된 애플리케이션에서 살아나는 타입은 둘뿐이다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-admin-api-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-api-c02
file: ../../../final/evidence/rendered/messaging-admin-api-c02.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-api-c02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-api.md#L98 이다.
module: messaging-admin-api
---
# 부팅된 애플리케이션에서 살아나는 타입은 둘뿐이다
leaf 밖 소비자는 21개 파일 4개 모듈이지만, 부팅된 애플리케이션에서 실제로 살아나는 것은 둘뿐이다. `MessagingAdminService` 빈도 `ApprovalVerifier` 빈도 없다.
## 본문
<!-- body:start -->
`messaging-core-api` 에서 쓰는 것: `DestinationName`, `MessageAuthorizationException`, `MessagingConfigurationException`. `messaging-policy``api` 로 선언돼 있지만 import 0건이다(§12.4).
## leaf 밖 소비자 21개 파일
| 모듈 | src/main | src/test | 역할 |
|---|---:|---:|---|
| `messaging-admin-runtime` | 10 | 6 | 실제 실행·검증 서비스 |
| `messaging-spring-boot-starter` | 2 | 0 | 빈 배선 + 저널 내구성 검사 |
| `messaging-outbox-jdbc-postgresql` | 1 | 1 | `JdbcAdminOperationJournal` |
| `messaging-kafka` | 0 | 1 | `KafkaTopologyValidationIT` |
## DestinationName 참조 위치
:::evidence key="messaging-admin-api-c02" alt="코드베이스에서 DestinationName 를 검색한 출력 16줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DestinationName 코드베이스 검색 — 16줄 · exit 0" zoom="true"
:::
## 빈이 없는 것이 설계인 범위
부팅된 애플리케이션에서 이 리프의 타입 중 실제로 살아나는 것은 **둘뿐**이다(`EVD-302`, `EVD-303`). `MessagingAdminService` 빈은 없고 `ApprovalVerifier` 빈도 없다. 이는 명시된 설계다. 그러나 이 스탠스가 **토폴로지 검증까지 덮지는 않는다** — §12.1 과 §17 의 첫 항목이 그것이다.
<!-- body:end -->
@@ -0,0 +1,59 @@
---
kind: CONCEPT
slug: messaging-admin-api-c05
title: 인가 실패와 프로그래밍 오류를 예외 타입으로 가른다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-admin-api-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-api-c05
file: ../../../final/evidence/rendered/messaging-admin-api-c05.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-api-c05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-api.md#L543 이다.
module: messaging-admin-api
---
# 인가 실패와 프로그래밍 오류를 예외 타입으로 가른다
거절은 전부 `MessageAuthorizationException` 또는 `MessagingConfigurationException`이고 안정 코드가 붙는다. `IllegalArgumentException`은 구조적으로 불가능한 값에만 쓴다.
## 본문
<!-- body:start -->
거절은 전부 `MessageAuthorizationException` 또는 `MessagingConfigurationException` 이고, 코드가 붙어 있다. 메시지가 전부 "무엇이 왜 거절되었는가" 를 서술형으로 쓴다.
| 코드 | 던지는 곳 | 의미 |
|---|---|---|
| `APPROVAL_PLAN_MISMATCH` | `HmacApprovalVerifier:74`, `ApprovedReplayPlan:26`, `ApprovedRedrivePlan:28` | 진짜 승인, 다른 계획 |
| `APPROVAL_EXPIRED` | `HmacApprovalVerifier:81`, `DestructiveOperationGuard:69` | 윈도우 밖 |
| `APPROVAL_SIGNATURE_INVALID` | `HmacApprovalVerifier:89, :93` | 16진 아님 / HMAC 불일치 |
| `ADMIN_CREDENTIAL_REQUIRED` | `DestructiveOperationGuard:58` | 애플리케이션 런타임 |
| `APPROVAL_REQUIRED` | `DestructiveOperationGuard:65` | 승인 없음 |
| `APPROVAL_OPERATION_MISMATCH` | `DestructiveOperationGuard:75` | 다른 작업의 승인 |
| `APPROVAL_SOURCE_MISMATCH` | `DestructiveOperationGuard:85` | 다른 목적지의 승인 |
| `APPROVAL_IMPACT_EXCEEDED` | `Approved*Plan:47/52` | 승인 상한 초과 |
| `TOPOLOGY_CHANGED_SINCE_APPROVAL` | `Approved*Plan:70/74, :79/82` | 추정치 무효 |
| `REDRIVE_LOOP_NOT_ACKNOWLEDGED` | `ApprovedRedrivePlan:88` | 루프 위험 미승인 |
| `AUTO_CREATE_IN_PRODUCTION` | `TopologyManagementMode:30` | 프로덕션 자동 생성 |
| `TOPOLOGY_MISMATCH` | `TopologyValidationReport:78` | BLOCKING 존재 |
## MessageAuthorizationException 참조 위치
:::evidence key="messaging-admin-api-c05" alt="코드베이스에서 MessageAuthorizationException 를 검색한 출력 38줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessageAuthorizationException 코드베이스 검색 — 38줄 · exit 0" zoom="true"
:::
## IllegalArgumentException을 쓰는 자리
`IllegalArgumentException` 은 **구조적으로 불가능한 값**에만 쓴다 — 음수 카운터, 빈 문자열, 역전된 윈도우, 4-eyes 위반. 인가 실패와 프로그래밍 오류가 예외 타입으로 갈린다.
## 로그 위생이 예외마다 같지 않다
`toString()` 하나가 로그 위생을 명시적으로 다룬다. `ApprovalGrant` 는 record 라 기본 `toString()` 이 전 필드를 찍는다는 점은 대비된다 — 다만 `ApprovalGrant` 자체가 로그에 닿는 경로는 확인되지 않았다.
<!-- body:end -->
@@ -0,0 +1,42 @@
---
kind: CONCEPT
slug: messaging-rabbit-c02
title: 자격증명은 연결 시도마다 해석된다
topic: security-and-trust-boundaries
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-rabbit-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-rabbit-c02
file: ../../../final/evidence/rendered/messaging-rabbit-c02.svg
evidence:
- ../../../final/evidence/raw/messaging-rabbit-c02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-rabbit.md#L106 이다.
module: messaging-rabbit
---
# 자격증명은 연결 시도마다 해석된다
RabbitMQ 클라이언트 연결은 오래 살고 스스로 재연결하므로, 시작 시점의 자격증명을 들고 있는 팩토리는 폐기된 자격증명으로 계속 재연결한다. 재연결 시점이 곧 회전된 자격증명이 적용되어야 할 시점이다.
## 본문
<!-- body:start -->
자격증명을 연결 시도마다 다시 해석하는 이유가 적혀 있다.
> "RabbitMQ client connections are long-lived and reconnect on their own, so a factory holding a credential from startup will happily reconnect with a revoked one for as long as the process runs — the reconnect is exactly the moment a rotated credential should take effect."
## AmqpCredentials가 record가 아닌 이유
비밀을 지우려면 가변이어야 하고, record 가 `char[]` 를 동등성에 쓰면 같은 자재를 가진 둘이 서로 다르다고 판정된다.
## AmqpCredentials 참조 위치
:::evidence key="messaging-rabbit-c02" alt="코드베이스에서 AmqpCredentials 를 검색한 출력 9줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="AmqpCredentials 코드베이스 검색 — 9줄 · exit 0" zoom="true"
:::
<!-- body:end -->