Two Authorizers, Two Envoys
한국어 원문으로 표시합니다.
목표
HTTP·gRPC 두 방식의 인가 서버를 직접 파이썬으로 쓰고 각각을 묻는 Envoy 를 띄워, 헤더가 어디로 흐르는지와 인가 서버가 죽었을 때 무엇이 일어나는지를 잰다.
왜 중요한가
인가를 서비스마다 코드로 넣으면 서비스 수만큼 서로 다른 인가가 생긴다. 판단을 한곳에 모으면 이번에는 그 한곳이 모든 요청의 경로 위에 놓인다 — 그 서버가 죽었을 때 막을지 흘릴지를 정하지 않으면, 장애 때 가용성과 보안 가운데 하나를 모르는 사이에 잃는다. Istio 의 CUSTOM 인가가 뒤에서 쓰는 것도 이 필터라, 여기서 본 헤더 규칙과 실패 동작이 메시에서도 그대로 나타난다.
단계
/root/envd-authz/authz_http.py에127.0.0.1:9191에서 듣는 인가 서버를 쓰고 백그라운드로 띄우세요.Authorization: Bearer alice-token이면 사용자alice,bob-token이면bob으로 보고 200 과 응답 헤더x-auth-user: <사용자>를, 토큰이 없거나 모르는 값이면 403 과 응답 헤더x-deny-reason: missing-or-unknown-token을 돌려줍니다. 요청마다/root/envd-authz/authz-http.log에 JSON 한 줄(path·user·tenant(x-tenant 헤더 값)·allowed)을 덧붙입니다.- 업스트림
python3 /opt/lab/envoy/echo.py 8091(받은 요청을 JSON 으로 돌려줌)를 띄우고,/root/envd-authz/envoy-http.yaml에 관리 포트9981, 리스너127.0.0.1:10081의 설정을 써서 띄우세요 (--base-id 11). HTTP 필터 사슬 맨 앞에envoy.filters.http.ext_authz를 두고stat_prefix: http_authz,http_service로 클러스터authz_http(127.0.0.1:9191)에 묻게 합니다. 인가 서버가 준x-auth-user는 업스트림으로,x-deny-reason은 거절 응답과 함께 클라이언트로 넘기고,failure_mode_allow는 false 입니다. - 리스너
10081로 세 요청을 보내고 결과를/root/envd-authz/03-http.txt에 네 줄로 적으세요 — 토큰 없이 보낸 요청의 상태 코드no_token=, 그 응답의x-deny-reason값deny_reason=,bob-token과 위조한 헤더x-auth-user: mallory를 함께 보냈을 때 업스트림이 받은x-auth-user값spoof_upstream=, 그리고alice-token에x-tenant: acme를 붙인 요청이 인가 서버 로그에 남긴tenant값http_saw_tenant=(값이 없으면none). /root/envd-authz/authz_grpc.py에envoy.service.auth.v3.Authorization/Check를 구현해127.0.0.1:9192에서 띄우세요(/opt/xds/bin/python3로 실행합니다 — grpcio 와 Envoy 의 protobuf 가 든 가상 환경입니다). 판단은 HTTP 판과 같되, 경로가/public으로 시작하면 토큰 없이 허용하고 이때 사용자는anonymous입니다. 허용이면OkHttpResponse에 헤더x-auth-user를, 거절이면DeniedHttpResponse에 상태 403·헤더x-deny-reason: grpc-missing-or-unknown-token·본문을 담습니다. 요청마다/root/envd-authz/authz-grpc.log에 같은 모양의 JSON 한 줄을 남깁니다./root/envd-authz/envoy-grpc.yaml에 관리 포트9982, 리스너127.0.0.1:10082인 두 번째 Envoy 를 써서 띄우세요(--base-id 12). ext_authz 는stat_prefix: grpc_authz,grpc_service의envoy_grpc로 클러스터authz_grpc(127.0.0.1:9192, HTTP/2)를 부르고,failure_mode_allow: true와failure_mode_allow_header_add: true를 켭니다. 첫 Envoy 는 그대로 둡니다./root/envd-authz/envoy-grpc.yaml에 경로가 정확히/healthz인 라우트를 맨 앞에 더해, 본문ok로 200 을 직접 돌려주고(direct_response) 그 라우트에서만 ext_authz 를 끄세요(typed_per_filter_config의ExtAuthzPerRoute로disabled: true). 그 Envoy 를 다시 띄운 뒤 토큰 없이/healthz를 세 번 부르세요.- 두 인가 서버를 모두 멈춘 채로 첫 Envoy 에는
alice-token을 붙인 요청을, 두 번째 Envoy 에는 토큰 없는/orders요청을 보내고/root/envd-authz/07-failure.txt에http_on_error=(첫 Envoy 의 상태 코드),grpc_on_error=(두 번째의 상태 코드),grpc_failure_header=(업스트림이 받은x-envoy-auth-failure-mode-allowed값) 세 줄을 적으세요. 적은 뒤 두 인가 서버를 다시 띄웁니다. /root/envd-authz/08-report.md에 여섯 줄 —fail_closed_code=·fail_open_code=(7단계의 두 코드),spoofed_header_reached_upstream=(위조한mallory가 업스트림에 닿았으면 yes),http_authz_saw_tenant=·grpc_authz_saw_tenant=(각 인가 서버 로그에x-tenant값이 찍혔으면 yes),healthz_asked_authz=(gRPC 인가 서버 로그에/healthz가 있으면 yes) — 을 적고, 그 아래 배운 것을 네 줄 이상 적으세요. gRPC 쪽 값을 확인하려면 두 번째 Envoy 로alice-token과x-tenant: acme를 붙인 요청을 한 번 보내 두세요.
참고
- gRPC 서버는
/opt/xds/bin/python3로 실행합니다. 이 가상 환경에 grpcio 와 Envoy API 의 protobuf(xds-protos)가 들어 있습니다. 시스템python3에는 없습니다. - Envoy 를 두 대 띄웁니다.
--base-id를 서로 다르게(11·12) 주고--concurrency 1을 붙이세요. 하나만 끌 때는curl -X POST localhost:<관리포트>/quitquitquit을 씁니다 —pkill -x envoy는 두 대를 모두 죽입니다. - 백그라운드 프로세스는
setsid --fork nohup <명령> > <로그> 2>&1 </dev/null로 셸에서 떼어 내세요. 그렇지 않으면 터미널을 닫을 때 함께 죽고 다음 단계의 채점이 실패합니다. pkill -f의 패턴은 첫 글자를 대괄호로 감싸세요('[a]uthz_http.py'). 그대로 쓰면 그 문자열이 든 셸 자신이 함께 죽습니다.- 흔한 실수 — gRPC 클러스터에 HTTP/2 를 켜지 않는 것. 두 번째 Envoy 는 실패하면 흘려보내도록 설정했으므로 모든 요청이 통과해 버려 잘못을 알아채기 어렵습니다.
HTTP 인가 서버를 띄운다
/root/envd-authz/authz_http.py 에 127.0.0.1:9191 에서 듣는 인가 서버를 쓰고 백그라운드로 띄우세요. Authorization: Bearer alice-token 이면 사용자 alice, bob-token 이면 bob 으로 보고 200 과 응답 헤더 x-auth-user: <사용자> 를, 토큰이 없거나 모르는 값이면 403 과 응답 헤더 x-deny-reason: missing-or-unknown-token 을 돌려줍니다. 요청마다 /root/envd-authz/authz-http.log 에 JSON 한 줄(path·user·tenant(x-tenant 헤더 값)·allowed)을 덧붙입니다.
ext_authz 의 HTTP 방식에서 인가 서버는 평범한 웹 서버입니다. Envoy 가 원래 요청의 메서드·경로로 다시 요청을 보내고, 2xx 면 허용, 그 밖이면 거절로 읽습니다. 그래서 GET 만이 아니라 모든 메서드에 같은 판단을 해야 합니다. 표준 라이브러리 http.server 로 충분하고, 띄울 때는 setsid --fork nohup python3 … > 로그 2>&1 </dev/null 로 셸에서 떼어 내세요. 채점기는 이 서버에 직접 토큰 셋(alice·bob·없음)을 보내 봅니다.
Envoy 가 요청마다 인가 서버에 묻게 한다
업스트림 python3 /opt/lab/envoy/echo.py 8091(받은 요청을 JSON 으로 돌려줌)를 띄우고, /root/envd-authz/envoy-http.yaml 에 관리 포트 9981, 리스너 127.0.0.1:10081 의 설정을 써서 띄우세요 (--base-id 11). HTTP 필터 사슬 맨 앞에 envoy.filters.http.ext_authz 를 두고 stat_prefix: http_authz, http_service 로 클러스터 authz_http(127.0.0.1:9191)에 묻게 합니다. 인가 서버가 준 x-auth-user 는 업스트림으로, x-deny-reason 은 거절 응답과 함께 클라이언트로 넘기고, failure_mode_allow 는 false 입니다.
필터 순서가 곧 처리 순서입니다. ext_authz 가 router 보다 앞에 있어야 거절된 요청이 업스트림에 닿지 않습니다. 인가 서버의 응답 헤더를 어디로 넘길지는 authorization_response 의 두 목록이 정합니다 — allowed_upstream_headers 는 허용일 때 업스트림 요청에, allowed_client_headers 는 거절일 때 클라이언트 응답에 붙습니다. 이 파드에는 Envoy 를 여럿 띄우므로 --base-id 를 서로 다르게 주고, 끌 때는 curl -X POST localhost:9981/quitquitquit 을 쓰세요.
무엇이 인가 서버로, 무엇이 업스트림으로 갔나
리스너 10081 로 세 요청을 보내고 결과를 /root/envd-authz/03-http.txt 에 네 줄로 적으세요 — 토큰 없이 보낸 요청의 상태 코드 no_token=, 그 응답의 x-deny-reason 값 deny_reason=, bob-token 과 위조한 헤더 x-auth-user: mallory 를 함께 보냈을 때 업스트림이 받은 x-auth-user 값 spoof_upstream=, 그리고 alice-token 에 x-tenant: acme 를 붙인 요청이 인가 서버 로그에 남긴 tenant 값 http_saw_tenant=(값이 없으면 none).
업스트림이 무엇을 받았는지는 echo 가 돌려주는 JSON 의 headers 에 있습니다. 인가 서버가 무엇을 받았는지는 여러분이 남긴 로그에 있습니다. HTTP 방식의 인가 요청에는 Host·Method·Path·Content-Length·Authorization 만 기본으로 실리고, 다른 헤더는 allowed_headers 에 적어야 넘어갑니다. 반대 방향(인가 서버 → 업스트림)에서 allowed_upstream_headers 는 같은 이름의 헤더를 덮어씁니다 — 클라이언트가 신원 헤더를 위조해도 업스트림에는 인가 서버가 정한 값이 닿는다는 뜻입니다.
gRPC 인가 서버를 띄운다
/root/envd-authz/authz_grpc.py 에 envoy.service.auth.v3.Authorization/Check 를 구현해 127.0.0.1:9192 에서 띄우세요(/opt/xds/bin/python3 로 실행합니다 — grpcio 와 Envoy 의 protobuf 가 든 가상 환경입니다). 판단은 HTTP 판과 같되, 경로가 /public 으로 시작하면 토큰 없이 허용하고 이때 사용자는 anonymous 입니다. 허용이면 OkHttpResponse 에 헤더 x-auth-user 를, 거절이면 DeniedHttpResponse 에 상태 403·헤더 x-deny-reason: grpc-missing-or-unknown-token·본문을 담습니다. 요청마다 /root/envd-authz/authz-grpc.log 에 같은 모양의 JSON 한 줄을 남깁니다.
gRPC 방식은 Envoy 가 요청을 다시 보내지 않고 요청의 속성을 CheckRequest 로 넘깁니다. 헤더는 request.attributes.request.http.headers(소문자 이름의 맵), 경로는 .path 에 있습니다. 판정은 CheckResponse.status.code 가 정합니다(google.rpc.code_pb2.OK 면 허용). 모듈 경로는 envoy.service.auth.v3.external_auth_pb2 · _pb2_grpc 입니다. 채점기는 이 서버에 직접 Check 를 세 번 보냅니다.
두 번째 Envoy 는 gRPC 로 묻고, 인가 서버가 없으면 흘려보낸다
/root/envd-authz/envoy-grpc.yaml 에 관리 포트 9982, 리스너 127.0.0.1:10082 인 두 번째 Envoy 를 써서 띄우세요(--base-id 12). ext_authz 는 stat_prefix: grpc_authz, grpc_service 의 envoy_grpc 로 클러스터 authz_grpc(127.0.0.1:9192, HTTP/2)를 부르고, failure_mode_allow: true 와 failure_mode_allow_header_add: true 를 켭니다. 첫 Envoy 는 그대로 둡니다.
gRPC 는 HTTP/2 위에서만 돕니다. 클러스터에 HTTP/2 를 켜지 않으면 Envoy 가 HTTP/1.1 로 붙다가 실패하고, 그 실패는 '인가 서버 오류' 로 세어집니다 — 이 Envoy 는 실패하면 흘려보내도록 설정했으므로 모든 요청이 통과해 버려 설정이 틀린 것을 알아채기 어렵습니다. 클러스터의 typed_extension_protocol_options 에 HttpProtocolOptions 의 explicit_http_config.http2_protocol_options 를 넣으세요.
건강 검사 경로는 인가 서버에 묻지 않는다
/root/envd-authz/envoy-grpc.yaml 에 경로가 정확히 /healthz 인 라우트를 맨 앞에 더해, 본문 ok 로 200 을 직접 돌려주고(direct_response) 그 라우트에서만 ext_authz 를 끄세요(typed_per_filter_config 의 ExtAuthzPerRoute 로 disabled: true). 그 Envoy 를 다시 띄운 뒤 토큰 없이 /healthz 를 세 번 부르세요.
로드밸런서의 건강 검사는 토큰을 모릅니다. 인가를 모든 경로에 걸면 건강 검사가 403 을 받고, 로드밸런서는 멀쩡한 프록시를 빼 버립니다. 필터를 끄는 것은 필터 설정이 아니라 라우트 쪽의 몫입니다 — 같은 필터가 경로마다 다르게 동작하게 하는 장치가 typed_per_filter_config 입니다. 끈 경로는 인가 서버에 아예 가지 않으므로 인가 서버 로그에 /healthz 가 남지 않아야 합니다.
인가 서버가 죽으면 — 막는 쪽과 흘리는 쪽
두 인가 서버를 모두 멈춘 채로 첫 Envoy 에는 alice-token 을 붙인 요청을, 두 번째 Envoy 에는 토큰 없는 /orders 요청을 보내고 /root/envd-authz/07-failure.txt 에 http_on_error=(첫 Envoy 의 상태 코드), grpc_on_error=(두 번째의 상태 코드), grpc_failure_header=(업스트림이 받은 x-envoy-auth-failure-mode-allowed 값) 세 줄을 적으세요. 적은 뒤 두 인가 서버를 다시 띄웁니다.
인가 서버가 응답하지 않으면 Envoy 는 설정에 따라 두 길 중 하나를 고릅니다. 막는 쪽(fail closed)은 status_on_error(기본 403)를 돌려주고, 흘리는 쪽(fail open)은 요청을 그대로 보내면서 원하면 표시 헤더를 붙입니다. 어느 쪽이든 통계의 ext_authz.<stat_prefix>.error 가 오르고, 흘렸다면 failure_mode_allowed 도 오릅니다. 서버를 멈출 때 pkill -f authz_http.py 처럼 쓰면 그 문자열이 든 셸도 함께 죽을 수 있으니 pkill -f '[a]uthz_http.py' 처럼 첫 글자를 대괄호로 감싸세요.
어느 쪽으로 실패할지 정한 근거를 남긴다
/root/envd-authz/08-report.md 에 여섯 줄 — fail_closed_code=·fail_open_code=(7단계의 두 코드), spoofed_header_reached_upstream=(위조한 mallory 가 업스트림에 닿았으면 yes), http_authz_saw_tenant=·grpc_authz_saw_tenant=(각 인가 서버 로그에 x-tenant 값이 찍혔으면 yes), healthz_asked_authz=(gRPC 인가 서버 로그에 /healthz 가 있으면 yes) — 을 적고, 그 아래 배운 것을 네 줄 이상 적으세요. gRPC 쪽 값을 확인하려면 두 번째 Envoy 로 alice-token 과 x-tenant: acme 를 붙인 요청을 한 번 보내 두세요.
값은 기억이 아니라 증거 파일과 두 인가 서버의 로그에서 옮기세요. 설명 줄에는 '인가 서버가 죽었을 때 막는 쪽을 고르면 무엇을 잃고, 흘리는 쪽을 고르면 무엇을 잃는가' 를 자기 말로 적어 두세요. 채점기는 같은 로그와 파일을 읽어 대조합니다.