Files
document-haness/docs/clean-architecture-backend-template/final/evidence/rendered/a14-f005-publicpaths-restrictedpathrule-chain.svg
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
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>
2026-09-04 22:51:59 +09:00

200 lines
16 KiB
XML

<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1998" viewBox="0 0 1200 1998" role="img">
<title>terminal evidence</title>
<desc>Terminal-style rendering generated from retained command output. Sensitive-looking values are redacted in the visual asset.</desc>
<rect x="1" y="1" width="1198" height="1996" rx="14" fill="#0d1117" stroke="#30363d"/>
<rect x="1" y="1" width="1198" height="44" rx="14" fill="#161b22"/>
<rect x="1" y="30" width="1198" height="14" fill="#161b22"/>
<circle cx="24" cy="22" r="6" fill="#ff5f57"/>
<circle cx="44" cy="22" r="6" fill="#febc2e"/>
<circle cx="64" cy="22" r="6" fill="#28c840"/>
<text x="92" y="27" fill="#8b949e" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">terminal evidence</text>
<text x="24" y="68" fill="#c9d1d9" font-size="15" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">$ set -e
set -o pipefail
D=$(mktemp -d); trap &#x27;rm -rf &quot;$D&quot;&#x27; EXIT
C=/shared/codebase/clean-architecture-backend-template
</tspan><tspan x="24" dy="22">W=$C/src/adapter/inbound/web
CACHE=/root/.gradle/caches/modules-2/files-2.1
java -version 2&gt;&amp;1 | head -1
cp=&quot;&quot;
while IFS</tspan><tspan x="24" dy="22">= read -r line; do
coord=${line%%=*}; confs=${line#*=}
case &quot;,$confs,&quot; in *&quot;,testRuntimeClasspath,&quot;*) ;; *) continue</tspan><tspan x="24" dy="22"> ;; esac
g=${coord%%:*}; rest=${coord#*:}; n=${rest%%:*}; v=${rest##*:}
jar=$(find &quot;$CACHE/$g/$n/$v&quot; -name &#x27;*.jar&#x27; !</tspan><tspan x="24" dy="22"> -name &#x27;*sources*&#x27; ! -name &#x27;*javadoc*&#x27; 2&gt;/dev/null | head -1)
[ -n &quot;$jar&quot; ] &amp;&amp; cp=&quot;$cp:$jar&quot;
done &lt; &lt;(grep -E &#x27;^[a-zA-</tspan><tspan x="24" dy="22">Z0-9._-]+:[^=]+=&#x27; &quot;$W/gradle.lockfile&quot;)
# 형제 모듈 산출물도 붙인다. 잠금 파일에는 외부 의존만 있고 프로젝트 의존은 없다</tspan><tspan x="24" dy="22">.
CP=$(find &quot;$C/src&quot; -path &#x27;*/build/libs/*+21234e38cdb9.jar&#x27; ! -name &#x27;*-testkit.jar&#x27; | tr &#x27;\n&#x27; &#x27;:&#x27;)${cp#:}
cat &gt; &quot;$D/Cha</tspan><tspan x="24" dy="22">inOrderProbe.java&quot; &lt;&lt;&#x27;JAVA&#x27;
import dev.caskeleton.adapter.inbound.web.auth.RestrictedPathRule;
import dev.caskeleton.ada</tspan><tspan x="24" dy="22">pter.inbound.web.auth.JwtToAuthenticatedPrincipalConverter;
import dev.caskeleton.adapter.inbound.web.auth.SecurityConfi</tspan><tspan x="24" dy="22">g;
import dev.caskeleton.adapter.inbound.web.settings.CorsSettings;
import dev.caskeleton.adapter.inbound.web.settings.S</tspan><tspan x="24" dy="22">ecuritySettings;
import jakarta.servlet.FilterChain;
import java.util.List;
import org.springframework.mock.web.MockHttp</tspan><tspan x="24" dy="22">ServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockSer</tspan><tspan x="24" dy="22">vletContext;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.SecurityF</tspan><tspan x="24" dy="22">ilterChain;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
/** 설정이 실제로 만</tspan><tspan x="24" dy="22">드는 필터 사슬에 무인증 요청을 넣어 본다. */
public final class ChainOrderProbe {
/** 이 프로브가 세우는 공개 경로. </tspan><tspan x="24" dy="22">운영자가 넓게 잡은 상황을 흉내 낸다. */
private static List&lt;String&gt; publicPaths = List.of();
public static class Wi</tspan><tspan x="24" dy="22">ring {
@org.springframework.context.annotation.Bean
SecuritySettings securitySettings() {
return new Secur</tspan><tspan x="24" dy="22">itySettings(
SecuritySettings.AuthenticationMode.JWT,
&quot;https://issuer.example&quot;,
</tspan><tspan x="24" dy="22"> &quot;api&quot;,
publicPaths,
new SecuritySettings.SessionCookieSettings(
&quot;SESSION</tspan><tspan x="24" dy="22">&quot;, true, true, &quot;Lax&quot;, &quot;/&quot;, &quot;XSRF-TOKEN&quot;, &quot;X-XSRF-TOKEN&quot;));
}
@org.springframework.context.annotation.Bean
C</tspan><tspan x="24" dy="22">orsSettings corsSettings() {
return new CorsSettings(
true, List.of(&quot;https://app.example&quot;), List.of(&quot;GET</tspan><tspan x="24" dy="22">&quot;), List.of(&quot;*&quot;), false, 600L);
}
@org.springframework.context.annotation.Bean
JwtToAuthenticatedPrincipalC</tspan><tspan x="24" dy="22">onverter jwtConverter() {
return new JwtToAuthenticatedPrincipalConverter();
}
@org.springframework.conte</tspan><tspan x="24" dy="22">xt.annotation.Bean
tools.jackson.databind.ObjectMapper objectMapper() {
return new tools.jackson.databind.json</tspan><tspan x="24" dy="22">.JsonMapper();
}
// 무인증 요청에는 불리지 않는다. JWT 가지가 빈을 요구하므로 자리만 채운다.
@org.springfra</tspan><tspan x="24" dy="22">mework.context.annotation.Bean
org.springframework.security.oauth2.jwt.JwtDecoder jwtDecoder() {
return token </tspan><tspan x="24" dy="22">-&gt; {
throw new UnsupportedOperationException(&quot;no token is presented in this probe&quot;);
};
}
}
/** </tspan><tspan x="24" dy="22">프로덕션이 실제로 등록하는 패턴과 권한. */
private static final String PROD_PATTERN = &quot;/internal/fileserver/**&quot;;
pri</tspan><tspan x="24" dy="22">vate static final String PROD_ROLE = &quot;ROLE_FILE_ADMIN&quot;;
private static final String ADMIN_PATH = &quot;/internal/fileserver</tspan><tspan x="24" dy="22">/storage-health&quot;;
private static boolean withRule = true;
public static class RuleWiring {
@org.springframewor</tspan><tspan x="24" dy="22">k.context.annotation.Bean
RestrictedPathRule fileserverAdminPathRule() {
return new RestrictedPathRule(PROD_PA</tspan><tspan x="24" dy="22">TTERN, List.of(PROD_ROLE));
}
}
private static int statusFor(String path, String authority) throws Exception {
</tspan><tspan x="24" dy="22"> AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
context.setServletC</tspan><tspan x="24" dy="22">ontext(new MockServletContext());
if (withRule) {
context.register(Wiring.class, RuleWiring.class, SecurityCon</tspan><tspan x="24" dy="22">fig.class);
} else {
context.register(Wiring.class, SecurityConfig.class);
}
context.refresh();
Se</tspan><tspan x="24" dy="22">curityFilterChain chain = context.getBean(SecurityFilterChain.class);
FilterChainProxy proxy = new FilterChainProxy(</tspan><tspan x="24" dy="22">chain);
proxy.afterPropertiesSet();
MockHttpServletRequest request = new MockHttpServletRequest(&quot;GET&quot;, path);
</tspan><tspan x="24" dy="22"> request.setServletPath(path);
MockHttpServletResponse response = new MockHttpServletResponse();
if (authority</tspan><tspan x="24" dy="22"> != null) {
// 인증된 호출자를 실어 보낸다. 무인증만 보면 캐치올과 규칙을 가를 수 없다.
var token =
</tspan><tspan x="24" dy="22"> new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(
&quot;caller&quot;, &quot;n/a&quot;,
</tspan><tspan x="24" dy="22"> List.of(new org.springframework.security.core.authority.SimpleGrantedAuthority(
authority)</tspan><tspan x="24" dy="22">));
var securityContext =
org.springframework.security.core.context.SecurityContextHolder.createEmptyCon</tspan><tspan x="24" dy="22">text();
securityContext.setAuthentication(token);
new org.springframework.security.web.context.RequestAttrib</tspan><tspan x="24" dy="22">uteSecurityContextRepository()
.saveContext(securityContext, request, response);
}
// 사슬 끝에 닿으면</tspan><tspan x="24" dy="22"> 200 을 적는다. 닿지 못하면 진입점이 자기 상태를 적는다.
FilterChain terminal =
(req, res) -&gt; ((jakarta.serv</tspan><tspan x="24" dy="22">let.http.HttpServletResponse) res).setStatus(200);
proxy.doFilter(request, response, terminal);
context.close();</tspan><tspan x="24" dy="22">
return response.getStatus();
}
private static void row(String label, List&lt;String&gt; paths, String authority) thr</tspan><tspan x="24" dy="22">ows Exception {
publicPaths = paths;
System.out.printf(&quot; %-30s %-22s %d%n&quot;, label,
paths.isEmpty() ? &quot;(</tspan><tspan x="24" dy="22">없음)&quot; : String.join(&quot;,&quot;, paths), statusFor(ADMIN_PATH, authority));
}
public static void main(String[] args) throw</tspan><tspan x="24" dy="22">s Exception {
System.out.println();
System.out.println(&quot;[관리 경로 &quot; + ADMIN_PATH + &quot; 에 요청을 보낸다]&quot;);
S</tspan><tspan x="24" dy="22">ystem.out.println();
System.out.println(&quot;무인증 호출자&quot;);
withRule = true;
row(&quot; yml 기본값&quot;, List.of(&quot;/v1/</tspan><tspan x="24" dy="22">healthcheck&quot;), null);
row(&quot; 원문이 든 값&quot;, List.of(&quot;/api/**&quot;), null);
row(&quot; 관리 경로를 덮는 값&quot;, List.of(&quot;/in</tspan><tspan x="24" dy="22">ternal/**&quot;), null);
row(&quot; 전부를 여는 값&quot;, List.of(&quot;/**&quot;), null);
System.out.println();
System.out.println(</tspan><tspan x="24" dy="22">&quot;인증됐지만 권한이 없는 호출자&quot;);
row(&quot; yml 기본값&quot;, List.of(&quot;/v1/healthcheck&quot;), &quot;ROLE_USER&quot;);
row(&quot; local 프</tspan><tspan x="24" dy="22">로파일 값&quot;, List.of(&quot;/api/healthcheck&quot;), &quot;ROLE_USER&quot;);
row(&quot; 원문이 든 값&quot;, List.of(&quot;/api/**&quot;), &quot;ROLE_USER&quot;);
r</tspan><tspan x="24" dy="22">ow(&quot; 관리 경로를 덮는 값&quot;, List.of(&quot;/internal/**&quot;), &quot;ROLE_USER&quot;);
row(&quot; 전부를 여는 값&quot;, List.of(&quot;/**&quot;), &quot;ROLE_USE</tspan><tspan x="24" dy="22">R&quot;);
System.out.println();
System.out.println(&quot;권한을 가진 호출자&quot;);
row(&quot; yml 기본값&quot;, List.of(&quot;/v1/health</tspan><tspan x="24" dy="22">check&quot;), &quot;ROLE_FILE_ADMIN&quot;);
System.out.println();
System.out.println(&quot;규칙 빈이 아예 없을 때&quot;);
withRule = </tspan><tspan x="24" dy="22">false;
row(&quot; yml 기본값&quot;, List.of(&quot;/v1/healthcheck&quot;), &quot;ROLE_USER&quot;);
}
}
JAVA
javac -encoding UTF-8 -cp &quot;$CP&quot; -d &quot;</tspan><tspan x="24" dy="22">$D&quot; &quot;$D/ChainOrderProbe.java&quot;
java -Dstdout.encoding=UTF-8 -cp &quot;$CP:$D&quot; ChainOrderProbe 2&gt;&amp;1 | grep -vE &#x27;^[0-9]{4}-|^\s+</tspan><tspan x="24" dy="22">at |WARN|INFO&#x27;
</tspan></text>
<text x="24" y="1410" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">cwd: /shared/codebase/clean-architecture-backend-template/src</tspan></text>
<text x="24" y="1432" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">time: 2026-09-02T22:24:22+00:00 · exit 0</tspan></text>
<line x1="24" y1="1446" x2="1176" y2="1446" stroke="#30363d"/>
<text x="24" y="1486" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">openjdk version &quot;21.0.12&quot; 2026-07-21</tspan></text>
<text x="24" y="1508" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"></tspan></text>
<text x="24" y="1530" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">[관리 경로 /internal/fileserver/storage-health 에 요청을 보낸다]</tspan></text>
<text x="24" y="1552" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"></tspan></text>
<text x="24" y="1574" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">무인증 호출자</tspan></text>
<text x="24" y="1596" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> yml 기본값 /v1/healthcheck 401</tspan></text>
<text x="24" y="1618" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 원문이 든 값 /api/** 401</tspan></text>
<text x="24" y="1640" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 관리 경로를 덮는 값 /internal/** 200</tspan></text>
<text x="24" y="1662" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 전부를 여는 값 /** 200</tspan></text>
<text x="24" y="1684" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"></tspan></text>
<text x="24" y="1706" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">인증됐지만 권한이 없는 호출자</tspan></text>
<text x="24" y="1728" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> yml 기본값 /v1/healthcheck 403</tspan></text>
<text x="24" y="1750" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> local 프로파일 값 /api/healthcheck 403</tspan></text>
<text x="24" y="1772" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 원문이 든 값 /api/** 403</tspan></text>
<text x="24" y="1794" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 관리 경로를 덮는 값 /internal/** 200</tspan></text>
<text x="24" y="1816" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> 전부를 여는 값 /** 200</tspan></text>
<text x="24" y="1838" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"></tspan></text>
<text x="24" y="1860" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">권한을 가진 호출자</tspan></text>
<text x="24" y="1882" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> yml 기본값 /v1/healthcheck 200</tspan></text>
<text x="24" y="1904" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"></tspan></text>
<text x="24" y="1926" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0">규칙 빈이 아예 없을 때</tspan></text>
<text x="24" y="1948" fill="#e6edf3" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve"><tspan x="24" dy="0"> yml 기본값 /v1/healthcheck 200</tspan></text>
</svg>