はじめに
APIを機能別にDockerコンテナー化(マイクロサービス、と言うらしい)したのはいいけれど、認可(アクセスコントロール)はどうしよう、という話です。
Istioは、Kubernetesの最小管理単位Podが、Dockerコンテナーを1つ以上配置できることを利用して、DockerコンテナーにIstioのプロキシー(Envoy)をinjectして(リバースだけでは無いみたいなので、単に「プロキシー」と書いておきます)、各Dockerコンテナーにプロキシーを入れてしまおう、という考え方です。
実は、昔Istio 0.7時代にも試したことがあるのですが、そのときはエンドユーザーの権限(≒エンドユーザーのアクセストークン)による認可ができなかったのですが(実装はあったらしいのですが、ドキュメントに記載が全く無かったため、何をどう設定すればよいか分からなかった)、Istio 0.8というか1.0で実装された、といううわさを聞いたので、改めてやることにしました。そのため、今回の記事ではIstio自体の説明や細かいインストール手順は省略させていただきます。そこまで書くと、とてもQiita1ページに収まる内容ではないので。
本記事の対象ではない読者
- APIの認可をやりたいが、どうすればよいか困っている人
- 今回は解決しないんだ…
- C#(ASP.NET core)で、可読性が高くメンテナンス性に優れた、クールな認可処理の実装方法を知りたい人
- 今回はプログラムの話じゃないんだ…
- Istioに興味は無いが、EKSの使い方を知りたい人
- 今回はEKS使ってないんだ…
ソフトウェアのバージョン
OS は CentOS 7.5です。
ソフトウェア名 | バージョン |
---|---|
Docker | 1.13 (CentOS 7.5でyumしたら入った) |
Kubernetes | 1.12.2 (2018年11月時点の最新) |
Istio | 1.0.3 (2018年11月時点の最新) |
Keycloak | 4.1 (2018年11月時点の最新ではないが、特に意味は無い) |
Istioデモ - Bookinfoの簡単な説明
自作のAPIでやってみたかったのですが、あまり時間がなかったため、Istioについているデモ Bookinfo を使うことにしました。このアプリケーションを簡単に説明しておきます。
※破線四角はPod
。実線四角で表している各APIはDockerコンテナー。
- エンドユーザーは
productpage
にアクセスする。逆に言うと、エンドユーザーが直接アクセスするAPIは、productpage
だけである。 - 最上部の領域にある「Sign in」ボタンは使わない。
-
productpage
は、左下の領域に表示する本の詳細情報であるdetails
と、右下の領域に表示するレビューであるreviews
があり、productpage
からdetails
とreviews
(3つ)のAPIを呼び出している。 -
reviews
にはv1, v2, v3の3つがあり、productpage
からはどれか1つランダムに呼び出す。つまり、F5連打すると、reviewsが切り替わる。(下の画像はv3が呼び出されている) -
reviews
(v2とv3)はレーティング情報としてratings
のAPIを呼び出している。そのレーティング値を、v2は黒星で、v3は赤星で表示している。
今日やろうとすること - 権限の制御
認可では一番単純な例となってしまいますが、アクセストークンのclaimに "email":"u001@hoge.example.co.jp"
があるユーザーのみAPIを許可するようにします。本当はグループやロールによる制御や、scope
の値を使っての制御にしたかったのですが、そこまでできませんでした。
Kubernetesのインストール(メモだけ)
AWS EKSを使いたかったのですが、この記事を書いた時点(2018年11月)ではまだ東京リージョンにサービスがなかったので、仕方なくEC2上に構築しました 。
EC2インスタンス上での構築は、昔私が書いた記事(前編と後編)を参照してください。1.10 → 1.12 の変更点だけ列挙しておきます。
- masterに、
/etc/systemd/system/kubelet.service.d/10-kubeadm.conf
にEnvironment="KUBELET_CGROUP_ARGS=--cgroup-driver=systemd"
を入れる必要が無くなった、というかこの設定自体が無くなった。 - flannelインストール時の、yamlファイルの場所が変わった。
kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/v0.9.1/Documentation/kube-flannel.yml
→kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/bc79dd1505b0c8681ece4de4c0d86c5cd2643275/Documentation/kube-flannel.yml
Istioのインストール(メモだけ)
基本的には、Istioのドキュメントを見てください(英語ですが)。しかし、手順どおりやってもうまくいかないので、注意点を列挙しておきます。
Istioのダウンロードとインストール
ダウンロードは curl
を使っているので、プロキシー環境下では、curl
にプロキシー設定がいる。~/.curlrc
というファイルを作って次のように書く。
proxy-user = "(ユーザー名):(パスワード)"
proxy = "http://(プロキシーサーバーのFQDN):(プロキシーサーバーのポート番号)"
Istioのセットアップ
- Option 1~4 まであるが、helmじゃないとセットアップに成功しない。つまり Option 1と2は使えない。Option 1か2でセットアップすると、Envoyがリクエストを受け付けてくれず、路頭に迷う。(エラーメッセージ紛失。多分
upstream connect error or disconnect/reset before headers
) -
helm template
では、ドキュメントに書いていないが、--set global.configValidation=false
オプションを付けるのが必須 。これを付けないと istio-galley でfailed calling admission webhook "pilot.validation.istio.io": Post https://istio-galley.istio-system.svc:443/admitpilot?timeout=30s: Not Found
が出続けて路頭に迷う。 - Istioセットアップ後は、
istio-ingressgateway
サービスのspec.type
をNodePort
に変更する。EC2インスタンスでは、外部IP(EXTERNAL-IP)が付けられないため。
サンプル Bookinfo のセットアップ
-
samples/bookinfo/networking/bookinfo-gateway.yaml
の中にあるGateway
とVirtualService
は次のものを使う(※VirutalService
のhost
の値が違う)
$ cat <<EOF | kubectl apply -f -
apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
name: productpage-gateway
spec:
selector:
istio: ingressgateway # use Istio default gateway implementation
servers:
- port:
number: 80
name: http
protocol: HTTP
hosts:
- "*"
EOF
$ cat <<EOF | kubectl apply -f -
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: productpage
spec:
hosts:
- "*"
gateways:
- productpage-gateway
http:
- match:
- uri:
exact: /productpage
- uri:
exact: /login
- uri:
exact: /logout
- uri:
prefix: /api/v1/products
route:
- destination:
port:
number: 9080
host: productpage.default.svc.cluster.local
EOF
- BookinfoへのアクセスURLは、istio-ingressgateway経由になるので、
http://(KubernetesのmasterのFQDN):(isto-ingressgatewayのポート)/productpage
になる。isto-ingressgateway のポート番号(NodePort)は、次のコマンドを実行して、80ポートに紐づいている値になる(次の例では31380)。
$ kubectl get service istio-ingressgateway -n istio-system
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
istio-ingressgateway NodePort 10.103.184.198 <none> 80:31380/TCP,443:31390/TCP,31400:31400/TCP,15011:30531/TCP,8060:32322/TCP,853:32651/TCP,15030:32213/TCP,15031:32726/TCP 6d
OpenID Providerの設定 - Keycloakの設定
実際にAPIの認可をやる前に、JWT(アクセストークン)を発行するOPの設定をちゃちゃっとやってしまいます。
Clientの設定
要点だけ、箇条書きにしておきます。
-
Client ID
は何でもよいが、この記事ではistio
にした。(クライアントシークレットはKeycloakが自動生成する) - 同意画面は出したくないので、
Consent Required
はOFF。 -
Client Protocol
は当然openid-connect
。 -
Access Type
は、普通のWebサーバーなので(クライアントシークレットを秘密にできるので)、confidential
に変えておく。 - 各
Flow
は、面倒なので、全部使えるようONにしておく。 - この記事では、アクセストークンの発行は(面倒なので)リソースオーナーパスワードクレデンシャルを使うので、
Valid Redirect URIs
は適当な値に。本当は、APIの認証を行うWebアプリケーションのURLにする必要がある。 - それ以外の項目は、デフォルト。
Client Scopeの設定
スコープにopenid
以外である、address
, phone
, profile
, email
を、認可リクエストでスコープに指定した場合返すように設定しました。しかしこの記事のケースでは、別にこのような設定にする必要はありませんでした。
ユーザーを作る
権限がある u001 と権限がない u002 をつくりました。
アクセストークンの検証 - 認証ポリシー (Authentication Policy)
ここから、認可を行っていきます。まず、認可に使うアクセストークン自体の検証をする設定を行います。
Istioのドキュメントでは、「アクセストークンを使う」とは一言も書かれていません。ただ「JWTの検証をする」とだけ書かれているだけであり、OpenID Connectの何トークンを使うべきか、は何も書かれていません。さらにこの機能は、認可ではなく「認証」と書かれているので、JWTを要求することを合わせると、IDトークンが正しいのかもしれません。しかし通常はIDトークンの有効期限は短く(1分~5分程度)、APIにアクセスする度にトークンを発行するために認可リクエストを送るのはおかしいので、私は(リフレッシュトークンができる)アクセストークンであると解釈しました。さらに言うと、この後でやる認可(Authorization)では、`Authorization: Bearer`ヘッダーにあるJWTを参照し、認証ポリシー(Authentication Policy)でも`Authorization: Bearer`ヘッダーを参照するので、アクセストークンでないと都合が悪い、という理由もあります。 |
なぜかドキュメントにはBookinfo用の認証ポリシー(Authentication Policy)が無いので、自分で作ります。
ポリシー(Policy)の定義
アクセストークンの検証として、issuer
の値と署名検証用のjwks_uriを設定します。設定は見たまま、特に難しいところは無いと思います。ちなみにorigins
というのが、Authorization: Bearer
ヘッダーの値を意味しているらしいです。昔(Istio 0.7)は、リクエストのどの値を使うか、RequestContext
に定義したのですが、どこかにいってしまったようです。
$ cat <<EOF | kubectl apply -f -
apiVersion: "authentication.istio.io/v1alpha1"
kind: "Policy"
metadata:
name: "productpage-jwt-example"
spec:
targets:
- name: productpage
peers:
- mtls: {}
origins:
- jwt:
issuer: "http://172.26.22.76/auth/realms/sample"
jwksUri: "http://172.26.22.76/auth/realms/sample/protocol/openid-connect/certs"
principalBinding: USE_ORIGIN
EOF
DestinationRule
ドキュメントにはmutual TLSでないとダメ、と書かれているので、そういう設定をしておきます。
productpage の DestinationRule では、spec.host
の値は、productpage
でもなくproductpage.default.svc
でもなく、productpage.default.svc.cluster.local
みたいです。
$ cat <<EOF | kubectl apply -f -
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
name: productpage
spec:
host: "productpage.default.svc.cluster.local"
trafficPolicy:
tls:
mode: ISTIO_MUTUAL
subsets:
- name: v1
labels:
version: v1
---
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
name: reviews
spec:
host: reviews
trafficPolicy:
tls:
mode: ISTIO_MUTUAL
subsets:
- name: v1
labels:
version: v1
- name: v2
labels:
version: v2
- name: v3
labels:
version: v3
---
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
name: ratings
spec:
host: ratings
trafficPolicy:
tls:
mode: ISTIO_MUTUAL
subsets:
- name: v1
labels:
version: v1
- name: v2
labels:
version: v2
- name: v2-mysql
labels:
version: v2-mysql
- name: v2-mysql-vm
labels:
version: v2-mysql-vm
---
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
name: details
spec:
host: details
trafficPolicy:
tls:
mode: ISTIO_MUTUAL
subsets:
- name: v1
labels:
version: v1
- name: v2
labels:
version: v2
---
EOF
試してみる
まずは、アクセストークンなしでリクエストを送ってみます。401(403でも500でも503でもなく)が出ればOKです。
※172.26.22.81はKubernetes masterのIPアドレス、31380はistio-ingressgatewayのNodePort
$ curl -s -D - -X GET http://172.26.22.81:31380/productpage
HTTP/1.1 401 Unauthorized
content-length: 29
content-type: text/plain
date: Tue, 04 Dec 2018 08:00:48 GMT
server: envoy
x-envoy-upstream-service-time: 0
Origin authentication failed.
次に、Keycloakからアクセストークンを発行して、それをAuthorization: Bearer
ヘッダーに付けて、リクエストを送ってみます(Keycloakからのレスポンスは見やすいように改行していますが、実際は1行です)。
※172.26.22.76はKeycloakのIPアドレス
$ curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" -H "Authorization: Basic aXN0aW86OTExZjU2ZTAtMmU2ZS00NGYzLTlhMjgtOWNlYzg2MDY4ZjIy" -d "username=u001&password=password&grant_type=password&scope=openid%20email"
http://172.26.22.76/auth/realms/sample/protocol/openid-connect/token
{
"access_token":"eyJhbGciOiJSUzI...(snip)...",
"expires_in":3600,
"refresh_expires_in":7199,
"refresh_token":"eyJhbGciOiJSUzI...(snip)...",
"token_type":"bearer",
"id_token":"eyJhbGciOiJSUzI...(snip)...",
"not-before-policy":0,
"session_state":"f074aae8-bc50-4da9-9c52-b3f06c1f2693",
"scope":"openid email"
}
$ curl -s -D - -X GET -H "Authorization: Bearer (アクセストークン)" http://172.26.22.81:31380/productpage
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 4415
server: envoy
date: Wed, 05 Dec 2018 03:43:33 GMT
x-envoy-upstream-service-time: 991
<!DOCTYPE html>
<html>
<head>
<title>Simple Bookstore App</title>
...(snip)...
アクセストークンによる制御 - 認可 (Authorization)
ようやくAPIレベルの認可ですが、これもなぜかドキュメントにエンドユーザーの権限による認可の記述がないため、自分で作ります。
RBACを有効にする
$ kubectl apply -f <(istioctl kube-inject -f samples/bookinfo/platform/kube/bookinfo-add-serviceaccount.yaml)
$ cat <<EOF | kubectl apply -f -
apiVersion: "rbac.istio.io/v1alpha1"
kind: RbacConfig
metadata:
name: default
spec:
mode: 'ON_WITH_INCLUSION'
inclusion:
namespaces: ["default"]
EOF
ドキュメントにも書かれていますが、設定の反映(浸透?)には時間が掛かります。早ければ1分程度、遅いと5分くらい掛かるようです。 |
試してみる1
全APIが不許可なので、403(401でも500でも503でもなく)が出ます。
$ curl -s -D - -X GET -H "Authorization: Bearer <アクセストークン>" http://172.26.22.81:31380/productpage
HTTP/1.1 403 Forbidden
content-length: 19
content-type: text/plain
date: Wed, 05 Dec 2018 03:48:26 GMT
server: envoy
x-envoy-upstream-service-time: 9
RBAC: access denied
productpageを認可 - ServiceRoleとServiceRoleBinding
アクセストークンに "email": "u001@hoge.example.co.jp"
を持つユーザー(=u001)だけ許可するよう、serviceroleとservicerolebindingを設定します。
$ cat <<EOF | kubectl apply -f -
apiVersion: "rbac.istio.io/v1alpha1"
kind: ServiceRole
metadata:
name: productpage-viewer
spec:
rules:
- services: ["productpage.default.svc.cluster.local"]
methods: ["GET"]
---
apiVersion: "rbac.istio.io/v1alpha1"
kind: ServiceRoleBinding
metadata:
name: bind-productpage-viewer
spec:
subjects:
- properties:
request.auth.claims[email]: "u001@hoge.example.co.jp"
roleRef:
kind: ServiceRole
name: "productpage-viewer"
EOF
ServiceRole
とServiceRoleBinding
の内容の意味は、ドキュメントにコンセプトが書いてあります。
ServiceRole
defines a group of permissions to access services.ServiceRoleBinding
grants aServiceRole
to particular subjects, such as a user, a group, or a service.The combination of
ServiceRole
andServiceRoleBinding
specifies: who is allowed to do what under which conditions. Specifically:
- who refers to the subjects section in
ServiceRoleBinding
.- what refers to the permissions section in
ServiceRole
.- which conditions refers to the conditions section you can specify with the Istio attributes in either
ServiceRole
orServiceRoleBinding
.
-
ServiceRole
は、サービスへアクセスするパーミッションのグループを定義します。 -
ServiceRoleBinding
は、ユーザー、グループ、サービスといった、個々の主体(subjects)へServiceRole
を承認します。
ServiceRole
とServiceRoleBinding
によって、誰がどのような条件下で何を許可するか、が設定されます。
具体的には、
- **「誰が」**は、
ServiceRoleBinding
のsubjectセクションで表します。 - **「何を」**は、
ServiceRole
のパーミッションセクションで表します。 - **「どのような条件」**は、
ServiceRole
かServiceRoleBinding
のどちからにある、Istio属性を使って設定できる、条件セクションで表します。
試してみる2
curl
なので超絶見にくいですが、ユーザーu001の場合、productpageは表示できるが、detailsとreviewsが表示できていないことが確認できます。
$ curl -s -D - -X GET -H "Authorization: Bearer <u001ユーザーのアクセストークン>" http://172.26.22.81:31380/productpage
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 3955
server: envoy
date: Wed, 05 Dec 2018 03:52:18 GMT
x-envoy-upstream-service-time: 36
<!DOCTYPE html>
<html>
<head>
<title>Simple Bookstore App</title>
...(snip)...
<div class="row">
<div class="col-md-6">
<h4 class="text-center text-primary">Error fetching product details!</h4>
<p>Sorry, product details are currently unavailable for this book.</p>
</div>
<div class="col-md-6">
<h4 class="text-center text-primary">Error fetching product reviews!</h4>
<p>Sorry, product reviews are currently unavailable for this book.</p>
</div>
...(snip)...
$ curl -s -D - -X GET -H "Authorization: Bearer <u002ユーザーのアクセストークン>" http://172.26.22.81:31380/productpage
HTTP/1.1 403 Forbidden
content-length: 19
content-type: text/plain
date: Wed, 05 Dec 2018 03:56:07 GMT
server: envoy
x-envoy-upstream-service-time: 4
RBAC: access denied
detailsとreviewsを許可
productpageと同じように、detailsとreviewsのServieRoleとServiceRoleBindingを設定します。
$ cat <<EOF | kubectl apply -f -
apiVersion: "rbac.istio.io/v1alpha1"
kind: ServiceRole
metadata:
name: details-reviews-viewer
spec:
rules:
- services: ["details.default.svc.cluster.local", "reviews.default.svc.cluster.local"]
methods: ["GET"]
---
apiVersion: "rbac.istio.io/v1alpha1"
kind: ServiceRoleBinding
metadata:
name: bind-details-reviews
spec:
subjects:
# - user: "cluster.local/ns/default/sa/productpage"
- properties:
request.auth.claims[email]: "u001@hoge.example.co.jp"
roleRef:
kind: ServiceRole
name: "details-reviews-viewer"
EOF
試してみる3
同じようにユーザーu001のアクセストークンを使ってアクセスします。detailsとreviewsの部分が出力されるようになっているはずです。
$ curl -s -D - -X GET -H "Authorization: Bearer <u001ユーザーのアクセストークン>" "http://172.26.22.81:31380/productpage"
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 3955
server: envoy
date: Thu, 06 Dec 2018 01:24:30 GMT
x-envoy-upstream-service-time: 39
<!DOCTYPE html>
<html>
<head>
<title>Simple Bookstore App</title>
...(snip)...
<div class="row">
<div class="col-md-6">
<h4 class="text-center text-primary">Error fetching product details!</h4>
<p>Sorry, product details are currently unavailable for this book.</p>
</div>
<div class="col-md-6">
<h4 class="text-center text-primary">Error fetching product reviews!</h4>
<p>Sorry, product reviews are currently unavailable for this book.</p>
</div>
...(snip)...
…うーん、ダメみたいですね。
認可(Authorization)の設定として、ServiceRoleBind
のspec.subjects
に
spec:
subjects:
- properties:
request.auth.claims[email]: "u001@hoge.example.co.jp"
と書きました。このrequest
は、見たとおりHTTPリクエストを表しているようなのですが、これはAPIが(というかAPIの前にいるEnvoyが)直接受け取るリクエストのことのようです。つまり、detailsとreviewsはproductpage内のAPIからリクエストを投げているので、productpage内のアプリケーションが気を利かせてリクエストヘッダーを設定してあげないと、この設定が効かないようです。私の予想では、istio-ingressgatewayからリクエストを通せば、後はIstioが勝手に各EnvoyにAuthorization
ヘッダーにアクセストークンを付けてくれると思っていたのですが、残念ながらそうなっていないようです。(もし「そんなはずはない」「こうすればできる」という情報をお持ちのかたがいらっしゃれば、コメントに書いてくれると嬉しいです)
まとめ、というより感想
残念な結果で終わってしまいましたが、0.7時代と比較すると、アクセストークン(というか厳密にはJWT)の検証をして、Authorization: Bearer
にあるJWTを使ってくれるようになりました。(0.7時代は X-hogehoge
というカスタムヘッダーでユーザー属性を直接リクエストに付ける必要があった --- 実際は0.7でもすでに実装はあったらしいですが)
Istio自体について言うと、まだ使いこなすにはものすごく厳しいプロダクトだと感じました。設定方法自体は整然としていて、すべてがうまくいっているときはちゃんと動くのですが、何か間違ったことをしてエラーや期待する結果にならなかったとき、調べる手段が無い(かよく分からない)というのが本当に厳しいです。調査方法の定石は、ログを見たりtcpdumpを見たりするのですが、Istioの場合ログがどこどう出るのか全く分かりません(ドキュメントの奥底深く探せばあるのかもしれませんが)。tcpdumpも、Envoy<->Dockerコンテナー内のアプリ間の通信を見なければならないのですが、Envoy上でtcpdumpの取り方が良く分かりません(ドキュメントに書いてあることを 0.7 時代にやりましたが、全く取得できませんでした)。
仮に、Envoy上でリクエストやレスポンスを見ることができたとしても、Envoyがどういうリクエストを期待しているかが分からないため、何が正しいかが分かりません。この辺がドキュメントに記載されるようにならないと、エラーが出たとき、コミュニティに質問を投げる以外、何もできません。Istioを試したことがある人の中には、インストール直後にEnvoyがリクエストを阻んで 503 が出続け、断念した人もいるのではないでしょうか。こういった状況になったとき、調べる手段が無いのは、まだ実用レベルには遠いと感じます。
認可(Authorization)の設定についても、ServiceRoleBinding
のspec.subjects
でWhoを書く、とありましたが、user
に書く内容がドキュメントからは良く分かりません。user: "service-account-a"
はサービスアカウントを表すと書かれていますが、サービスアカウントを表す文字列がどういうルールなのかの記載がありません。今回の例では、productpage
のサービスカウント名が何なのか、最後まで分かりませんでした。サンプルではcluster.local/ns/default/sa/productpage
となっていますが、自分の試した環境ではこの値を設定しても403になるので、どうも違う値のようです(そしてこの値がどこから出てきたのかも分からない)。
そういう訳で、Istio全体としては、あとMajor番号が2つくらい上がらないと、人間が使うには難しいと感じました(実際、設定の整合性が合わせるために、精密な作業が求められる)。しかしRESTful APIで認可を包括的に設定したい、という需要は少なく無いでしょうし(私も欲しい)、まだ定番の方式やプロダクトも無い状況なので、早く何か欲しいところではあります。
また、今回はAPI Gatewayの話(≒認可の話)のため、APIの認証の話が出来ませんでした(書く予定はあったのですが、話がまとめられませんでした)。API Gatewayは、認証は完了している想定で話が進みますが、現実の問題では認証もやらなくてはならないので、無視できない問題です。機会があれば書きたいと思います - まぁ、多分来年のアドベントかな!