From c8a891c407330fa1b889909ba823a0b1ecb369b9 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 01:49:05 +0900 Subject: [PATCH] fix: honour the contract's nullable fields and its error codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two contract mismatches, both found by driving the API and both invisible from inside the repository because nothing compares the wire to the spec. Nullable-but-required. The contract says required means "the key is present", not "the value is set" — WorkingCopyInputBase spells it out: "불완전한 초안도 저장할 수 있어야 하므로 필드는 required이되 빈 값과 null을 허용한다". The generator moves `required` straight to @NotNull, so topicId, projectId, lastVerifiedOn, verifiedOn, decidedOn, decisionStatus and questionStatus all became non-null, and saving a partial draft failed: {"projectId": null} → 400 NOT_NULL "Required value is missing" prepareStudioCodegenSpec already derives a codegen-only copy of the spec, so the relaxation happens there — 33 properties leave `required` in that copy and the canonical file is untouched, which matters because the frontend reads the same file and its reading is the correct one. Value constraints stay: title still carries @NotNull @Size(max = 120). all-null / omitted / empty slug 201 title 121 chars 422 slug "Bad Slug!" 422 Error codes. Body validation fell through to the template's handler and answered 400 VALIDATION_FAILED, a code the Studio contract does not declare (it knows REQUEST_VALIDATION_FAILED and DOCUMENT_VALIDATION_ FAILED); denials answered AUTHZ_INSUFFICIENT_PERMISSION where the contract assigns STUDIO_ACCESS_DENIED to 403. The frontend validates the envelope's code against an enum, so an undeclared code breaks parsing rather than surfacing as the error it is. Both now map in StudioExceptionHandler, which is already scoped to the techlog package so fileserver and healthcheck keep their existing shapes. body validation 422 REQUEST_VALIDATION_FAILED denial 403 STUDIO_ACCESS_DENIED Co-Authored-By: Claude Opus 5 (1M context) --- src/adapter/inbound/web/build.gradle | 41 ++++++++++++++++++ .../inbound/web/auth/SecurityConfig.java | 3 +- .../web/techlog/StudioExceptionHandler.java | 43 +++++++++++++++++++ .../auth/StudioOidcLoginSuccessHandler.java | 21 +++++---- .../StudioSessionInfrastructureConfig.java | 29 ++++++------- 5 files changed, 112 insertions(+), 25 deletions(-) diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index 9bdc136..f5e3012 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -243,6 +243,47 @@ tasks.register('prepareStudioCodegenSpec') { } collapseNullableOneOf(doc) + // (4b) `type: [X, "null"]` 인 필드는 required 목록에서 뺀다. + // + // 계약이 이 필드들을 required 로 두는 뜻은 "키가 있어야 한다"이지 "값이 있어야 한다"가 + // 아니다 — WorkingCopyInputBase 의 주석이 그렇게 못박고 있다("불완전한 초안도 저장할 수 + // 있어야 하므로 필드는 required 이되 빈 값과 null 을 허용한다"). 그런데 생성기는 required + // 를 그대로 @NotNull 로 옮긴다. 그래서 topicId/projectId/lastVerifiedOn/verifiedOn/ + // decidedOn/decisionStatus/questionStatus 가 전부 non-null 강제가 되고, 초안 저장이 + // 400 NOT_NULL 로 거부됐다(실측: {"projectId": null} → NOT_NULL "Required value is missing"). + // + // 원본 계약은 건드리지 않는다 — 프론트엔드가 같은 파일을 읽고, 그쪽 해석은 옳다. 코드젠 + // 사본에서만 required 를 벗겨 @NotNull 이 붙지 않게 한다. 값 제약(형식·길이·enum)은 + // 그대로 남는다. + int[] relaxed = [0] + def relaxNullableRequired + relaxNullableRequired = { Object node -> + if (node instanceof Map) { + def props = node.get('properties') + def required = node.get('required') + if (props instanceof Map && required instanceof List) { + def drop = [] + props.each { Object name, Object schema -> + if (!(schema instanceof Map)) return + def type = schema.get('type') + if (type instanceof List && type.contains('null') && required.contains(name)) { + drop << name + } + } + if (!drop.isEmpty()) { + required.removeAll(drop) + relaxed[0] += drop.size() + if (required.isEmpty()) node.remove('required') + } + } + new ArrayList(node.values()).each { relaxNullableRequired(it) } + } else if (node instanceof List) { + node.each { relaxNullableRequired(it) } + } + } + relaxNullableRequired(doc) + logger.lifecycle("prepareStudioCodegenSpec: nullable required 해제 ${relaxed[0]}건") + // (1) x-implements 주입 + union 목록 수집 def unions = [:] schemas.each { String name, Object schema -> diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java index f2b4fd9..240ef01 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java @@ -161,7 +161,8 @@ public class SecurityConfig { .deleteCookies(securitySettings.session().cookieName()) .logoutSuccessHandler( (request, response, authentication) -> - response.setStatus(jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT))); + response.setStatus( + jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT))); } return http.build(); } diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java index bd05a70..5c10a4f 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/StudioExceptionHandler.java @@ -6,11 +6,14 @@ import dev.caskeleton.application.techlog.error.StudioException; import dev.caskeleton.shared.response.Envelope; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.ResponseEntity; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -76,6 +79,46 @@ public class StudioExceptionHandler { return requestValidationFailed(ex.getName(), "Parameter value is invalid"); } + /** + * 요청 본문 bean validation 실패(예: {@code title} 120자 초과). {@code GlobalExceptionHandler}도 이 예외를 처리하지만 + * 400 {@code OperationalError.VALIDATION_FAILED}를 낸다 — Studio 계약에 없는 코드이고 (계약이 아는 것은 {@code + * REQUEST_VALIDATION_FAILED}와 {@code DOCUMENT_VALIDATION_FAILED}뿐이다), 상태도 계약이 본문 검증 실패에 배정한 422가 + * 아니다. 프론트엔드는 봉투의 {@code code}를 enum으로 검증하므로 계약 밖 코드는 응답 파싱 자체를 깨뜨린다. studio 스코프에서 계약 코드로 옮긴다. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleBodyValidation(MethodArgumentNotValidException ex) { + List> fieldErrors = + ex.getBindingResult().getFieldErrors().stream() + .map( + error -> + Map.of( + "path", + "/" + error.getField(), + "message", + error.getDefaultMessage() == null + ? "Value is invalid" + : error.getDefaultMessage())) + .collect(Collectors.toList()); + return ErrorResponseFactory.envelope( + StudioError.REQUEST_VALIDATION_FAILED, + StudioClientSafeMessages.forError(StudioError.REQUEST_VALIDATION_FAILED), + Map.of("fieldErrors", fieldErrors)); + } + + /** + * 권한 부족. 스켈레톤의 분류기는 {@code AUTHZ_INSUFFICIENT_PERMISSION}을 내지만 계약이 403에 배정한 코드는 {@code + * STUDIO_ACCESS_DENIED}다({@code responses.AccessDenied.x-error-codes}). 상태는 그대로 403이고 코드만 계약 쪽으로 + * 옮긴다. + */ + @ExceptionHandler(AuthorizationDeniedException.class) + public ResponseEntity> handleAccessDenied(AuthorizationDeniedException ex) { + log.warn("studio access denied: {}", ex.getMessage()); + return ErrorResponseFactory.envelope( + StudioError.STUDIO_ACCESS_DENIED, + StudioClientSafeMessages.forError(StudioError.STUDIO_ACCESS_DENIED), + null); + } + /** * {@code details}를 계약의 {@code ValidationErrorDetails}({@code fieldErrors: [{path, message}]}) 모양에 * 맞춰 싣는다 — 자유형 {@code Object}로 아무 모양이나 실으면 계약의 {@code oneOf} 제약을 위반한다. diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java index e7c131d..0d9c5ad 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/auth/StudioOidcLoginSuccessHandler.java @@ -1,9 +1,6 @@ package dev.caskeleton.adapter.inbound.web.techlog.auth; import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -15,6 +12,8 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; @@ -24,19 +23,20 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; /** * OIDC 로그인 결과를 세션이 담을 수 있는 형태로 바꾼다. * *

