{ "assetKey": "a14-f005-publicpaths-restrictedpathrule-chain", "kind": "terminal", "command": "set -e\nset -o pipefail\nD=$(mktemp -d); trap 'rm -rf \"$D\"' EXIT\nC=/shared/codebase/clean-architecture-backend-template\nW=$C/src/adapter/inbound/web\nCACHE=/root/.gradle/caches/modules-2/files-2.1\njava -version 2>&1 | head -1\ncp=\"\"\nwhile IFS= read -r line; do\n coord=${line%%=*}; confs=${line#*=}\n case \",$confs,\" in *\",testRuntimeClasspath,\"*) ;; *) continue ;; esac\n g=${coord%%:*}; rest=${coord#*:}; n=${rest%%:*}; v=${rest##*:}\n jar=$(find \"$CACHE/$g/$n/$v\" -name '*.jar' ! -name '*sources*' ! -name '*javadoc*' 2>/dev/null | head -1)\n [ -n \"$jar\" ] && cp=\"$cp:$jar\"\ndone < <(grep -E '^[a-zA-Z0-9._-]+:[^=]+=' \"$W/gradle.lockfile\")\n# 형제 모듈 산출물도 붙인다. 잠금 파일에는 외부 의존만 있고 프로젝트 의존은 없다.\nCP=$(find \"$C/src\" -path '*/build/libs/*+21234e38cdb9.jar' ! -name '*-testkit.jar' | tr '\\n' ':')${cp#:}\ncat > \"$D/ChainOrderProbe.java\" <<'JAVA'\nimport dev.caskeleton.adapter.inbound.web.auth.RestrictedPathRule;\nimport dev.caskeleton.adapter.inbound.web.auth.JwtToAuthenticatedPrincipalConverter;\nimport dev.caskeleton.adapter.inbound.web.auth.SecurityConfig;\nimport dev.caskeleton.adapter.inbound.web.settings.CorsSettings;\nimport dev.caskeleton.adapter.inbound.web.settings.SecuritySettings;\nimport jakarta.servlet.FilterChain;\nimport java.util.List;\nimport org.springframework.mock.web.MockHttpServletRequest;\nimport org.springframework.mock.web.MockHttpServletResponse;\nimport org.springframework.mock.web.MockServletContext;\nimport org.springframework.security.web.FilterChainProxy;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.web.context.support.AnnotationConfigWebApplicationContext;\n\n/** 설정이 실제로 만드는 필터 사슬에 무인증 요청을 넣어 본다. */\npublic final class ChainOrderProbe {\n\n\n /** 이 프로브가 세우는 공개 경로. 운영자가 넓게 잡은 상황을 흉내 낸다. */\n private static List publicPaths = List.of();\n\n public static class Wiring {\n @org.springframework.context.annotation.Bean\n SecuritySettings securitySettings() {\n return new SecuritySettings(\n SecuritySettings.AuthenticationMode.JWT,\n \"https://issuer.example\",\n \"api\",\n publicPaths,\n new SecuritySettings.SessionCookieSettings(\n \"SESSION\", true, true, \"Lax\", \"/\", \"XSRF-TOKEN\", \"X-XSRF-TOKEN\"));\n }\n\n @org.springframework.context.annotation.Bean\n CorsSettings corsSettings() {\n return new CorsSettings(\n true, List.of(\"https://app.example\"), List.of(\"GET\"), List.of(\"*\"), false, 600L);\n }\n\n @org.springframework.context.annotation.Bean\n JwtToAuthenticatedPrincipalConverter jwtConverter() {\n return new JwtToAuthenticatedPrincipalConverter();\n }\n\n @org.springframework.context.annotation.Bean\n tools.jackson.databind.ObjectMapper objectMapper() {\n return new tools.jackson.databind.json.JsonMapper();\n }\n\n // 무인증 요청에는 불리지 않는다. JWT 가지가 빈을 요구하므로 자리만 채운다.\n @org.springframework.context.annotation.Bean\n org.springframework.security.oauth2.jwt.JwtDecoder jwtDecoder() {\n return token -> {\n throw new UnsupportedOperationException(\"no token is presented in this probe\");\n };\n }\n }\n\n /** 프로덕션이 실제로 등록하는 패턴과 권한. */\n private static final String PROD_PATTERN = \"/internal/fileserver/**\";\n private static final String PROD_ROLE = \"ROLE_FILE_ADMIN\";\n private static final String ADMIN_PATH = \"/internal/fileserver/storage-health\";\n\n private static boolean withRule = true;\n\n public static class RuleWiring {\n @org.springframework.context.annotation.Bean\n RestrictedPathRule fileserverAdminPathRule() {\n return new RestrictedPathRule(PROD_PATTERN, List.of(PROD_ROLE));\n }\n }\n\n private static int statusFor(String path, String authority) throws Exception {\n AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();\n context.setServletContext(new MockServletContext());\n if (withRule) {\n context.register(Wiring.class, RuleWiring.class, SecurityConfig.class);\n } else {\n context.register(Wiring.class, SecurityConfig.class);\n }\n context.refresh();\n SecurityFilterChain chain = context.getBean(SecurityFilterChain.class);\n FilterChainProxy proxy = new FilterChainProxy(chain);\n proxy.afterPropertiesSet();\n\n MockHttpServletRequest request = new MockHttpServletRequest(\"GET\", path);\n request.setServletPath(path);\n MockHttpServletResponse response = new MockHttpServletResponse();\n if (authority != null) {\n // 인증된 호출자를 실어 보낸다. 무인증만 보면 캐치올과 규칙을 가를 수 없다.\n var token =\n new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(\n \"caller\", \"n/a\",\n List.of(new org.springframework.security.core.authority.SimpleGrantedAuthority(\n authority)));\n var securityContext =\n org.springframework.security.core.context.SecurityContextHolder.createEmptyContext();\n securityContext.setAuthentication(token);\n new org.springframework.security.web.context.RequestAttributeSecurityContextRepository()\n .saveContext(securityContext, request, response);\n }\n // 사슬 끝에 닿으면 200 을 적는다. 닿지 못하면 진입점이 자기 상태를 적는다.\n FilterChain terminal =\n (req, res) -> ((jakarta.servlet.http.HttpServletResponse) res).setStatus(200);\n proxy.doFilter(request, response, terminal);\n context.close();\n return response.getStatus();\n }\n\n private static void row(String label, List paths, String authority) throws Exception {\n publicPaths = paths;\n System.out.printf(\" %-30s %-22s %d%n\", label,\n paths.isEmpty() ? \"(없음)\" : String.join(\",\", paths), statusFor(ADMIN_PATH, authority));\n }\n\n public static void main(String[] args) throws Exception {\n System.out.println();\n System.out.println(\"[관리 경로 \" + ADMIN_PATH + \" 에 요청을 보낸다]\");\n System.out.println();\n System.out.println(\"무인증 호출자\");\n withRule = true;\n row(\" yml 기본값\", List.of(\"/v1/healthcheck\"), null);\n row(\" 원문이 든 값\", List.of(\"/api/**\"), null);\n row(\" 관리 경로를 덮는 값\", List.of(\"/internal/**\"), null);\n row(\" 전부를 여는 값\", List.of(\"/**\"), null);\n System.out.println();\n System.out.println(\"인증됐지만 권한이 없는 호출자\");\n row(\" yml 기본값\", List.of(\"/v1/healthcheck\"), \"ROLE_USER\");\n row(\" local 프로파일 값\", List.of(\"/api/healthcheck\"), \"ROLE_USER\");\n row(\" 원문이 든 값\", List.of(\"/api/**\"), \"ROLE_USER\");\n row(\" 관리 경로를 덮는 값\", List.of(\"/internal/**\"), \"ROLE_USER\");\n row(\" 전부를 여는 값\", List.of(\"/**\"), \"ROLE_USER\");\n System.out.println();\n System.out.println(\"권한을 가진 호출자\");\n row(\" yml 기본값\", List.of(\"/v1/healthcheck\"), \"ROLE_FILE_ADMIN\");\n System.out.println();\n System.out.println(\"규칙 빈이 아예 없을 때\");\n withRule = false;\n row(\" yml 기본값\", List.of(\"/v1/healthcheck\"), \"ROLE_USER\");\n }\n}\nJAVA\njavac -encoding UTF-8 -cp \"$CP\" -d \"$D\" \"$D/ChainOrderProbe.java\"\njava -Dstdout.encoding=UTF-8 -cp \"$CP:$D\" ChainOrderProbe 2>&1 | grep -vE '^[0-9]{4}-|^\\s+at |WARN|INFO'\n", "cwd": "/shared/codebase/clean-architecture-backend-template/src", "exitCode": 0, "executedAt": "2026-09-02T22:24:22+00:00", "sourceRevision": "21234e38cdb9a926cbc92bb97a2aee2e4a7d2916", "raw": "evidence/raw/a14-f005-publicpaths-restrictedpathrule-chain.txt", "svg": "evidence/rendered/a14-f005-publicpaths-restrictedpathrule-chain.svg", "rawSha256": "b96b6128b0225a97f79cd4e73b8523efe273fb2f9b535041805aaad9e273fdb5", "lines": 22, "redaction": "none — 값은 이 자산이 넣거나 저장소에서 읽은 것이다" }