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:
co-authored by
Claude Opus 5
parent
d5889d644a
commit
37d5614129
+5
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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;
|
||||
|
||||
+8
-1
@@ -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;
|
||||
|
||||
+8
-2
@@ -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;
|
||||
|
||||
+8
-1
@@ -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;
|
||||
|
||||
+8
-1
@@ -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;
|
||||
|
||||
+8
-1
@@ -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;
|
||||
|
||||
|
||||
+8
-1
@@ -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;
|
||||
|
||||
+8
-1
@@ -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}. */
|
||||
|
||||
+8
-1
@@ -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
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user