fix: make Studio authorization actually work, and stop it failing as a 500

Four linked defects, found by driving the 19 operations against a running
backend on PostgreSQL behind a real Keycloak realm.

1. The role→permission mapping never bound. Both profiles wrote it as

     role-permissions:
       ${APP_STUDIO_AUTHOR_ROLE:studio-author}:
         - studio:write

   and Spring Boot resolves placeholders in @ConfigurationProperties
   *values*, not in Map *keys* — the key bound as the literal
   "${APP_STUDIO_AUTHOR_ROLE:studio-author}", matched no real role, and
   left RolePermissionRegistry empty. Every Studio write answered 403, in
   local and prod alike. Setting APP_STUDIO_AUTHOR_ROLE explicitly did not
   help; a literal key returned 201 immediately. StudioAuthzEnvironmentPost
   Processor now resolves the role name as a scalar (where placeholders do
   work) and contributes the mapping under a literal key, so the name stays
   deployment-configurable. Registered the same way the tracing bridge is.

2. Reads were unguarded. Only WRITE carried @RequiresPermission, so any
   authenticated caller could list every draft and fetch one by id:

     listStudioDocuments  200, 2 drafts     getStudioDocument  200
     getStudioDashboard   200               listStudioAssets   200

   The nine read use cases now declare studio:read. They lose `final` for
   the same CGLIB reason the write ones already document.

