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";