Trino 에 접근제어 붙이기 : Google Workspace Secure LDAP 을 Ranger 에 연결하기
들어가며
1편에서 Ranger 는 자기 DB 에 있는 유저와 그룹에 대해서만 정책을 판정 할 수 있고, 그 목록은 usersync 라는 컴포넌트가 LDAP 을 보고 채워준다는 이야기를 했다. 이번 편은 usersync 에 Google Workspace Secure LDAP 을 소스로 붙이는 과정을 다룬다.
작업은 크게 네 가지였다.
- usersync 컨테이너 이미지 빌드 (공식 이미지가 없다)
- mTLS 클라이언트 인증서 구성
- install.properties 주입
- delta sync 관련 소스 패치
왜 Google Workspace Secure LDAP 인가
사내 유저 정보의 원천은 Google Workspace 다. 입사하면 계정이 생기고 팀을 옮기면 그룹이 바뀌고 퇴사하면 비활성화되는 흐름이 전부 거기서 일어난다. 이 상황에서 OpenLDAP 같은 디렉터리 서버를 새로 세우면 이번에는 그 서버와 Google Workspace 사이의 동기화가 또 필요해지는데, 접근제어를 붙이려다 디렉터리 서버 운영이라는 일을 하나 더 만드는 구조라 제외했다.
Google Workspace 에는 Secure LDAP 이라는 기능이 있는데, LDAP 서버를 직접 프로비저닝하지 않아도 Workspace 에 등록된 유저와 그룹 정보를 LDAP 프로토콜로 조회 할 수 있다. 추가 인프라 없이 기존 유저 정보의 단일 출처를 그대로 이어받는 셈.
대신 표준 LDAP 이라면 있을 운영 속성 몇 가지를 제공하지 않는다. 이 이야기는 delta sync 섹션에서 이어진다.
Secure LDAP 는 mTLS 가 필수다
Google Admin 콘솔에서 LDAP 클라이언트를 만들면 접속 자격이 두 겹으로 나온다.
- 클라이언트 인증서 (cert + key) : 연결 자체를 mTLS 로 맺는 데 필요하다. 이것이 없으면
ldaps://ldap.google.com:636연결이 성립하지 않는다. - bind 자격증명 (username + password) : LDAP bind 용 계정 정보다.
공식 문서는 stunnel 같은 프록시에 인증서를 물려 mTLS 를 대신 처리하게 하는 구성을 안내하는데, usersync 는 JVM 에서 돌기 때문에 프록시 없이 Java keystore 로 직접 처리 할 수 있다. 다만 Google 이 내려주는 것은 PEM 형식의 cert 와 key 두 파일이고 Java 쪽은 PKCS12 keystore 를 원해서 변환이 필요한데, openssl 로 변환한 p12 파일을 시크릿으로 넣으면 인증서를 갱신 할 때마다 누군가 로컬에서 변환을 반복해야 한다. 우리는 시크릿 관리를 ExternalSecrets 로 하고 있어서 템플릿 함수 pemToPkcs12Pass 를 사용했다.
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: ranger-ldap-keystore
spec:
target:
name: ranger-ldap-keystore
template:
engineVersion: v2
data:
# PEM cert + key -> PKCS12
ldap-keystore.p12: |
{{ pemToPkcs12Pass .ldap_cert .ldap_key "<keystore-password>" | b64dec }}
data:
- secretKey: ldap_cert
remoteRef:
key: ops/ranger
property: ldap_cert # PEM 인증서
- secretKey: ldap_key
remoteRef:
key: ops/ranger
property: ldap_key # PEM 개인키Secrets Manager 에는 Google 이 준 PEM 두 개를 그대로 넣어두고, 쿠버네티스 시크릿으로 렌더링되는 시점에 PKCS12 로 변환되는 구성. 인증서 갱신이 Secrets Manager 값 교체로 끝난다.

