## 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`)는 **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`)는 **raw role 문자열**(`admin`/`user` — Keycloak 원본, prefix 없음)을 담는다.' 'raw/branch-notes/feature-authentication-authorization-contract.md' 169:ca-tmpl `AuthenticatedUser.roles`(`adapter-web`, `Set`)는 **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/` - **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/`' 'raw/branch-notes/feature-keycloak-docker-compose-stack.md' 138:Spring Boot Resource Server, `issuer-uri=http://localhost:8080/realms/` $ 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=` 확인 - **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=`를 명시적으로 선택했고 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=` 확인' 'raw/branch-notes/feature-keycloak-federation-spa-zero-change.md' 78:`aud=` 확인 $ 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 ```