{@code oauth2Login} 이 만드는 {@code OAuth2AuthenticationToken} 의 principal 은 {@code OidcUser} 다. * 그런데 {@code PrimitiveSessionSecurityContextRepository#saveContext} 는 principal 이 {@link - * AuthenticatedPrincipal} 이 아니면 거부한다 — 자격증명·토큰·프레임워크 객체 그래프가 세션 직렬화 경계를 - * 넘지 못하게 하는 의도적인 제약이다. 그래서 로그인 직후 여기서 claim 만 뽑아 {@code AuthenticatedPrincipal} - * 로 갈아끼운다. 세션에 남는 것은 sub·email·role 뿐이고 ID/Access 토큰은 남지 않는다. + * AuthenticatedPrincipal} 이 아니면 거부한다 — 자격증명·토큰·프레임워크 객체 그래프가 세션 직렬화 경계를 넘지 못하게 하는 의도적인 제약이다. 그래서 + * 로그인 직후 여기서 claim 만 뽑아 {@code AuthenticatedPrincipal} 로 갈아끼운다. 세션에 남는 것은 sub·email·role 뿐이고 + * ID/Access 토큰은 남지 않는다. * *

역할 추출은 {@code JwtToAuthenticatedPrincipalConverter} 와 같은 규칙이다 — Keycloak 의 {@code - * realm_access.roles} 와 {@code resource_access[*].roles} 를 합집합으로 본다. 두 경로(JWT 검증과 세션 로그인)가 - * 같은 역할 집합을 만들어야 {@code studio:read}/{@code studio:write} 매핑이 모드와 무관하게 동일하게 걸린다. + * realm_access.roles} 와 {@code resource_access[*].roles} 를 합집합으로 본다. 두 경로(JWT 검증과 세션 로그인)가 같은 역할 + * 집합을 만들어야 {@code studio:read}/{@code studio:write} 매핑이 모드와 무관하게 동일하게 걸린다. */ @Component @ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session") @@ -63,7 +63,10 @@ public class StudioOidcLoginSuccessHandler implements AuthenticationSuccessHandl new AuthenticatedPrincipal(user.getSubject(), user.getEmail(), roles); Collection authorities = roles.stream() - .map(r -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + r.toUpperCase(Locale.ROOT))) + .map( + r -> + (GrantedAuthority) + new SimpleGrantedAuthority("ROLE_" + r.toUpperCase(Locale.ROOT))) .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication( diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java index 66c1960..c57c6c2 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioSessionInfrastructureConfig.java @@ -11,26 +11,26 @@ import org.springframework.session.data.redis.RedisSessionRepository; /** * {@code auth-mode=redis-session} 의 세션 저장소. * - *

