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>
200 lines
16 KiB
XML
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 'rm -rf "$D"' 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>&1 | head -1
|
|
cp=""
|
|
while IFS</tspan><tspan x="24" dy="22">= read -r line; do
|
|
coord=${line%%=*}; confs=${line#*=}
|
|
case ",$confs," in *",testRuntimeClasspath,"*) ;; *) continue</tspan><tspan x="24" dy="22"> ;; esac
|
|
g=${coord%%:*}; rest=${coord#*:}; n=${rest%%:*}; v=${rest##*:}
|
|
jar=$(find "$CACHE/$g/$n/$v" -name '*.jar' !</tspan><tspan x="24" dy="22"> -name '*sources*' ! -name '*javadoc*' 2>/dev/null | head -1)
|
|
[ -n "$jar" ] && cp="$cp:$jar"
|
|
done < <(grep -E '^[a-zA-</tspan><tspan x="24" dy="22">Z0-9._-]+:[^=]+=' "$W/gradle.lockfile")
|
|
# 형제 모듈 산출물도 붙인다. 잠금 파일에는 외부 의존만 있고 프로젝트 의존은 없다</tspan><tspan x="24" dy="22">.
|
|
CP=$(find "$C/src" -path '*/build/libs/*+21234e38cdb9.jar' ! -name '*-testkit.jar' | tr '\n' ':')${cp#:}
|
|
cat > "$D/Cha</tspan><tspan x="24" dy="22">inOrderProbe.java" <<'JAVA'
|
|
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<String> 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,
|
|
"https://issuer.example",
|
|
</tspan><tspan x="24" dy="22"> "api",
|
|
publicPaths,
|
|
new SecuritySettings.SessionCookieSettings(
|
|
"SESSION</tspan><tspan x="24" dy="22">", true, true, "Lax", "/", "XSRF-TOKEN", "X-XSRF-TOKEN"));
|
|
}
|
|
|
|
@org.springframework.context.annotation.Bean
|
|
C</tspan><tspan x="24" dy="22">orsSettings corsSettings() {
|
|
return new CorsSettings(
|
|
true, List.of("https://app.example"), List.of("GET</tspan><tspan x="24" dy="22">"), List.of("*"), 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">-> {
|
|
throw new UnsupportedOperationException("no token is presented in this probe");
|
|
};
|
|
}
|
|
}
|
|
|
|
/** </tspan><tspan x="24" dy="22">프로덕션이 실제로 등록하는 패턴과 권한. */
|
|
private static final String PROD_PATTERN = "/internal/fileserver/**";
|
|
pri</tspan><tspan x="24" dy="22">vate static final String PROD_ROLE = "ROLE_FILE_ADMIN";
|
|
private static final String ADMIN_PATH = "/internal/fileserver</tspan><tspan x="24" dy="22">/storage-health";
|
|
|
|
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("GET", 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(
|
|
"caller", "n/a",
|
|
</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) -> ((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<String> paths, String authority) thr</tspan><tspan x="24" dy="22">ows Exception {
|
|
publicPaths = paths;
|
|
System.out.printf(" %-30s %-22s %d%n", label,
|
|
paths.isEmpty() ? "(</tspan><tspan x="24" dy="22">없음)" : String.join(",", 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("[관리 경로 " + ADMIN_PATH + " 에 요청을 보낸다]");
|
|
S</tspan><tspan x="24" dy="22">ystem.out.println();
|
|
System.out.println("무인증 호출자");
|
|
withRule = true;
|
|
row(" yml 기본값", List.of("/v1/</tspan><tspan x="24" dy="22">healthcheck"), null);
|
|
row(" 원문이 든 값", List.of("/api/**"), null);
|
|
row(" 관리 경로를 덮는 값", List.of("/in</tspan><tspan x="24" dy="22">ternal/**"), null);
|
|
row(" 전부를 여는 값", List.of("/**"), null);
|
|
System.out.println();
|
|
System.out.println(</tspan><tspan x="24" dy="22">"인증됐지만 권한이 없는 호출자");
|
|
row(" yml 기본값", List.of("/v1/healthcheck"), "ROLE_USER");
|
|
row(" local 프</tspan><tspan x="24" dy="22">로파일 값", List.of("/api/healthcheck"), "ROLE_USER");
|
|
row(" 원문이 든 값", List.of("/api/**"), "ROLE_USER");
|
|
r</tspan><tspan x="24" dy="22">ow(" 관리 경로를 덮는 값", List.of("/internal/**"), "ROLE_USER");
|
|
row(" 전부를 여는 값", List.of("/**"), "ROLE_USE</tspan><tspan x="24" dy="22">R");
|
|
System.out.println();
|
|
System.out.println("권한을 가진 호출자");
|
|
row(" yml 기본값", List.of("/v1/health</tspan><tspan x="24" dy="22">check"), "ROLE_FILE_ADMIN");
|
|
System.out.println();
|
|
System.out.println("규칙 빈이 아예 없을 때");
|
|
withRule = </tspan><tspan x="24" dy="22">false;
|
|
row(" yml 기본값", List.of("/v1/healthcheck"), "ROLE_USER");
|
|
}
|
|
}
|
|
JAVA
|
|
javac -encoding UTF-8 -cp "$CP" -d "</tspan><tspan x="24" dy="22">$D" "$D/ChainOrderProbe.java"
|
|
java -Dstdout.encoding=UTF-8 -cp "$CP:$D" ChainOrderProbe 2>&1 | grep -vE '^[0-9]{4}-|^\s+</tspan><tspan x="24" dy="22">at |WARN|INFO'
|
|
</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 "21.0.12" 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>
|