fix: honour the contract's nullable fields and its error codes
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a828b5d9fe
commit
c8a891c407
@@ -243,6 +243,47 @@ tasks.register('prepareStudioCodegenSpec') {
|
|||||||
}
|
}
|
||||||
collapseNullableOneOf(doc)
|
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 목록 수집
|
// (1) x-implements 주입 + union 목록 수집
|
||||||
def unions = [:]
|
def unions = [:]
|
||||||
schemas.each { String name, Object schema ->
|
schemas.each { String name, Object schema ->
|
||||||
|
|||||||
+2
-1
@@ -161,7 +161,8 @@ public class SecurityConfig {
|
|||||||
.deleteCookies(securitySettings.session().cookieName())
|
.deleteCookies(securitySettings.session().cookieName())
|
||||||
.logoutSuccessHandler(
|
.logoutSuccessHandler(
|
||||||
(request, response, authentication) ->
|
(request, response, authentication) ->
|
||||||
response.setStatus(jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT)));
|
response.setStatus(
|
||||||
|
jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT)));
|
||||||
}
|
}
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|||||||
+43
@@ -6,11 +6,14 @@ import dev.caskeleton.application.techlog.error.StudioException;
|
|||||||
import dev.caskeleton.shared.response.Envelope;
|
import dev.caskeleton.shared.response.Envelope;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.core.Ordered;
|
import org.springframework.core.Ordered;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.MissingServletRequestParameterException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
@@ -76,6 +79,46 @@ public class StudioExceptionHandler {
|
|||||||
return requestValidationFailed(ex.getName(), "Parameter value is invalid");
|
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<Envelope<Void>> handleBodyValidation(MethodArgumentNotValidException ex) {
|
||||||
|
List<Map<String, Object>> fieldErrors =
|
||||||
|
ex.getBindingResult().getFieldErrors().stream()
|
||||||
|
.map(
|
||||||
|
error ->
|
||||||
|
Map.<String, Object>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<Envelope<Void>> 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 details}를 계약의 {@code ValidationErrorDetails}({@code fieldErrors: [{path, message}]}) 모양에
|
||||||
* 맞춰 싣는다 — 자유형 {@code Object}로 아무 모양이나 실으면 계약의 {@code oneOf} 제약을 위반한다.
|
* 맞춰 싣는다 — 자유형 {@code Object}로 아무 모양이나 실으면 계약의 {@code oneOf} 제약을 위반한다.
|
||||||
|
|||||||
+12
-9
@@ -1,9 +1,6 @@
|
|||||||
package dev.caskeleton.adapter.inbound.web.techlog.auth;
|
package dev.caskeleton.adapter.inbound.web.techlog.auth;
|
||||||
|
|
||||||
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
|
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.ServletException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
@@ -15,6 +12,8 @@ import java.util.List;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
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.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.GrantedAuthority;
|
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.oauth2.core.oidc.user.OidcUser;
|
||||||
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
||||||
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
|
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OIDC 로그인 결과를 세션이 담을 수 있는 형태로 바꾼다.
|
* OIDC 로그인 결과를 세션이 담을 수 있는 형태로 바꾼다.
|
||||||
*
|
*
|
||||||
* <p>{@code oauth2Login} 이 만드는 {@code OAuth2AuthenticationToken} 의 principal 은 {@code OidcUser} 다.
|
* <p>{@code oauth2Login} 이 만드는 {@code OAuth2AuthenticationToken} 의 principal 은 {@code OidcUser} 다.
|
||||||
* 그런데 {@code PrimitiveSessionSecurityContextRepository#saveContext} 는 principal 이 {@link
|
* 그런데 {@code PrimitiveSessionSecurityContextRepository#saveContext} 는 principal 이 {@link
|
||||||
* AuthenticatedPrincipal} 이 아니면 거부한다 — 자격증명·토큰·프레임워크 객체 그래프가 세션 직렬화 경계를
|
* AuthenticatedPrincipal} 이 아니면 거부한다 — 자격증명·토큰·프레임워크 객체 그래프가 세션 직렬화 경계를 넘지 못하게 하는 의도적인 제약이다. 그래서
|
||||||
* 넘지 못하게 하는 의도적인 제약이다. 그래서 로그인 직후 여기서 claim 만 뽑아 {@code AuthenticatedPrincipal}
|
* 로그인 직후 여기서 claim 만 뽑아 {@code AuthenticatedPrincipal} 로 갈아끼운다. 세션에 남는 것은 sub·email·role 뿐이고
|
||||||
* 로 갈아끼운다. 세션에 남는 것은 sub·email·role 뿐이고 ID/Access 토큰은 남지 않는다.
|
* ID/Access 토큰은 남지 않는다.
|
||||||
*
|
*
|
||||||
* <p>역할 추출은 {@code JwtToAuthenticatedPrincipalConverter} 와 같은 규칙이다 — Keycloak 의 {@code
|
* <p>역할 추출은 {@code JwtToAuthenticatedPrincipalConverter} 와 같은 규칙이다 — Keycloak 의 {@code
|
||||||
* realm_access.roles} 와 {@code resource_access[*].roles} 를 합집합으로 본다. 두 경로(JWT 검증과 세션 로그인)가
|
* realm_access.roles} 와 {@code resource_access[*].roles} 를 합집합으로 본다. 두 경로(JWT 검증과 세션 로그인)가 같은 역할
|
||||||
* 같은 역할 집합을 만들어야 {@code studio:read}/{@code studio:write} 매핑이 모드와 무관하게 동일하게 걸린다.
|
* 집합을 만들어야 {@code studio:read}/{@code studio:write} 매핑이 모드와 무관하게 동일하게 걸린다.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session")
|
@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);
|
new AuthenticatedPrincipal(user.getSubject(), user.getEmail(), roles);
|
||||||
Collection<GrantedAuthority> authorities =
|
Collection<GrantedAuthority> authorities =
|
||||||
roles.stream()
|
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));
|
.collect(java.util.stream.Collectors.toCollection(ArrayList::new));
|
||||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||||
context.setAuthentication(
|
context.setAuthentication(
|
||||||
|
|||||||
+14
-15
@@ -11,26 +11,26 @@ import org.springframework.session.data.redis.RedisSessionRepository;
|
|||||||
/**
|
/**
|
||||||
* {@code auth-mode=redis-session} 의 세션 저장소.
|
* {@code auth-mode=redis-session} 의 세션 저장소.
|
||||||
*
|
*
|
||||||
* <p>이 모드는 Studio 계약이 선언한 표면이다 — {@code securitySchemes.sessionCookie} 는 세션 쿠키를, mutation
|
* <p>이 모드는 Studio 계약이 선언한 표면이다 — {@code securitySchemes.sessionCookie} 는 세션 쿠키를, mutation 은 추가로
|
||||||
* 은 추가로 {@code X-CSRF-TOKEN} 헤더를 요구한다. SPA 가 토큰을 직접 들지 않고 백엔드가 세션을 소유하는 BFF
|
* {@code X-CSRF-TOKEN} 헤더를 요구한다. SPA 가 토큰을 직접 들지 않고 백엔드가 세션을 소유하는 BFF 구성이며, {@code SecurityConfig}
|
||||||
* 구성이며, {@code SecurityConfig} 의 {@code REDIS_SESSION} 분기(CSRF 쿠키 저장소 + 세션 고정 방지)와 {@code
|
* 의 {@code REDIS_SESSION} 분기(CSRF 쿠키 저장소 + 세션 고정 방지)와 {@code RedisSessionWebConfig}(서블릿 세션 필터 +
|
||||||
* RedisSessionWebConfig}(서블릿 세션 필터 + host-only 쿠키)는 이미 그 전제로 쓰여 있었다.
|
* host-only 쿠키)는 이미 그 전제로 쓰여 있었다.
|
||||||
*
|
*
|
||||||
* <p>빠져 있던 조각은 저장소 하나뿐이다. {@code AuthenticationModeCompositionConfig} 가 이 모드에서
|
* <p>빠져 있던 조각은 저장소 하나뿐이다. {@code AuthenticationModeCompositionConfig} 가 이 모드에서 {@code
|
||||||
* {@code redisVersionedSessionRepository} 와 {@code springSessionRepositoryFilter} 를 <em>이름으로</em>
|
* redisVersionedSessionRepository} 와 {@code springSessionRepositoryFilter} 를 <em>이름으로</em> 요구하는데,
|
||||||
* 요구하는데, 뒤의 것은 {@code RedisSessionWebConfig} 의 {@code @EnableSpringHttpSession} 이 이미 등록하고
|
* 뒤의 것은 {@code RedisSessionWebConfig} 의 {@code @EnableSpringHttpSession} 이 이미 등록하고 있었고 앞의 것이 어디에도
|
||||||
* 있었고 앞의 것이 어디에도 없었다. 그래서 {@code getStudioSession} 이 항상 503 이었다.
|
* 없었다. 그래서 {@code getStudioSession} 이 항상 503 이었다.
|
||||||
*/
|
*/
|
||||||
@Configuration(proxyBeanMethods = false)
|
@Configuration(proxyBeanMethods = false)
|
||||||
@ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session")
|
@ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session")
|
||||||
public class StudioSessionInfrastructureConfig {
|
public class StudioSessionInfrastructureConfig {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이름이 계약이다 — {@code AuthenticationModeCompositionConfig#validate} 가 이 문자열을 찾는다. 이름을
|
* 이름이 계약이다 — {@code AuthenticationModeCompositionConfig#validate} 가 이 문자열을 찾는다. 이름을 바꾸면 부팅이
|
||||||
* 바꾸면 부팅이 "Redis Session repository/filter is incomplete" 로 실패한다.
|
* "Redis Session repository/filter is incomplete" 로 실패한다.
|
||||||
*
|
*
|
||||||
* <p>{@code @EnableRedisHttpSession} 을 쓰지 않는 이유도 같다 — 그 애노테이션은 빈 이름을
|
* <p>{@code @EnableRedisHttpSession} 을 쓰지 않는 이유도 같다 — 그 애노테이션은 빈 이름을 {@code sessionRepository} 로
|
||||||
* {@code sessionRepository} 로 고정한다.
|
* 고정한다.
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public RedisSessionRepository redisVersionedSessionRepository(
|
public RedisSessionRepository redisVersionedSessionRepository(
|
||||||
@@ -39,9 +39,8 @@ public class StudioSessionInfrastructureConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 키는 문자열로, 값은 기본 JDK 직렬화로 둔다. 세션에 들어가는 것은 {@code
|
* 키는 문자열로, 값은 기본 JDK 직렬화로 둔다. 세션에 들어가는 것은 {@code PrimitiveSessionSecurityContextRepository} 가 만든
|
||||||
* PrimitiveSessionSecurityContextRepository} 가 만든 원시 스냅샷뿐이라(자격증명·토큰·프레임워크 객체가
|
* 원시 스냅샷뿐이라(자격증명·토큰·프레임워크 객체가 직렬화 경계를 넘지 않는다) 값 직렬화기를 따로 좁힐 필요가 없다.
|
||||||
* 직렬화 경계를 넘지 않는다) 값 직렬화기를 따로 좁힐 필요가 없다.
|
|
||||||
*/
|
*/
|
||||||
private static RedisTemplate<String, Object> sessionRedisTemplate(
|
private static RedisTemplate<String, Object> sessionRedisTemplate(
|
||||||
RedisConnectionFactory connectionFactory) {
|
RedisConnectionFactory connectionFactory) {
|
||||||
|
|||||||
Reference in New Issue
Block a user