이 모드는 Studio 계약이 선언한 표면이다 — {@code securitySchemes.sessionCookie} 는 세션 쿠키를, mutation - * 은 추가로 {@code X-CSRF-TOKEN} 헤더를 요구한다. SPA 가 토큰을 직접 들지 않고 백엔드가 세션을 소유하는 BFF - * 구성이며, {@code SecurityConfig} 의 {@code REDIS_SESSION} 분기(CSRF 쿠키 저장소 + 세션 고정 방지)와 {@code - * RedisSessionWebConfig}(서블릿 세션 필터 + host-only 쿠키)는 이미 그 전제로 쓰여 있었다. + *

이 모드는 Studio 계약이 선언한 표면이다 — {@code securitySchemes.sessionCookie} 는 세션 쿠키를, mutation 은 추가로 + * {@code X-CSRF-TOKEN} 헤더를 요구한다. SPA 가 토큰을 직접 들지 않고 백엔드가 세션을 소유하는 BFF 구성이며, {@code SecurityConfig} + * 의 {@code REDIS_SESSION} 분기(CSRF 쿠키 저장소 + 세션 고정 방지)와 {@code RedisSessionWebConfig}(서블릿 세션 필터 + + * host-only 쿠키)는 이미 그 전제로 쓰여 있었다. * - *

빠져 있던 조각은 저장소 하나뿐이다. {@code AuthenticationModeCompositionConfig} 가 이 모드에서 - * {@code redisVersionedSessionRepository} 와 {@code springSessionRepositoryFilter} 를 이름으로 - * 요구하는데, 뒤의 것은 {@code RedisSessionWebConfig} 의 {@code @EnableSpringHttpSession} 이 이미 등록하고 - * 있었고 앞의 것이 어디에도 없었다. 그래서 {@code getStudioSession} 이 항상 503 이었다. + *

빠져 있던 조각은 저장소 하나뿐이다. {@code AuthenticationModeCompositionConfig} 가 이 모드에서 {@code + * redisVersionedSessionRepository} 와 {@code springSessionRepositoryFilter} 를 이름으로 요구하는데, + * 뒤의 것은 {@code RedisSessionWebConfig} 의 {@code @EnableSpringHttpSession} 이 이미 등록하고 있었고 앞의 것이 어디에도 + * 없었다. 그래서 {@code getStudioSession} 이 항상 503 이었다. */ @Configuration(proxyBeanMethods = false) @ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session") public class StudioSessionInfrastructureConfig { /** - * 이름이 계약이다 — {@code AuthenticationModeCompositionConfig#validate} 가 이 문자열을 찾는다. 이름을 - * 바꾸면 부팅이 "Redis Session repository/filter is incomplete" 로 실패한다. + * 이름이 계약이다 — {@code AuthenticationModeCompositionConfig#validate} 가 이 문자열을 찾는다. 이름을 바꾸면 부팅이 + * "Redis Session repository/filter is incomplete" 로 실패한다. * - *

{@code @EnableRedisHttpSession} 을 쓰지 않는 이유도 같다 — 그 애노테이션은 빈 이름을 - * {@code sessionRepository} 로 고정한다. + *

{@code @EnableRedisHttpSession} 을 쓰지 않는 이유도 같다 — 그 애노테이션은 빈 이름을 {@code sessionRepository} 로 + * 고정한다. */ @Bean public RedisSessionRepository redisVersionedSessionRepository( @@ -39,9 +39,8 @@ public class StudioSessionInfrastructureConfig { } /** - * 키는 문자열로, 값은 기본 JDK 직렬화로 둔다. 세션에 들어가는 것은 {@code - * PrimitiveSessionSecurityContextRepository} 가 만든 원시 스냅샷뿐이라(자격증명·토큰·프레임워크 객체가 - * 직렬화 경계를 넘지 않는다) 값 직렬화기를 따로 좁힐 필요가 없다. + * 키는 문자열로, 값은 기본 JDK 직렬화로 둔다. 세션에 들어가는 것은 {@code PrimitiveSessionSecurityContextRepository} 가 만든 + * 원시 스냅샷뿐이라(자격증명·토큰·프레임워크 객체가 직렬화 경계를 넘지 않는다) 값 직렬화기를 따로 좁힐 필요가 없다. */ private static RedisTemplate sessionRedisTemplate( RedisConnectionFactory connectionFactory) {