이렇게 만든 keystore 를 usersync 파드에 마운트하고 JVM 옵션으로 지정해주면 mTLS 쪽은 끝난다.
env:
- name: JAVA_OPTS
value: >-
-Djavax.net.ssl.keyStore=/ldap-cert/ldap-keystore.p12
-Djavax.net.ssl.keyStoreType=PKCS12
-Djavax.net.ssl.keyStorePassword=<keystore-password>
-Djavax.net.ssl.trustStore=/opt/java/openjdk/jre/lib/security/cacerts
-Djavax.net.ssl.trustStorePassword=fooBarBaztrustStore 는 별도로 만들지 않고 JDK 에 기본으로 들어있는 cacerts 를 그대로 썼다. ldap.google.com 의 서버 인증서는 공인 CA 체인이라 기본 truststore 로 검증이 된다.
usersync 를 띄우기 전에 자격이 유효한지 ldapsearch 로 먼저 확인해보기로 했다. 커맨드라인에서는 클라이언트 인증서를 LDAPTLS_CERT / LDAPTLS_KEY 환경변수로 물릴 수 있는데, 인증서까지 제대로 넣고도 bind 가 계속 이렇게 실패했다.
$ LDAPTLS_CERT=~/foo/ldap_cert.crt \
LDAPTLS_KEY=~/foo/ldap_cert.key \
ldapsearch -x -LLL -H ldaps://ldap.google.com:636 \
-b "ou=Users,dc=example,dc=com" \
-D "<bind-user>" -W \
'(mail=user@example.com)'
Enter LDAP Password:
ldap_bind: Invalid credentials (49)
additional info: Incorrect password비밀번호를 다시 확인하고, bind DN 형식을 이리저리 바꿔보고, Secure LDAP 서비스 상태가 OFF 인 것 아닌가? 하는 의심까지 했는데 전부 아니었다. 원인은 클라이언트 쪽으로, macOS 에 기본 설치된 ldapsearch 는 SNI 를 지원하지 않는 빌드라 Google 쪽과의 인증이 성립하지 않는다. brew install openldap 으로 설치한 ldapsearch (/opt/homebrew/opt/openldap/bin/ldapsearch) 를 쓰면 같은 명령이 그대로 통과한다. Google 문서의 연결 테스트 안내에도 OpenLDAP 클라이언트 요구사항이 정리되어 있다.
usersync 공식 이미지는 없다
Ranger Admin 은 Docker Hub 에 apache/ranger 로 공식 이미지가 있는데 usersync 는 없다. apache/ranger-usersync 라는 저장소 자체가 없어서, 소스에서 직접 빌드해야 한다.
빌드에 필요한 것들은 레포의 dev-support/ranger-docker 디렉토리에 모여있다. 이번 작업과 관련된 부분만 추리면 이런 구조다.
dev-support/ranger-docker/
├── Dockerfile.ranger-usersync # usersync 이미지 정의
├── docker-compose.ranger-usersync.yml
├── scripts/
│ ├── ranger-usersync.sh # 컨테이너 ENTRYPOINT
│ └── ranger-usersync-install.properties
└── dist/
└── ranger-2.7.0-usersync.tar.gz # Maven 빌드 산출물을 여기에 둔다빌드는 2단인데, 먼저 Maven 으로 usersync tar.gz 를 만들어 dist/ 에 두면 Dockerfile.ranger-usersync 가 그 tar.gz 를 풀어서 이미지로 만든다.
ARG RANGER_BASE_IMAGE
ARG RANGER_BASE_VERSION
FROM ${RANGER_BASE_IMAGE}:${RANGER_BASE_VERSION}
ARG USERSYNC_VERSION
COPY ./dist/ranger-${USERSYNC_VERSION}-usersync.tar.gz /home/ranger/dist/
RUN tar xvfz /home/ranger/dist/ranger-${USERSYNC_VERSION}-usersync.tar.gz --directory=${RANGER_HOME} && \
...Maven 빌드 커맨드는 최종적으로 이렇게 되었다.
$ mvn clean install -DskipTests -Denunciate.skip=true포인트는 -Denunciate.skip=true 쪽인데. enunciate 는 REST API 문서를 생성하는 Maven 플러그인으로, 빌드 중 duplicate class 에러를 내면서 실패했다. 로컬 ~/.m2 캐시 상태와 겹치면 나는 문제로 보이는데, 문서 생성은 이번 목적과 무관한데도 -DskipDocs 로는 꺼지지 않았고 플러그인 자체를 skip 해야 통과했다.
베이스 이미지는 업스트림이 제공하는 apache/ranger-base 를 그대로 썼다. 태그가 20251023-1-8, 20251023-1-11, 20251023-1-17 같은 식으로 나뉘는데, 끝자리가 JDK 버전. 2.7.0 의 런타임 스크립트들은 아직 Java 8 전제가 남아있어서 -8 태그를 골랐다.
노드 그룹에 arm64 와 amd64 가 섞여있는 환경이라 buildx 로 멀티 아키텍처 빌드해서 사내 레지스트리에 올렸다.
docker buildx build \
--platform linux/arm64,linux/amd64 \
--tag <registry>/ranger-usersync:2.7.0 \
-f Dockerfile.ranger-usersync \
--build-arg RANGER_BASE_IMAGE=apache/ranger-base \
--build-arg RANGER_BASE_VERSION=20251023-1-8 \
--build-arg USERSYNC_VERSION=2.7.0 \
--push .install.properties 는 ConfigMap 과 시크릿을 합쳐서 만든다
usersync 의 설정은 install.properties 파일 한 장이다. 기동 시 setup.py 가 이 파일을 읽어 실제 런타임이 읽는 XML 설정으로 변환하는데, 쿠버네티스에 올리면서는 세 가지 처리가 필요했다.
첫번째는 비밀값 분리다. install.properties 에는 LDAP bind 비밀번호 같은 민감한 값이 섞여 들어간다. 파일 전체를 ConfigMap 으로 두면 비밀값이 git 에 남고, 파일 전체를 손으로 시크릿으로 만들면 나머지 설정의 변경 이력이 사라진다. ExternalSecrets 의 templateFrom 을 쓰면 설정 본문은 ConfigMap 템플릿에 두고 민감값만 Secrets Manager 에서 가져와 렌더링 할 수 있다.
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: ranger-usersync-install-properties-secret
spec:
target:
name: ranger-usersync-install-properties
template:
engineVersion: v2
templateFrom:
- configMap:
name: ranger-usersync-install-properties-template
items:
- key: install.properties
data:
- secretKey: ldap_user
remoteRef:
key: ops/ranger
property: ldap_user
- secretKey: ldap_password
remoteRef:
key: ops/ranger
property: ldap_password설정 파일은 git 에서 리뷰되고 비밀값은 git 에 없는 형태가 된다.
두번째는 쓰기 가능한 마운트다. 이 시크릿을 subPath 로 install.properties 자리에 마운트하면 끝 아닌가? 싶었지만, setup.py 가 기동 과정에서 이 경로에 쓰기를 시도한다. 시크릿 마운트는 read-only 라 그대로는 기동이 실패해서, init container 가 시크릿 내용을 emptyDir 로 복사하고 본 컨테이너는 그 복사본을 마운트한다.
initContainers:
- name: prepare-config
command:
- sh
- -c
- |
cp /config-template/install.properties /workdir/install.properties
chmod 644 /workdir/install.properties세번째는 install.properties 에 항목이 없는 설정이다. 삭제된 유저를 soft delete 처리하는 ranger.usersync.deletes.enabled 같은 값은 install.properties 에 대응 항목이 없어서, usersync 가 읽는 XML 설정 파일인 ranger-ugsync-default.xml 에 직접 넣어야 한다. setup.py 가 기동 시 conf.dist 디렉토리의 설정 파일들을 conf 로 복사하기 때문에, conf.dist/ranger-ugsync-default.xml 자리에 우리 파일을 마운트해뒀다.
<!-- LDAP 에서 사라진 유저/그룹을 Ranger 에서도 (soft) delete -->
<property>
<name>ranger.usersync.deletes.enabled</name>
<value>true</value>
</property>
<!-- 삭제 체크 주기. sync cycle 단위 -->
<property>
<name>ranger.usersync.deletes.frequency</name>
<value>10</value>
</property>Google Workspace 라서 표준 LDAP 과 다른 부분도있고, 우리 상황에 맞게 설정하다보니 LDAP 관련 설정은 아래와 같이 하게되었다.
SYNC_LDAP_USER_NAME_ATTRIBUTE=mail: 유저명 속성으로 흔히 쓰는cn이나uid대신 이메일을 쓴다. Trino 가 인증에서 넘겨주는 아이덴티티가 이메일이라, Ranger 에 등록되는 유저명도 이메일이어야 정책 판정이 같은 키로 맞아떨어진다.SYNC_LDAP_USER_OBJECT_CLASS=person: Google 쪽 유저 엔트리의 objectClass 가 person 이다.SYNC_GROUP_MEMBER_ATTRIBUTE_NAME=member: POSIX 그룹의memberUid가 아니라 groupOfNames 의member속성으로 멤버십이 표현된다. 값도 uid 가 아니라 멤버의 DN 이다.SYNC_LDAP_USER_SEARCH_FILTER=: memberOf 기반 필터가 기대대로 동작하지 않는 경우가 있어서 필터 없이 전체를 받고 있다.SYNC_LDAP_USERNAME_CASE_CONVERSION=lower: LDAP 은 대소문자를 구분하지 않지만 Ranger 의 유저명은 구분한다. 어느 쪽 표기가 들어와도 한 유저로 모이도록 소문자로 통일한다.
delta sync 는 두 곳에서 동작하지 않았다
1편에서 이야기한 delta sync 문제를 마저 정리한다. usersync 의 delta sync 는 uSNChanged(Active Directory) 나 modifyTimestamp(표준 LDAP 운영 속성) 를 검색 필터에 넣어 변경분만 가져오는 방식인데, Google Workspace Secure LDAP 은 이 두 속성을 제공하지 않는다.
- LdapUserGroupBuilder.java 의
getUsers()는 delta sync 가 꺼져있어도 검색 필터에 해당 조건을 무조건 붙인다. 두 속성이 없는 서버에서는 매칭되는 엔트리가 0건이 되어 유저가 한 명도 동기화되지 않는다. - setup.py#L259 는 LDAP 소스이기만 하면
ranger.usersync.ldap.deltasync를 무조건true로 덮어쓴다. install.properties 에SYNC_LDAP_DELTASYNC=false를 적어도 반영되지 않는다.
포크에 패치를 두 개 넣었다. 필터 쪽은 delta sync 가 꺼져있으면 해당 조건을 붙이지 않도록 분기를 넣었고, setup.py 쪽은 당장은 false 하드코딩으로 대체했다. 업스트림에는 설정값을 존중하는 형태로 정리해서 RANGER-5466 (#827) 로 올려뒀는데, PR 의 변경은 두 부분이다.
LdapUserGroupBuilder.java 쪽은 delta sync 가 꺼져있으면 검색 필터를 objectclass 조건만으로 만든다. (그룹을 읽는 getGroups() 에도 같은 패턴의 분기가 하나 더 있다)
- extendedUserSearchFilter = "(objectclass=" + userObjectClass + ")(|(uSNChanged>=" + deltaSyncUserTime + ")(modifyTimestamp>=" + deltaSyncUserTimeStamp + "Z))";
+ if (config.isDeltaSyncEnabled()) {
+ extendedUserSearchFilter = "(objectclass=" + userObjectClass + ")(|(uSNChanged>=" + deltaSyncUserTime + ")(modifyTimestamp>=" + deltaSyncUserTimeStamp + "Z))";
+ } else {
+ extendedUserSearchFilter = "(objectclass=" + userObjectClass + ")";
+ }setup.py 쪽은 무조건 덮어쓰던 값을, 사용자가 지정하지 않았을 때만 기본값 true 로 채우도록 바꿨다.
- ret['ranger.usersync.ldap.deltasync'] = "true"
+ if ('ranger.usersync.ldap.deltasync' not in ret or len(str(ret['ranger.usersync.ldap.deltasync'])) == 0):
+ ret['ranger.usersync.ldap.deltasync'] = "true"이 글을 쓰는 시점 기준 리뷰 대기 중이고, 우리는 해당 변경이 꼭 필요하니까 포크/빌드해서 사용중이다.
돌아가는지 어떻게 아는가
usersync 는 60분 주기로 Workspace 전체 유저와 그룹을 full sync 한다. 매 사이클이 무엇을 몇 건 만들고 바꿨는지는 Ranger Admin 의 audit 화면에 남고, 동기화가 살아있는지는 거기서 확인한다. delta sync 를 못 쓰는 대가로 매번 전체를 훑지만, 우리 조직 규모에서는 한 사이클이 부담스러운 수준은 아니었다.
사이클 하나가 끝날 때의 로그는 이런 모습이다. (실제 로그에서 이메일만 바꿨다)
INFO o.a.r.u.p.PolicyMgrUserGroupBuilder [UnixUserSyncThread] - user alice@example.com already marked for delete
INFO o.a.r.u.p.PolicyMgrUserGroupBuilder [UnixUserSyncThread] - user bob@example.com already marked for delete
INFO o.a.r.u.p.PolicyMgrUserGroupBuilder [UnixUserSyncThread] - No. of users marked for delete = 0
INFO o.a.r.l.p.LdapUserGroupBuilder [UnixUserSyncThread] - deltaSyncUserTime = 0 and highestdeltaSyncUserTime = 0
INFO o.a.r.l.p.LdapUserGroupBuilder [UnixUserSyncThread] - deltaSyncGroupTime = 0 and highestdeltaSyncGroupTime = 0
INFO o.a.r.u.UserGroupSync [UnixUserSyncThread] - End: update user/group from source==>sink
INFO o.a.r.u.UserGroupSync [UnixUserSyncThread] - Sleeping for [3600000] milliSeconds읽을 만한 지점이 셋 있다.
deltaSyncUserTime = 0: delta sync 를 끈 상태가 로그에 그대로 드러난다. 변경분 조회의 기준 시각이 epoch(0) 으로 고정되어 매번 전체를 읽는다.already marked for delete: LDAP 에서 사라진 (퇴사한) 유저들이 soft delete 상태로 유지되고 있다는 표시다.Sleeping for [3600000] milliSeconds:SYNC_INTERVAL=60그대로, 다음 사이클까지 60분 대기한다.
참고로 이 로그가 kubectl logs 로 바로 보이는 것부터가 포크에서 손본 결과다. 1편에서 이야기했듯 원래는 기동 스크립트가 로그를 파일로 리다이렉트하고 있어서 파드 안에 들어가야 볼 수 있었다.
동기화 경로는 LDAP -> usersync -> Ranger Admin(DB) -> 엔진 플러그인(userstore 캐시) 로 이어지는데, "유저의 그룹이 정책에 반영이 안 되는것 같다" 는 문의가 오면 Ranger Admin 의 API 두 개를 비교해서 문제 구간을 좁힐 수 있다. 참고로 usersync 가 저장한 결과는 Ranger DB 의 x_user , x_group , x_group_users 테이블에 들어가고, 플러그인에 내려줄 userstore 의 버전은 x_ranger_global_state 가 관리한다.

# Ranger DB 에 저장된 유저 정보 (usersync 가 넣은 결과)
curl -u "admin:$PWD" "http://ranger:6080/service/xusers/users/userName/{user}"
# 엔진 플러그인이 내려받는 userstore (버전 기반 캐시)
curl -u "admin:$PWD" "http://ranger:6080/service/xusers/secure/download/{serviceName}"| xusers API (DB) | userstore API (플러그인용) | 문제 위치 |
|---|---|---|
| 그룹 없음 | 그룹 없음 | usersync 이전. LDAP 값과 동기화 로그부터 확인 |
| 그룹 있음 | 그룹 없음 | Ranger Admin 의 userstore 캐시. 버전 갱신이 안 된 것 |
| 그룹 있음 | 그룹 있음 | 엔진 쪽 플러그인 캐시. 엔진이 새 버전을 아직 안 받은 것 |
가운데 행은 실제로 겪은 케이스다. 그룹 멤버십이 바뀌어도 userstore 버전이 올라가지 않아 캐시가 이전 상태로 남는 버그였고, 1편 말미의 표에 있던 RANGER-5463 (#824) 이 이것이다.
마치며
- Google Workspace Secure LDAP 은 디렉터리 서버를 새로 세우지 않고 기존 유저 단일 출처를 이어받는 선택지다. 대신 mTLS 클라이언트 인증서가 필수고, 표준 LDAP 운영 속성 일부가 없어서 delta sync 를 쓸 수 없다.
- PEM 에서 PKCS12 로의 변환은 ExternalSecrets 의
pemToPkcs12Pass로 컨트롤러에 맡길 수 있다. 인증서 갱신이 Secrets Manager 값 교체로 끝난다. - usersync 는 공식 이미지가 없어 소스 빌드가 필요하다. Maven 빌드는
-Denunciate.skip=true가 필요했고, 베이스 이미지는 JDK 8 태그를 쓴다. - 민감한 값이 섞인 설정 파일은 ExternalSecrets
templateFrom으로 ConfigMap 템플릿과 시크릿 값을 합쳐 렌더링하면 git 리뷰와 비밀 관리를 둘 다 챙길 수 있다. - delta sync 는 코드와 설치 스크립트 두 곳에서 막혀 있었고, 포크 패치로 풀어서 업스트림 PR 로 올렸다.
- 동기화 문제는 xusers API 와 userstore API 의 비교로 문제 구간을 좁힐 수 있다.