Trino 에 접근제어 붙이기 : Google Workspace Secure LDAP 을 Ranger 에 연결하기

Google Workspace Secure LDAP 을 usersync 로 Apache Ranger 에 연결하는 구성
💡
이 글은 Trino 접근제어 시리즈의 2편입니다. Apache Ranger 가 무엇이고 어떻게 동작하는지는 [1편](https://www.kimsehwan96.com/trino-access-control-with-apache-ranger-1/)에서 다뤘습니다. Apache Ranger 2.7.0 기준으로 작성되었습니다.

들어가며

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 값 교체로 끝난다.

인증서가 usersync 까지 가는 경로. PEM 에서 PKCS12 로의 변환은 ExternalSecrets 컨트롤러가 담당한다
인증서가 usersync 까지 가는 경로. PEM 에서 PKCS12 로의 변환은 ExternalSecrets 컨트롤러가 담당한다

이렇게 만든 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=fooBarBaz

trustStore 는 별도로 만들지 않고 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.javagetUsers() 는 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 가 관리한다.

동기화 경로와 문제 확인 지점. 두 API 의 응답을 비교하면 어느 구간이 문제인지 갈린다
동기화 경로와 문제 확인 지점. 두 API 의 응답을 비교하면 어느 구간이 문제인지 갈린다
# 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 의 비교로 문제 구간을 좁힐 수 있다.