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:
DongHyeonka
2026-08-20 01:49:05 +09:00
co-authored by Claude Opus 5
parent a828b5d9fe
commit c8a891c407
5 changed files with 112 additions and 25 deletions
+41
View File
@@ -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 ->
@@ -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();
}
@@ -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<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 Object}로 아무 모양이나 실으면 계약의 {@code oneOf} 제약을 위반한다.
@@ -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 로그인 결과를 세션이 담을 수 있는 형태로 바꾼다.
*
* <p>{@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 토큰은 남지 않는다.
*
* <p>역할 추출은 {@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<GrantedAuthority> 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(
@@ -11,26 +11,26 @@ import org.springframework.session.data.redis.RedisSessionRepository;
/**
* {@code auth-mode=redis-session} 의 세션 저장소.
*
* <p>이 모드는 Studio 계약이 선언한 표면이다 — {@code securitySchemes.sessionCookie} 는 세션 쿠키를, mutation
* 은 추가로 {@code X-CSRF-TOKEN} 헤더를 요구한다. SPA 가 토큰을 직접 들지 않고 백엔드가 세션을 소유하는 BFF
* 구성이며, {@code SecurityConfig} 의 {@code REDIS_SESSION} 분기(CSRF 쿠키 저장소 + 세션 고정 방지)와 {@code
* RedisSessionWebConfig}(서블릿 세션 필터 + host-only 쿠키)는 이미 그 전제로 쓰여 있었다.
* <p>이 모드는 Studio 계약이 선언한 표면이다 — {@code securitySchemes.sessionCookie} 는 세션 쿠키를, mutation 은 추가로
* {@code X-CSRF-TOKEN} 헤더를 요구한다. SPA 가 토큰을 직접 들지 않고 백엔드가 세션을 소유하는 BFF 구성이며, {@code SecurityConfig}
* 의 {@code REDIS_SESSION} 분기(CSRF 쿠키 저장소 + 세션 고정 방지)와 {@code RedisSessionWebConfig}(서블릿 세션 필터 +
* host-only 쿠키)는 이미 그 전제로 쓰여 있었다.
*
* <p>빠져 있던 조각은 저장소 하나뿐이다. {@code AuthenticationModeCompositionConfig} 가 이 모드에서
* {@code redisVersionedSessionRepository} 와 {@code springSessionRepositoryFilter} 를 <em>이름으로</em>
* 요구하는데, 뒤의 것은 {@code RedisSessionWebConfig} 의 {@code @EnableSpringHttpSession} 이 이미 등록하고
* 있었고 앞의 것이 어디에도 없었다. 그래서 {@code getStudioSession} 이 항상 503 이었다.
* <p>빠져 있던 조각은 저장소 하나뿐이다. {@code AuthenticationModeCompositionConfig} 가 이 모드에서 {@code
* redisVersionedSessionRepository} 와 {@code springSessionRepositoryFilter} 를 <em>이름으로</em> 요구하는데,
* 뒤의 것은 {@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" 로 실패한다.
*
* <p>{@code @EnableRedisHttpSession} 을 쓰지 않는 이유도 같다 — 그 애노테이션은 빈 이름을
* {@code sessionRepository} 로 고정한다.
* <p>{@code @EnableRedisHttpSession} 을 쓰지 않는 이유도 같다 — 그 애노테이션은 빈 이름을 {@code sessionRepository} 로
* 고정한다.
*/
@Bean
public RedisSessionRepository redisVersionedSessionRepository(
@@ -39,9 +39,8 @@ public class StudioSessionInfrastructureConfig {
}
/**
* 키는 문자열로, 값은 기본 JDK 직렬화로 둔다. 세션에 들어가는 것은 {@code
* PrimitiveSessionSecurityContextRepository} 가 만든 원시 스냅샷뿐이라(자격증명·토큰·프레임워크 객체가
* 직렬화 경계를 넘지 않는다) 값 직렬화기를 따로 좁힐 필요가 없다.
* 키는 문자열로, 값은 기본 JDK 직렬화로 둔다. 세션에 들어가는 것은 {@code PrimitiveSessionSecurityContextRepository} 가 만든
* 원시 스냅샷뿐이라(자격증명·토큰·프레임워크 객체가 직렬화 경계를 넘지 않는다) 값 직렬화기를 따로 좁힐 필요가 없다.
*/
private static RedisTemplate<String, Object> sessionRedisTemplate(
RedisConnectionFactory connectionFactory) {