Files
llm-wiki/raw/branch-notes/feature-security-operational-baseline.md
T

491 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: branch / feature-security-operational-baseline
source_type: branch-note
status: raw
branch: feature-security-operational-baseline
parent_branch:
related_projects: [ca-skeleton]
governing_docs: [wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets.md]
tags: [branch, ca-skeleton, security, jwt, authentication, authorization]
created: 2026-05-21
target_merge:
status_label: in-progress
last_implementation: 2026-06-08
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-008
kind: project-work-item
project: ca-skeleton-operational-contract
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-008
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-FRAMEWORK-001@1]
refines: []
overrides: []
depends_on: []
contract_packet: 1
contract_packet_sha256: 4aeeee0f8f366a32a08f4e6c687e7bf7bab5de74f51c5ca05069b8cdb6cf7cfe
---
> **현재 알려진 최신 상태**: 2026-06-08 Phase C2 기록은 아래 여러 항목을 `locally-verified`로 보고한다. 다만 현재 wiki workspace에는 해당 `src/` code owner가 없어 이번 정합 작업에서 재검증하지 못했다. 따라서 active 표는 **Phase C2 보고값**과 **현행 코드 재확인 필요**를 함께 표시하며, pre-C2 표·명령은 historical/superseded로 본다.
# branch: feature-security-operational-baseline
> Layer: `raw/branch-notes/` — JWT Resource Server 기준의 인증/인가 실패 운영 분류를 정의합니다.
<!-- section-id: branch-parent -->
## 부모 (필수)
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다.
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: security failure·header contract와 negative test가 통과한다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-FRAMEWORK-001@1` | framework는 Spring Boot 3.5.14다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
<!-- 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 -->
## 목표
Security 실패를 401/403으로만 처리하면 운영자가 missing token, expired token, issuer mismatch, public path misconfiguration을 구분할 수 없습니다. 클라이언트 응답은 과노출하지 않고 내부 로그에는 안전한 분류 code를 남깁니다.
- 이슈:
- PR:
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- JWT Resource Server baseline.
- missing/malformed/expired token 분류.
- invalid signature/issuer/audience 분류.
- claim mapping failure 분류.
- public path misconfiguration 테스트 기준.
- CORS rejection log 기준.
- token/PII 로그 금지.
### 제외 범위
- OAuth authorization server 구현.
- session 기반 security.
- business role/permission model.
## 근거 (필수, 최소 1개+)
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
| Source | 정당화하는 결정 |
|---|---|
| [[raw/official-docs/security-jwt-rfc-7519-validation]] | RFC 7519 claim 검증 표준 (ca-tmpl clock skew 60s가 RFC 권고 "a few minutes leeway" 내 |
| [[raw/official-docs/security-authorization-cheatsheet-owasp]] | OWASP deny-by-default 원칙 |
| [[raw/official-docs/security-oauth2-pkce-rfc-8252]] | issuance flow 영역, JWT 검증과 보완재 관계 |
| [[raw/official-docs/security-mtls-rfc-8705]] | sender-constrained로 강함 vs PKI 운영 부담 + public client 미지원 |
| [[raw/official-docs/security-aws-sigv4-hmac-signing]] | webhook 검증 같은 영역 한정 |
| [[raw/official-docs/security-opa-policy-engine-official]] | 정책-코드 분리 강점 vs latency·운영 부담; ca-tmpl AUTHZ 2종은 in-process 충분 |
| [[raw/company-tech-blogs/security-toss-actuator-healthcheck]] | 토스 — health 정보의 민감성 분류 |
| [[raw/official-docs/owasp-file-upload-cheat-sheet]] | OWASP file upload 방어 원칙 (extension allowlist, Content-Type 신뢰 금지, UUID 파일명, webroot 밖 저장, size limit, AV 스캔, least-privilege) — upload endpoint 의 deny-by-default 운영 baseline 보강. 본 branch 의 JWT/CORS 결정에는 직접 연결되지 않으며, 파일 처리 상세는 [[raw/branch-notes/feature-file-resource-handling-contract]] 소관 |
| [[raw/official-docs/fetch-spec-cors]] | WHATWG Fetch §3.3 CORS protocol — D9 (CORS allowlist + credentials false default + max-age + wildcard+credentials 금지) 의 1차 normative 근거. FETCH-CORS-C3: credentials=include 시 Access-Control-Allow-Origin=* 금지. FETCH-CORS-C5: max-age 기본 5초. D9 UNSUPPORTED_DECISION 해소 — `official-standard` |
| [[raw/official-docs/jwks-nimbus-jose-jwksourcebuilder-spring-integration]] | D10 JWKS refresh *메커니즘*: Spring 기본 cache 5min, `withJwkSetUri()` 기본 `rateLimited(false)`/`refreshAheadCache(false)`, unknown kid → `cache.invalidate()` (NIMBUS-JWKS-C4/C5/C6) — `official-vendor-doc`. 10min/1min exact number 는 미증명 |
| [[raw/official-docs/jwks-keycloak-key-rotation-active-passive]] | D10 rotation overlap 의 IdP-side 근거: Keycloak active/passive key model + 권고 rotation 주기 (KC-ROT-C1~C6) — `official-vendor-doc` |
| [[raw/company-tech-blogs/jwks-workos-unknown-kid-refresh-rate-limit-pattern]] | D10 unknown kid refetch-before-reject + rate-limit 5~10min 권고 + overlap 공식(token TTL+cache TTL+10min) (WORKOS-JWKS-C1~C4) — `company-case-study` (best practice 승격 금지; ca-tmpl 1/min 은 이보다 짧아 trade-off 명시) |
| [[raw/official-docs/rfc9110-http-semantics]] | D7 401/403 HTTP semantics: §15.5.2 401(인증 자격 부재 + WWW-Authenticate MUST, RFC9110-C23) + §15.5.4 403(자격 불충분, RFC9110-C24) — `official-standard`. AuthN matrix 401 행 / AUTHZ matrix 403 행의 normative 근거 |
| [[raw/official-docs/security-spring-jwt-timestamp-validator-clock-skew]] | D2 clock skew 60s: Spring Security Resource Server default clock skew = 60초 (SS-JTVC-C1) — `official-vendor-doc`. 코드의 default-의존을 벤더 doc 으로 확정 |
## 외부 근거 / 대안 조사 (2026-05-22 — Group G-B: Security Baseline)
본 branch의 JWT Resource Server + AuthN/AuthZ Decision Matrix 12행 + JWKS 10min refresh + clock skew 60s + rotation overlap 24h 결정에 대한 외부 source.
- **채택 결정 (JWT Resource Server + RFC 7519 + deny-by-default)**:
- [[raw/official-docs/security-jwt-rfc-7519-validation]] — RFC 7519 claim 검증 표준 (ca-tmpl clock skew 60s가 RFC 권고 "a few minutes leeway" 내)
- [[raw/official-docs/security-authorization-cheatsheet-owasp]] — OWASP deny-by-default 원칙
- **검토한 대안**:
- **대안 1: Session+cookie** — stateless 확장성 손실 + revocation 용이 (ca-tmpl scope 부적합)
- **대안 2: OAuth2 Authorization Code + PKCE** — [[raw/official-docs/security-oauth2-pkce-rfc-8252]] (issuance flow 영역, JWT 검증과 보완재 관계)
- **대안 3: mTLS** — [[raw/official-docs/security-mtls-rfc-8705]] (sender-constrained로 강함 vs PKI 운영 부담 + public client 미지원)
- **대안 4: HMAC SigV4** — [[raw/official-docs/security-aws-sigv4-hmac-signing]] (webhook 검증 같은 영역 한정)
- **대안 5: OPA policy engine** — [[raw/official-docs/security-opa-policy-engine-official]] (정책-코드 분리 강점 vs latency·운영 부담; ca-tmpl AUTHZ 2종은 in-process 충분)
- **비교 핵심**: ca-tmpl JWT Resource Server는 stateless 확장성 우위 + RFC 7519 + JWKS rotation으로 일부 revocation 회수. mTLS/OPA는 강하지만 skeleton 단계 운영 부담 큼. SigV4는 외부 webhook 한정. OAuth2 PKCE는 issuance flow라 보완재.
**후속 보강 (2026-05-22)**: 한국 보안 사례 source 추가 (public path / health detail 노출 관점). [[raw/company-tech-blogs/security-toss-actuator-healthcheck]] (토스 — health 정보의 민감성 분류) 참조. 본 branch의 `public path misconfiguration → INTERNAL_AUTH_MISCONFIGURATION 500 + P1 alert` 분류 정책과 정합. JWT/secret 직접 source는 미발견 — follow-up 후보로 유지.
## TODO
> TODO drained 2026-05-22 — 결정은 아래 "결정 사항" / "AuthN/AuthZ Decision Matrix" / "Decisionized Work Items" 참조. missing/malformed/expired/invalid signature/issuer/audience/claim mapping/client message/CORS/token-PII log 모두 matrix row 또는 결정 라인으로 반영됨. 잔존 TODO 없음.
## 진행 중 메모
- security event log에는 principal 식별자를 최소화합니다.
## 결정 사항 (decisions)
- 2026-05-21: JWT Resource Server를 baseline security model로 둠.
- 2026-05-22: JWT key rotation/JWKS refresh failure는 explicit security failure catalog에 포함. unknown `kid`, stale JWKS, refresh failure, rotation overlap window를 분리.
- 2026-05-22: CORS는 allowlist default, credentials false default, preflight max-age 600s default. gateway override 시 mapping table 필요.
- 2026-05-22: API gateway/WAF/Ingress가 TLS/request-size/WAF/rate-limit을 선차단할 수 있으며, app envelope bypass 가능성을 runbook에 명시.
- 2026-05-22: JWT clock skew tolerance = 60s (Spring Security JwtTimestampValidator leeway). skew 초과 expired는 AUTH_TOKEN_EXPIRED.
- 2026-05-22: JWKS refresh interval = 10분, on-demand refresh on unknown kid (rate-limited 1회/1분).
- 2026-05-22: rotation overlap window = 새 kid 도입 → 24h 동안 old kid 병행 → cutover.
- 2026-05-22: CORS allowlist SSOT = app-level 우선, gateway/WAF는 보조. allowlist origin은 env-driven runtime configuration의 `APP_SECURITY_CORS_ORIGINS`로 주입.
- 2026-05-22: public path misconfiguration 판정 알고리즘 = ~~SecurityFilterChain dump를 startup 시 snapshot~~**2026-06-09 as-built 정합: `SECURITY_PUBLIC_PATHS`(env, permitAll 의 결정론적 SSOT) 를 snapshot 으로 저장, 다음 build 와 diff** (filter-chain reflection 은 Spring 버전 brittle → 폐기, `build.gradle:185-188`). public path 변경 시 snapshot 재생성+commit 요구. **한계**: Java 하드코딩 `permitAll()`(env 우회)은 미검출(§구현 가이드 5 참조).
- 2026-05-22: secret rotation 책임 분담 = secrets-config-source-contract SSOT consume. 본 branch는 JWT signing key rotation의 **운영 관측**(JWKS refresh, kid mismatch 분류) 책임만 owns. secret 저장/주입은 secrets branch에 위임.
- 2026-05-22: JWT key rotation overlap(24h) ≥ idempotency TTL(24h)는 의도된 정합. idempotent replay가 key rotation cutover를 안전하게 가로지름. rate-limit-idempotency branch와 invariant.
## Work Item Contract
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
| field | required | rule |
| --- | --- | --- |
| Decision | yes | 구현자가 선택해야 하는 기본값 |
| Allowed | yes | 허용되는 예외와 조건 |
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
## AuthN/AuthZ Decision Matrix
| 상황 | HTTP status | error.code | error.category |
|------|-------------|-----------|----------------|
| token 누락 | 401 | AUTH_TOKEN_MISSING | AUTH |
| token malformed (parse fail) | 401 | AUTH_TOKEN_MALFORMED | AUTH |
| token expired (clock skew tolerance 60s 초과) | 401 | AUTH_TOKEN_EXPIRED | AUTH |
| invalid signature | 401 | AUTH_TOKEN_INVALID_SIGNATURE | AUTH |
| issuer mismatch | 401 | AUTH_ISSUER_MISMATCH | AUTH |
| audience mismatch | 401 | AUTH_AUDIENCE_MISMATCH | AUTH |
| unknown kid (JWKS 미캐시) | 401 + Retry-After 5s | AUTH_KID_UNKNOWN | AUTH |
| JWKS endpoint outage (JWKS cache hit 시 통과, miss 시) | 401 (캐시 miss 후 fallback 실패) 또는 503 (JWKS outage 명확) | AUTH_JWKS_UNAVAILABLE | TRANSIENT_DEPENDENCY |
| claim mapping failure (subject/principal 추출 실패) | 401 | AUTH_CLAIM_MAPPING_FAILED | AUTH |
| valid token + 권한 부족 | 403 | AUTHZ_INSUFFICIENT_PERMISSION | AUTHZ |
| valid token + tenant cross-access (cross-tenant 시도) | 403 | AUTHZ_TENANT_MISMATCH | AUTHZ |
| public path misconfiguration (보호 endpoint가 unauthenticated 통과) | 500 + P1 alert | INTERNAL_AUTH_MISCONFIGURATION | INTERNAL |
> **Historical/superseded (pre-Phase-C2)**: 과거에는 production 분류가 coarse 3-code뿐이었다. Phase C2 기록은 12-code classifier·EntryPoint/DeniedHandler를 `locally-verified`로 보고하며 coarse 3-code는 non-filter fallback으로 유지한다고 한다. 현행 code owner 재확인은 `needs-confirmation`이다.
## Decisionized Work Items
| item | Decision | Allowed | Forbidden | Required test |
| --- | --- | --- | --- | --- |
| JWT rotation | JWKS refresh + unknown `kid` + stale key classified | cached key during overlap window | generic auth failure only | key rotation failure test |
| CORS | explicit origin allowlist, max-age 600s, credentials false | credentials true with exact origin only | wildcard with credentials | CORS preflight test |
| gateway/WAF | app documents bypassed envelope cases | gateway-owned 413/429 with correlation log | assuming all failures reach app | gateway mapping checklist |
| client message | generic auth/authz message | internal reason in secure log only | issuer/audience/token detail in response | leakage test |
## 결정-근거 매핑
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. company-tech-blog 증거는 `company-case-study` 로 표기하며 공식 best practice 로 승격하지 않음.
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|
| D1 | JWT Resource Server 를 baseline security model 로 채택 (stateless 검증) | `raw/official-docs/security-jwt-rfc-7519-validation.md#JWT-RFC7519-C1`, `raw/official-docs/security-jwt-rfc-7519-validation.md#JWT-RFC7519-C2`, `raw/official-docs/security-jwt-rfc-7519-validation.md#JWT-RFC7519-C4`, `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C3` | `official-standard + official-reference` | RFC 7519 는 claim 검증 spec 만 정의 — revocation / logout 메커니즘은 RFC 범위 밖, 별도 결정 필요 |
| D2 | clock skew tolerance = 60s (Spring `JwtTimestampValidator` leeway), 초과 expired → `AUTH_TOKEN_EXPIRED` | `raw/official-docs/security-jwt-rfc-7519-validation.md#JWT-RFC7519-C2`, `raw/official-docs/security-jwt-rfc-7519-validation.md#JWT-RFC7519-C3`, `raw/official-docs/security-spring-jwt-timestamp-validator-clock-skew.md#SS-JTVC-C1` | `official-standard` (RFC "a few minutes" 상한) + `official-vendor-doc` (Spring default = 60s, SS-JTVC-C1) | 60s 가 운영 환경 NTP drift 에 충분한지 실증 필요; integration test (61s expired token reject) 미완료 |
| D3 | `aud` mismatch → 401 `AUTH_AUDIENCE_MISMATCH` 분류 | `raw/official-docs/security-jwt-rfc-7519-validation.md#JWT-RFC7519-C1` | `official-standard` (MUST reject) | 다중 audience JWT 처리 시 식별 기준 선택 — RFC 범위 밖 |
| D4 | `iss` mismatch → 401 `AUTH_ISSUER_MISMATCH` 분류 | `raw/official-docs/security-jwt-rfc-7519-validation.md#JWT-RFC7519-C4` | `official-standard` (application 재량으로 RFC 가 명시) | 401 vs 403 boundary case 선택은 RFC 가 강제하지 않음 — OWASP 권고 (OWASP-AUTHZ-C3) 으로 정당화 |
| D5 | deny-by-default + public path misconfiguration → 500 + P1 alert (**`SECURITY_PUBLIC_PATHS` env snapshot diff** — as-built; filter-chain reflection 폐기) | `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C1`, `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C2`, `raw/company-tech-blogs/security-toss-actuator-healthcheck.md#TOSS-HEALTH-C1` | `official-reference + company-case-study` (OWASP cheat sheet 는 권고 — normative 표준 아님) | env 기반이라 **Java 하드코딩 `permitAll()`(env 우회) 미검출** (§구현 가이드 5 한계); snapshot diff false-positive |
| D6 | every-request 인증 검증 (stateless JWT 매 요청마다 검증) | `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C5` | `official-reference` | session caching 미사용 시 verifier 부하 — JWKS cache + rate-limit (1회/1분) 으로 완화 |
| D7 | 401 (authn) vs 403 (authz) 분리, AUTHZ category 는 valid token + 권한/tenant 불일치에만 사용 | `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C3`, `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C4`, `raw/official-docs/rfc9110-http-semantics.md#RFC9110-C23` (401 = 인증 자격 부재 + WWW-Authenticate MUST → AUTH matrix), `raw/official-docs/rfc9110-http-semantics.md#RFC9110-C24` (403 = server 가 이해했으나 자격 불충분 → AUTHZ matrix) | `official-reference` (OWASP authn/authz 분리) + `official-standard` (RFC 9110 §15.5.2/§15.5.4 가 401/403 HTTP semantics 정의) | 경계 case(valid token + scope vs role)에서 401 vs 403 선택은 RFC 가 강제 안 함 — application 결정. (RFC 7235 는 RFC 9110 이 obsolete — 9110 이 현행) |
| D8 | gateway/WAF + app envelope 이중 enforcement (defense in depth) | `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C6` | `official-reference` | gateway bypass 시나리오 (직접 pod 접근 등) 의 envelope coverage 검증 필요 |
| D9 (2026-05-31 보강) | CORS allowlist default, credentials false default, max-age 600s, gateway override 시 mapping table | `raw/official-docs/fetch-spec-cors.md#FETCH-CORS-C1` (CORS protocol = cross-origin 공유 여부 HTTP header 집합), `#FETCH-CORS-C2` (preflight = OPTIONS + `Access-Control-Request-Method`), `#FETCH-CORS-C3` (credentials mode `include``Access-Control-Allow-Origin: *` 금지 — normative), `#FETCH-CORS-C4` (`Access-Control-Allow-Credentials` = credentials mode 응답 공유 제어), `#FETCH-CORS-C5` (`Access-Control-Max-Age` 기본 5초, UA-imposed upper limit 별도). API branch cross-cite: [[raw/branch-notes/feature-api-contract-baseline]] D13 (OPTIONS preflight envelope 우회) | `official-standard` (WHATWG Fetch — living standard, browser-side normative) | wildcard `*` + credentials `true` 조합 금지의 1차 normative 근거는 FETCH-CORS-C3. max-age 600s 의 *정확한 숫자* 는 FETCH-CORS-C5 가 "5초 기본 + UA upper limit" 만 명시 — 600s 는 project-internal trade-off (UA cache hit 율 ↑ vs CORS rule 변경 propagation 지연). gateway/WAF override 시 mapping table 의무는 표준 외 (project-internal). 후속: Spring `CorsConfiguration.checkOrigin()` 의 startup 검증 동작은 별도 vendor doc 필요 (Claims To Verify 참조) |
| D10 | JWKS refresh interval = 10분, unknown `kid` on-demand refresh (rate-limited 1회/1분), rotation overlap window 24h | `raw/official-docs/jwks-nimbus-jose-jwksourcebuilder-spring-integration.md#NIMBUS-JWKS-C5` (Spring 기본 JWKS cache 5min — 10분은 그 2배, project trade-off), `#NIMBUS-JWKS-C6` (unknown kid → `JWKSetCacheRefreshEvaluator` + `cache.invalidate()` on-demand refresh, Spring Security 6.x #11638 이후), `#NIMBUS-JWKS-C4` (`withJwkSetUri()` 기본 `rateLimited(false)`+`refreshAheadCache(false)` → 1/min rate-limit 은 Nimbus `JWKSourceBuilder` 또는 app-layer 로 별도 구현), `raw/official-docs/jwks-keycloak-key-rotation-active-passive.md#KC-ROT-C1` (Keycloak active/passive key = overlap 메커니즘의 IdP-side 근거), `raw/company-tech-blogs/jwks-workos-unknown-kid-refresh-rate-limit-pattern.md#WORKOS-JWKS-C4` (overlap = token TTL + cache TTL + buffer 공식), `#WORKOS-JWKS-C2` (rate-limit 5~10min 권고) + `docs/runbooks/auth-token-rotation-failure.md` (24h overlap 운영 절차) | `official-vendor-doc` (메커니즘) + `company-case-study` (exact numbers — best practice 승격 금지) | **메커니즘은 지지, exact number 는 UNSUPPORTED_IMPL_DECISION**: (1) 10min cache = Caffeine `expireAfterWrite(10m)` 로 표현 가능하나 숫자는 project trade-off. (2) **1/min rate-limit < WorkOS 권고 5~10min** → thundering-herd/DoS 방어 약함(`WORKOS-JWKS-C2` 와 충돌 — 더 빠른 kid 전파를 위한 의도적 aggressive 선택). (3) 24h overlap 의 공식(token TTL+cache TTL+buffer) 정합은 Keycloak realm access-token TTL 확인 후 재평가 — ca-tmpl repo 엔 token TTL 부재(IdP-side, NEEDS_CONTEXT) |
| D11 | 한국 사례 토스 — health detail 의 보안 민감성 (보조 정합 참조) | `raw/company-tech-blogs/security-toss-actuator-healthcheck.md#TOSS-HEALTH-C1` | `company-case-study` (best practice 승격 금지) | actuator security branch ([[raw/branch-notes/feature-management-actuator-security-contract]]) 와 cross-link 필요 — 본 security baseline 의 `INTERNAL_AUTH_MISCONFIGURATION` 정책과 정합성 확인 |
## 구현 가이드
> *결정 (Decisions)* 이 "*무엇* 을 할 것인가" 라면, 본 §는 "*어디에 어떻게* 구현될 것인가" 의 사전 명세. ca-tmpl `src/` 실제 클래스/패키지/registry 값을 anchor 로 쓰되, **코드로 확인된 것은 `actually-implemented`, registry/설계만 있고 코드 미확인은 `planned`** 로 표기한다 (2026-06-08 `src/` grep 검증).
>
> **3-rule meta principle** (CLAUDE.md §15.5): R1 모든 cell 은 Decision ID + Supporting Claim reference / R2 근거 없는 detail 은 `UNSUPPORTED_IMPL_DECISION` + trade-off / R3 본 branch 범위 밖 detail 은 §Audit & Findings 로 이관.
### 1. SecurityFilterChain wiring — deny-by-default + stateless
> **Trace**: D1 (JWT Resource Server) · D5 (deny-by-default, `OWASP-AUTHZ-C1/C2`) · D6 (every-request, `OWASP-AUTHZ-C5`).
>
> - **UNSUPPORTED_IMPL_DECISION**: CSRF disable 결정 — OWASP 는 stateless+비쿠키 시 CSRF 무관함을 함의하나 명시 권고는 아님. trade-off: JWT in `Authorization` header(쿠키 아님) → CSRF 표면 없음 → disable 로 필터 단순화.
| 항목 | 구현 anchor | 등급 |
|---|---|---|
| 필터체인 Bean | `dev.caskeleton.adapter.web.auth.SecurityConfig#filterChain` (`src/adapter-web/.../auth/SecurityConfig.java`) | `actually-implemented` |
| deny-by-default | `auth.requestMatchers(publicPaths).permitAll()``auth.anyRequest().authenticated()` | `actually-implemented` |
| stateless | `sessionManagement(STATELESS)` | `actually-implemented` |
| CSRF off | `csrf(csrf -> csrf.disable())` | `actually-implemented` |
| resource server | `oauth2ResourceServer(oauth -> oauth.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)))` | `actually-implemented` |
| public paths source | `SecuritySettings#publicPaths()` ← env `SECURITY_PUBLIC_PATHS` (application.yml L171 `ca-skeleton.security.public-paths`) | `actually-implemented` |
| Cache-Control writer | `headers(h -> h.cacheControl(c -> c.disable()))`**단일 owner 위임**: [[raw/branch-notes/feature-api-contract-baseline]] D16 `CacheControlFilter``Cache-Control: no-store` + `Vary` 발행 | `actually-implemented` (cross-contract) |
### 2. JWT validation chain — current-known + code recheck gate
> **Trace**: D2 · D3 · D4. pre-C2 auto-config-only 설명은 **historical/superseded**다. Phase C2 기록은 `SupplierJwtDecoder` 기반 custom bean과 explicit 60s validator chain을 보고하지만, 현재 workspace에 code owner가 없어 현행 여부는 `needs-confirmation`이다.
>
> - **IMPL trade-off** (근거 확보됨): clock skew **60s** 는 Spring default leeway 이며 그 default 값이 60s 임은 `SS-JTVC-C1`(`official-vendor-doc`, "Resource Server configures a clock skew of 60 seconds")로 확정. 단 코드는 `.clockSkew(Duration.ofSeconds(60))` 를 명시 설정하지 *않고* default 에 의존 → Spring version 이 default 를 바꾸면 silent drift. trade-off: 명시 설정(drift 차단, 코드 1줄) vs default 의존(설정 최소화). §Claims To Verify 의 integration test(61s reject)로 잔여 검증.
| 항목 | 구현 anchor | 등급 |
|---|---|---|
| JWT decoder | **Phase C2 report**: `JwtDecoderConfig`의 lazy `SupplierJwtDecoder` custom bean. pre-C2 auto-config-only 경로는 superseded | `locally-verified`(2026-06-08 기록) / current code `needs-confirmation` |
| issuer·audience 검증 | **Phase C2 report**: issuer + audience validator chain | `locally-verified`(보고) / current code `needs-confirmation` |
| expiry/clock skew (D2) | vendor default 60s 근거는 유효. **Phase C2 report**는 explicit 60s와 30s/90s boundary test를 기록 | `locally-verified`(보고) / current code `needs-confirmation` |
| settings binding | `dev.caskeleton.adapter.web.settings.SecuritySettings``issuerUri` required fail-fast, `audience` 누락 시 warn+skip, `publicPaths` | `actually-implemented` |
| claim→principal mapping (matrix `AUTH_CLAIM_MAPPING_FAILED`) | `dev.caskeleton.adapter.web.auth.JwtToAuthenticatedUserConverter``sub`→principal, `realm_access`+`resource_access` roles → `ROLE_*` | `actually-implemented` (단 실패 시 *전용 code* 매핑은 §3 drift) |
### 3. Auth 실패 → error code 분류 (matrix 집행)
> **Trace**: AuthN/AuthZ Decision Matrix 12행 · D7 (401/403 분리, `OWASP-AUTHZ-C3/C4`). registry SSOT = `docs/registries/error-codes.yaml` (owner_branch = 본 branch, 12 codes).
>
> - **CODE_GRANULARITY_DRIFT** (§Audit & Findings): 설계는 12 codes, production enum 은 3 codes. 아래 표는 *현재 코드 실체* 와 *설계 계약* 을 분리 표기.
| 분류 단계 | 구현 anchor | 등급 |
|---|---|---|
| auth 예외 핸들러 | `dev.caskeleton.adapter.web.error.GlobalExceptionHandler` L8194 (`@ExceptionHandler` × 3) | `actually-implemented` |
| `InvalidBearerTokenException``OperationalError.INVALID_TOKEN` (AUTH 401) | `GlobalExceptionHandler#handleInvalidToken` | `actually-implemented` (coarse) |
| `AuthenticationException``OperationalError.UNAUTHENTICATED` (AUTH 401) | `GlobalExceptionHandler#handleUnauthenticated` | `actually-implemented` (coarse) |
| `AccessDeniedException``OperationalError.FORBIDDEN` (AUTHZ 403) | `GlobalExceptionHandler#handleForbidden` | `actually-implemented` (coarse) |
| fine-grained 12 codes | **Phase C2 report**: `OperationalError` + registry mapping test에 구현, coarse 3-code는 fallback 유지 | `locally-verified`(보고) / current code `needs-confirmation` |
| fine-grained 분류 메커니즘 | **Phase C2 report**: `SecurityErrorClassifier` + `EnvelopeAuthenticationEntryPoint`/`EnvelopeAccessDeniedHandler`가 filter-layer 오류를 분류 | `locally-verified`(보고) / current code `needs-confirmation` |
### 4. JWKS rotation & unknown-`kid` 운영 정책 (D10)
> **Trace**: D10 (JWKS 10min refresh / on-demand unknown-kid / 24h overlap), Supporting `NIMBUS-JWKS-C4/C5/C6` · `KC-ROT-C1` · `WORKOS-JWKS-C2/C4`. 본 branch 는 secret *저장/주입* 이 아니라 JWT signing key rotation 의 **운영 관측**만 owns (2026-05-22 결정; 저장은 [[raw/branch-notes/feature-secrets-config-source-contract]] 위임).
>
> - **UNSUPPORTED_IMPL_DECISION** (자동조사 2026-06-08 완료 후 정제): 메커니즘은 vendor doc 으로 지지되나 **exact number 는 project trade-off**. (1) 10min = Spring 기본 5min(`NIMBUS-JWKS-C5`)의 2배 → Caffeine `expireAfterWrite(10m)`. (2) **1/min < WorkOS 권고 5~10min(`WORKOS-JWKS-C2`)** — 더 빠른 kid 전파 vs thundering-herd/DoS 방어 약화의 의도적 aggressive 선택. (3) 24h overlap = `WORKOS-JWKS-C4` 공식(token TTL+cache TTL+buffer) — Keycloak token TTL 확인 후 재평가(NEEDS_CONTEXT).
| 항목 | 구현 anchor | 등급 |
|---|---|---|
| JWKS 자동 resolve | issuer-uri `/.well-known/openid-configuration` → Nimbus JWKS auto-discovery (auto-config). 기본 cache TTL 5min, `rateLimited(false)`+`refreshAheadCache(false)` (`NIMBUS-JWKS-C4/C5`) | `actually-implemented` (Nimbus default cache) |
| unknown kid on-demand refresh | Spring Security 6.x(#11638 이후)가 unknown kid 감지 시 `JWKSetCacheRefreshEvaluator``cache.invalidate()` → 재조회 (`NIMBUS-JWKS-C6`). **단 rate-limit 없음** — Spring layer 미제공 | `actually-implemented` (refresh) / rate-limit `planned` |
| 10min cache + 1/min rate-limit (메커니즘 선택지) | **택1**: (A) `NimbusJwtDecoder.withJwkSetUri(...).cache(caffeine expireAfterWrite(10m))` + app-layer rate-limit(Bucket4j) — auto-config 유지; (B) `withJwkSource(JWKSourceBuilder.create(uri).refreshAheadCache(...).rateLimited(60_000))` — Nimbus built-in(`NIMBUS-JWKS-C2/C3`), auto-config override 필요. 둘 다 **미작성** | `planned` |
| 24h rotation overlap | IdP-side: Keycloak active/passive key(`KC-ROT-C1`). 운영 절차 documented: `docs/runbooks/auth-token-rotation-failure.md` §4 ("publish → 24h 대기 → switch", 비상 시 cache TTL 60s 강제, overlap 48h 일시 확장) | `documented-only` (runbook + IdP 설정) |
| 분류 code | `AUTH_KID_UNKNOWN`(retryable=true, Retry-After 5s) · `AUTH_JWKS_UNAVAILABLE`(TRANSIENT_DEPENDENCY) registry 등록 | `documented-only` (§3 drift 적용 — 미구현) |
### 5. public path misconfiguration guard (D5)
> **Trace**: D5 (`OWASP-AUTHZ-C1/C2` deny-by-default) + §테스트 계약 snapshot diff.
>
> - **2026-06-09 정합 (as-built 메커니즘 변경)**: 노트 초안은 "startup 시 `SecurityFilterChain.getFilters()` introspection 으로 snapshot" 을 명세했으나, **as-built 게이트는 `SecurityFilterChain` reflection 을 쓰지 않는다**. `src/build.gradle:185-188` 가 명시적으로 그 결정을 기록: filter-chain reflection 은 Spring 버전 간 brittle → 대신 **`permitAll()` 을 실제로 먹이는 결정론적 SSOT 인 `SECURITY_PUBLIC_PATHS`(src/.env → `SecuritySettings.publicPaths()`)** 를 snapshot. 즉 `verifyPublicPathSnapshot` 은 env 의 public-path 목록을 `docs/security/public-paths-snapshot.txt` 와 diff.
> - **⚠️ 한계(정직 고지)**: env 기반이므로 **Java 코드에 하드코딩된 `permitAll()`**(`SECURITY_PUBLIC_PATHS` 우회)은 이 게이트가 *못 잡는다*. "보호 endpoint 의 silent 노출 차단" 보장은 *모든 public path 가 env 를 경유* 한다는 전제에서만 성립. (filter-chain 실측 introspection 으로 승급하려면 brittle-reflection trade-off 재검토 필요.)
> - **UNSUPPORTED_IMPL_DECISION**: snapshot-diff 메커니즘 자체 — OWASP 는 deny-by-default *원칙* 만 권고. trade-off: 정상 PR 의 path 추가마다 review(false-positive) vs unintended public path 통과 차단.
| 항목 | 구현 anchor | 등급 |
|---|---|---|
| snapshot 추출 | `src/build.gradle:197~``SECURITY_PUBLIC_PATHS`(src/.env) 파싱 → `docs/security/public-paths-snapshot.txt` (**filter-chain reflection 아님**, build.gradle:185-188 결정) | `actually-implemented` |
| diff gate | gradle task (`build.gradle` public-path snapshot 검증; 변경 시 snapshot 재생성+commit 요구) | `actually-implemented` |
| 위반 분류 | `INTERNAL_AUTH_MISCONFIGURATION` (INTERNAL 500 + P1 alert) | `documented-only` (enum/registry 등록, runtime emit 코드 부재) |
### 6. CORS 정책 (D9)
> **Trace**: D9 (`FETCH-CORS-C3` wildcard+credentials 금지 normative, `FETCH-CORS-C5` max-age). API branch cross-cite: [[raw/branch-notes/feature-api-contract-baseline]] D13 (OPTIONS preflight envelope 우회).
>
> - **UNSUPPORTED_IMPL_DECISION**: max-age **600s** — FETCH-CORS-C5 는 "기본 5초 + UA upper limit" 만. trade-off: UA preflight cache hit ↑ vs CORS rule 변경 propagation 지연 ↑.
| 항목 | 구현 anchor | 등급 |
|---|---|---|
| CORS source | `SecurityConfig#corsConfigurationSource` + `UrlBasedCorsConfigurationSource("/**")` | `actually-implemented` |
| settings | `dev.caskeleton.adapter.web.settings.CorsSettings` (record, `@Validated`, prefix `ca-skeleton.cors`) | `actually-implemented` |
| enabled toggle | `CorsSettings#enabled``APP_SECURITY_CORS_ENABLED`; disabled → 빈 source (CORS inactive) | `actually-implemented` |
| origins (D9 allowlist) | `allowedOrigins``APP_SECURITY_CORS_ORIGINS`; **enabled+empty → fail-fast throw** (cross-field, JSR-303 불가) | `actually-implemented` |
| methods | default `[GET,POST,PATCH,PUT,DELETE,OPTIONS]``APP_SECURITY_CORS_ALLOWED_METHODS` | `actually-implemented` |
| headers | default `["*"]``APP_SECURITY_CORS_ALLOWED_HEADERS` | `actually-implemented` |
| credentials (D9 false default) | `allowCredentials``APP_SECURITY_CORS_ALLOW_CREDENTIALS` | `actually-implemented` |
| max-age 600s | `maxAgeSeconds``APP_SECURITY_CORS_MAX_AGE`, `@PositiveOrZero` | `actually-implemented` (숫자는 env-driven; 600s 는 §UNSUPPORTED 위) |
| wildcard+credentials 정적 거부 (D9 normative) | **Phase C2 report**: `CorsSettings`가 enabled+`["*"]`+credentials=true를 startup fail-fast | `locally-verified`(보고) / current code `needs-confirmation` |
### 7. PII 로그 redaction
> **Trace**: §진행 중 메모("principal 식별자 최소화") + §테스트 계약("token in log = fail").
>
> - **UNSUPPORTED_IMPL_DECISION**: redaction 메커니즘 미정 — log masking 강제는 [[raw/branch-notes/feature-secrets-config-source-contract]]/log-management 계약과 겹침. trade-off: 본 branch 는 *contract test*(grep `eyJ`/`Bearer`)로 위반 검출만 owns, masking filter 구현은 위임.
| 항목 | 구현 anchor | 등급 |
|---|---|---|
| token leak contract test | **Phase C2 report**: entry-point 응답·로그에서 `Authorization`/`Bearer`/JWT(`eyJ`) 노출을 거부하는 contract test | `locally-verified`(보고) / current code `needs-confirmation` |
| principal 최소화 | security event log 에 principal 식별자 최소화 | `documented-only` |
## 엣지·실패·의존
> R4(깊이 게이트) 캡처용. 정상 경로 외에 *구현 중 부딪힐* 실패/엣지/다른 계약 의존.
- **실패·엣지 경로**:
- **JWKS endpoint outage**: cache hit 시 통과, miss 시 `AUTH_JWKS_UNAVAILABLE`(TRANSIENT_DEPENDENCY) — outage 명확하면 503, cache miss 후 fallback 실패면 401. runbook `auth-token-rotation-failure.md` §2 (cache TTL 60s 강제) 발동.
- **unknown `kid` (rotation 직후)**: on-demand refresh(rate-limited) → 여전히 미해결이면 `AUTH_KID_UNKNOWN`(retryable=true, Retry-After 5s). 24h overlap window 내면 old kid 로 검증 통과.
- **clock skew 경계**: 60s leeway 초과 expired만 `AUTH_TOKEN_EXPIRED`. NTP drift > 60s 면 정상 token 도 오판 → NTP sync 운영 의존.
- **public path 오설정**: Phase C2 report의 env snapshot gate가 drift를 차단한다. 단 Java hard-coded `permitAll()`은 미검출이며 current task 존재는 재확인 필요.
- **CORS wildcard+credentials**: Phase C2 report는 startup fail-fast를 기록한다. current code 재확인 전까지 `needs-confirmation`.
- **다중 audience JWT**: `aud` 가 list 일 때 식별 기준 미정의 (RFC 범위 밖, Open Risk D3).
- **다른 계약 의존**:
- [[raw/branch-notes/feature-operational-error-observability-foundation]] `D10``Category` 10-value enum SSOT (`shared/error/Category.java`: AUTH/AUTHZ/TRANSIENT_DEPENDENCY/INTERNAL 등). 본 branch 의 모든 `error.category` 가 이 enum 을 consume. enum 변경 시 matrix 영향.
- [[raw/branch-notes/feature-api-contract-baseline]] `D16``CacheControlFilter` 가 Cache-Control 단일 owner. 본 branch 는 Spring Security 의 default cache writer 를 disable 하여 충돌 회피. `D13` — OPTIONS preflight envelope 우회(CORS D9 와 정합). `D8` — request body size 413(보안 baseline 의 upload 와 인접).
- [[raw/branch-notes/feature-secrets-config-source-contract]] — JWT signing key *저장/주입/rotation script*. 본 branch 는 rotation 의 **운영 관측**만 owns. secret source 계약 변경 시 JWKS resolver 입력 영향.
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]] — idempotency TTL 24h ≥ key rotation overlap 24h invariant. replay 가 rotation cutover 를 안전 통과해야 함(교차 시나리오 테스트 cross-link).
- [[raw/branch-notes/feature-management-actuator-security-contract]] — actuator(제어면) 보안. 본 branch(데이터면)의 `INTERNAL_AUTH_MISCONFIGURATION` 와 health detail 노출 정책(D11) 정합.
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] `D10``@ConfigurationProperties` + JSR-303 + fail-fast 검증 패턴. `CorsSettings`/`SecuritySettings` 가 이 패턴을 따름.
## 테스트 계약
- token 값이 log에 나오면 실패.
- expired token과 invalid signature가 같은 internal code로 뭉개지면 실패.
- public path snapshot diff 검사: `SECURITY_PUBLIC_PATHS` env SSOT를 `docs/security/public-paths-snapshot.txt`와 비교하는 `./gradlew verifyPublicPathSnapshot`을 사용하고, 의도한 변경은 `-PapprovePublicPathChange` 승인 경로로 처리한다. **reflection은 폐기**됐으며 Java hard-coded `permitAll()`은 이 gate가 탐지하지 못한다. Phase C2 report의 task 존재·CI wiring은 current code owner에서 재확인한다.
- client response에 issuer/audience 내부 값이 과노출되면 실패.
- unknown `kid`/JWKS refresh failure/key rotation overlap이 분류되지 않으면 실패.
- wildcard CORS + credentials 허용이면 실패.
## 검증해야 할 주장
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| Spring `JwtTimestampValidator` 의 default leeway 가 60s 와 일치 | ~~RFC 7519 는 implementer 재량~~**벤더 doc 확인 완료**: [[raw/official-docs/security-spring-jwt-timestamp-validator-clock-skew]] `SS-JTVC-C1` "By default, Resource Server configures a clock skew of 60 seconds." (`official-vendor-doc`). 코드상 `.clockSkew()` 명시 설정 없음(default 의존) — Spring version drift 위험은 유지 | 통합 테스트로 61s expired token 거절 확인 (auto-config 경로 검증) | `needs-implementation-test` |
| fine-grained 12 codes의 현행 production emit | Phase C2 report는 구현·테스트 완료를 기록하지만 current code owner가 이 workspace에 없음 | 현행 `OperationalError`, classifier, EntryPoint/DeniedHandler와 expired/signature/issuer 분리 test를 재실행 | `needs-confirmation (reported locally-verified)` |
| env public-path snapshot gate의 현행 task·CI wiring | Phase C2 report는 `verifyPublicPathSnapshot` 구현을 기록하지만 current code 미확인 | task 목록 확인 후 env path 변경→fail, approval flag→pass를 재실행. hard-coded `permitAll()` blind spot 별도 기록 | `needs-confirmation (reported locally-verified)` |
| CORS wildcard + credentials true startup 거부 | Phase C2 report는 `CorsSettings` fail-fast 구현을 기록하지만 current code 미확인 | 현행 settings test에서 enabled+wildcard+credentials=true startup failure 확인 | `needs-confirmation (reported locally-verified)` |
| JWKS refresh 10min: Caffeine `expireAfterWrite(10m)` + `withJwkSetUri().cache()` 조합으로 표현 | 메커니즘은 `NIMBUS-JWKS-C5` 로 지지(Spring 기본 5min, Cache 주입 가능). 10min exact value 는 project trade-off | Caffeine + Spring Cache 통합 integration test: JWKS endpoint mock → 10분 후 fetch 재트리거 확인 | `needs-implementation-test` |
| unknown kid on-demand refresh 동작 + 1/min rate-limit | refresh 자체는 `NIMBUS-JWKS-C6`(Spring 6.x #11638) 로 지지. **rate-limit 은 Spring layer 미제공(`NIMBUS-JWKS-C4`)** — Nimbus `JWKSourceBuilder.rateLimited()`(Alt B) 또는 app-layer Bucket4j(Alt A) 설계 결정 필요. 1/min < WorkOS 5~10min(`WORKOS-JWKS-C2`) | Alt A/B 중 택1 후 JWKS endpoint mock + unknown kid 연속 요청으로 rate-limit 측정 | `needs-design-decision` |
| rotation overlap 24h 가 `WORKOS-JWKS-C4` 공식(token TTL+cache TTL+10min)과 정합 | ca-tmpl repo 엔 access-token TTL 부재(Keycloak realm-side, IdP 설정). TTL < (24hcachebuffer) 여야 공식 충족 | ca-tmpl Keycloak realm client access-token lifespan 확인 후 24h 재평가 | `needs-confirmation` (NEEDS_CONTEXT: token TTL) |
| rotation overlap window 24h 가 idempotency TTL 24h 와 안전하게 정합 | invariant 가정은 별도 raw source 미증명 — replay+rotation 교차 시나리오 테스트 필요 | [[raw/branch-notes/feature-rate-limit-idempotency-contract]] 의 contract test 와 cross-link, key rotation mid-replay 시나리오 통합 테스트 작성 | `planned` |
| token 값이 log에 등장하지 않음 | Phase C2 report는 redaction contract test를 기록하지만 current code/log configuration 미확인 | 현행 test와 log output을 대상으로 `Authorization`/`Bearer`/`eyJ` self-grep 재실행 | `needs-confirmation (reported locally-verified)` |
| `INTERNAL_AUTH_MISCONFIGURATION` 500 + P1 alert 가 prod runbook 에 등록 | runbook `auth-token-rotation-failure.md` 는 stub 단계. alert routing / paging 정책 별도 확인 필요 | [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] 또는 metric-alerting branch 와 cross-link, alertmanager rule 추가 PR | `planned` |
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
> governing doc = [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]] 의 **축 1: 데이터면 인증/인가**(§프로젝트 컨텍스트 1번). `/coverage` 가 재생성하는 초안 — 손유지 금지. 기준: `rules/coverage-gate.md`. 축 2(Actuator)·축 3(Secrets)는 본 branch 밖 → delegated.
| 관심사 (governing doc 축 1) | 상태 | owner | 심각도 | 근거 |
|--------|------|-------|--------|------|
| JWT Resource Server (deny-by-default authn) | covered-here | — | — | D1, D5, D6 / §구현가이드 1 |
| AuthN/AuthZ matrix 12행 (분류 계약) | covered-here | — | — | §AuthN/AuthZ Matrix, D3/D4/D7 / §구현가이드 3 |
| clock skew tolerance 60s | covered-here | — | — | D2 / §구현가이드 2 |
| JWKS 10분 refresh + unknown kid | covered-here | — | — | D10 / §구현가이드 4 |
| key rotation overlap 24h | covered-here | — | — | D10 / runbook `auth-token-rotation-failure.md` |
| public path snapshot diff | covered-here | — | — | D5 / §구현가이드 5 / §테스트 계약 |
| CORS allowlist + credentials false + max-age | covered-here | — | — | D9 / §구현가이드 6 |
| 401/403 분리 (authn vs authz) | covered-here | — | — | D7 / §구현가이드 3 |
| token/PII 로그 금지 | covered-here | — | — | §구현가이드 7 / §테스트 계약 |
| JWT signing key 저장/주입/rotation script | delegated | [[raw/branch-notes/feature-secrets-config-source-contract]] | OK | [[raw/branch-notes/feature-secrets-config-source-contract]] (2026-05-22 결정 / registry `owner_branch`) |
| Actuator 제어면 보안 (port 9001 + allowlist) | delegated | [[raw/branch-notes/feature-management-actuator-security-contract]] | OK | [[raw/branch-notes/feature-management-actuator-security-contract]] (governing doc 축 2 / D11 cross-link) |
| `Category` enum SSOT (error.category) | delegated | [[raw/branch-notes/feature-operational-error-observability-foundation]] | OK | [[raw/branch-notes/feature-operational-error-observability-foundation]] (`shared/error/Category.java` / §엣지·실패·의존) |
## 마주친 문제
- 아직 없음.
## Audit & Findings (2026-06-08 — `/branch-spec` ca-tmpl ground-truth 대조)
> `src/` 코드·`docs/registries`·runbook 을 읽고 노트의 self-report 와 대조한 결과. **사용자 작성 결정 영역은 자동 rewrite 하지 않고 정합 권고만** 남긴다 (CLAUDE.md §11, `/branch-spec` §2).
### Phase C2 구현 완료 (2026-06-08, `locally-verified`)
> 사용자 지시 "문서 보고 하나도 빠짐없이 구현" 당시의 보존 기록. JWKS cache/rate-limit만 Minimal 결정으로 제외됐고 당시 test suite GREEN으로 기록됐다. **현재 workspace에는 code owner가 없어 이 표는 당시 evidence grade를 보존하되 현행 상태 증명으로 재사용하지 않는다.**
| 구현 항목 | 파일 | 등급 | 비고 |
|---|---|---|---|
| 12 fine-grained AUTH/AUTHZ/INTERNAL codes | `shared-contract/.../OperationalError.java` | `locally-verified` | registry SSOT 와 status/category/retryable 일치. `ErrorCodeRegistryMappingTest`·`BusinessRuleValidationContractTest` GREEN. coarse 3-code 는 non-filter fallback 으로 유지 |
| exception → fine-grained 분류기 | `adapter-web/.../auth/SecurityErrorClassifier.java` | `locally-verified` | `JwtValidationException`(exp/iss/aud) + `BadJwtException`(signature/malformed/kid) + JWKS outage 메시지 heuristic. 12개 unit test |
| AuthenticationEntryPoint / AccessDeniedHandler | `adapter-web/.../auth/EnvelopeAuthenticationEntryPoint.java`·`EnvelopeAccessDeniedHandler.java`·`AuthErrorResponseWriter.java` | `locally-verified` | filter-layer 실패를 Envelope 로 변환(=`@RestControllerAdvice` 미도달 문제 해소). WWW-Authenticate(401 MUST, RFC9110-C23) + Retry-After(KID 5s/JWKS 30s) |
| token/PII redaction | (entry point) | `locally-verified` | 응답·로그에 `eyJ`/Bearer/raw message 미노출 — `token_value_never_leaks...` contract test |
| explicit clock skew 60s + issuer + audience | `adapter-web/.../auth/JwtDecoderConfig.java` | `locally-verified` | custom `JwtDecoder` bean(`SupplierJwtDecoder` lazy → startup 시 IdP 불필요). validator chain unit test(30s 통과 / 90s 거절 = D2 silent-drift 위험 해소) |
| CORS wildcard+credentials 정적 거부 | `adapter-web/.../settings/CorsSettings.java` | `locally-verified` | enabled+`["*"]`+credentials=true → startup fail-fast (D9/FETCH-CORS-C3). Spring runtime 의존 제거 |
| public path snapshot gate | `src/build.gradle` `verifyPublicPathSnapshot` + `docs/security/public-paths-snapshot.txt` | `locally-verified` | drift → build fail, `-PapprovePublicPathChange` 로 승인. fail/approval path 수동 검증 완료 |
| JWKS 10min cache + 1/min rate-limit | — | `documented-only` | **Minimal 결정**: exact number 는 NEEDS_CONTEXT(Keycloak token TTL, IdP-side). Nimbus/Spring default cache 유지 |
| 24h rotation overlap | runbook + IdP | `documented-only` | IdP-side(Keycloak active/passive), 변경 없음 |
**구현 중 발견(드리프트 정정)**: `OperationalErrorTest.internal_category_codes_are_retryable` 가 "모든 INTERNAL = retryable" 를 단언했으나 registry 는 `INTERNAL_AUTH_MISCONFIGURATION` 을 retryable=false 로 둠(redeploy 필요한 deterministic config bug). registry SSOT 가 옳다고 판단 → enum 을 false 로 맞추고 테스트에 misconfig 예외를 명시. → [[raw/errors/internal-auth-misconfiguration-retryable-invariant-conflict-2026-06-08]]
- **`CODE_GRANULARITY_DRIFT`** (~~설계 12 codes ↔ 코드 3 codes~~ → **RESOLVED 2026-06-08, 옵션 (a)**): `docs/registries/error-codes.yaml`**12 fine-grained AUTH/AUTHZ/INTERNAL codes** 를 production `OperationalError` enum 에 추가하고, custom `EnvelopeAuthenticationEntryPoint`+`EnvelopeAccessDeniedHandler`+`SecurityErrorClassifier``JwtValidationException`/`BadJwtException`/`OAuth2Error` 를 inspect → expired/malformed/signature/issuer/audience/kid/jwks 로 분기 emit. coarse 3-code(`UNAUTHENTICATED`/`INVALID_TOKEN`/`FORBIDDEN`)는 controller 직접-throw 등 non-filter 경로 fallback 으로 유지. 등급: matrix·12codes = `locally-verified`. (옵션 (b) registry downgrade 는 미채택.)
- **`AUTH_KID_UNKNOWN` retryable 정합** (drift 아님, 기록용): registry L124 가 2026-06-01 `retryable: false→true` 로 변경(JWKS 회전 중 ~5s 후 해소 가능, Retry-After 5s 와 정합). 노트 matrix 는 retryable 열이 없어 무영향. BusinessRuleValidationContractTest 가 이 retryable 값을 검증.
- **Historical/superseded — D2 default-only 설명**: Phase C2 report는 explicit 60s custom decoder와 boundary test로 해소했다고 기록한다. current code owner 재확인 전에는 reported state와 `needs-confirmation`을 함께 유지한다.
- **Historical/superseded — snapshot/redaction 미구현 설명**: Phase C2 report는 env snapshot gate와 token redaction contract test를 구현했다고 기록한다. JWKS exact cache/rate-limit만 `documented-only`로 남는다. current code는 별도 재확인 필요.
## 묶음
<!-- GENERATED: sources:start -->
- [[raw/company-tech-blogs/jwks-workos-unknown-kid-refresh-rate-limit-pattern]]
- [[raw/company-tech-blogs/security-toss-actuator-healthcheck]]
- [[raw/official-docs/actuator-istio-sidecar-management-alt]]
- [[raw/official-docs/fetch-spec-cors]]
- [[raw/official-docs/jwks-keycloak-key-rotation-active-passive]]
- [[raw/official-docs/jwks-nimbus-jose-jwksourcebuilder-spring-integration]]
- [[raw/official-docs/owasp-file-upload-cheat-sheet]]
- [[raw/official-docs/rfc9110-http-semantics]]
- [[raw/official-docs/secrets-aws-secrets-manager-rotation]]
- [[raw/official-docs/security-authorization-cheatsheet-owasp]]
- [[raw/official-docs/security-aws-sigv4-hmac-signing]]
- [[raw/official-docs/security-jwt-rfc-7519-validation]]
- [[raw/official-docs/security-mtls-rfc-8705]]
- [[raw/official-docs/security-oauth2-pkce-rfc-8252]]
- [[raw/official-docs/security-opa-policy-engine-official]]
- [[raw/official-docs/security-spring-jwt-timestamp-validator-clock-skew]]
<!-- GENERATED: sources:end -->
<!-- GENERATED: interviews:start -->
- [[raw/interviews/jwt-resource-server-fine-grained-error-classification-2026-06-08]]
<!-- GENERATED: interviews:end -->
<!-- GENERATED: errors:start -->
- [[raw/errors/internal-auth-misconfiguration-retryable-invariant-conflict-2026-06-08]]
<!-- GENERATED: errors:end -->
<!-- GENERATED: blog-topics:start -->
- [[raw/blog-topics/spring-security-filter-layer-error-envelope-2026-06-08]]
<!-- GENERATED: blog-topics:end -->
> 본 feature branch 는 leaf — 자식 자료 없음. Phase C2 실 코드 작성 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
### 근거 자료
- [[raw/official-docs/fetch-spec-cors]] — WHATWG Fetch §3.3 CORS protocol: D9 wildcard+credentials 금지(FETCH-CORS-C3) + Access-Control-Max-Age 기본 5초(FETCH-CORS-C5) normative 근거. D9 의 UNSUPPORTED_DECISION 해소
- [[raw/official-docs/jwks-nimbus-jose-jwksourcebuilder-spring-integration]] — D10 JWKS refresh 메커니즘 (Spring/Nimbus cache·rate-limit·unknown-kid). 2026-06-08 `wiki-decision-researcher` 자동조사 산출
- [[raw/official-docs/jwks-keycloak-key-rotation-active-passive]] — D10 rotation overlap IdP-side (Keycloak active/passive key). 2026-06-08 자동조사 산출
- [[raw/company-tech-blogs/jwks-workos-unknown-kid-refresh-rate-limit-pattern]] — D10 unknown-kid rate-limit + overlap 공식 (engineering practice). 2026-06-08 자동조사 산출
- [[raw/official-docs/security-spring-jwt-timestamp-validator-clock-skew]] — D2 clock skew 60s: Spring Security `JwtTimestampValidator` default leeway = 60s (SS-JTVC-C1, `official-vendor-doc`). 2026-06-08 `wiki-source-summarizer` 산출
- [[raw/official-docs/rfc9110-http-semantics]] — D7 401/403 HTTP semantics (RFC9110-C23/C24). 기존 RFC 9110 raw 에 §15.5.2/§15.5.4 발췌 보강. 2026-06-08
### 오류 기록 (본 feature 작업 중 발생)
- [[raw/errors/internal-auth-misconfiguration-retryable-invariant-conflict-2026-06-08]] — enum "모든 INTERNAL=retryable" 단언 ↔ registry `INTERNAL_AUTH_MISCONFIGURATION` retryable=false 충돌. SSOT(registry) 기준으로 정정 + 테스트에 예외 명시.
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
- [[raw/interviews/jwt-resource-server-fine-grained-error-classification-2026-06-08]] — resource-server 인증 실패를 401/403 으로만 뭉개지 않고 fine-grained 분류한 방법 (filter-layer 가 `@RestControllerAdvice` 미도달 → custom EntryPoint, exception heuristic, token redaction).
### Blog topics
- [[raw/blog-topics/spring-security-filter-layer-error-envelope-2026-06-08]] — "Spring Security 인증 실패는 왜 @RestControllerAdvice 로 안 잡히나" + AuthenticationEntryPoint 로 공통 에러 Envelope 통일 + SupplierJwtDecoder lazy clock-skew 패턴.
## 관련 일일 노트
> 이 브랜치를 작업한 날짜들. 양방향 nav 유지.
- (없음 — Phase C2 실 구현 단계에서 누적)
## 완료 후 정리
- PR 링크:
- 리뷰 메모:
- 머지 결과 / 배포 환경:
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
- `actually-implemented` 항목: SecurityFilterChain(deny-by-default/stateless/CSRF off/resource server)·CorsSettings·SecuritySettings·JwtToAuthenticatedUserConverter (이전 단계)
- `locally-verified` 항목 (2026-06-08): 12 fine-grained codes / SecurityErrorClassifier / EnvelopeAuthenticationEntryPoint·AccessDeniedHandler / JwtDecoderConfig(60s skew) / CORS wildcard+credentials 거부 / `verifyPublicPathSnapshot` gate / token redaction contract test
- `prod-verified` 항목: (없음 — 실 IdP 연동 통합 테스트 미수행)
- **추출하지 않을 항목** (planned / documented-only / abandoned): JWKS 10min cache + 1/min rate-limit (Minimal 결정, exact number NEEDS_CONTEXT) / 24h rotation overlap (IdP-side) / 실 토큰 서명 통합 테스트(IdP 필요)