이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- URL 권한 검사와 Method Security는 각각 어떤 경계를 보호하는가?
- Controller 외부의 batch, message consumer, 내부 API가 Service를 호출할 때 권한 누락을 어떻게 막는가?
@PreAuthorize와 permission service를 어떻게 테스트 가능한 구조로 써야 하는가?
개요
URL 권한 검사는 HTTP 진입 경계를 보호한다.
Method Security는 Service method 같은 업무 행위 경계를 보호한다.
둘은 경쟁 관계가 아니다. URL rule은 “이 endpoint에 들어올 수 있는가”를 막고, method security는 “이 업무 action을 수행할 수 있는가”를 막는다.
Service가 Controller뿐 아니라 batch, message consumer, admin tool, 내부 API에서 재사용되면 method security가 특히 중요해진다.
공격 시나리오
주문 환불 기능이 있다.
외부 API는 다음 rule로 보호된다.
POST /api/orders/{orderId}/refunds requires ROLE_SUPPORT나중에 운영 편의를 위해 내부 재처리 endpoint가 추가된다.
POST /internal/refunds/retry이 endpoint는 내부망 전용이라며 URL rule이 느슨하다. 그런데 같은 refundService.forceRefund(orderId)를 호출한다.
Service method에 권한 검사가 없으면 새 진입점이 생길 때마다 우회 경로가 생긴다.
취약한 요청/응답 예시
POST /internal/refunds/retry HTTP/1.1
Host: api.internal.example.com
Authorization: Bearer low-privilege-internal-token
Content-Type: application/json
{
"orderId": 991,
"force": true
}HTTP/1.1 202 Accepted
Content-Type: application/json
{"refundId": 5011, "status": "REQUESTED"}Controller 단의 URL rule만 믿고 Service action을 보호하지 않으면 내부 API, batch, message consumer가 우회 경로가 된다.
주제별 핵심 판단
- Method Security는 Controller 경로뿐 아니라 Service 호출 경로의 정책을 보강한다.
- 내부 호출, self-invocation, batch 경로에서는 annotation만으로 충분하지 않을 수 있다.
개선된 요청/응답 예시
권한 검사는 두 층으로 둔다.
- 개선된 설계는 HTTP matcher, method security, domain policy가 같은 action 이름을 공유한다.
- 권한 실패는
AccessDeniedException으로 처리하고 응답과 감사 로그를 일관되게 남긴다.
방어 설계
HTTP boundary:
- endpoint 접근 가능 role
- 인증 방식
- CSRF/CORS/API gateway policy
business method boundary:
- actor can perform action on resource
- resource status allows action
- approval or MFA required
- audit decision표현식이 길어지면 annotation에 로직을 넣지 말고 permission service로 분리한다.
좋지 않음:
@PreAuthorize("hasRole('SUPPORT') and #amount < 10000 and ...")
나음:
@PreAuthorize("@refundPermission.canRequest(authentication, #orderId)")permission service는 단위 테스트와 통합 테스트가 가능해야 한다.
Spring/HTTP 예시
Method Security를 활성화한다.
@Configuration
@EnableMethodSecurity
class SecurityConfig {
}업무 action은 Service method에 둔다.
@Service
public class RefundService {
@PreAuthorize("@refundPermission.canRequest(authentication, #orderId)")
@Transactional
public RefundResult requestRefund(long orderId, RefundReason reason) {
Order order = orderRepository.getForUpdate(orderId);
Refund refund = refundPolicy.createRefund(order, reason);
audit.refundRequested(orderId, refund.id());
return RefundResult.from(refund);
}
}permission service는 resource와 principal을 함께 본다.
@Component
public class RefundPermission {
public boolean canRequest(Authentication authentication, long orderId) {
AccountPrincipal principal = (AccountPrincipal) authentication.getPrincipal();
return orderRepository.existsRefundableOrderForActor(
orderId,
principal.accountId(),
principal.authorities());
}
}Spring AOP 기반 method security는 proxy 경계를 지난 호출에서 동작한다. 같은 클래스 내부의 self-invocation이나 final/private method 설계는 별도로 주의한다.
운영 로그와 감사 지점
level=WARN event=method_authorization_denied result=blocked method=RefundService.requestRefund actor_id=1004 resource_type=order resource_id=991 action=request_refund reason=permission_service_denied trace_id=81aa20d0level=INFO event=method_authorization_allowed result=allowed method=RefundService.requestRefund actor_id=17 resource_type=order resource_id=991 action=request_refund decision_source=refundPermission trace_id=81aa20d1method security 실패는 403 응답만으로 끝내지 말고 어떤 method와 action이 막혔는지 남겨야 한다.
실전 판단 기준
- Controller endpoint rule과 Service method rule이 모두 테스트되는가?
- annotation이 붙은 메서드가 실제 프록시를 거쳐 호출되는지 확인한다.
- Repository 조건과 method policy가 서로 다른 결론을 내지 않는지 확인한다.
테스트 포인트
- 낮은 권한 principal이 Service method를 직접 호출하는 Spring 테스트에서 거부되는가?
- 내부 API와 message consumer가 같은 Service를 호출해도 method security가 동작하는가?
- permission service의 허용/거부 case가 단위 테스트로 분리되어 있는가?
- self-invocation 때문에 annotation이 우회되는 구조가 없는가?
- 거부 이벤트가 method, action, resource, reason을 남기는가?
위험 신호!
- URL rule이 있으니 Service 권한 검사는 필요 없다고 본다.
- 내부 API와 batch job은 신뢰한다며 권한 검사를 생략한다.
@PreAuthorize표현식이 너무 길어 정책을 테스트하기 어렵다.- 같은 클래스 내부 호출로 method security가 적용되지 않는다.
- permission service가 client-supplied
userId나role을 사용한다. - method security 실패가 access log에만 남고 감사 로그가 없다.
확인 질문
확인 질문
- URL 권한 검사와 Method Security의 차이는 무엇인가?
- URL 검사는 HTTP 진입 경계를 막고, method security는 업무 action 경계를 막는다.
- Service method에 권한 검사를 두는 이유는 무엇인가?
- 같은 업무 로직이 여러 진입점에서 호출될 때 권한 누락 우회 경로를 줄이기 위해서다.
- 표현식이 길어질 때 permission service로 빼야 하는 이유는 무엇인가?
- 정책을 이름 붙이고 테스트 가능하게 만들어 리뷰와 변경 비용을 줄이기 위해서다.