3. Failures were masked. IdempotencyExecutor's catch called store.discard,
   whose @Modifying bulk delete needs a transaction and had none, so it
   threw TransactionRequiredException over the original exception — the 403
   above surfaced as 500 INTERNAL_ERROR with no cause in the log, which is
   why this shipped. discard now runs REQUIRES_NEW (cleanup must survive
   the failed work's rollback) and a cleanup failure is attached with
   addSuppressed instead of replacing what actually went wrong.

4. Reservations leaked. With discard throwing every time, failed requests
   left their idempotency rows behind. After the fix only the successful
   call's COMPLETED row remains.

Verified end to end: studio-author writes with no extra configuration;
an unprivileged caller gets 403 on all five read operations and on write;
create → save → validate → preview → publish → unpublish all succeed;
optimistic lock returns 409 VERSION_CONFLICT; the publication reaches
public_resource_projection and flips to WITHDRAWN on unpublish.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-20 00:14:18 +09:00
co-authored by Claude Opus 5
parent d5889d644a
commit 37d5614129
16 changed files with 159 additions and 32 deletions
@@ -105,7 +105,11 @@ public final class IdempotencyExecutor {
store.complete(scope, new StoredResponse(codec.serialize(result)));
return result;
} catch (RuntimeException e) {
store.discard(scope);
try {
store.discard(scope);
} catch (RuntimeException cleanupFailure) {
e.addSuppressed(cleanupFailure);
}
throw e;
}
}
@@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.PreviewDetailView;
@@ -23,11 +24,17 @@ import java.util.Objects;
* {@code getCurrentStudioPreview}. Preview 상태({@code CURRENT}/{@code STALE}/{@code EXPIRED})는 서버가
* 계산한다(계약 설명, spec §7.3) — 프론트가 여러 값을 조합해 재추론하지 않는다.
*/
/*
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
* 같은 이유다(CreateStudioDocumentUseCase 참조).
*/
@RequiresPermission(StudioPermissions.READ)
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetCurrentStudioPreviewUseCase
public class GetCurrentStudioPreviewUseCase
implements QueryUseCase<GetPreviewQuery, PreviewDetailView> {
private final StudioDocumentLoader documents;
@@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.AssetDetailView;
@@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/** {@code getStudioAsset}. */
/*
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
* 같은 이유다(CreateStudioDocumentUseCase 참조).
*/
@RequiresPermission(StudioPermissions.READ)
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetStudioAssetUseCase implements QueryUseCase<GetAssetQuery, AssetDetailView> {
public class GetStudioAssetUseCase implements QueryUseCase<GetAssetQuery, AssetDetailView> {
private final AssetRepositoryPort assets;
private final TransactionPort transactions;
@@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.studio.model.DashboardView;
import dev.caskeleton.application.techlog.studio.model.NextAction;
import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort;
@@ -19,12 +20,17 @@ import java.util.Objects;
* {@code getStudioDashboard}. {@code nextAction} 을 포함한 모든 workflow 상태는 서버가 계산한다 — 프론트가 여러 endpoint
* 를 조합해 재추론하지 않는다(계약 설명).
*/
/*
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
* 같은 이유다(CreateStudioDocumentUseCase 참조).
*/
@RequiresPermission(StudioPermissions.READ)
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetStudioDashboardUseCase
implements QueryUseCase<GetDashboardQuery, DashboardView> {
public class GetStudioDashboardUseCase implements QueryUseCase<GetDashboardQuery, DashboardView> {
/** 계약 {@code StudioDashboard} 의 각 목록 maxItems. */
private static final int SECTION_SIZE = 5;
@@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView;
@@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/** {@code getStudioDocument}. */
/*
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
* 같은 이유다(CreateStudioDocumentUseCase 참조).
*/
@RequiresPermission(StudioPermissions.READ)
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetStudioDocumentUseCase
public class GetStudioDocumentUseCase
implements QueryUseCase<GetDocumentQuery, WorkingCopyDetailView> {
private final WorkingCopyRepositoryPort workingCopies;
@@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView;
@@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/** {@code getStudioPublicationSnapshot}. 현재 source 에서 다시 만들지 않고 게시 시점에 고정된 것을 그대로 돌려준다(ADR-002). */
/*
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
* 같은 이유다(CreateStudioDocumentUseCase 참조).
*/
@RequiresPermission(StudioPermissions.READ)
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class GetStudioPublicationSnapshotUseCase
public class GetStudioPublicationSnapshotUseCase
implements QueryUseCase<GetSnapshotQuery, PublicationSnapshotView> {
private final PublicationHistoryQueryPort history;
@@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
@@ -21,11 +22,17 @@ import dev.caskeleton.application.usecase.QueryUseCase;
* TransactionPort.inRead(...)}를 직접 호출하도록 정적으로 강제한다. {@link
* dev.caskeleton.application.notification.NotificationOperationsSnapshotUseCase}와 같은 패턴이다.
*/
/*
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
* 같은 이유다(CreateStudioDocumentUseCase 참조).
*/
@RequiresPermission(StudioPermissions.READ)
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ListCatalogUseCase implements QueryUseCase<ListCatalogQuery, CatalogPageView> {
public class ListCatalogUseCase implements QueryUseCase<ListCatalogQuery, CatalogPageView> {
private static final int MAX_LIMIT = 100;
@@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.AssetPageView;
@@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/** {@code listStudioAssets}. */
/*
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
* 같은 이유다(CreateStudioDocumentUseCase 참조).
*/
@RequiresPermission(StudioPermissions.READ)
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ListStudioAssetsUseCase implements QueryUseCase<ListAssetsQuery, AssetPageView> {
public class ListStudioAssetsUseCase implements QueryUseCase<ListAssetsQuery, AssetPageView> {
private static final int MAX_LIMIT = 100;
private static final int MAX_QUERY_LENGTH = 100;
@@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
@@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/** {@code listStudioDocuments}. */
/*
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
* 같은 이유다(CreateStudioDocumentUseCase 참조).
*/
@RequiresPermission(StudioPermissions.READ)
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ListStudioDocumentsUseCase
public class ListStudioDocumentsUseCase
implements QueryUseCase<ListDocumentsQuery, DocumentPageView> {
/** 계약 {@code components.parameters.Limit}. */
@@ -3,6 +3,7 @@ package dev.caskeleton.application.techlog.studio.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.PublicationPageView;
@@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase;
import java.util.Objects;
/** {@code listStudioPublications}. */
/*
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
* 같은 이유다(CreateStudioDocumentUseCase 참조).
*/
@RequiresPermission(StudioPermissions.READ)
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public final class ListStudioPublicationsUseCase
public class ListStudioPublicationsUseCase
implements QueryUseCase<ListPublicationsQuery, PublicationPageView> {
private static final int MAX_LIMIT = 100;
@@ -9,6 +9,15 @@ package dev.caskeleton.application.techlog.studio.service;
*/
public final class StudioPermissions {
/**
* 작업본·게시기록·Asset·대시보드 조회가 요구하는 권한.
*
* <p>읽기에도 권한이 필요한 이유: Studio 가 읽는 것은 게시 전 초안이다. 인증만 통과하면 누구나 {@code listStudioDocuments} 로 남의 초안
* 목록을, {@code getStudioDocument} 로 그 본문을 볼 수 있어서는 안 된다 — 계약도 Studio 표면 전체에 권한을
* 요구한다(securitySchemes.sessionCookie).
*/
public static final String READ = "studio:read";
/** 편집본 생성·저장·검증·미리보기·게시가 요구하는 권한. */
public static final String WRITE = "studio:write";