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
@@ -19,6 +19,8 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* DB-backed {@link IdempotencyStorePort}. {@link #tryBegin} uses the {@code uq_idempotency_scope}
@@ -162,7 +164,14 @@ public class IdempotencyStoreAdapter implements IdempotencyStorePort {
row.getExpiresAt()));
}
/**
* {@code deleteByScope} 는 {@code @Modifying} 벌크 delete 이므로 활성 트랜잭션을 요구한다. 이 메서드는 {@code
* IdempotencyExecutor} 의 실패 경로에서 호출되는데 그 지점에는 트랜잭션이 없다 — 예약 레코드를 지우려다 {@code
* TransactionRequiredException} 을 던져 원래 실패를 덮고 있었다(403 이 500 으로 바뀌고 로그에 원인이 남지 않았다). REQUIRES_NEW
* 인 이유: 정리는 실패한 작업의 롤백에 휩쓸리면 안 된다.
*/
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void discard(IdempotencyScope scope) {
repository.deleteByScope(
IdempotencyRecordEntityMapper.tenantColumn(scope),