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>
18 KiB
kind, slug, title, topic, project, status, sourceRevision, evidenceCapturedOn, rootTreeNode, body, assets, evidence, source
| kind | slug | title | topic | project | status | sourceRevision | evidenceCapturedOn | rootTreeNode | body | assets | evidence | source | |||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | a14-f005-publicpaths-restrictedpathrule | 공개 경로가 관리 경로를 덮으면 제한 규칙이 건너뛰어지고, 그 겹침을 보는 것이 없다 | web-inbound-and-http-surface | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | 2026-09-02 | case:a14-f005-publicpaths-restrictedpathrule | case-a14-f005-publicpaths-restrictedpathrule.body.md |
|
|
|
공개 경로가 관리 경로를 덮으면 제한 규칙이 건너뛰어지고, 그 겹침을 보는 것이 없다
보안 사슬이 설정에서 온 공개 경로를 먼저 등록하고 관리 평면 규칙을 그다음에 등록한다. 앞의 등록이 뒤의 패턴을 삼키면 뒤는 아무 일도 하지 못한다. 실제 패턴과 권한으로 사슬을 세워 열한 번 왕복해 보니, 권한이 없는 인증 호출자가 403 을 받아야 할 자리에서 사슬 끝까지 갔다.
관계
- publicPaths가 먼저 등록되어 제한 경로 규칙을 덮는다 이 사례에서 확인한 등록 순서를 개념으로 정리한 문서다.
- 단일 admission point는 우회 경로를 세어야 성립한다 우회 경로를 세지 않으면 단일 지점이 성립하지 않는다는 점이 같다.
- production 판정이 두 개의 리터럴 프로파일 이름에 걸려 있다 두 사례 모두 설정값 때문에 보안 규칙보다 먼저 요청이 통과할 수 있다.
- runtime_memberships를 먼저 읽고 심각도를 정한다 이 리프에 그 완화가 적용되지 않는다는 것을 확인할 때 쓴 규칙이다.
문제
보안 사슬에 두 종류의 등록이 있다. 설정으로 받은 공개 경로를 모두 허용하는 등록과, 관리 평면을 지키는 제한 경로 규칙이다. 등록 순서가 어떤 결과를 만드는지 확인했다.
결론
공개 경로가 먼저 등록된다.
제한 경로 규칙의 자바독은 이 규칙이 존재하는 이유를 적는다. 나중에 참조되는 응용 수준 정책은 이미 요청을 통과시킨 전송을 되돌릴 수 없다는 것이다. 그런데 모두 허용 등록이 그보다 먼저 와서 정확히 그 일을 한다.
순서만 읽고 결론을 내지 않았다. 프로덕션 패턴과 권한으로 사슬을 짓고, 호출자 셋과 공개 경로 값 다섯을 바꿔 가며 열한 번 왕복했다.
인증 없는 요청만 보아서는 판정이 갈리지 않는다. 캐치올도 같은 401 을 내기 때문이다. 그래서 권한만 없는 호출자를 함께 넣었다.
권한만 없는 호출자를 넣으면 결과가 갈린다. 덮이지 않은 요청은 403 을 받는다. 규칙이 요구하는 권한이 이 호출자에게 없어서다. 덮이는 순간 같은 호출자가 사슬 끝까지 간다. 규칙은 등록되어 있는데 아무 일도 하지 않는다.
두 대조군이 그 403 의 출처를 못박는다. 규칙 빈을 빼면 같은 요청이 200 이 되고, 권한을 가진 호출자를 넣어도 200 이 된다. 403 을 만든 것은 규칙이고 캐치올이나 진입점이 아니다.
원문이 예로 든 값은 이 일을 일으키지 않는다. 관리 경로가 /internal/ 아래에 있어서 /api/** 를 열어도 닿지 않고, 같은 호출자가 그 값에서는 403 을 받는다. 겹치게 하려면 운영자가 /internal/** 이나 /** 같은 값을 직접 넣어야 한다. 원문도 그 조건을 달아 두었다.
여기에 배포 형태라는 조건이 하나 더 있다. 관리 평면 라우트는 관리 컨텍스트에 실려 나가고, 출하 설정은 그 서버에 별도 포트를 준다. 포트가 갈려 있으면 그 경로를 받을 핸들러가 애플리케이션 쪽에 없다.
이 대목은 소스 주석이 먼저 말해 둔 것이라 받아 적지 않고 프레임워크에 질의했다. 출하되는 한 쌍만 갈린 쪽으로 나오고, 관리 포트를 애플리케이션과 맞추거나 비우거나 지운 세 경우는 모두 한 컨텍스트로 묶인다. 같다는 판정에서 켜지는 설정이 고르는 목록에 이 저장소의 관리 컨텍스트 설정이 있고, 그 설정의 등록 타입은 두 배치 모두를 받는다. 그 배포에서는 관리 라우트가 애플리케이션 쪽으로 넘어온다. 프로브는 그때 요청을 판정하는 사슬의 생성 메서드를 그대로 불렀다.
부팅에는 그 겹침을 검사하는 자리가 없다. 규칙 타입을 언급하는 파일이 셋이고 전부 main 인데, 두 집합을 함께 쥐는 자리는 등록하는 곳 하나뿐이고 거기서 하는 일은 비교가 아니다. 공개 경로 쪽은 표기 세 형태로 다시 훑었다. 그 키를 쥔 파일이 열아홉이고 그중 둘은 추적되지 않는데, 어느 쪽에도 제한 패턴이 함께 나오지 않는다.
가까운 통제가 하나 있기는 하다. 공개 경로에는 스냅숏 게이트가 걸려 있다. 다만 그것이 재는 것은 운영자 기계에 있는 파일 하나와 기준선의 차이다. 그 파일은 .gitignore 가 막아 저장소에 없고, 없으면 게이트는 비교에 들어가기 전에 던진다. 그 태스크를 부르는 자리가 워크플로 셋에 네 곳인데, 어느 곳도 그 파일을 만들지 않는다. 배포 시점 환경 변수는 애초에 이 경로 어디에도 나타나지 않는다.
규칙 생성자는 자기가 물러지는 것만 막는다. 빈 권한 목록을 거부하고 그 이유를 예외 문구에 적어 둔다. 그 이유가 규칙 전체가 건너뛰어지는 경우에도 그대로 적용된다는 것이 이 사례다.
권고는 둘 중 하나다. 제한 규칙을 공개 경로보다 먼저 등록하거나, 두 패턴 집합이 겹치면 부팅에서 거부하는 것이다.
판정은 P3 다. 이 결함이 오늘 도는 배포에 닿으려면 네 가지가 동시에 참이어야 한다. 파일서버 플랫폼 스위치가 켜져야 하고, 그 아래 관리 평면 스위치도 켜져야 하고, 운영자가 관리 경로를 덮는 공개 경로 값을 넣어야 하고, 관리 서버 포트를 애플리케이션 포트와 같게 두거나 비워야 한다. 넷 다 출하값이 반대쪽이다. 두 스위치는 거짓으로 나가고, 출하되는 공개 경로 목록에는 헬스체크만 있고, 두 포트는 9001 과 8080 으로 갈라져 있다.
심각도 규칙은 리프의 런타임 멤버십을 먼저 읽으라고 적는다. 이 리프의 멤버십은 비어 있지 않으므로 리프 단위 완화를 쓸 수 없고, 그 규칙이 등급을 낮춰 주지도 않는다. P3 인 이유는 위 네 조건이지 그 규칙이 아니다.
검증 환경
OpenJDK : 21.0.12 확인 방식 : 프로덕션 패턴과 권한으로 세운 필터 사슬에 열한 번 왕복, 관리 포트 판정과 컨텍스트 선택을 프레임워크에 직접 질의, 등록 순서와 출하 스위치 확인 소스 수정 : x
재현 조건
이 사례의 프로브는 새로 만든 것이고 원문에는 없다. 원문 근거는 분석 문서의 #L582 절이다.
- 사슬 등록 블록을 읽어 세 등록의 순서를 확인한다.
- 제한 경로 규칙의 자바독과 생성자 검증을 읽는다.
- 규칙 빈을 내놓는 설정과 그것을 켜는 두 스위치를 찾는다.
- 관리 라우트를 등록하는 관리 컨텍스트 설정과 출하 포트를 읽고, 두 포트가 같을 때 그 설정이 어디로 가는지 프레임워크에 묻는다.
- 공개 경로 기본값과 규칙 타입을 언급하는 파일을 전수로 찾는다.
- 프로덕션 코드가 쓰는 생성 메서드로 필터 사슬을 세운다.
- 무인증 호출자와 권한 없는 인증 호출자로 각각 공개 경로를 바꿔 가며 상태를 읽는다.
- 권한을 가진 호출자와 규칙 빈을 뺀 사슬로 403 의 출처를 가른다.
본문
등록 세 개가 한 블록 안에 순서대로 있다.
// SecurityConfig.java:83-94
.authorizeHttpRequests(
auth -> {
if (publicPaths.length > 0) {
auth.requestMatchers(publicPaths).permitAll();
}
// Ordered before the authenticated catch-all: a management path must be refused at
// the transport, not by an application policy the request has already passed.
for (RestrictedPathRule rule : restricted) {
auth.requestMatchers(rule.pathPattern()).hasAnyAuthority(rule.authorities());
}
auth.anyRequest().authenticated();
})
주석은 제한 규칙을 인증 캐치올보다 앞에 둔 것을 설명한다. 그보다 앞선 등록은 다루지 않는다.
규칙은 어디서 오고 무엇이 켜야 하는가
:::evidence key="a14-f005-publicpaths-restrictedpathrule" alt="코드베이스 정적 검색 출력 138줄. 사슬 등록 블록, 규칙 생성자의 빈 권한 거부, 규칙을 내놓는 자리, 프로덕션 패턴과 관리 평면 스위치, 그 설정 클래스를 부르는 자리 전부와 그것을 들이는 자동설정의 조건, 컴포넌트 스캔의 제외 정규식 전문, 관리 컨텍스트 등록과 두 포트, 설정 기본값, 공개 경로를 접근자로 읽는 자리, 그 키를 쥔 파일 열아홉과 그중 추적되지 않는 둘과 제한 패턴을 함께 언급하는 파일 수, 규칙 타입을 언급하는 파일 셋, 스냅숏 게이트의 두 입력과 값 쪽 입력의 무시 규칙과 없을 때의 중단과 그것을 부르는 네 자리와 워크플로의 .env 언급 전부, 마지막으로 게이트가 제한 패턴을 언급하는 횟수가 차례로 보인다." caption="등록 순서와 프로덕션이 등록하는 값 — 138줄 · exit 0" zoom="true" :::
규칙을 내놓는 자리는 FileserverAdminPlaneConfiguration:36 하나이고, 패턴은 /internal/fileserver/**, 권한 기본값은 ROLE_FILE_ADMIN 이다.
그 설정 클래스를 이름으로 부르는 자리는 셋인데, 그중 등록에 해당하는 것은 FileserverPlatformAutoConfiguration:71 의 @Import 하나다. 나머지 둘은 클래스 선언과 자바독의 링크다. 그 자동설정에도 자기 스위치가 있고, 컴포넌트 스캔은 자동설정 패키지를 정규식으로 제외한다. 스위치를 우회해 이 설정에 닿는 길이 없다는 뜻이다. 두 스위치는 false 로 출하된다.
공개 경로 기본값은 헬스체크 하나다. publicPaths() 를 읽는 프로덕션 코드는 SecurityConfig:75 하나뿐이다.
열한 번 왕복해서 받은 상태
:::evidence key="a14-f005-publicpaths-restrictedpathrule-chain" alt="JVM 프로브 출력 22줄. 관리 라우트 하나에 요청을 보내며 호출자 세 종류와 공개 경로 값 다섯을 바꿔 가며 받은 상태 코드를 네 묶음으로 적었다. 마지막 묶음은 규칙 빈을 뺀 사슬이다." caption="호출자와 공개 경로 조합별 상태 — 22줄 · exit 0" zoom="true" :::
SecurityConfig 의 사슬 생성 메서드를 그대로 불러 필터 사슬을 만들고 /internal/fileserver/storage-health 에 요청을 보냈다. 컨트롤러가 실제로 매핑하는 여덟 라우트 중 하나다. 사슬 끝에 200 을 적는 종단을 달았으므로 200 은 인가를 통과했다는 뜻이다.
무인증 호출자만으로는 규칙이 일했는지 알 수 없다. yml 기본값에서도 401 이고 /api/** 를 열어도 401 인데, 그 401 은 anyRequest().authenticated() 도 낼 수 있는 값이다.
권한 없는 인증 호출자를 넣으면 갈린다. 겹치지 않는 세 값에서는 403 인데 그중 하나가 /api/healthcheck 다. local 프로파일이 그 값을 리터럴로 박아 두고, 샘플 애플리케이션도 같은 값을 기본으로 쓴다. /internal/** 이나 /** 로 덮으면 같은 호출자가 200 을 받는다.
그 403 이 규칙에서 왔다는 것은 두 줄이 더 말한다. 권한을 ROLE_FILE_ADMIN 으로 바꾸면 같은 요청이 200 이고, 규칙 빈이 없는 사슬에 보내도 200 이다. 규칙이 있고 권한이 없을 때만 403 이다.
어느 배포에서 성립하는가
관리 라우트를 등록하는 것은 @ManagementContextConfiguration 이고, 그 컨트롤러 패키지는 컴포넌트 스캔의 제외 정규식에 이름이 올라 있다. 출하 설정은 관리 서버에 9001, 애플리케이션에 8080 을 준다.
포트가 갈린 배포에서는 관리 경로가 애플리케이션 커넥터에 오르지 않는다. 넓은 공개 경로가 덮는 것은 핸들러가 없는 경로다.
:::evidence key="a14-f005-publicpaths-restrictedpathrule-mgmtport" alt="JVM 프로브 출력 17줄. 포트 조합 넷에 대한 판정, 같다고 판정될 때 켜지는 설정과 그것이 고르는 타입, 그 설정이 고르는 클래스 넷이 차례로 보이고 그중 하나가 이 저장소의 관리 컨텍스트 설정이다." caption="관리 포트 판정과 컨텍스트 선택 — 17줄 · exit 0" zoom="true" :::
이 대목은 관리 컨텍스트 설정의 자바독이 먼저 적어 둔 것이라, 옮겨 적는 대신 프레임워크에 물었다. ManagementPortType 은 출하값 한 쌍을 DIFFERENT 로, 두 포트가 같은 값과 관리 포트가 빈 값과 아예 없는 값을 SAME 으로 답한다. SAME 일 때 켜지는 설정 안의 중첩 설정이 @EnableManagementContext(SAME) 를 달고 있고, 그 선택자가 고르는 넷 중 하나가 FileserverAdminManagementContextConfiguration 이다. 그 설정의 등록 타입은 ANY 라 두 배치 어디에도 들어간다.
두 포트가 같으면 관리 라우트는 애플리케이션 컨텍스트로 들어온다. 그 요청을 받는 것은 SecurityConfig 가 만드는 필터 사슬이고, 이 프로브가 부른 것이 그것을 만드는 메서드다.
두 집합을 마주 놓는 코드
규칙 타입을 언급하는 파일은 셋이고 전부 main 이다. 두 집합을 함께 쥐는 자리는 SecurityConfig 하나인데 거기서 하는 일은 비교가 아니라 순서대로 등록하는 것이다.
공개 경로 쪽에서도 훑었다. 점 표기와 환경 변수와 중첩 YAML 세 형태로 빌드 산출물을 뺀 작업 트리를 뒤지면 그 키를 쥔 파일이 열아홉인데, 그중 둘은 저장소가 추적하지 않는다. .env 와 SDD 작업 폴더에 남은 리뷰 diff 다. 추적되는 열일곱은 스냅숏 기준선과 게이트 스크립트, 문서 셋, yml 다섯, 자바 일곱이고 자바는 전부 시험이다. 열아홉 중 제한 패턴을 함께 언급하는 파일은 없다.
스냅숏 게이트는 있다. src/gradle/public-path-snapshot.gradle 이 승인 없는 변경을 빌드에서 막는다고 되어 있다. 그런데 그것이 읽는 두 파일의 성격이 다르다. 기준선 docs/security/public-paths-snapshot.txt 는 저장소에 있지만, 값을 가져오는 .env 는 .gitignore:7 이 막는 운영자 입력이다.
그 파일이 없으면 게이트는 비교하지 않고 missing public-path environment file 로 던진다. 이 태스크를 부르는 자리는 워크플로 셋에 네 곳이다. ci-quality-gates.yml 이 서로 다른 두 잡에서 한 번씩, 나머지 둘이 한 번씩이다. 워크플로 전체를 훑어도 .env 가 나오는 곳은 트리거 경로 목록 둘뿐이고, 그 파일을 만드는 단계는 어느 워크플로에도 없다. 그러니 실제로 맞춰 보는 것은 운영자 기계의 값이다. 배포 시점 환경 변수는 그 경로에도 들어오지 않는다. 게이트가 제한 패턴을 언급하는 횟수도 0 이다.
생성자가 막는 것과 막지 못하는 것
// RestrictedPathRule.java:30-34
if (requiredAuthorities.isEmpty()) {
throw new IllegalArgumentException(
"requiredAuthorities must not be empty: a rule that requires nothing is weaker than the "
+ "authenticated default it replaces");
}
빈 권한 목록은 거부하고 그 이유를 문구에 적는다. 아무것도 요구하지 않는 규칙은 그것이 대체하는 인증된 기본값보다 약하다는 것이다.
같은 논리를 규칙 전체에 적용하면 이 사례가 된다. 규칙 전체가 건너뛰어지면 그것도 기본값보다 약하다. 그 경우는 아무 데서도 걸리지 않는다.
확인하지 못한 것
세션 모드에서는 돌려 보지 않았다. 프로브가 세운 것은 JWT 모드의 사슬이고, 등록 블록은 두 모드가 갈리기 전에 있으므로 순서는 같다.
프로브는 애플리케이션 사슬 하나만 감쌌다. 프로덕션에는 액추에이터 사슬이 더 있고, 스위치가 켜지면 콜백 사슬도 붙는다. 둘 다 경로 매처가 관리 경로와 겹치지 않아 판정이 달라지지 않는다고 읽었을 뿐 돌려 보지는 않았다. 관리 컨텍스트 쪽은 포트 판정과 설정 선택까지만 물었고, 애플리케이션을 두 포트가 같은 설정으로 실제로 띄운 것은 아니다.
200 은 프로브가 사슬 끝에 단 종단이 적는 값이지 컨트롤러의 응답이 아니다. 인증된 호출자도 토큰을 제시해 만든 것이 아니라 사슬이 읽는 자리에 직접 넣은 것이라, 이 왕복은 디코더와 변환기를 지나지 않는다.