diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java
index 280bcc6..396391a 100644
--- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java
+++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/idempotency/IdempotencyStoreAdapter.java
@@ -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),
diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessor.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessor.java
new file mode 100644
index 0000000..04012fd
--- /dev/null
+++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessor.java
@@ -0,0 +1,56 @@
+package dev.caskeleton.bootstrap.techlog;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.springframework.boot.EnvironmentPostProcessor;
+import org.springframework.boot.SpringApplication;
+import org.springframework.core.env.ConfigurableEnvironment;
+import org.springframework.core.env.MapPropertySource;
+
+/**
+ * Studio 의 IdP 역할을 {@code studio:read} / {@code studio:write} 에 잇는다.
+ *
+ *
왜 YAML 이 아니라 여기인가: 프로파일 YAML 은 이 매핑을
+ *
+ *
+ * role-permissions:
+ * ${APP_STUDIO_AUTHOR_ROLE:studio-author}:
+ * - studio:write
+ *
+ *
+ * 로 적고 있었다. Spring Boot 는 {@code @ConfigurationProperties} 의 값에서는 플레이스홀더를 풀지만 Map
+ * 키에서는 풀지 않는다 — 키는 리터럴 {@code "${APP_STUDIO_AUTHOR_ROLE:studio-author}"} 로 바인딩된다. 어떤 실제 역할과도
+ * 일치하지 않으므로 {@link dev.caskeleton.adapter.inbound.web.authz.RolePermissionRegistry} 는 빈 레지스트리가 되고,
+ * Studio 의 모든 쓰기가 403 이 된다. 실측으로 확인했다: {@code APP_STUDIO_AUTHOR_ROLE=studio-author} 를 명시해도 403, 리터럴
+ * 키로 바꾸면 즉시 201.
+ *
+ * 역할 이름은 배포마다 다르므로(계약도 {@code StudioSession.roles} 를 고정하지 않는다) 코드에 박을 수 없다. 스칼라 프로퍼티는 플레이스홀더가 정상
+ * 동작하므로, 여기서 이름을 해석한 뒤 리터럴 키로 매핑을 심는다.
+ *
+ *
{@code addLast} 로 넣으므로 운영자가 같은 키를 직접 주면 그쪽이 이긴다. {@code META-INF/spring.factories} 에 등록된다.
+ */
+public class StudioAuthzEnvironmentPostProcessor implements EnvironmentPostProcessor {
+
+ static final String ROLE_KEY = "app.studio.author-role";
+ static final String DEFAULT_ROLE = "studio-author";
+ private static final String PREFIX = "ca-skeleton.authz.role-permissions.";
+
+ @Override
+ public void postProcessEnvironment(
+ ConfigurableEnvironment environment, SpringApplication application) {
+ String role = resolveRole(environment);
+ Map mapping = new LinkedHashMap<>();
+ mapping.put(PREFIX + role + "[0]", "studio:read");
+ mapping.put(PREFIX + role + "[1]", "studio:write");
+ environment.getPropertySources().addLast(new MapPropertySource("studioAuthzMapping", mapping));
+ }
+
+ /**
+ * {@code APP_STUDIO_AUTHOR_ROLE} 은 relaxed binding 으로 {@code app.studio.author-role} 에 닿는다. 값이 비어
+ * 있으면 기본값을 쓴다 — 빈 문자열을 키로 심으면 역할 없는 호출자에게 권한이 붙는다.
+ */
+ private static String resolveRole(ConfigurableEnvironment environment) {
+ String configured = environment.getProperty(ROLE_KEY);
+ return (configured == null || configured.isBlank()) ? DEFAULT_ROLE : configured.trim();
+ }
+}
diff --git a/src/app-bootstrap/src/main/resources/META-INF/spring.factories b/src/app-bootstrap/src/main/resources/META-INF/spring.factories
index 8a537d8..275a195 100644
--- a/src/app-bootstrap/src/main/resources/META-INF/spring.factories
+++ b/src/app-bootstrap/src/main/resources/META-INF/spring.factories
@@ -1,6 +1,7 @@
org.springframework.boot.EnvironmentPostProcessor=\
dev.caskeleton.bootstrap.tracing.TracingSamplingEnvironmentPostProcessor,\
-dev.caskeleton.bootstrap.runtime.RedisReadinessGroupPostProcessor
+dev.caskeleton.bootstrap.runtime.RedisReadinessGroupPostProcessor,\
+ dev.caskeleton.bootstrap.techlog.StudioAuthzEnvironmentPostProcessor
org.springframework.boot.SpringBootExceptionReporter=\
dev.caskeleton.bootstrap.runtime.startup.StartupFailureExceptionReporter
diff --git a/src/app-bootstrap/src/main/resources/application-local.yml b/src/app-bootstrap/src/main/resources/application-local.yml
index 2e8a117..57af34b 100644
--- a/src/app-bootstrap/src/main/resources/application-local.yml
+++ b/src/app-bootstrap/src/main/resources/application-local.yml
@@ -73,16 +73,9 @@ spring:
# 그대로 동작한다.
backend: filesystem
- authz:
- role-permissions:
- # Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라
- # 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다.
- #
- # application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿
- # 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을
- # 흔들지 않고 프로파일에서 더한다.
- ${APP_STUDIO_AUTHOR_ROLE:studio-author}:
- - studio:write
+ # authz.role-permissions 는 StudioAuthzEnvironmentPostProcessor 가 심는다.
+ # YAML Map 키에는 플레이스홀더가 풀리지 않아 ${APP_STUDIO_AUTHOR_ROLE} 를 키로 쓰면
+ # 매핑이 통째로 죽는다(모든 Studio 호출 403). 역할 이름은 APP_STUDIO_AUTHOR_ROLE 로 준다.
security:
oauth2:
diff --git a/src/app-bootstrap/src/main/resources/application-prod.yml b/src/app-bootstrap/src/main/resources/application-prod.yml
index 9cde678..4911903 100644
--- a/src/app-bootstrap/src/main/resources/application-prod.yml
+++ b/src/app-bootstrap/src/main/resources/application-prod.yml
@@ -26,16 +26,9 @@ spring:
ca-skeleton:
persistence:
vendor: postgresql
- authz:
- role-permissions:
- # Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라
- # 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다.
- #
- # application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿
- # 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을
- # 흔들지 않고 프로파일에서 더한다.
- ${APP_STUDIO_AUTHOR_ROLE:studio-author}:
- - studio:write
+ # authz.role-permissions 는 StudioAuthzEnvironmentPostProcessor 가 심는다.
+ # YAML Map 키에는 플레이스홀더가 풀리지 않아 ${APP_STUDIO_AUTHOR_ROLE} 를 키로 쓰면
+ # 매핑이 통째로 죽는다(모든 Studio 호출 403). 역할 이름은 APP_STUDIO_AUTHOR_ROLE 로 준다.
security:
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
diff --git a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutor.java b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutor.java
index 25fe1ae..c1fc915 100644
--- a/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutor.java
+++ b/src/application-core/src/main/java/dev/caskeleton/application/idempotency/IdempotencyExecutor.java
@@ -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;
}
}
diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java
index e93120f..3daeb0a 100644
--- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java
+++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetCurrentStudioPreviewUseCase.java
@@ -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 {
private final StudioDocumentLoader documents;
diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java
index 76e1d73..26f59c3 100644
--- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java
+++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioAssetUseCase.java
@@ -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 {
+public class GetStudioAssetUseCase implements QueryUseCase {
private final AssetRepositoryPort assets;
private final TransactionPort transactions;
diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java
index 93eb9de..222c956 100644
--- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java
+++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDashboardUseCase.java
@@ -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 {
+public class GetStudioDashboardUseCase implements QueryUseCase {
/** 계약 {@code StudioDashboard} 의 각 목록 maxItems. */
private static final int SECTION_SIZE = 5;
diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java
index cfcea80..d75d773 100644
--- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java
+++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioDocumentUseCase.java
@@ -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 {
private final WorkingCopyRepositoryPort workingCopies;
diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java
index 8e26722..56edb5d 100644
--- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java
+++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/GetStudioPublicationSnapshotUseCase.java
@@ -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 {
private final PublicationHistoryQueryPort history;
diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java
index f39c031..5b3c24c 100644
--- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java
+++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListCatalogUseCase.java
@@ -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 {
+public class ListCatalogUseCase implements QueryUseCase {
private static final int MAX_LIMIT = 100;
diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java
index a864f66..141c957 100644
--- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java
+++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioAssetsUseCase.java
@@ -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 {
+public class ListStudioAssetsUseCase implements QueryUseCase {
private static final int MAX_LIMIT = 100;
private static final int MAX_QUERY_LENGTH = 100;
diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java
index 66b2e25..516a49e 100644
--- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java
+++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioDocumentsUseCase.java
@@ -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 {
/** 계약 {@code components.parameters.Limit}. */
diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java
index eb7fbbb..43dbbf4 100644
--- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java
+++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ListStudioPublicationsUseCase.java
@@ -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 {
private static final int MAX_LIMIT = 100;
diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java
index 3718b96..e98b0d0 100644
--- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java
+++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/StudioPermissions.java
@@ -9,6 +9,15 @@ package dev.caskeleton.application.techlog.studio.service;
*/
public final class StudioPermissions {
+ /**
+ * 작업본·게시기록·Asset·대시보드 조회가 요구하는 권한.
+ *
+ * 읽기에도 권한이 필요한 이유: Studio 가 읽는 것은 게시 전 초안이다. 인증만 통과하면 누구나 {@code listStudioDocuments} 로 남의 초안
+ * 목록을, {@code getStudioDocument} 로 그 본문을 볼 수 있어서는 안 된다 — 계약도 Studio 표면 전체에 권한을
+ * 요구한다(securitySchemes.sessionCookie).
+ */
+ public static final String READ = "studio:read";
+
/** 편집본 생성·저장·검증·미리보기·게시가 요구하는 권한. */
public static final String WRITE = "studio:write";