Stan 기술블로그

Keycloak realm 하나 바꾸는 데 커밋 여섯 번

사내 모니터링 콘솔에 로그인하면 다시 로그인 화면으로 돌아왔다. 원래 realm은 내게 관리 권한이 없어 고칠 수 없었고, 그래서 내가 관리하는 다른 realm으로 급하게 우회했다. realm 이름 한 줄이면 될 줄 알았는데 커밋이 여섯 번 필요했다. 서버에 들어갈 수 없고 SVN 커밋이 곧 배포인 곳에서, web.xml은 배포되지 않았고 서버에 남은 환경변수는 코드 기본값을 이겼다. 우회는 7월 31일에 붙었고, 8월 3일 원래 realm으로 되돌리면서 코드에 박았던 시크릿은 문째로 없앴다.

알림 글과 세션 글에서 “전례”로 언급한 바로 그 일이다. 콘솔을 정식으로 인수하기 한 달 전, 7월 말의 일이다.

(사내 인프라 특성상 realm·도메인·사람 이름은 일반화했다. 시스템 이름은 “콘솔”로 부른다.)


로그인하면 다시 로그인 화면

콘솔은 Keycloak으로 로그인한다(구글 계정 연동). 그런데 구글 로그인을 마치고 돌아오면 콘솔이 ROLE_REQUIRED를 띄우고 다시 로그인 화면으로 보냈다. 무한 루프다.

콘솔은 ID 토큰 안의 역할(role) 클레임을 보고 들여보낼지 정한다. 원래 realm(console-realm)에서 그 역할을 토큰에 넣으려면 구글 IDP 매퍼와 역할을 손봐야 했는데, 나는 그 realm의 관리 권한이 없었다. 관리자는 휴가 중이었다.

손에 있는 건 이것뿐이었다.

  • 서버(Tomcat) 셸 접근 없음.
  • SVN 커밋 → 자동 빌드·배포. 커밋이 곧 운영 반영이고, 스테이징은 없다.
  • 내가 관리자인 다른 realm(team-realm).

그래서 정한 우회: 콘솔이 붙는 realm을 team-realm으로 바꾸고, 거기에 클라이언트·역할·매퍼를 내가 직접 만든다.

에러 코드 두 개가 나침반이었다

서버 로그를 볼 수 없으니, 커밋할 때마다 손에 쥐는 단서는 로그인 화면의 에러 코드뿐이었다. 둘을 이렇게 읽었다.

화면에 뜬 것 뜻 어디까지 갔나
ROLE_REQUIRED 토큰 교환은 성공, 역할이 없음 realm에는 붙었다. 인가에서 막힘
LOGIN_FAILED 토큰 교환 자체가 실패 시크릿·리다이렉트 같은 클라이언트 설정이 안 맞음
커밋 전과 같은 에러 — 배포가 안 됐을 수 있다

콘솔이 지금 어느 realm에 붙어 있는지도 바로 보인다. 로그인할 때 브라우저가 튕겨 가는 주소 auth.example.com/realms/<이름>/...에 realm 이름이 그대로 있다. 배포가 반영됐는지 판정하는 가장 빠른 방법이었다.

커밋 여섯 번

리비전 바꾼 것 결과
r69 realm 기본값을 team-realm으로 LOGIN_FAILED
r70 되돌림 ROLE_REQUIRED
r71 리스너로 realm·시크릿 주입 (web.xml에 등록) ROLE_REQUIRED — 아무것도 안 바뀜
r72 리스너 삭제, 코드 기본값을 직접 수정 LOGIN_FAILED
r73 시크릿을 코드에 강제 고정 로그인 성공
r74 원래 realm으로 복귀 로그인 성공

[1차] realm 이름만 바꿨다

OidcSupport의 realm 기본값을 team-realm으로 바꿨다. 결과는 LOGIN_FAILED. 에러 종류가 바뀌었으니 realm은 바뀐 것이다. 다만 서버에 들어 있는 시크릿은 옛 realm 클라이언트의 것이라 토큰 교환이 실패했다. 새 시크릿을 서버에 넣을 방법이 없어서 r70으로 되돌렸다.

[2차] 리스너로 주입했다 — 아무것도 안 바뀌었다

서버 셸 없이 시크릿을 넣으려고, 앱이 뜰 때 realm과 시크릿을 넣어 주는 ServletContextListener를 만들어 web.xml에 등록했다. 결과는 ROLE_REQUIRED. 커밋 전과 똑같다. 위 표의 셋째 줄, “배포가 안 됐다”다.

답은 코드 안에 있었다. 배포 리비전을 기록하는 BuildMetadata.java의 주석이다.

publishes compiled Java classes but does not copy non-Java source resources

배포기는 바뀐 .java를 컴파일해 옮길 뿐, web.xml 같은 리소스는 옮기지 않는다. 그래서 이 앱은 배포 리비전조차 .properties가 아니라 .java 상수로 박아 두고 있었다. 리스너 클래스는 배포됐지만 그걸 등록하는 web.xml이 운영에 없으니, 리스너는 한 번도 불리지 않았다.

