Files
llm-wiki/raw/branch-notes/feature-keycloak-spring-rs-role-mapping.md
T

264 lines
26 KiB
Markdown

---
title: branch / feature-keycloak-spring-rs-role-mapping (Keycloak role → Spring RBAC mapping)
source_type: branch-note
status: raw
id: BR-KEYCLOAK-CHILD-783CA54B
kind: branch-child
project: keycloak-patterns-overview
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-004
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-SPA-CLIENT-001@1, DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1]
refines: []
overrides: []
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-003]
contract_packet: 1
branch: feature-keycloak-spring-rs-role-mapping
parent_branch: feature-keycloak-spring-rs-audience-validator
related_projects: [keycloak-patterns]
tags: [branch, keycloak-patterns, p3a, implementation, spring-boot, resource-server, jwt, authorization]
created: 2026-05-25
target_merge:
status_label: in-progress
contract_packet_sha256: dd660a8ddd1df3dabc7775e90818479fd5796ee4072d707242dc10c70827c701
---
# branch: feature-keycloak-spring-rs-role-mapping (Keycloak role → Spring RBAC mapping)
> Layer: `raw/branch-notes/` — [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]]의 WI004 child branch.
> **P3A는 실 구현 대상**. 본 sub-sub는 학습 + 작업 plan 기록 — 실 구현은 `/home/donghyeon/workspace/keycloak-patterns/`.
> **정합 노트 (2026-07-14 감사)**: 본 노트의 Spring RS 공통 셋업 + `aud` 검증 내용은 형제 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 **owner** (중복 정리). 본 노트의 고유 책임 = **role → `@PreAuthorize` (RBAC 인가)** 이며, 이는 §5 **deferred authZ 트랙**이다(4 패턴 authN E2E 이후 착수). hub 분류: FOLD-IN(→ audience-validator 근거) + RBAC 부분 DEFERRED.
<!-- section-id: branch-parent -->
## 부모 (필수)
[[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]]
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: foreign audience token 수용 실패를 재현하고 validator 적용 후 401을 검증한다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-SPA-CLIENT-001@1` | AP1은 public client와 Authorization Code + PKCE를 사용한다 | AP1 Resource Server의 role claim 변환과 authorization에 적용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1` | done-bar는 E2E success와 signature security failure 재현·해결 evidence다 | audience validator parent의 security verification contract를 승계한다 | [[raw/project-notes/keycloak-patterns-overview]] |
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|---|---|---|---|
<!-- section-id: declared-overrides -->
### 선언한 예외
| Override ID | Overrides | Reason | Approval | Status |
|---|---|---|---|---|
없음.
<!-- GENERATED: branch-contract:end -->
<!-- section-id: branch-goal -->
## 목표
검증을 통과한 Keycloak JWT의 `realm_access.roles`를 Spring `ROLE_*` authority로 변환하고 `/api/admin`에 RBAC를 강제한다. RS 공통 셋업과 audience 검증은 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D6을 선행 계약으로 소비한다.
면접 질문: "Keycloak이 발급한 JWT를 Spring에서 어떻게 검증하나요?"
→ "토큰 검증은 audience owner 계약을 따르고, 이 branch에서는 `realm_access.roles`의 nested claim을 custom converter로 읽어 `ROLE_*` authority로 바꿉니다. `/api/admin``admin-role`을 요구하고 prefix 중복을 음성 테스트합니다."
- 이슈:
- PR: (별도 keycloak-patterns repo)
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- `JwtAuthenticationConverter` — Keycloak `realm_access.roles` → Spring `ROLE_*`
- `/api/me` endpoint: `@AuthenticationPrincipal Jwt` → JWT claims 반환
- `/api/admin` endpoint: `@PreAuthorize("hasRole('admin-role')")` 또는 SecurityFilterChain matcher
- RBAC matcher/method-security 선택과 double-prefix 음성 테스트
### 제외 범위
- Opaque token introspection (Keycloak access token은 JWT)
- Custom JWT claim 변환 (예: `preferred_username``User` 도메인 객체 매핑)
- Spring Session / 서버 측 세션
- Method-level security 정밀 튜닝
- Spring RS 의존성·`issuer-uri`·`JwtDecoder`·audience value/validator·CORS → [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D6. expected audience는 owner의 `backend-client-id`를 소비하며 여기서 재명세하지 않는다
## 근거 (필수, 최소 1개+)
- [[raw/official-docs/spring-security-resource-server-jwt]] — Spring Security Resource Server JWT 공식
- [[raw/official-docs/spring-security-authorize-http-requests]] — RBAC enforcement location Alternative A (`authorizeHttpRequests` + `requestMatchers(...).hasRole(...)`) 공식 근거 — request-level 모델링, `AuthorizationFilter` timing, path-only matching 한계
- [[raw/company-tech-blogs/keycloak-jwt-role-extraction-betweendata]] — `personal-blog`(Christian Huff). 커스텀 `Converter<Jwt, Collection<GrantedAuthority>>``realm_access` claim 을 읽어 `ROLE_` prefix 로 변환하고 `DelegatingJwtGrantedAuthoritiesConverter` 로 default scope converter 와 결합하는 구현 사례. `engineering-blog` 강도 — 공식 best practice 아님, D4 구현 detail 참고용
- [[raw/official-docs/spring-security-method-security]] — RBAC enforcement location Alternative B(`@EnableMethodSecurity` + `@PreAuthorize("hasRole('admin-role')")`) 채택 근거 + unannotated method 미보호 CRITICAL backstop 경고(catch-all `HttpSecurity` 규칙 필수)
- [[raw/official-docs/spring-security-nested-authorities-claim-issue-15201]] — `JwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("realm_access.roles")` nested claim 미지원 known-limitation + custom converter 워크어라운드 + `ExpressionJwtGrantedAuthoritiesConverter`(6.4+) 공식 확인 (GitHub Issue #15201, vendor 저장소)
- [[raw/official-docs/spring-security-authorization-defense-in-depth]] — RBAC enforcement location Alternative C(request-level + method-level 동시 사용 = defense in depth) 결정의 벤더 공식 근거
- [[raw/official-docs/spring-security-authorization-architecture]] — `ROLE_` prefix 는 Spring Security 기본값(role-based rule 이 `ROLE_` 자동 부착, `SS-AUTHZ-ARCH-C5`) — `hasRole("admin-role")` double-prefix 계약(§구현 가이드 3)의 공식 근거
## TODO
- [ ] 선행 계약 확인: [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D6이 정상·wrong-audience E2E를 통과 — 등급: `planned`
- [ ] `JwtAuthenticationConverter` 빈: `realm_access.roles``SimpleGrantedAuthority("ROLE_" + role)` 매핑 — 등급: `planned`
- [ ] `@RestController` `MeController`: `GET /api/me``@AuthenticationPrincipal Jwt jwt``Map.of("sub", jwt.getSubject(), "preferred_username", jwt.getClaim("preferred_username"), "roles", jwt.getClaim("realm_access"))` 반환 — 등급: `planned`
- [ ] `@RestController` `AdminController`: `GET /api/admin``@PreAuthorize("hasRole('admin-role')")` 또는 matcher 기반 — 등급: `planned`
- [ ] Dockerfile (multi-stage: gradle build → JRE 21 runtime) — 등급: `planned`
- [ ] 로컬 검증: regular-user 토큰으로 `/api/me` 200, `/api/admin` 403 — 등급: `planned`
- [ ] 로컬 검증: admin-user 토큰으로 `/api/admin` 200 — 등급: `planned`
- [ ] 선행 owner의 wrong-audience 401 결과를 consume하고 본 branch에서는 RBAC 200/403만 추가 검증 — 등급: `planned`
- [ ] 로컬 검증: `hasRole("admin-role")`(prefix 자동) vs `hasRole("ROLE_admin-role")`(double-prefix 버그) 대조 — 등급: `planned`
## 진행 중 메모
- **RS/audience prerequisite**: [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D4/D6이 owner다. 본 branch는 검증을 통과한 JWT만 입력으로 받는다.
- **role mapping**: Keycloak token claim 구조 — `realm_access: { roles: [admin-role, user-role] }`. resource_access는 client별 role (out of scope).
- **`/api/me` 응답에 raw JWT claims 노출 신중**: 학습 목적이라 OK, prod에서는 필요한 claim만 반환.
- **Spring Boot 3 + Spring Security 6** 기준 lambda DSL 사용. 옛 fluent API는 deprecated.
- **(2026-07-18 자동조사) `setAuthoritiesClaimName("realm_access.roles")` 는 nested 미지원**: 공식 확인된 사실 = nested `realm_access.roles` 는 이 API 로 못 읽고 custom `Converter` 또는 SS ≥6.4 의 `ExpressionJwtGrantedAuthoritiesConverter` 로만 처리(`SS-15201-C2`/`C3`). *왜* 실패하는지의 내부 원리("dot 을 경로 구분자로 안 쓰고 top-level claim 을 literal lookup")는 **추정** — SS-15201 는 이를 증명하지 않으며 소스/Javadoc 별도 확인 필요. 관측 결과는 **0 authority(silent 403)** 로 예상. §Decision Evidence Map D6 + §구현 가이드 1 참조.
## 결정 사항 (decisions)
- 2026-07-18 (delegated): RS 셋업·audience 검증은 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D6을 따른다.
- 2026-05-25: **realm-global 권한만 필요한 baseline에서는 realm role만 매핑**한다. client-specific 권한 namespace가 필요하면 `resource_access.<client>.roles` variant를 별도 결정한다. client 개수 자체는 선택 근거가 아니다.
- 2026-05-25: **`@PreAuthorize` 대신 SecurityFilterChain matcher 우선.** 이유: 권한 정책 한 곳 집중 → 면접 답변 일관성.
- 2026-07-18: **realm role → authority 매핑에 custom `Converter<Jwt, Collection<GrantedAuthority>>` 채택 (`setAuthoritiesClaimName` 폐기).** 이유: `setAuthoritiesClaimName` 은 nested `realm_access.roles` 를 파싱 못함(literal top-level lookup, silent 403). 대안: Boot ≥3.4 로 pin 시 `ExpressionJwtGrantedAuthoritiesConverter` + SpEL 한 줄. 근거: SS-15201, SSRS-JWT-C4, betweendata 사례. (자동조사 `/branch-spec`)
- 2026-07-18: **D5(RBAC 강제 지점)의 `UNSUPPORTED_DECISION` 해소 — 근거 확보.** 기본 A(HTTP matcher), 조건부 B(`@PreAuthorize`+catch-all)/C(defense-in-depth). 근거: 공식 authorize-http-requests / method-security / features-authorization. (자동조사 `/branch-spec`)
## 결정-근거 매핑
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. `Decision ID` 는 본 branch-note 안에서 안정적으로 유지.
> `선택 조건` 열: "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
> **정합 (2026-07-14)**: RS-common(D1·D2·D3)은 형제 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 owner — 본 노트 in-scope 는 role→RBAC(D4·D5·D6). 상세는 §Audit & Findings.
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|---|
| D1 | **DELEGATED** — [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D6의 RS 공통 셋업을 consume | RBAC는 검증 완료 JWT 위에 얹힘 | owner D6 | `delegated` | 본 branch에서 버전·decoder wiring을 재명세하지 않음 |
| D2 | **DELEGATED** — [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D4의 audience 계약(`backend-client-id`)을 consume | expected audience 변경은 owner에서만 | owner D1/D4 | `delegated` | 본 branch는 audience 구현·테스트를 복제하지 않음 |
| D3 | **DELEGATED** — [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D6의 bearer RS 실행 계약을 consume | RBAC 입력 전제 | owner D6 | `delegated` | session/CORS 세부를 재명세하지 않음 |
| D4 | realm-global 권한이면 `realm_access.roles`만 매핑; client-specific 권한이 필요하면 `resource_access.<client>.roles` variant | 선택 기준은 **권한 namespace**다. client 수가 많아도 공통 권한이면 realm role을 유지할 수 있고, client별 격리가 필요하면 client role을 추가한다 | `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C4` | `official-vendor-doc + project policy` | 현재 authZ는 deferred. 실제 client별 권한 요구를 확정하기 전 realm-only를 외부 경험으로 승격 금지 |
| D5 | RBAC 강제 지점 — 기본 `SecurityFilterChain` matcher(A), 조건부 B/C | 기본=A(`authorizeHttpRequests` matcher): endpoint 소수 + role↔URL 안정 + "정책 한 곳 집중/면접 일관성" 목표. B(`@PreAuthorize`, **A catch-all 유지 필수**): 파라미터/소유권 기반 판단 또는 비-HTTP 진입점. C(A+B 병행=defense-in-depth): 프로덕션 노출 + matcher/annotation 누락이 실제 위협일 때 | `raw/official-docs/spring-security-authorize-http-requests.md#SS-AUTHZ-HTTP-C1` (request-level 모델링 — `/admin` 하위 authority), `raw/official-docs/spring-security-authorize-http-requests.md#SS-AUTHZ-HTTP-C3` (AuthorizationFilter 가 DispatcherServlet/컨트롤러 실행 전 차단), `raw/official-docs/spring-security-method-security.md#SPRING-MS-C5` (unannotated method 미보호 → B 시 catch-all 필수), `raw/official-docs/spring-security-authorization-defense-in-depth.md#SS-AUTHZ-DID-C1` (request+method = defense in depth) | `official-vendor-doc` (+ personal/company-blog corroborate: Okta·Marco Behler·howtodoinjava — 미아카이브, official 로 충분) | C 채택 시 두 계층 role 조건 동기화 미스매치가 "단일 설명 위치" 목표 훼손; A 단독 시 URL glob drift(SS-AUTHZ-HTTP-C4 path-only); B 단독 시 미어노테이트/self-invocation 무보호 |
| D6 | Keycloak `realm_access.roles``GrantedAuthority` 매핑 메커니즘 = 수동 custom `Converter<Jwt, Collection<GrantedAuthority>>` (`setAuthoritiesClaimName` 폐기) | nested claim(`realm_access.roles`)이라 flat-claim 전용 `setAuthoritiesClaimName` 는 확정 실패(nested 미지원 = SS-15201 공식; 내부 lookup 원리는 추정 → §진행 중 메모). 대안: Boot ≥3.4 / SS ≥6.4 로 pin 가능하면 `ExpressionJwtGrantedAuthoritiesConverter` + SpEL `[realm_access][roles]` (커스텀 클래스 없이 한 줄) | `raw/official-docs/spring-security-nested-authorities-claim-issue-15201.md#SS-15201-C2` (custom `JwtGrantedAuthoritiesConverter` 구현이 nested role 추출에 필요), `raw/official-docs/spring-security-nested-authorities-claim-issue-15201.md#SS-15201-C3` (`ExpressionJwtGrantedAuthoritiesConverter` fix, milestone 6.4.0), `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C4` (default 는 `scope`/`scp` 만 매핑), `raw/company-tech-blogs/keycloak-jwt-role-extraction-betweendata.md#KC-ROLE-BD-C1` (custom `Converter``realm_access` 읽는 구현 예) | `official-vendor-doc + engineering-blog` | `jwt.getClaim()` 이 claim 부재 시 빈 컬렉션 반환(silent 403)은 소스 self-grep 전까지 `needs-confirmation`; betweendata 예제는 `ROLE_realm_` prefix + resource role 도 매핑(D4 범위 밖) → 본 브랜치는 `ROLE_` + realm-only 로 조정 |
## 구현 가이드
> 본 §는 이 branch 의 **in-scope = RBAC/role 매핑 트랙(D4·D5·D6)** 만 구체화한다. RS 공통 셋업(Boot 의존성·`issuer-uri`·`JwtDecoder`·`aud` 검증 = D1·D2·D3)은 **형제 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 owner**(2026-07-14 감사) → 여기서 재명세하지 않고 §엣지·실패·의존 "다른 계약 의존" 으로 링크(R3 OUT_OF_BRANCH_SCOPE).
> `keycloak-patterns` repo 부재(NO_GROUND_TRUTH, §Audit) → 아래 전부 `planned`. 실 구현 후 코드 grep 으로 등급 승급.
### 1. Realm role → GrantedAuthority 매핑 (핵심)
> **Trace**: D4(realm-only) + D6(custom Converter 메커니즘) — `SS-15201-C2`/`SS-15201-C3`, `SSRS-JWT-C4`, `KC-ROLE-BD-C1`.
>
> - **UNSUPPORTED_IMPL_DECISION**: (1) authority prefix 문자열 = `ROLE_` (betweendata 사례는 `ROLE_realm_`) — `hasRole("admin-role")` 이 `ROLE_admin-role` 을 기대하므로 `ROLE_` 채택. trade-off: betweendata 예제와 불일치하나 표준 `hasRole` 계약에 정합(§3). (2) claim 부재 시 빈 컬렉션 반환(null-safe) — 근거 raw 는 방어 코드 형태를 규정 안 함, silent-403 진단성 위한 임의 선택.
| 항목 | 명세 |
|---|---|
| 클래스 | `KeycloakRealmRoleConverter implements Converter<Jwt, Collection<GrantedAuthority>>` (별도 파일 또는 `SecurityConfig` static nested class) |
| 읽기 | `Map<String,Object> realmAccess = jwt.getClaimAsMap("realm_access");``realmAccess.get("roles")``Collection<String>` 으로 |
| 방출 | 각 role → `new SimpleGrantedAuthority("ROLE_" + role)` |
| null-safety | `realmAccess == null` 또는 `roles``Collection` 아니면 → `Collections.emptyList()` |
| wiring | `JwtAuthenticationConverter jac = new JwtAuthenticationConverter(); jac.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter());``.oauth2ResourceServer(o -> o.jwt(j -> j.jwtAuthenticationConverter(jac)))` |
| 대안(버전 pin 시) | Boot ≥3.4 / SS ≥6.4 → 커스텀 클래스 대신 `ExpressionJwtGrantedAuthoritiesConverter` + SpEL `"[realm_access][roles]"` (`SS-15201-C3`) — 단 본 브랜치는 버전 미pin 이라 custom Converter 를 기본으로 함 |
### 2. `/api/admin` RBAC 강제 지점 (기본 A)
> **Trace**: D5 — `SS-AUTHZ-HTTP-C1`/`SS-AUTHZ-HTTP-C3`, `SPRING-MS-C5`(backstop), `SS-AUTHZ-DID-C1`(조건부 C).
>
> - **UNSUPPORTED_IMPL_DECISION**: URL glob `/api/admin/**` + role 명 `admin-role` — 공식 예시는 illustrative(`SS-AUTHZ-HTTP-C1` "Does not prove admin-role name"); glob/명명은 프로젝트 임의 결정(Keycloak realm role 명명은 `feature-keycloak-realm-client-export` 소관).
| 항목 | 명세 |
|---|---|
| 강제(A) | `.authorizeHttpRequests(a -> a.requestMatchers("/api/admin/**").hasRole("admin-role").anyRequest().authenticated())` |
| `/api/me` | 별도 role 없이 `authenticated()` (위 `anyRequest()` 로 커버) |
| 타이밍 | `AuthorizationFilter``DispatcherServlet` 이전 실행 → 컨트롤러 도달 전 차단(`SS-AUTHZ-HTTP-C3`) |
| 조건부 승격(B) | 파라미터/소유권 기반 필요 시 `@EnableMethodSecurity` + `@PreAuthorize("hasRole('admin-role')")`, **단 `anyRequest().authenticated()` catch-all 유지 필수**(`SPRING-MS-C5` — unannotated method 무보호 방지) |
| 조건부 승격(C) | 프로덕션 노출 시 A+B 병행(defense in depth, `SS-AUTHZ-DID-C1`) — 두 계층 role 조건 동기화 규율 전제 |
### 3. `hasRole` prefix 계약 (double-prefix 함정)
> **Trace**: D6 — `SS-AUTHZ-ARCH-C5`(`ROLE_` 자동 prefix = Spring Security 기본값, 공식), `KC-ROLE-BD-C2`(double-prefix 위험 사례).
`hasRole("admin-role")` 은 내부적으로 `ROLE_` 를 자동 prefix (`SS-AUTHZ-ARCH-C5`) → §1 converter 가 이미 `ROLE_admin-role` 을 만들었으므로 인자는 prefix 없이 `hasRole("admin-role")` 로 호출한다. `hasRole("ROLE_admin-role")` 로 부르면 `ROLE_ROLE_admin-role` 을 조회 → admin 이 항상 403. §Claims To Verify + TODO 에 이 self-check(prefix 유무 대조) 추가.
## 엣지·실패·의존
> R4(깊이 게이트) 캡처용. 정상 경로 외 실패/엣지/다른 계약 의존을 미리 열거.
- **실패·엣지 경로**:
- **nested claim silent failure** — `setAuthoritiesClaimName("realm_access.roles")` 사용 시 literal top-level lookup 실패 → 0 authority → 모든 `hasRole` false → 전 요청 403, 예외/로그 없음(`SS-15201-C2`). 기대 동작: custom converter(§1)로 회피 + `/api/me` 응답에 `ROLE_user-role` 존재 확인.
- **`realm_access` claim 부재** — Keycloak client 에 realm-role mapper 없으면 claim 누락 → converter empty → 403. 기대: null-safe converter(§1) + realm role mapper 설정(→ 아래 의존).
- **double-prefix** — `hasRole("ROLE_admin-role")` 오용 시 `ROLE_ROLE_admin-role` → admin 항상 403(§3).
- **unannotated-method gap** (조건부 B 채택 시) — 어노테이션 누락 endpoint 무보호. catch-all `anyRequest().authenticated()` 유지로 방어(`SPRING-MS-C5`).
- **다른 계약 의존**:
- [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D6 — RS 공통 셋업과 `backend-client-id` audience 검증 owner. 본 role 매핑은 검증 완료 JWT 위에 얹힌다.
- [[raw/branch-notes/feature-keycloak-realm-client-export]] `#D5` — realm-export.json 이 realm role(`admin-role`/`user-role`) 정의 + user `realmRoles` 부여를 담음(그 노트 §TODO Role 생성). Keycloak 기본 realm-roles protocol mapper 가 이를 token 의 `realm_access.roles` 로 실음 → export 가 role 을 안 담으면 본 매핑은 빈 authority(위 "claim 부재" 엣지).
- [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] `#D1``KC_HOSTNAME`/`iss` 문자열 일치(token 이 통과해야 role 매핑 단계에 도달).
## Audit & Findings
> §2 ground-truth 확인 + 결정 정합 감사 결과. 자동 rewrite 대상 아님(surface + 정합 권고).
- **BROKEN_CODE_DRIFT** (surface-only, read-only 권고): 인용 근거 [[raw/official-docs/spring-security-resource-server-jwt]] 의 §"권한 추출 customize (해석)" 코드가 `setAuthoritiesClaimName("realm_access.roles")` 를 사용 — nested claim 을 파싱하지 못해 **작동하지 않는 패턴**(`SS-15201-C2`). 그 raw 는 이미 "추가 확인 필요" 로 flag 되어 있으나, 코드 블록 자체에 "nested 미지원 → custom Converter / `ExpressionJwtGrantedAuthoritiesConverter`(6.4+) 필요" caveat 추가를 권고. 해당 raw 는 별도 소유 → 자동 수정 안 함(정합 권고만).
- **DELEGATION** (2026-07-14 감사 정합): RS 공통 셋업 + `aud`(D1·D2·D3)는 형제 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 owner. 본 노트 in-scope = **role→RBAC(D4·D5·D6)** = deferred authZ 트랙([[raw/project-notes/keycloak-patterns-overview]] §Deferred — 4 패턴 authN E2E 후 착수). D1·D3 가 여기서 `UNSUPPORTED_DECISION` 인 것은 RS-common(sibling 소유 rationale)이기 때문 — 본 브랜치 추가 조사 대상 아님(R3 OUT_OF_BRANCH_SCOPE).
- **NO_GROUND_TRUTH**: 실 구현 대상 repo `/home/donghyeon/workspace/keycloak-patterns/` 부재(2026-07-18 확인) → 본 노트 모든 항목 `planned`/`documented-only`. `actually-implemented` 주장은 코드 대조 불가이므로 하지 않음(§구현 가이드는 사전 명세일 뿐).
## 검증해야 할 주장
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| RS/audience prerequisite가 완료된 JWT만 RBAC converter에 도달 | 선행 owner repo가 아직 미구현 | [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D6 E2E 결과를 consume하고 본 branch 테스트 fixture의 전제로 기록 | `planned (delegated)` |
| custom `KeycloakRealmRoleConverter`(§1)가 실제 Keycloak token 에서 `ROLE_user-role`/`ROLE_admin-role` authority 를 방출 (`setAuthoritiesClaimName` 은 D6/SS-15201 로 이미 폐기 확정) | 메커니즘은 확정됐으나 로컬 실동작 + 실제 token 의 `realm_access.roles` 구조/composite role 확장 여부 미확인 | regular-user 로 token 발급 → backend `/api/me` 응답에서 `ROLE_user-role` granted authority 존재 확인 | `planned` |
| `@PreAuthorize("hasRole('admin-role')")` / `.hasRole("admin-role")` 가 converter 의 `ROLE_admin-role` 과 정확히 매칭(double-prefix 없음) | `hasRole``ROLE_` 를 자동 prefix — converter 도 `ROLE_` 를 붙이므로 인자에 `ROLE_` 재기입 시 `ROLE_ROLE_` 버그(`KC-ROLE-BD-C2`) | admin-user token 으로 `/api/admin` 200 확인 → 인자를 `hasRole("ROLE_admin-role")` 로 바꿔 403 되는지 대조 | `planned` |
## 마주친 문제
- (선행 계약) audience 발급·검증 문제는 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D4에서 추적한다. 본 branch는 검증 완료 뒤의 RBAC만 소유한다.
- (구현 시작 후 추가) `issuer-uri`가 backend 기동 시점에 reachable하지 않으면 Spring startup 실패 — docker-compose `depends_on healthy`로 해결.
## 묶음
<!-- GENERATED: sources:start -->
- [[raw/company-tech-blogs/keycloak-jwt-role-extraction-betweendata]]
- [[raw/official-docs/spring-security-authorization-defense-in-depth]]
- [[raw/official-docs/spring-security-authorize-http-requests]]
- [[raw/official-docs/spring-security-method-security]]
- [[raw/official-docs/spring-security-nested-authorities-claim-issue-15201]]
- [[raw/official-docs/spring-security-resource-server-jwt]]
<!-- GENERATED: sources:end -->
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
- (없음 — 현재 documented-only 단계)
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
- (없음 — Phase 3 실 구현 단계에 누적)
## 관련 일일 노트
## 완료 후 정리
> 로컬 `curl` 검증 통과 시 `planned` → `actually-implemented`/`locally-verified` 승급.
- PR 링크: (별도 keycloak-patterns repo)
- 리뷰 메모:
- 머지 결과 / 배포 환경: 로컬 docker-compose / 단일 EC2 시뮬레이션
- **wiki 추출 대상**:
- `actually-implemented` 항목: (구현 후 채움)
- `locally-verified` 항목: (구현 후 채움)
- `prod-verified` 항목: (없음)
- **추출하지 않을 항목**: 현재 전부 `planned`.