408 lines
57 KiB
Markdown
408 lines
57 KiB
Markdown
---
|
|
title: branch / feature-authentication-authorization-contract
|
|
source_type: branch-note
|
|
status: raw
|
|
branch: feature-authentication-authorization-contract
|
|
parent_branch:
|
|
related_projects: [ca-skeleton]
|
|
governing_docs: [wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets.md]
|
|
tags: [branch, ca-skeleton, security, authorization, authz, rbac]
|
|
created: 2026-06-08
|
|
target_merge:
|
|
status_label: review
|
|
last_implementation: 2026-06-08
|
|
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-048
|
|
kind: project-work-item
|
|
project: ca-skeleton-operational-contract
|
|
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-048
|
|
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-ERROR-ENVELOPE-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-FRAMEWORK-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-ARCHTEST-001@1]
|
|
refines: []
|
|
overrides: []
|
|
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-008, WI-CA-SKELETON-OPERATIONAL-CONTRACT-005, WI-CA-SKELETON-OPERATIONAL-CONTRACT-018, WI-CA-SKELETON-OPERATIONAL-CONTRACT-014, WI-CA-SKELETON-OPERATIONAL-CONTRACT-022, WI-CA-SKELETON-OPERATIONAL-CONTRACT-001]
|
|
contract_packet: 1
|
|
contract_packet_sha256: 01bf98ba6718be772bdd1b8ffac611c2fc2f9ce395fa052a2ed2b40376847b14
|
|
---
|
|
|
|
> **2026-06-08 구현 완료 (working tree, 미커밋)** — 본 노트 설계대로 ca-tmpl `src/` 에 authz 계약 구현됨(코드 javadoc 이 D1/D2/D3/D4/§3 인용). 등급·발견은 §Audit & Findings 참조. **노트 정정**: §2 의 ArchUnit rule 을 "REFERENCE ONLY / 미구현(host=architecture-enforcement-rules)" 로 적었으나, 실제로는 architecture-enforcement suite(`app-bootstrap/.../CleanArchitectureTest`)에 D4 rule 로 구현됨 — 위임 설계대로 producer=본 branch / host=suite 가 실현됨.
|
|
|
|
# branch: feature-authentication-authorization-contract
|
|
|
|
> Layer: `raw/branch-notes/` — **product API 인가(authorization) 계약**: 인증된 principal 이 *무엇을 할 수 있는가* 를 결정하는 enforcement point(PEP) + permission/role 모델 + use-case 단위 권한 선언. 인증(authN)·JWT 검증·401/403 분류는 sibling `feature-security-operational-baseline` 가 owns(중복 금지). 머지 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출.
|
|
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
|
|
|
<!-- section-id: branch-parent -->
|
|
## 부모 (필수)
|
|
|
|
> 이 branch 는 project 의 직접 자식(`parent_branch:` 비어있음). ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT.
|
|
|
|
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]] — §35 D/E #5 ("AuthN/AuthZ product API baseline — JWT/OAuth2 RS + RBAC/ABAC + endpoint auth annotation") 가 본 branch 신설 근거. project §5(presentation/application/domain exception ownership)·§6(`AUTHZ` category)·§10(repository capability — *별개 축*)·§11 Security 가 관련 영역.
|
|
|
|
선택 (형제 — 직접 의존):
|
|
|
|
- [[raw/branch-notes/feature-security-operational-baseline]] — authN + JWT 검증 + principal mapping + 401/403 matrix owner. 본 branch 는 `AuthenticatedUser.roles`의 **prefix 없는 raw role**을 consume하고, Spring `ROLE_*` authority는 adapter 경계의 파생 표현으로만 취급한다.
|
|
- [[raw/branch-notes/feature-repository-access-permission-contract]] — `@UseCaseCapability` (use case → *infrastructure* capability). **사용자 권한이 아님**(registry 명시) — 본 branch 와 직교하는 축.
|
|
|
|
<!-- GENERATED: branch-contract:start -->
|
|
<!-- section-id: branch-contract-packet -->
|
|
## 브랜치 계약 패킷
|
|
|
|
- **생성 시 프로젝트 개정**: `1`
|
|
- **패킷 스키마**: `contract_packet: 1`
|
|
- **완료 조건**: authentication·authorization ownership, error mapping, forbidden dependency test가 명시된다
|
|
|
|
<!-- section-id: inherited-project-decisions -->
|
|
### 상속한 프로젝트 결정
|
|
|
|
| Decision Ref | Project Summary | Branch Application | Source |
|
|
|---|---|---|---|
|
|
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-MODULE-LAYOUT-001@1` | Gradle multi-module에서 domain-core·application-core·adapter-*·shared-contract·app-bootstrap·sample-portfolio 책임을 분리한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
|
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-ERROR-ENVELOPE-001@1` | foundation이 envelope schema와 error.category enum의 단일 owner다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
|
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-FRAMEWORK-001@1` | framework는 Spring Boot 3.5.14다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
|
|
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-ARCHTEST-001@1` | architecture test는 archunit-junit5 1.3.0을 사용한다 | 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 -->
|
|
## 목표
|
|
|
|
인증(authN)은 *너는 누구인가*, 인가(authZ)는 *너가 이 작업을 할 권한이 있는가* 다 (OWASP-AUTHZ-C3). `feature-security-operational-baseline` 은 JWT 를 검증하고 claim 을 `AuthenticatedUser.roles`의 raw role로 보존하며 Spring 경계에서 `ROLE_*` authority를 파생하고, 권한 부족을 `AUTHZ_INSUFFICIENT_PERMISSION` 403 으로 *분류* 까지 하지만, **무엇이 그 403 을 발생시킬지(실제 authz 결정) 를 정의하지 않는다.** ca-tmpl `src/` 에는 method/endpoint 단위 authorization 이 전무하다 — `@PreAuthorize`/`@EnableMethodSecurity`/`AuthorizationManager` 0건, `SecurityConfig` 는 `.authenticated()` (인증만 하면 누구나 통과) 뿐. 즉 `AUTHZ_INSUFFICIENT_PERMISSION` code 는 registry 에 등록돼 있으나 *아무도 emit 하지 않는다.*
|
|
|
|
본 branch 는 그 빈 자리를 채운다: **인증된 principal 의 권한을 use-case 단위로 검증하는 enforcement point + permission 중심 RBAC 모델 + 확장점**. ca-tmpl 은 도메인 없는 skeleton 이므로 concrete 비즈니스 role 은 정의하지 않고, *계약 + infrastructure + sample-portfolio 시연* 만 둔다.
|
|
|
|
- 이슈:
|
|
- PR:
|
|
|
|
<!-- section-id: branch-scope -->
|
|
## 범위
|
|
|
|
### 포함 범위
|
|
|
|
- **Enforcement point**: use-case `AuthorizationPort` / `@RequiresPermission` 추상화 (application-core). Spring Security 를 application/domain layer 밖에 유지.
|
|
- **Permission 중심 RBAC 모델**: permission = 집행 단위(`resource:action`), role = permission 묶음. role→permission 해소.
|
|
- **`@RequiresPermission` 선언 의무 + ArchUnit 집행** (rule host = architecture-enforcement-rules suite — REFERENCE ONLY).
|
|
- **실패 → `AUTHZ_INSUFFICIENT_PERMISSION` 403 emission** (code SSOT = security-baseline; 본 branch 는 *emission point* producer).
|
|
- **3-tier access model**: public ⊂ authenticated ⊂ authorized(permission).
|
|
- **sample-portfolio authz 시연** (worklog read/write/close permission).
|
|
|
|
### 제외 범위
|
|
|
|
> 의도적 제외 — sibling owner 영역. 면접 시 "이건 다른 계약 소관" 근거.
|
|
|
|
- **JWT 검증 / JWKS / clock skew / claim→principal·Spring authority 매핑 / CORS / 401·403 분류 matrix** → `feature-security-operational-baseline` owns. 본 branch 는 그 출력 중 prefix 없는 raw role set만 *consume* 한다.
|
|
- **`@UseCaseCapability` (repository infra-capability)** → `feature-repository-access-permission-contract` owns. *사용자 권한과 혼동 금지*(그 branch out-of-scope 에 "runtime authorization 혼동" 명시).
|
|
- **cross-tenant authz (`AUTHZ_TENANT_MISMATCH`)** → `feature-tenant-context-policy` owns. 본 branch 는 ABAC 확장점만 언급.
|
|
- **OAuth2 authorization server / token 발급 flow / IdP(Keycloak) realm 설정** → IdP-side. 본 branch 는 resource-server 측 authz 결정만.
|
|
- **concrete 비즈니스 role/permission 값** (도메인 영역). sample-portfolio 시연 외 실제 role 정의 안 함.
|
|
- **ArchUnit rule suite 자체** → `feature-architecture-enforcement-rules` host. 본 branch 는 rule producer.
|
|
|
|
## 근거 (필수, 최소 1개+)
|
|
|
|
> 본 branch 결정 근거. company-tech-blog 증거는 `company-case-study` 로 표기(공식 best practice 승격 금지).
|
|
|
|
| Source | 정당화하는 결정 |
|
|
|---|---|
|
|
| [[raw/official-docs/security-authorization-cheatsheet-owasp]] | D1(server-side enforcement·always-decide), D2(least-privilege H+V), D5(authn/authz distinct→403), D9(deny-by-default). OWASP-AUTHZ-C1~C6 |
|
|
| [[raw/project-notes/ca-skeleton-operational-contract]] | D1(§5 domain/application Spring 모름 + §14/§25 TransactionPort 추상화 선례), D4(§10 + capabilities.yaml ArchUnit 집행 패턴), D5(§6 AUTHZ category), D8(§17·§22 sample-portfolio) |
|
|
| [[raw/branch-notes/feature-security-operational-baseline]] | D3 입력 seam: `JwtToAuthenticatedUserConverter`가 raw role principal과 Spring `ROLE_*` authority를 분리해 제공; D5: `AUTHZ_INSUFFICIENT_PERMISSION` code + EnvelopeAccessDeniedHandler |
|
|
| [[raw/official-docs/keycloak-identity-provider-mappers]] | D3 role 출처(IdP realm/client role → claim) — `official-vendor-doc`(단 realm-role→permission 직접 발급 아님; app-side 매핑 보강 근거) |
|
|
| [[raw/official-docs/spring-security-authorization-architecture]] | D1 — custom `AuthorizationManager<MethodInvocation>` 가 Java-level 집행 가능(SS-AUTHZ-ARCH-C3), `@PreAuthorize` 는 Spring-managed bean coupling 요구(SS-AUTHZ-ARCH-C2) → application-core 부적합. `official-vendor-doc` |
|
|
| [[raw/official-docs/owasp-authz-permission-model-abac-rbac]] | D2 — permission-as-string abstraction(OWASP-PM-C3) + role→permission indirect(OWASP-PM-C4) + least-privilege H+V(OWASP-PM-C5). **반례**: OWASP 는 ABAC generally prefer(OWASP-PM-C1) → trade-off 명시. `official-reference` |
|
|
| [[raw/official-docs/aws-iam-google-iam-permission-naming-convention]] | D6 — `resource:action` colon separator 가 AWS IAM `service:Action`(IAM-NAMING-C1) 관행과 일관, Google 3-segment dotted(IAM-NAMING-C2)는 단일 서비스 과도. `official-vendor-doc` |
|
|
| [[raw/company-tech-blogs/curity-oauth2-scope-vs-permission-naming]] | D6 — scope=entry-point vs internal permission 분리 + `resource:action` colon naming(CURITY-SCOPE-C2). `company-case-study`(AWS IAM 으로 corroborate, 단독 승격 금지) |
|
|
| [[raw/official-docs/keycloak-authorization-services-realm-client-roles]] | D3 — KC Authorization Services(UMA) 대안은 본 branch scope 밖(KC-AUTHZ-C1/C4, `official-vendor-doc`). realm/client role 의 JWT claim 구조(KC-AUTHZ-C2)는 *engineering-blog 수준 → needs-confirmation*(1차 근거는 ca-tmpl 코드) |
|
|
|
|
## TODO
|
|
|
|
각 항목 옆 증거 등급: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
|
|
|
- [x] `AuthorizationPort` + `@RequiresPermission` (application-core) 정의 — 등급: `locally-verified` (2026-06-08 구현. `AuthorizationPort.requirePermission(AuthorizationPrincipal, Permission)` + `AuthorizationPrincipal`/`AuthorizationDeniedException`/`@RequiresPermission` 전부 Spring-Security-free. `Permission` record 는 `shared-contract`. `AuthorizationContractTest`/`PermissionTest` 통과)
|
|
- [x] role→permission 해소 adapter (raw role → effective permissions) — 등급: `locally-verified` (2026-06-08 `RolePermissionRegistry`(case-insensitive, fail-closed, wildcard 미지원=§3 기본 B) + `RolePermissionProperties`(`ca-skeleton.authz.role-permissions`, key=raw role) + `AuthorizationAdapter implements AuthorizationPort`. `RolePermissionRegistryTest`/`AuthorizationAdapterTest`/`RolePermissionPropertiesTest`(binding) 통과)
|
|
- [x] `@RequiresPermission` 미선언 mutating use case ArchUnit rule — 등급: `locally-verified` (2026-06-08 사용자 요청으로 F4/REFERENCE ONLY 위임을 해제하고 host suite(`app-bootstrap/.../CleanArchitectureTest`)에 직접 구현. **2 rule**: `mutating_use_cases_declare_required_permission`(=`@UseCaseCapability(WRITE_REPOSITORY)` 인데 `@RequiresPermission` 미선언이면 build fail — non-vacuous 검증: DeleteWorkLogUseCase 어노테이션 제거 시 정확히 이 rule 만 FAILED 확인 후 복원) + `application_and_domain_do_not_depend_on_spring_security`(D1 import 금지). producer=본 branch / host=suite 위임이 실현됨)
|
|
- [x] AuthorizationPort 거부 → `AUTHZ_INSUFFICIENT_PERMISSION` 403 wiring — 등급: `locally-verified` (2026-06-08 `RequiresPermissionAuthorizationManager`(`AuthorizationManager<MethodInvocation>`) + `MethodSecurityConfig`(`@EnableMethodSecurity(prePostEnabled=false)` + ROLE_INFRASTRUCTURE Advisor). 거부 → `AuthorizationDecision(false)` → Spring `AccessDeniedException` → `GlobalExceptionHandler#handleForbidden` → `SecurityErrorClassifier.classifyAccessDenied` → `AUTHZ_INSUFFICIENT_PERMISSION`. `GlobalExceptionHandlerTest`/`RequiresPermissionAuthorizationManagerTest` 통과. **filter 경로(EnvelopeAccessDeniedHandler)와 method 경로(controller-advice) 가 이제 동일 classifier 사용**)
|
|
- [x] permission naming registry + sample-portfolio authz 시연 — 등급: `locally-verified` (2026-06-08 `worklog:read/write/close` + role bundle(user={read,write}, admin={read,write,close}) `application.yml`. mutating use case 4종에 `@RequiresPermission` 부착(Create/Update/Batch=`worklog:write`, Delete=`worklog:close`=admin-tier). `WorkLogAuthorizationContractTest`(@SpringBootTest, 실제 AOP proxy 경유) 4 cases 통과: user→write 허용 / user→close 거부 / admin→close 허용 / unauth→거부)
|
|
- [x] negative E2E (HTTP→method security→403 envelope 전 경로) — 등급: `locally-verified` (2026-06-08 사용자 요청. `WorkLogAuthorizationE2ETest`(@WebMvcTest, 실 `WorkLogController` DELETE 엔드포인트): user → 403 + envelope `error.code=AUTHZ_INSUFFICIENT_PERMISSION`(repository.deleteById 미호출 검증), admin → 204(deleteById 호출). MVC dispatch→proxied use case method-security→AccessDeniedException→GlobalExceptionHandler→envelope 전 경로 검증)
|
|
- [x] 자동조사(D1/D2/D3/D6) Supporting Claim 연결 — 등급: `actually-implemented` (2026-06-08 `wiki-decision-researcher` 5 raw 산출 + 연결 완료)
|
|
|
|
## 진행 중 메모
|
|
|
|
- **잔존 저위험 (2026-06-08, 의도적 미해소)**:
|
|
1. **authN→authz seam 미통합 검증**: `WorkLogAuthorizationE2ETest`/`WorkLogAuthorizationContractTest` 는 둘 다 `addFilters=false`(+`SecurityAutoConfiguration` 제외)로 JWT 필터체인을 끄고 `AuthenticatedUser` 를 SecurityContext 에 직접 주입한다. 따라서 *인가 leg*(method-security→403 envelope)는 닫혔으나, `JWT → SecurityFilterChain → JwtToAuthenticatedUserConverter → AuthenticatedUser.roles → registry lookup` seam 은 authz 와 묶여 한 번에 검증되지 않음(security-baseline 단위검증에 의존). 닫으려면 full `@SpringBootTest`(mock JWT 발급 + 실 필터체인 + method security) 필요 — ~50 env 의존으로 별도 작업.
|
|
2. **`proxy-target-class` flip 미가드**: E2E/contract test 둘 다 자기 컨텍스트에 `@EnableAspectJAutoProxy(proxyTargetClass=true)` 를 강제하므로, prod 에서 `spring.aop.proxy-target-class=false` 로 바꾸면 **테스트는 통과하면서 prod 만 깨진다**(concrete `*UseCase` 주입이 JDK proxy 로 fallback → `BeanNotOfRequiredTypeException`). 즉 테스트가 이 flip 을 잡지 못함 = 가드 없음(저위험). prod 는 Boot 기본(CGLIB)이라 현재 안전. → [[raw/errors/method-security-cglib-vs-jdk-proxy-usecase-injection-2026-06-08]]
|
|
- security-baseline 은 2026-06-08 Phase C2 로 authN 전 영역 `locally-verified` 까지 구현됨. 본 branch 는 그 위에 *authz 결정* 만 얹으므로, `JwtToAuthenticatedUserConverter`(realm_access+resource_access → `AuthenticatedUser` raw roles + adapter `ROLE_*` authorities)·`AuthenticatedUser`·`EnvelopeAccessDeniedHandler` 가 본 branch 구현의 전제 anchor.
|
|
- **code SSOT 위임 (coverage audit Should-fix 해소)**: `AUTHZ_INSUFFICIENT_PERMISSION`·`AUTHZ_TENANT_MISMATCH` 는 `error-codes.yaml` 에 `owner_branch = feature-security-operational-baseline` 로 등록(2026-06-08 코드 확인). 본 branch 는 code 를 *새로 만들지 않고* emission point(실제 발생원)만 추가 — code registry SSOT = [[raw/branch-notes/feature-security-operational-baseline]], emission producer = 본 branch.
|
|
- **TODO (project note 갱신 — §25 SSOT Owner Map row 부재, coverage audit Should-fix)**: project `ca-skeleton-operational-contract` §25 SSOT Owner Map 에 신규 row 추가 필요 — `| product authorization enforcement point (PEP) | feature-authentication-authorization-contract | security-operational-baseline(ROLE_* authority consumer + AUTHZ code SSOT), architecture-enforcement-rules(rule host), sample-domain-contract-fixture(authz fixture) | AuthorizationPort + @RequiresPermission SSOT |`. §35 D/E #5 의 `(없음)` → scaffolded 로 상태 갱신도 동반. (project note 편집은 본 branch-spec 범위 밖 — 별도 작업으로 처리.)
|
|
|
|
## 결정 사항
|
|
|
|
> 대안과 함께 기록. 각 결정 근거는 위 Sources. 상세 매핑은 아래 Decision Evidence Map.
|
|
|
|
- 2026-06-08: **D1** enforcement layer = use-case `AuthorizationPort`(application-core), Spring `@PreAuthorize` 아님 / 이유: application·domain 이 Spring Security type 을 import 하면 project §5·§19 원칙 위반(TransactionPort 선례 §14·§25); custom `AuthorizationManager<MethodInvocation>` 가 Java-level 집행 제공(SS-AUTHZ-ARCH-C3) / 대안: Spring method security `@PreAuthorize`(SS-AUTHZ-ARCH-C2 = bean coupling → 위반), web-layer `authorizeHttpRequests` coarse 규칙 / 근거: SS-AUTHZ-ARCH-C2/C3/C4 + project-ssot + OWASP-AUTHZ-C6 / 위험: AOP proxy bypass → ArchUnit 보강.
|
|
- 2026-06-08: **D2** authz 모델 = permission 중심 RBAC(permission=집행 단위, role=permission 묶음) / 이유: 도메인이 role 추가해도 enforcement 코드 불변 + least-privilege(H+V, OWASP-PM-C5) + action→permission string abstraction(OWASP-PM-C3) / 대안: role-only RBAC, ABAC(**OWASP-PM-C1 = ABAC generally prefer** — 정적 permission 규모 YAGNI 로 trade-off, AuthorizationPort interface 가 ABAC migration path 보장) / 근거: OWASP-PM-C3/C4/C5.
|
|
- 2026-06-08: **D3** AuthorizationPort 는 현재 principal 의 **prefix 없는 raw role**을 role→permission registry key로 사용한다. Spring `ROLE_*` authority는 adapter가 파생하는 표현이며 registry 입력이 아니다. 매핑 source = app-side static config 기본, IdP 가 permission claim 직접 발급 시 그것 우선 / 대안: IdP-authoritative only(Keycloak Authorization Services/UMA — KC-AUTHZ-C1/C4, 본 branch scope 밖), JWT scope claim only / 근거: security-baseline `JwtToAuthenticatedUserConverter` + KC-AUTHZ-C2/C3(needs-confirmation).
|
|
- 2026-06-08: **D4** mutating/sensitive use case 는 `@RequiresPermission` 선언 의무, ArchUnit 으로 미선언 차단(repository-access `@UseCaseCapability` 패턴 mirror) / 대안: compile-time annotation processor, runtime AOP(capabilities.yaml 정책상 forbidden) / 근거: project §10 + capabilities.yaml(enforcement=archunit, runtime AOP forbidden). **rule host = architecture-enforcement-rules suite(REFERENCE ONLY)**.
|
|
- 2026-06-08: **D5** AuthorizationPort 거부 → `AUTHZ_INSUFFICIENT_PERMISSION`(AUTHZ 403). code SSOT = security-baseline(registry owner) / 본 branch 는 emission point producer. IDOR-sensitive 도메인은 403→404 masking 확장점(OWASP-AUTHZ-C7) / 근거: OWASP-AUTHZ-C3 + security-baseline matrix.
|
|
- 2026-06-08: **D6** permission naming = `resource:action` lowercase colon-delimited (예: `worklog:close`) / 대안: `service.resource.verb`(Google IAM), `service:Action`(AWS IAM), OAuth2 scope / 근거: 자동조사(진행 중) + OWASP-AUTHZ-C4. exact delimiter 는 근거 미확정 시 `UNSUPPORTED_IMPL_DECISION`.
|
|
- 2026-06-08: **D8** sample-portfolio 가 authz 시연 fixture(worklog:read/write/close, ROLE_USER/ROLE_ADMIN). sample model owner = sample-fixture branch(본 branch 는 authz 부착 producer) / 근거: project §17·§22.
|
|
- 2026-06-08: **D9** 3-tier: public(permitAll) ⊂ authenticated ⊂ authorized(permission). tier1-2 = security-baseline(deny-by-default), tier3 = 본 branch(authenticated≠authorized) / 근거: OWASP-AUTHZ-C1/C2 + security-baseline D5/D6.
|
|
|
|
## 결정-근거 매핑
|
|
|
|
> `Decision ID` 는 본 note 안에서 안정 유지. `Supporting Claims` 는 `raw/<category>/<slug>.md#C1` 형식.
|
|
> `선택 조건`(R2): 이 조건일 때 이 결정, 다른 조건이면 어떤 대안.
|
|
|
|
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
|
|---|---|---|---|---|---|
|
|
| D1 | enforcement = use-case `AuthorizationPort`(application-core), Spring method security 아님 | 기본 = application port. **application/domain 이 Spring Security type import 하면 안 됨**(project 원칙) → port. coarse endpoint gating 만 필요하면 web-layer `authorizeHttpRequests`(security-baseline). 표준 단순성이 원칙보다 우선이면 `@PreAuthorize` | `raw/official-docs/spring-security-authorization-architecture.md#SS-AUTHZ-ARCH-C3`(custom `AuthorizationManager<MethodInvocation>` = Java-level 집행), `#SS-AUTHZ-ARCH-C2`(`@PreAuthorize` = Spring bean coupling → 부적합), `#SS-AUTHZ-ARCH-C4`(rule 위치 trade-off), `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C6`, `#OWASP-AUTHZ-C1`, `raw/project-notes/ca-skeleton-operational-contract.md`(§5 layer 격리 + §14/§25 TransactionPort 선례) | `official-vendor-doc + project-ssot + official-reference` | **AOP proxy bypass**(self-invocation / non-Spring-bean 호출)에 취약(SS-AUTHZ-ARCH-C2) → ArchUnit 이 미보호 진입점 정적 차단 필요; `@PreAuthorize` 대비 boilerplate ↑ |
|
|
| D2 | authz 모델 = permission 중심 RBAC (permission=집행 단위, role=묶음) | 기본 = permission-centric RBAC. owner/relationship 기반(예: `worklog.owner==principal`) 필요 도메인 → AuthorizationPort 구현체가 ABAC predicate 추가(거부 아님; interface 가 migration path 보장). role-only 는 도메인 role 추가 시 enforcement 수정 → 기각 | `raw/official-docs/owasp-authz-permission-model-abac-rbac.md#OWASP-PM-C3`(action→permission string abstraction), `#OWASP-PM-C4`(role=permission bundle indirect), `#OWASP-PM-C5`(least-privilege H+V), `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C4` | `official-reference` (OWASP) | **OWASP 는 ABAC/ReBAC generally prefer(OWASP-PM-C1)** — permission-centric RBAC 는 정적 permission+소수 role 규모에서 YAGNI 근거의 단순성 trade-off; dynamic attribute(시간/지리/owner) 요구 시 ABAC 전환 |
|
|
| D3 | AuthorizationPort 가 prefix 없는 raw role → role→permission registry 확장 → 요구 permission 포함 판정. `ROLE_*` authority는 Spring adapter의 파생 표현 | 기본 = app-side static config(IdP coupling 최소). IdP(Keycloak)가 permission claim 직접 발급하면 IdP-authoritative 우선. JWT scope claim 만으로 부족하면 registry 확장 | [[raw/branch-notes/feature-security-operational-baseline]] — principal mapping seam을 consume; `raw/official-docs/keycloak-authorization-services-realm-client-roles.md#KC-AUTHZ-C2`(realm/client role JWT claim 구조 — *engineering-blog 수준*), `#KC-AUTHZ-C3`, `raw/official-docs/keycloak-identity-provider-mappers.md#KC-IDP-MAPPER-C2` | `cross-branch (security-baseline JwtToAuthenticatedUserConverter code = locally-verified, 1차 근거) + engineering-blog (KC-AUTHZ-C2, needs-confirmation)` | role→permission config drift; IdP 권한 변경 시 app config 동기화. **KC-AUTHZ-C2 는 engineering 수준 → 메커니즘 1차 근거는 ca-tmpl 코드(realm_access+resource_access 파싱 locally-verified)이고 KC-AUTHZ-C2 는 보조; official Keycloak doc 재확인 needs-confirmation** |
|
|
| D4 | mutating/sensitive use case `@RequiresPermission` 선언 의무, ArchUnit 차단 | 집행: 기본 = ArchUnit(capabilities.yaml 정책 상속), compile-time processor = alt, **runtime AOP = forbidden**(capabilities.yaml 명시). **적용 범위(depth audit #3 해소)**: skeleton 1차 = **mutating-only**(=`@UseCaseCapability(WRITE_REPOSITORY)` 보유 use case). authenticated read 결과 필터링이 필요해지면 read 강제로 확장(sample-portfolio 구현 후 결정); public read 는 항상 제외 | `raw/project-notes/ca-skeleton-operational-contract.md`(§10 capability 선언 + capabilities.yaml `enforcement: archunit` / `runtime AOP forbidden`), §25 F1(ArchUnit suite SSOT=architecture-enforcement), `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C4`(least-privilege — read 무조건 강제는 과대) | `project-ssot + official-reference` | rule host = `feature-architecture-enforcement-rules`(REFERENCE ONLY); read 강제 확장 시점은 sample 구현 후 |
|
|
| D5 | 거부 → `AUTHZ_INSUFFICIENT_PERMISSION` 403. code SSOT=security-baseline, 본 branch=emission point | N/A (code 매핑 고정). 단 IDOR-sensitive 도메인은 403→404 masking 확장점 | `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C3`, `#OWASP-AUTHZ-C7`(IDOR), `raw/branch-notes/feature-security-operational-baseline.md`(AuthN/AuthZ matrix `AUTHZ_INSUFFICIENT_PERMISSION` 403 행 + `EnvelopeAccessDeniedHandler`) | `official-reference + cross-branch` | §25 SSOT Owner Map 에 "authorization enforcement point" row 추가 필요(producer/consumer 명시) |
|
|
| D6 | permission naming = `resource:action`(lowercase, colon) | 기본 = `resource:action`(2-segment, 단일 서비스). multi-service gateway 수준 permission 필요 시 `service:resource:action` 으로 확장. Google `service.resource.verb`(dotted)는 Java package 혼동 + service prefix 중복으로 기각 | `raw/official-docs/aws-iam-google-iam-permission-naming-convention.md#IAM-NAMING-C1`(AWS `service:Action` colon), `#IAM-NAMING-C2`(Google dotted 3-segment 대안), `raw/company-tech-blogs/curity-oauth2-scope-vs-permission-naming.md#CURITY-SCOPE-C2`(`resource:action` industry practice), `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C4`(granularity) | `official-vendor-doc`(AWS IAM) + `company-case-study`(Curity corroborate) | RFC 강제 표준 없음(convention) — team 문서화로 유지; wildcard(`worklog:*`) 전개 규칙 + permission explosion vs coarse 미정 |
|
|
| D8 | sample-portfolio authz 시연(worklog:read/write/close, ROLE_USER/ADMIN) | N/A (fixture). sample model 변경은 sample-fixture branch | `raw/project-notes/ca-skeleton-operational-contract.md`(§17 sample-portfolio + §22 "unauthorized worklog update | auth/authz separation") | `project-ssot` | sample role/permission 이 도메인 role 로 오인 방지 — sample package 격리 |
|
|
| D9 | 3-tier: public ⊂ authenticated ⊂ authorized. tier3(authenticated≠authorized) 추가 | N/A (계층 고정) | `raw/official-docs/security-authorization-cheatsheet-owasp.md#OWASP-AUTHZ-C1`, `#OWASP-AUTHZ-C2`, `#OWASP-AUTHZ-C5`, `raw/branch-notes/feature-security-operational-baseline.md`(D5 deny-by-default / D6 every-request) | `official-reference + cross-branch` | every-request 권한 검증(C5) 비용 — role→permission 해소 caching(stateless 유지 vs staleness) |
|
|
|
|
## 구현 가이드
|
|
|
|
> *결정* 이 "*무엇*" 이라면 본 §는 "*어디에 어떻게*". **2026-06-08 구현 완료** — 아래 `planned` 다수가 실제 코드로 실현됨(as-built 등급·파일 anchor 는 §Audit & Findings 의 구현 인벤토리 참조; 본 § 표의 `planned` 는 *설계 시점* 표기로 보존). 설계 시점 "ca-tmpl 0건" 기술은 구현 전 상태.
|
|
>
|
|
> **3-rule meta principle**(CLAUDE.md §15.5): R1 모든 cell = Decision ID + Supporting Claim / R2 근거 없는 detail = `UNSUPPORTED_IMPL_DECISION` + trade-off / R3 본 branch 범위 밖 = 별도 §/sibling 이관.
|
|
|
|
### 1. AuthorizationPort 계약 (application-core)
|
|
|
|
> **Trace**: D1(use-case port, `OWASP-AUTHZ-C6` + project §5/§14/§25) · D3(role→permission 해소) · D9(tier3).
|
|
>
|
|
> - **UNSUPPORTED_IMPL_DECISION**: port API 모양(`requirePermission(Permission)` throw vs `check(...)→boolean`). trade-off: throw 방식 = 호출부 단순 + fail-closed 자연스러움 vs boolean = 분기 유연. 기본 throw(fail-closed).
|
|
|
|
| 항목 | 구현 anchor | 등급 |
|
|
|---|---|---|
|
|
| port 인터페이스 | `dev.caskeleton.application.<core>.security.AuthorizationPort` — `void requirePermission(Permission required)` (application-core, **Spring Security import 금지**) | `planned` |
|
|
| Permission 표현 | `domain-core` 또는 `shared-contract` 의 `record Permission(String resource, String action)` (`resource:action`, D6) | `planned` |
|
|
| 현재 principal 접근 | adapter 가 `SecurityContext`→`AuthenticatedUser`(security-baseline `dev.caskeleton.adapter.web.auth.AuthenticatedUser`) 에서 authorities 추출 → port 입력. application 은 principal 을 *주입* 받음(Spring 비의존) | `planned` (security-baseline `AuthenticatedUser` = `actually-implemented`) |
|
|
| 거부 신호 | `AuthorizationDeniedException`(application/domain-neutral) throw → adapter-web 이 403 매핑(§4) | `planned` |
|
|
|
|
### 2. `@RequiresPermission` 선언 + ArchUnit 집행 (REFERENCE ONLY — host=architecture-enforcement-rules)
|
|
|
|
> **Trace**: D4(`@UseCaseCapability` 패턴 mirror, capabilities.yaml `enforcement: archunit` / `runtime AOP forbidden` + project §25 F1).
|
|
>
|
|
> - **OUT_OF_BRANCH_SCOPE(F4)**: ArchUnit rule 의 *실제 코드 위치* = `feature-architecture-enforcement-rules` suite. 본 branch 는 rule *producer*(어떤 규칙이 필요한지 정의), host 아님. 아래 코드 skeleton = REFERENCE ONLY.
|
|
> - **UNSUPPORTED_IMPL_DECISION**: 적용 범위 = mutating/sensitive use case (read-only query 강제 여부 미정 — §Open Risk D4). trade-off: all-use-case 강제 = 누락 0 vs read 마다 permission 선언 boilerplate.
|
|
|
|
| 항목 | 구현 anchor | 등급 |
|
|
|---|---|---|
|
|
| annotation | `@RequiresPermission(String value)` (`value="worklog:close"`, TYPE 또는 METHOD target — `@UseCaseCapability` 와 동일 위치 convention). **retention=RUNTIME** (adapter 의 `AuthorizationManager` 가 reflect) | `planned` |
|
|
| 집행 메커니즘 (adapter, SS-AUTHZ-ARCH-C3) | adapter-web 의 `RequiresPermissionAuthorizationManager implements AuthorizationManager<MethodInvocation>` 가 `MethodInvocation` 에서 `@RequiresPermission` 읽어 `AuthorizationPort.requirePermission(...)` 위임. `@EnableMethodSecurity(prePostEnabled=false)` + custom `Advisor` 로 wiring — application-core 는 여전히 Spring-free(annotation 만 보유, 집행은 adapter) | `planned` |
|
|
| ArchUnit rule (구현됨 — host=suite) | `CleanArchitectureTest.declareRequiredPermissionWhenMutating()`(app-bootstrap, L335-358) — mutating use case(`@UseCaseCapability(repositoryAccess=WRITE_REPOSITORY)`)인데 `@RequiresPermission` 미선언 → build fail. D4 tag. **위임 설계대로 producer=본 branch / host=architecture-enforcement suite** | `actually-implemented` |
|
|
| no-Spring-Security-in-application | `CleanArchitectureTest.application_and_domain_do_not_depend_on_spring_security`(L291, "D1") — application/domain 의 `org.springframework.security..` import → build fail | `actually-implemented` |
|
|
| AOP proxy bypass (SS-AUTHZ-ARCH-C2 위험, **잔존**) | 위 D4 rule 은 annotation *존재* 만 보장; self-invocation / non-Spring-bean 호출의 *invocation-path* 우회는 정적으로 미검출. controller→usecase 는 proxy 경유라 현재 안전하나 구조적 잔존 위험 → §Claims To Verify | `documented-only` (gap) |
|
|
|
|
### 3. role→permission 해소 adapter (D3, D2)
|
|
|
|
> **Trace**: D3(role→effective permissions) · D2(permission-centric). 입력 = security-baseline `AuthenticatedUser.roles`.
|
|
>
|
|
> - **registry key 형식 결정 (depth audit #2 해소)**: ca-tmpl `AuthenticatedUser.roles`(`adapter-web`, `Set<String>`)는 **raw role 문자열**(`admin`/`user` — Keycloak 원본, prefix 없음)을 담는다. Spring `GrantedAuthority` 만 `"ROLE_"+toUpperCase()` prefix 를 받는다(`JwtToAuthenticatedUserConverter.java` L33-34). 따라서 registry key = **raw role 명(prefix 없음)** — `ROLE_ADMIN` 아님. application-core 가 Spring-free 이므로 port 는 `GrantedAuthority` 가 아니라 *raw role set* 을 consume.
|
|
> - **UNSUPPORTED_IMPL_DECISION**: (1) 매핑 저장소 = app-side `@ConfigurationProperties` static map(`ca-skeleton.authz.role-permissions`) 기본 — IdP coupling 최소, env-driven(§9). (2) **role 명 case 정규화** — Keycloak raw role 의 대소문자 보장 없음 → registry lookup 을 case-insensitive(lowercase 정규화) 로. trade-off: 정규화(Keycloak 설정 무관 안정) vs exact-match(설정 강제).
|
|
> - **principal 추상화 (Spring-free)**: `AuthenticatedUser` 는 `adapter-web` 타입 → application-core 가 import 불가. port 는 application-core/shared-contract 의 principal 추상(`Set<String> roles` + subject)을 받고, adapter 가 `AuthenticatedUser`→그 추상으로 매핑.
|
|
|
|
| 항목 | 구현 anchor | 등급 |
|
|
|---|---|---|
|
|
| role→permission registry | `RolePermissionRegistry`(adapter 또는 shared-contract) ← `ca-skeleton.authz.role-permissions`. **key = raw role 명(lowercase)**: `admin: [worklog:read, worklog:write, worklog:close]`, `user: [worklog:read, worklog:write]` (← `AuthenticatedUser.roles`, `ROLE_` prefix 없음). 정적 `@ConfigurationProperties` map = **startup-bound → staleness 없음**(IdP claim 직접 발급 채택 시에만 별도 TTL 필요) | `planned` |
|
|
| effective permission 확장 | `AuthenticatedUser.roles`(raw) → registry lookup → permission set union. **wildcard 전개(`worklog:*`)**: `UNSUPPORTED_IMPL_DECISION` — (A) 정적 prefix-union(registry 등록 `worklog:` 전체 union, 미래 permission 자동 포함) vs (B) 명시 열거만(wildcard 미지원, admin = 명시 목록). 기본 = (B) 명시 열거(least-privilege OWASP-AUTHZ-C4 우선, `worklog:delete` 자동 포함 차단) | `planned` |
|
|
| AuthorizationPort 구현체 | `AuthorizationAdapter implements AuthorizationPort`(adapter-web) — effective permissions 에 required 포함 여부, fail-closed(미발견 role → 권한 0) | `planned` |
|
|
|
|
### 4. 거부 → `AUTHZ_INSUFFICIENT_PERMISSION` 403 (emission point; code SSOT=security-baseline)
|
|
|
|
> **Trace**: D5(`OWASP-AUTHZ-C3` distinct→403 + security-baseline matrix row). code 자체는 security-baseline `error-codes.yaml` owner.
|
|
>
|
|
> - **OUT_OF_BRANCH_SCOPE**: error code *정의/registry* = security-baseline. 본 branch = 거부 → 403 envelope wiring.
|
|
> - **예외 경로 결정 (depth audit #1 해소)**: application-core 는 Spring-free 이므로 `AuthorizationPort` 는 Spring `AccessDeniedException` 을 throw할 수 *없다*. 따라서 **2-hop 경로**를 명시: (1) application-core port 가 domain-neutral `AuthorizationDeniedException`(자체 타입) throw → (2) adapter-web `RequiresPermissionAuthorizationManager`(Spring-aware)가 이를 Spring `org.springframework.security.access.AccessDeniedException` 으로 변환(또는 Spring 6.x `AuthorizationDeniedException extends AccessDeniedException` 사용). method-invocation 시점 throw 이므로 **filter-layer `EnvelopeAccessDeniedHandler` 가 아니라 controller-advice `GlobalExceptionHandler#handleForbidden(AccessDeniedException)`(L91, `actually-implemented`)** 에 도달.
|
|
> - **UNSUPPORTED_IMPL_DECISION**: `handleForbidden` 현재는 coarse `FORBIDDEN` 매핑. fine-grained `AUTHZ_INSUFFICIENT_PERMISSION` 을 emit 하려면 `handleForbidden` 이 `SecurityErrorClassifier.classifyAccessDenied(ex)`(L60, AccessDeniedException→`AUTHZ_INSUFFICIENT_PERMISSION`) 에 위임하도록 변경 필요. trade-off: handler 위임 변경(filter/method 양 경로 code 일치) vs coarse FORBIDDEN 수용(변경 0, 분류 손실). 기본 = 위임 변경(분류 일관).
|
|
|
|
| 항목 | 구현 anchor | 등급 |
|
|
|---|---|---|
|
|
| port 거부 신호 (application-core) | `AuthorizationDeniedException`(application/domain-neutral 자체 타입, Spring 비의존) | `planned` |
|
|
| adapter 변환 (adapter-web) | `RequiresPermissionAuthorizationManager` 가 거부 → Spring `AccessDeniedException`(or `AuthorizationDeniedException extends AccessDeniedException`) | `planned` |
|
|
| 403 envelope emit | `GlobalExceptionHandler#handleForbidden(AccessDeniedException)`(L91, `actually-implemented`) → 위임 변경 방법: handler 내 `OperationalError.FORBIDDEN` 라인을 `SecurityErrorClassifier.classifyAccessDenied(ex)`(L60) 반환값으로 교체(코드 1줄) → `AUTHZ_INSUFFICIENT_PERMISSION`. 미교체 시 coarse `FORBIDDEN` | `planned` (handler 존재, classifier 위임 신규) |
|
|
| IDOR masking 확장점 | 403↔404 선택은 도메인 결정(OWASP-AUTHZ-C7). skeleton 기본 = 403(정직), masking 은 확장점만 | `documented-only` |
|
|
|
|
### 5. permission naming + sample-portfolio authz 시연 (D6, D8)
|
|
|
|
> **Trace**: D6(naming `resource:action`) · D8(sample fixture, project §17/§22). sample model owner = sample-fixture branch(부착만).
|
|
>
|
|
> - **naming 확정 (D6)**: `resource:action`(2-segment colon) — AWS IAM(`IAM-NAMING-C1`)+Curity(`CURITY-SCOPE-C2`) 정합. wildcard 미지원(§3 기본 B).
|
|
|
|
| 항목 | 구현 anchor | 등급 |
|
|
|---|---|---|
|
|
| permission 값 | `worklog:read` · `worklog:write` · `worklog:close` (sample-portfolio) | `planned` |
|
|
| role 묶음 (key=raw role, §3) | `user → {worklog:read, worklog:write}`, `admin → {worklog:read, worklog:write, worklog:close}` (명시 열거 — wildcard 미사용, §3 기본 B) | `planned` |
|
|
| use case 부착 | sample-portfolio `CloseWorkLogUseCase` 등에 `@RequiresPermission("worklog:close")` (sample package 격리 — 도메인 role 오인 방지) | `planned` |
|
|
| contract test | authenticated+permission 없음 → 403 / 있음 → 200, sample 시연 | `planned` |
|
|
|
|
## 엣지·실패·의존
|
|
|
|
> R4 캡처. 정상 경로 외 *구현 중 부딪힐* 실패/엣지/다른 계약 의존.
|
|
|
|
- **실패·엣지 경로**:
|
|
- **authenticated 인데 permission 없음**: 401 아님 → `AUTHZ_INSUFFICIENT_PERMISSION` 403(D5/D9). 인증은 됐으나 인가 실패의 핵심 경로.
|
|
- **role→permission config 누락/오타**: 미발견 role → fail-closed(권한 0, 403). config drift 시 정당 사용자도 거부 → startup 검증(알려진 role 집합 대조) 권고.
|
|
- **`@RequiresPermission` 미선언 mutating use case**: ArchUnit build fail(D4). 누락 = silent 무인가 통과 방지.
|
|
- **wildcard 전개**(`worklog:*`): 기본 = 미지원(§3 B 명시 열거) — admin 도 명시 permission 목록. 만약 (A) 정적 prefix-union 채택 시 `worklog:*` 가 미래 `worklog:delete` 자동 포함 → least-privilege(OWASP-AUTHZ-C4) 위반 위험. 기본값이 least-privilege 보존.
|
|
- **IDOR/BOLA**(OWASP-AUTHZ-C7): resource 존재를 403 으로 노출 vs 404 masking. skeleton 기본 403, 도메인 확장점.
|
|
- **every-request 해소 비용**(OWASP-AUTHZ-C5): role→permission 해소를 매 요청 수행 vs principal 단위 cache — stateless 유지(security-baseline) 와 cache staleness trade-off.
|
|
- **다른 계약 의존**:
|
|
- [[raw/branch-notes/feature-security-operational-baseline]] — raw role principal을 입력으로 제공하고 Spring `ROLE_*` authority는 adapter에서 파생하며, `AUTHZ_INSUFFICIENT_PERMISSION` code/emission 경로를 소유한다. 이 seam이 바뀌면 본 branch의 registry 입력 형식이 영향받는다.
|
|
- [[raw/branch-notes/feature-repository-access-permission-contract]] `@UseCaseCapability`(infra-capability) — **직교 축**(사용자 권한 아님). `@RequiresPermission` 와 *동시* 선언되며 ArchUnit 패턴 공유(mirror). 혼동 시 user-authz 를 capability 로 착각.
|
|
- [[raw/branch-notes/feature-architecture-enforcement-rules]] ArchUnit suite host — D4 rule 의 실제 코드 위치(REFERENCE ONLY).
|
|
- [[raw/branch-notes/feature-sample-domain-contract-fixture]] sample-portfolio model owner — D8 authz 시연 부착 대상.
|
|
- [[raw/branch-notes/feature-tenant-context-policy]] `AUTHZ_TENANT_MISMATCH`(cross-tenant authz) — ABAC tenant 축은 그 branch. 본 branch 는 확장점만.
|
|
- [[raw/branch-notes/feature-operational-error-observability-foundation]] `Category` enum(`AUTHZ`) SSOT — D5 category consume.
|
|
|
|
## 검증해야 할 주장
|
|
|
|
> 공식 문서·사례는 근거지만 내 프로젝트 동작을 자동 보장하지 않는다.
|
|
|
|
| Claim | Why uncertain | How to verify | Status |
|
|
|---|---|---|---|
|
|
| ~~application-core Spring-free authz~~ | — | **RESOLVED**: `AuthorizationPort`/`AuthorizationPrincipal`/`@RequiresPermission` 가 application-core 에 Spring-free, `CleanArchitectureTest` D1 rule(L291)이 import 차단 | `actually-implemented` |
|
|
| ~~registry key = raw role(prefix 없음)~~ | — | **RESOLVED**: `AuthorizationPrincipal`(raw roles, "never ROLE_*"), `RolePermissionRegistry`(lowercase normalize), `AuthorizationContractTest` | `actually-implemented` |
|
|
| ~~mutating use case `@RequiresPermission` 강제~~ | — | **RESOLVED**: `CleanArchitectureTest.declareRequiredPermissionWhenMutating()`(L335-358, D4) — 미선언 WRITE_REPOSITORY use case build fail | `actually-implemented` |
|
|
| ~~거부 → `AUTHZ_INSUFFICIENT_PERMISSION` 403 emit~~ | — | **RESOLVED(method-security 경로)**: `GlobalExceptionHandler#handleForbidden` 가 `SecurityErrorClassifier.classifyAccessDenied` 위임 → fine-grained code. `GlobalExceptionHandlerTest` | `actually-implemented` |
|
|
| **(잔존 #4, narrowed) authN→authz seam(JWT 필터체인) 미통합 검증** | **HTTP→method-security→403 envelope leg 는 `WorkLogAuthorizationE2ETest`(@WebMvcTest, 실 controller, positive+negative)로 RESOLVED.** 잔존은 그 *앞단* seam 뿐: `WorkLogAuthorizationE2ETest`/`WorkLogAuthorizationContractTest` 둘 다 `addFilters=false`(+`SecurityAutoConfiguration` 제외)로 JWT 필터를 끄고 `AuthenticatedUser` 직접 주입 → `JWT 디코딩→SecurityFilterChain→JwtToAuthenticatedUserConverter→AuthenticatedUser.roles→registry` seam 은 security-baseline 단위검증에 의존 | full `@SpringBootTest`(mock JWT 발급 + 실 필터체인 + method security)로 JWT 인증된 요청이 권한 없으면 403 envelope, 있으면 200 — authN→authz 통합 1회 | `needs-confirmation` (저위험; 머지 전 권고) |
|
|
| **(잔존 #2) AOP self-invocation/non-bean 우회** | D4 rule 은 annotation *존재* 만 보장, invocation-path 미검출(SS-AUTHZ-ARCH-C2). controller→usecase 는 proxy 경유라 현재 안전 | 모든 mutating use case 진입점이 Spring-managed bean 경유인지 정적/통합 검증 추가 | `planned` |
|
|
| **(잔존 #8) config drift → 정당 사용자 fail-closed(가용성)** | `RolePermissionProperties` startup-bound static. role 명 오타/IdP role 변경 시 정당 사용자도 deny(보안 아닌 가용성). startup 검증(알려진 role 집합 대조) 미구현 | startup 시 registry role 집합과 기대 role 대조 검증 추가 | `planned` |
|
|
| permission-centric RBAC 채택이 OWASP "prefer ABAC" 권고(OWASP-PM-C1)에 대한 정당한 trade-off | ~~자동조사 필요~~ → **근거 확보**: OWASP-PM-C3/C4/C5(permission abstraction + least-privilege). ABAC 는 정적 permission 규모에 YAGNI. 단 owner/relationship 기반 도메인 요구 시 재평가 | dynamic attribute(시간/owner) 실요구 등장 시 AuthorizationPort 구현체를 ABAC 로 교체(interface 불변) — migration 통합 테스트 | `needs-confirmation` |
|
|
| role→permission 매핑 source(app-config vs IdP claim) 기본값 적정 | Keycloak realm/client role 의 JWT claim 위치(KC-AUTHZ-C2)가 *engineering 수준* — official 재확인 필요. permission claim 직접 발급(UMA)은 scope 밖 | Keycloak official doc 으로 realm_access/resource_access claim 구조 재확인 + IdP realm 설정 확인 | `needs-confirmation` (KC-AUTHZ-C2) |
|
|
|
|
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
|
|
|
> `/coverage` 가 재생성하는 초안 — 손유지 금지. 기준: `rules/coverage-gate.md`. governing = security 클러스터 doc(인접) + project §35 D/E #5 가 열거하는 product-authz 관심사. **전용 canonical 은 미존재** — 향후 `/ingest` 시 `wiki/projects/ca-tmpl/authorization-rbac-method-security.md` 가 추출 대상(현재 governing_docs 는 nearest security doc → coverage-auditor 가 MIS-SCOPED 가능성 Advisory 로 평가).
|
|
|
|
| 관심사 | 상태 | owner | 심각도 | 근거 |
|
|
|--------|------|-------|--------|------|
|
|
| product authorization enforcement point(PEP) | covered-here | — | — | D1, D3 / §구현가이드 1 |
|
|
| permission/role 모델(permission-centric RBAC) | covered-here | — | — | D2, D6 / §구현가이드 3·5 |
|
|
| use-case 권한 선언 강제(`@RequiresPermission`) | covered-here | — | — | D4 / §구현가이드 2 |
|
|
| `AUTHZ_INSUFFICIENT_PERMISSION` 403 emission | covered-here | — | — | D5 / §구현가이드 4 |
|
|
| 3-tier access(authenticated≠authorized) | covered-here | — | — | D9 |
|
|
| sample authz 시연 | covered-here | — | — | D8 / §구현가이드 5 |
|
|
| JWT authN / `ROLE_*` 매핑 / 401·403 matrix / CORS | delegated | [[raw/branch-notes/feature-security-operational-baseline]] | OK | [[raw/branch-notes/feature-security-operational-baseline]] |
|
|
| repository infra-capability(`@UseCaseCapability`) | delegated | [[raw/branch-notes/feature-repository-access-permission-contract]] | OK | [[raw/branch-notes/feature-repository-access-permission-contract]] (out-of-scope: "runtime authorization 혼동") |
|
|
| cross-tenant authz(`AUTHZ_TENANT_MISMATCH`) | delegated | [[raw/branch-notes/feature-tenant-context-policy]] | OK | [[raw/branch-notes/feature-tenant-context-policy]] |
|
|
| ArchUnit rule suite host | covered-here(in host suite) | [[raw/branch-notes/feature-architecture-enforcement-rules]] | OK | D4(`mutating_use_cases_declare_required_permission`)+D1(`application_and_domain_do_not_depend_on_spring_security`) 가 host suite `CleanArchitectureTest` 에 실제 구현됨(REFERENCE ONLY 위임 해제). producer=본 branch / host=suite |
|
|
| `AUTHZ_INSUFFICIENT_PERMISSION` code 정의/registry | delegated | [[raw/branch-notes/feature-security-operational-baseline]] | OK | code SSOT=security-baseline(error-codes.yaml owner_branch 확인); 본 branch=emission producer. 명시 위임 = §진행 중 메모 "code SSOT 위임" |
|
|
| §25 SSOT Owner Map — product authz PEP row 등록 | delegated | (project note 갱신 작업) | 🟡 Should-fix | §25 에 본 branch row 부재 → §진행 중 메모 TODO 로 등록(project note 편집은 별도 작업) |
|
|
| `Category` enum(`AUTHZ`) SSOT | delegated | [[raw/branch-notes/feature-operational-error-observability-foundation]] | OK | [[raw/branch-notes/feature-operational-error-observability-foundation]] |
|
|
|
|
## 마주친 문제
|
|
|
|
- **2026-06-08 (resolved): method security 가 use case bean 을 JDK dynamic proxy 로 감싸 concrete-type 주입 실패.** `@EnableMethodSecurity` + custom Advisor 가 `@RequiresPermission` use case 를 proxy 할 때, isolated test context(@SpringBootTest classes=…, auto-config 없음)에서는 JDK interface proxy 가 생성돼 `WorkLogController`/test 가 주입하는 concrete `*UseCase` 타입에 assign 불가 → `BeanNotOfRequiredTypeException`. **원인**: Spring Boot 의 `AopAutoConfiguration` 이 prod 에서 `spring.aop.proxy-target-class=true`(CGLIB) 를 기본 설정하지만, auto-config 없는 슬라이스엔 그 기본이 안 적용됨. **해소**: contract test 의 nested config 에 `@EnableAspectJAutoProxy(proxyTargetClass = true)` 추가(prod 동작 mirror). prod 는 CaSkeletonApplication 의 `@SpringBootApplication` 이 CGLIB 보장하므로 영향 없음. → 자세히 [[raw/errors/method-security-cglib-vs-jdk-proxy-usecase-injection-2026-06-08]]
|
|
- **2026-06-08 (clarified): unauthenticated 호출은 method-security 단에서 `AccessDeniedException` 이 아니라 `AuthenticationException`(`AuthenticationCredentialsNotFoundException`).** method-security 의 deferred `Supplier<Authentication>.get()` 이 null authentication 을 만나면 401-family 예외를 던진다(403 아님). prod 에서는 filter chain(`.anyRequest().authenticated()`)이 그 전에 401 로 차단하므로 method-security 의 unauth 경로는 defense-in-depth backstop. contract test 는 이를 `isInstanceOf(AuthenticationException.class)` 로 단언(처음엔 AccessDeniedException 기대해 실패 → 정정). → [[raw/errors/method-security-cglib-vs-jdk-proxy-usecase-injection-2026-06-08]] 동일 노트에 기록
|
|
|
|
## Audit & Findings (2026-06-08 — 구현 대조 + findings 검증)
|
|
|
|
> `src/` 코드와 노트 self-report 를 대조한 결과. 사용자 작성 결정 영역은 자동 rewrite 하지 않고 정합/등급만 갱신.
|
|
|
|
### 구현 인벤토리 (as-built, `actually-implemented`)
|
|
|
|
| 구현 항목 | 파일 | Trace |
|
|
|---|---|---|
|
|
| `AuthorizationPort`(PEP) + `AuthorizationPrincipal`(raw roles) + `AuthorizationDeniedException` + `@RequiresPermission`(RUNTIME, Spring-free) | `application-core/.../security/` | D1, D4, §1·§3 |
|
|
| `Permission`(`resource:action` VO, 2-segment, 3-segment 거부) | `shared-contract/.../security/Permission.java` | D6 |
|
|
| `RequiresPermissionAuthorizationManager`(custom `AuthorizationManager<MethodInvocation>`, fail-closed) + `MethodSecurityConfig`(`@EnableMethodSecurity(prePostEnabled=false)` + `AuthorizationManagerBeforeMethodInterceptor` advisor) | `adapter-web/.../authz/` | D1, §2 |
|
|
| `RolePermissionRegistry`(lowercase normalize, 명시 열거/wildcard 없음) + `RolePermissionProperties`(`ca-skeleton.authz.role-permissions`, raw role key) + `AuthorizationAdapter`(fail-closed) | `adapter-web/.../authz/` | D2, D3, §3 |
|
|
| `handleForbidden` → `SecurityErrorClassifier.classifyAccessDenied` 위임 → `AUTHZ_INSUFFICIENT_PERMISSION` | `adapter-web/.../error/GlobalExceptionHandler.java` | D5, §4 |
|
|
| sample-portfolio `@RequiresPermission`: create/update/batch=`worklog:write`, delete=`worklog:close` (read 면제) + role bundle `user:{read,write}` / `admin:{read,write,close}`(application.yml) | `sample-portfolio/.../worklog/` + `app-bootstrap/application.yml` L180-182 | D8, §5 |
|
|
| **D4 ArchUnit**: `declareRequiredPermissionWhenMutating()`(미선언 WRITE use case build fail) + **D1 ArchUnit**: `application_and_domain_do_not_depend_on_spring_security` | `app-bootstrap/.../architecture/CleanArchitectureTest.java` L291·L335-358 | D4, D1 |
|
|
| 테스트: `PermissionTest` · `AuthorizationContractTest`(application-core) · `RolePermissionRegistryTest` · `AuthorizationAdapterTest` · `RolePermissionPropertiesTest`(binding) · `RequiresPermissionAuthorizationManagerTest` · `GlobalExceptionHandlerTest` · `WorkLogAuthorizationContractTest`(method-security 3-tier 시연, CGLIB pin) · **`WorkLogAuthorizationE2ETest`(@WebMvcTest, 실 `WorkLogController` DELETE 엔드포인트: HTTP→MVC→method-security→`AccessDeniedException`→`GlobalExceptionHandler`→403 envelope; positive(admin→204)+negative(user→403 `AUTHZ_INSUFFICIENT_PERMISSION`))** | 각 모듈 `src/test/` | — |
|
|
|
|
> 등급: 2026-06-08 `./gradlew check` GREEN(전 모듈 test + ArchUnit 49 rules + verifyCleanArchitectureDependencies + verifyPublicPathSnapshot) 실행 → 위 항목 `locally-verified`. D4 rule 은 비공허(non-vacuous) 검증까지 완료(DeleteWorkLogUseCase 어노테이션 제거 시 정확히 해당 rule 만 FAILED 후 복원). 미커밋 working tree. 잔존 미검증 = JWT 필터 seam(아래 Claims To Verify 잔존 #4) 뿐.
|
|
|
|
### Findings 검증 (사용자 제기 10항 대조)
|
|
|
|
| # | 사용자 주장 | 코드 대조 결과 |
|
|
|---|---|---|
|
|
| 1 | ArchUnit 강제 부재 → 인가 누락 silent + spring-security import 가드 없음 | **반증(FALSE)**: 둘 다 구현됨 — `declareRequiredPermissionWhenMutating()`(D4) 가 미선언 mutating use case build fail, `application_and_domain_do_not_depend_on_spring_security`(D1)가 import 차단. 위임 설계대로 host=architecture-enforcement suite 실현. (노트 §2 의 "REFERENCE ONLY/planned" 표기가 stale 이었음 → 정정함) |
|
|
| 2 | AOP proxy bypass | **부분 valid**: D4 rule 은 annotation *존재* 만 보장, self-invocation/non-bean *invocation-path* 우회는 미검출. 현 호출 경로(controller→usecase proxy)는 안전. → Claims To Verify 잔존 #2 |
|
|
| 3 | CGLIB/proxy-target-class 의존 | **valid, 기록됨**: [[raw/errors/method-security-cglib-vs-jdk-proxy-usecase-injection-2026-06-08]]. **설계 대안(미채택)**: controller 가 concrete `*UseCase` 아닌 input-port 인터페이스 주입 시 JDK proxy 로 충분 → CGLIB 하드 의존 제거 + §19 정합 ↑. 구현은 test 에 CGLIB 강제(prod mirror)로 핀 — 정당한 선택이나 근본 결합은 잔존 |
|
|
| 4 | E2E 전 경로 미검증 | **대부분 RESOLVED**: method-security→AuthorizationPort→registry + 3-tier 는 `WorkLogAuthorizationContractTest`, **HTTP→MVC→method-security→403 `AUTHZ_INSUFFICIENT_PERMISSION` envelope(positive admin→204 + negative user→403)는 `WorkLogAuthorizationE2ETest`(실 `WorkLogController` DELETE)** 가 검증. *잔존 seam* = JWT 필터체인→`JwtToAuthenticatedUserConverter`→`AuthenticatedUser.roles`(두 테스트 모두 `addFilters=false`로 principal 직접 주입) → Claims To Verify 잔존 #4(저위험, 머지 전 권고) |
|
|
| 5·6·7 | read 미적용 / IDOR·owner ABAC / cross-tenant | **valid(의도적 범위)**: D4 mutating-only, D2/D5 ABAC·IDOR 확장점, tenant 위임 — 노트 정합 |
|
|
| 8 | config drift fail-closed | **valid(가용성)**: 보안 아닌 가용성. startup known-role 검증 미구현 → Claims To Verify 잔존 #8 |
|
|
| 9 | every-request 비용 | valid(무시 가능): static config startup-bound, staleness 없음 |
|
|
| 10 | §25 SSOT Owner Map row 부재 | valid: project note 편집(본 branch 밖) — §진행 중 메모 TODO |
|
|
|
|
> **머지 전 실질 권고**(코드 작업): 1번(ArchUnit D4/D1)·4번의 HTTP→authz E2E 는 *이미 해소됨*(`WorkLogAuthorizationE2ETest`). 잔존 = (4-narrowed) authN→authz seam(full `@SpringBootTest` + mock JWT)·(2) invocation-path 가드 또는 (3) input-port 주입 전환 — 모두 저위험.
|
|
|
|
## 묶음 (이 branch에서 파생된 자료)
|
|
|
|
<!-- GENERATED: sources:start -->
|
|
- [[raw/company-tech-blogs/curity-oauth2-scope-vs-permission-naming]]
|
|
- [[raw/official-docs/aws-iam-google-iam-permission-naming-convention]]
|
|
- [[raw/official-docs/keycloak-authorization-services-realm-client-roles]]
|
|
- [[raw/official-docs/owasp-authz-permission-model-abac-rbac]]
|
|
- [[raw/official-docs/spring-security-authorization-architecture]]
|
|
<!-- GENERATED: sources:end -->
|
|
|
|
<!-- GENERATED: interviews:start -->
|
|
- [[raw/interviews/clean-architecture-method-authorization-without-spring-coupling-2026-06-08]]
|
|
<!-- GENERATED: interviews:end -->
|
|
|
|
<!-- GENERATED: errors:start -->
|
|
- [[raw/errors/method-security-cglib-vs-jdk-proxy-usecase-injection-2026-06-08]]
|
|
<!-- GENERATED: errors:end -->
|
|
|
|
<!-- GENERATED: blog-topics:start -->
|
|
- [[raw/blog-topics/framework-free-method-authorization-clean-architecture-2026-06-08]]
|
|
<!-- GENERATED: blog-topics:end -->
|
|
|
|
> 본 branch 는 hub. 파생 raw 누적 시 카테고리별 그룹화. 현재 leaf — 자동조사 산출 raw 가 §Sources 에 연결되면 아래 갱신.
|
|
|
|
### 근거 자료
|
|
|
|
- [[raw/official-docs/security-authorization-cheatsheet-owasp]] — OWASP-AUTHZ-C1~C7 (deny-by-default / authn-authz distinct / least-privilege / every-request / server-side / IDOR)
|
|
- [[raw/official-docs/spring-security-authorization-architecture]] — SS-AUTHZ-ARCH-C1~C6 (D1: custom `AuthorizationManager` / `@PreAuthorize` AOP coupling). 2026-06-08 `wiki-decision-researcher` 산출
|
|
- [[raw/official-docs/owasp-authz-permission-model-abac-rbac]] — OWASP-PM-C1~C6 (D2: permission-centric RBAC + ABAC counterclaim + least-privilege H+V). 2026-06-08 자동조사 산출
|
|
- [[raw/official-docs/aws-iam-google-iam-permission-naming-convention]] — IAM-NAMING-C1~C5 (D6: `resource:action` — AWS/Google IAM 비교). 2026-06-08 자동조사 산출
|
|
- [[raw/official-docs/keycloak-authorization-services-realm-client-roles]] — KC-AUTHZ-C1~C4 (D3: realm/client role JWT claim + UMA 대안). 2026-06-08 자동조사 산출
|
|
- [[raw/company-tech-blogs/curity-oauth2-scope-vs-permission-naming]] — CURITY-SCOPE-C1~C3 (D6: scope vs permission 분리 + colon naming, `company-case-study`). 2026-06-08 자동조사 산출
|
|
|
|
### 오류 기록 (이 branch 작업 중 발생)
|
|
|
|
- [[raw/errors/method-security-cglib-vs-jdk-proxy-usecase-injection-2026-06-08]] — method-security AOP proxy 가 use case 를 JDK interface proxy 로 감싸 concrete-type 주입 실패(CGLIB 강제로 해소) + unauthenticated→AuthenticationException(403 아님) 명확화. 2026-06-08 구현 중 발생, 둘 다 resolved.
|
|
|
|
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
|
|
|
- [[raw/interviews/clean-architecture-method-authorization-without-spring-coupling-2026-06-08]] — "왜 @PreAuthorize 안 쓰고 use-case AuthorizationPort 인가", "permission vs role 모델", "거부를 어떻게 403 으로 emit 하나(2-hop)", "AOP proxy bypass 위험" 등.
|
|
|
|
### 블로그·채용공고 연계 글감
|
|
|
|
- [[raw/blog-topics/framework-free-method-authorization-clean-architecture-2026-06-08]] — application layer 를 Spring-Security-free 로 유지하면서 method-level authorization 을 거는 패턴(annotation in core + AuthorizationManager in adapter).
|
|
|
|
## 관련 일일 노트
|
|
|
|
- (없음 — 구현 단계에서 누적)
|
|
|
|
## 완료 후 정리
|
|
|
|
> 머지/종료 시 채움. `/ingest`가 이 섹션 기준으로 wiki/projects/에 추출.
|
|
|
|
- PR 링크:
|
|
- 리뷰 메모:
|
|
- 머지 결과 / 배포 환경:
|
|
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출 — 전용 canonical 후보 `wiki/projects/ca-tmpl/authorization-rbac-method-security.md`):
|
|
- `actually-implemented` 항목:
|
|
- `locally-verified` 항목:
|
|
- `prod-verified` 항목:
|
|
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|