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
+9
@@ -19,6 +19,8 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.stereotype.Repository;
|
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}
|
* DB-backed {@link IdempotencyStorePort}. {@link #tryBegin} uses the {@code uq_idempotency_scope}
|
||||||
@@ -162,7 +164,14 @@ public class IdempotencyStoreAdapter implements IdempotencyStorePort {
|
|||||||
row.getExpiresAt()));
|
row.getExpiresAt()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code deleteByScope} 는 {@code @Modifying} 벌크 delete 이므로 활성 트랜잭션을 요구한다. 이 메서드는 {@code
|
||||||
|
* IdempotencyExecutor} 의 실패 경로에서 호출되는데 그 지점에는 트랜잭션이 없다 — 예약 레코드를 지우려다 {@code
|
||||||
|
* TransactionRequiredException} 을 던져 원래 실패를 덮고 있었다(403 이 500 으로 바뀌고 로그에 원인이 남지 않았다). REQUIRES_NEW
|
||||||
|
* 인 이유: 정리는 실패한 작업의 롤백에 휩쓸리면 안 된다.
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
public void discard(IdempotencyScope scope) {
|
public void discard(IdempotencyScope scope) {
|
||||||
repository.deleteByScope(
|
repository.deleteByScope(
|
||||||
IdempotencyRecordEntityMapper.tenantColumn(scope),
|
IdempotencyRecordEntityMapper.tenantColumn(scope),
|
||||||
|
|||||||
+56
@@ -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} 에 잇는다.
|
||||||
|
*
|
||||||
|
* <p>왜 YAML 이 아니라 여기인가: 프로파일 YAML 은 이 매핑을
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* role-permissions:
|
||||||
|
* ${APP_STUDIO_AUTHOR_ROLE:studio-author}:
|
||||||
|
* - studio:write
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* 로 적고 있었다. Spring Boot 는 {@code @ConfigurationProperties} 의 <em>값</em>에서는 플레이스홀더를 풀지만 <em>Map
|
||||||
|
* 키</em>에서는 풀지 않는다 — 키는 리터럴 {@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.
|
||||||
|
*
|
||||||
|
* <p>역할 이름은 배포마다 다르므로(계약도 {@code StudioSession.roles} 를 고정하지 않는다) 코드에 박을 수 없다. 스칼라 프로퍼티는 플레이스홀더가 정상
|
||||||
|
* 동작하므로, 여기서 이름을 해석한 뒤 리터럴 키로 매핑을 심는다.
|
||||||
|
*
|
||||||
|
* <p>{@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<String, Object> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
org.springframework.boot.EnvironmentPostProcessor=\
|
org.springframework.boot.EnvironmentPostProcessor=\
|
||||||
dev.caskeleton.bootstrap.tracing.TracingSamplingEnvironmentPostProcessor,\
|
dev.caskeleton.bootstrap.tracing.TracingSamplingEnvironmentPostProcessor,\
|
||||||
dev.caskeleton.bootstrap.runtime.RedisReadinessGroupPostProcessor
|
dev.caskeleton.bootstrap.runtime.RedisReadinessGroupPostProcessor,\
|
||||||
|
dev.caskeleton.bootstrap.techlog.StudioAuthzEnvironmentPostProcessor
|
||||||
|
|
||||||
org.springframework.boot.SpringBootExceptionReporter=\
|
org.springframework.boot.SpringBootExceptionReporter=\
|
||||||
dev.caskeleton.bootstrap.runtime.startup.StartupFailureExceptionReporter
|
dev.caskeleton.bootstrap.runtime.startup.StartupFailureExceptionReporter
|
||||||
|
|||||||
@@ -73,16 +73,9 @@ spring:
|
|||||||
# 그대로 동작한다.
|
# 그대로 동작한다.
|
||||||
backend: filesystem
|
backend: filesystem
|
||||||
|
|
||||||
authz:
|
# authz.role-permissions 는 StudioAuthzEnvironmentPostProcessor 가 심는다.
|
||||||
role-permissions:
|
# YAML Map 키에는 플레이스홀더가 풀리지 않아 ${APP_STUDIO_AUTHOR_ROLE} 를 키로 쓰면
|
||||||
# Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라
|
# 매핑이 통째로 죽는다(모든 Studio 호출 403). 역할 이름은 APP_STUDIO_AUTHOR_ROLE 로 준다.
|
||||||
# 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다.
|
|
||||||
#
|
|
||||||
# application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿
|
|
||||||
# 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을
|
|
||||||
# 흔들지 않고 프로파일에서 더한다.
|
|
||||||
${APP_STUDIO_AUTHOR_ROLE:studio-author}:
|
|
||||||
- studio:write
|
|
||||||
|
|
||||||
security:
|
security:
|
||||||
oauth2:
|
oauth2:
|
||||||
|
|||||||
@@ -26,16 +26,9 @@ spring:
|
|||||||
ca-skeleton:
|
ca-skeleton:
|
||||||
persistence:
|
persistence:
|
||||||
vendor: postgresql
|
vendor: postgresql
|
||||||
authz:
|
# authz.role-permissions 는 StudioAuthzEnvironmentPostProcessor 가 심는다.
|
||||||
role-permissions:
|
# YAML Map 키에는 플레이스홀더가 풀리지 않아 ${APP_STUDIO_AUTHOR_ROLE} 를 키로 쓰면
|
||||||
# Studio 편집 권한(@RequiresPermission("studio:write")). 키는 IdP 가 주는 RAW role 이름이라
|
# 매핑이 통째로 죽는다(모든 Studio 호출 403). 역할 이름은 APP_STUDIO_AUTHOR_ROLE 로 준다.
|
||||||
# 배포마다 다르다 — APP_STUDIO_AUTHOR_ROLE 로 자기 realm 의 이름을 준다.
|
|
||||||
#
|
|
||||||
# application.yml 이 아니라 프로파일에 두는 이유: SampleRemovalSmokeContractTest 가 템플릿
|
|
||||||
# 기준선인 `role-permissions: {}` 가 그대로 있는지를 검사한다. 제품 권한 매핑은 그 기준선을
|
|
||||||
# 흔들지 않고 프로파일에서 더한다.
|
|
||||||
${APP_STUDIO_AUTHOR_ROLE:studio-author}:
|
|
||||||
- studio:write
|
|
||||||
|
|
||||||
security:
|
security:
|
||||||
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
|
# Studio's contract (studio-v1.yaml StudioSession.csrfHeaderName) fixes this header name as a
|
||||||
|
|||||||
+5
-1
@@ -105,7 +105,11 @@ public final class IdempotencyExecutor {
|
|||||||
store.complete(scope, new StoredResponse(codec.serialize(result)));
|
store.complete(scope, new StoredResponse(codec.serialize(result)));
|
||||||
return result;
|
return result;
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
store.discard(scope);
|
try {
|
||||||
|
store.discard(scope);
|
||||||
|
} catch (RuntimeException cleanupFailure) {
|
||||||
|
e.addSuppressed(cleanupFailure);
|
||||||
|
}
|
||||||
throw e;
|
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.Idempotency;
|
||||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
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.StudioError;
|
||||||
import dev.caskeleton.application.techlog.error.StudioException;
|
import dev.caskeleton.application.techlog.error.StudioException;
|
||||||
import dev.caskeleton.application.techlog.studio.model.PreviewDetailView;
|
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})는 서버가
|
* {@code getCurrentStudioPreview}. Preview 상태({@code CURRENT}/{@code STALE}/{@code EXPIRED})는 서버가
|
||||||
* 계산한다(계약 설명, spec §7.3) — 프론트가 여러 값을 조합해 재추론하지 않는다.
|
* 계산한다(계약 설명, spec §7.3) — 프론트가 여러 값을 조합해 재추론하지 않는다.
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
|
||||||
|
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
|
||||||
|
* 같은 이유다(CreateStudioDocumentUseCase 참조).
|
||||||
|
*/
|
||||||
|
@RequiresPermission(StudioPermissions.READ)
|
||||||
@UseCaseCapability(
|
@UseCaseCapability(
|
||||||
transactionMode = TransactionMode.READ_ONLY,
|
transactionMode = TransactionMode.READ_ONLY,
|
||||||
idempotency = Idempotency.IDEMPOTENT,
|
idempotency = Idempotency.IDEMPOTENT,
|
||||||
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
||||||
public final class GetCurrentStudioPreviewUseCase
|
public class GetCurrentStudioPreviewUseCase
|
||||||
implements QueryUseCase<GetPreviewQuery, PreviewDetailView> {
|
implements QueryUseCase<GetPreviewQuery, PreviewDetailView> {
|
||||||
|
|
||||||
private final StudioDocumentLoader documents;
|
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.Idempotency;
|
||||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
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.StudioError;
|
||||||
import dev.caskeleton.application.techlog.error.StudioException;
|
import dev.caskeleton.application.techlog.error.StudioException;
|
||||||
import dev.caskeleton.application.techlog.studio.model.AssetDetailView;
|
import dev.caskeleton.application.techlog.studio.model.AssetDetailView;
|
||||||
@@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/** {@code getStudioAsset}. */
|
/** {@code getStudioAsset}. */
|
||||||
|
/*
|
||||||
|
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
|
||||||
|
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
|
||||||
|
* 같은 이유다(CreateStudioDocumentUseCase 참조).
|
||||||
|
*/
|
||||||
|
@RequiresPermission(StudioPermissions.READ)
|
||||||
@UseCaseCapability(
|
@UseCaseCapability(
|
||||||
transactionMode = TransactionMode.READ_ONLY,
|
transactionMode = TransactionMode.READ_ONLY,
|
||||||
idempotency = Idempotency.IDEMPOTENT,
|
idempotency = Idempotency.IDEMPOTENT,
|
||||||
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
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 AssetRepositoryPort assets;
|
||||||
private final TransactionPort transactions;
|
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.Idempotency;
|
||||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
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.DashboardView;
|
||||||
import dev.caskeleton.application.techlog.studio.model.NextAction;
|
import dev.caskeleton.application.techlog.studio.model.NextAction;
|
||||||
import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort;
|
import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort;
|
||||||
@@ -19,12 +20,17 @@ import java.util.Objects;
|
|||||||
* {@code getStudioDashboard}. {@code nextAction} 을 포함한 모든 workflow 상태는 서버가 계산한다 — 프론트가 여러 endpoint
|
* {@code getStudioDashboard}. {@code nextAction} 을 포함한 모든 workflow 상태는 서버가 계산한다 — 프론트가 여러 endpoint
|
||||||
* 를 조합해 재추론하지 않는다(계약 설명).
|
* 를 조합해 재추론하지 않는다(계약 설명).
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
|
||||||
|
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
|
||||||
|
* 같은 이유다(CreateStudioDocumentUseCase 참조).
|
||||||
|
*/
|
||||||
|
@RequiresPermission(StudioPermissions.READ)
|
||||||
@UseCaseCapability(
|
@UseCaseCapability(
|
||||||
transactionMode = TransactionMode.READ_ONLY,
|
transactionMode = TransactionMode.READ_ONLY,
|
||||||
idempotency = Idempotency.IDEMPOTENT,
|
idempotency = Idempotency.IDEMPOTENT,
|
||||||
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
||||||
public final class GetStudioDashboardUseCase
|
public class GetStudioDashboardUseCase implements QueryUseCase<GetDashboardQuery, DashboardView> {
|
||||||
implements QueryUseCase<GetDashboardQuery, DashboardView> {
|
|
||||||
|
|
||||||
/** 계약 {@code StudioDashboard} 의 각 목록 maxItems. */
|
/** 계약 {@code StudioDashboard} 의 각 목록 maxItems. */
|
||||||
private static final int SECTION_SIZE = 5;
|
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.Idempotency;
|
||||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
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.StudioError;
|
||||||
import dev.caskeleton.application.techlog.error.StudioException;
|
import dev.caskeleton.application.techlog.error.StudioException;
|
||||||
import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView;
|
import dev.caskeleton.application.techlog.studio.model.WorkingCopyDetailView;
|
||||||
@@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/** {@code getStudioDocument}. */
|
/** {@code getStudioDocument}. */
|
||||||
|
/*
|
||||||
|
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
|
||||||
|
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
|
||||||
|
* 같은 이유다(CreateStudioDocumentUseCase 참조).
|
||||||
|
*/
|
||||||
|
@RequiresPermission(StudioPermissions.READ)
|
||||||
@UseCaseCapability(
|
@UseCaseCapability(
|
||||||
transactionMode = TransactionMode.READ_ONLY,
|
transactionMode = TransactionMode.READ_ONLY,
|
||||||
idempotency = Idempotency.IDEMPOTENT,
|
idempotency = Idempotency.IDEMPOTENT,
|
||||||
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
||||||
public final class GetStudioDocumentUseCase
|
public class GetStudioDocumentUseCase
|
||||||
implements QueryUseCase<GetDocumentQuery, WorkingCopyDetailView> {
|
implements QueryUseCase<GetDocumentQuery, WorkingCopyDetailView> {
|
||||||
|
|
||||||
private final WorkingCopyRepositoryPort workingCopies;
|
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.Idempotency;
|
||||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
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.StudioError;
|
||||||
import dev.caskeleton.application.techlog.error.StudioException;
|
import dev.caskeleton.application.techlog.error.StudioException;
|
||||||
import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView;
|
import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView;
|
||||||
@@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/** {@code getStudioPublicationSnapshot}. 현재 source 에서 다시 만들지 않고 게시 시점에 고정된 것을 그대로 돌려준다(ADR-002). */
|
/** {@code getStudioPublicationSnapshot}. 현재 source 에서 다시 만들지 않고 게시 시점에 고정된 것을 그대로 돌려준다(ADR-002). */
|
||||||
|
/*
|
||||||
|
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
|
||||||
|
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
|
||||||
|
* 같은 이유다(CreateStudioDocumentUseCase 참조).
|
||||||
|
*/
|
||||||
|
@RequiresPermission(StudioPermissions.READ)
|
||||||
@UseCaseCapability(
|
@UseCaseCapability(
|
||||||
transactionMode = TransactionMode.READ_ONLY,
|
transactionMode = TransactionMode.READ_ONLY,
|
||||||
idempotency = Idempotency.IDEMPOTENT,
|
idempotency = Idempotency.IDEMPOTENT,
|
||||||
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
||||||
public final class GetStudioPublicationSnapshotUseCase
|
public class GetStudioPublicationSnapshotUseCase
|
||||||
implements QueryUseCase<GetSnapshotQuery, PublicationSnapshotView> {
|
implements QueryUseCase<GetSnapshotQuery, PublicationSnapshotView> {
|
||||||
|
|
||||||
private final PublicationHistoryQueryPort history;
|
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.Idempotency;
|
||||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
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.StudioError;
|
||||||
import dev.caskeleton.application.techlog.error.StudioException;
|
import dev.caskeleton.application.techlog.error.StudioException;
|
||||||
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
|
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
|
||||||
@@ -21,11 +22,17 @@ import dev.caskeleton.application.usecase.QueryUseCase;
|
|||||||
* TransactionPort.inRead(...)}를 직접 호출하도록 정적으로 강제한다. {@link
|
* TransactionPort.inRead(...)}를 직접 호출하도록 정적으로 강제한다. {@link
|
||||||
* dev.caskeleton.application.notification.NotificationOperationsSnapshotUseCase}와 같은 패턴이다.
|
* dev.caskeleton.application.notification.NotificationOperationsSnapshotUseCase}와 같은 패턴이다.
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
|
||||||
|
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
|
||||||
|
* 같은 이유다(CreateStudioDocumentUseCase 참조).
|
||||||
|
*/
|
||||||
|
@RequiresPermission(StudioPermissions.READ)
|
||||||
@UseCaseCapability(
|
@UseCaseCapability(
|
||||||
transactionMode = TransactionMode.READ_ONLY,
|
transactionMode = TransactionMode.READ_ONLY,
|
||||||
idempotency = Idempotency.IDEMPOTENT,
|
idempotency = Idempotency.IDEMPOTENT,
|
||||||
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
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;
|
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.Idempotency;
|
||||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
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.StudioError;
|
||||||
import dev.caskeleton.application.techlog.error.StudioException;
|
import dev.caskeleton.application.techlog.error.StudioException;
|
||||||
import dev.caskeleton.application.techlog.studio.model.AssetPageView;
|
import dev.caskeleton.application.techlog.studio.model.AssetPageView;
|
||||||
@@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/** {@code listStudioAssets}. */
|
/** {@code listStudioAssets}. */
|
||||||
|
/*
|
||||||
|
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
|
||||||
|
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
|
||||||
|
* 같은 이유다(CreateStudioDocumentUseCase 참조).
|
||||||
|
*/
|
||||||
|
@RequiresPermission(StudioPermissions.READ)
|
||||||
@UseCaseCapability(
|
@UseCaseCapability(
|
||||||
transactionMode = TransactionMode.READ_ONLY,
|
transactionMode = TransactionMode.READ_ONLY,
|
||||||
idempotency = Idempotency.IDEMPOTENT,
|
idempotency = Idempotency.IDEMPOTENT,
|
||||||
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
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_LIMIT = 100;
|
||||||
private static final int MAX_QUERY_LENGTH = 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.Idempotency;
|
||||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
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.StudioError;
|
||||||
import dev.caskeleton.application.techlog.error.StudioException;
|
import dev.caskeleton.application.techlog.error.StudioException;
|
||||||
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
|
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
|
||||||
@@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/** {@code listStudioDocuments}. */
|
/** {@code listStudioDocuments}. */
|
||||||
|
/*
|
||||||
|
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
|
||||||
|
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
|
||||||
|
* 같은 이유다(CreateStudioDocumentUseCase 참조).
|
||||||
|
*/
|
||||||
|
@RequiresPermission(StudioPermissions.READ)
|
||||||
@UseCaseCapability(
|
@UseCaseCapability(
|
||||||
transactionMode = TransactionMode.READ_ONLY,
|
transactionMode = TransactionMode.READ_ONLY,
|
||||||
idempotency = Idempotency.IDEMPOTENT,
|
idempotency = Idempotency.IDEMPOTENT,
|
||||||
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
||||||
public final class ListStudioDocumentsUseCase
|
public class ListStudioDocumentsUseCase
|
||||||
implements QueryUseCase<ListDocumentsQuery, DocumentPageView> {
|
implements QueryUseCase<ListDocumentsQuery, DocumentPageView> {
|
||||||
|
|
||||||
/** 계약 {@code components.parameters.Limit}. */
|
/** 계약 {@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.Idempotency;
|
||||||
import dev.caskeleton.application.capability.RepositoryAccess;
|
import dev.caskeleton.application.capability.RepositoryAccess;
|
||||||
import dev.caskeleton.application.capability.UseCaseCapability;
|
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.StudioError;
|
||||||
import dev.caskeleton.application.techlog.error.StudioException;
|
import dev.caskeleton.application.techlog.error.StudioException;
|
||||||
import dev.caskeleton.application.techlog.studio.model.PublicationPageView;
|
import dev.caskeleton.application.techlog.studio.model.PublicationPageView;
|
||||||
@@ -14,11 +15,17 @@ import dev.caskeleton.application.usecase.QueryUseCase;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/** {@code listStudioPublications}. */
|
/** {@code listStudioPublications}. */
|
||||||
|
/*
|
||||||
|
* 이 클래스가 final 이 아닌 이유: @RequiresPermission 은 Spring AOP 로 강제되고, Boot 는 기본적으로
|
||||||
|
* CGLIB 프록시를 쓴다 — final 클래스는 subclass 할 수 없어 빈 생성이 실패한다. 쓰기 use case 들과
|
||||||
|
* 같은 이유다(CreateStudioDocumentUseCase 참조).
|
||||||
|
*/
|
||||||
|
@RequiresPermission(StudioPermissions.READ)
|
||||||
@UseCaseCapability(
|
@UseCaseCapability(
|
||||||
transactionMode = TransactionMode.READ_ONLY,
|
transactionMode = TransactionMode.READ_ONLY,
|
||||||
idempotency = Idempotency.IDEMPOTENT,
|
idempotency = Idempotency.IDEMPOTENT,
|
||||||
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
|
||||||
public final class ListStudioPublicationsUseCase
|
public class ListStudioPublicationsUseCase
|
||||||
implements QueryUseCase<ListPublicationsQuery, PublicationPageView> {
|
implements QueryUseCase<ListPublicationsQuery, PublicationPageView> {
|
||||||
|
|
||||||
private static final int MAX_LIMIT = 100;
|
private static final int MAX_LIMIT = 100;
|
||||||
|
|||||||
+9
@@ -9,6 +9,15 @@ package dev.caskeleton.application.techlog.studio.service;
|
|||||||
*/
|
*/
|
||||||
public final class StudioPermissions {
|
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";
|
public static final String WRITE = "studio:write";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user