The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 lines
8.3 KiB
JSON
15 lines
8.3 KiB
JSON
{
|
|
"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<String> 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<String> 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 — 값은 이 자산이 넣거나 저장소에서 읽은 것이다"
|
|
}
|