init: llm-wiki-haness 하네스 설계

This commit is contained in:
DongHyeonka
2026-07-24 14:21:35 +09:00
parent 42bf3db4fd
commit 6c53ded9cb
2436 changed files with 194486 additions and 1 deletions
@@ -0,0 +1,105 @@
# Keycloak Branch-note Adversarial Review
대상은 lane 4개에서 추출한 60개 finding 전부다. 각 행은 원 주장을 실무성, 과장, 전제의 세 관점에서 반증하려고 시도한 결과다.
## Summary
| Metric | Count |
|---|---:|
| KEEP | 21 |
| DOWNGRADE | 27 |
| REJECT | 12 |
| Final High | 10 |
| Final Medium | 18 |
| Final Low | 20 |
| Removed | 12 |
## Per-finding Falsification
| Finding ID | Original Claim | Strongest Counterargument | Evidence Needed To Falsify | Falsification Result | Verdict | Final Severity |
|---|---|---|---|---|---|---|
| L1-F01 | ROLE_* 입력과 raw-role registry 계약이 다르다. | ROLE_*는 Spring Security 경계에서 생성되는 GrantedAuthority이고 raw role은 그 이전 AuthenticatedUser 저장 형식일 수 있다. registry 호출 직전에 명시적 변환이 있다면 서로 다른 처리 단계를 말하므로 충돌하지 않는다. | AuthorizationAdapter 호출 직전 값, registry key, binding test에서 admin과 ROLE_ADMIN 중 실제 lookup key를 확인한다. | 중간 변환 증거가 없고 명세는 registry가 raw role을 소비한다고 적는다. | KEEP | Medium |
| L1-F02 | unlink guard의 consumer 상태가 owner보다 오래됐다. | owner 근거가 Keycloak main 소스라면 실제 배포 release tag의 guard와 다를 수 있다. consumer의 needs-confirmation은 stale 상태가 아니라 배포 이미지 기준 확인을 남긴 보수적 gate일 수 있으므로 영향은 라벨 정합에 한정될 수 있다. | 사용 image tag의 LinkedAccountsResource와 마지막 identity unlink 응답을 main과 비교한다. | 배포 tag 동일성이 확인되지 않아 영향 강도를 낮춘다. | DOWNGRADE | Low |
| L1-F03 | 위임한 email_verified 정책이 active owned decision으로 남았다. | 같은 Decisions 섹션 뒤에 재프레이밍과 owner 위임이 있어 앞 문장은 역사 보존으로 읽을 수 있다. 최신 DEM과 구현 가이드만 실행 기준으로 쓰는 저장소 관행이 확실하다면 실제 오귀속 가능성은 제한된다. | 신규 구현자가 선택하는 owner와 checker가 오래된 bullet을 active D-row로 파싱하는지 확인한다. | 위임은 유효하지만 오래된 active-looking 문구의 영향은 낮다. | DOWNGRADE | Low |
| L1-F04 | password 확인 고정 흐름과 SMTP email verification 기본이 갈린다. | SPA UX는 security-first profile을 설명하고 owner는 그 profile에서 email authenticator를 끄고 password 재인증을 고를 수 있게 한다. 제품 기본값과 프로젝트 강화 profile은 동시에 성립할 수 있어 무조건적인 기술 모순은 아니다. | realm export의 First Broker Login execution과 SMTP 설정, password 화면 E2E를 확인한다. | 조건 없는 문구 drift만 남고 profile이 고정되면 양립한다. | DOWNGRADE | Medium |
| L1-F05 | IMPORT의 takeover 근거가 최신 DEM과 반대다. | 오래된 사용자 결정 뒤 correction memo와 D5가 잘못된 인과를 이미 정정한다. DEM을 정본으로 소비하면 잘못된 IMPORT 선택은 실행되지 않으며 문제는 active 정책보다 역사 문장의 상태 표시다. | 실제 realm Sync Mode, ADR 선택 이유, correction을 보지 않은 구현 사례를 확인한다. | 정정이 이미 있어 실무 영향은 낮다. | DOWNGRADE | Low |
| L1-F06 | persistent sub key와 collision locator가 합쳐졌다. | D1과 구현 가이드는 IdP alias와 sub를 저장 key로 두면서 OOTB collision discovery는 email 또는 username임을 별도 위험으로 기록한다. 이미 저장 key와 최초 후보 탐색을 나눴으므로 finding이 전제한 단일 key 강제가 없다. | E2E에서 email은 후보 탐색에만 쓰고 federated identity에는 sub가 저장되는지 확인한다. | 원문이 두 단계를 이미 분리한다. | REJECT | N/A |
| L1-F07 | OAuth 2.1의 조건부 BFF 권고를 일반 권고처럼 표시한다. | 같은 문서 D5와 인접 설명에 client credentials 조건이 있어 전체를 읽으면 선택 오류가 줄어든다. 조직 정책으로 BFF를 우선할 수도 있으므로 matrix 한 셀만으로 실제 architecture 오선택까지 단정하기는 어렵다. | matrix만 사용한 ADR 결과와 전체 문서를 사용한 결정 결과를 비교한다. | wording drift는 확인되나 failure 인과가 약하다. | DOWNGRADE | Low |
| L1-F08 | bridge network의 localhost issuer와 extra_hosts 기본은 함께 동작하지 않는다. | rendered Compose가 host network, localhost host-gateway alias, 또는 별도 jwk-set-uri를 사용하면 정상일 수 있다. 그러나 현재 문서는 host.docker.internal만 추가하면서 discovery URL을 localhost로 유지해 그 반론을 뒷받침하지 않는다. | docker compose config, 컨테이너 DNS, discovery URL, JWKS fetch log를 함께 확인한다. | 현재 선택 기본을 구제하는 구성이 문서에 없다. | KEEP | High |
| L1-F09 | parent와 child가 Google sub federation key를 함께 소유한다. | parent가 pattern requirement를, child가 Keycloak mechanism을 소유하는 분할은 가능하다. 하지만 parent가 D1~D5를 설정 SSOT라고 선언하고 양쪽이 같은 key와 email 금지를 결정하므로 concern 층위 분리가 현재 텍스트에는 없다. | requirement와 mechanism concern ID, inbound reference, 변경 승인 주체를 확인한다. | 같은 mutable key 정책에 두 SSOT가 있다. | KEEP | High |
| L1-F10 | P1B가 P1A와 동일하다면서 backend trust를 바꾼다. | P1B가 defense-in-depth JWT validation을 추가한 별도 variant라면 달라도 된다. 그러나 비교 표가 backend 검증 행을 동일이라고 직접 표시해 별도 variant 해석이 현재 텍스트와 양립하지 않는다. | 두 pattern의 dependency, SecurityFilterChain, token과 header forwarding을 diff한다. | 동일 표시와 header-only owner 계약이 직접 충돌한다. | KEEP | High |
| L1-F11 | oauth2-proxy와 Traefik ForwardAuth를 대안으로 쓰지만 함께 사용한다. | Traefik ForwardAuth가 middleware와 외부 auth service 묶음을 줄인 표현일 수 있고 숙련자는 forwardAuth.address가 oauth2-proxy를 호출함을 알아낼 수 있다. 따라서 architecture 모순보다 선택축 이름이 부정확한 문제에 가깝다. | 각 option의 service와 forwardAuth target을 적은 배포표로 신규 구현자의 선택을 관찰한다. | 상호 배타적 표현은 고쳐야 하나 심각도는 낮춘다. | DOWNGRADE | Medium |
| L1-F12 | 브라우저 token 부재와 cookie token 저장이 같은 threat model에 섞였다. | 노출 없음은 브라우저가 bytes를 보유하지 않는다는 뜻보다 JavaScript가 raw token을 읽지 못한다는 축약일 수 있다. cookie session과 server-side store를 조건부 대안으로 설명했다면 두 문장이 같은 배치를 뜻하지도 않는다. | session-store-type, cookie payload, Redis 사용 여부로 실제 custody를 구분한다. | 물리 보유와 JS 접근성의 용어 drift만 확정된다. | DOWNGRADE | Low |
| L1-F13 | zero-change 범위가 hosted login보다 넓게 읽힌다. | 문서는 Account Linking을 명시적으로 out of scope로 두고 기존 SPA login path 유지로 목표를 제한한다. 제외된 별도 기능까지 제목이 포함한다고 해석하면 이미 제공된 scope boundary를 무시하게 된다. | parent acceptance와 branch TODO에 CIAL 또는 linked-account API가 배정됐는지 확인한다. | explicit out-of-scope가 원 주장을 반증한다. | REJECT | N/A |
| L1-F14 | zero-change aud owner와 값이 모호하다. | 더 직접적인 L4-F08이 같은 audience mapper와 validator root cause 및 remediation을 포괄한다. 독립 유지하면 동일 결함을 두 priority로 계산하므로 위험 수를 부풀리게 된다. | realm client ID, mapper target, backend expected value를 한 matrix에서 비교한다. | L4-F08과 독립 조치가 없는 중복이다. | REJECT | N/A |
| L2-F01 | 미아카이브 CVE 때문에 Confirm Link에 version gate가 필요하다. | draft가 해당 CVE의 raw 출처와 영향 버전을 UNVERIFIED로 분류한다. 존재와 영향 범위가 확인되지 않은 외부 메모만으로 source-backed owner의 보장을 충돌로 판정하면 severity가 검증되지 않은 주장 하나에 의존한다. | 공식 advisory, CVE record, 영향 및 패치 버전, 배포 image tag와 재현 flow를 확인한다. | 핵심 반증 근거가 trace되지 않아 독립 finding으로 유지하지 않는다. | REJECT | N/A |
| L2-F02 | email_verified=false가 silent-link 차단과 hard-reject 사이에서 갈린다. | 다른 repository나 deferred branch에 custom SPI가 이미 있다면 hard-reject consumer 문장이 맞을 수 있다. 그러나 corpus는 SPI를 out of scope로 두고 consumer는 구현 근거 없이 링크와 생성 거부를 acceptance처럼 사용한다. | authentication flow export, provider JAR, false-email E2E 응답으로 custom reject 존재를 증명한다. | corpus 안에 hard-reject owner와 artifact가 없다. | KEEP | High |
| L2-F03 | IdP IMPORT와 role mapper FORCE의 적용 범위가 불명확하다. | IdP default IMPORT 위에 특정 mapper의 Sync Mode Override FORCE를 두는 계층 설정은 의도적으로 공존할 수 있다. attribute owner도 role freshness가 필요한 sibling의 FORCE를 설명해 실제 contradiction보다 범위 표기가 부족한 문제일 가능성이 높다. | export에서 IdP syncMode와 mapper syncModeOverride, 로그인 후 profile과 role 갱신을 확인한다. | 공존 가능한 설정이므로 심각도를 낮춘다. | DOWNGRADE | Low |
| L2-F04 | basic-scope 예외를 오래된 test-user 설명이 덮는다. | 실제 authorize request에 sensitive scope가 더 있거나 Console audience 조건이 다르면 옛 TODO가 별도 scenario로 맞을 수 있다. 그러나 현재 D5는 openid profile email만 고정해 그 조건이 문서에 없다. | 실제 scope와 Console audience, 비등록 계정 동의 및 만료 동작을 확인한다. | 현재 선택 scope에서는 authoritative D5와 어긋난다. | KEEP | Low |
| L2-F05 | JavaScript origins를 owner는 비우고 consumer는 등록한다. | 향후 Google browser SDK를 쓴다면 origin이 필요할 수 있고 불필요한 origin 하나가 즉시 인증 실패를 만들지는 않는다. 현재 server-side brokering에 한정하면 stale이지만 영향은 낮다. | browser가 Google SDK를 직접 호출하는지 network trace와 두 client E2E로 확인한다. | 현재 topology에서 불필요하지만 낮은 위험이다. | DOWNGRADE | Low |
| L2-F06 | Caddy default HSTS 설명이 명시적 구성과 상충한다. | Cloudflare나 상위 load balancer가 HSTS를 넣으면 최종 응답에는 header가 있을 수 있다. 그래도 제품 default와 외부 주입은 다른 사실이므로 Caddy default라는 설명을 구제하지 못한다. | 최소 Caddyfile와 production edge 응답을 각각 캡처해 header 생성 주체를 확인한다. | default 설명은 현재 authoritative 구성과 어긋난다. | KEEP | Medium |
| L2-F07 | Reference-Only를 선언하고 foreign detail을 유지한다. | composition hub의 end-to-end 상세는 유용하고 owner 우선 주석이 사람에게 정본을 알려줄 수 있다. 하지만 저장소 계약은 자동 생성 view가 아닌 수동 상세 복제를 금지하므로 규칙 변경이나 동기화 장치 없이는 반론이 성립하지 않는다. | owner 변경 시 consumer가 자동 갱신되거나 CI가 drift를 검출하는지 확인한다. | 현 상태는 수동 상세 복제다. | KEEP | Medium |
| L2-F08 | P2A와 P2B가 brokering zero-change를 함께 소유한다. | baseline은 확장 가능성, P2B는 실제 acceptance를 소유한다고 나눌 수 있다. 그러나 두 D-row가 같은 code-zero invariant를 담고 consumer가 이중 주장으로 표기해 change authority 경계가 없다. | 두 D-row의 concern ID, approval owner, zero-change branch inbound map을 확인한다. | 동일 mutable invariant가 여러 D-row에 있다. | KEEP | Medium |
| L2-F09 | SPA Direct 안의 HttpOnly cookie는 사실상 BFF다. | consumer가 이미 BFF 변형이라고 표시해 pure SPA 기본을 몰래 바꾼 결정이 아닐 수 있다. 더 직접적인 owner finding L4-F04가 architecture tension과 조치를 포괄한다. | P2A 기본 storage와 refresh endpoint 유무를 L4-F04 owner와 비교한다. | explicit variant 표기와 중복 때문에 제거한다. | REJECT | N/A |
| L3-F01 | auth_request body 설명과 proxy_pass_request_body default가 충돌한다. | 첫 문장은 auth_request core subrequest, 표는 proxy handler가 upstream으로 body를 전달하는 별도 단계를 설명할 수 있다. 층위를 분리하면 양립하며 실행 config는 이미 off를 선택한다. | off 제거 전후 auth upstream의 Content-Length와 body bytes, nginx -T를 비교한다. | 설명 층위는 섞였지만 실행 실패는 현재 방지된다. | DOWNGRADE | Medium |
| L3-F02 | sign-in TODO가 browser와 API 모두 302로 만들 수 있다. | TODO는 named location을 작성하라는 작업일 뿐 어느 route에 적용하라는 지시가 아니다. 뒤 D9가 browser만 연결하고 API는 401로 두므로 전 route 적용 가정은 텍스트에서 나오지 않는다. | rendered config의 error_page가 붙은 location 목록과 route별 E2E 응답을 확인한다. | named location 생성과 적용 범위를 혼동했다. | REJECT | N/A |
| L3-F03 | local JWT 동작을 token introspection이라고 부른다. | 팀 내부 shorthand로만 쓰면 운영 영향이 작을 수 있다. 그러나 RFC 7662 network call 여부는 latency, revocation, firewall 설계를 바꾸므로 protocol 용어를 그대로 두면 실제 오해가 생긴다. | oauth2-proxy debug log와 outbound capture에서 introspection endpoint 호출을 확인한다. | rename 비용이 작고 오해 위험이 남는다. | KEEP | Low |
| L3-F04 | 세 decision reference가 parser 형식이 아니다. | 사람은 bare slug와 D-id를 이해하고 owner 변경이 드물면 drift가 없을 수 있다. 하지만 저장소가 deterministic impact analysis를 목표로 하고 checker가 wikilink 형식을 요구해 사람 가독성만으로 계약을 충족하지 못한다. | 수정 전후 checker packet과 inbound reference에 세 edge가 들어오는지 비교한다. | deterministic reference contract를 충족하지 않는다. | KEEP | Low |
| L3-F05 | 폐기한 numbered naming이 active-looking D2로 남았다. | 폐기 경고가 앞에 있고 D2가 역사 bullet이라 checker가 active owner로 보지 않을 수 있다. 독자가 경고를 먼저 보면 신규 branch가 옛 규칙을 채택할 가능성도 낮다. | D-row parser 결과, inbound D2 reference, scaffold가 numbered slug를 만드는지 확인한다. | stale 표현은 맞지만 영향은 낮다. | DOWNGRADE | Low |
| L3-F06 | D1과 D5가 S256 enforcement를 함께 소유한다. | D5는 TODO step을 D1에 매핑한 trace row로 읽히고 같은 문서 안에서 값도 같다. 별도 owner 경쟁이나 상충 값이 없어 task mapping을 독립 mutable decision으로 본 것이 과하다. | inbound reference가 D5를 owner로 쓰는지와 변경 승인 주체를 확인한다. | 독립 ownership 증거가 없다. | REJECT | N/A |
| L3-F07 | 오래된 PKCE Admin label이 TODO에 남았다. | Keycloak version과 locale에 따라 옛 label이 남을 수 있고 최신 구현 가이드도 함께 있다. 하지만 pinned target에서 TODO는 실행 checklist이므로 실제 UI와 다르면 작업을 방해한다. | target image Admin Console과 realm export field명으로 실제 label을 고정한다. | version 확인 뒤 국소 수정할 유효 drift다. | KEEP | Low |
| L3-F08 | trycloudflare.com을 영구와 random으로 함께 설명한다. | 특정 client가 named hostname을 지속 제공한다면 첫 설명이 맞을 수 있다. 그러나 보존 source model은 random quick tunnel과 managed custom hostname을 나누며 parent도 정적 redirect domain을 요구한다. | tunnel create와 restart 전후 hostname, DNS route, Google redirect E2E를 기록한다. | 현재 source model에서는 두 유형이 양립하지 않는다. | KEEP | High |
| L3-F09 | realm export가 import wiring을 scope에 남겼다. | 자동 import는 system outcome이고 mount path와 startup flag의 변경 권한을 주장하지 않을 수 있다. 명시적 trace가 stack owner를 지목하므로 실제 ownership 충돌보다 scope 문구가 모호한 문제다. | 두 branch의 artifact 목록과 실제 PR 변경 파일로 wiring 수정 주체를 확인한다. | owner trace가 있어 severity를 낮춘다. | DOWNGRADE | Low |
| L3-F10 | credential export를 근거 없이 사실로 단정한다. | 기본 realm export와 별도 user export는 다른 mode라 password 없음과 특정 credentials 포함 가능이 동시에 맞을 수 있다. 문제는 상호 모순보다 pinned version의 primary evidence가 없다는 점이다. | target kc.sh export help, JSON credentials, reimport 로그인 결과를 보존한다. | unsupported certainty는 남지만 모순 서사는 약하다. | DOWNGRADE | Medium |
| L3-F11 | rotation concept와 execution note가 같은 sequence를 함께 소유한다. | 한쪽은 protocol contract이고 다른 쪽은 executable scenario라 기대값 반복은 자연스럽다. P3A D5가 시연으로 명명되고 양쪽이 미근거 family behavior를 표시해 역할 분리도 일부 존재한다. | inbound reference와 approval owner로 P3A가 concept D4를 consume만 하는지 확인한다. | owner 경쟁보다 invariant와 test split일 가능성이 높다. | DOWNGRADE | Medium |
| L3-F12 | back-channel receiver가 위임됐지만 destination은 optional이다. | optional demonstration에 receiver 구현과 token validation이 포함될 수 있어 한 줄만으로 owner 부재를 단정할 수 없다. 다만 planned artifact와 endpoint acceptance가 없어 구현자가 포함 범위를 알기 어렵다. | P3A planned files의 endpoint, validator, provider-trigger E2E를 확인한다. | owner gap 가능성은 있으나 전체 context가 부족하다. | DOWNGRADE | Medium |
| L3-F13 | Max Reuse >0을 무의미와 약한 대안으로 함께 쓴다. | 무의미는 strict stolen-token demonstration에 부적합하다는 축약일 수 있고 다른 note도 값이 커지면 탐지력이 약해져 해당 시연이 불가하다고 적는다. 범위를 좁히면 같은 trade-off다. | target에서 값 0과 1, 병렬 refresh, RT 재사용 결과를 측정한다. | incompatible policy보다 범위 없는 표현 문제다. | DOWNGRADE | Medium |
| L3-F14 | Caddy handle_path가 method B의 prefix를 제거한다. | 다른 rewrite가 prefix를 복구하거나 Keycloak이 root path로 실행되면 동작할 수 있다. 그러나 copyable draft와 D3에는 그런 layer가 없고 note 자체도 handle로 바꾸라고 정정한다. | caddy adapt와 upstream access log에서 discovery, auth, token path를 확인한다. | 현재 선택 조합은 직접 404를 만들 수 있다. | KEEP | High |
| L3-F15 | parent와 child가 Cloudflare 우선 선택을 함께 소유한다. | integration parent가 provider를 고르고 child가 운영 detail을 소유하는 분할은 가능하다. 그러나 양쪽 D-row가 provider 우선순위와 fallback 조건까지 결정해 selection과 detail의 경계가 없다. | owner map과 inbound D3 및 D1 reference, 변경 승인 주체를 확인한다. | 같은 mutable preference가 두 normative D-row에 있다. | KEEP | Medium |
| L3-F16 | parent exact proxy bundle과 child detail owner가 겹친다. | integration parent는 여러 child 결과를 조립한 deployable bundle을 소유할 수 있고 detail owner는 설명과 근거만 뜻할 수 있다. child의 변수별 D-row를 함께 보지 않으면 exact 이중 ownership은 입증되지 않는다. | child DEM의 다섯 변수 D-row와 parent D4의 change authority를 비교한다. | assembly와 detail 경계 context가 부족하다. | DOWNGRADE | Medium |
| L4-F01 | localhost와 extra_hosts 대안이 실행 구성으로 종결되지 않았다. | L1-F08이 실제 Compose 기본의 hostname 불일치를 더 직접적으로 포착하고 iss-mismatch note는 대안과 미승인 상태를 상세히 기록한다. 이 행은 같은 root cause를 덜 정확하게 반복한다. | parent D3, child D6, rendered Compose의 DNS와 discovery 역할을 비교한다. | L1-F08과 조치가 같은 중복이다. | REJECT | N/A |
| L4-F02 | audience owner 정정 뒤 parent 표가 옛 owner를 지목한다. | 표가 과거 child inventory라면 history일 수 있다. 그러나 셀은 현재 component 구현 근거를 직접 가리키고 정정과 같은 문서에 있으면서 역사 표시가 없다. | pointer 수정 전후 packet과 구현자가 선택한 owner를 비교한다. | current reference가 명시적 owner split과 충돌한다. | KEEP | Medium |
| L4-F03 | PKCE 순서가 parent manual-first와 child library-first로 반대다. | parent의 이어지는 문구가 child D1을 정본으로 두고 실제 coding은 library-first라고 drift를 이미 명시한다. 앞 절반만 취하면 해소된 경고를 active contradiction처럼 계산한다. | parent 전체 D4 cell과 implementation plan이 쓰는 순서를 확인한다. | source가 child order를 canonical로 확정한다. | REJECT | N/A |
| L4-F04 | SPA Direct의 HttpOnly refresh cookie는 TMB를 요구한다. | taxonomy가 기존 Resource Server의 작은 refresh endpoint를 AP1 변형으로 허용하면 명칭은 유지할 수 있다. 그래도 server-side custody, Set-Cookie, CSRF, rotation 책임이 추가되어 trust boundary가 달라진다. | architecture owner의 AP1 정의와 실제 refresh endpoint, cookie issuer, CSRF test를 확인한다. | 문서도 사실상 TMB라 인정하지만 기본 선택을 닫지 않았다. | KEEP | High |
| L4-F05 | 근거를 해소한 D3와 D6가 Claims에서 unsupported로 남았다. | Claims 표가 조사 전 history라면 모순이 아니고 runtime E2E는 여전히 남는다. 다만 날짜와 resolved 표시 없이 active queue에 있어 문헌 근거와 runtime 검증을 혼동시킨다. | Claims 상태 transition과 후속 task의 중복 조사 여부를 확인한다. | stale이지만 영향은 재조사 비용에 가깝다. | DOWNGRADE | Low |
| L4-F06 | cookie-CSRF를 위임한 audience owner에 관련 decision이 없다. | TMB 변형을 채택하지 않으면 cookie CSRF concern이 생기지 않고 unseen BFF owner가 소유할 수도 있다. 두 note만으로 project-wide owner 부재를 확정하기에는 enumeration이 부족하다. | 선택 storage profile, refresh endpoint owner, CSRF D-row와 negative test를 확인한다. | 조건부 concern이고 전역 context가 부족하다. | DOWNGRADE | Low |
| L4-F07 | custom audience validator 필수 목표와 property-first 선택이 어긋난다. | custom validator를 학습 예제로 두고 production baseline은 property-first로 둘 수 있다. D1과 구현 가이드가 단일 audience는 property, 복합은 custom으로 이미 조건을 기록해 실제 선택은 종결됐다. | TODO와 plan에서 baseline과 비교 학습 단계가 분리됐는지 확인한다. | 상단 wording만 stale이라 severity를 낮춘다. | DOWNGRADE | Low |
| L4-F08 | expected audience가 backend-client-id와 spa-client로 갈린다. | API audience를 의도적으로 spa-client와 같게 둘 수 있다. 그러나 owner는 backend mapper를 필수로 하고 consumer는 다른 literal을 고정하며 그 동일성 결정이 없어 정상 acceptance를 신뢰할 수 없다. | realm client IDs, Audience mapper, token aud, backend expected value와 negative test를 캡처한다. | 별도 client 전제에서 401 또는 잘못된 허용으로 이어진다. | KEEP | High |
| L4-F09 | audience owner 위임 뒤 role note가 세부를 재명세한다. | 한 파일에서 RS setup과 RBAC를 함께 구현하는 fold-in snapshot일 수 있고 owner 주석이 정본을 알려준다. 다만 복제된 spa-client 값이 owner와 이미 갈려 drift가 현실화했다. | role PR이 owner 없이 audience 값을 바꾸는지와 checker 추적 결과를 확인한다. | ownership 문제는 남지만 L4-F08과 영향이 겹친다. | DOWNGRADE | Low |
| L4-F10 | realm-role-only의 single-client 전제가 multi-client base와 stale하다. | RBAC branch는 현재 한 client만 대상으로 하거나 여러 client에 같은 realm-global policy를 의도할 수 있다. 서로 다른 rollout 단계와 authz scope를 비교해 stale이라고 단정했다. | rollout target과 각 client permission namespace 필요를 realm export로 확인한다. | multi-client가 곧 client role 필요를 뜻하지 않는다. | DOWNGRADE | Low |
| L4-F11 | 3-leg 표기에 Backend가 없지만 Hop 3 verifier는 Backend다. | 3-leg는 federation actor chain의 학습 약칭이고 hop table은 API validation을 더한 별도 sequence일 수 있다. 실제 표에는 Backend가 있어 구현 verifier 누락이라는 failure도 이미 완화된다. | diagram과 acceptance가 Backend validation을 빠뜨리는지 확인한다. | naming ambiguity만으로 독립 defect를 구성하기 어렵다. | REJECT | N/A |
| L4-F12 | 면접 문장은 nonce 자동 처리를 사실로 말하지만 D3는 미검증이다. | OIDC broker가 nonce를 처리할 것이라는 기대는 합리적이고 표준 의무를 요약했을 수 있다. 그래도 특정 Keycloak version의 자동 처리를 외부 답변으로 말하려면 표준 의무와 제품 관측을 나눠야 한다. | source 또는 HAR에서 nonce 발행, upstream token, callback 대조와 거부를 확인한다. | 문서가 제품 동작 미검증을 인정하면서 외부 문장은 사실형이다. | KEEP | Medium |
| L4-F13 | out-of-scope mapper와 linking을 구체 정책으로 재진술한다. | 해당 row는 관심사 이름과 owner link만 한 줄로 요약해 상세 mechanism 복제보다 Reference-Only가 허용하는 pointer와 요약에 가깝다. 실제 설정값이나 예외도 재명세하지 않는다. | row가 owner D-number와 한 줄 조건만 갖고 구체 값이 없는지 확인한다. | 상세 재진술 분류가 source 추상도와 맞지 않는다. | REJECT | N/A |
| L4-F14 | Traefik note가 P3A를 edge-auth pattern으로 잘못 귀속한다. | 같은 EC2에 oauth2-proxy를 추가한 배포 변형은 가능하다. 그러나 P3A owner가 SPA Direct와 backend JWT validation을 정의하므로 배포 가능성만으로 인증 pattern ID를 바꿀 수 없다. | AP taxonomy, P3A component diagram, Traefik target pattern을 대조한다. | 배포 축과 authentication architecture 축이 혼동됐다. | KEEP | High |
| L4-F15 | middleware가 302를 생성한다는 설명과 pass-through D3가 갈린다. | browser 관점에서는 Traefik endpoint에서 302를 받으므로 middleware가 발급했다는 관찰 축약일 수 있다. 생성 주체와 전달 주체 구분은 장애 분석에 유용하지만 architecture 자체를 바꾸는 차이는 아니다. | oauth2-proxy와 Traefik log, Location header origin을 함께 추적한다. | 책임 표현 drift는 맞지만 영향이 낮다. | DOWNGRADE | Low |
| L4-F16 | relative API와 token URL이 static-only nginx로 향한다. | 실제 nginx나 dev server에 문서 밖 proxy가 있으면 정상이다. 그러나 parent가 nginx를 static-only로 명시하고 3-port CORS topology를 선택해 그런 proxy를 배제한다. | deployed nginx config, browser resolved URL, backend와 Keycloak access log를 확인한다. | 현재 명세대로면 두 request가 nginx에서 실패한다. | KEEP | High |
| L4-F17 | oidc-client-ts default를 localStorage라 하면서 미증명으로 둔다. | pinned version에서 실제 default가 localStorage라면 기술값은 맞고 raw evidence만 보강하면 된다. stateStore와 userStore가 다른 default를 가져 단일 문장이 지나치게 뭉쳤을 수도 있다. | lock version의 공식 API와 runtime storage keys로 두 store와 token persistence를 확인한다. | evidence drift는 맞지만 기술값 오류는 미확정이다. | DOWNGRADE | Low |
| L4-F18 | silent SSO는 제외했는데 automaticSilentRenew mechanism은 미결정이다. | 문서가 이를 Claims To Verify로 명시해 아직 선택되지 않은 planned work로 관리한다. 미결정 상태는 양립 불가능한 active decision이 아니고 network trace로 닫는 gate도 있다. | pinned config와 expiry trace에서 refresh grant 또는 iframe 호출을 확인한다. | explicit open question을 consistency defect로 올렸다. | REJECT | N/A |
| L4-F19 | Phase C2 뒤 active 표가 codes와 CORS와 redaction을 미구현으로 둔다. | raw branch note가 설계 시점과 as-built audit를 시간순으로 보존하고 하단 C2 표가 최신임을 표시한다면 오래된 행은 history다. 신규 task가 옛 표를 소비하지 않으면 runtime 위험보다 탐색 비용 문제다. | current code와 C2 artifact, 옛 표의 downstream 참조를 확인한다. | stale은 맞지만 실제 영향 근거가 부족하다. | DOWNGRADE | Medium |
| L4-F20 | decoder와 clock skew가 세 상태로 기록된다. | custom wrapper 안에서 vendor default validator를 쓸 수 있어 custom bean과 default leeway는 동시에 성립한다. code owner를 보지 않고 세 문장을 모두 상호 배타적으로 분류한 부분이 있다. | bean graph, validator chain, skew boundary test로 현재 mechanism을 확인한다. | custom 미작성 drift는 남지만 source만으로 정확한 상태를 못 정한다. | DOWNGRADE | Medium |
| L4-F21 | public-path 계약이 reflection과 env snapshot을 함께 유지한다. | 앞쪽 정정이 reflection 폐기와 env gate의 한계를 명시하고 C2 표도 current task를 제공하면 뒤 Test Contract는 역사 초안으로 식별할 수 있다. 실제 구현자가 정정을 읽는다면 옛 task 재생성 가능성은 제한된다. | snapshot task, CI wiring, reflection command의 downstream 참조를 확인한다. | active-looking command drift는 남지만 current mechanism은 정정됐다. | DOWNGRADE | Medium |
## Consolidation
- Issuer/network: L1-F08을 유지하고 L4-F01은 중복 제거.
- Audience: L4-F08을 대표로 두고 L1-F14는 제거, L4-F02와 L4-F09는 보조 finding.
- SPA Direct/TMB: L4-F04를 대표로 두고 L2-F09는 제거, L4-F06은 조건부 owner risk.
- Account linking: L2-F02를 대표로 유지하고 L1-F03은 낮은 우선순위로 조정.
- Rotation: L3-F11, L3-F12, L3-F13을 한 승인 단위에서 함께 검증.
- Security baseline: L4-F19, L4-F20, L4-F21을 as-built 대조 한 번으로 처리.
## Machine Summary
~~~wiki-verdict
agent: wiki-adversarial-reviewer
KEEP: L1-F01,L1-F08,L1-F09,L1-F10,L2-F02,L2-F04,L2-F06,L2-F07,L2-F08,L3-F03,L3-F04,L3-F07,L3-F08,L3-F14,L3-F15,L4-F02,L4-F04,L4-F08,L4-F12,L4-F14,L4-F16
DOWNGRADE: L1-F02,L1-F03,L1-F04,L1-F05,L1-F07,L1-F11,L1-F12,L2-F03,L2-F05,L3-F01,L3-F05,L3-F09,L3-F10,L3-F11,L3-F12,L3-F13,L3-F16,L4-F05,L4-F06,L4-F07,L4-F09,L4-F10,L4-F15,L4-F17,L4-F19,L4-F20,L4-F21
REJECT: L1-F06,L1-F13,L1-F14,L2-F01,L2-F09,L3-F02,L3-F06,L4-F01,L4-F03,L4-F11,L4-F13,L4-F18
~~~
~~~wiki-stats
agent: wiki-adversarial-reviewer
found: 60
processed: 60
dropped: 0
~~~
@@ -0,0 +1,194 @@
# Keycloak branch-note consistency 적용 상태
**일자:** 2026-07-18
**범위:** Keycloak 관련 branch-note 38개
**상태:** COMPLETE
**원본 리뷰:** `docs/superpowers/specs/2026-07-18-keycloak-branch-note-consistency-report.md`
## 1. Summary
- 38개 문서를 4개 비중복 lane으로 `READ_FULL` 처리했다.
- 36개 문서를 수정했고, 2개 문서는 리뷰 finding과 직접 관련된 변경이 없어 본문을 유지했다.
- adversarial review의 60개 finding을 모두 판정했다. `KEEP 21 + DOWNGRADE 27`의 retained 48건은 반영했고, `REJECT 12`는 독립 결함으로 재도입하지 않았다.
- `REJECT` 중 다른 retained finding과 같은 원인을 공유하거나 active 문구 정리가 필요했던 항목은 대표 owner 수정에 통합했다.
- 후속 의미 재감사 20건은 `Ready`, blocking 0, should-fix 0으로 종료됐다.
## 2. Evidence Matrix
| Path | Status | Evidence | Application |
|---|---|---|---|
| `raw/branch-notes/experiment-nplus1-highlight-feed.md` | READ_FULL | lines 1-326 | MODIFIED — branch-note 필수 구조 보강 |
| `raw/branch-notes/feature-authentication-authorization-contract.md` | READ_FULL | lines 1-341 | MODIFIED — raw role / authority 경계 정렬 |
| `raw/branch-notes/feature-boundary-validation-mapping-contract.md` | READ_FULL | lines 1-407 | NO_CHANGE — 적용 대상 finding 없음 |
| `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md` | READ_FULL | lines 1-228 | MODIFIED — linking owner 직접 참조와 UX 책임 분리 |
| `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md` | READ_FULL | lines 1-279 | MODIFIED — persistent federation key 단일 owner |
| `raw/branch-notes/feature-keycloak-bff-vs-spa-direct.md` | READ_FULL | lines 1-274 | MODIFIED — SPA/TMB 조건과 stale risk 정리 |
| `raw/branch-notes/feature-keycloak-docker-compose-stack.md` | READ_FULL | lines 1-286 | MODIFIED — issuer/JWKS profile owner 위임과 Compose wiring 분리 |
| `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md` | READ_FULL | lines 1-323 | MODIFIED — P1B header-only trust 정렬 |
| `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md` | READ_FULL | lines 1-316 | MODIFIED — ingress/auth-service 선택축과 cookie custody 구분 |
| `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md` | READ_FULL | lines 1-193 | MODIFIED — backend validation 실제 owner 직접 참조 |
| `raw/branch-notes/feature-keycloak-first-broker-login-flow.md` | READ_FULL | lines 1-263 | MODIFIED — silent-link 방지와 custom SPI variant 분리 |
| `raw/branch-notes/feature-keycloak-google-claim-attribute-mapping.md` | READ_FULL | lines 1-238 | MODIFIED — IdP sync owner와 role freshness owner 분리 |
| `raw/branch-notes/feature-keycloak-google-redirect-uri-policy.md` | READ_FULL | lines 1-262 | MODIFIED — basic scope 예외·JS origins·decision ref 정렬 |
| `raw/branch-notes/feature-keycloak-header-spoofing-defense.md` | READ_FULL | lines 1-255 | NO_CHANGE — 적용 대상 finding 없음 |
| `raw/branch-notes/feature-keycloak-https-termination-caddy-nginx.md` | READ_FULL | lines 1-284 | MODIFIED — HSTS explicit configuration 경계 |
| `raw/branch-notes/feature-keycloak-idp-brokering-google-client.md` | READ_FULL | lines 1-251 | MODIFIED — server-side brokering JS origins owner 참조 |
| `raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role.md` | READ_FULL | lines 1-153 | MODIFIED — mapper-level override와 linking owner 위임 |
| `raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md` | READ_FULL | lines 1-467 | MODIFIED — foreign detail을 owner pointer로 축소 |
| `raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md` | READ_FULL | lines 1-416 | MODIFIED — token custody/audience owner 직접 참조 |
| `raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch.md` | READ_FULL | lines 1-284 | MODIFIED — public issuer/internal JWKS profile owner |
| `raw/branch-notes/feature-keycloak-nginx-auth-request-integration.md` | READ_FULL | lines 1-306 | MODIFIED — auth_request/proxy body forwarding 층위 구분 |
| `raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow.md` | READ_FULL | lines 1-275 | MODIFIED — local JWT validation 용어와 decision ref 정렬 |
| `raw/branch-notes/feature-keycloak-patterns.md` | READ_FULL | lines 1-223 | MODIFIED — superseded naming과 hub 필수 구조 보강 |
| `raw/branch-notes/feature-keycloak-pkce-flow-stages.md` | READ_FULL | lines 1-188 | MODIFIED — target UI label과 필수 구조 보강 |
| `raw/branch-notes/feature-keycloak-public-domain-tunneling.md` | READ_FULL | lines 1-220 | MODIFIED — provider selection/operational profile owner 분리 |
| `raw/branch-notes/feature-keycloak-realm-client-export.md` | READ_FULL | lines 1-239 | MODIFIED — import wiring 위임과 credential 검증 상태 정리 |
| `raw/branch-notes/feature-keycloak-refresh-rotation-and-logout.md` | READ_FULL | lines 1-255 | MODIFIED — 실행 관찰 owner로 범위 축소 |
| `raw/branch-notes/feature-keycloak-refresh-token-rotation.md` | READ_FULL | lines 1-283 | MODIFIED — policy owner와 Max Reuse 검증 계약 정리 |
| `raw/branch-notes/feature-keycloak-reverse-proxy-headers.md` | READ_FULL | lines 1-297 | MODIFIED — Caddy prefix 보존과 하위 owner 경계 |
| `raw/branch-notes/feature-keycloak-single-ec2-google-federation.md` | READ_FULL | lines 1-349 | MODIFIED — integration invariant와 child detail owner 분리 |
| `raw/branch-notes/feature-keycloak-single-ec2-no-google.md` | READ_FULL | lines 1-344 | MODIFIED — audience owner, library-first, pure-SPA 기준선 |
| `raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff.md` | READ_FULL | lines 1-258 | MODIFIED — pure SPA memory-only와 TMB/BFF variant 분리 |
| `raw/branch-notes/feature-keycloak-spring-rs-audience-validator.md` | READ_FULL | lines 1-270 | MODIFIED — expected audience `backend-client-id` 단일화 |
| `raw/branch-notes/feature-keycloak-spring-rs-role-mapping.md` | READ_FULL | lines 1-216 | MODIFIED — RBAC 범위와 audience owner 참조 분리 |
| `raw/branch-notes/feature-keycloak-three-leg-trust-chain.md` | READ_FULL | lines 1-229 | MODIFIED — OIDC nonce 의무와 제품 동작 검증 상태 분리 |
| `raw/branch-notes/feature-keycloak-traefik-forwardauth-alternative.md` | READ_FULL | lines 1-238 | MODIFIED — AP4 분류와 redirect 생성/전달 주체 분리 |
| `raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md` | READ_FULL | lines 1-259 | MODIFIED — library-first, absolute/runtime endpoint, explicit memory store |
| `raw/branch-notes/feature-security-operational-baseline.md` | READ_FULL | lines 1-416 | MODIFIED — historical/current-known/needs-confirmation 분리 |
## 3. Finding Disposition
상태 의미:
- `APPLIED`: retained finding을 owner-first 기준으로 반영.
- `CONSOLIDATED`: adversarial `REJECT`를 독립 결함으로 적용하지 않고 대표 retained finding 또는 active 문구 정리에 통합.
- `REJECTED_NO_CHANGE`: adversarial 반증을 수용해 별도 수정하지 않음.
| Finding | Adversarial | Application |
|---|---|---|
| L1-F01 | KEEP | APPLIED |
| L1-F02 | DOWNGRADE | APPLIED |
| L1-F03 | DOWNGRADE | APPLIED |
| L1-F04 | DOWNGRADE | APPLIED |
| L1-F05 | DOWNGRADE | APPLIED |
| L1-F06 | REJECT | REJECTED_NO_CHANGE |
| L1-F07 | DOWNGRADE | APPLIED |
| L1-F08 | KEEP | APPLIED |
| L1-F09 | KEEP | APPLIED |
| L1-F10 | KEEP | APPLIED |
| L1-F11 | DOWNGRADE | APPLIED |
| L1-F12 | DOWNGRADE | APPLIED |
| L1-F13 | REJECT | REJECTED_NO_CHANGE |
| L1-F14 | REJECT | CONSOLIDATED — L4-F08 audience owner/value 정리 |
| L2-F01 | REJECT | CONSOLIDATED — unsupported CVE 문구의 active 영향만 축소 |
| L2-F02 | KEEP | APPLIED |
| L2-F03 | DOWNGRADE | APPLIED |
| L2-F04 | KEEP | APPLIED |
| L2-F05 | DOWNGRADE | APPLIED |
| L2-F06 | KEEP | APPLIED |
| L2-F07 | KEEP | APPLIED |
| L2-F08 | KEEP | APPLIED |
| L2-F09 | REJECT | CONSOLIDATED — L4-F04 token custody 경계에 통합 |
| L3-F01 | DOWNGRADE | APPLIED |
| L3-F02 | REJECT | REJECTED_NO_CHANGE |
| L3-F03 | KEEP | APPLIED |
| L3-F04 | KEEP | APPLIED |
| L3-F05 | DOWNGRADE | APPLIED |
| L3-F06 | REJECT | REJECTED_NO_CHANGE |
| L3-F07 | KEEP | APPLIED |
| L3-F08 | KEEP | APPLIED |
| L3-F09 | DOWNGRADE | APPLIED |
| L3-F10 | DOWNGRADE | APPLIED |
| L3-F11 | DOWNGRADE | APPLIED |
| L3-F12 | DOWNGRADE | APPLIED |
| L3-F13 | DOWNGRADE | APPLIED |
| L3-F14 | KEEP | APPLIED |
| L3-F15 | KEEP | APPLIED |
| L3-F16 | DOWNGRADE | APPLIED |
| L4-F01 | REJECT | CONSOLIDATED — L1-F08 issuer/network profile에 통합 |
| L4-F02 | KEEP | APPLIED |
| L4-F03 | REJECT | CONSOLIDATED — active manual-first 문구를 historical/superseded 처리 |
| L4-F04 | KEEP | APPLIED |
| L4-F05 | DOWNGRADE | APPLIED |
| L4-F06 | DOWNGRADE | APPLIED |
| L4-F07 | DOWNGRADE | APPLIED |
| L4-F08 | KEEP | APPLIED |
| L4-F09 | DOWNGRADE | APPLIED |
| L4-F10 | DOWNGRADE | APPLIED |
| L4-F11 | REJECT | REJECTED_NO_CHANGE |
| L4-F12 | KEEP | APPLIED |
| L4-F13 | REJECT | REJECTED_NO_CHANGE |
| L4-F14 | KEEP | APPLIED |
| L4-F15 | DOWNGRADE | APPLIED |
| L4-F16 | KEEP | APPLIED |
| L4-F17 | DOWNGRADE | APPLIED |
| L4-F18 | REJECT | REJECTED_NO_CHANGE |
| L4-F19 | DOWNGRADE | APPLIED |
| L4-F20 | DOWNGRADE | APPLIED |
| L4-F21 | DOWNGRADE | APPLIED |
## 4. Owner Decisions Applied
1. Audience는 backend API용 `backend-client-id` 하나를 owner 값으로 사용한다.
2. Docker dev는 public issuer identity와 bridge-container JWKS retrieval address를 분리하며, exact profile은 issuer mismatch branch D6이 소유한다.
3. P1A/P1B backend trust는 header-only이고 Google federation 여부만 다르다.
4. Google persistent federation key는 `(IdP alias, sub)` owner 하나로 수거한다.
5. `email_verified=false` core 정책은 silent auto-link 차단이며, hard-reject는 구현된 custom SPI variant가 있을 때만 별도 채택한다.
6. Pure SPA는 access/refresh token 모두 memory-only이고 reload 시 재인증한다. HttpOnly refresh cookie는 TMB/BFF variant다.
7. Stable Google callback은 named tunnel과 managed custom domain을 사용하고 quick tunnel은 임시 dev 용도로 제한한다.
8. Caddy method B는 `/keycloak` prefix를 보존한다.
9. 실제 SPA 구현 순서는 `oidc-client-ts` 우선이며 manual PKCE는 비교 학습 단계다.
10. Traefik + oauth2-proxy는 edge ForwardAuth AP4이며, SPA 요청 URL은 static-only nginx topology에 맞춘 runtime/absolute endpoint를 사용한다.
## 5. Verification
| Gate | Result |
|---|---|
| Scope partition | 38 = 10 + 10 + 10 + 8, duplicate 0, missing 0 |
| Full-read coverage | 38/38 READ_FULL |
| Structure lint | 38/38 PASS |
| Deterministic consistency, Keycloak scope | 0 findings |
| Deterministic consistency, whole repository | 136 pre-existing out-of-scope findings (`BARE_OWNER_REF` 74, `BARE_DECISION_REF` 62) |
| Link validation | 38/38 per-file lint PASS; Keycloak scope broken link 0 |
| Semantic audit round 1 | Not-ready: blocking 1, should-fix 3 |
| Semantic audit round 2 | Ready: blocking 0, should-fix 0; 20/20 processed |
| Semantic round 2 classifications | original candidates: CONSISTENT 11, NOT_AN_EDGE 9; current actual/replacement edges 14/14 CONSISTENT |
| `git diff --check` | PASS |
실행한 대표 명령:
```bash
python3 .claude/hooks/wiki_structure_lint.py --file <각 scope 문서>
python3 .claude/hooks/wiki_consistency_check.py --all
python3 .claude/hooks/wiki_structure_lint.py --all --links-only
git diff --check -- <scope 문서>
```
## 6. Residual Risk
문서 간 의미 모순은 이번 scope에서 닫혔다. 다음 항목은 문서에 `planned` 또는 `needs-confirmation`으로 유지했다.
- target Keycloak release의 account unlink guard와 Admin UI label 확인
- Docker Linux host-gateway JWKS E2E 및 key rotation
- realm export의 credential/secret 포함 범위
- `email_verified=false`와 First Broker Login 실제 flow E2E
- audience mapper가 발급 token의 `aud``backend-client-id`를 넣는지 확인
- nonce wire trace, Traefik redirect 전달, Caddy route, tunnel callback E2E
- Max Reuse 0/1과 refresh token reuse 영향 측정
- pinned SPA library의 storage/renew 동작 확인
- Phase C2 security baseline의 실제 code/test 재확인
저장소 전체 consistency 136건과 links-only 29건은 Keycloak scope 밖의 기존 항목이며 이번 적용에서 수정하지 않았다.
## 7. Stats
```wiki-stats
found: 60
processed: 60
dropped: 0
modified_files: 36
no_change_files: 2
semantic_candidates: 20
semantic_ready: true
```
@@ -0,0 +1,52 @@
# Evidence Matrix
범위 정의: `raw/branch-notes/` 아래 Markdown 중 파일명 또는 본문에 대소문자 무관 `keycloak`이 있는 파일. `rg -il --glob '*.md' 'keycloak' raw/branch-notes | sort` 결과를 고정 입력으로 사용했다.
| Path | Status | Evidence | Extracted facts |
|---|---|---|---|
| `raw/branch-notes/experiment-nplus1-highlight-feed.md` | READ_FULL | L1-L233 | N+1 실험·측정 계약이며 Keycloak은 예정 형제 주제로만 등장한다. |
| `raw/branch-notes/feature-authentication-authorization-contract.md` | READ_FULL | L1-L341 | application authz PEP, raw role→permission registry, Keycloak role claim 입력 경계. |
| `raw/branch-notes/feature-boundary-validation-mapping-contract.md` | READ_FULL | L1-L407 | validation/mapping 경계 계약이며 Keycloak은 sample seed 설명에 등장한다. |
| `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md` | READ_FULL | L1-L228 | SPA account-link UX, First Broker Login, link/unlink, CIAL, linked-account read path. |
| `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md` | READ_FULL | L1-L279 | `sub` federation key, local-account collision, re-auth, unlink lockout, Sync Mode. |
| `raw/branch-notes/feature-keycloak-bff-vs-spa-direct.md` | READ_FULL | L1-L274 | SPA Direct/BFF 선택, token holder, session, OAuth 2.1 조건. |
| `raw/branch-notes/feature-keycloak-docker-compose-stack.md` | READ_FULL | L1-L287 | Keycloak/Postgres/app/nginx Compose topology, health, import, issuer/network. |
| `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md` | READ_FULL | L1-L325 | P1B Google brokering, P1A 불변식, account matching, child-owner 위임. |
| `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md` | READ_FULL | L1-L316 | P1A header-trust ForwardAuth, oauth2-proxy/nginx/Traefik, cookie session. |
| `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md` | READ_FULL | L1-L193 | P2A→P2B login-path zero-change, token claim baseline, account-link exclusion. |
| `raw/branch-notes/feature-keycloak-first-broker-login-flow.md` | READ_FULL | L1-L261 | Confirm Link 소유증명, silent auto-link 차단, hard-reject SPI 별도 트랙. |
| `raw/branch-notes/feature-keycloak-google-claim-attribute-mapping.md` | READ_FULL | L1-L238 | attribute-mapping owner, IdP default IMPORT, role mapping 위임. |
| `raw/branch-notes/feature-keycloak-google-redirect-uri-policy.md` | READ_FULL | L1-L261 | exact redirect URI, basic-scope 예외, JavaScript origins 비움. |
| `raw/branch-notes/feature-keycloak-header-spoofing-defense.md` | READ_FULL | L1-L255 | NetworkPolicy·SG·shared secret·trusted proxy 방어 경계. |
| `raw/branch-notes/feature-keycloak-https-termination-caddy-nginx.md` | READ_FULL | L1-L284 | TLS 종단 대안과 명시적 HSTS 구성. |
| `raw/branch-notes/feature-keycloak-idp-brokering-google-client.md` | READ_FULL | L1-L251 | Google IdP client, `trustEmail=false`, callback·origin 위임. |
| `raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role.md` | READ_FULL | L1-L148 | role/RBAC owner, FORCE, email verification 강제 주장. |
| `raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md` | READ_FULL | L1-L512 | P2B composition hub, zero-change brokering, SMTP 사실, foreign-decision 상세. |
| `raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md` | READ_FULL | L1-L416 | P2A SPA Direct 정의, refresh-token 저장, brokering 비교. |
| `raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch.md` | READ_FULL | L1-L287 | issuer/JWKS 분리 대안, 미승인 대안, 외부 owner 충돌. |
| `raw/branch-notes/feature-keycloak-nginx-auth-request-integration.md` | READ_FULL | L1-L306 | `auth_request` body handling, browser/API failure routing, upstream ownership edges. |
| `raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow.md` | READ_FULL | L1-L275 | bearer-token 용어, nginx decision references, discovery startup dependency. |
| `raw/branch-notes/feature-keycloak-patterns.md` | READ_FULL | L1-L205 | retired numbered naming과 retained D2. |
| `raw/branch-notes/feature-keycloak-pkce-flow-stages.md` | READ_FULL | L1-L167 | PKCE S256 ownership과 Admin Console terminology. |
| `raw/branch-notes/feature-keycloak-public-domain-tunneling.md` | READ_FULL | L1-L220 | quick/named Cloudflare Tunnel domain과 tunnel-choice ownership. |
| `raw/branch-notes/feature-keycloak-realm-client-export.md` | READ_FULL | L1-L238 | import wiring ownership, credential export assertions, PKCE terminology. |
| `raw/branch-notes/feature-keycloak-refresh-rotation-and-logout.md` | READ_FULL | L1-L254 | rotation demonstration, Max Reuse semantics, back-channel logout scope. |
| `raw/branch-notes/feature-keycloak-refresh-token-rotation.md` | READ_FULL | L1-L284 | rotation contract, family invalidation, receiver endpoint delegation. |
| `raw/branch-notes/feature-keycloak-reverse-proxy-headers.md` | READ_FULL | L1-L297 | Caddy path behavior, Keycloak proxy variables, delegated security decisions. |
| `raw/branch-notes/feature-keycloak-single-ec2-google-federation.md` | READ_FULL | L1-L370 | parent integration decisions와 delegated tunnel/proxy details. |
| `raw/branch-notes/feature-keycloak-single-ec2-no-google.md` | READ_FULL | L1-L342 | base topology, issuer reachability, PKCE order, component ownership. |
| `raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff.md` | READ_FULL | L1-L256 | access/refresh token storage, TMB 경계, CSRF 위임. |
| `raw/branch-notes/feature-keycloak-spring-rs-audience-validator.md` | READ_FULL | L1-L268 | audience 검증 owner와 property/custom 구현 선택. |
| `raw/branch-notes/feature-keycloak-spring-rs-role-mapping.md` | READ_FULL | L1-L231 | RBAC/role mapping과 audience-owner 위임. |
| `raw/branch-notes/feature-keycloak-three-leg-trust-chain.md` | READ_FULL | L1-L229 | federation/API validation hop, nonce, foreign claim/linking 정책. |
| `raw/branch-notes/feature-keycloak-traefik-forwardauth-alternative.md` | READ_FULL | L1-L237 | Traefik ForwardAuth routing과 P3A pattern 비교. |
| `raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md` | READ_FULL | L1-L257 | oidc-client-ts/manual PKCE, API/token URL, silent renew. |
| `raw/branch-notes/feature-security-operational-baseline.md` | READ_FULL | L1-L417 | auth code/CORS/redaction/JWT/public-path operational contracts. |
## Completeness
- Enumerated `N=38`
- Evidence rows `M=38`
- `READ_FULL R=38`, `BLOCKED B=0`
- Path-set difference: missing `0`, duplicate `0`, extra `0`
- Completeness formula: `M == N AND P == R`에서 per-file index section `P=38`; 결과 `38 == 38 AND 38 == 38`.
@@ -0,0 +1,615 @@
## Lane Inventory
| Path | Status | Evidence Lines | Extracted Facts |
|---|---|---|---|
| `raw/branch-notes/experiment-nplus1-highlight-feed.md` | READ_FULL | L1-L233 | N+1 실험·측정 계약. Keycloak은 예정 형제 주제로만 언급되며 Keycloak 결정 없음. |
| `raw/branch-notes/feature-authentication-authorization-contract.md` | READ_FULL | L1-L341 | application authz PEP, raw role→permission registry, Keycloak role claim 입력 경계. |
| `raw/branch-notes/feature-boundary-validation-mapping-contract.md` | READ_FULL | L1-L407 | validation/mapping 경계 계약. Keycloak은 sample seed 설명에만 등장하며 Keycloak 결정 없음. |
| `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md` | READ_FULL | L1-L228 | SPA account-link UX, First Broker Login, link/unlink, CIAL, linked-account read path. |
| `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md` | READ_FULL | L1-L279 | `sub` federation key, local-account collision, re-auth, unlink lockout, Sync Mode. |
| `raw/branch-notes/feature-keycloak-bff-vs-spa-direct.md` | READ_FULL | L1-L274 | SPA Direct/BFF 선택, token holder, session, OAuth 2.1 조건. |
| `raw/branch-notes/feature-keycloak-docker-compose-stack.md` | READ_FULL | L1-L287 | Keycloak/Postgres/app/nginx Compose topology, health, import, issuer/network. |
| `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md` | READ_FULL | L1-L325 | P1B Google brokering, P1A 불변식, account matching, child-owner 위임. |
| `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md` | READ_FULL | L1-L316 | P1A header-trust ForwardAuth, oauth2-proxy/nginx/Traefik, cookie session. |
| `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md` | READ_FULL | L1-L193 | P2A→P2B login-path zero-change, token claim baseline, account-link exclusion. |
## 1. `experiment-nplus1-highlight-feed.md`
**0-finding justification:** Keycloak 일관성 관점에서 3개 항목을 확인했다.
1. Keycloak ownership — 해당 없음: 예정 형제 주제로만 언급됨(`:25`).
2. 인증 흐름 — 해당 없음: feed 측정은 HTTP 인증과 무관하다고 경계를 둠(`:108`).
3. Decision Evidence Map — D-01~D-10은 N+1·CQRS 실험 결정이며 Keycloak 설정을 소유하지 않음(`:64-77`).
따라서 이 파일에 대한 Keycloak inconsistency finding은 0건이다. 문서 전체 품질 판정은 본 lane 범위가 아니다.
## 2. `feature-authentication-authorization-contract.md`
### L1-F01: D3의 `ROLE_*` 입력과 실제 raw-role registry 계약이 다르다
- **Source file:** `raw/branch-notes/feature-authentication-authorization-contract.md`
- **Source quote:**
> AuthorizationPort 가 `ROLE_*` authorities → role→permission registry 확장 → 요구 permission 포함 판정. 매핑 source = app-side config 기본
- **Counterpart/current quote:**
> ca-tmpl `AuthenticatedUser.roles`(`adapter-web`, `Set<String>`)는 **raw role 문자열**(`admin`/`user` — Keycloak 원본, prefix 없음)을 담는다.
- **Source lines:** `raw/branch-notes/feature-authentication-authorization-contract.md:124`, `raw/branch-notes/feature-authentication-authorization-contract.md:169`
- **Severity:** Medium
- **Classification:** `FACT — STALE_SUMMARY`; 실패 영향은 `INFERENCE`.
- **Claim:** D3는 registry 입력을 `ROLE_*` authority로 정의하지만 구현 명세는 prefix 없는 raw role을 canonical 입력으로 정의한다.
- **Real-world assumptions:**
1. 구현이 `:169`의 raw-role contract를 따른다.
- **Falsifies if:** adapter가 registry 호출 전에 raw role을 `ROLE_*`로 변환한다.
- **Verify:** `AuthorizationAdapter` 입력과 `RolePermissionProperties` key를 함께 확인한다.
2. 운영자가 D3를 보고 config key를 작성한다.
- **Falsifies if:** config schema가 raw key만 허용하고 startup에서 prefix를 거부한다.
- **Verify:** `application.yml` binding test에 `admin``ROLE_ADMIN` 케이스를 추가한다.
- **Concrete failure mode:** 문서대로 `ROLE_ADMIN`을 registry key로 넣고 runtime이 `admin`을 조회하면 fail-closed로 permission 0개가 되어 정당한 요청이 403으로 거부된다.
- **Falsification condition:** `ROLE_*`는 Spring adapter 내부의 중간 표현일 뿐이며 D3가 raw-role 변환 경계를 명시하도록 이미 갱신돼 있다면 finding은 무효다.
- **Required action:** D3를 “Keycloak raw role → adapter에서 Spring authority 파생, authz registry는 raw role consume”으로 고치고 security-baseline owner 결정을 pointer로 연결한다.
- **Why this action:** as-built contract와 config vocabulary를 하나로 만들어 prefix drift를 없앤다.
- **Alternatives considered:**
1. **Raw role canonical** — 현재 코드와 맞고 문서만 수정한다.
2. **`ROLE_*` canonical** — Spring layer에는 단순하지만 registry/config migration이 필요하다.
3. **Permission claim 직접 consume** — IdP 중앙집중형에 맞지만 Keycloak 결합이 커진다.
4. **명시적 principal DTO에서 raw role/authority 둘 다 보존** — 경계가 선명하지만 모델이 늘어난다.
- **Counterargument:** D3의 `ROLE_*`가 단지 security-baseline 출력의 설명이고 실제 registry 입력은 `:169`에서 명확하다고 볼 수 있다. 사용자 확인 방법은 D3만 읽은 신규 운영자가 config key를 올바르게 작성할 수 있는지 문서 리뷰로 재현하는 것이다.
- **Required action verification:** raw-role/authority 용어를 grep하고 D3·§3·config example이 같은 vocabulary를 쓰는지 확인한다.
- **Verification command and observed output:**
```text
$ grep -nFo -- 'AuthorizationPort 가 `ROLE_*` authorities → role→permission registry 확장 → 요구 permission 포함 판정. 매핑 source = app-side config 기본' 'raw/branch-notes/feature-authentication-authorization-contract.md'
124:AuthorizationPort 가 `ROLE_*` authorities → role→permission registry 확장 → 요구 permission 포함 판정. 매핑 source = app-side config 기본
$ grep -nFo -- 'ca-tmpl `AuthenticatedUser.roles`(`adapter-web`, `Set<String>`)는 **raw role 문자열**(`admin`/`user` — Keycloak 원본, prefix 없음)을 담는다.' 'raw/branch-notes/feature-authentication-authorization-contract.md'
169:ca-tmpl `AuthenticatedUser.roles`(`adapter-web`, `Set<String>`)는 **raw role 문자열**(`admin`/`user` — Keycloak 원본, prefix 없음)을 담는다.
```
**Single-finding justification:** authN/authZ ownership, permission model, failure mapping, method-security enforcement, Keycloak role input의 5개 항목을 검토했다. lane 내부에서 양립하지 않는 항목은 role vocabulary 1건이다. security-baseline의 실제 owner 결정은 unseen이므로 cross-lane 후보로 분리했다.
## 3. `feature-boundary-validation-mapping-contract.md`
**0-finding justification:** Keycloak 일관성 관점에서 3개 항목을 확인했다.
1. In/Out scope와 decisions는 validation/mapping 책임만 다룸(`:60-101`).
2. Decision Evidence Map D1~D15는 mapper·validation·ArchUnit 계약임(`:258-278`).
3. Keycloak은 sample seed 설명에만 등장함(`:350`).
따라서 Keycloak 설계·흐름 inconsistency finding은 0건이다.
## 4. `feature-keycloak-account-linking-spa-ux.md`
### L1-F02: unlink 안전정책의 owner 요약이 승격 전 상태에 머문다
- **Source file:** `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md`
- **Source quote:**
> orphan 거부 메커니즘은 owner 브랜치 D3(역시 `needs-confirmation`)에서 추적
- **Counterpart/current quote:**
> Account Console self-service unlink lockout 방지는 **Keycloak 엔진이 서버에서 이미 강제**
- **Source lines:** `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md:119`, `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:152`
- **Severity:** Medium
- **Classification:** `FACT — STALE_SUMMARY`.
- **Claim:** owner D3가 engine-source-backed 결정으로 승격됐지만 SPA UX의 D4 요약은 여전히 `needs-confirmation`으로 남아 있다.
- **Real-world assumptions:**
1. 배포 버전에서도 owner가 인용한 unlink guard가 유지된다.
- **Falsifies if:** 배포 release tag에서 guard가 제거·변경됐다.
- **Verify:** 배포 tag의 `LinkedAccountsResource`와 dev unlink 응답을 확인한다.
- **Concrete failure mode:** SPA UX 설계자가 server guard를 모른 채 중복 lockout API를 만들거나, 반대로 상태를 미확정으로 보고 UX 계약을 계속 보류한다.
- **Falsification condition:** 배포 버전 검증이 실패해 owner D3가 다시 `needs-confirmation`으로 내려가면 현재 요약은 stale하지 않다.
- **Required action:** D4·§Unlink·Claims의 요약을 `[[...sub-vs-email]] D3 — server guard confirmed; release-tag verification remains` 한 줄로 갱신한다.
- **Why this action:** evidence strength와 deployment-specific risk를 동시에 보존한다.
- **Alternatives considered:**
1. **Pointer + current summary** — 읽기 편하고 drift 전파가 필요하다.
2. **Pointer only** — drift가 적지만 UX 문맥이 약해진다.
3. **Version-pinned summary** — 정확하지만 Keycloak upgrade마다 갱신 비용이 든다.
- **Counterargument:** owner 근거가 `main` branch이므로 배포판에는 여전히 불확실성이 있다. 다만 이는 `UNSUPPORTED`가 아니라 “source-backed, release-tag 확인 필요” 상태다.
- **Verification command and observed output:**
```text
$ grep -nFo -- 'orphan 거부 메커니즘은 owner 브랜치 D3(역시 `needs-confirmation`)에서 추적' 'raw/branch-notes/feature-keycloak-account-linking-spa-ux.md'
119:orphan 거부 메커니즘은 owner 브랜치 D3(역시 `needs-confirmation`)에서 추적
$ grep -nFo -- 'Account Console self-service unlink lockout 방지는 **Keycloak 엔진이 서버에서 이미 강제**' 'raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md'
152:Account Console self-service unlink lockout 방지는 **Keycloak 엔진이 서버에서 이미 강제**
```
### L1-F03: delegated `email_verified` 정책이 active owned decision으로도 남아 있다
- **Source file:** `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md`
- **Source quote:**
> **`email_verified=true`만 link 허용**
- **Counterpart/current quote:**
> `email_verified` 는 Keycloak *linking gate* 가 아니라 Trust Email(계정 생성 시 verified 표시) 설정
- **Source lines:** `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md:102`, `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md:117`
- **Severity:** Medium
- **Classification:** `FACT — RESTATED_FOREIGN_DECISION + ambiguous mechanism`.
- **Claim:** active Decisions는 이 branch가 link gate를 소유하는 것처럼 단정하지만, DEM은 owner 위임과 Trust Email/custom-step 구분을 기록한다.
- **Real-world assumptions:**
1. 구현자가 `:102`를 Keycloak 기본 `Trust Email` 설정으로 해석한다.
- **Falsifies if:** owner D2가 custom First Broker Login step을 구현하고 참조자가 이를 명시한다.
- **Verify:** unseen idp-mappers D2와 realm export의 authenticator 구성을 확인한다.
- **Concrete failure mode:** `Trust Email=true`만으로 linking을 제한했다고 오인해 Confirm Link/re-auth 통제가 빠지고, threat model과 실제 flow가 달라진다.
- **Falsification condition:** owner D2가 `email_verified` custom gate를 실제 소유·구현하고 `:102`가 그 pointer 요약으로만 읽히도록 고쳐졌다면 finding은 해소된다.
- **Required action:** `:102`의 owned decision을 제거하고 owner D2 pointer로 치환하며, Trust Email과 custom linking gate를 별개 용어로 적는다.
- **Why this action:** Reference-Only를 지키면서 설정 lever 혼동을 막는다.
- **Alternatives considered:**
1. **Trust Email만 사용** — 계정 생성 verified 표시에 적합하지만 linking proof는 아니다.
2. **Custom `email_verified` gate** — 정책은 강하지만 SPI/flow 구현이 필요할 수 있다.
3. **Confirm Link + secret proof** — account ownership을 검증하지만 UX 마찰이 있다.
4. **Collision linking 비활성화** — 단순하지만 기존 local account 연결을 제공하지 않는다.
- **Counterargument:** `:102`가 native setting이 아니라 상위 정책을 말한 것이라면 행동 자체는 유지할 수 있다. 그래도 owner와 mechanism을 함께 명시해야 오독을 막을 수 있다.
- **Verification command and observed output:**
```text
$ grep -nFo -- '**`email_verified=true`만 link 허용**' 'raw/branch-notes/feature-keycloak-account-linking-spa-ux.md'
102:**`email_verified=true`만 link 허용**
$ grep -nFo -- '`email_verified` 는 Keycloak *linking gate* 가 아니라 Trust Email(계정 생성 시 verified 표시) 설정' 'raw/branch-notes/feature-keycloak-account-linking-spa-ux.md'
117:`email_verified` 는 Keycloak *linking gate* 가 아니라 Trust Email(계정 생성 시 verified 표시) 설정
```
### L1-F04: SPA UX가 password 확인을 고정 흐름으로 말하지만 lane owner 후보는 email verification 기본을 기록한다
- **Source file:** `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md`
- **Source quote:**
> 또는 로그인 화면에서 Google로 처음 로그인했을 때 같은 email의 기존 계정이 있으면 'Confirm Link Existing Account' authenticator가 비밀번호 확인을 요구합니다.
- **Counterpart/current quote:**
> SMTP 설정 realm 은 "Verify Existing Account By Email" 이 기본 `ALTERNATIVE` 이므로 admin 이 명시 DISABLE 해야 password 재인증 실행
- **Source lines:** `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md:34`, `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:151`
- **Severity:** High
- **Classification:** `FACT — CONTRADICTION`; authoritative flow owner는 unseen이므로 `NEEDS_CROSS_LANE_AUDIT`.
- **Claim:** 동일 email collision UX의 기본 verification method가 password와 email 두 가지로 서술된다.
- **Real-world assumptions:**
1. realm에 SMTP가 설정돼 있고 built-in first-broker-login flow를 사용한다.
- **Falsifies if:** Email authenticator가 disabled이거나 SMTP가 없다.
- **Verify:** First Broker Login execution config와 realm SMTP 설정을 확인한다.
2. SPA UX 문장이 사용자 안내·테스트 기대값으로 사용된다.
- **Falsifies if:** 문서가 security-first profile 한정이라고 표시돼 있다.
- **Verify:** UX copy와 E2E expected step을 비교한다.
- **Concrete failure mode:** 문서는 password 화면을 기대하지만 실제 realm은 email verification을 보여 테스트·사용자 안내가 어긋나거나, security-first 의도와 달리 secret proof가 생략된다.
- **Falsification condition:** first-broker owner D2가 Email authenticator disable을 고정하고 realm export가 이를 보장한다면 SPA 문장은 성립한다.
- **Required action:** first-broker owner에서 `email-default``password-step-up` profile 중 하나를 선택하고, SPA UX는 그 D-row를 pointer+1줄로만 참조한다.
- **Why this action:** UX, threat model, realm execution config를 같은 선택 조건에 묶는다.
- **Alternatives considered:**
1. **Email verification 기본** — SMTP 운영이 가능하고 마찰을 낮출 때.
2. **Password re-auth 강제** — 기존 local secret 소유 증명이 필요할 때.
3. **조건부 step-up** — 위험도에 따라 email/password를 나눌 때.
4. **기존-account linking 미제공** — duplicate account 비용을 감수할 때.
- **Counterargument:** 이 문장이 이미 security-first profile을 전제한다고 해석할 수 있다. 그러나 선택 조건이 본문에 없고 default flow 설명처럼 쓰였다는 점은 남는다.
- **Verification command and observed output:**
```text
$ grep -nFo -- "또는 로그인 화면에서 Google로 처음 로그인했을 때 같은 email의 기존 계정이 있으면 'Confirm Link Existing Account' authenticator가 비밀번호 확인을 요구합니다." 'raw/branch-notes/feature-keycloak-account-linking-spa-ux.md'
34:또는 로그인 화면에서 Google로 처음 로그인했을 때 같은 email의 기존 계정이 있으면 'Confirm Link Existing Account' authenticator가 비밀번호 확인을 요구합니다.
$ grep -nFo -- 'SMTP 설정 realm 은 "Verify Existing Account By Email" 이 기본 `ALTERNATIVE` 이므로 admin 이 명시 DISABLE 해야 password 재인증 실행' 'raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md'
151:SMTP 설정 realm 은 "Verify Existing Account By Email" 이 기본 `ALTERNATIVE` 이므로 admin 이 명시 DISABLE 해야 password 재인증 실행
```
## 5. `feature-keycloak-account-linking-sub-vs-email.md`
### L1-F05: active Sync Mode decision의 takeover 근거가 current DEM과 반대다
- **Source file:** `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md`
- **Source quote:**
> Sync Mode = IMPORT (first login만). Google 측 email 변경이 Keycloak으로 자동 전파되지 않음 → Scenario B 회피.
- **Counterpart/current quote:**
> **본 branch(takeover)와 직교** — Sync Mode 는 이미 링크된 계정의 attribute(email/name) 최신성(FORCE) vs 안정성(IMPORT) trade-off 이며 link key(sub)를 안 바꾸므로 takeover 무관.
- **Source lines:** `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:137`, `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:154`
- **Severity:** Medium
- **Classification:** `FACT — STALE_SUMMARY + RESTATED_FOREIGN_DECISION`.
- **Claim:** Decisions는 IMPORT가 Scenario B를 방어한다고 말하지만 current D5는 Sync Mode와 takeover가 직교하며 값 owner도 sibling이라고 정정한다.
- **Real-world assumptions:**
1. 운영자가 takeover 방어를 이유로 IMPORT를 선택한다.
- **Falsifies if:** IMPORT는 attribute ownership 정책으로 별도 채택된다.
- **Verify:** realm export와 ADR의 선택 근거를 확인한다.
- **Concrete failure mode:** 보안상 필요하지 않은 IMPORT가 고정돼 Google-authoritative profile freshness 요구를 막거나, FORCE를 선택하면 takeover가 생긴다는 잘못된 경보를 만든다.
- **Falsification condition:** IMPORT가 로컬 attribute 편집 보존이라는 별도 요구로 선택되고 active Decisions의 이유가 갱신되면 finding은 사라진다.
- **Required action:** `:137`을 historical decision으로 이동하고 active D5는 google-claim owner D3 pointer와 freshness/stability 조건만 남긴다.
- **Why this action:** federation key 보안과 attribute synchronization을 독립 축으로 유지한다.
- **Alternatives considered:**
1. **IMPORT** — 로컬 편집을 보존할 때.
2. **FORCE** — Google을 attribute authority로 둘 때.
3. **INHERIT/LEGACY** — realm default를 따르지만 upgrade drift를 감수한다.
4. **Owner-only delegation** — 이 노트는 takeover 영향만 “직교”로 기록한다.
- **Counterargument:** email 충돌의 운영 영향을 줄이기 위해 IMPORT가 여전히 유효할 수 있다. 다만 그 이유는 takeover 방지가 아니라 attribute stability다.
- **Verification command and observed output:**
```text
$ grep -nFo -- 'Sync Mode = IMPORT (first login만). Google 측 email 변경이 Keycloak으로 자동 전파되지 않음 → Scenario B 회피.' 'raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md'
137:Sync Mode = IMPORT (first login만). Google 측 email 변경이 Keycloak으로 자동 전파되지 않음 → Scenario B 회피.
$ grep -nFo -- '**본 branch(takeover)와 직교** — Sync Mode 는 이미 링크된 계정의 attribute(email/name) 최신성(FORCE) vs 안정성(IMPORT) trade-off 이며 link key(sub)를 안 바꾸므로 takeover 무관.' 'raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md'
154:**본 branch(takeover)와 직교** — Sync Mode 는 이미 링크된 계정의 attribute(email/name) 최신성(FORCE) vs 안정성(IMPORT) trade-off 이며 link key(sub)를 안 바꾸므로 takeover 무관.
```
### L1-F06: persistent federation key와 local-account collision locator가 `sub-only` 하나로 합쳐져 있다
- **Source file:** `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md`
- **Source quote:**
> federated identity primary key = Google `sub` claim 만 (email 은 link key 아님) — **본 branch core owned**
- **Counterpart/current quote:**
> OOTB 는 email/username 매칭 → sub-only 원하면 커스텀 authenticator (별도 branch)
- **Source lines:** `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:150`, `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:172`
- **Severity:** High
- **Classification:** `FACT — ambiguous semantics / incompatible scope assumption`; 실패 영향은 `INFERENCE`.
- **Claim:** 이미 연결된 federated identity의 안정 key와, 아직 연결되지 않은 local account 후보를 찾는 locator가 구분되지 않아 D1을 구현하려면 out-of-scope custom authenticator가 필요하다는 결론에 이른다.
- **Real-world assumptions:**
1. 기존 local account에는 아직 Google `sub` mapping이 없다.
- **Falsifies if:** 외부 directory에 local-user↔Google-sub 사전 매핑이 존재한다.
- **Verify:** first-login 전 `FEDERATED_IDENTITY`와 별도 mapping source를 확인한다.
2. OOTB flow가 email/username으로 후보를 찾고 별도 verification을 수행한다.
- **Falsifies if:** 배포판에 sub-based collision authenticator가 있다.
- **Verify:** owner flow와 authenticator source를 확인한다.
- **Concrete failure mode:** “sub-only collision detection”을 그대로 구현하면 기존 local account를 찾지 못해 duplicate user가 생기거나, custom authenticator가 제외돼 D1이 실행 불가능한 정책으로 남는다.
- **Falsification condition:** D1의 “link key”가 오직 persistent federated identity lookup만 의미하고 email candidate discovery+re-auth를 허용한다고 명시하면 모순은 용어 문제로 축소된다.
- **Required action:** 결정을 두 개로 분리한다: `(IdP alias, external sub)`는 persistent federation key, email/username은 existing-account 후보 locator일 수 있으나 link 승인 proof는 아니다.
- **Why this action:** stable identity와 initial discovery의 서로 다른 위협 모델을 분리한다.
- **Alternatives considered:**
1. **Email candidate + re-auth** — OOTB 활용, email은 proof가 아님.
2. **기존 계정 로그인 후 CIAL** — 안정적이나 사용자가 두 단계 수행한다.
3. **사전 directory mapping** — 기업 환경에 맞지만 동기화 운영이 필요하다.
4. **항상 신규 계정 생성** — 단순하지만 account fragmentation을 감수한다.
- **Counterargument:** “primary key”가 DB 저장 key만 뜻했다면 D1 자체는 맞다. 이 경우 custom authenticator 요구를 D1에서 분리하고 용어만 바로잡으면 된다.
- **Verification command and observed output:**
```text
$ grep -nFo -- 'federated identity primary key = Google `sub` claim 만 (email 은 link key 아님) — **본 branch core owned**' 'raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md'
150:federated identity primary key = Google `sub` claim 만 (email 은 link key 아님) — **본 branch core owned**
$ grep -nFo -- 'OOTB 는 email/username 매칭 → sub-only 원하면 커스텀 authenticator (별도 branch)' 'raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md'
172:OOTB 는 email/username 매칭 → sub-only 원하면 커스텀 authenticator (별도 branch)
```
## 6. `feature-keycloak-bff-vs-spa-direct.md`
### L1-F07: 요약 매트릭스가 OAuth 2.1의 조건부 BFF 권고를 일반 권고처럼 표시한다
- **Source file:** `raw/branch-notes/feature-keycloak-bff-vs-spa-direct.md`
- **Source quote:**
> | OAuth 2.1 draft 권고 | (허용) | ✅ (browser-based app) |
- **Counterpart/current quote:**
> public client + PKCE 만이면 SPA Direct 도 표준 허용 — "browser-based app 전반 의무" 아님
- **Source lines:** `raw/branch-notes/feature-keycloak-bff-vs-spa-direct.md:130`, `raw/branch-notes/feature-keycloak-bff-vs-spa-direct.md:165`
- **Severity:** Medium
- **Classification:** `FACT — STALE_SUMMARY`.
- **Claim:** 상단 matrix는 browser-based app이면 BFF가 표준 우위인 것처럼 보이지만 D5는 client credentials 사용 시에만 해당한다고 제한한다.
- **Real-world assumptions:**
1. 독자가 matrix를 architecture choice의 요약 SSOT로 사용한다.
- **Falsifies if:** D5 조건을 반드시 함께 읽도록 표가 연결돼 있다.
- **Verify:** matrix만 떼어 ADR 입력으로 사용해도 조건이 보이는지 확인한다.
- **Concrete failure mode:** public SPA+PKCE가 요구에 맞는데도 “표준 권고”를 이유로 stateful BFF를 선택해 session store·CSRF 통제가 불필요하게 추가된다.
- **Falsification condition:** 조직 보안 정책이 표준과 별개로 BFF를 요구하면 선택은 유지할 수 있지만 표준 근거의 일반화는 여전히 수정 대상이다.
- **Required action:** matrix cell을 “client credentials가 필요한 browser app에서 BFF”로 좁히고 D5 pointer를 붙인다.
- **Why this action:** 표준 조건과 프로젝트 risk preference를 분리한다.
- **Alternatives considered:**
1. **Public SPA + PKCE** — stateless와 multi-client가 중요할 때.
2. **Confidential BFF** — browser token exposure를 줄이고 server session을 운영할 때.
3. **Edge ForwardAuth** — 여러 backend가 공통 ingress 인증을 쓸 때.
4. **Token-mediating backend** — session과 API fan-out을 절충할 때.
- **Counterargument:** 금융·의료 등 내부 정책이 BFF를 요구할 수 있다. 그 경우 근거는 내부 risk decision으로 적어야 한다.
- **Verification command and observed output:**
```text
$ grep -nFo -- '| OAuth 2.1 draft 권고 | (허용) | ✅ (browser-based app) |' 'raw/branch-notes/feature-keycloak-bff-vs-spa-direct.md'
130:| OAuth 2.1 draft 권고 | (허용) | ✅ (browser-based app) |
$ grep -nFo -- 'public client + PKCE 만이면 SPA Direct 도 표준 허용 — "browser-based app 전반 의무" 아님' 'raw/branch-notes/feature-keycloak-bff-vs-spa-direct.md'
165:public client + PKCE 만이면 SPA Direct 도 표준 허용 — "browser-based app 전반 의무" 아님
```
**Single-finding justification:** token holder, session state, Spring client type, mobile split, OAuth 2.1 조건의 5개 항목을 검토했다. lane 내부의 명시적 mismatch는 OAuth 2.1 matrix 요약 1건이다. P2A/token-storage/refresh/audience owner 내용은 unseen이라 cross-lane으로 분리했다.
## 7. `feature-keycloak-docker-compose-stack.md`
### L1-F08: bridge-network 기본값과 app의 `localhost` issuer URL은 함께 동작하지 않는다
- **Source file:** `raw/branch-notes/feature-keycloak-docker-compose-stack.md`
- **Source quote:**
> Spring Boot Resource Server, `issuer-uri=http://localhost:8080/realms/<realm>`
- **Counterpart/current quote:**
> **target env = 단일 EC2 Linux 를 기본 가정하므로 기본값은 `extra_hosts: [host.docker.internal:host-gateway]`** (backend 가 `host.docker.internal:8080` 로 JWKS/issuer 도달).
- **Source lines:** `raw/branch-notes/feature-keycloak-docker-compose-stack.md:138`, `raw/branch-notes/feature-keycloak-docker-compose-stack.md:207`
- **Severity:** High
- **Classification:** `FACT — CONTRADICTION`.
- **Claim:** app container는 `host.docker.internal`로 host-published Keycloak에 접근한다고 선택했지만 Resource Server URL은 app container 자신을 가리키는 `localhost`로 남아 있다.
- **Real-world assumptions:**
1. app과 Keycloak이 별도 bridge containers로 실행된다.
- **Falsifies if:** app이 host process이거나 `network_mode: host`를 쓴다.
- **Verify:** rendered Compose의 network mode를 확인한다.
2. Resource Server가 startup에 `issuer-uri` discovery를 수행한다.
- **Falsifies if:** reachable `jwk-set-uri`를 별도로 두고 issuer validation만 localhost로 한다.
- **Verify:** Spring security property와 startup log의 discovery URL을 확인한다.
- **Concrete failure mode:** app이 자기 container의 `localhost:8080`으로 discovery/JWKS를 호출해 connection refused로 기동 실패하거나 첫 JWT 검증에서 실패한다.
- **Falsification condition:** 실제 default가 `network_mode: host`이고 port-table 무효화를 수용한다면 localhost는 도달 가능하다.
- **Required action:** public issuer와 internal retrieval URL을 분리하거나, browser와 containers가 함께 resolve하는 hostname을 채택한다.
- **Why this action:** token `iss` 일치와 network reachability를 동시에 만족시킨다.
- **Alternatives considered:**
1. **Host network + localhost issuer** — Linux 한정, `ports:` 의미가 사라진다.
2. **Public `issuer-uri` + internal `jwk-set-uri`** — issuer는 유지하고 JWKS만 reachable URL로 가져온다.
3. **공통 DNS hostname** — browser/app/Keycloak이 같은 host를 resolve하도록 한다.
4. **app을 host에서 실행** — 학습은 단순하지만 4-service Compose 목표가 바뀐다.
- **Counterargument:** 이 노트는 iss mismatch 실험을 의도하므로 실패 구성이 교육용일 수 있다. 그러나 P3A “정상 기동 stack”과 “함정 시연” profile을 분리해야 한다.
- **Verification command and observed output:**
```text
$ grep -nFo -- 'Spring Boot Resource Server, `issuer-uri=http://localhost:8080/realms/<realm>`' 'raw/branch-notes/feature-keycloak-docker-compose-stack.md'
138:Spring Boot Resource Server, `issuer-uri=http://localhost:8080/realms/<realm>`
$ grep -nFo -- '**target env = 단일 EC2 Linux 를 기본 가정하므로 기본값은 `extra_hosts: [host.docker.internal:host-gateway]`** (backend 가 `host.docker.internal:8080` 로 JWKS/issuer 도달).' 'raw/branch-notes/feature-keycloak-docker-compose-stack.md'
207:**target env = 단일 EC2 Linux 를 기본 가정하므로 기본값은 `extra_hosts: [host.docker.internal:host-gateway]`** (backend 가 `host.docker.internal:8080` 로 JWKS/issuer 도달).
```
**Single-finding justification:** service topology, DB env, health port, realm import, secret mapping, issuer/network의 6개 항목을 검토했다. 다른 항목은 선택 조건·risk가 함께 적혀 있으며, 함께 선택된 default끼리 양립하지 않는 것은 issuer reachability 1건이다.
## 8. `feature-keycloak-edge-forwardauth-google-federation.md`
### L1-F09: parent D5와 child D1이 같은 `sub` mapping 결정을 각각 소유한다
- **Source file:** `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md`
- **Source quote:**
> 사용자 매핑 식별자 = Google `sub` (영구 식별자), email 은 primary key 금지
- **Ownership quote:**
> 이 브랜치의 D1~D5 가 이들의 설정 SSOT.
- **Counterpart quote:**
> federated identity primary key = Google `sub` claim 만 (email 은 link key 아님) — **본 branch core owned**
- **Source lines:** `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md:195`, `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md:253`, `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:150`
- **Severity:** High
- **Classification:** `FACT — DUAL_OWNERSHIP + RESTATED_FOREIGN_DECISION`.
- **Claim:** 동일 concern인 Google `sub` federation key를 parent D5와 child D1이 모두 owner로 선언한다.
- **Real-world assumptions:**
1. 두 D-row가 같은 persistent mapping key를 뜻한다.
- **Falsifies if:** parent는 pattern requirement, child는 별도 implementation mechanism을 소유한다고 concern을 분리한다.
- **Verify:** concern 이름과 change authority를 owner map에서 비교한다.
- **Concrete failure mode:** child가 candidate-discovery와 `sub` semantics를 정정해도 parent D5가 그대로 남아 참조자가 서로 다른 결론을 가져간다.
- **Falsification condition:** parent D5가 “requirement only”라는 별도 concern ID를 갖고 child D1만 concrete key policy owner임을 명시하면 dual ownership이 해소된다.
- **Required action:** 한 문서만 D-row owner로 남긴다. 권장 구조는 parent가 P1B topology만 소유하고 D5를 child D1 pointer+1줄 요약으로 바꾸는 것이다.
- **Why this action:** child의 위협 모델·evidence 변화가 한 owner에서 전파되게 한다.
- **Alternatives considered:**
1. **Child owner, parent pointer** — 현재 문서 분해와 가장 잘 맞는다.
2. **Parent owner, child 분석-only** — hub가 무거워지지만 owner는 하나다.
3. **Child를 parent로 병합** — drift는 줄지만 문서 깊이가 커진다.
4. **Requirement/mechanism concern 분리** — 두 owner를 유지하되 동일 결정이 아니도록 명명한다.
- **Counterargument:** parent D5가 requirement이고 child D1이 상세라고 볼 수 있다. 그러나 현재 두 문구는 같은 key와 email 금지를 결정하며 경계가 명시되지 않았다.
- **Verification command and observed output:**
```text
$ grep -nFo -- '사용자 매핑 식별자 = Google `sub` (영구 식별자), email 은 primary key 금지' 'raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md'
195:사용자 매핑 식별자 = Google `sub` (영구 식별자), email 은 primary key 금지
$ grep -nFo -- '이 브랜치의 D1~D5 가 이들의 설정 SSOT.' 'raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md'
253:이 브랜치의 D1~D5 가 이들의 설정 SSOT.
$ grep -nFo -- 'federated identity primary key = Google `sub` claim 만 (email 은 link key 아님) — **본 branch core owned**' 'raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md'
150:federated identity primary key = Google `sub` claim 만 (email 은 link key 아님) — **본 branch core owned**
```
### L1-F10: P1B가 “P1A와 동일”이라면서 backend trust를 JWT signature로 바꾼다
- **Source file:** `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md`
- **Source quote:**
> | Backend의 token 검증 | Keycloak signature | 동일 (Google token은 backend까지 안 옴) |
- **Counterpart quote:**
> **백엔드는 JWT 검증을 하지 않고 헤더만 신뢰**
- **Source lines:** `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md:145`, `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md:112`
- **Severity:** High
- **Classification:** `FACT — CONTRADICTION`.
- **Claim:** P1A는 header-only trust를 정의하지만 P1B 비교표는 P1A와 P1B 모두 backend가 Keycloak signature를 검증한다고 요약한다.
- **Real-world assumptions:**
1. P1B가 P1A에 Google leg만 추가하는 변형이다.
- **Falsifies if:** P1B가 defense-in-depth JWT validation을 추가하는 별도 variant다.
- **Verify:** backend dependency/config diff를 확인한다.
2. backend가 한 trust contract를 따라 구현된다.
- **Falsifies if:** route별로 header-only와 bearer validation을 명시적으로 나눈다.
- **Verify:** ingress header/token forwarding과 SecurityFilterChain을 대조한다.
- **Concrete failure mode:** 구현팀 일부는 ingress header만 신뢰하고 다른 일부는 bearer token을 요구해, 같은 보호 route에서 401 또는 spoofing 방어 수준 차이가 발생한다.
- **Falsification condition:** P1B가 새 variant임을 명시하고 “P1A와 동일” 문구를 제거하면 contradiction이 아니다.
- **Required action:** P1 contract를 header-only, JWT-only, 또는 header+JWT 중 하나로 고정하고 P1B는 그 contract를 그대로 참조한다.
- **Why this action:** Google federation 추가와 backend trust 변경을 독립 decision으로 만든다.
- **Alternatives considered:**
1. **Header-only** — backend 코드가 적지만 ingress isolation이 load-bearing이다.
2. **JWT-only** — backend가 self-contained validation을 수행한다.
3. **Header + JWT AND 검증** — 방어가 늘지만 운영·코드 비용도 늘어난다.
4. **Route별 variant** — 유연하지만 routing contract가 필요하다.
- **Counterargument:** oauth2-proxy가 access token을 선택적으로 넘기므로 backend마다 다르게 쓸 수 있다. 그렇다면 “동일 패턴”이 아니라 두 variant를 명명해야 한다.
- **Verification command and observed output:**
```text
$ grep -nFo -- '| Backend의 token 검증 | Keycloak signature | 동일 (Google token은 backend까지 안 옴) |' 'raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md'
145:| Backend의 token 검증 | Keycloak signature | 동일 (Google token은 backend까지 안 옴) |
$ grep -nFo -- '**백엔드는 JWT 검증을 하지 않고 헤더만 신뢰**' 'raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md'
112:**백엔드는 JWT 검증을 하지 않고 헤더만 신뢰**
```
## 9. `feature-keycloak-edge-forwardauth-no-google.md`
### L1-F11: oauth2-proxy와 Traefik ForwardAuth를 대안으로 놓지만 구현안에서는 함께 사용한다
- **Source file:** `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md`
- **Source quote:**
> oauth2-proxy vs Traefik ForwardAuth plugin 선택 기준 정리.
- **Counterpart quote:**
> oauth2-proxy `/oauth2/auth` 로 위임, 2XX=allow + 원본 요청 진행
- **Source lines:** `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md:167`, `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md:227`
- **Severity:** High
- **Classification:** `FACT — CONTRADICTION / incompatible alternatives`.
- **Claim:** D1은 oauth2-proxy와 Traefik ForwardAuth를 교체 가능한 단일 축으로 취급하지만 Traefik middleware는 실제로 oauth2-proxy auth endpoint를 호출한다.
- **Real-world assumptions:**
1. “Traefik ForwardAuth”는 middleware 자체를 뜻하며 OIDC session provider가 아니다.
- **Falsifies if:** 문서가 별도 all-in-one `traefik-forward-auth` 제품을 뜻한다.
- **Verify:** image/name/config와 `forwardAuth.address` target을 확인한다.
- **Concrete failure mode:** “Traefik 선택”만으로 OIDC callback/session 기능이 생긴다고 보고 oauth2-proxy 같은 auth service를 배치하지 않아 로그인 흐름이 성립하지 않는다.
- **Falsification condition:** 정확한 auth provider 제품을 명명하고 Traefik middleware와의 역할을 분리하면 finding은 해소된다.
- **Required action:** 선택 축을 `(Ingress: nginx|Traefik)``(Auth service: oauth2-proxy|다른 OIDC agent)`의 2차원으로 다시 작성한다.
- **Why this action:** middleware와 identity-aware auth service의 책임을 분리한다.
- **Alternatives considered:**
1. **Nginx + oauth2-proxy**`auth_request` 방식.
2. **Traefik + oauth2-proxy**`forwardAuth.address` 방식.
3. **Traefik + 별도 ForwardAuth service** — 대체 provider를 명시한다.
4. **oauth2-proxy reverse-proxy mode** — 별도 ingress subrequest 없이 upstream을 직접 proxy한다.
- **Counterargument:** D1이 “Ingress-Nginx stack과 Traefik stack”을 줄여 쓴 표현일 수 있다. 그러나 현재 제목과 표는 component 대 component 비교로 읽힌다.
- **Verification command and observed output:**
```text
$ grep -nFo -- 'oauth2-proxy vs Traefik ForwardAuth plugin 선택 기준 정리.' 'raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md'
167:oauth2-proxy vs Traefik ForwardAuth plugin 선택 기준 정리.
$ grep -nFo -- 'oauth2-proxy `/oauth2/auth` 로 위임, 2XX=allow + 원본 요청 진행' 'raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md'
227:oauth2-proxy `/oauth2/auth` 로 위임, 2XX=allow + 원본 요청 진행
```
### L1-F12: “브라우저에 access token 없음”과 “access token을 cookie에 저장”이 같은 threat model에 섞여 있다
- **Source file:** `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md`
- **Source quote:**
> **토큰 클라이언트 노출 없음**: `access_token`을 브라우저에 두지 않음 → XSS로 토큰이 탈취될 surface 제거. BFF-like.
- **Counterpart quote:**
> **세션 쿠키 크기**: access_token까지 쿠키에 저장하면 nginx의 기본 4kb 헤더 한도를 넘어 분할 쿠키 처리 로직 필요.
- **Source lines:** `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md:120`, `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md:127`
- **Severity:** Medium
- **Classification:** `FACT — CONTRADICTION / incompatible token-storage assumptions`.
- **Claim:** server-side session과 encrypted cookie session을 구분하지 않아 browser token possession과 JS raw-token readability가 같은 의미로 쓰인다.
- **Real-world assumptions:**
1. oauth2-proxy가 cookie-backed session에 token material을 넣을 수 있다.
- **Falsifies if:** chosen config가 Redis/server-side store만 사용하고 cookie에는 opaque ID만 둔다.
- **Verify:** oauth2-proxy session-store type과 cookie payload 크기를 확인한다.
- **Concrete failure mode:** 보안 설계가 server-side BFF 수준의 token custody를 가정하지만 실제로는 browser-held encrypted cookie가 bearer-equivalent session이 되어 replay·size·rotation 위험이 남는다.
- **Falsification condition:** cookie가 opaque session identifier뿐이고 token은 server store에만 있으면 `:120`의 보관 경계가 성립한다.
- **Required action:** session mode를 결정하고 “raw token JS 접근”, “encrypted cookie 보유”, “server-side token 보관”을 별도 행으로 기술한다.
- **Why this action:** XSS exfiltration, cookie replay, header-size 문제를 각각 맞는 통제로 연결한다.
- **Alternatives considered:**
1. **Redis/server-side session** — token custody가 명확하지만 state store가 필요하다.
2. **Opaque cookie ID + server memory** — 단일 인스턴스 학습에 맞지만 scale-out이 약하다.
3. **Encrypted cookie에 token 포함** — 운영은 단순하지만 size/replay 경계를 문서화해야 한다.
4. **Access token upstream 전달** — backend JWT validation contract가 추가된다.
- **Counterargument:** httpOnly encrypted cookie는 JS가 raw access token을 읽지 못하므로 “직접 노출 없음”이라는 보안 이점은 남는다. 다만 “브라우저에 두지 않음”이라는 물리적 보관 문구는 좁혀야 한다.
- **Verification command and observed output:**
```text
$ grep -nFo -- '**토큰 클라이언트 노출 없음**: `access_token`을 브라우저에 두지 않음 → XSS로 토큰이 탈취될 surface 제거. BFF-like.' 'raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md'
120:**토큰 클라이언트 노출 없음**: `access_token`을 브라우저에 두지 않음 → XSS로 토큰이 탈취될 surface 제거. BFF-like.
$ grep -nFo -- '**세션 쿠키 크기**: access_token까지 쿠키에 저장하면 nginx의 기본 4kb 헤더 한도를 넘어 분할 쿠키 처리 로직 필요.' 'raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md'
127:**세션 쿠키 크기**: access_token까지 쿠키에 저장하면 nginx의 기본 4kb 헤더 한도를 넘어 분할 쿠키 처리 로직 필요.
```
## 10. `feature-keycloak-federation-spa-zero-change.md`
### L1-F13: zero-change는 hosted-login 경로에만 성립하지만 제목·목표는 P2B 전체처럼 읽힌다
- **Source file:** `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md`
- **Source quote:**
> **P2A 코드 그대로 두고 Keycloak에 Google IdP만 추가**해서 SPA가 변경 없이 Google 로그인 가능함을 실증한다.
- **Counterpart quote:**
> SPA 가 link 를 트리거하는 공식 메커니즘 = Client Initiated Account Linking (서명된 redirect URL fabrication)
- **Scope quote:**
> Account Linking 정책 — [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]]
- **Source lines:** `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md:31`, `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md:105`, `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md:53`
- **Severity:** Medium
- **Classification:** `FACT — ambiguous scope / incompatible defaults`.
- **Claim:** hosted Keycloak login button만 쓰면 login entry는 zero-change지만, in-SPA account linking을 포함하면 URL fabrication·hashing·상태 표시 코드가 필요하다.
- **Real-world assumptions:**
1. 상위 P2B 독자가 “Google federation 기능 전체”에 zero-change를 일반화한다.
- **Falsifies if:** 상위 문서도 hosted-login-only로 범위를 명시한다.
- **Verify:** P2B parent의 In/Out scope와 TODO를 확인한다.
- **Concrete failure mode:** account-link button/status 요구가 있는데도 SPA change가 없다고 계획해 CIAL, CSRF state, linked-account read path가 누락된다.
- **Falsification condition:** 제품 요구가 Keycloak-hosted login page에서 Google을 선택하는 것뿐이고 account UX를 제공하지 않으면 zero-change는 성립한다.
- **Required action:** 제목·목표를 “Google IdP sign-in entry zero-change”로 좁히고 account-link 기능별 change matrix를 추가한다.
- **Why this action:** brokering의 login-path 불변식과 선택적 account UX 비용을 함께 보존한다.
- **Alternatives considered:**
1. **Hosted login only** — SPA diff 0.
2. **`idpHint` 버튼** — 작은 SPA 변경으로 Google을 직접 선택한다.
3. **Account Console 위임** — SPA account-link 코드는 줄지만 별도 UX로 이동한다.
4. **In-SPA linking/status** — CIAL과 read API/backend 구현이 필요하다.
- **Counterargument:** 이 branch는 `:53`에서 Account Linking을 명시적으로 제외한다. finding은 branch 내부 구현이 아니라 제목·목표가 상위 P2B 범위로 일반화될 위험을 지적한다.
- **Verification command and observed output:**
```text
$ grep -nFo -- '**P2A 코드 그대로 두고 Keycloak에 Google IdP만 추가**해서 SPA가 변경 없이 Google 로그인 가능함을 실증한다.' 'raw/branch-notes/feature-keycloak-federation-spa-zero-change.md'
31:**P2A 코드 그대로 두고 Keycloak에 Google IdP만 추가**해서 SPA가 변경 없이 Google 로그인 가능함을 실증한다.
$ grep -nFo -- 'SPA 가 link 를 트리거하는 공식 메커니즘 = Client Initiated Account Linking (서명된 redirect URL fabrication)' 'raw/branch-notes/feature-keycloak-account-linking-spa-ux.md'
105:SPA 가 link 를 트리거하는 공식 메커니즘 = Client Initiated Account Linking (서명된 redirect URL fabrication)
$ grep -nFo -- 'Account Linking 정책 — [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]]' 'raw/branch-notes/feature-keycloak-federation-spa-zero-change.md'
53:Account Linking 정책 — [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]]
```
### L1-F14: zero-change token baseline에서 `aud` owner와 기대값이 모호하다
- **Source file:** `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md`
- **Source quote:**
> `aud=<spa-client-id>` 확인
- **Counterpart quote:**
> `aud`=backend/SPA client, `azp`=SPA client → **구조 동일**
- **Source lines:** `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md:78`, `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md:131`
- **Severity:** High
- **Classification:** `FACT — ambiguous default`; P2A audience owner가 unseen이므로 `NEEDS_CROSS_LANE_AUDIT`.
- **Claim:** 동일 token expectation에서 `aud`가 SPA client로 고정되기도 하고 backend 또는 SPA client로 열려 있기도 하다.
- **Real-world assumptions:**
1. SPA와 Resource API가 별도 clients이고 backend가 audience를 강제한다.
- **Falsifies if:** 하나의 client가 SPA와 API audience를 겸하도록 의도됐다.
- **Verify:** realm export의 client/audience mapper와 backend validator를 확인한다.
- **Concrete failure mode:** token `aud`가 SPA client뿐인데 backend가 API audience를 기대하면 401이 발생한다. 반대로 backend가 SPA audience를 수용하면 cross-client token reuse 경계가 약해질 수 있다.
- **Falsification condition:** P2A owner가 `aud=<spa-client-id>`를 명시적으로 선택했고 backend validator도 같은 값을 SSOT로 참조한다면 모호성은 해소된다.
- **Required action:** P2A audience owner D-row를 직접 참조하고 expected claims를 `iss`, `aud`, `azp` 각각 정확한 client ID로 고정한다.
- **Why this action:** zero-change 검증은 기준선이 한 값으로 정의돼야 의미가 있다.
- **Alternatives considered:**
1. **`aud=api-client`, `azp=spa-client`** — SPA/API 분리가 선명하다.
2. **`aud=spa-client`** — 단일-client 학습에는 단순하지만 backend trust를 명시해야 한다.
3. **복수 audience** — 여러 API에 유연하지만 token scope가 넓어진다.
4. **BFF audience** — BFF가 token holder인 별도 pattern이다.
- **Counterargument:** `backend/SPA client`가 환경별 placeholder를 뜻할 수 있다. 그렇다면 zero-change test가 실제 어떤 값을 assert할지 선택 조건을 추가해야 한다.
- **Verification command and observed output:**
```text
$ grep -nFo -- '`aud=<spa-client-id>` 확인' 'raw/branch-notes/feature-keycloak-federation-spa-zero-change.md'
78:`aud=<spa-client-id>` 확인
$ grep -nFo -- '`aud`=backend/SPA client, `azp`=SPA client → **구조 동일**' 'raw/branch-notes/feature-keycloak-federation-spa-zero-change.md'
131:`aud`=backend/SPA client, `azp`=SPA client → **구조 동일**
```
## Grep verification
- Findings: 14
- Verbatim quotes: 30
- 실행한 quote-specific `grep -nFo`: 30
- 비어 있지 않은 관측 결과: 30
- Line corrections: 0
- Dropped quotes/findings: 0
- 모든 finding의 source/counterpart quote가 위 명령 결과와 일치했다.
## Claim Traceability Check
- Claim ID 연결 검사: 10개 branch-note의 DEM 구조를 읽었다. 같은 lane에 counterpart가 있는 decision edge는 의미 대조했으며, raw claim source body와 unseen owner D-row는 고정 slice 밖이므로 `BROKEN_CLAIM_REFERENCE` 여부를 판정하지 않았다.
- Decision Evidence Map: 10개 검토. lane-local semantic drift는 L1-F01~L1-F14에 반영했다.
- UNSUPPORTED_DECISION: 표기 자체만으로 inconsistency로 올리지 않았다. categorical summary/current DEM/owner summary가 어긋난 경우만 finding으로 다뤘다.
- **Repository changes:** 없음. 읽기 전용.
## Cross-Lane Candidates
| Citing file:line | Referenced owner/decision | Status | One-line reason |
|---|---|---|---|
| `raw/branch-notes/feature-authentication-authorization-contract.md:124,219` | `feature-security-operational-baseline``JwtToAuthenticatedUserConverter`, AUTHZ code SSOT | NEEDS_CROSS_LANE_AUDIT | `ROLE_*` authority와 raw role seam의 실제 owner vocabulary 확인 필요. |
| `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md:181` | `feature-keycloak-first-broker-login-flow` D1/D2 | NEEDS_CROSS_LANE_AUDIT | auto-link disable과 Email/Re-auth execution profile의 authoritative D-row 확인 필요. |
| `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md:183` | `feature-keycloak-idp-mappers-claim-to-role` D2 | NEEDS_CROSS_LANE_AUDIT | `email_verified`가 custom gate인지 Trust Email 설정인지 owner 판정 필요. |
| `raw/branch-notes/feature-keycloak-account-linking-spa-ux.md:184` | `feature-keycloak-google-claim-attribute-mapping` D1/D4 | NEEDS_CROSS_LANE_AUDIT | `sub`/email claim mapping과 collision input의 owner summary 확인 필요. |
| `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:211` | `feature-keycloak-first-broker-login-flow` D1/D2/D4 | NEEDS_CROSS_LANE_AUDIT | password/email verification 및 `email_verified=false` 방어 flow가 unseen owner에 위임됨. |
| `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:212` | `feature-keycloak-google-claim-attribute-mapping` D3/D4 | NEEDS_CROSS_LANE_AUDIT | Sync Mode와 email Attribute Importer의 current owner 결정을 확인해야 함. |
| `raw/branch-notes/feature-keycloak-bff-vs-spa-direct.md:226-229` | P2A parent D1, token-storage D1/D5, refresh D1/D2, audience D1/D4 | NEEDS_CROSS_LANE_AUDIT | SPA Direct 기준선 네 축이 본 lane 밖 owner에 의존함. |
| `raw/branch-notes/feature-keycloak-docker-compose-stack.md:236,240` | `feature-keycloak-single-ec2-no-google` D3; `feature-keycloak-iss-claim-hostname-mismatch` | NEEDS_CROSS_LANE_AUDIT | public issuer/internal reachability contract의 authoritative network profile 확인 필요. |
| `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md:252` | root `feature-keycloak-patterns` F3/F4 | NEEDS_CROSS_LANE_AUDIT | shared realm/client와 secret ownership이 P1B Google client 설계 전제임. |
| `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md:253` | idp-client, first-broker, claim-mapping child owners | NEEDS_CROSS_LANE_AUDIT | parent D1-D5와 child owner 경계를 전체 hierarchy에서 확인해야 함. |
| `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md:248` | `feature-keycloak-header-spoofing-defense` D1/D3/D4 | NEEDS_CROSS_LANE_AUDIT | header-only trust의 load-bearing network isolation 결정이 unseen owner에 있음. |
| `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md:249-250` | oauth2-proxy flow D2; nginx auth-request D2/D3/D5 | NEEDS_CROSS_LANE_AUDIT | P1A hub가 세부 config를 재진술했는지 owner D-row와 의미 대조 필요. |
| `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md:145` | `feature-keycloak-internal-spa-direct-no-google` D3 | NEEDS_CROSS_LANE_AUDIT | `iss`/signature/expiry/audience baseline과 정확한 `aud` 값을 확인해야 함. |
| `raw/branch-notes/feature-keycloak-federation-spa-zero-change.md:146-149` | P2B parent, first-broker, claim-mapping, three-leg-trust owners | NEEDS_CROSS_LANE_AUDIT | zero-change 전제가 현재 delegated owners의 D-row와 맞는지 확인 필요. |
```wiki-stats
agent: wiki-research-lane
found: 10
processed: 10
dropped: 0
```
@@ -0,0 +1,477 @@
## Lane Inventory
| Path | Status | Evidence Lines | Extracted Facts |
| --- | --- | --- | --- |
| `raw/branch-notes/feature-keycloak-first-broker-login-flow.md` | READ_FULL | 1261 | D1/D2 Confirm Link 소유증명, D4 silent auto-link 차단, hard-reject SPI 별도 트랙 |
| `raw/branch-notes/feature-keycloak-google-claim-attribute-mapping.md` | READ_FULL | 1238 | attribute-mapping owner, D3 IdP default IMPORT, D5 role mapping 위임 |
| `raw/branch-notes/feature-keycloak-google-redirect-uri-policy.md` | READ_FULL | 1261 | exact redirect URI, basic-scope 예외, JavaScript origins 비움 |
| `raw/branch-notes/feature-keycloak-header-spoofing-defense.md` | READ_FULL | 1255 | K8s NetworkPolicy·EC2 SG·shared secret·trusted proxy 방어 경계 |
| `raw/branch-notes/feature-keycloak-https-termination-caddy-nginx.md` | READ_FULL | 1284 | TLS 종단 대안과 명시적 HSTS 구성 |
| `raw/branch-notes/feature-keycloak-idp-brokering-google-client.md` | READ_FULL | 1251 | Google IdP client, `trustEmail=false`, callback·origin 위임 |
| `raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role.md` | READ_FULL | 1148 | role/RBAC owner, D1 FORCE, D2 email verification 강제 주장 |
| `raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md` | READ_FULL | 1512 | P2B composition hub, D8 zero-change brokering, D10 SMTP 사실, foreign-decision 상세 재진술 |
| `raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md` | READ_FULL | 1416 | P2A SPA Direct 정의, D2 refresh-token 저장, D4 brokering 비교 |
| `raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch.md` | READ_FULL | 1287 | issuer/JWKS 분리 대안, 미승인 F, 외부 owner 충돌 명시 |
## File 1 — `feature-keycloak-first-broker-login-flow.md`
### L2-F01: Confirm Link 보장의 버전 전제가 owner에 반영되지 않음
- **Severity:** High
- **Failure modes:** `STALE_SUMMARY`, `UNSUPPORTED_DECISION`
- **Source files:** first-broker-login-flow, idp-brokering-google-client, internal-spa-direct-google-federation
- **Original goal:**
- 인용:
```text
core 차단(D1+D2)은 `trustEmail`·`email_verified` 검증과 무관하게 성립
```
- 위치: `raw/branch-notes/feature-keycloak-first-broker-login-flow.md:135`
- **Current state:**
- 인용:
```text
이는 `trustEmail=false`+email 인증 상태에서도 Confirm Link 흐름이 우회 가능했던 실제(패치완료) 취약점
```
- 위치: `raw/branch-notes/feature-keycloak-idp-brokering-google-client.md:214`
- 인용:
```text
`CVE-2026-9087` 은 **raw 에 아카이브된 출처가 없다**
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md:338`
- **Claim:** 문서 상태에 관한 FACT는 세 노트가 서로 다른 보장 강도를 제공한다는 것이다. 실제 취약점·영향 버전은 아직 `INFERENCE/UNVERIFIED`다.
- **Assumptions:** F의 취약점 보고가 정확하고 배포 버전이 영향 범위일 때만 owner의 무조건부 보장이 실패한다.
- **Concrete failure:** 구현자가 line 135만 소비하면 패치 상태를 확인하지 않고 Confirm Link를 충분한 차단으로 취급할 수 있다.
- **Falsification:** 공식 Keycloak advisory가 해당 흐름에 영향이 없다고 밝히거나, 배포 버전이 영향 범위 밖임을 증명하면 보안 충돌은 사라진다.
- **Required action / synthesis recommendation:** 공식 advisory를 raw로 보존한 뒤 owner D4에 버전 조건을 추가한다. 그 전에는 F의 “실제 취약점” 표현을 `needs-confirmation`으로 낮추고, owner에는 미검증 version gate만 둔다.
- **Why:** 보안 owner의 보장은 소비자가 한 줄만 읽어도 유효 조건을 알 수 있어야 한다.
- **Alternatives:** ① 패치 버전 allowlist 게이트, ② 취약 버전 배포 차단, ③ 별도 custom SPI 방어, ④ Google brokering 비활성화.
- **Counterargument:** 현재 지원 버전만 사용하면 Confirm Link 보장은 유효할 수 있다. 그렇더라도 owner 문서에 적용 버전 조건을 적으면 이 반론과 충돌하지 않는다.
- **Verification:**
```bash
grep -nFo -- 'core 차단(D1+D2)은 `trustEmail`·`email_verified` 검증과 무관하게 성립' raw/branch-notes/feature-keycloak-first-broker-login-flow.md
# Observed: 135:core 차단(D1+D2)은 `trustEmail`·`email_verified` 검증과 무관하게 성립
grep -nFo -- '이는 `trustEmail=false`+email 인증 상태에서도 Confirm Link 흐름이 우회 가능했던 실제(패치완료) 취약점' raw/branch-notes/feature-keycloak-idp-brokering-google-client.md
# Observed: 214:이는 `trustEmail=false`+email 인증 상태에서도 Confirm Link 흐름이 우회 가능했던 실제(패치완료) 취약점
grep -nFo -- '`CVE-2026-9087` 은 **raw 에 아카이브된 출처가 없다**' raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md
# Observed: 338:`CVE-2026-9087` 은 **raw 에 아카이브된 출처가 없다**
```
### L2-F02: `email_verified=false` 정책이 silent-link 차단과 hard-reject 사이에서 갈림
- **Severity:** High
- **Failure modes:** `CONTRADICTION`, `RESTATED_FOREIGN_DECISION`
- **Source files:** first-broker-login-flow, idp-mappers-claim-to-role, internal-spa-direct-google-federation
- **Original goal:**
- 인용:
```text
`email_verified=false` 계정의 **silent auto-link 차단**
```
- 위치: `raw/branch-notes/feature-keycloak-first-broker-login-flow.md:135`
- 인용:
```text
`email_verified=false` 를 flow 진입 즉시 *hard-reject* 하려면 커스텀 SPI authenticator 필요 → 별도 트랙
```
- 위치: `raw/branch-notes/feature-keycloak-first-broker-login-flow.md:135`
- **Current state:**
- 인용:
```text
`email_verified=true` 강제 (Attribute Importer + First Broker Login Flow custom step)
```
- 위치: `raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role.md:120`
- 인용:
```text
Google `email_verified=false` 계정 | 링크/생성 거부
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md:387`
- **Claim:** owner D4는 소유증명 없는 auto-link만 차단한다. G와 H는 별도 SPI가 구현된 것처럼 전체 링크·생성을 거부한다고 요약한다.
- **Assumptions:** slice 안에는 hard-reject custom authenticator의 별도 owner나 구현 증거가 없다.
- **Concrete failure:** `email_verified=false` 사용자가 실제로는 Confirm Link 또는 신규 계정 흐름에 진입하는데, 테스트가 즉시 거부를 기대해 보안 정책과 구현이 어긋난다.
- **Falsification:** 별도 owner가 custom authenticator 구현과 E2E 거부 결과를 제시하면 G/H의 정책이 성립한다.
- **Required action / synthesis recommendation:** G/H를 owner D4의 “silent-link prevention only” 포인터로 축소한다. hard-reject가 필요하면 SPI branch를 단일 owner로 만들고 그 결정이 확정된 뒤 세 문서를 갱신한다.
- **Why:** 계정 연결 방어와 모든 계정 생성 거부는 서로 다른 정책·구현 계약이다.
- **Alternatives:** ① Confirm Link만 유지, ② custom SPI로 즉시 거부, ③ 통제된 enterprise IdP만 허용, ④ Google federation 자체를 사용하지 않음.
- **Counterargument:** Google이 일반적으로 verified email을 제공하므로 차이가 드물 수 있다. 발생 빈도와 정책 보장은 별개이며, 문서는 미검증 claim의 실제 동작을 정의해야 한다.
- **Verification:**
```bash
grep -nFo -- '`email_verified=false` 계정의 **silent auto-link 차단**' raw/branch-notes/feature-keycloak-first-broker-login-flow.md
# Observed: 135:`email_verified=false` 계정의 **silent auto-link 차단**
grep -nFo -- '`email_verified=false` 를 flow 진입 즉시 *hard-reject* 하려면 커스텀 SPI authenticator 필요 → 별도 트랙' raw/branch-notes/feature-keycloak-first-broker-login-flow.md
# Observed: 135:`email_verified=false` 를 flow 진입 즉시 *hard-reject* 하려면 커스텀 SPI authenticator 필요 → 별도 트랙
grep -nFo -- '`email_verified=true` 강제 (Attribute Importer + First Broker Login Flow custom step)' raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role.md
# Observed: 120:`email_verified=true` 강제 (Attribute Importer + First Broker Login Flow custom step)
grep -nFo -- 'Google `email_verified=false` 계정 | 링크/생성 거부' raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md
# Observed: 387:Google `email_verified=false` 계정 | 링크/생성 거부
```
## File 2 — `feature-keycloak-google-claim-attribute-mapping.md`
### L2-F03: IMPORT owner와 FORCE 소비자의 적용 범위가 불명확함
- **Severity:** Medium
- **Failure modes:** `AMBIGUOUS_OWNERSHIP`, 조건부 `CONTRADICTION`
- **Source files:** google-claim-attribute-mapping, idp-mappers-claim-to-role
- **Original goal:**
- 인용:
```text
그쪽이 attribute-mapping **owner**
```
- 위치: `raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role.md:19`
- **Current state:**
- 인용:
```text
| D1 | Sync Mode = `FORCE`
```
- 위치: `raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role.md:119`
- 인용:
```text
| D3 | Sync Mode = IMPORT (first login 시점 매핑만)
```
- 위치: `raw/branch-notes/feature-keycloak-google-claim-attribute-mapping.md:130`
- 인용:
```text
IdP-level `Sync Mode` = 모든 mapper default
```
- 위치: `raw/branch-notes/feature-keycloak-google-claim-attribute-mapping.md:130`
- **Claim:** B가 IdP-level default를 IMPORT로 소유하지만, G의 D1은 FORCE가 global인지 role mapper override인지 밝히지 않는다.
- **Assumptions:** G D1이 IdP-level 설정을 가리키면 두 값은 공존할 수 없다. mapper-level override이고 Keycloak이 해당 override를 지원한다면 공존 가능하다.
- **Concrete failure:** 운영자가 동일 Admin UI 필드를 두 지침대로 번갈아 설정하거나, 모든 attribute를 로그인마다 덮어써 사용자 편집값을 잃을 수 있다.
- **Falsification:** G D1을 특정 role mapper의 override로 명시하고 공식 근거·Admin UI 실측으로 override 우선순위를 확인하면 충돌이 해소된다.
- **Required action / synthesis recommendation:** B D3를 IdP default owner로 유지하고, G D1은 role mapper 전용 override로 증명·범위화하거나 제거한다.
- **Why:** fresh role과 보존해야 할 profile attribute는 서로 다른 갱신 정책을 가질 수 있으나 설정 계층을 분명히 해야 한다.
- **Alternatives:** ① global IMPORT + role mapper FORCE, ② 전부 IMPORT, ③ 전부 FORCE, ④ role 계산을 별도 authorization 계층으로 이동.
- **Counterargument:** role freshness에는 FORCE가 합리적이다. 이 finding은 FORCE 자체가 아니라 어느 설정 계층에 적용하는지 불명확한 점을 지적한다.
- **Verification:**
```bash
grep -nFo -- '그쪽이 attribute-mapping **owner**' raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role.md
# Observed: 19:그쪽이 attribute-mapping **owner**
grep -nFo -- '| D1 | Sync Mode = `FORCE`' raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role.md
# Observed: 119:| D1 | Sync Mode = `FORCE`
grep -nFo -- '| D3 | Sync Mode = IMPORT (first login 시점 매핑만)' raw/branch-notes/feature-keycloak-google-claim-attribute-mapping.md
# Observed: 130:| D3 | Sync Mode = IMPORT (first login 시점 매핑만)
grep -nFo -- 'IdP-level `Sync Mode` = 모든 mapper default' raw/branch-notes/feature-keycloak-google-claim-attribute-mapping.md
# Observed: 130:| D3 | Sync Mode = IMPORT (first login 시점 매핑만)
# Observed: 173:IdP-level `Sync Mode` = 모든 mapper default
```
## File 3 — `feature-keycloak-google-redirect-uri-policy.md`
### L2-F04: basic-scope 예외를 옛 test-user 한도 설명이 덮음
- **Severity:** Low
- **Failure mode:** `STALE_SUMMARY`
- **Source file:** google-redirect-uri-policy
- **Original goal:**
- 인용:
```text
이 basic identity scope 조합은 test-user allowlist 등록·100명 상한·7일 만료·unverified 경고가 모두 면제됨
```
- 위치: `raw/branch-notes/feature-keycloak-google-redirect-uri-policy.md:143`
- **Current state:**
- 인용:
```text
학습용은 `openid email profile`만 사용 → unverified로 100명 test users까지 동작
```
- 위치: `raw/branch-notes/feature-keycloak-google-redirect-uri-policy.md:92`
- **Claim:** D5의 정정 이후에도 TODO가 이전 한도 모델을 유지한다.
- **Assumptions:** 요청 scope가 정말 `openid email profile`에 한정된다.
- **Concrete failure:** 불필요한 test-user 등록·인원 관리 절차를 구현 계획에 넣고, 서로 다른 Google quota를 혼동한다.
- **Falsification:** sensitive/restricted scope가 실제로 추가되면 TODO의 한도 설명이 별도 시나리오에서 다시 필요하다.
- **Required action / synthesis recommendation:** line 92를 D5 포인터로 바꾸거나 삭제하고, sensitive-scope 시나리오는 별도 조건문으로 분리한다.
- **Why:** 한 파일 안의 TODO가 authoritative D-row보다 오래된 운영 지침을 제공한다.
- **Alternatives:** ① TODO 삭제 후 D5 포인터, ② 최신 예외 설명으로 갱신, ③ sensitive-scope 실험용 별도 TODO로 범위화.
- **Counterargument:** Testing 상태의 다른 quota를 기억시키려는 문장일 수 있다. 그렇다면 basic identity scope와 다른 quota임을 명시해야 한다.
- **Verification:**
```bash
grep -nFo -- '이 basic identity scope 조합은 test-user allowlist 등록·100명 상한·7일 만료·unverified 경고가 모두 면제됨' raw/branch-notes/feature-keycloak-google-redirect-uri-policy.md
# Observed: 143:이 basic identity scope 조합은 test-user allowlist 등록·100명 상한·7일 만료·unverified 경고가 모두 면제됨
grep -nFo -- '학습용은 `openid email profile`만 사용 → unverified로 100명 test users까지 동작' raw/branch-notes/feature-keycloak-google-redirect-uri-policy.md
# Observed: 92:학습용은 `openid email profile`만 사용 → unverified로 100명 test users까지 동작
```
### L2-F05: JavaScript origins가 owner와 consumer에서 다름
- **Severity:** Medium
- **Failure modes:** `STALE_SUMMARY`, `RESTATED_FOREIGN_DECISION`
- **Source files:** google-redirect-uri-policy, idp-brokering-google-client
- **Original goal:**
- 인용:
```text
| Authorized JavaScript origins | (비움) |
```
- 위치: `raw/branch-notes/feature-keycloak-google-redirect-uri-policy.md:167`
- **Current state:**
- 인용:
```text
Authorized JavaScript origins: `https://<kc-host>`
```
- 위치: `raw/branch-notes/feature-keycloak-idp-brokering-google-client.md:81`
- **Claim:** redirect-policy owner는 server-side flow 때문에 origins를 비우지만, Google-client TODO는 외부 Keycloak host를 등록하라고 한다.
- **Assumptions:** Google OAuth 교환은 Keycloak 서버가 수행하며 브라우저 Google SDK를 직접 사용하지 않는다.
- **Concrete failure:** 불필요한 browser origin을 허용하고, 동일 client를 재현하는 사람마다 다른 Console 설정을 만든다.
- **Falsification:** Google Console 실측에서 해당 client type이 origin을 요구하거나 실제 browser-side Google SDK 흐름이 추가되면 host 등록이 필요하다.
- **Required action / synthesis recommendation:** F의 origin 값을 삭제하고 C D6 포인터로 교체한다. 브라우저 흐름이 생기면 별도 client와 owner를 둔다.
- **Why:** callback URI와 JavaScript origin은 다른 정책 필드이며, consumer가 owner 값을 재정의하면 drift가 생긴다.
- **Alternatives:** ① 비워 둠, ② browser flow 도입 시 최소 origin만 등록, ③ browser용 Google client 분리.
- **Counterargument:** host 하나를 미리 등록해도 당장 실패하지 않을 수 있다. 그러나 사용하지 않는 권한 표면과 재현 차이는 남는다.
- **Verification:**
```bash
grep -nFo -- '| Authorized JavaScript origins | (비움) |' raw/branch-notes/feature-keycloak-google-redirect-uri-policy.md
# Observed: 167:| Authorized JavaScript origins | (비움) |
grep -nFo -- 'Authorized JavaScript origins: `https://<kc-host>`' raw/branch-notes/feature-keycloak-idp-brokering-google-client.md
# Observed: 81:Authorized JavaScript origins: `https://<kc-host>`
```
## File 4 — `feature-keycloak-header-spoofing-defense.md`
**0 independent findings.** D3(K8s)과 D4(EC2)는 환경별 대안이며 동시에 적용해야 하는 상충 결정이 아니다. D5는 두 환경의 보조 계층, D6는 Keycloak proxy 신뢰 경계로 분리돼 있다. 다만 부모·proxy owner가 slice 밖이므로 lines 202206은 `NEEDS_CROSS_LANE_AUDIT`로 이관한다.
## File 5 — `feature-keycloak-https-termination-caddy-nginx.md`
### L2-F06: Caddy HSTS 기본값 설명이 authoritative 구성과 상충함
- **Severity:** Medium
- **Failure mode:** `STALE_SUMMARY`
- **Source file:** https-termination-caddy-nginx
- **Original goal:**
- 인용:
```text
**명시 필요** (default 로 단정 금지)
```
- 위치: `raw/branch-notes/feature-keycloak-https-termination-caddy-nginx.md:173`
- **Current state:**
- 인용:
```text
Caddy는 디폴트로 HTTPS redirect + HSTS
```
- 위치: `raw/branch-notes/feature-keycloak-https-termination-caddy-nginx.md:43`
- **Claim:** 구현 표는 HSTS를 명시하라고 정정했지만 초기 설계 답변은 여전히 자동 적용이라고 말한다.
- **Assumptions:** 별도 global Caddy 설정이나 upstream edge가 HSTS를 주입하지 않는다.
- **Concrete failure:** line 43을 따라 구성하면 HTTPS redirect는 동작해도 HSTS 헤더가 빠질 수 있다.
- **Falsification:** 대상 Caddy 버전의 공식 문서나 응답 헤더 실측이 자동 HSTS를 입증하면 line 43이 맞다.
- **Required action / synthesis recommendation:** line 43을 “automatic HTTPS redirect, HSTS explicit”으로 정정하고 D2/D5에만 값을 소유시킨다.
- **Why:** TLS 자동화와 HSTS 정책은 서로 다른 기능이다.
- **Alternatives:** ① Caddy `header` 명시, ② nginx에서 명시, ③ Cloudflare edge에서 단일 owner로 주입, ④ ephemeral dev에서는 의도적으로 생략하고 범위를 표시.
- **Counterargument:** 학습 환경에서는 HSTS가 필요하지 않을 수 있다. 그렇다면 dev-only 생략 결정으로 적어야지 default 기능으로 설명하면 안 된다.
- **Verification:**
```bash
grep -nFo -- 'Caddy는 디폴트로 HTTPS redirect + HSTS' raw/branch-notes/feature-keycloak-https-termination-caddy-nginx.md
# Observed: 43:Caddy는 디폴트로 HTTPS redirect + HSTS
grep -nFo -- '**명시 필요** (default 로 단정 금지)' raw/branch-notes/feature-keycloak-https-termination-caddy-nginx.md
# Observed: 173:**명시 필요** (default 로 단정 금지)
```
## File 6 — `feature-keycloak-idp-brokering-google-client.md`
**0 additional independent findings.** 이 파일은 L2-F01의 미아카이브 CVE 주장과 L2-F05의 stale JavaScript-origin TODO에 직접 참여한다. 그 외 D6의 `trustEmail=false` 값 선택과 default 미확정 구분은 내부적으로 일관된다. 부모가 정하는 `<kc-host>`는 slice 밖이므로 line 192를 교차 lane 후보로 남긴다.
## File 7 — `feature-keycloak-idp-mappers-claim-to-role.md`
**0 additional independent findings.** 이 파일의 실제 상충은 L2-F02의 hard-reject 주장과 L2-F03의 FORCE 적용 범위로 모두 수거했다. role/RBAC만 소유한다는 line 19와 attribute-level D1/D2가 함께 남아 있는 것이 두 finding의 공통 원인이다.
## File 8 — `feature-keycloak-internal-spa-direct-google-federation.md`
### L2-F07: Reference-Only를 선언하면서 foreign decision 상세를 의도적으로 유지함
- **Severity:** Medium
- **Failure mode:** `RESTATED_FOREIGN_DECISION`
- **Source file:** internal-spa-direct-google-federation
- **Original goal:**
- 인용:
```text
본 노트는 `rules/consistency-contract.md` 의 **Reference-Only** 규약에 따라 포인터 + 1줄 요약으로만 인용한다
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md:22`
- **Current state:**
- 인용:
```text
**유지 + 위임 고지** — 학습용 흐름 이해에 필요한 개요이므로 삭제하지 않고
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md:443`
- **Claim:** 위임 고지는 Reference-Only의 “포인터 + 1줄” 제한을 대체하지 않는다. 파일 스스로 detailed restatement를 식별하면서 유지하기로 했다.
- **Assumptions:** 현재 consistency-contract의 Single-Owner/Reference-Only 규칙이 그대로 적용된다.
- **Concrete failure:** attribute mapping 또는 trust-chain owner가 바뀔 때 H의 상세 표가 같은 변경을 수동으로 따라가지 못해 stale copy가 된다.
- **Falsification:** 규칙이 synchronized derived view를 허용하도록 바뀌고, owner 변경 때 자동 검증되는 생성물이 되면 상세 유지가 가능하다.
- **Required action / synthesis recommendation:** branch-note에서는 owner 포인터와 한 줄 요약만 남긴다. 학습용 상세 설명은 canonical concept나 owner에서 생성되는 view로 이동한다.
- **Why:** “owner가 우선”이라는 주석은 독자가 stale 상세를 실행하는 것을 막지 못한다.
- **Alternatives:** ① 한 줄 pointer-only, ② canonical explainer로 이동, ③ owner 기반 자동 생성 view, ④ H가 새 owner가 되고 기존 owner를 pointer로 전환.
- **Counterargument:** composition hub에는 end-to-end 이해를 위한 상세가 필요하다. 그 요구는 canonical explainer나 생성 view로 충족할 수 있다.
- **Verification:**
```bash
grep -nFo -- '본 노트는 `rules/consistency-contract.md` 의 **Reference-Only** 규약에 따라 포인터 + 1줄 요약으로만 인용한다' raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md
# Observed: 22:본 노트는 `rules/consistency-contract.md` 의 **Reference-Only** 규약에 따라 포인터 + 1줄 요약으로만 인용한다
grep -nFo -- '**유지 + 위임 고지** — 학습용 흐름 이해에 필요한 개요이므로 삭제하지 않고' raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md
# Observed: 443:**유지 + 위임 고지** — 학습용 흐름 이해에 필요한 개요이므로 삭제하지 않고
```
### L2-F08: brokering zero-change payload에 두 owner가 존재함
- **Severity:** Medium
- **Failure mode:** `DUAL_OWNERSHIP`
- **Source files:** internal-spa-direct-google-federation, internal-spa-direct-no-google
- **Original goal:**
- 인용:
```text
P2B 의 federation 은 **Keycloak 레이어에서만** broker
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md:324`
- **Current state:**
- 인용:
```text
brokering 은 SPA/Backend **코드 0줄**로 얹히는 cross-cutting
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md:282`
- 인용:
```text
**이중 주장 (`/sync` 수거 대상)**
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md:402`
- **Claim:** H D8과 I D4가 동일한 zero-change 결론을 각각 결정 payload로 보유하며 H도 이를 이중 주장으로 인정한다.
- **Assumptions:** “Keycloak-only broker”와 “SPA/backend code zero”가 같은 구조적 보장의 두 표현이다.
- **Concrete failure:** `kc_idp_hint`, 로그인 버튼 노출 기본값, SPA 변경 조건이 진화할 때 두 D-row가 다른 경계를 가질 수 있다.
- **Falsification:** I D4를 범위 선택과 H D8 pointer만 남기도록 축소하면 소유권이 분리된다.
- **Required action / synthesis recommendation:** H D8을 composition owner로 지정하고 I D4를 포인터로 전환한다. 별도 `federation-spa-zero-change` owner를 택하려면 먼저 그 unseen branch와 교차 감사를 수행한다.
- **Why:** 아키텍처 불변성은 여러 구현 결정의 전제가 되므로 한 곳에서 조건과 예외를 소유해야 한다.
- **Alternatives:** ① H D8 owner, ② I D4 owner, ③ zero-change 전용 child owner, ④ project hub의 고정 결정으로 승격.
- **Counterargument:** baseline과 extension 양쪽에 같은 설명이 있어야 읽기 쉽다. pointer와 한 줄 요약이면 가독성을 유지하면서 owner를 하나로 둘 수 있다.
- **Verification:**
```bash
grep -nFo -- 'P2B 의 federation 은 **Keycloak 레이어에서만** broker' raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md
# Observed: 324:P2B 의 federation 은 **Keycloak 레이어에서만** broker
grep -nFo -- 'brokering 은 SPA/Backend **코드 0줄**로 얹히는 cross-cutting' raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md
# Observed: 282:brokering 은 SPA/Backend **코드 0줄**로 얹히는 cross-cutting
grep -nFo -- '**이중 주장 (`/sync` 수거 대상)**' raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md
# Observed: 402:**이중 주장 (`/sync` 수거 대상)**
```
L2-F01과 L2-F02도 이 파일의 version gate 및 `email_verified` edge table에 직접 적용된다. D10의 SMTP 소유권은 실제 docker-compose owner가 slice 밖이라 finding으로 확정하지 않고 교차 lane 후보로 남겼다.
## File 9 — `feature-keycloak-internal-spa-direct-no-google.md`
### L2-F09: SPA Direct 정의 안에 BFF refresh-cookie 선택을 넣음
- **Severity:** High
- **Failure modes:** `INCOMPATIBLE_ASSUMPTIONS`, `RESTATED_FOREIGN_DECISION`
- **Source file:** internal-spa-direct-no-google
- **Original goal:**
- 인용:
```text
**Edge proxy 없음.**
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md:17`
- **Current state:**
- 인용:
```text
**secure httpOnly cookie** (BFF 변형)
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md:188`
- 인용:
```text
| D2 | refresh token 저장 위치 = secure httpOnly cookie 권장
```
- 위치: `raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md:280`
- **Claim:** `INFERENCE` — HttpOnly refresh cookie를 발급·회전·소비하려면 token-mediating server가 필요하다. 정의된 backend는 Resource Server일 뿐이고 edge도 없으므로 D2 권장은 현재 패턴 안에서 실행할 주체가 없다.
- **Assumptions:** 브라우저 SPA는 HttpOnly cookie 값을 읽을 수 없고, Keycloak이 이 프로젝트 origin용 refresh cookie를 대신 관리하도록 설계되지 않았다.
- **Concrete failure:** 구현자가 Resource Server에 token exchange·cookie issuance·CSRF 방어를 뒤늦게 추가하면서 P2A를 사실상 BFF/AP3로 바꾸지만 문서·threat model은 SPA Direct로 남는다.
- **Falsification:** 별도 mediator 없이 Keycloak/browser adapter가 usable HttpOnly refresh cookie를 제공한다는 공식 근거와 E2E 결과가 있거나, 이미 mediator가 아키텍처에 포함돼 있음을 증명하면 성립하지 않는다.
- **Required action / synthesis recommendation:** P2A 기본은 memory-only로 두거나, HttpOnly 선택 시 명시적으로 token-mediating/BFF 패턴으로 전환하고 CSRF·rotation owner를 함께 연결한다. unseen token-storage owner도 교차 감사해야 한다.
- **Why:** storage 선택이 토큰 보유 주체와 인증 아키텍처를 바꾸고 있다.
- **Alternatives:** ① memory-only, ② Web Worker 격리, ③ 최소 token-mediator 추가, ④ full BFF, ⑤ browser storage 사용 후 XSS 위험 수용.
- **Counterargument:** reload 후 세션 유지에는 HttpOnly cookie UX가 낫다. 그 장점은 mediator 도입 비용과 CSRF 계약을 명시적으로 수용할 때만 비교할 수 있다.
- **Verification:**
```bash
grep -nFo -- '**Edge proxy 없음.**' raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md
# Observed: 17:**Edge proxy 없음.**
grep -nFo -- '**secure httpOnly cookie** (BFF 변형)' raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md
# Observed: 188:**secure httpOnly cookie** (BFF 변형)
grep -nFo -- '| D2 | refresh token 저장 위치 = secure httpOnly cookie 권장' raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md
# Observed: 280:| D2 | refresh token 저장 위치 = secure httpOnly cookie 권장
```
L2-F08의 zero-change 이중 소유도 이 파일 D4에 적용된다.
## File 10 — `feature-keycloak-iss-claim-hostname-mismatch.md`
**0 slice-internal findings.** D6의 `jwk-set-uri` 대안은 부모와 audience-validator owner가 승인하기 전까지 권고안으로만 유지되고, 현재 기본을 C로 명시해 파일 내부에서 두 구성을 동시에 채택하지 않는다. `KC-HOST-C6`는 미아카이브 claim 후보라고 lines 142·230·247에 명시돼 Supporting Claim으로 위장하지 않는다. 외부 owner 충돌은 lines 204209의 교차 lane 후보로 분리한다.
## Synthesis
이 slice에는 High 3건, Medium 5건, Low 1건의 정합 gap이 있다. 가장 직접적인 보안 오해는 `email_verified=false`를 silent-link 차단과 hard-reject로 혼용한 L2-F02다. L2-F01의 버전 위험은 파급은 크지만 공식 advisory가 raw에 없어 실재·영향 범위를 아직 FACT로 취급할 수 없다. IMPORT/FORCE, JavaScript origins, HSTS는 owner 값보다 오래된 소비자·초기 설명이 남은 drift다. P2B는 Reference-Only를 선언하면서 상세 foreign decisions를 유지하고, zero-change 결론도 P2A와 공동 소유한다. P2A의 HttpOnly-cookie 권장은 SPA Direct와 BFF의 경계를 흐리므로 구현 전 owner 결정이 필요하다. Header-spoofing과 issuer-mismatch 파일은 slice 내부에서는 대안 조건과 미승인 상태를 구분하고 있다.
## Inferences
1. CVE 보고가 정확할 때만 owner의 무조건부 Confirm Link 보장이 영향 버전에서 실패한다 — Based on: `feature-keycloak-idp-brokering-google-client.md:214`, `feature-keycloak-internal-spa-direct-google-federation.md:338`.
2. G D1이 mapper-level override라면 IMPORT/FORCE는 공존할 수 있다 — Based on: `feature-keycloak-google-claim-attribute-mapping.md:130`, `feature-keycloak-idp-mappers-claim-to-role.md:119`.
3. HttpOnly refresh cookie에는 token-mediating server가 필요하다 — Based on: `feature-keycloak-internal-spa-direct-no-google.md:17,188,280`. 실제 adapter 동작으로 반증 가능하다.
## Out of Scope
- Slice 밖 parent/project/owner branch의 본문 의미 감사
- Keycloak·Google Console·Caddy 실제 구동
- CVE 공식 advisory 외부 fetch 및 raw 보존
- branch-note 수정 또는 canonical 문서 생성
## Coverage Gaps
- Files marked NOT_READ or BLOCKED: none
- Whole-corpus conclusions: INCOMPLETE — 아래 unseen owner/decision을 교차 lane에서 확인해야 한다.
## Grep Verification
각 finding 안에 25개 quote pattern의 절대경로 명령과 observed output을 기록했다. 대표 확인:
```bash
grep -nFo -- 'core 차단(D1+D2)은 `trustEmail`·`email_verified` 검증과 무관하게 성립' raw/branch-notes/feature-keycloak-first-broker-login-flow.md
# Observed: 135:core 차단(D1+D2)은 `trustEmail`·`email_verified` 검증과 무관하게 성립
grep -nFo -- '| D2 | refresh token 저장 위치 = secure httpOnly cookie 권장' raw/branch-notes/feature-keycloak-internal-spa-direct-no-google.md
# Observed: 280:| D2 | refresh token 저장 위치 = secure httpOnly cookie 권장
```
- 검증한 verbatim quote 총 개수 V: 25
- 일치 P: 25
- 라인 정정 C: 0
- 폐기 D: 0
## Claim Traceability Check
- Claim ID 연결 검사: slice에서 추출한 unique Claim ID 153개 중 152개가 branch/project note 밖의 raw source에서 lexical resolve. `KC-HOST-C6` 1개는 iss-claim-hostname-mismatch:142·230·247이 명시한 미아카이브 후보이며 Supporting Claim으로 사용되지 않아 `BROKEN_CLAIM_REFERENCE`는 none found.
- Decision Evidence Map: 10/10 파일에서 1개씩, 총 10개 검토. 이번 lane은 raw Claim ID 존재성까지 확인했으며 각 raw claim의 의미 재감사는 범위 밖이다.
- UNSUPPORTED_DECISION: 정합 finding과 직접 연결된 3개 축 — CVE version gate(H:338), JavaScript origins 비움(C:73·167), P2A brokering scope/zero-change(I:282). 그 밖의 project-scope·implementation caveat는 파일이 명시적으로 라벨링해 상충 finding으로 승격하지 않았다.
## Cross-Lane Audit Candidates
| File:line | Referenced owner / decision | Reason |
| --- | --- | --- |
| `feature-keycloak-google-redirect-uri-policy.md:202-205` | single-EC2-google-federation D4/D6; public-domain-tunneling D1/D2 | callback host/path와 tunnel 선택 owner가 unseen |
| `feature-keycloak-header-spoofing-defense.md:202-206` | edge-forwardauth-no-google D2/D4; nginx-auth-request; reverse-proxy-headers | 부모 enforcement 위임과 신규 `X-Internal-Auth-Token` 계약 확인 필요 |
| `feature-keycloak-https-termination-caddy-nginx.md:232-233` | reverse-proxy-headers D1; public-domain-tunneling | TLS 종단 위치와 forwarded-header mode 결합 검증 필요 |
| `feature-keycloak-idp-brokering-google-client.md:189,192` | account-linking-sub-vs-email; edge-forwardauth-google-federation | `trustEmail` 소비 의미와 `<kc-host>` owner 확인 필요 |
| `feature-keycloak-internal-spa-direct-google-federation.md:403` | 미지정 CSRF/`state` owner | 파일이 project-wide owner 부재를 주장하므로 다른 lane 확인 필요 |
| `feature-keycloak-internal-spa-direct-google-federation.md:404` | docker-compose-stack / realm configuration | H D10의 SMTP deployment-fact 소유권이 실제 stack owner와 겹치는지 확인 필요 |
| `feature-keycloak-internal-spa-direct-google-federation.md:407-408` | account-linking-sub-vs-email D1; three-leg-trust-chain D1/D2/D5 | L2-F07의 foreign-detail summary가 실제 owner와 같은지 확인 필요 |
| `feature-keycloak-internal-spa-direct-no-google.md:365-368` | audience-validator; token-storage; refresh-rotation; PKCE-flow | Hubs의 foreign D-row 요약과 L2-F09 token-storage owner 확인 필요 |
| `feature-keycloak-iss-claim-hostname-mismatch.md:204-209` | single-EC2-no-google D3; audience-validator D6; docker-compose D3/D5 | 파일이 이미 owner conflict·trigger erosion·3자 재진술을 명시함 |
## Stats
```wiki-stats
agent: wiki-research-lane
found: 10
processed: 10
dropped: 0
```
@@ -0,0 +1,386 @@
# Lane 3 — Keycloak Branch-Note Consistency Audit
## Lane Inventory
| Path | Status | Evidence Lines | Extracted Facts |
| --- | --- | --- | --- |
| `raw/branch-notes/feature-keycloak-nginx-auth-request-integration.md` | READ_FULL | 1306 | `auth_request` body handling, browser/API failure routing, upstream ownership edges |
| `raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow.md` | READ_FULL | 1275 | bearer-token terminology, nginx decision references, discovery startup dependency |
| `raw/branch-notes/feature-keycloak-patterns.md` | READ_FULL | 1205 | retired numbered naming versus retained D2 |
| `raw/branch-notes/feature-keycloak-pkce-flow-stages.md` | READ_FULL | 1167 | PKCE S256 ownership and Admin Console terminology |
| `raw/branch-notes/feature-keycloak-public-domain-tunneling.md` | READ_FULL | 1220 | quick versus named Cloudflare Tunnel domains and tunnel-choice ownership |
| `raw/branch-notes/feature-keycloak-realm-client-export.md` | READ_FULL | 1238 | import wiring ownership, credential export assertions, PKCE terminology |
| `raw/branch-notes/feature-keycloak-refresh-rotation-and-logout.md` | READ_FULL | 1254 | rotation demonstration, Max Reuse semantics, back-channel logout scope |
| `raw/branch-notes/feature-keycloak-refresh-token-rotation.md` | READ_FULL | 1284 | rotation contract, family invalidation, receiving endpoint delegation |
| `raw/branch-notes/feature-keycloak-reverse-proxy-headers.md` | READ_FULL | 1297 | Caddy path behavior, Keycloak proxy variables, delegated security decisions |
| `raw/branch-notes/feature-keycloak-single-ec2-google-federation.md` | READ_FULL | 1370 | parent integration decisions and delegated tunnel/proxy details |
## 1. `feature-keycloak-nginx-auth-request-integration.md`
### L3-F01: `auth_request` body behavior contradicts its configuration table
- **Severity / class:** High — `CONTRADICTION`
- **Original goal / current state:** line 101 says the body is not automatically copied; line 163 says nginx defaults to forwarding it.
- **Claim:** Both statements cannot govern the same configuration.
- **Assumption:** `proxy_pass_request_body off` can be omitted in the rendered config. This is falsified if every applicable location explicitly contains it.
- **Failure / falsification:** Request bodies reach the auth upstream, causing unwanted disclosure, buffering, or size failures. Falsify with `nginx -T` and upstream request capture.
- **Action / why:** Rewrite line 101 to say body forwarding must be disabled explicitly, and make the table canonical.
- **Alternatives:** (1) explicit `off`, (2) intentional bounded forwarding with documented limits, (3) a separate body-less auth endpoint.
- **Counterargument:** oauth2-proxy may ignore the body. That does not establish that nginx did not transmit it.
```text
$ sed -n '101p;163p' 'raw/branch-notes/feature-keycloak-nginx-auth-request-integration.md'
- nginx `auth_request`는 subrequest이므로 **POST body가 자동 복제되지 않는다**. `/oauth2/auth`는 HEAD-like check이므로 body 불필요 → `proxy_pass_request_body off` 권장.
| `proxy_pass_request_body` | `off` | `O2PN-C8`, `NGAR-C8`. **생략하면 default `on`(`NGXPM-C1`) 이라 body 가 전달됨** |
```
### L3-F02: unconditional sign-in TODO is stale after browser/API response split
- **Severity / class:** Medium — `STALE_SUMMARY`
- **Original goal / current state:** line 89 gives one 302 sign-in task; lines 193194 later divide browser 302 from API 401.
- **Claim:** The implementation checklist can be read as applying the redirect to every protected location.
- **Assumption:** Implementers use the TODO before the later D9 table. Falsified if generated config and tests always preserve the location split.
- **Failure / falsification:** API clients receive HTML redirects, parsing failures, or loops. Verify both browser and `/api/` requests.
- **Action / why:** Restrict the TODO to browser locations and add an explicit API 401 task.
- **Alternatives:** (1) location split, (2) 401 everywhere with frontend login handling, (3) redirect allowlist, (4) content-negotiated behavior.
- **Counterargument:** The TODO may describe only creation of the named location. Its application scope nevertheless remains unstated there.
```text
$ sed -n '89p;193p;194p' 'raw/branch-notes/feature-keycloak-nginx-auth-request-integration.md'
- [ ] named location `@oauth2_signin` 작성: `return 302 https://$host/oauth2/start?rd=$scheme://$host$request_uri;` — 등급: `planned`
| browser-facing (`location /`) | `auth_request /oauth2/auth;` | `error_page 401 = @oauth2_signin;` → 302 | D2, `O2PN-C5` |
| API/machine (`location /api/`) | `auth_request /oauth2/auth;` | `error_page 401 =401;` → **plain 401 pass-through** | D9, `O2PN-C9` |
```
## 2. `feature-keycloak-oauth2-proxy-oidc-flow.md`
### L3-F03: locally validated JWT behavior remains labeled “token introspection”
- **Severity / class:** Low — `STALE_SUMMARY`, `NAMING_DRIFT`
- **Original goal / current state:** the TODO calls the flags an introspection mode, while its own audit says no introspection endpoint call is documented.
- **Claim:** The term implies a network protocol behavior not supported by the notes evidence.
- **Assumption:** Readers use the label to infer latency, revocation, and firewall dependencies. Falsified by an observed outbound introspection request.
- **Failure / falsification:** Architecture is designed around an endpoint call that may not happen. Verify debug logs and outbound traffic.
- **Action / why:** Use neutral “bearer-token mode” or the verified local-validation/bypass behavior.
- **Alternatives:** (1) local JWT validation, (2) bearer-token bypass, (3) separately implement RFC 7662 introspection, (4) retain a neutral label pending measurement.
- **Counterargument:** “Introspection” might be informal internal vocabulary, but it conflicts with the protocols established meaning.
```text
$ sed -n '85p;101p' 'raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow.md'
- [ ] token introspection 모드 (`--skip-jwt-bearer-tokens`, `--extra-jwt-issuers`)의 의미 정리 — 등급: `planned`
- **2026-07-17** (drift): 본 노트가 쓰는 **"token introspection 모드" 명칭이 부정확할 가능성**을 확인 — 공식 문서는 introspection endpoint 호출을 서술하지 않음. 사용자 작성 본문이라 자동 수정하지 않고 §Audit & Findings `NAMING_DRIFT` 로 남김. rename 여부는 사용자 판단.
```
### L3-F04: three decision references are not machine-resolvable
- **Severity / class:** Low — `BARE_DECISION_REF`, ambiguous ownership
- **Original goal / current state:** all three passages intend to identify an owner decision but use bare slugs or quoted prose instead of the repositorys decision-reference syntax.
- **Claim:** Impact analysis cannot reliably bind these references to their owners.
- **Assumption:** Deterministic consistency tooling is part of change propagation. Falsified only if the tool and contract are changed to recognize these forms.
- **Failure / falsification:** Owner changes do not surface in consumer impact reports. Re-run the consistency checker after repair.
- **Action / why:** Replace each with `[[raw/branch-notes/<slug>]] D<n>`.
- **Alternatives:** N/A — reference syntax is fixed by `rules/consistency-contract.md`.
- **Counterargument:** A person can understand the references, but that does not make them machine-traceable.
```text
$ sed -n '214p;228p' 'raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow.md'
| `SESSION_STORAGE_4KB_ABSENT` | session storage 공식 페이지에 `4k`/`4096`/`split` 문자열이 **0건** — 4kb cookie split 함정의 근거는 이 페이지가 아니라 **nginx 통합 페이지**([[raw/official-docs/oauth2-proxy-nginx-integration-official]], "Nginx normally only copies the first `Set-Cookie` header ... if your cookies are larger than 4kb, you will need to extract additional cookies manually")에 있다. | `raw/official-docs/oauth2-proxy-session-storage-official.md` §Usage Boundaries | D5a 의 Open Risk 에 반영 완료. 4kb 대응의 owner 는 형제 branch `feature-keycloak-nginx-auth-request-integration` `D5`. |
| **인가 거부 시 실제 응답 코드/본문** (401 vs 403 vs 로그인 루프) | D4 Open Risk 가 자인 — `O2PK-C3` 은 인가 실패 코드의 의미를 명시 안 하고, `O2PBEH-C2`~`C4` 는 authentication 단계 전용. 형제 branch `feature-keycloak-nginx-auth-request-integration` `D2` 의 `error_page` 분기가 이 값에 의존 | 허용 role 이 **없는** 사용자로 로그인 후 보호 경로 요청 → 응답 코드/본문 확인. `/oauth2/auth` subrequest 응답도 함께 확인(202/401 만 반환하는지 — `O2PE-C5` 와 대조) | `needs-confirmation` |
```
```text
$ sed -n '182p' 'raw/branch-notes/feature-keycloak-realm-client-export.md'
- [[raw/branch-notes/feature-keycloak-single-ec2-no-google]] `#D3` — `KC_HOSTNAME=localhost` + issuer-uri + network 계약 owner. 이 hostname 계약이 D2 의 redirect URI 값(`http://localhost/*`, `http://127.0.0.1/*`)의 전제. parent `#D3` 이 hostname/포트를 바꾸면 본 realm-export.json 의 redirect URI(D2) 를 동기화해야 함. (sibling `feature-keycloak-docker-compose-stack` 도 동일 의존을 "parent D3" 로 인용). 아울러 본 branch 의 client scope(realm 1개 + `spa-client` public + PKCE S256) 는 parent `#D5` 의 실 구현.
```
## 3. `feature-keycloak-patterns.md`
### L3-F05: retired numbered naming remains an active D2 decision
- **Severity / class:** Medium — `CONTRADICTION`, `STALE_SUMMARY`
- **Original goal / current state:** line 60 retires numbered naming; line 134 still presents it as D2.
- **Claim:** A prohibited convention remains encoded as an active-looking decision.
- **Assumption:** D-rows denote current decisions. Falsified if D2 is structurally marked historical and excluded from owner resolution.
- **Failure / falsification:** New branches or consumers adopt the retired numbered hierarchy. Search inbound D2 references.
- **Action / why:** Move D2 to a retired-decision section or apply an explicit retired status.
- **Alternatives:** N/A — numbered branch hierarchy is prohibited by repository policy.
- **Counterargument:** The warning at line 60 is visible, but downstream D-reference semantics remain ambiguous.
- **Single-finding justification:** Other content is legacy-to-AP mapping; no second lane-local decision conflict was found.
```text
$ sed -n '60p;134p' 'raw/branch-notes/feature-keycloak-patterns.md'
> **⚠️ 갱신 (2026-07-14)**: 아래 6패턴 인덱스는 **Phase 0 legacy(배치×federation 축)**. 현 실행계획 SSOT 는 [[raw/project-notes/keycloak-patterns-overview]] 의 **§Branch 분해 / 실행계획(R4)** — 인증 아키텍처 4패턴(AP1~AP4) + 19 Tier-2. 신규 작업은 hub 분해표를 따르며, 아래 슬러그는 hub §2.3 매핑대로 AP 로 re-map 대상. D2(`-{N}-{M}` numbered 명명)는 CLAUDE.md §11 위반으로 폐기(각 sub-sub 는 이미 content-descriptive 슬러그라 실제 영향은 프레이밍뿐).
- **D2** 2026-05-25: sub-sub-branch 명명은 `-{N}-{M}` dash-숫자 형식. M은 학습/구현 단계 순서.
```
## 4. `feature-keycloak-pkce-flow-stages.md`
### L3-F06: D1 and D5 both own S256 enforcement
- **Severity / class:** Medium — `DUAL_OWNERSHIP`
- **Original goal / current state:** D1 and D5 both encode the S256 formula and Keycloak enforcement, with different evidence freshness.
- **Claim:** Two decisions own the same mutable contract.
- **Assumption:** Downstream notes treat a D-number as owner identity. Falsified if D5 is structurally non-normative.
- **Failure / falsification:** UI-label or rejection-semantics updates reach only one row. Compare inbound D1/D5 references.
- **Action / why:** Keep D1 canonical and turn D5 into a reference-only TODO mapping.
- **Alternatives:** (1) D1 owner/D5 pointer, (2) D5 owner/D1 scope summary, (3) merge rows, (4) split formula from vendor enforcement.
- **Counterargument:** D5 may exist only to map TODO step 2, but its current table form still looks normative.
```text
$ sed -n '118p;122p' 'raw/branch-notes/feature-keycloak-pkce-flow-stages.md'
| D1 | PKCE method = `S256` 만 정리 대상 (`plain` 은 비교용 1줄). Keycloak client 설정에서 PKCE method 옵션(정식 UI 라벨 "PKCE method")을 S256 으로 지정 | `raw/official-docs/oauth2-pkce-rfc-7636.md#PKCE-RFC7636-C3` (S256 공식: `BASE64URL-ENCODE(SHA256(ASCII(verifier)))`), `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C1` (OAuth 2.1: "Clients MUST use code_challenge and code_verifier ..."), `raw/official-docs/keycloak-client-pkce-method-enforcement-official.md#KC-PKCE-C1` (Admin UI 옵션 정식 명칭·위치), `#KC-PKCE-C3` (S256 선택 시 서술) | `official-standard + official-standard + official-vendor-doc` | OA21-C1 은 PKCE 사용 자체를 MUST 로 강제하지만 "S256 강제 / plain 금지" 라는 정확한 문장은 OA21-C1 인용에 포함 안 됨 — §7.5.1 예외 조건 확인 필요. 단, RFC 7636 + OAuth 2.1 종합 권고로 보면 정당. **2026-07-17 업데이트**: `KC-PKCE-C1` 이 UI 라벨 오류를 정정("Proof Key for Code Exchange Code Challenge Method" 가 아니라 "PKCE method", Capability Config 섹션)했으나, `KC-PKCE-C3` 은 "Keycloak applies... S256" 이라고만 서술 — **S256 설정 시 `code_challenge_method=plain` 요청을 실제로 거부(reject)한다는 명시적 문장은 여전히 없음**. 아래 Claims To Verify 의 "plain 메서드 요청을 거부" 항목은 `needs-confirmation` 유지 |
| D5 (TODO 표 step 2) | S256 공식 `code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))` + Keycloak client 에 S256 강제 | `raw/official-docs/oauth2-pkce-rfc-7636.md#PKCE-RFC7636-C3` (S256 공식), `raw/official-docs/keycloak-securing-apps-overview-official.md#KC-SECAPP-C1` (Keycloak 이 OIDC/OAuth 2.0 stack 을 secure 가능) | `official-standard` + `official-vendor-doc` (Keycloak overview) | Keycloak client 의 "Proof Key for Code Exchange Code Challenge Method = S256" 정확한 admin UI 위치는 KC-SECAPP-C1 인용 범위 밖 — Keycloak Securing Applications 의 client adapter / settings 섹션 별도 인용 필요 |
```
### L3-F07: deprecated PKCE Admin label survives in two TODOs
- **Severity / class:** Low — `STALE_SUMMARY`
- **Original goal / current state:** two implementation tasks use the old label; the audit identifies `PKCE method` as the actual label.
- **Claim:** The correction has not reached the operational checklist.
- **Assumption:** The pinned Keycloak version uses the audited label. Falsified by the target Admin Console displaying the older text.
- **Failure / falsification:** Implementers cannot find or select the intended field. Inspect the target-version UI.
- **Action / why:** Update both TODOs to `PKCE method = S256`; preserve the old label only as version history.
- **Alternatives:** N/A — use the label observed in the pinned target version.
- **Counterargument:** Labels can vary by Keycloak version or locale; record that variant explicitly if confirmed.
```text
$ sed -n '73p' 'raw/branch-notes/feature-keycloak-pkce-flow-stages.md'
- Keycloak client 설정: `Proof Key for Code Exchange Code Challenge Method = S256` 강제
```
```text
$ sed -n '81p;202p' 'raw/branch-notes/feature-keycloak-realm-client-export.md'
- [ ] Client Advanced 탭: `Proof Key for Code Exchange Code Challenge Method = S256` — 등급: `planned`
- **`NAMING_DRIFT`** (Advisory, user prose 보존) — §목표·§TODO 의 라벨 "Proof Key for Code Exchange Code Challenge Method" 는 `KC-PKCE-C1` 기준 부정확. 실제 Admin Console 옵션명은 **"PKCE method"** (Capability Config 섹션 하위). 구현 시 이 라벨로 찾을 것. 사용자 작성 prose 는 verbatim 보존, 정정은 §구현 가이드 1 에 반영.
```
## 5. `feature-keycloak-public-domain-tunneling.md`
### L3-F08: `trycloudflare.com` is described as both permanent and random
- **Severity / class:** High — `CONTRADICTION`, `STALE_SUMMARY`
- **Original goal / current state:** line 69 offers a permanent `<sub>.trycloudflare.com`; line 77 says quick-tunnel URLs are random, while the parent repeats the static-looking form.
- **Claim:** Quick-tunnel hostnames and named-tunnel managed DNS are conflated.
- **Assumption:** Google requires a stable registered redirect and the described quick-tunnel behavior applies. Falsified by an account providing a persistent named `trycloudflare.com` hostname.
- **Failure / falsification:** Restarting the tunnel invalidates the Google redirect URI. Create/restart the tunnel and compare hostnames.
- **Action / why:** Distinguish random quick tunnels from named tunnels backed by managed DNS; synchronize the parent summary.
- **Alternatives:** (1) random URL plus console updates, (2) named tunnel plus managed domain, (3) ngrok reserved domain, (4) another stable public endpoint.
- **Counterargument:** Product capabilities may have changed; verify against the exact account and client version.
```text
$ sed -n '69p;77p' 'raw/branch-notes/feature-keycloak-public-domain-tunneling.md'
- [ ] **Cloudflare Tunnel 동작 확인** — `cloudflared tunnel create <name>` + `cloudflared tunnel route dns <name> <subdomain>` → 영구 도메인 (`<sub>.trycloudflare.com` 무료 또는 자체 도메인 CNAME) — 등급: `planned`
- Cloudflare Tunnel의 `trycloudflare.com` quick tunnel은 무료지만 URL이 random (ngrok와 유사). 정적 도메인 원하면 Cloudflare account + 자체 도메인 (Cloudflare DNS로 위임) + named tunnel 필요.
```
```text
$ sed -n '356p' 'raw/branch-notes/feature-keycloak-single-ec2-google-federation.md'
- **비교 핵심**: P3A 대비 **추가 운영 비용은 "public 접근 가능한 callback URL"** 1가지. Google OAuth client console에 등록할 redirect_uri는 **HTTPS 필수 + localhost 제외** (Google 명시 — `http://localhost` 만 예외, IP 주소 X). 학습 환경에서는 **ngrok** (`https://<random>.ngrok-free.app` 임시 URL) 또는 **Cloudflare Tunnel** (`<name>.trycloudflare.com` 또는 자체 도메인) 사용. ngrok은 무료 plan에서 URL이 매 세션 변경 → Google client console 매번 업데이트 필요 → Cloudflare Tunnel이 학습 단계에 더 적합 (정적 도메인). **Keycloak `KC_PROXY_HEADERS=xforwarded` + `KC_HOSTNAME=<public-domain>`** 명시 안 하면 reverse proxy 뒤에서 redirect URL이 internal `keycloak:8080`으로 떨어져 깨짐.
```
This file is also the child-side source for `L3-F15`.
## 6. `feature-keycloak-realm-client-export.md`
### L3-F09: import wiring remains in scope after ownership was delegated
- **Severity / class:** Medium — `STALE_SUMMARY`, `RESTATED_FOREIGN_DECISION`
- **Original goal / current state:** line 53 includes automatic container import; line 168 says this branch authors JSON only and must not restate wiring.
- **Claim:** Scope and ownership trace assign the same concern differently.
- **Assumption:** Scope bullets denote implementation responsibility. Falsified if line 53 is explicitly reclassified as a consumer assumption.
- **Failure / falsification:** Realm and stack branches independently change mount paths or startup flags. Inspect actual changed artifacts.
- **Action / why:** Keep JSON authorship here and make import wiring reference-only to the stack owner.
- **Alternatives:** (1) JSON/stack split, (2) this branch owns full import lifecycle, (3) dedicated import-lifecycle owner.
- **Counterargument:** Line 53 might describe desired behavior, not ownership; its current placement does not state that distinction.
```text
$ sed -n '53p;168p' 'raw/branch-notes/feature-keycloak-realm-client-export.md'
- Keycloak 컨테이너 `--import-realm` 옵션으로 부트 시 자동 import
> **Trace / R3**: volume mount(`./realm-export.json:/opt/keycloak/data/import/realm-export.json`) + Keycloak 부트 command `--import-realm` 는 sibling [[raw/branch-notes/feature-keycloak-docker-compose-stack]] `#D4` 가 owner (`KC-IMPORT-C1/C2/C3/C4` 인용). 본 branch 는 **realm-export.json 저작만** — 배선은 restate 하지 않는다. 배선 계약이 바뀌면(import dir 경로/flag) 본 export 파일 배치에 영향(§엣지·의존).
```
### L3-F10: credential export is asserted before its evidence gate
- **Severity / class:** High — `CONTRADICTION`, unsupported certainty
- **Original goal / current state:** line 99 states credential behavior; line 193 says the password-inclusive `usersExport` claim has no primary raw source.
- **Claim:** Operational prose presents an unverified export mode as available.
- **Assumption:** Bootstrap automation may rely on exported users or passwords. Falsified by version-matched documentation and inspected export output.
- **Failure / falsification:** Imported users cannot authenticate, or sensitive credentials are handled under a false assumption.
- **Action / why:** Move credential claims to verification status until the pinned images help and actual export are inspected.
- **Alternatives:** (1) no users and separate bootstrap, (2) verified user export with secret controls, (3) Admin API provisioning, (4) disposable test credentials.
- **Counterargument:** The behavior may be remembered from another Keycloak version; that does not establish the pinned version.
```text
$ sed -n '99p;193p' 'raw/branch-notes/feature-keycloak-realm-client-export.md'
- Realm export JSON은 **user password를 포함하지 않음** (보안). user는 ID/role만 export됨 → 부트 후 admin이 password 재설정 필요. 또는 realm export에서 `usersExport=true`로 별도 export 가능.
| `usersExport=true` 옵션으로 user (password 포함) export 가능 | 본 branch 진행 중 메모 — 1차 raw 미수집 | Keycloak `kc.sh export --help` 또는 admin REST API 문서 발췌 | `planned` |
```
This file is also implicated in `L3-F04` and `L3-F07`.
## 7. `feature-keycloak-refresh-rotation-and-logout.md`
### L3-F11: concept and execution notes both own the same rotation sequence
- **Severity / class:** High — `DUAL_OWNERSHIP`, `RESTATED_FOREIGN_DECISION`
- **Original goal / current state:** the concept note declares a concept/execution split, but both notes retain a normative D-row for the same family-invalidation flow.
- **Claim:** Mutable expected behavior has two decision owners.
- **Assumption:** D-rows are normative. Falsified if P3A D5 is structurally converted into a non-normative test procedure.
- **Failure / falsification:** A failed family-kill test updates only one note. Inspect incoming D4/D5 references and modification ownership.
- **Action / why:** Keep policy/expected behavior in the concept owner and make P3A reference it from its test procedure.
- **Alternatives:** (1) concept owner/execution pointer, (2) execution owner/concept principle, (3) explicit invariant/procedure split, (4) separate verified-behavior owner.
- **Counterargument:** Definition and demonstration are naturally repeated, but both current rows make the repeated value independently mutable.
```text
$ sed -n '159p;240p' 'raw/branch-notes/feature-keycloak-refresh-token-rotation.md'
| D4 | rotation flow 4단계 (RT_1 사용 → invalidate → AT_2+RT_2 발급 → 재사용 시 family invalidate) | N/A — rotation ON 이면 항상 이 flow. 단 재사용 시 무효화 **범위**(단일 토큰 vs family 전체)는 verify 대상 | `raw/official-docs/keycloak-refresh-token-rotation-sessions-official.md#KC-RTROT-C2` (RT 1회 사용 후 무효 + 최신 RT 저장 강제 = flow 1~3단계 근거) + `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3` (refresh token = scope/RS bound MUST, 배경) + `UNSUPPORTED_DECISION` (4단계 "family 전체 invalidate" 는 `KC-RTROT-C6` doc text 부재) | `official-vendor-doc (1~3단계) + official-standard (배경) + UNSUPPORTED_DECISION (family invalidate)` | family invalidate 동작을 docker-compose 실측(RT_1 재사용 → RT_2 도 거부되는지)으로 확인 — Claims To Verify 에 계획됨 |
- cousin(다른 phase) [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] (P3A) — 본 개념 계약의 **실측·시연** 소유. 본 노트 = 개념/계약, 그쪽 = 실행.
```
```text
$ sed -n '124p' 'raw/branch-notes/feature-keycloak-refresh-rotation-and-logout.md'
| D5 | rotation flow 시연 (RT_1 사용 → AT_2+RT_2 → RT_1 재사용 → family 전체 invalidate → 재로그인 강제) | N/A (rotation ON 전제의 단일 시연 시나리오). rotation OFF 면 재사용 탐지 자체가 불가 → D1 이 전제 | `raw/official-docs/keycloak-refresh-token-rotation-reuse-admin-official.md#KC-ROT-C1` (rotation = 사용된 RT 무효화 + 새 발급) + `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3`·`#OA21-C6` (RT bound MUST, code grant → AT+RT) + `raw/official-docs/oidc-client-ts-library.md#OIDCTS-C4`·`#OIDCTS-C5` (Refresh Token Grant + Silent Refresh — SPA 자동 갱신 경로) + `UNSUPPORTED_DECISION` ("RT_1 재사용 → family 전체 invalidate" 자동 동작 = `KC-ROT-C6` 문서 부재) | `official-vendor-doc (KC-ROT-C1, OIDCTS-C4/C5) + official-standard (OA21-C3/C6) + UNSUPPORTED_DECISION (family invalidate 구체 동작)` | docker-compose 실측: RT 재사용 시 4xx + RT_2 동시 invalidate(family kill) 확인 (Claims To Verify #1) |
```
### L3-F12: delegated back-channel receiver is out of scope at its destination
- **Severity / class:** High — `CONTRADICTION`, ambiguous ownership
- **Original goal / current state:** the concept note delegates receiver implementation to P3A; P3A lists provider-triggered logout as an optional demonstration only.
- **Claim:** No note unambiguously owns the receiving endpoint.
- **Assumption:** “옵션 시연만” excludes backend implementation. Falsified by an explicit endpoint artifact and validation plan in P3A.
- **Failure / falsification:** Logout Token reception is relied upon but never built or verified.
- **Action / why:** Add the endpoint to P3A, remove the delegation, or create a dedicated owner.
- **Alternatives:** (1) implement in P3A, (2) remove from scope, (3) dedicated branch, (4) provider-side demonstration with explicit absence of receiver.
- **Counterargument:** The optional demonstration might include the endpoint; planned files and acceptance tests must show that.
```text
$ sed -n '67p' 'raw/branch-notes/feature-keycloak-refresh-token-rotation.md'
- **back-channel logout 수신** backend 구현(Logout Token 수신 endpoint) — D3, P3A 실 구현 시
```
```text
$ sed -n '61p' 'raw/branch-notes/feature-keycloak-refresh-rotation-and-logout.md'
- back-channel logout (Keycloak admin → 모든 session kill) — 옵션 시연만
```
### L3-F13: Max Reuse greater than zero is both meaningless and a weaker alternative
- **Severity / class:** High — `CONTRADICTION`, incompatible policy assumptions
- **Original goal / current state:** one note says any value above zero makes reuse detection meaningless; the other treats one or more as weaker but viable.
- **Claim:** The policy models the setting with incompatible semantics.
- **Assumption:** Max Reuse is a bounded-use counter in the pinned version. If the field differs or is absent, both claims need replacement.
- **Failure / falsification:** Concurrency mitigation either disables the intended test or rejects legitimate refreshes. Test values 0 and 1 with concurrent requests.
- **Action / why:** Replace certainty with one measured semantics table shared by both notes.
- **Alternatives:** (1) zero plus refresh serialization, (2) measured bounded reuse, (3) BFF/server-side serialization, (4) defer selection until measurement.
- **Counterargument:** “의미 없음” may mean only “unsuitable for D5”; the note does not currently state that narrower scope.
```text
$ sed -n '127p' 'raw/branch-notes/feature-keycloak-refresh-token-rotation.md'
- `Refresh Token Max Reuse > 0`이면 reuse detection 의미 없음
```
```text
$ sed -n '120p' 'raw/branch-notes/feature-keycloak-refresh-rotation-and-logout.md'
| D1 | rotation ON + Max Reuse 0 강제 — stolen token 탐지 시연 | stolen-token 탐지를 *시연*하려면 rotation ON + Max Reuse 0. 정상 SPA 가 동시 갱신(race)으로 같은 RT 를 2회 보낼 위험이 크면 대안: Max Reuse ≥1 로 완화(단 탐지력 약화, D5 시연 불가) | `raw/official-docs/keycloak-refresh-token-rotation-reuse-admin-official.md#KC-ROT-C1` ("Revoke Refresh Token" ON = 사용된 RT revoke + 새 토큰 발급 = rotation) + `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3` (RT scope/RS bound MUST — rotation 권고 배경) + `UNSUPPORTED_DECISION` ("Refresh Token Max Reuse" 설정명 + 재사용 시 family invalidate 자동 동작 = `KC-ROT-C6` 문서 부재) | `official-vendor-doc (KC-ROT-C1) + official-standard (OA21-C3 배경) + UNSUPPORTED_DECISION (Max Reuse 0 라벨 + family invalidate)` | admin UI 실측으로 "Refresh Token Max Reuse" 필드 실재·라벨 + RT 재사용 시 family kill 동작 확인 (Claims To Verify #1) |
```
## 8. `feature-keycloak-refresh-token-rotation.md`
This file is the co-source for `L3-F11`, `L3-F12`, and `L3-F13`. No additional lane-local inconsistency remained after those shared findings; its remaining related D-rows retain explicit `UNSUPPORTED_DECISION` labels rather than asserting family invalidation as verified.
## 9. `feature-keycloak-reverse-proxy-headers.md`
### L3-F14: executable Caddy draft strips a prefix the selected method requires
- **Severity / class:** High — `STALE_SUMMARY`, cannot-coexist configuration
- **Original goal / current state:** the copyable draft uses `handle_path`; its later audit says method B requires prefix-preserving `handle`/`reverse_proxy`.
- **Claim:** The executable draft and `KC_HTTP_RELATIVE_PATH=/keycloak` cannot coexist without another rewrite.
- **Assumption:** Standard `handle_path` stripping applies and Keycloak runs at `/keycloak`. Falsified by a verified rewrite or root-path Keycloak configuration.
- **Failure / falsification:** Discovery and authorization endpoints reach the wrong path and return 404. Test all OIDC endpoints through Caddy.
- **Action / why:** Repair the snippet itself or explicitly switch the design to method A.
- **Alternatives:** (1) prefix-preserving `handle`, (2) method A with deliberate stripping, (3) explicit verified rewrite, (4) nginx prefix preservation.
- **Counterargument:** Another layer may restore the prefix; no such layer is shown in the draft.
```text
$ sed -n '125p;202p' 'raw/branch-notes/feature-keycloak-reverse-proxy-headers.md'
handle_path /keycloak/* {
> ⚠️ **method B ↔ proxy 라우팅 정합 함정 (depth 감사 2026-07-18 F1)**: §진행 중 메모의 **Caddy 초안 `handle_path /keycloak/*`** 은 `/keycloak` prefix 를 **strip** 한다 (= method A 동작). 그런데 D3 은 method B(`KC_HTTP_RELATIVE_PATH=/keycloak`, Keycloak 이 스스로 `/keycloak/*` 발급)를 채택했으므로, prefix 가 strip 된 요청이 prefix 를 기대하는 Keycloak 에 도달 → **이중 strip 으로 404**. 초안의 "Caddy 환경변수는 nginx case 와 동일" 은 *환경변수*는 맞으나 *라우팅*은 다름을 가린다. nginx `location /keycloak/ { proxy_pass http://127.0.0.1:8080; }` 는 prefix 를 **보존**하므로 method B 와 정합. **method B 를 Caddy 로 쓰려면 `handle_path`(strip) 대신 `handle`/`reverse_proxy` 로 `/keycloak` prefix 를 보존**해야 한다. Caddy vendor doc 미보존(D4 UNSUPPORTED)이라 FACT 단정 대신 §Claims To Verify 로 실측 위임.
```
### L3-F16: parent owns exact proxy values while delegating proxy details
- **Severity / class:** High — `DUAL_OWNERSHIP`, `RESTATED_FOREIGN_DECISION`
- **Original goal / current state:** parent D4 fixes five exact variables; its ownership summary delegates proxy-header detail to the child.
- **Claim:** The parent contains a mutable configuration bundle that overlaps delegated owners.
- **Assumption:** Parent D4 is consumed as normative configuration. Falsified if it is generated from one canonical source and children cease owning values.
- **Failure / falsification:** A routing or security owner changes a value while the parent remains deployable and stale.
- **Action / why:** Keep integration invariants in the parent and reference each variables owner decision.
- **Alternatives:** (1) child owners plus parent references, (2) parent owns final bundle and children provide evidence, (3) explicit per-variable ownership matrix.
- **Counterargument:** An integration parent may need the assembled bundle, but then it must be the declared canonical owner rather than also delegating details.
```text
$ sed -n '239p;301p' 'raw/branch-notes/feature-keycloak-single-ec2-google-federation.md'
| D4 | Keycloak `KC_HOSTNAME=https://kc.example.com` + `KC_HTTP_RELATIVE_PATH=/keycloak` + `KC_PROXY_HEADERS=xforwarded` + `KC_HTTP_ENABLED=true` + `KC_PROXY_TRUSTED_ADDRESSES=127.0.0.1` | `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C2`, `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C5`, `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C2`, `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C3`, `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C4`, `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C5`, `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C6` | `official-vendor-doc` | `KC_HTTP_RELATIVE_PATH=/keycloak` 와 `X-Forwarded-Prefix` 가 nginx → Keycloak 경로에서 OIDC discovery (`.well-known/openid-configuration`) 의 `issuer` 값을 어떻게 결정하는지 실 검증 필요 (`KC-RP-C6` 두 방법의 trade-off 미해소) |
- sub-sub-branch 관심사 위임: [[raw/branch-notes/feature-keycloak-public-domain-tunneling]](tunnel 상세) · [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]](proxy header 상세) · [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]](redirect URI 정책) · [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]](TLS termination) 가 각 관심사 detail owner.
```
## 10. `feature-keycloak-single-ec2-google-federation.md`
### L3-F15: parent and child both decide Cloudflare-over-ngrok
- **Severity / class:** Medium — `DUAL_OWNERSHIP`
- **Original goal / current state:** parent D3 and child D1 both own the same provider preference, while the child supplies the selection conditions.
- **Claim:** Tunnel-selection authority is duplicated.
- **Assumption:** Both D-rows are normative and independently mutable. Falsified by a declared selection/detail split without restating the mutable preference.
- **Failure / falsification:** A provider-policy change updates only one branch. Inspect inbound D3/D1 references and approval ownership.
- **Action / why:** Make the child the selection owner and reduce the parent to required outcomes plus a decision reference.
- **Alternatives:** (1) child owns selection, (2) parent owns selection and child operationalizes, (3) parent owns requirements while child maps conditions to providers.
- **Counterargument:** Parent selection plus child implementation can be valid, but both current rows phrase the provider preference as their own decision.
```text
$ sed -n '238p' 'raw/branch-notes/feature-keycloak-single-ec2-google-federation.md'
| D3 | 개발용 public URL 노출 수단은 Cloudflare Tunnel 우선, ngrok 차선 | `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C1`, `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C2`, `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C4`, `raw/official-docs/ngrok-http-tunnel-official.md#NGROK-C1`, `raw/official-docs/ngrok-http-tunnel-official.md#NGROK-C3`, `raw/official-docs/ngrok-http-tunnel-official.md#NGROK-C4`, `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C3`, `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C5` | `official-vendor-doc + official-vendor-doc + official-vendor-doc` | Cloudflare Tunnel `<UUID>.cfargotunnel.com` 도메인이 Google OAuth client redirect URI 정책 (`GOOGLE-REDIR-C2` raw IP 금지 / `C3` exact match) 을 통과하는지 직접 검증 안 됨 |
```
```text
$ sed -n '106p' 'raw/branch-notes/feature-keycloak-public-domain-tunneling.md'
| D1 | 학습 환경 1순위 **Cloudflare Tunnel** (named tunnel + 정적 URL + 무료 TLS + inbound port 0) | 정적 public URL + inbound port 0 + 무료 TLS 가 필요하고 Cloudflare 에 위임한 관리 도메인 1개를 확보할 수 있을 때. 도메인이 없거나 1회성 데모면 → **D2**(ngrok) 또는 `trycloudflare.com` quick tunnel(random URL) | `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C1` (cloudflared outbound), `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C2` (firewall inbound 차단 권장), `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C3` (tunnel `<UUID>.cfargotunnel.com` subdomain 자동 부여), `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C4` (사용자 hostname CNAME → cfargotunnel.com), `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C2` (Google redirect URI raw IP 금지 → 도메인 필요) | `official-vendor-doc + official-vendor-doc` | Cloudflare Tunnel 의 `<UUID>.cfargotunnel.com` generic subdomain 이 Google Cloud Console redirect URI 정책에 통과하는지는 `CLOUDFLARE-TUNNEL-C4` "Does not prove" 단서에 명시된 미검증 사항 — P3B 실 검증 필요 |
```
This parent is also implicated in `L3-F08` and `L3-F16`.
## Grep verification
- 실제 실행해 위에 출력한 `sed`/`grep` source lines V: 37
- byte-exact 일치 P: 37
- 라인 정정 C: 0
- 폐기 D: 0
## Claim Traceability Check
- Claim ID 연결 검사: lane-local decision references를 대조했고 `L3-F04`의 bare references 3건을 확인했다. 외부 raw Claim ID source truth 재검증은 slice 밖이다.
- Decision Evidence Map: 10개 branch note의 DEM을 검토했다. 중복 또는 stale ownership은 `L3-F06`, `L3-F11`, `L3-F15`, `L3-F16`에 기록했다.
- UNSUPPORTED_DECISION: 새 unsupported decision을 사실로 승격하지 않았다. 원문의 Max Reuse, family invalidation, proxy 관련 labels는 그대로 보존했다.
## Cross-lane candidates
| Source edge | External owner / decision | Reason |
| --- | --- | --- |
| `feature-keycloak-nginx-auth-request-integration.md:50` | `feature-keycloak-edge-forwardauth-no-google D1` | standalone nginx 예시와 parent Kubernetes/Traefik split의 정합성 — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-nginx-auth-request-integration.md:249` | `feature-keycloak-edge-forwardauth-no-google D2` | forwarded-header naming의 canonical owner — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-nginx-auth-request-integration.md:253` | `feature-keycloak-header-spoofing-defense D5` | 요구하는 `X-Internal-Auth-Token`이 현재 nginx D4에 없음 — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-oauth2-proxy-oidc-flow.md:194` | `feature-keycloak-docker-compose-stack D3` | discovery startup gating과 healthcheck 책임 — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-realm-client-export.md:168` | `feature-keycloak-docker-compose-stack D4` | realm import mount/command ownership — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-realm-client-export.md:182` | `feature-keycloak-single-ec2-no-google D3`, docker-compose D3 | hostname/redirect URI 전제와 bare sibling 귀속 — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-reverse-proxy-headers.md:151` | `feature-keycloak-https-termination-caddy-nginx` | Caddy/nginx selection ownership — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-reverse-proxy-headers.md:153`, `:258` | `feature-keycloak-header-spoofing-defense D6` | `KC_PROXY_TRUSTED_ADDRESSES` owner 정규화 — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-reverse-proxy-headers.md:154` | `feature-keycloak-iss-claim-hostname-mismatch` | `KC_HOSTNAME` value owner와 parent D4의 중복 여부 — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-single-ec2-google-federation.md:240`, `:279` | first-broker/account-linking siblings | parent D5와 consume-only 구현 절의 경계 — `NEEDS_CROSS_LANE_AUDIT` |
| `feature-keycloak-single-ec2-google-federation.md:241`, `:301` | `feature-keycloak-google-redirect-uri-policy` | parent D6 exact URI와 delegated policy owner의 경계 — `NEEDS_CROSS_LANE_AUDIT` |
```wiki-stats
agent: wiki-research-lane
found: 10
processed: 10
dropped: 0
```
@@ -0,0 +1,564 @@
## Lane Inventory
| Path | Status | Evidence Lines | Extracted Facts |
|---|---|---:|---:|
| `raw/branch-notes/feature-keycloak-single-ec2-no-google.md` | READ_FULL | 1342 | 3 findings |
| `raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff.md` | READ_FULL | 1256 | 3 findings |
| `raw/branch-notes/feature-keycloak-spring-rs-audience-validator.md` | READ_FULL | 1268 | 2 findings |
| `raw/branch-notes/feature-keycloak-spring-rs-role-mapping.md` | READ_FULL | 1231 | 2 findings |
| `raw/branch-notes/feature-keycloak-three-leg-trust-chain.md` | READ_FULL | 1229 | 3 findings |
| `raw/branch-notes/feature-keycloak-traefik-forwardauth-alternative.md` | READ_FULL | 1237 | 2 findings |
| `raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md` | READ_FULL | 1257 | 3 findings |
| `raw/branch-notes/feature-security-operational-baseline.md` | READ_FULL | 1417 | 3 findings |
## `raw/branch-notes/feature-keycloak-single-ec2-no-google.md`
### L4-F01 — `localhost` issuer와 `extra_hosts` 해결책이 실행 가능한 한 가지 구성으로 종결되지 않음
- **Severity / 유형**: High — ambiguous/incompatible default flow
- **Source quotes**:
- `"extra_hosts: [host.docker.internal:host-gateway]"``raw/branch-notes/feature-keycloak-single-ec2-no-google.md:146`
- `"backend도 \`issuer-uri=http://localhost:8080/...\` 사용. 컨테이너 간 통신은 \`network_mode: host\` 또는 \`extra_hosts\` 사용."` — `raw/branch-notes/feature-keycloak-single-ec2-no-google.md:181`
- **Claim**: bridge-network backend 컨테이너에서 `localhost`는 backend 자신이다. `extra_hosts``host.docker.internal` 이름을 추가할 뿐 `localhost`를 호스트로 바꾸지 않는다. 두 메커니즘을 동일한 해결책처럼 병기하면 기본 구성이 실행 불가능하다.
- **Assumptions**: backend도 Docker bridge network에서 실행되고, 별도 `jwk-set-uri` override가 없다.
- **Failure mode**: OIDC discovery/JWKS 접근 실패로 backend startup 실패 또는 전 요청 401.
- **Falsification**: 실제 기본이 `network_mode: host`, backend host-process 실행, 또는 `issuer-uri=localhost``jwk-set-uri=host.docker.internal`을 명시적으로 분리한 구성이라면 비판이 무효화된다.
- **Required action / Why**: `feature-keycloak-iss-claim-hostname-mismatch` owner에서 기본 한 가지를 고정하고 이 노트는 포인터만 유지한다. issuer 문자열 일치와 네트워크 도달성은 서로 다른 조건이기 때문이다.
- **Alternatives**:
1. host network + `issuer-uri=localhost`
2. bridge + `issuer-uri=localhost` + 명시적 `jwk-set-uri=host.docker.internal`
3. front/backchannel 동적 URL을 명시적으로 구성
- **Counterargument**: `extra_hosts`로 호스트 접근은 가능하다. 다만 접근 hostname은 `host.docker.internal`이므로, 현재 적힌 `localhost` discovery URI와 자동으로 결합되지는 않는다.
- **Self-grep**:
```text
$ grep -nFo -e 'backend도 `issuer-uri=http://localhost:8080/...` 사용. 컨테이너 간 통신은 `network_mode: host` 또는 `extra_hosts` 사용.' -e 'extra_hosts: [host.docker.internal:host-gateway]' -- raw/branch-notes/feature-keycloak-single-ec2-no-google.md
146:extra_hosts: [host.docker.internal:host-gateway]
181:backend도 `issuer-uri=http://localhost:8080/...` 사용. 컨테이너 간 통신은 `network_mode: host` 또는 `extra_hosts` 사용.
```
### L4-F02 — `aud` validator owner 정정 후 컴포넌트 표가 옛 owner를 계속 지목
- **Severity / 유형**: Medium — `STALE_SUMMARY`
- **Source quotes**:
- `"Spring RS 셋업·\`aud\` 검증 = [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 owner"` — `...single-ec2-no-google.md:110`
- `"\`aud\` validator 는 child [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] D2"` — `...single-ec2-no-google.md:215`
- **Claim**: 같은 문서가 owner를 `audience-validator`로 정정한 뒤 실행 표에서는 여전히 deferred RBAC 문서의 D2를 가리킨다.
- **Assumptions**: line 110의 2026-07-18 owner split이 최신 결정이다.
- **Failure mode**: 구현자가 role-mapping의 복제된 validator를 수정하고 정본과 다시 갈라진다.
- **Falsification**: role-mapping D2가 여전히 공동 owner라면 Single-Owner 선언 자체를 철회해야 한다.
- **Required action / Why**: line 215와 동일 요약들을 `[[...spring-rs-audience-validator]] D1`로 바꾸고 role-mapping은 RBAC D4D6만 가리킨다.
- **Alternatives**: RS/audience 전체를 role-mapping으로 다시 이관할 수도 있으나 두 문서 중 하나만 owner여야 한다.
- **Counterargument**: 표가 역사적 child 목록일 수 있다. 그러나 셀 문구가 현재 구현 근거를 직접 지목하므로 역사 기록으로 읽히지 않는다.
- **Self-grep**:
```text
$ grep -nFo -e 'Spring RS 셋업·`aud` 검증 = [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 owner' -e '`aud` validator 는 child [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] D2' -- raw/branch-notes/feature-keycloak-single-ec2-no-google.md
110:Spring RS 셋업·`aud` 검증 = [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 owner
215:`aud` validator 는 child [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] D2
```
### L4-F03 — PKCE 구현 순서가 parent와 실행 owner에서 반대
- **Severity / 유형**: Medium — `CONTRADICTION`
- **Source quotes**:
- `"vanilla JS는 manual fetch + \`crypto.subtle\` 기반 PKCE 구현 우선"` — `...single-ec2-no-google.md:182`
- `"\`oidc-client-ts\` 우선, manual은 별도 단계."` — `raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md:106`
- **Claim**: parent는 manual-first, 실행 owner는 library-first를 결정한다.
- **Assumptions**: 두 문구 모두 실제 코딩 순서를 뜻한다.
- **Failure mode**: 작업 순서·완료 조건·테스트 baseline이 달라진다.
- **Falsification**: 전자를 사전 학습 순서, 후자를 repo 구현 순서로 명시하면 양립 가능하다.
- **Required action / Why**: 실행 순서는 child D1을 정본으로 두고 parent D4를 “manual 사전 학습 선택지”로 재분류한다.
- **Alternatives**: manual spike → 폐기 → library 구현, 또는 library E2E → manual 비교 실습.
- **Counterargument**: 결국 둘 다 수행하므로 충돌이 작다. 하지만 단계별 구현 branch의 착수 순서와 acceptance criteria에는 직접 영향을 준다.
- **Self-grep**:
```text
$ grep -nFo -e 'vanilla JS는 manual fetch + `crypto.subtle` 기반 PKCE 구현 우선' -- raw/branch-notes/feature-keycloak-single-ec2-no-google.md
182:vanilla JS는 manual fetch + `crypto.subtle` 기반 PKCE 구현 우선
$ grep -nFo -e '`oidc-client-ts` 우선, manual은 별도 단계.' -- raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md
106:`oidc-client-ts` 우선, manual은 별도 단계.
```
## `raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff.md`
### L4-F04 — “SPA Direct, BFF 미채택” 기본이 실제로는 TMB를 요구
- **Severity / 유형**: High — incompatible architecture default
- **Source quotes**:
- `"P2A 권장 조합 = access_token: 메모리 + refresh_token: secure httpOnly cookie"``...spa-token-storage-tradeoff.md:140`
- `"사실상 최소 Token-Mediating Backend(TMB). \"순수 no-server SPA Direct\" 와의 긴장"``...spa-token-storage-tradeoff.md:165`
- **Claim**: 브라우저 JS는 HttpOnly cookie를 설정할 수 없다. D1을 구현하려면 backend가 refresh token을 받아 `Set-Cookie`하고 `/refresh`를 소유해야 하므로 순수 SPA Direct가 아니다.
- **Assumptions**: “SPA Direct”가 token endpoint를 브라우저가 직접 호출하고 server-side token custody가 없다는 뜻이다.
- **Failure mode**: HttpOnly 저장이 구현되지 않거나, 암묵적으로 TMB를 추가하면서 CSRF·rotation·logout 계약이 빠진다.
- **Falsification**: 프로젝트 taxonomy가 Resource Server의 `/refresh` 추가를 여전히 AP1로 명시적으로 허용하면 무효화된다.
- **Required action / Why**: D1을 순수 AP1과 TMB 변형으로 분리하고 기본을 하나 고정한다. token custody가 인증 패턴을 바꾸기 때문이다.
- **Alternatives**:
1. 순수 SPA: 양 토큰 memory-only + reload 재로그인
2. TMB: refresh token HttpOnly cookie + `/refresh`
3. BFF: 모든 token server-side
- **Counterargument**: 기존 Resource Server에 endpoint 하나만 추가하면 된다. 그래도 refresh token 보유·CSRF·cookie lifecycle 책임이 추가되어 trust boundary가 달라진다.
- **Self-grep**:
```text
$ grep -nFo -e 'P2A 권장 조합 = access_token: 메모리 + refresh_token: secure httpOnly cookie' -e '사실상 최소 Token-Mediating Backend(TMB). "순수 no-server SPA Direct" 와의 긴장' -- raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff.md
140:P2A 권장 조합 = access_token: 메모리 + refresh_token: secure httpOnly cookie
165:사실상 최소 Token-Mediating Backend(TMB). "순수 no-server SPA Direct" 와의 긴장
```
### L4-F05 — D3·D6 근거 상태를 해소한 뒤 Claims To Verify가 계속 `UNSUPPORTED`로 기록
- **Severity / 유형**: Medium — `STALE_SUMMARY`
- **Source quotes**:
- `"D3 를 브라우저·토폴로지 조건부로 **정밀화**"``...spa-token-storage-tradeoff.md:131`
- `"D6 를 \`UNSUPPORTED\` 에서 해소"` — `...spa-token-storage-tradeoff.md:132`
- `"본 branch D3 의 근거가 UNSUPPORTED"``...spa-token-storage-tradeoff.md:214`
- `"D6 가 UNSUPPORTED"``...spa-token-storage-tradeoff.md:216`
- **Claim**: active decision과 검증 큐가 같은 근거 상태를 다르게 표시한다.
- **Assumptions**: 2026-07-18 decision update가 최신이다.
- **Failure mode**: 이미 조사한 근거를 재조사하거나, 실제로 남은 runtime 검증과 문헌 근거 갭을 혼동한다.
- **Falsification**: Claims 행이 과거 상태를 보존하는 역사 섹션이면 날짜와 resolved 표기가 있어야 한다.
- **Required action / Why**: D3는 “근거 확보, topology별 E2E planned”, D6는 “inference 근거 확보, 저장 동작 E2E planned”로 분리한다.
- **Alternatives**: 해소된 행을 resolved archive로 이동하고 새 empirical claim만 남긴다.
- **Counterargument**: 구현 검증은 아직 안 됐다. 그렇더라도 “문헌 unsupported”와 “runtime unverified”는 다른 상태다.
- **Self-grep**:
```text
$ grep -nFo -e 'D3 를 브라우저·토폴로지 조건부로 **정밀화**' -e '본 branch D3 의 근거가 UNSUPPORTED' -e 'D6 를 `UNSUPPORTED` 에서 해소' -e 'D6 가 UNSUPPORTED' -- raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff.md
131:D3 를 브라우저·토폴로지 조건부로 **정밀화**
132:D6 를 `UNSUPPORTED` 에서 해소
214:본 branch D3 의 근거가 UNSUPPORTED
216:D6 가 UNSUPPORTED
```
### L4-F06 — cookie-CSRF 구현 위임 대상에 소유 Decision이 없음
- **Severity / 유형**: Medium — ambiguous owner / owner-less concern
- **Source quotes**:
- `"Spring Security \`CsrfTokenRepository\` 설정/검증은 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 계열 RS 결정으로 위임"` — `...spa-token-storage-tradeoff.md:63`
- `"본 노트는 발급된 토큰의 **검증 측**만."``...spring-rs-audience-validator.md:64`
- **Claim**: storage 문서는 CSRF 실 구현을 audience-validator “계열”로 보냈지만, 대상 문서는 bearer JWT 검증만 소유하며 CSRF D-row가 없다. 포인터도 `D<n>` 없이 광범위하다.
- **Assumptions**: HttpOnly refresh cookie/TMB 변형을 실제 선택한다.
- **Failure mode**: bearer API의 `csrf.disable()`를 cookie refresh endpoint에도 적용하거나, CSRF token 발급·검증을 아무 branch도 구현하지 않는다.
- **Falsification**: 별도 unseen owner가 CSRF endpoint 계약을 이미 소유한다면 그 owner D-row를 직접 가리키면 된다.
- **Required action / Why**: TMB/BFF refresh endpoint owner에 CSRF Decision을 두고 여기서는 정확한 D 포인터만 유지한다.
- **Alternatives**: audience-validator가 범위를 확장할 경우 명시적 D-row·cookie endpoint scope·음성 테스트를 추가한다.
- **Counterargument**: 같은 `SecurityFilterChain`에서 구현할 수 있다. 구현 위치와 결정 owner는 별개이며 bearer와 cookie 요청은 CSRF 위협 모델이 다르다.
- **Self-grep**:
```text
$ grep -nFo -e 'Spring Security `CsrfTokenRepository` 설정/검증은 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 계열 RS 결정으로 위임' -- raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff.md
63:Spring Security `CsrfTokenRepository` 설정/검증은 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 계열 RS 결정으로 위임
$ grep -nFo -e '본 노트는 발급된 토큰의 **검증 측**만.' -- raw/branch-notes/feature-keycloak-spring-rs-audience-validator.md
64:본 노트는 발급된 토큰의 **검증 측**만.
223:본 노트는 발급된 토큰의 **검증 측**만.
```
## `raw/branch-notes/feature-keycloak-spring-rs-audience-validator.md`
### L4-F07 — “custom validator 필수” 요약이 최신 property-first 선택 조건과 어긋남
- **Severity / 유형**: Medium — `STALE_SUMMARY`
- **Source quotes**:
- `"custom \`OAuth2TokenValidator<Jwt>\`를 추가해야 하는 이유"` — `...spring-rs-audience-validator.md:17`
- `"단일 audience** 면 property 한 줄이 단순·안전(권장)"``...spring-rs-audience-validator.md:183`
- **Claim**: 목적·본문은 custom validator를 필수로 표현하지만 최신 구현 가이드는 단일 audience에서 Boot `audiences` property를 권장 baseline으로 둔다.
- **Assumptions**: 본 프로젝트는 우선 단일 expected audience다.
- **Failure mode**: 불필요한 custom decoder wiring에서 default issuer/timestamp validator를 덮어쓰거나 누락한다.
- **Falsification**: 다중 audience/`azp` 조건부 검증이 고정 요구라면 custom이 필수일 수 있다.
- **Required action / Why**: “aud 검증은 필수, 구현 방식은 단일=property / 복합=custom”으로 목표·TODO·면접 문장을 동기화한다.
- **Alternatives**: custom 방식은 비교 학습 예제로 남긴다.
- **Counterargument**: custom 구현 학습이 목적이다. 학습 예제라는 점과 production 기본을 분리하면 된다.
- **Self-grep**:
```text
$ grep -nFo -e 'custom `OAuth2TokenValidator<Jwt>`를 추가해야 하는 이유' -e '단일 audience** 면 property 한 줄이 단순·안전(권장)' -- raw/branch-notes/feature-keycloak-spring-rs-audience-validator.md
17:custom `OAuth2TokenValidator<Jwt>`를 추가해야 하는 이유
183:단일 audience** 면 property 한 줄이 단순·안전(권장)
```
### L4-F08 — expected audience가 `backend-client-id`와 `spa-client`로 갈림
- **Severity / 유형**: High — `CONTRADICTION`
- **Source quotes**:
- `"backend client_id 로 \`aud\` 를 검증"` — `...spring-rs-audience-validator.md:156`
- `"\`aud=spa-client\`를 강제했습니다"` — `...spring-rs-role-mapping.md:38`
- **Claim**: 정본은 API/backend용 audience를 요구하지만 소비 문서는 SPA public client ID를 expected audience로 고정한다.
- **Assumptions**: `backend-client-id``spa-client`는 별도 client다.
- **Failure mode**: 정상 토큰이 모두 401이 되거나, 잘못된 resource audience를 허용해 cross-resource token reuse 방어가 무효화된다.
- **Falsification**: API audience 식별자를 의도적으로 `spa-client`와 동일하게 정의했다면 두 문구는 같은 값을 뜻할 수 있다. 이 경우 owner가 그 동일성부터 명시해야 한다.
- **Required action / Why**: realm/client-export owner와 함께 expected audience 심볼 하나를 고정하고 mapper·validator·음성 테스트에 동일하게 사용한다.
- **Alternatives**: backend client ID, API audience URI, 또는 의도적으로 SPA client ID를 API audience로 재사용.
- **Counterargument**: Keycloak public client가 access token audience일 수 있다. 그렇다면 “backend client_id mapper 필수” 결정이 잘못된 것이므로 둘 중 하나를 고쳐야 한다.
- **Self-grep**:
```text
$ grep -nFo -e 'backend client_id 로 `aud` 를 검증' -- raw/branch-notes/feature-keycloak-spring-rs-audience-validator.md
156:backend client_id 로 `aud` 를 검증
$ grep -nFo -e '`aud=spa-client`를 강제했습니다' -- raw/branch-notes/feature-keycloak-spring-rs-role-mapping.md
38:`aud=spa-client`를 강제했습니다
```
## `raw/branch-notes/feature-keycloak-spring-rs-role-mapping.md`
### L4-F09 — RS/audience 위임 선언 뒤 active scope·TODO에서 세부를 계속 재명세
- **Severity / 유형**: Medium — `RESTATED_FOREIGN_DECISION`
- **Source quotes**:
- `"Spring RS 공통 셋업 + \`aud\` 검증 내용은 형제 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 **owner**"` — `...spring-rs-role-mapping.md:19`
- `"custom \`OAuth2TokenValidator<Jwt>\` for \`aud=spa-client\`"` — `...spring-rs-role-mapping.md:50`
- `"여기서 재명세하지 않고"``...spring-rs-role-mapping.md:128`
- **Claim**: owner 정리는 정확하지만 제목·WHY·In scope·TODO가 custom validator, issuer, decoder를 실행 항목으로 계속 보유한다.
- **Assumptions**: line 19/128의 owner 이관이 최신이다.
- **Failure mode**: audience 구현이 두 branch에서 독립 진화하며 audience 값과 wiring이 갈라진다.
- **Falsification**: 이 파일이 역사적 migration snapshot이라면 active scope/TODO가 아니라 Audit 섹션에 있어야 한다.
- **Required action / Why**: 파일을 RBAC-only로 축소하고 RS-common은 `[[...audience-validator]] D1/D6 — 1줄 요약`만 남긴다.
- **Alternatives**: 반대로 전체 RS를 이 파일로 합치고 audience-validator를 폐기할 수 있으나 Single-Owner는 하나여야 한다.
- **Counterargument**: 구현 편의를 위한 fold-in이다. 세부 복제는 owner 진화 시 현재처럼 `aud` 값이 갈라지는 원인이 된다.
- **Self-grep**:
```text
$ grep -nFo -e 'Spring RS 공통 셋업 + `aud` 검증 내용은 형제 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 **owner**' -e 'custom `OAuth2TokenValidator<Jwt>` for `aud=spa-client`' -e '여기서 재명세하지 않고' -- raw/branch-notes/feature-keycloak-spring-rs-role-mapping.md
19:Spring RS 공통 셋업 + `aud` 검증 내용은 형제 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 **owner**
50:custom `OAuth2TokenValidator<Jwt>` for `aud=spa-client`
128:여기서 재명세하지 않고
```
### L4-F10 — realm-role-only 결정의 “단일 client” 전제가 4-pattern/client base와 stale
- **Severity / 유형**: Medium — assumption drift / `STALE_SUMMARY`
- **Source quotes**:
- `"단일 client 환경(client-level role 분리 의미 적음)"``...spring-rs-role-mapping.md:122`
- `"4 패턴 통합 base 로 확장 시 realm 은 공유 1개 유지, client 는 패턴당 1개"``...single-ec2-no-google.md:199`
- **Claim**: RBAC D4의 선택 조건은 client가 하나라는 전제인데 현재 배포 base는 패턴별 여러 client를 둔다.
- **Assumptions**: deferred authZ를 4개 패턴 통합 base에 적용할 예정이다.
- **Failure mode**: 패턴별 권한 분리가 필요한데 realm role이 모든 client에 전역 적용되거나, 반대로 필요한 공통 role이 중복 정의된다.
- **Falsification**: RBAC가 모든 client에 의도적으로 동일한 realm-global 정책이면 유지 가능하다.
- **Required action / Why**: D4 선택 조건을 “realm-global 권한” 대 “client-specific 권한”으로 재작성하고 client 수를 근거로 삼지 않는다.
- **Alternatives**: realm-only, `resource_access` only, 공통 realm role + 민감 endpoint client role 병행.
- **Counterargument**: authZ는 deferred라 지금 결정할 필요가 없다. 그렇다면 “단일 client라 의미 없음”이라는 확정 이유도 deferred로 낮춰야 한다.
- **Self-grep**:
```text
$ grep -nFo -e '단일 client 환경(client-level role 분리 의미 적음)' -- raw/branch-notes/feature-keycloak-spring-rs-role-mapping.md
122:단일 client 환경(client-level role 분리 의미 적음)
$ grep -nFo -e '4 패턴 통합 base 로 확장 시 realm 은 공유 1개 유지, client 는 패턴당 1개' -- raw/branch-notes/feature-keycloak-single-ec2-no-google.md
199:4 패턴 통합 base 로 확장 시 realm 은 공유 1개 유지, client 는 패턴당 1개
```
## `raw/branch-notes/feature-keycloak-three-leg-trust-chain.md`
### L4-F11 — 3-leg 표기에는 Backend가 없지만 실제 Hop 3의 검증자는 Backend
- **Severity / 유형**: Medium — ambiguous trust-chain taxonomy
- **Source quotes**:
- `"P2A는 2-leg trust (Browser ↔ Keycloak)였으나, P2B는 **3-leg trust** (Browser ↔ Keycloak ↔ Google)."``...three-leg-trust-chain.md:33`
- `"**Hop 3: SPA → Backend**"``...three-leg-trust-chain.md:47`
- **Claim**: WHY의 actor chain은 BrowserKeycloakGoogle만 표시하지만 계약 표는 Google→Keycloak→SPA→Backend의 세 hop과 네 actor를 다룬다.
- **Assumptions**: “leg”가 실제 검증 hop을 뜻한다.
- **Failure mode**: trust-boundary 그림에서 backend validator가 빠지거나, Browser↔Keycloak leg와 Keycloak→SPA 발급 leg가 서로 다른 방식으로 계산된다.
- **Falsification**: “3-leg”가 federation actor 수를 뜻한다면 Hop 3 명칭을 다른 체계로 분리해야 한다.
- **Required action / Why**: “3-hop / four-party validation chain”으로 정의하거나 federation chain과 API validation chain을 별도 도식으로 나눈다.
- **Alternatives**: `Google→Keycloak`, `Keycloak→SPA`, `SPA→Backend`를 canonical hop 정의로 고정.
- **Counterargument**: 제목은 학습용 약칭이다. 신뢰 경계 owner 문서에서는 약칭도 verifier 누락을 만들지 않도록 정의가 필요하다.
- **Self-grep**:
```text
$ grep -nFo -e 'P2A는 2-leg trust (Browser ↔ Keycloak)였으나, P2B는 **3-leg trust** (Browser ↔ Keycloak ↔ Google).' -e '**Hop 3: SPA → Backend**' -- raw/branch-notes/feature-keycloak-three-leg-trust-chain.md
33:P2A는 2-leg trust (Browser ↔ Keycloak)였으나, P2B는 **3-leg trust** (Browser ↔ Keycloak ↔ Google).
47:**Hop 3: SPA → Backend**
```
### L4-F12 — 면접 답변은 Keycloak nonce 자동 처리를 사실로 말하지만 D3는 미검증
- **Severity / 유형**: Medium — evidence-status contradiction
- **Source quotes**:
- `"\`nonce\`를 확인합니다."` — `...three-leg-trust-chain.md:36`
- `"Keycloak 의 *자동 처리 동작*은 미검증"``...three-leg-trust-chain.md:120`
- **Claim**: normative OIDC RP 의무는 확보됐지만 Keycloak adapter가 해당 요청/검증을 자동 수행하는지는 아직 확인하지 않았다. 면접 문장은 이 구분을 제거한다.
- **Assumptions**: line 36은 추후 파생 가능한 답변 초안이다.
- **Failure mode**: 구현 관측 없이 Keycloak-specific 동작을 외부에 확정적으로 주장한다.
- **Falsification**: Keycloak source, 공식 IdP 문서 또는 HAR로 nonce 송신·대조가 확인되면 승격 가능하다.
- **Required action / Why**: 검증 전에는 “OIDC RP가 nonce를 검증해야 하며, Keycloak 자동 처리는 확인 예정”으로 제한한다.
- **Alternatives**: CV6을 닫은 뒤 현재 문장 복원.
- **Counterargument**: 표준 준수 제품이라 예상 가능하다. 표준이 요구하는 것과 특정 버전·설정이 실제 수행하는 것은 별도 증거다.
- **Self-grep**:
```text
$ grep -nFo -e '`nonce`를 확인합니다.' -e 'Keycloak 의 *자동 처리 동작*은 미검증' -- raw/branch-notes/feature-keycloak-three-leg-trust-chain.md
36:`nonce`를 확인합니다.
120:Keycloak 의 *자동 처리 동작*은 미검증
```
### L4-F13 — out-of-scope claim/linking 정책을 구체 검증 항목으로 재진술
- **Severity / 유형**: Medium — `RESTATED_FOREIGN_DECISION`
- **Source quotes**:
- `"claim mapping — [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]]"``...three-leg-trust-chain.md:57`
- `"Account Linking — [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]]"``...three-leg-trust-chain.md:58`
- `"\`email_verified\`, \`hd\`, Account Linking 결정"` — `...three-leg-trust-chain.md:95`
- **Claim**: 이 노트는 mapper/account-linking을 위임하면서 내부 hop 표에서는 `email_verified`·`hd`·linking을 Keycloak이 검증하는 고정 정책처럼 적는다. 이들은 설정/flow 의존이며 owner D 포인터도 없다.
- **Assumptions**: 기본 brokering만으로 세 항목이 모두 강제되지는 않는다.
- **Failure mode**: `hd` enforcement 또는 verified-email linking guard가 구성되지 않았는데 trust check가 존재한다고 오인한다.
- **Falsification**: unseen owners가 세 항목을 명시적으로 강제하고 이 문서가 정확한 D-row를 가리키면 1줄 요약으로 유지 가능하다.
- **Required action / Why**: row를 “First Broker Login + mapper 정책(config-dependent)”으로 낮추고 각 owner D-row만 연결한다.
- **Alternatives**: 이 검증들을 본 문서가 소유하려면 out-of-scope 선언을 제거하고 Single-Owner를 재배치해야 한다.
- **Counterargument**: 단순 요약이다. 현재 요약은 어떤 검사가 실제 활성인지까지 암시하므로 메커니즘 복제에 해당한다.
- **Self-grep**:
```text
$ grep -nFo -e '`email_verified`, `hd`, Account Linking 결정' -e 'claim mapping — [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]]' -e 'Account Linking — [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]]' -- raw/branch-notes/feature-keycloak-three-leg-trust-chain.md
57:claim mapping — [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]]
58:Account Linking — [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]]
95:`email_verified`, `hd`, Account Linking 결정
```
## `raw/branch-notes/feature-keycloak-traefik-forwardauth-alternative.md`
### L4-F14 — P3A를 nginx+oauth2-proxy edge-auth 패턴으로 잘못 귀속
- **Severity / 유형**: High — `CONTRADICTION`
- **Source quotes**:
- `"nginx+oauth2-proxy 확정(P3A)"``...traefik-forwardauth-alternative.md:169`
- `"client (vanilla JS SPA via nginx static)"` / `"Browser → Keycloak → Backend Resource Server JWT validation"``...single-ec2-no-google.md:33`
- **Claim**: Traefik 문서는 P3A를 edge ForwardAuth 구성으로 고정하지만 P3A owner는 SPA-direct PKCE + backend JWT validation이며 nginx는 static server다.
- **Assumptions**: P3A 최신 재매핑은 AP1 + single-EC2 deployment base다.
- **Failure mode**: oauth2-proxy를 잘못 추가해 browser token custody, backend validation 책임, 인증 redirect 흐름이 모두 바뀐다.
- **Falsification**: “AP4 edge-auth를 single-EC2 위에 올린 별도 실행 변형”을 뜻했다면 P3A가 아니라 그 조합으로 명명해야 한다.
- **Required action / Why**: nginx+oauth2-proxy는 P1A/AP4 owner로 돌리고, single-EC2는 배포 축으로만 참조한다.
- **Alternatives**: `AP1-on-single-EC2``AP4-on-single-EC2`를 별도 matrix row로 둔다.
- **Counterargument**: 같은 EC2에서 oauth2-proxy도 실행할 수 있다. 물리 배포 가능성과 인증 패턴 정체성은 별도 축이다.
- **Self-grep**:
```text
$ grep -nFo -e 'nginx+oauth2-proxy 확정(P3A)' -- raw/branch-notes/feature-keycloak-traefik-forwardauth-alternative.md
169:nginx+oauth2-proxy 확정(P3A)
$ grep -nFo -e 'client (vanilla JS SPA via nginx static)' -e 'Browser → Keycloak → Backend Resource Server JWT validation' -- raw/branch-notes/feature-keycloak-single-ec2-no-google.md
33:client (vanilla JS SPA via nginx static)
33:Browser → Keycloak → Backend Resource Server JWT validation
```
### L4-F15 — redirect를 middleware가 생성한다고 적었지만 D3는 auth server 응답 전달로 정의
- **Severity / 유형**: Medium — `STALE_SUMMARY`
- **Source quotes**:
- `"middleware가 직접 302를 발급"``...traefik-forwardauth-alternative.md:97`
- `"비 2XX → 인증 서버 응답 그대로 client 에 반환"``...traefik-forwardauth-alternative.md:112`
- **Claim**: ForwardAuth middleware가 redirect를 생성하는 것과 auth service의 302를 전달하는 것은 다른 책임 모델이다.
- **Assumptions**: D3의 vendor-backed contract가 정본이다.
- **Failure mode**: redirect loop나 `Location` 손실을 Traefik 설정 문제로만 진단하고 oauth2-proxy 응답을 점검하지 않는다.
- **Falsification**: 사용 중인 auth plugin이 Traefik 내부에서 직접 redirect를 생성한다면 별도 D10 경로로 분리해야 한다.
- **Required action / Why**: line 97을 “auth server의 302를 Traefik이 그대로 전달”로 갱신한다.
- **Alternatives**: oauth2-proxy 외부 서버와 in-process OIDC plugin의 redirect owner를 표에서 분리한다.
- **Counterargument**: browser 입장에서는 Traefik에서 302가 온다. 그러나 장애 분석에는 302 생성 주체와 전달 주체 구분이 필요하다.
- **Self-grep**:
```text
$ grep -nFo -e 'middleware가 직접 302를 발급' -e '비 2XX → 인증 서버 응답 그대로 client 에 반환' -- raw/branch-notes/feature-keycloak-traefik-forwardauth-alternative.md
97:middleware가 직접 302를 발급
112:비 2XX → 인증 서버 응답 그대로 client 에 반환
```
## `raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md`
### L4-F16 — relative `/api/me`·`/token` URL이 static-only nginx topology에서 잘못된 origin으로 감
- **Severity / 유형**: High — `CONTRADICTION`
- **Source quotes**:
- `"fetch('/api/me', { headers: { Authorization: 'Bearer ' + user.access_token } })"``...vanilla-js-spa-pkce.md:176`
- `"manual \`fetch('/token'"` — `...vanilla-js-spa-pkce.md:190`
- `"nginx는 단순 static 파일 서빙"``...single-ec2-no-google.md:159`
- **Claim**: SPA origin이 `http://localhost`이고 nginx가 static-only이면 두 relative URL은 nginx:80으로 향한다. 문서의 backend:8081·Keycloak:8080 직노출 topology와 양립하지 않는다.
- **Assumptions**: nginx에 `/api`·`/token` reverse proxy가 없다.
- **Failure mode**: API는 404, token exchange는 404/405가 되어 E2E가 시작되지 않는다.
- **Falsification**: 실제 nginx config가 두 path를 proxy한다면 parent의 static-only·3-port/CORS 계약이 stale다.
- **Required action / Why**: direct topology면 absolute `http://localhost:8081/api/me`와 discovered Keycloak token endpoint를 사용하고, same-origin topology면 nginx proxy를 owner 결정으로 승격한다.
- **Alternatives**:
1. 3-port direct + CORS
2. nginx `/api`·OIDC proxy + same-origin
3. runtime config에서 endpoint base URL 주입
- **Counterargument**: dev proxy가 상대 URL을 처리할 수 있다. 현재 산출물은 vanilla static nginx이며 dev proxy가 명세에 없다.
- **Self-grep**:
```text
$ grep -nFo -e 'fetch('\''/api/me'\'', { headers: { Authorization: '\''Bearer '\'' + user.access_token } })' -e 'manual `fetch('\''/token'\''' -- raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md
84:fetch('/api/me', { headers: { Authorization: 'Bearer ' + user.access_token } })
176:fetch('/api/me', { headers: { Authorization: 'Bearer ' + user.access_token } })
190:manual `fetch('/token'
$ grep -nFo -e 'nginx는 단순 static 파일 서빙' -- raw/branch-notes/feature-keycloak-single-ec2-no-google.md
159:nginx는 단순 static 파일 서빙
```
### L4-F17 — oidc-client-ts default가 localStorage라는 단정과 “미증명” 상태가 공존
- **Severity / 유형**: Medium — evidence-status `CONTRADICTION`
- **Source quotes**:
- `"oidc-client-ts 기본은 \`WebStorageStateStore\` (localStorage)."` — `...vanilla-js-spa-pkce.md:98`
- `"default store 종류(localStorage 여부)는 본 Sources 로 미증명"``...vanilla-js-spa-pkce.md:168`
- **Claim**: 같은 파일이 library default를 사실과 미확인으로 동시에 표시한다. `stateStore``userStore` default도 구분되지 않았다.
- **Assumptions**: library version별 default가 달라질 수 있고 현재 raw source가 이를 고정하지 않는다.
- **Failure mode**: 불필요하거나 잘못된 override로 redirect transaction state가 소실되거나, 실제 token이 persistent storage에 남는다.
- **Falsification**: pin된 oidc-client-ts 버전 공식 API/default 문서가 localStorage를 명시하면 첫 문장을 근거화할 수 있다.
- **Required action / Why**: 검증 전 line 98을 inference로 낮추고 두 store의 default와 token 저장 여부를 각각 확인한다.
- **Alternatives**: 명시적 custom store로 version default에 의존하지 않되 full-page redirect state 생존 테스트를 추가한다.
- **Counterargument**: 어차피 memory override를 쓸 예정이다. `stateStore`까지 memory로 바꾸면 callback state가 사라질 수 있어 default 확인이 여전히 필요하다.
- **Self-grep**:
```text
$ grep -nFo -e 'oidc-client-ts 기본은 `WebStorageStateStore` (localStorage).' -e 'default store 종류(localStorage 여부)는 본 Sources 로 미증명' -- raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md
98:oidc-client-ts 기본은 `WebStorageStateStore` (localStorage).
168:default store 종류(localStorage 여부)는 본 Sources 로 미증명
```
### L4-F18 — iframe silent SSO는 out-of-scope인데 `automaticSilentRenew` 메커니즘은 미결정
- **Severity / 유형**: Medium — ambiguous flow/default
- **Source quotes**:
- `"iframe 기반 silent SSO (deprecated, 대신 refresh token 사용)"``...vanilla-js-spa-pkce.md:58`
- `"어느 메커니즘이 default 인지 본 인용 범위 밖"``...vanilla-js-spa-pkce.md:217`
- **Claim**: iframe을 제외하고 refresh grant를 채택했다고 쓰면서 실제 library가 iframe과 refresh grant 중 무엇을 쓰는지 확인하지 않았다.
- **Assumptions**: E2E acceptance에는 reload·expiry 갱신 경로가 포함된다.
- **Failure mode**: Safari/cross-site에서 silent renew 실패, reload 후 in-memory user 복구 실패, 또는 rotation 시연이 실행되지 않는다.
- **Falsification**: pin된 library 설정과 network trace로 refresh-token grant만 사용함을 확인하면 out-of-scope 선언과 정합해진다.
- **Required action / Why**: refresh grant를 baseline으로 고정하고 필요한 scope/client 설정·reload bootstrap을 명세하거나 iframe을 조건부 경로로 다시 scope에 넣는다.
- **Alternatives**: memory-only + 명시적 재로그인.
- **Counterargument**: library가 런타임에 적절한 방식을 택한다. 브라우저 정책에 따라 결과가 달라지므로 학습 E2E에는 결정적 기대 동작이 필요하다.
- **Self-grep**:
```text
$ grep -nFo -e 'iframe 기반 silent SSO (deprecated, 대신 refresh token 사용)' -e '어느 메커니즘이 default 인지 본 인용 범위 밖' -- raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md
58:iframe 기반 silent SSO (deprecated, 대신 refresh token 사용)
217:어느 메커니즘이 default 인지 본 인용 범위 밖
```
## `raw/branch-notes/feature-security-operational-baseline.md`
### L4-F19 — Phase C2 완료 뒤 active 구현 표가 codes·CORS·redaction을 계속 미구현으로 표시
- **Severity / 유형**: High — `STALE_SUMMARY`
- **Source quotes**:
- `"production enum \`OperationalError\` 미구현"` — `...feature-security-operational-baseline.md:222`
- `"CorsSettings\` 가 \`[\"*\"]\`+\`allowCredentials=true\` 조합을 startup 거부하지 **않음**"` — `...feature-security-operational-baseline.md:269`
- `"token leak contract test"` (`planned`) — `...feature-security-operational-baseline.md:279`
- `"12 fine-grained AUTH/AUTHZ/INTERNAL codes"``...feature-security-operational-baseline.md:360`
- `"token/PII redaction"``...feature-security-operational-baseline.md:363`
- `"CORS wildcard+credentials 정적 거부"``...feature-security-operational-baseline.md:365`
- `CODE_GRANULARITY_DRIFT ... RESOLVED``...feature-security-operational-baseline.md:372`
- **Claim**: Phase C2 as-built 표는 세 항목을 locally verified로 승격했지만 앞선 Implementation Specification은 여전히 planned/미구현 상태다.
- **Assumptions**: Phase C2 표가 최신 코드 대조 결과다.
- **Failure mode**: 동일 기능을 재구현하거나 canonical 추출에서 구현 사실을 누락하고, 현재 테스트 범위를 잘못 판단한다.
- **Falsification**: Phase C2 표가 이후 revert된 상태라면 해당 표를 downgrade해야 한다.
- **Required action / Why**: active 구현 가이드와 Claims를 Phase C2 as-built에 맞추고 과거 상태는 Audit history로만 남긴다.
- **Alternatives**: 각 오래된 표에 “pre-2026-06-08 historical” 라벨을 붙인다.
- **Counterargument**: 아래 Audit에서 해소 사실을 찾을 수 있다. 상단 active specification과 검증 큐가 stale이면 소비자는 끝까지 읽지 않는 한 반대 결론을 얻는다.
- **Self-grep**:
```text
$ grep -nFo -e 'production enum `OperationalError` 미구현' -e '12 fine-grained AUTH/AUTHZ/INTERNAL codes' -e 'CODE_GRANULARITY_DRIFT' -e 'CorsSettings` 가 `["*"]`+`allowCredentials=true` 조합을 startup 거부하지 **않음**' -e 'token leak contract test' -e 'CORS wildcard+credentials 정적 거부' -e 'token/PII redaction' -- raw/branch-notes/feature-security-operational-baseline.md
144:CODE_GRANULARITY_DRIFT
214:CODE_GRANULARITY_DRIFT
222:production enum `OperationalError` 미구현
269:CorsSettings` 가 `["*"]`+`allowCredentials=true` 조합을 startup 거부하지 **않음**
279:token leak contract test
317:CODE_GRANULARITY_DRIFT
360:12 fine-grained AUTH/AUTHZ/INTERNAL codes
363:token/PII redaction
365:CORS wildcard+credentials 정적 거부
372:CODE_GRANULARITY_DRIFT
372:12 fine-grained AUTH/AUTHZ/INTERNAL codes
```
### L4-F20 — JWT decoder와 clock-skew의 현재 메커니즘이 세 가지 상태로 기록
- **Severity / 유형**: High — `CONTRADICTION`
- **Source quotes**:
- `"auto-config** (custom decoder 미작성)"``...feature-security-operational-baseline.md:197`
- `"custom \`JwtDecoder\` bean"` — `...feature-security-operational-baseline.md:364`
- `"60s 는 코드에 명시되지 않고 Spring default leeway 에 의존"``...feature-security-operational-baseline.md:374`
- **Claim**: active guide는 auto-config/default skew, Phase C2 표는 custom decoder와 explicit skew, 후속 Audit bullet은 다시 명시 설정 부재라고 말한다.
- **Assumptions**: 세 진술 모두 같은 2026-06-08 이후 코드 상태를 설명한다.
- **Failure mode**: 잘못된 bean을 수정하거나 default drift 위험·startup IdP 의존성·validator chain 테스트 범위를 오판한다.
- **Falsification**: `SupplierJwtDecoder`가 custom wrapper이지만 내부적으로 auto-config/default skew를 그대로 사용한다면 line 364의 “explicit clock skew” 표현이 잘못된 것이다.
- **Required action / Why**: 실제 code owner에서 `JwtDecoderConfig`의 validator chain을 대조한 뒤 한 상태만 남긴다.
- **Alternatives**:
1. custom decoder + explicit `JwtTimestampValidator(60s)`
2. auto-config + vendor default 60s
- **Counterargument**: custom wrapper와 auto-config를 함께 쓸 수 있다. 그래도 “custom decoder 미작성”과 “custom bean locally verified”는 동시에 참일 수 없다.
- **Self-grep**:
```text
$ grep -nFo -e 'auto-config** (custom decoder 미작성)' -e 'custom `JwtDecoder` bean' -e '60s 는 코드에 명시되지 않고 Spring default leeway 에 의존' -- raw/branch-notes/feature-security-operational-baseline.md
197:auto-config** (custom decoder 미작성)
364:custom `JwtDecoder` bean
374:60s 는 코드에 명시되지 않고 Spring default leeway 에 의존
```
### L4-F21 — public-path 검증 계약이 폐기된 reflection 방식과 구현된 env-snapshot 방식을 함께 유지
- **Severity / 유형**: High — `STALE_SUMMARY` / incompatible verification command
- **Source quotes**:
- `"SecurityFilterChain.getFilters()\` introspection"` — `...feature-security-operational-baseline.md:305`
- `"gradle task 미존재"``...feature-security-operational-baseline.md:318`
- `"public path snapshot gate"` (`locally-verified`) — `...feature-security-operational-baseline.md:366`
- **Claim**: 구현 가이드 line 243250은 env-based snapshot으로 정정됐지만 Test Contract와 Claims는 reflection 및 task 부재를 계속 전제로 한다.
- **Assumptions**: Phase C2의 `verifyPublicPathSnapshot` task가 현행이다.
- **Failure mode**: 존재하는 task를 다시 작성하거나 reflection coverage를 기대해 Java hard-coded `permitAll()`까지 탐지된다고 오판한다.
- **Falsification**: task가 이후 제거됐다면 Phase C2와 구현 가이드를 downgrade해야 한다.
- **Required action / Why**: Test Contract와 Claims를 env `SECURITY_PUBLIC_PATHS` snapshot command·승인 flag·hard-coded `permitAll()` 미검출 한계로 동기화한다.
- **Alternatives**: 별도 reflection/behavioral test를 추가해 env gate의 blind spot을 보완한다.
- **Counterargument**: line 243에 정정 설명이 있다. 하지만 실행 명령을 제공하는 §테스트 계약과 active Claims가 반대 상태여서 운영자가 잘못된 검증을 수행한다.
- **Self-grep**:
```text
$ grep -nFo -e 'SecurityFilterChain.getFilters()` introspection' -e 'gradle task 미존재' -e 'public path snapshot gate' -- raw/branch-notes/feature-security-operational-baseline.md
243:SecurityFilterChain.getFilters()` introspection
305:SecurityFilterChain.getFilters()` introspection
318:gradle task 미존재
318:SecurityFilterChain.getFilters()` introspection
366:public path snapshot gate
```
## Cross-lane owner edges
| L4 finding | Unseen owner / governing source | 필요한 대조 | 상태 |
|---|---|---|---|
| L4-F01 | `feature-keycloak-iss-claim-hostname-mismatch` D6, `feature-keycloak-docker-compose-stack` | bridge/host network 기본과 `issuer-uri`/`jwk-set-uri` 조합 | NEEDS_CROSS_LANE_AUDIT |
| L4-F02, F10, F14 | `raw/project-notes/keycloak-patterns-overview` F3/F5/§8 | 최신 AP taxonomy·owner split·single-EC2 deployment 축 | NEEDS_CROSS_LANE_AUDIT |
| L4-F04, F06 | `feature-keycloak-internal-spa-direct-no-google`, `feature-keycloak-bff-vs-spa-direct` | TMB 허용 여부와 cookie/CSRF owner | NEEDS_CROSS_LANE_AUDIT |
| L4-F08 | `feature-keycloak-realm-client-export` | 실제 client IDs와 Audience mapper target | NEEDS_CROSS_LANE_AUDIT |
| L4-F10 | `feature-keycloak-realm-client-export` D5 | realm roles 대 client roles의 발급 계약 | NEEDS_CROSS_LANE_AUDIT |
| L4-F12, F13 | `feature-keycloak-first-broker-login-flow`, `feature-keycloak-idp-mappers-claim-to-role`, `feature-keycloak-account-linking-spa-ux` | nonce 자동 처리·`email_verified`·`hd`·linking 실제 정책 | NEEDS_CROSS_LANE_AUDIT |
| L4-F14, F15 | `feature-keycloak-edge-forwardauth-no-google`, `feature-keycloak-oauth2-proxy-oidc-flow` | AP4 owner 및 302 생성/전달 주체 | NEEDS_CROSS_LANE_AUDIT |
| L4-F18 | `feature-keycloak-refresh-token-rotation`, `feature-keycloak-refresh-rotation-and-logout` | refresh grant·rotation·reload 복구 계약 | NEEDS_CROSS_LANE_AUDIT |
| L4-F19F21 | `wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets.md` + ca-tmpl code owner | Phase C2 as-built와 canonical/current code 정합 | NEEDS_CROSS_LANE_AUDIT |
## Claim Traceability Check
- Claim ID 연결 검사: 8개 branch-note의 Supporting Claims 구조를 모두 확인했다. exact slice 밖 raw-source Claim 본문은 재열람하지 않았으므로 외부 Claim byte 검증은 범위 밖이다. slice 내부 owner 의미 오귀속은 L4-F02·F06·F09로 보고했다.
- Decision Evidence Map: 8개 모두 검토. 최신 D-row와 본문/소비자 요약의 semantic drift는 L4-F02·F03·F05·F07F10·F12·F19F21에 반영했다.
- UNSUPPORTED_DECISION: 새 미라벨 결정으로 확정한 건은 없으며, 기존 label을 무시한 상위 요약 과장은 L4-F12에 포함했다.
## Grep verification
- 실행한 `grep -nFo` self-check: 25회
- 검증한 verbatim quote fragment V: 55
- 일치 P: 55
- 라인 정정 C: 0
- 폐기 D: 0
- 모든 command exit code: 0
- 이 lane은 파일 수정 명령을 실행하지 않았다. 공유 worktree에는 선행 변경이 존재하므로 workspace-clean 주장은 하지 않는다.
## Stats
```wiki-stats
agent: wiki-research-lane
found: 8
processed: 8
dropped: 0
```
@@ -0,0 +1,69 @@
# Keycloak Semantic Edge Risk Sample
이 lane은 valid reference occurrence 647건 전체가 아니라 lane finding 중 고위험 후보 20건을 표본 대조했다. 실제 D 또는 § edge는 9건이었고 11건은 내부 모순, D-id 없는 비교, self-reference여서 NOT_AN_EDGE로 분리했다.
## Counts
| Classification | Count |
|---|---:|
| CONSISTENT | 2 |
| STALE_SUMMARY | 1 |
| CONTRADICTION | 1 |
| RESTATED_FOREIGN_DECISION | 5 |
| NOT_AN_EDGE | 11 |
## Edge Verdicts
| Lane ID | Citing → owner | Ref | Verbatim evidence | Verdict | Action |
|---|---|---|---|---|---|
| L1-F02 | account-linking-spa-ux:119 → account-linking-sub-vs-email:152 | D3 | Citing: “orphan 거부 메커니즘은 owner 브랜치 D3(역시 needs-confirmation)에서 추적” / Owner: “Account Console self-service unlink lockout 방지는 Keycloak 엔진이 서버에서 이미 강제” | STALE_SUMMARY | Citing을 server guard 확인 및 release-tag 재확인 필요라는 한 줄로 갱신. |
| L1-F03 | account-linking-spa-ux:117 → idp-mappers-claim-to-role:120 | D2 | Citing은 email_verified=true와 Attribute Importer와 custom step을 위임하면서 복제하고, owner D2도 같은 mechanism을 결정한다. | RESTATED_FOREIGN_DECISION | Citing에는 owner D2 pointer와 linking trust gate consume 한 줄만 남김. |
| L1-F05 | account-linking-sub-vs-email:154 → google-claim-attribute-mapping:130 | D3 | Citing과 owner가 IMPORT와 FORCE의 값 및 local edit 대 Google 최신성 조건을 함께 보유한다. | RESTATED_FOREIGN_DECISION | Takeover와 Sync Mode가 직교한다는 한 줄과 owner pointer만 유지. |
| L1-F08 | docker-compose-stack:236 → single-ec2-no-google:197 | D3 | Citing은 KC_HOSTNAME, issuer-uri, host network 또는 extra_hosts를 복제하고 owner D3도 같은 bundle을 결정한다. | RESTATED_FOREIGN_DECISION | Docker note는 issuer/network wiring을 consume한다는 한 줄로 축소. |
| L1-F14 | federation-spa-zero-change:130 → internal-spa-direct-no-google:281 | D3 | 양쪽이 iss, signature, exp, aud 4종 backend validation을 같은 의미로 사용한다. | CONSISTENT | 이 edge에는 조치 없음. 정확한 audience 값은 별도 L4-F08에서 처리. |
| L2-F03 | google-claim-attribute-mapping:130 → idp-mappers-claim-to-role:119 | D1 | Citing의 role freshness 목적 FORCE 요약이 owner D1의 최신 정보 반영 FORCE와 양립한다. | CONSISTENT | 적용 범위는 owner에서 명확화하되 edge summary 수정은 없음. |
| L2-F09 | internal-spa-direct-no-google:342 → spa-token-storage-tradeoff:140 | D1 | Hub와 owner가 access memory와 refresh secure HttpOnly cookie 조합을 같은 값으로 결정한다. | RESTATED_FOREIGN_DECISION | Hub는 storage owner D1 pointer와 의존 한 줄만 유지. |
| L3-F15 | public-domain-tunneling:106,169 → single-ec2-google-federation:238 | D3 | Parent D3와 child D1이 Cloudflare 우선, ngrok 차선의 provider 순서를 각각 normative decision으로 둔다. | RESTATED_FOREIGN_DECISION | Parent를 provider order owner로 두고 child는 운영 detail만 소유. |
| L4-F03 | single-ec2-no-google:182 → vanilla-js-spa-pkce:124 | D1 | Citing: “manual fetch와 crypto.subtle 기반 PKCE 구현 우선” / Owner: “oidc-client-ts 우선 채택, manual PKCE는 비교 학습용 별도 단계” | CONTRADICTION | Owner D1을 실행 순서 정본으로 두고 citing의 manual-first를 historical learning order로 격하. |
## NOT_AN_EDGE
| Lane ID | Evidence | Reason |
|---|---|---|
| L1-F01 | authentication-authorization-contract:73,169 | 자신의 D3와 같은 문서 raw-role 설명의 불일치이며 foreign edge가 아니다. |
| L1-F07 | bff-vs-spa-direct:130,165 | 같은 문서 요약 matrix와 D5의 불일치다. |
| L1-F10 | edge-forwardauth-google-federation:145 대 edge-forwardauth-no-google:112 | cross-doc 비교는 가능하나 target D 또는 § reference가 없다. |
| L1-F11 | edge-forwardauth-no-google:167,227 | 같은 문서의 선택축과 implementation table 문제다. |
| L2-F05 | idp-brokering-google-client:81 대 google-redirect-uri-policy:167 | 값은 다르지만 consumer에 owner D6 reference가 없다. |
| L3-F01 | nginx-auth-request-integration:101,163 | 같은 문서 body-forwarding 설명의 불일치다. |
| L3-F11 | refresh-token-rotation:159 대 refresh-rotation-and-logout:124 | 두 D-row의 중복 관찰이지만 연결 문구에 target D-id가 없다. |
| L3-F12 | refresh-token-rotation:67 대 refresh-rotation-and-logout:61 | destination wikilink와 D-id가 없는 범위 위임이다. |
| L3-F13 | refresh-token-rotation:127 대 refresh-rotation-and-logout:120 | Max Reuse 정책 사이에 D 또는 § reference edge가 없다. |
| L3-F16 | single-ec2-google-federation:239,301 | 같은 parent 내부 bundle과 D-id 없는 detail 위임의 ownership 문제다. |
| L4-F08 | role-mapping:38,115 대 audience-validator:156 | 값 drift는 있으나 packet의 D4는 role-mapping 자신의 RBAC D4라 유효 foreign D4 edge가 아니다. |
## Reconciliation with adversarial review
Semantic edge 판정과 finding 존속 판정은 목적이 다르다. 예를 들어 L4-F03 edge 문구는 CONTRADICTION이지만 citing 자체가 DECISION_DRIFT를 표시하고 owner를 정본으로 인정하므로 적대 리뷰는 독립 priority에서 REJECT했다. 원문을 고칠 때는 여전히 stale active 문구와 D-row를 수거하는 것이 맞다.
## Limits
- literal reference 647건과 logical edge 298건 중 고위험 후보 20건만 선택했다.
- 20건 중 edge verdict가 가능한 것은 9건이었다.
- 전체 logical edge 의미 대조는 별도 사용자 확인 후 20건 이하 lane으로 나눠야 한다.
~~~wiki-verdict
agent: wiki-consistency-auditor
verdict: not-ready
blocking: 1
should_fix: 6
advisory: 0
~~~
~~~wiki-stats
agent: wiki-consistency-auditor
found: 20
processed: 9
dropped: 11
dropped_reason: NOT_AN_EDGE
~~~
@@ -0,0 +1,193 @@
# Per-file Findings Index
이 파일은 인덱스다. finding의 주장·인용·반론·수정안은 lane 보고서가 소유한다. 동일 finding이 여러 파일을 비교하면 관련 파일 양쪽에 같은 ID가 나타날 수 있다.
## `experiment-nplus1-highlight-feed.md`
- Finding: 없음. Keycloak 결정은 없고 sibling 주제 언급만 있다.
- Detail: [lane 01](lanes/lane-01-keycloak-sorted-01-10.md)
## `feature-authentication-authorization-contract.md`
- Findings: `L1-F01`
- Detail: [lane 01](lanes/lane-01-keycloak-sorted-01-10.md)
## `feature-boundary-validation-mapping-contract.md`
- Finding: 없음. Keycloak 결정은 sample seed 설명에 한정된다.
- Detail: [lane 01](lanes/lane-01-keycloak-sorted-01-10.md)
## `feature-keycloak-account-linking-spa-ux.md`
- Findings: `L1-F02`, `L1-F03`, `L1-F04`, `L1-F13`
- Detail: [lane 01](lanes/lane-01-keycloak-sorted-01-10.md)
## `feature-keycloak-account-linking-sub-vs-email.md`
- Findings: `L1-F02`, `L1-F04`, `L1-F05`, `L1-F06`, `L1-F09`
- Detail: [lane 01](lanes/lane-01-keycloak-sorted-01-10.md)
## `feature-keycloak-bff-vs-spa-direct.md`
- Findings: `L1-F07`, `L2-F09`, `L4-F04`
- Detail: [lane 01](lanes/lane-01-keycloak-sorted-01-10.md), [lane 02](lanes/lane-02-keycloak-sorted-11-20.md), [lane 04](lanes/lane-04-keycloak-sorted-31-38.md)
## `feature-keycloak-docker-compose-stack.md`
- Findings: `L1-F08`, `L3-F09`, `L4-F01`
- Detail: [lane 01](lanes/lane-01-keycloak-sorted-01-10.md), [lane 03](lanes/lane-03-keycloak-sorted-21-30.md), [lane 04](lanes/lane-04-keycloak-sorted-31-38.md)
## `feature-keycloak-edge-forwardauth-google-federation.md`
- Findings: `L1-F09`, `L1-F10`
- Detail: [lane 01](lanes/lane-01-keycloak-sorted-01-10.md)
## `feature-keycloak-edge-forwardauth-no-google.md`
- Findings: `L1-F10`, `L1-F11`, `L1-F12`, `L4-F14`
- Detail: [lane 01](lanes/lane-01-keycloak-sorted-01-10.md), [lane 04](lanes/lane-04-keycloak-sorted-31-38.md)
## `feature-keycloak-federation-spa-zero-change.md`
- Findings: `L1-F13`, `L1-F14`, `L2-F08`
- Detail: [lane 01](lanes/lane-01-keycloak-sorted-01-10.md), [lane 02](lanes/lane-02-keycloak-sorted-11-20.md)
## `feature-keycloak-first-broker-login-flow.md`
- Findings: `L1-F02`, `L1-F04`, `L2-F01`, `L2-F02`
- Detail: [lane 01](lanes/lane-01-keycloak-sorted-01-10.md), [lane 02](lanes/lane-02-keycloak-sorted-11-20.md)
## `feature-keycloak-google-claim-attribute-mapping.md`
- Findings: `L1-F05`, `L2-F03`
- Detail: [lane 01](lanes/lane-01-keycloak-sorted-01-10.md), [lane 02](lanes/lane-02-keycloak-sorted-11-20.md)
## `feature-keycloak-google-redirect-uri-policy.md`
- Findings: `L2-F04`, `L2-F05`; deterministic `BARE_DECISION_REF` 3건.
- Detail: [lane 02](lanes/lane-02-keycloak-sorted-11-20.md)
## `feature-keycloak-header-spoofing-defense.md`
- Finding: lane-local contradiction 없음; cross-lane trusted-header owner edge가 남아 있다.
- Detail: [lane 02](lanes/lane-02-keycloak-sorted-11-20.md)
## `feature-keycloak-https-termination-caddy-nginx.md`
- Findings: `L2-F06`
- Detail: [lane 02](lanes/lane-02-keycloak-sorted-11-20.md)
## `feature-keycloak-idp-brokering-google-client.md`
- Findings: `L2-F01`, `L2-F05`; deterministic `BARE_DECISION_REF` 1건.
- Detail: [lane 02](lanes/lane-02-keycloak-sorted-11-20.md)
## `feature-keycloak-idp-mappers-claim-to-role.md`
- Findings: `L1-F03`, `L2-F02`, `L2-F03`
- Detail: [lane 01](lanes/lane-01-keycloak-sorted-01-10.md), [lane 02](lanes/lane-02-keycloak-sorted-11-20.md)
## `feature-keycloak-internal-spa-direct-google-federation.md`
- Findings: `L2-F01`, `L2-F02`, `L2-F07`, `L2-F08`
- Detail: [lane 02](lanes/lane-02-keycloak-sorted-11-20.md)
## `feature-keycloak-internal-spa-direct-no-google.md`
- Findings: `L2-F08`, `L2-F09`, `L4-F04`
- Detail: [lane 02](lanes/lane-02-keycloak-sorted-11-20.md), [lane 04](lanes/lane-04-keycloak-sorted-31-38.md)
## `feature-keycloak-iss-claim-hostname-mismatch.md`
- Findings: `L1-F08`, `L4-F01`; 이 문서 자체는 owner 충돌을 명시적으로 기록한다.
- Detail: [lane 01](lanes/lane-01-keycloak-sorted-01-10.md), [lane 02](lanes/lane-02-keycloak-sorted-11-20.md), [lane 04](lanes/lane-04-keycloak-sorted-31-38.md)
## `feature-keycloak-nginx-auth-request-integration.md`
- Findings: `L3-F01`, `L3-F02`; deterministic bare reference의 owner 대상.
- Detail: [lane 03](lanes/lane-03-keycloak-sorted-21-30.md)
## `feature-keycloak-oauth2-proxy-oidc-flow.md`
- Findings: `L3-F03`, `L3-F04`; deterministic `BARE_DECISION_REF` 3건.
- Detail: [lane 03](lanes/lane-03-keycloak-sorted-21-30.md)
## `feature-keycloak-patterns.md`
- Findings: `L3-F05`
- Detail: [lane 03](lanes/lane-03-keycloak-sorted-21-30.md)
## `feature-keycloak-pkce-flow-stages.md`
- Findings: `L3-F06`, `L3-F07`
- Detail: [lane 03](lanes/lane-03-keycloak-sorted-21-30.md)
## `feature-keycloak-public-domain-tunneling.md`
- Findings: `L3-F08`, `L3-F15`
- Detail: [lane 03](lanes/lane-03-keycloak-sorted-21-30.md)
## `feature-keycloak-realm-client-export.md`
- Findings: `L3-F04`, `L3-F07`, `L3-F09`, `L3-F10`; deterministic `BARE_DECISION_REF` 1건.
- Detail: [lane 03](lanes/lane-03-keycloak-sorted-21-30.md)
## `feature-keycloak-refresh-rotation-and-logout.md`
- Findings: `L3-F11`, `L3-F12`, `L3-F13`
- Detail: [lane 03](lanes/lane-03-keycloak-sorted-21-30.md)
## `feature-keycloak-refresh-token-rotation.md`
- Findings: `L3-F11`, `L3-F12`, `L3-F13`
- Detail: [lane 03](lanes/lane-03-keycloak-sorted-21-30.md)
## `feature-keycloak-reverse-proxy-headers.md`
- Findings: `L3-F14`, `L3-F16`
- Detail: [lane 03](lanes/lane-03-keycloak-sorted-21-30.md)
## `feature-keycloak-single-ec2-google-federation.md`
- Findings: `L3-F08`, `L3-F15`, `L3-F16`
- Detail: [lane 03](lanes/lane-03-keycloak-sorted-21-30.md)
## `feature-keycloak-single-ec2-no-google.md`
- Findings: `L4-F01`, `L4-F02`, `L4-F03`, `L4-F10`, `L4-F14`, `L4-F16`; deterministic `BARE_DECISION_REF` 1건(`fix-plan D4`, false-positive 후보).
- Detail: [lane 04](lanes/lane-04-keycloak-sorted-31-38.md)
## `feature-keycloak-spa-token-storage-tradeoff.md`
- Findings: `L2-F09`, `L4-F04`, `L4-F05`, `L4-F06`
- Detail: [lane 02](lanes/lane-02-keycloak-sorted-11-20.md), [lane 04](lanes/lane-04-keycloak-sorted-31-38.md)
## `feature-keycloak-spring-rs-audience-validator.md`
- Findings: `L1-F14`, `L4-F02`, `L4-F06`, `L4-F07`, `L4-F08`, `L4-F09`
- Detail: [lane 01](lanes/lane-01-keycloak-sorted-01-10.md), [lane 04](lanes/lane-04-keycloak-sorted-31-38.md)
## `feature-keycloak-spring-rs-role-mapping.md`
- Findings: `L4-F02`, `L4-F08`, `L4-F09`, `L4-F10`
- Detail: [lane 04](lanes/lane-04-keycloak-sorted-31-38.md)
## `feature-keycloak-three-leg-trust-chain.md`
- Findings: `L4-F11`, `L4-F12`, `L4-F13`
- Detail: [lane 04](lanes/lane-04-keycloak-sorted-31-38.md)
## `feature-keycloak-traefik-forwardauth-alternative.md`
- Findings: `L1-F11`, `L4-F14`, `L4-F15`
- Detail: [lane 01](lanes/lane-01-keycloak-sorted-01-10.md), [lane 04](lanes/lane-04-keycloak-sorted-31-38.md)
## `feature-keycloak-vanilla-js-spa-pkce.md`
- Findings: `L4-F03`, `L4-F16`, `L4-F17`, `L4-F18`
- Detail: [lane 04](lanes/lane-04-keycloak-sorted-31-38.md)
## `feature-security-operational-baseline.md`
- Findings: `L1-F01`, `L4-F19`, `L4-F20`, `L4-F21`
- Detail: [lane 01](lanes/lane-01-keycloak-sorted-01-10.md), [lane 04](lanes/lane-04-keycloak-sorted-31-38.md)
@@ -0,0 +1,57 @@
# Priority Recommendations
적대 리뷰에서 살아남은 High 10건만 우선순위에 올렸다. 각 행은 하나의 finding과 1:1로 대응한다. 원문 수정은 아직 승인되지 않았다.
| Priority | Finding | Why Now | Concrete Change | Verification | Risk / Approval |
|---:|---|---|---|---|---|
| 1 | L4-F08 | mapper가 넣는 API audience와 backend expected audience가 다르면 정상 token이 401이 되거나 다른 resource token을 허용할 수 있다. | realm client ID, API audience symbolic name, mapper target, Spring audiences 값을 한 owner D-row에서 하나로 고정하고 role-mapping의 복제 값을 pointer로 바꾼다. | realm export, 발급 token aud, 정상 및 wrong-audience 401 test를 함께 확인한다. | high / 개별 승인 |
| 2 | L1-F08 | 선택된 bridge-network 기본이 localhost discovery와 host.docker.internal reachability를 동시에 요구한다. | issuer identity와 JWKS network address를 분리하거나 host network를 기본으로 택해 한 실행 profile로 종결한다. | docker compose config, app container DNS, startup discovery와 JWKS log를 확인한다. | high / 개별 승인 |
| 3 | L4-F16 | copyable SPA code의 relative API와 token URL이 static-only nginx origin으로 향한다. | nginx에 명시적 proxy route를 추가하거나 SPA config에 backend와 Keycloak absolute base URL을 주입하고 topology owner에서 한 방식을 선택한다. | browser Network panel, nginx access log, backend와 Keycloak log로 resolved destination을 확인한다. | high / 개별 승인 |
| 4 | L3-F14 | Caddy handle_path가 Keycloak relative path를 제거해 discovery와 authorization endpoint가 404가 될 수 있다. | method B를 유지하면 handle 또는 prefix-preserving reverse_proxy로 snippet을 바꾸고, strip 방식을 택하면 Keycloak root-path 설계로 owner decision을 바꾼다. | caddy adapt와 upstream request path로 discovery, auth, token endpoint를 검사한다. | high / 개별 승인 |
| 5 | L2-F02 | silent auto-link 차단과 email_verified=false 전체 hard-reject가 같은 acceptance처럼 쓰인다. | First Broker Login owner에서 silent-link policy와 custom SPI hard-reject policy를 분리하고, SPI artifact가 없으면 consumer의 링크와 생성 거부 문장을 제거한다. | realm flow export, provider JAR, false-email 로그인 E2E로 선택 정책을 증명한다. | high / 개별 승인 |
| 6 | L4-F04 | HttpOnly refresh cookie를 기본으로 두면 server-side token custody와 CSRF 책임이 생겨 SPA Direct 경계가 바뀐다. | pure SPA memory-only와 TMB refresh-cookie variant를 별도 decision으로 나누고 기본 하나와 fallback 조건을 고정한다. | refresh endpoint와 cookie issuer 존재, CSRF negative test, reload 및 rotation E2E를 확인한다. | high / 개별 승인 |
| 7 | L1-F10 | P1B 표는 P1A와 동일이라 적지만 backend trust는 JWT signature와 header-only로 갈린다. | P1B가 P1A trust invariant를 유지할지 새 JWT-validation variant가 될지 선택하고 pattern ID, component table, security tests를 함께 갱신한다. | 두 pattern의 SecurityFilterChain, forwarded headers와 tokens, spoofing negative test를 diff한다. | high / 개별 승인 |
| 8 | L4-F14 | Traefik note가 SPA Direct P3A를 edge-auth AP4로 연결해 배포 축과 인증 architecture 축을 섞는다. | target pattern pointer를 실제 AP owner로 바꾸고 physical deployment alternative는 별도 행으로 분리한다. | project pattern matrix와 component flow에서 browser token holder와 backend verifier가 일치하는지 확인한다. | high / 개별 승인 |
| 9 | L3-F08 | random quick-tunnel URL을 static redirect hostname처럼 사용하면 restart 뒤 Google callback 등록이 stale해진다. | quick tunnel과 named tunnel plus managed hostname을 분리하고 static callback 요구 시 허용되는 provider profile을 하나로 고정한다. | tunnel restart 전후 hostname, DNS route, Google redirect E2E를 기록한다. | high / 개별 승인 |
| 10 | L1-F09 | Google sub federation key의 변경 권한이 parent와 child 양쪽 SSOT에 있다. | parent는 requirement만 두고 child D1을 mechanism owner로 삼거나, 반대로 parent owner를 유지하고 child를 pointer와 implementation detail로 축소한다. | inbound decision reference와 owner map에서 mutable key policy owner가 하나인지 확인한다. | high / 개별 승인 |
## Decision Conditions
| Finding | Goal | Assumption and invalidation | Strongest counterargument | Decision |
|---|---|---|---|---|
| L4-F08 | API token acceptance와 resource isolation을 같은 audience contract로 묶는다. | spa-client와 backend-client-id가 별도 client라는 전제다. realm export에서 동일 identifier로 의도적으로 정의됐다면 값 충돌은 사라진다. | public client ID를 API audience로 재사용할 수 있다. | identifier 재사용 여부를 owner에서 명시하고 mapper와 validator에 같은 symbol을 사용한다. |
| L1-F08 | issuer identity와 container reachability가 함께 성립하는 기본을 만든다. | backend가 bridge container라는 전제다. host process나 host network가 실제 기본이면 반론이 성립한다. | extra_hosts로 host 접근이 가능하다. | extra_hosts hostname과 issuer hostname은 자동으로 같아지지 않으므로 rendered config를 기준으로 profile을 고정한다. |
| L4-F16 | copyable SPA request가 의도한 origin으로 간다. | nginx가 static-only라는 parent 결정이 전제다. 실제 proxy route가 있으면 finding은 약해진다. | relative URL은 same-origin 배포에서 단순하다. | same-origin proxy를 문서와 config에 추가하거나 absolute runtime config를 쓴다. |
| L3-F14 | proxy path와 Keycloak relative path를 같은 routing contract로 만든다. | Caddy handle_path의 prefix strip과 method B가 동시에 선택됐다는 전제다. 별도 rewrite가 있으면 반론이 가능하다. | upstream에서 prefix를 복구할 수 있다. | 보이지 않는 rewrite에 의존하지 말고 copyable snippet에 선택을 표현한다. |
| L2-F02 | account-link policy의 실제 거부 범위를 acceptance와 맞춘다. | custom hard-reject SPI가 corpus에 없다는 전제다. 별도 artifact가 확인되면 consumer 문장을 유지할 수 있다. | Google verified email이 일반적이라 edge case가 드물다. | 빈도와 보안 계약은 별개이므로 flow export와 negative test로 범위를 고정한다. |
| L4-F04 | token custody에 맞는 architecture와 CSRF contract를 선택한다. | HttpOnly refresh cookie를 실제 기본으로 쓴다는 전제다. memory-only를 고르면 TMB concern이 사라진다. | 기존 Resource Server에 endpoint 하나만 추가할 수 있다. | endpoint 수보다 custody와 browser-triggered credential 책임을 기준으로 pattern을 분류한다. |
| L1-F10 | P1A와 P1B의 backend trust boundary를 명시한다. | 동일이라는 표가 security invariant까지 포함한다는 전제다. federation 부분만 동일하다고 범위를 좁히면 별도 variant가 가능하다. | defense-in-depth JWT validation을 P1B에 추가할 수 있다. | 그 경우 동일 표현을 제거하고 새 pattern invariant와 test를 둔다. |
| L4-F14 | pattern taxonomy와 배포 alternative를 서로 다른 축으로 유지한다. | P3A owner가 SPA Direct라는 전제다. project hub가 이미 AP4로 재분류했다면 owner 문서를 먼저 바꿔야 한다. | 같은 EC2에 oauth2-proxy를 추가할 수 있다. | 배치 가능성은 architecture ID 변경 근거가 아니므로 두 축을 분리한다. |
| L3-F08 | redirect URI가 tunnel lifecycle 동안 안정적으로 유지되게 한다. | quick tunnel URL이 random이라는 보존 source model이 전제다. target account가 persistent hostname을 제공하면 profile을 수정할 수 있다. | 특정 plan이나 managed domain은 static일 수 있다. | quick, named, managed custom hostname을 별도 조건으로 문서화한다. |
| L1-F09 | federation key 변경 권한을 한 owner에 둔다. | 두 D-row가 같은 mutable policy를 결정한다는 전제다. requirement와 mechanism concern ID가 분리되면 양립한다. | integration parent가 requirement를 반복할 수 있다. | 값과 선택 조건은 child owner에 두고 parent는 invariant pointer만 유지한다. |
## Fix Plan
| Finding | 필요한 수정 | 대상 파일:line | 위험 | 승인 필요? | 패치 범위 |
|---|---|---|---|---|---|
| L4-F08 | audience identifier와 owner 통합 | `raw/branch-notes/feature-keycloak-spring-rs-audience-validator.md:156`, `raw/branch-notes/feature-keycloak-spring-rs-role-mapping.md:38` | high | yes, 개별 | D-row, mapper, validator, test contract |
| L1-F08 | issuer와 network profile 한 가지 선택 | `raw/branch-notes/feature-keycloak-docker-compose-stack.md:138`, `raw/branch-notes/feature-keycloak-docker-compose-stack.md:207` | high | yes, 개별 | Compose 및 D3 관련 문단 |
| L4-F16 | SPA endpoint routing 방식 선택 | `raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md:176`, `raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md:190`, `raw/branch-notes/feature-keycloak-single-ec2-no-google.md:159` | high | yes, 개별 | code snippet과 topology decision |
| L3-F14 | Caddy prefix-preserving snippet 적용 | `raw/branch-notes/feature-keycloak-reverse-proxy-headers.md:125`, `raw/branch-notes/feature-keycloak-reverse-proxy-headers.md:202` | high | yes, 개별 | Caddy snippet과 D3 trace |
| L2-F02 | silent-link와 hard-reject 분리 | `raw/branch-notes/feature-keycloak-first-broker-login-flow.md:135`, `raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role.md:120`, `raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation.md:387` | high | yes, 개별 | policy D-row와 consumer summaries |
| L4-F04 | pure SPA와 TMB variant 분리 | `raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff.md:140`, `raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff.md:165` | high | yes, 개별 | D1, architecture taxonomy, CSRF owner |
| L1-F10 | P1B backend trust variant 확정 | `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md:145`, `raw/branch-notes/feature-keycloak-edge-forwardauth-no-google.md:112` | high | yes, 개별 | comparison table와 security invariant |
| L4-F14 | P3A pointer를 실제 pattern owner로 교체 | `raw/branch-notes/feature-keycloak-traefik-forwardauth-alternative.md:169`, `raw/branch-notes/feature-keycloak-single-ec2-no-google.md:33` | medium | yes, 개별 | taxonomy pointer와 설명 |
| L3-F08 | tunnel hostname 유형 분리 | `raw/branch-notes/feature-keycloak-public-domain-tunneling.md:69`, `raw/branch-notes/feature-keycloak-public-domain-tunneling.md:77`, `raw/branch-notes/feature-keycloak-single-ec2-google-federation.md:356` | medium | yes, 개별 | provider matrix와 redirect assumptions |
| L1-F09 | sub key owner 하나 선택 | `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md:195`, `raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation.md:253`, `raw/branch-notes/feature-keycloak-account-linking-sub-vs-email.md:150` | high | yes, 개별 | owner map와 D-row |
| L3-F04 | bare D-reference를 wikilink plus D-id로 교체 | `raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow.md:214`, `raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow.md:228`, `raw/branch-notes/feature-keycloak-realm-client-export.md:182` 및 deterministic warning 5건 | low | yes, 묶음 | 8개 reference syntax |
| L3-F11,L3-F12,L3-F13 | rotation owner, receiver scope, Max Reuse 표현을 한 계약에서 검증 | `raw/branch-notes/feature-keycloak-refresh-token-rotation.md:159`, `raw/branch-notes/feature-keycloak-refresh-rotation-and-logout.md:124` | medium | yes, 묶음 | D4/D5, scope, measured semantics |
| L4-F19,L4-F20,L4-F21 | Phase C2 as-built 기준으로 history와 active spec 분리 | `raw/branch-notes/feature-security-operational-baseline.md:243` 및 Phase C2 sections | medium | yes, as-built 확인 후 | active table, Claims, Test Contract |
## Verification order
1. Realm export와 token sample로 L4-F08을 먼저 닫는다.
2. Rendered Compose와 proxy config로 L1-F08, L3-F14, L4-F16을 함께 검증한다.
3. First Broker Login export와 negative E2E로 L2-F02를 확인한다.
4. Token storage architecture 선택 뒤 L4-F04의 CSRF와 rotation owner를 정한다.
5. 나머지 ownership 및 taxonomy cleanup을 적용하고 consistency checker를 다시 실행한다.
@@ -0,0 +1,161 @@
from __future__ import annotations
import pathlib
import re
ROOT = pathlib.Path(__file__).resolve().parents[5]
TOPIC = "2026-07-18-keycloak-branch-note-consistency"
SPEC = ROOT / "docs/superpowers/specs"
BASE = SPEC / TOPIC
MASTER = SPEC / f"{TOPIC}-report.md"
CONTROLLER = SPEC / f"{TOPIC}-controller-verification.md"
ADV_ROOT = SPEC / f"{TOPIC}-adversarial-review.md"
scope = sorted(
str(path.relative_to(ROOT))
for path in (ROOT / "raw/branch-notes").glob("*.md")
if "keycloak" in path.name.lower() or "keycloak" in path.read_text(encoding="utf-8").lower()
)
matrix = (BASE / "evidence-matrix.md").read_text(encoding="utf-8")
matrix_rows = []
malformed = []
for line in matrix.splitlines():
if re.match(r"^\| \x60raw/branch-notes/[^\x60]+\.md\x60 \|", line):
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
if len(cells) != 4:
malformed.append(line)
continue
matrix_rows.append((cells[0].strip("\x60"), cells[1], cells[2], cells[3]))
if cells[1] not in {"READ_FULL", "READ_PARTIAL", "NOT_READ", "BLOCKED"}:
malformed.append(line)
matrix_paths = [row[0] for row in matrix_rows]
missing = sorted(set(scope) - set(matrix_paths))
extra = sorted(set(matrix_paths) - set(scope))
duplicates = sorted({path for path in matrix_paths if matrix_paths.count(path) > 1})
nonexistent = sorted(path for path in matrix_paths if not (ROOT / path).is_file())
lane_files = sorted((BASE / "lanes").glob("lane-0[1-4]-*.md"))
sections = {}
schema_failures = []
for lane in lane_files:
text = lane.read_text(encoding="utf-8")
matches = list(re.finditer(r"(?m)^### (L[1-4]-F\d{2})\b[^\n]*", text))
for index, match in enumerate(matches):
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
sections.setdefault(match.group(1), []).append(text[match.start():end])
expected_ids = (
[f"L1-F{i:02d}" for i in range(1, 15)]
+ [f"L2-F{i:02d}" for i in range(1, 10)]
+ [f"L3-F{i:02d}" for i in range(1, 17)]
+ [f"L4-F{i:02d}" for i in range(1, 22)]
)
for finding_id, entries in sections.items():
section = entries[0]
checks = {
"source_file": "raw/branch-notes/" in section,
"severity": bool(re.search(r"Severity|심각도", section, re.I)),
"falsification": bool(re.search(r"Falsification|Falsified|무효|반증", section, re.I)),
"recommendation": bool(re.search(r"Required action|Action / why|synthesis recommendation|권고|조치", section, re.I)),
}
failed = [name for name, ok in checks.items() if not ok]
if failed:
schema_failures.append(f"{finding_id}:{','.join(failed)}")
per_file = (BASE / "per-file-findings.md").read_text(encoding="utf-8")
per_file_sections = len(re.findall(r"(?m)^## \x60[^\x60]+\.md\x60$", per_file))
proofs = (BASE / "sed-proofs.md").read_text(encoding="utf-8")
proof_ids = re.findall(r"(?m)^\| \x60(L[1-4]-F\d{2})\x60 \|", proofs)
adversarial = (BASE / "adversarial-review.md").read_text(encoding="utf-8")
adv_ids = re.findall(r"(?m)^\| (L[1-4]-F\d{2}) \|", adversarial)
generic = ["수동 보완책이 존재함", "일부 비핵심 경로", "치명적인 영향이 없음"]
generic_hits = sum(adversarial.count(term) for term in generic)
priority = (BASE / "priority-recommendations.md").read_text(encoding="utf-8")
priority_ids = sorted(set(re.findall(r"L[1-4]-F\d{2}", priority)))
required = [
MASTER,
CONTROLLER,
ADV_ROOT,
BASE / "evidence-matrix.md",
BASE / "per-file-findings.md",
BASE / "sed-proofs.md",
BASE / "priority-recommendations.md",
BASE / "unresolved-risk-register.md",
BASE / "adversarial-review.md",
]
missing_artifacts = [str(path.relative_to(ROOT)) for path in required if not path.is_file()]
link_files = [path for path in required if path.is_file()] + lane_files
broken_links = []
for source in link_files:
text = source.read_text(encoding="utf-8")
for match in re.finditer(r"\[[^\]]+\]\(([^)]+\.md)(?:#[^)]+)?\)", text):
target = pathlib.Path(match.group(1))
if str(target).startswith(("http:/", "https:/")):
continue
resolved = target if target.is_absolute() else (source.parent / target).resolve()
if not resolved.is_file():
broken_links.append(f"{source.relative_to(ROOT)}->{target}")
banned = [
"100%", "완벽", "완전", "극한", "극단", "정밀한", "흔들림 없이",
"절대로", "최강", "역사상 가장", "명품", "원천 차단", "보증", "폭사",
]
forbidden = []
for path in [MASTER, CONTROLLER, BASE / "priority-recommendations.md", BASE / "unresolved-risk-register.md"]:
if not path.is_file():
continue
text = re.sub(r"\x60\x60\x60.*?\x60\x60\x60", "", path.read_text(encoding="utf-8"), flags=re.S)
text = re.sub(r"\x60[^\x60]*\x60", "", text)
for line_number, line in enumerate(text.splitlines(), 1):
for term in banned:
if term in line:
forbidden.append(f"{path.relative_to(ROOT)}:{line_number}:{term}")
claim_texts = [(ROOT / path).read_text(encoding="utf-8") for path in scope]
values = {
"raw_file_count": len(scope),
"raw_line_count": sum(len(text.splitlines()) for text in claim_texts),
"matrix_rows": len(matrix_rows),
"read_full_rows": sum(row[1] == "READ_FULL" for row in matrix_rows),
"blocked_rows": sum(row[1] == "BLOCKED" for row in matrix_rows),
"missing_paths": len(missing),
"extra_paths": len(extra),
"duplicate_paths": len(duplicates),
"nonexistent_paths": len(nonexistent),
"malformed_matrix_rows": len(malformed),
"lane_finding_count": sum(len(entries) for entries in sections.values()),
"unique_finding_ids": len(sections),
"duplicate_finding_ids": sum(len(entries) != 1 for entries in sections.values()),
"missing_finding_ids": len(set(expected_ids) - set(sections)),
"finding_schema_failures": len(schema_failures),
"per_file_sections": per_file_sections,
"sed_proof_rows": len(proof_ids),
"sed_proof_unique_ids": len(set(proof_ids)),
"adversarial_rows": len(adv_ids),
"adversarial_unique_ids": len(set(adv_ids)),
"adversarial_generic_hits": generic_hits,
"priority_unique_ids": len(priority_ids),
"unresolved_priority_ids": len(set(priority_ids) - set(expected_ids)),
"broken_internal_links": len(broken_links),
"forbidden_word_hits": len(forbidden),
"missing_required_artifacts": len(missing_artifacts),
"decision_evidence_map_files": sum("## Decision Evidence Map" in text for text in claim_texts),
"claims_extracted_files": sum("## Claims Extracted" in text for text in claim_texts),
"missing_claims_extracted": sum("## Claims Extracted" not in text for text in claim_texts),
"unsupported_decision_files": sum("UNSUPPORTED_DECISION" in text for text in claim_texts),
"unsupported_decision_occurrences": sum(text.count("UNSUPPORTED_DECISION") for text in claim_texts),
"broken_claim_reference_literal_occurrences": sum(text.count("BROKEN_CLAIM_REFERENCE") for text in claim_texts),
}
for key, value in values.items():
print(f"{key}={value}")
for label, entries in [
("missing", missing), ("extra", extra), ("duplicates", duplicates),
("nonexistent", nonexistent), ("malformed", malformed),
("schema_failures", schema_failures), ("missing_artifacts", missing_artifacts),
("broken_links", broken_links), ("forbidden_hits", forbidden),
]:
if entries:
print(f"{label}=" + " || ".join(entries))
@@ -0,0 +1,11 @@
# Implementation Plan
1. `rg -il --glob '*.md' 'keycloak' raw/branch-notes`로 범위를 확정한다.
2. 38개 파일을 10/10/10/8개 lane으로 분할하고 각 파일을 정확히 한 lane에 배정한다.
3. `wiki_consistency_check.py --all``--packets`로 결정론 finding 및 참조 팩킷을 만든다.
4. lane별 READ_FULL evidence matrix와 line-verified finding을 수집한다.
5. cross-lane 후보를 `wiki-consistency-auditor`로 의미 대조한다.
6. finding이 5개 이상이면 `wiki-adversarial-reviewer`로 전 항목을 반증 시도한다.
7. controller가 파일 집합·행 수·finding ID·인용·artifact·금지어를 재계산한다.
8. 원문을 수정하지 않고 위험도·승인 단위가 포함된 fix-plan을 보고한다.
@@ -0,0 +1,7 @@
# Task
- 요청: `raw/branch-notes/`에서 Keycloak 관련 문서를 모두 읽고 설계 간 비일관성을 리뷰한다.
- 범위 산정: 파일명 또는 본문에 `keycloak`이 포함된 branch-note 38개.
- 원문 수정: 하지 않음.
- 산출물: evidence matrix, lane별 finding, semantic edge audit, adversarial review, fix-plan, controller verification, master report.
@@ -0,0 +1,80 @@
from __future__ import annotations
import pathlib
import re
import shlex
import subprocess
ROOT = pathlib.Path(__file__).resolve().parents[5]
LANES = ROOT / "docs/superpowers/specs/2026-07-18-keycloak-branch-note-consistency/lanes"
FINDING = re.compile(r"^###\s+(L[1-4]-F\d{2})\b")
commands: dict[str, list[tuple[pathlib.Path, int, str]]] = {}
inventory: list[tuple[str, str]] = []
for lane in sorted(LANES.glob("lane-0[1-4]-*.md")):
current = None
for line_number, raw in enumerate(lane.read_text(encoding="utf-8").splitlines(), 1):
match = FINDING.match(raw)
if match:
current = match.group(1)
commands.setdefault(current, [])
continue
row = re.match(r"^\| (?:\x60)?(raw/branch-notes/[^\x60|]+\.md)(?:\x60)? \| (READ_FULL|NOT_READ|BLOCKED) \|", raw)
if row:
inventory.append((row.group(1), row.group(2)))
candidate = raw.strip()
if candidate.startswith("$ "):
candidate = candidate[2:]
if current and candidate.startswith(("grep ", "sed ")):
commands[current].append((lane, line_number, candidate))
expected = (
[f"L1-F{i:02d}" for i in range(1, 15)]
+ [f"L2-F{i:02d}" for i in range(1, 10)]
+ [f"L3-F{i:02d}" for i in range(1, 17)]
+ [f"L4-F{i:02d}" for i in range(1, 22)]
)
passed = 0
verified_output_lines = 0
failures = []
no_commands = []
for finding_id in expected:
if not commands.get(finding_id):
no_commands.append(finding_id)
continue
for lane, line_number, command in commands[finding_id]:
try:
argv = shlex.split(command)
except ValueError as exc:
failures.append(f"{finding_id}:{lane.name}:{line_number}:parse:{exc}")
continue
if not argv or argv[0] not in {"grep", "sed"}:
failures.append(f"{finding_id}:{lane.name}:{line_number}:unsafe")
continue
paths = []
for token in argv[1:]:
if token.endswith(".md"):
path = pathlib.Path(token)
paths.append((path if path.is_absolute() else ROOT / path).resolve())
if not paths or any(ROOT not in path.parents for path in paths):
failures.append(f"{finding_id}:{lane.name}:{line_number}:path")
continue
result = subprocess.run(argv, cwd=ROOT, text=True, capture_output=True)
if result.returncode != 0 or not result.stdout.strip():
failures.append(f"{finding_id}:{lane.name}:{line_number}:exit={result.returncode}")
continue
passed += 1
verified_output_lines += len([line for line in result.stdout.splitlines() if line.strip()])
print(f"inventory_rows={len(inventory)}")
print(f"inventory_unique={len(set(path for path, _ in inventory))}")
print(f"read_full={sum(status == 'READ_FULL' for _, status in inventory)}")
print(f"finding_ids={len(commands)}")
print(f"expected_finding_ids={len(expected)}")
print(f"commands_passed={passed}")
print(f"commands_failed={len(failures)}")
print(f"verified_output_lines={verified_output_lines}")
print(f"findings_without_commands={len(no_commands)}")
if failures:
print("failures=" + " || ".join(failures))
@@ -0,0 +1,11 @@
# Walkthrough
- 2026-07-18: Keycloak 관련 branch-note 38개, 10,649줄을 식별했다.
- 2026-07-18: 분할 감사 `10 + 10 + 10 + 8 = 38`, 중복 0, 누락 0을 확인했다.
- 2026-07-18: 결정론 검사 전체 결과 144건 중 Keycloak 범위의 `BARE_DECISION_REF` 8건을 분리했다.
- 2026-07-18: 유효 참조 647건과 logical edge 298개를 산출했다.
- 2026-07-18: lane finding 60건의 107개 인용 명령을 controller가 재실행해 실패 0, source output 158줄을 확인했다.
- 2026-07-18: semantic risk sample 20건을 대조해 actual edge 9건, `NOT_AN_EDGE` 11건으로 분류했다.
- 2026-07-18: 적대 리뷰에서 KEEP 21, DOWNGRADE 27, REJECT 12로 판정했으며 retained High는 10건이다.
- 2026-07-18: 9개 controller gate 중 claim traceability를 포함한 `finding_gate`만 FAIL로 계산해 master Verdict를 PARTIAL로 확정했다.
- 2026-07-18: branch-note 원문은 수정하지 않고 report artifacts만 생성했다.
@@ -0,0 +1,81 @@
# Quote Verification Ledger
각 lane에 기록된 `grep -nFo` 또는 `sed -n` 명령을 controller가 allowlist 방식으로 다시 실행했다. 실행 파일은 `grep|sed`만 허용했고, 대상 `.md` 경로가 workspace 내부인지 확인했으며, 종료 코드 `0`과 non-empty stdout을 모두 요구했다.
```text
$ python3 /tmp/validate_keycloak_review.py
inventory_rows=38
inventory_unique=38
read_full=38
finding_ids=60
expected_finding_ids=60
commands_passed=107
commands_failed=0
verified_output_lines=158
findings_without_commands=0
```
실제 인용 출력은 각 lane의 finding 아래 code block에 보존되어 있다. 아래 표는 finding별 controller 재실행 결과다.
| Finding ID | Commands | Controller rerun | Evidence owner |
|---|---:|---|---|
| `L1-F01` | 2 | VERIFIED | [lane 01](lanes/lane-01-keycloak-sorted-01-10.md) |
| `L1-F02` | 2 | VERIFIED | [lane 01](lanes/lane-01-keycloak-sorted-01-10.md) |
| `L1-F03` | 2 | VERIFIED | [lane 01](lanes/lane-01-keycloak-sorted-01-10.md) |
| `L1-F04` | 2 | VERIFIED | [lane 01](lanes/lane-01-keycloak-sorted-01-10.md) |
| `L1-F05` | 2 | VERIFIED | [lane 01](lanes/lane-01-keycloak-sorted-01-10.md) |
| `L1-F06` | 2 | VERIFIED | [lane 01](lanes/lane-01-keycloak-sorted-01-10.md) |
| `L1-F07` | 2 | VERIFIED | [lane 01](lanes/lane-01-keycloak-sorted-01-10.md) |
| `L1-F08` | 2 | VERIFIED | [lane 01](lanes/lane-01-keycloak-sorted-01-10.md) |
| `L1-F09` | 3 | VERIFIED | [lane 01](lanes/lane-01-keycloak-sorted-01-10.md) |
| `L1-F10` | 2 | VERIFIED | [lane 01](lanes/lane-01-keycloak-sorted-01-10.md) |
| `L1-F11` | 2 | VERIFIED | [lane 01](lanes/lane-01-keycloak-sorted-01-10.md) |
| `L1-F12` | 2 | VERIFIED | [lane 01](lanes/lane-01-keycloak-sorted-01-10.md) |
| `L1-F13` | 3 | VERIFIED | [lane 01](lanes/lane-01-keycloak-sorted-01-10.md) |
| `L1-F14` | 2 | VERIFIED | [lane 01](lanes/lane-01-keycloak-sorted-01-10.md) |
| `L2-F01` | 3 | VERIFIED | [lane 02](lanes/lane-02-keycloak-sorted-11-20.md) |
| `L2-F02` | 4 | VERIFIED | [lane 02](lanes/lane-02-keycloak-sorted-11-20.md) |
| `L2-F03` | 4 | VERIFIED | [lane 02](lanes/lane-02-keycloak-sorted-11-20.md) |
| `L2-F04` | 2 | VERIFIED | [lane 02](lanes/lane-02-keycloak-sorted-11-20.md) |
| `L2-F05` | 2 | VERIFIED | [lane 02](lanes/lane-02-keycloak-sorted-11-20.md) |
| `L2-F06` | 2 | VERIFIED | [lane 02](lanes/lane-02-keycloak-sorted-11-20.md) |
| `L2-F07` | 2 | VERIFIED | [lane 02](lanes/lane-02-keycloak-sorted-11-20.md) |
| `L2-F08` | 3 | VERIFIED | [lane 02](lanes/lane-02-keycloak-sorted-11-20.md) |
| `L2-F09` | 5 | VERIFIED | [lane 02](lanes/lane-02-keycloak-sorted-11-20.md) |
| `L3-F01` | 1 | VERIFIED | [lane 03](lanes/lane-03-keycloak-sorted-21-30.md) |
| `L3-F02` | 1 | VERIFIED | [lane 03](lanes/lane-03-keycloak-sorted-21-30.md) |
| `L3-F03` | 1 | VERIFIED | [lane 03](lanes/lane-03-keycloak-sorted-21-30.md) |
| `L3-F04` | 2 | VERIFIED | [lane 03](lanes/lane-03-keycloak-sorted-21-30.md) |
| `L3-F05` | 1 | VERIFIED | [lane 03](lanes/lane-03-keycloak-sorted-21-30.md) |
| `L3-F06` | 1 | VERIFIED | [lane 03](lanes/lane-03-keycloak-sorted-21-30.md) |
| `L3-F07` | 2 | VERIFIED | [lane 03](lanes/lane-03-keycloak-sorted-21-30.md) |
| `L3-F08` | 2 | VERIFIED | [lane 03](lanes/lane-03-keycloak-sorted-21-30.md) |
| `L3-F09` | 1 | VERIFIED | [lane 03](lanes/lane-03-keycloak-sorted-21-30.md) |
| `L3-F10` | 1 | VERIFIED | [lane 03](lanes/lane-03-keycloak-sorted-21-30.md) |
| `L3-F11` | 2 | VERIFIED | [lane 03](lanes/lane-03-keycloak-sorted-21-30.md) |
| `L3-F12` | 2 | VERIFIED | [lane 03](lanes/lane-03-keycloak-sorted-21-30.md) |
| `L3-F13` | 2 | VERIFIED | [lane 03](lanes/lane-03-keycloak-sorted-21-30.md) |
| `L3-F14` | 1 | VERIFIED | [lane 03](lanes/lane-03-keycloak-sorted-21-30.md) |
| `L3-F15` | 2 | VERIFIED | [lane 03](lanes/lane-03-keycloak-sorted-21-30.md) |
| `L3-F16` | 1 | VERIFIED | [lane 03](lanes/lane-03-keycloak-sorted-21-30.md) |
| `L4-F01` | 1 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F02` | 1 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F03` | 2 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F04` | 1 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F05` | 1 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F06` | 2 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F07` | 1 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F08` | 2 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F09` | 1 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F10` | 2 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F11` | 1 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F12` | 1 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F13` | 1 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F14` | 2 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F15` | 1 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F16` | 2 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F17` | 1 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F18` | 1 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F19` | 1 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F20` | 1 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
| `L4-F21` | 1 | VERIFIED | [lane 04](lanes/lane-04-keycloak-sorted-31-38.md) |
@@ -0,0 +1,25 @@
# Unresolved Risk Register
| Risk ID | 상태 | 근거 | 영향 | 다음 검증 |
|---|---|---|---|---|
| `R-01` Claim traceability migration | OPEN | 38개 중 `Decision Evidence Map`은 38개에 있으나 `## Claims Extracted`는 2개에만 있다. `UNSUPPORTED_DECISION` literal은 32개 파일, 125회다. | branch decision의 raw Claim 의미를 모든 파일에서 역검증했다고 말할 수 없다. | `/migrate-claims` 범위를 별도로 승인한 뒤 claim reference lexical/semantic audit. |
| `R-02` Reference-edge semantic breadth | OPEN | scope 안 유효 wikilink reference occurrence 647건, logical `(citing, owner, D-id)` edge 298건. 이번 Layer 2는 고위험 후보 20개만 표본 대조했다. | 저위험으로 분류된 stale summary나 foreign-decision restatement가 더 남을 수 있다. | 전체 298 logical edge 대조는 `/sync` 규칙에 따라 별도 확인 후 20개 이하 lane으로 분할한다. |
| `R-03` Runtime behavior | OPEN | branch-note와 archived source를 비교했으며 Keycloak/Google/Caddy/nginx/Traefik을 구동하지 않았다. | Max Reuse, logout family behavior, issuer/JWKS reachability, redirect/origin 같은 동작은 문서 간 정합과 실제 동작이 다를 수 있다. | pinned version compose/E2E matrix 실행 후 `locally-verified` evidence 추가. |
| `R-04` Security baseline as-built | OPEN | `feature-security-operational-baseline.md`의 현재 코드/canonical 비교 후보가 lane 밖 ca-tmpl code와 `wiki/projects/`를 요구한다. | `L4-F19`~`L4-F21`의 stale 판정 강도는 실제 코드 상태에 따라 달라진다. | 별도 code/canonical audit로 현행 artifact와 branch-note를 대조한다. |
| `R-05` Unarchived security/version claim | OPEN | `L2-F01`의 CVE/version 전제는 repo raw에 공식 advisory가 없다고 문서 자체가 표시한다. | Confirm Link 흐름의 적용 버전 조건을 확정할 수 없다. | 공식 advisory를 raw로 보존하고 배포 Keycloak version과 교차 확인한다. |
| `R-06` Deterministic false-positive candidate | OPEN | `feature-keycloak-single-ec2-no-google.md:109``fix-plan D4`를 checker가 branch owner로 오인한 후보. | 자동 묶음 수정 시 존재하지 않는 wikilink를 만들 수 있다. | checker tokenization을 확인해 false-positive면 규칙/fixture로 분리한다. |
| `R-07` Source edits pending approval | DEFERRED | 이번 요청은 리뷰이며 branch-note 원문을 수정하지 않았다. | 발견된 owner drift와 contradictory default가 계속 남아 있다. | priority/fix-plan에서 사용자가 항목별 또는 low-risk 묶음 승인을 선택한다. |
## Traceability counts
```text
scope files=38
Decision Evidence Map files=38
Claims Extracted files=2
MISSING_CLAIMS_EXTRACTED files=36
UNSUPPORTED_DECISION files=32
UNSUPPORTED_DECISION occurrences=125
BROKEN_CLAIM_REFERENCE literal labels=0
```
`BROKEN_CLAIM_REFERENCE` literal 0은 모든 raw Claim target이 검증됐다는 뜻이 아니다. lane 2의 lexical sample에서는 unique Claim ID 153개 중 152개가 resolve되고 `KC-HOST-C6` 1개가 미아카이브 후보였으며, 다른 lane은 raw source body 의미를 범위 밖으로 남겼다.