diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessorTest.java new file mode 100644 index 0000000..8fcf301 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessorTest.java @@ -0,0 +1,88 @@ +package dev.caskeleton.bootstrap.techlog; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.core.ResolvableType; +import org.springframework.core.env.MapPropertySource; +import org.springframework.mock.env.MockEnvironment; + +/** + * 이 후처리기의 산출물은 프로퍼티가 아니라 바인딩 결과다. 프로퍼티가 environment 에 들어갔는지만 보면 통과하면서도 실제 앱에서는 매핑이 죽는 경우가 + * 있다 — application.yml 이 {@code role-permissions: {}} 로 같은 이름을 이미 선언하고 있고, 그 소스가 {@code addLast} 보다 + * 우선순위가 높기 때문이다. 그래서 여기서는 {@code Map>} 로 실제 바인딩해서 확인한다. + */ +class StudioAuthzEnvironmentPostProcessorTest { + + private final StudioAuthzEnvironmentPostProcessor epp = new StudioAuthzEnvironmentPostProcessor(); + + private static final Bindable>> ROLE_PERMISSIONS = + Bindable.of( + ResolvableType.forClassWithGenerics( + Map.class, + ResolvableType.forClass(String.class), + ResolvableType.forClassWithGenerics(List.class, String.class))); + + private static Map> bind(MockEnvironment env) { + return Binder.get(env) + .bind("ca-skeleton.authz.role-permissions", ROLE_PERMISSIONS) + .orElse(Map.of()); + } + + /** + * 앱이 실제로 바인딩하는 대상은 {@code Map} 이 아니라 {@code RolePermissionPolicy} 레코드다(생성자 바인딩). 맵으로만 확인하면 레코드 + * 경로에서만 나타나는 차이를 놓친다. + */ + @Test + void bindsThroughTheRecordTheApplicationActuallyUses() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("app.studio.author-role", "site-admin"); + env.getPropertySources() + .addLast( + new MapPropertySource( + "applicationDefaults", Map.of("ca-skeleton.authz.role-permissions", ""))); + + epp.postProcessEnvironment(env, new SpringApplication()); + + Map> bound = + Binder.get(env) + .bind("ca-skeleton.authz", Bindable.of(RolePermissionPolicy.class)) + .map(RolePermissionPolicy::rolePermissions) + .orElse(Map.of()); + assertThat(bound).containsEntry("site-admin", List.of("studio:read", "studio:write")); + } + + @Test + void grantsBothReadAndWriteToTheConfiguredRole() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("app.studio.author-role", "site-admin"); + + epp.postProcessEnvironment(env, new SpringApplication()); + + assertThat(bind(env)).containsEntry("site-admin", List.of("studio:read", "studio:write")); + } + + /** + * application.yml 이 선언하는 빈 맵을 재현한다. 이것이 매핑을 가리면 Studio 의 모든 쓰기가 403 이 된다 — 읽기는 통과하는데 쓰기만 막히는, + * 진단하기 어려운 모양으로 나타난다. + */ + @Test + void survivesAnEmptyMapDeclaredByTheApplicationDefaults() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("app.studio.author-role", "site-admin"); + env.getPropertySources() + .addLast( + new MapPropertySource( + "applicationDefaults", Map.of("ca-skeleton.authz.role-permissions", ""))); + + epp.postProcessEnvironment(env, new SpringApplication()); + + assertThat(bind(env)).containsEntry("site-admin", List.of("studio:read", "studio:write")); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzWiringTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzWiringTest.java new file mode 100644 index 0000000..bf98bc8 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzWiringTest.java @@ -0,0 +1,76 @@ +package dev.caskeleton.bootstrap.techlog; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.authz.AuthorizationAdapter; +import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy; +import dev.caskeleton.adapter.inbound.web.authz.RolePermissionRegistry; +import dev.caskeleton.application.security.AuthorizationPort; +import dev.caskeleton.shared.security.Permission; +import java.util.Set; +import org.aopalliance.intercept.MethodInvocation; +import org.junit.jupiter.api.Test; +import org.springframework.aop.Advisor; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; + +/** + * 후처리기가 심은 매핑이 실행 중인 컨텍스트에서도 살아 있는지 본다. + * + *

바인딩만 따로 확인하면 통과하지만 앱에서는 죽는 경우가 있어서다. {@code MethodSecurityConfig} 의 advisor 는 auto-proxy 보다 먼저 + * 만들어져야 하는 인프라 빈인데 {@link AuthorizationPort} 를 생성자 파라미터로 받는다. 그래서 {@code AuthorizationAdapter → + * RolePermissionRegistry → RolePermissionPolicy} 가 BeanPostProcessor 등록이 끝나기 전에 끌려 올라온다 — 운영 로그가 이 + * 세 빈에 대해 "not eligible for getting processed by all BeanPostProcessors" 를 정확히 그렇게 찍고 있다. + */ +class StudioAuthzWiringTest { + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(RolePermissionPolicy.class) + static class Wiring { + + @Bean + RolePermissionRegistry rolePermissionRegistry(RolePermissionPolicy policy) { + return new RolePermissionRegistry(policy); + } + + @Bean + AuthorizationPort authorizationAdapter(RolePermissionRegistry registry) { + return new AuthorizationAdapter(registry); + } + + /** MethodSecurityConfig 와 같은 모양: 인프라 advisor 가 포트를 직접 받는다. */ + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static Advisor requiresPermissionAuthorizationAdvisor(AuthorizationPort authorizationPort) { + AuthorizationManager manager = (authentication, invocation) -> null; + return new AuthorizationManagerBeforeMethodInterceptor( + AnnotationMatchingPointcut.forMethodAnnotation(Deprecated.class), manager); + } + } + + @Test + void theConfiguredRoleKeepsBothPermissionsInsideARunningContext() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("app.studio.author-role", "site-admin"); + new StudioAuthzEnvironmentPostProcessor().postProcessEnvironment(env, new SpringApplication()); + + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.setEnvironment(env); + context.register(Wiring.class); + context.refresh(); + + RolePermissionRegistry registry = context.getBean(RolePermissionRegistry.class); + assertThat(registry.effectivePermissions(Set.of("site-admin"))) + .contains(Permission.parse("studio:read"), Permission.parse("studio:write")); + } + } +}