이 사실은 이후 콘솔 작업 전부의 전제가 됐다. 설정을 넣으려면 .java 안에서 넣는다. (한참 뒤, 앱 설정 폴더의 일부 XML은 재시작 없이 반영된다는 것도 확인했다. 그래도 web.xml은 아니다.)

[3차] 코드 기본값을 바꿨다 — 서버 환경변수가 이겼다

리스너를 지우고 OidcSupport의 기본값(realm, 시크릿)을 직접 고쳤다. LOGIN_FAILED. realm은 바뀌었는데 시크릿이 여전히 안 먹었다.

시크릿은 ServerSecrets.get(key, 기본값)으로 읽는데, 우선순위가 이렇다.

System property  >  환경변수(SMON_*)  >  코드 기본값

코드 기본값은 마지막 순서다. 서버에는 옛 realm 시절의 SMON_OAUTH_CLIENT_SECRET 환경변수가 남아 있었고, 그게 내가 바꾼 기본값을 이겼다.

[4차] 시크릿을 코드에 고정했다

환경변수를 보지 않고 코드에 박힌 값을 쓰게 고정했다. 로그인 → 대시보드 진입. 7월 31일, 우회가 붙었다.

대가가 있었다. 시크릿이 SVN 히스토리에 남았다. 프로젝트 문서에 “시크릿은 SVN에 넣지 않는다”가 적혀 있는 곳이다. 급해서 감수했고, 이 빚은 되돌릴 때 갚았다.

Keycloak 쪽에서도 한 번 — 역할은 담는 바구니가 맞아야 한다

코드 말고 Keycloak 설정에서도 ROLE_REQUIRED를 한 번 만났다.

  • 콘솔이 읽는 smon_roles 클레임은 User Realm Role 매퍼가 만든다. 즉 realm role만 담는다.
  • 처음에 console-admin을 client role(resource_access.smon)로 줬다. 매퍼가 읽지 않는 바구니라 토큰에 역할이 없었다.
  • console-admin을 realm role로 다시 부여하자 해결됐다.
  • 매퍼는 Add to ID token = ON이어야 한다. 이 코드는 ID 토큰만 읽는다.

진단은 Keycloak 관리 화면의 Clients → (클라이언트) → Client scopes → Evaluate → Generated ID Token에서 했다. 로그인을 반복하지 않고도, 이 사용자로 로그인하면 ID 토큰에 무엇이 들어가는지 미리 볼 수 있다.

되돌리기 — 시크릿은 바꾸지 않고 문을 없앴다

8월 3일, 원래 realm의 관리 권한을 확보했다(관리자가 휴가에서 돌아왔다). 원래 realm에 같은 구성을 갖췄다. 클라이언트, 구글 IDP, realm role console-admin·console-viewer, User Realm Role 매퍼(ID 토큰 포함)다.

그리고 r74로 코드를 원래대로 돌렸다. realm 기본값은 원래 realm으로, 시크릿은 다시 ServerSecrets.get(...)(환경변수)으로.

이번엔 서버 접근이 필요 없었다. 서버 환경변수에 남아 있던 시크릿이 원래 realm의 것이기 때문이다. 처음 증상이 LOGIN_FAILED가 아니라 ROLE_REQUIRED였다는 게 증거다. 토큰 교환은 처음부터 성공하고 있었다. 3차에서 나를 막았던 그 환경변수가, 돌아올 땐 그대로 맞는 값이었다.

r74 커밋은 서버 쪽 pre-commit 훅에 한 번 막혔다.

svn: E165001: Commit blocked by pre-commit hook

다시 시도해도 같았고, 클라이언트에서 우회할 방법이 없다. 배포 담당이 훅을 복구한 뒤에야 커밋이 들어갔다.

남은 건 r73이 SVN에 남긴 시크릿이다. 값을 바꾸는(rotate) 대신, team-realm의 콘솔 클라이언트를 통째로 삭제했다. 클라이언트가 없으면 그 시크릿으로 열 문도 없다. 값은 히스토리에 남아 있지만 죽은 값이다. 우회 realm에서 깨져 보이던 로그인 테마도 원래 realm으로 돌아오며 저절로 해결됐다.

마치며

realm 이름 한 줄이면 끝날 줄 알았던 일에 커밋이 여섯 번 들었다. 네 번은 우회를 붙이는 데, 두 번은 되돌리는 데 썼다. 실패한 커밋들이 남긴 건 이 콘솔이 어떻게 배포되고 설정을 읽는지에 대한 지도였다.

진짜로 배운 것

  1. 커밋했는데 그대로라면 코드보다 배포를 의심한다. 배포 파이프라인이 무엇을 옮기는지 먼저 안다. 이 콘솔에서 web.xml은 옮겨지지 않는다.
  2. 설정 우선순위를 안다. 코드 기본값은 마지막 순서다. 서버에 남은 옛 값이 새 기본값을 조용히 이긴다.
  3. 에러 코드를 “어디까지 갔나”로 읽는다. ROLE_REQUIRED와 LOGIN_FAILED는 같은 실패가 아니라 서로 다른 지점에서 멈춘 것이다.
  4. 급한 우회는 되돌릴 계획까지가 한 세트다. 박아 넣은 시크릿은 값을 바꾸거나, 이번처럼 그 시크릿이 여는 문을 없앤다.

사내 모니터링 콘솔 카테고리의 글