既存RailsアプリをSSO化して本番環境で活用した話(後編)

この記事は、2021/9/16 に行われた WESEEK Tech Conference の内容のうち、後編の「OpenID Connect 基盤で複数のバックエンドの認証、認可を統一した話」の部分をまとめた記事となります。

目次

実現したこと

まず、最初に本記事で実現したことをご紹介します。

  1. 認証認可部分の実装をサービスの外に出し、共通化した
  2. どのようなサービスに対しても、OIDC 基盤と nautilus 上にあるユーザ・権限情報を利用して、認証認可を追加できるようになった

全体の構成図としては以下のような形になりました。

モチベーション

  • JPNAP ポータルサイト(My.JPNAP) は発足した
    • nautilus というバックエンド 1 つの状態
  • nautilus 以外で提供している情報も顧客向けに公開したい(追加要件)
    • nautilus = My.JPNAP backend
    • トラフィックグラフ
    • 実際に顧客向けインターフェースで流れているトラフィック量
    • Looking Glass
    • etc…
  • 前段に oauth2-proxy を導入することで、既存のサービスに手をいれずに SSO 認証基盤を利用した認証は可能になった
  • 認可は?
    • nautilus ではアプリ内に自前で認可ロジックを入れて、リクエストが来たときにチェックするようにしていた
    • それ以外のサービスの場合、oauth2-proxy だけでは高度な認可は実現できない
    • チェック対象のサービスによらず、URL ごとに必要な権限のチェックができるようにしたい

上記のような追加要件に対して、別のサービスに対しても同じ枠組みで認証/認可の仕組みを入れたい!という要望が出てきました。
アプリ内で認可ロジックを入れている現状を、他のサービスに対してどのように共通化/共有化を実現するかを考えていきました。

技術検討ですが、量が多いため 構成編、認可手法編 の 2 部に分けて説明します。

技術検討(構成編)

前提条件

  • nautilus が権限情報を持っている
    • どの契約情報に対して、どういう操作を許可するのか
  • 既にあるサービスに手を入れる形にはしたくない
    • 面倒見なければいけないコード量を増やしたくない
      • 違う言語/FW を使ってるアプリだと、増えるのはコード量だけではない
    • OSS だったら拡張して、自前バージョンを作り、保守する必要がある…
  • 稼働環境は Kubernetes を利用している

上記のような前提のもと、サービスにリクエストが到達する前に認可できるようにできないかを考えました。

構成イメージ

構成イメージ/導入前


導入後の理想像/認可ロジックはまだ不明

課題まとめ

これまで出てきた要件、前提をまとめると、解決すべき課題は以下の 5 点になりました。

  1. 認可ロジックだけをどうやって抜き出してサービス前段で実行するのか
    • 利用者からのリクエストは HTTP なので、前段にリバースプロキシを入れる?
  2. プロキシにどの実装を使うのか
  3. プロキシの設定をどうやって管理/反映するのか
  4. 認可ロジックを何で実装するか
  5. 認可情報をサービスへどうやって渡すのか

構成編では 1. ~ 3. を扱い、4./5. については認可手法編で紹介します。

手法検討

どのような手法で実現するかという点については、Kubernetes を利用していたこともあり、「サービスメッシュ」という単語がチーム内で出てきたため、以下の 2 手法からどちらを選択するかという話になりました。

  1. サービスメッシュを導入する
    • メッシュのプロキシに認可レイヤーを持たせる
      • 既に Kubernetes を利用していたため、Istio がチーム内で知られていた
    • ただ、認可のレイヤーを追加するだけでは too much 感?
  2. サービスの前にプロキシを自前で立てる
    • プロキシ実装の選択、設定などを自分で考え、管理・運用する必要がある
      • 認可対象のサービスごとに異なる設定以外も管理・運用する必要がある
      • どういう構成で動かすのか、どういう設定にするか
    • 考えなければいけないことが多い

チームで検討した結果、技術的に面白そう、要件を満たせそう、という点から 1. を選択することになりました。

サービスメッシュ/Istio とは?

サービスメッシュとは?

サービスメッシュを導入した場合の構成は、サービス本体を「Microservice」、プロキシが「Sidecar」という表現で表されます。
各サービス着の通信、各サービス発の通信はすべて Sidecar として稼働するプロキシを通過する構成となります。
そのため、各サービスに到達する前、各サービスから発信するときに行いたい処理を共通化した設定として記載し、サービスメッシュで管理できます。
共通化する設定としては、代表的に以下のような機能が挙げられます。

  • サービス間の通信を透過的に暗号化する(mTLS)
  • rate-limit
  • カナリアリリース
  • サーキットブレイキング
  • Observability/Monitoring

Istio とは?

  • Kubernetes 上でサービスメッシュを実現するための 1 実装
  • Sidecar proxy として Envoy を採用
    • 今回認可を共通化するために利用した External Authorization Filter(ext_authz) も Envoy の一機能
  • メッシュ内の設定は全て Kubernetes 上のリソースとして管理される
    • 他のリソースと同様に YAML として管理可能
    • 既に My.JPNAP では Git リポジトリでリソースを管理していたため都合がよい

課題まとめのうち解消した項目

課題まとめで掲出した 1. ~ 3. に対して、以下のような答えが出ました。

  1. 認可ロジックだけをどうやって抜き出してサービス前段で実行するのか
    • 利用者からのリクエストは HTTP なので、前段にリバースプロキシを入れる
    • Istio なら各 Pod に sidecar として Envoy が入ってくれる
    • Envoy だったら External Authorization Filter を差し込んで実行できそう
  2. プロキシにどの実装を使うのか
    • Istio は Envoy 一択
  3. プロキシの設定をどうやって管理/反映するのか
    • Istio の Custom Resources で、他の Kubernetes リソースとともに yaml で管理できる

検討結果の構成イメージ

技術検討(認可手法編)

続いて、課題まとめ 4./5. で挙げた項目について考えていきます。

  1. 認可ロジックを何で実装するか
  2. 認可情報をサービスへどうやって渡すのか

前提条件の確認

  • 構成は決まったが、実際の認可ロジックはどうやって実装する?
    • Istio(Envoy) に対象となるサービスにリクエストを送る前に、外部のサービスに問い合わせて許可/拒否するモジュールはあった(External Authorization Filter)
    • 認可ロジックを実装する外部のサービス部分をどうするか考える必要がある
  • 認可ロジック実装で、考える必要があった事柄
    • クライアントが送ってきた token の検証方法
    • 認可ロジックの実装方法、利用実装
    • リクエストしてきた人のログイン情報をサービスに渡す方法
  • 自前実装は増やしたくない

token 検証をどうするか?

  • クライアントが送ってきた access token の検証方法
    • ORY Oathkeeper を利用することに
    • 検証には OAuth2.0 Token Introspection(RFC7662) を使う
      • Hydra/Oathkeeper は実装を持っている
  • Oathkeeper とは?
    • Hydra と同じく ORY 社が開発している Identity and Access Proxy
    • Reverse-proxy モード以外にも、HTTP リクエストを通す/通さないの判定をしてくれる API が備わっている
      • authenticator(認証)、authorizer(認可)など、フェーズごとに設定できる
    • URL ごとにどのような認証認可を行うかを JSON or YAML で書いておく
    • Envoy との連携もサポート

Oathkeeper 設定サンプル

- id: request-authorization-to-status-api
  version: v0.38.3-beta.1
  match:
    url: <https|http>://status.example.com/api/v1/<.*>
    methods:
    - GET
    - POST
    - PATCH
    - PUT
    - DELETE
  authenticators:
  - handler: oauth2_introspection
  authorizer:
    handler: allow
  mutators:
  - handler: id_token
  errors:
  - handler: json

別途エンドポイントの設定は必要ですが、上記の設定だけで、特定の URL/HTTP method で来たリクエストについて、HTTP リクエスト内に入っている access token の検証が可能です。

認可ロジックをどうするか?

  • 認証ロジックの実装方法、利用実装
    • Oathkeeper にも authorizer の設定はあるが、外部サービスに検証を依頼するのが主で Oathkeeper それ自体ではロジックの定義は不可能
    • OPA を利用することに
  • OPA とは?

OPA 設定サンプル

# envoyから渡されるhttp_requestそのままではほしいデータのキーのが深すぎるため
# ショートネームでアクセス出来るようにしている
import input.attributes.request.http as http_request

# ルールのデフォルト値
default allow = false

(snip)

# GET /api/v4/user_groups/:user_groups_id/user_groups/:id
allow {
  some user_group_id, ug_id
  http_request.method == "GET"
  input.parsed_path = ["api", "v4", "user_groups", user_group_id, "user_groups", ug_id]

  is_token_valid
  ABILITY_MANAGE_USER_GROUP == nautilus_authz_data(user_group_id, MODEL_UG, ug_id)["abilities"][_]
}

上記例では、/api/v4/user_groups/:user_groups_id/user_groups/:id という URL に対する GET リクエストについて、token が正しいこと、必要な権限が存在することを確認できた場合、リクエストを許可するルールになります。
変数 input に HTTP リクエストに関する情報が入ってくるので、それを基にメソッド、URL 等の情報から条件を書いていくことが可能です。

ログイン情報の受け渡しをどうするか?

  • リクエストしてきた人のログイン情報をOPA/サービスに渡す方法
    • Oathkeeper の mutator(変換機能) を使い、JWT ID Token を生成し渡すことに
    • サービスは JWT の中身からリクエストしてきた利用者の情報を拾える
    • また JWT には署名機能もついているため、検証を行うことで無効な JWT を拒否することもできる
  • OPA は JWT のデコード/検証に built-in で対応している
  • サービス側でログイン情報を拾う場合は、JWT のデコード/検証ロジックの実装だけ
    • どんな言語でも非常に薄い実装になる!
    • 認可はサービスに到達する前に実施済みなので、考慮不要!

課題まとめのうち解消した項目

課題まとめで掲出した 4./5. に対して、以下のような答えが出ました。

  1. 認可ロジックを何で実装するか
    • Oathkeeper と OPA を組み合わせて利用する
    • Oathkeeper では access token の検証、OPA で URL ごとに認可ルールを書いていく
  2. 認可情報をサービスへどうやって渡すのか
    • Oathkeeper で JWT を発行して、認可情報を渡す形で実現した

完成構成

構成イメージ

導入前後の構成イメージを比較してみます。

構成イメージ/導入前


構成イメージ/導入後

導入後のフロー詳細

点線が Envoy external authorization filter が行う通信を示しています。
まず、oathkeeper で access token の検証、JWT への変換を行い、OPA で各種認可処理が通ると、サービスにリクエストが到達するという構成になりました。
また、サービス側でログイン情報を取得する必要がある場合は、JWT をデコード/検証して情報を取り出せる状態になりました。

サービス側で追加実装した箇所

  • OPA 向けにユーザが持っている権限を返すエンドポイント(nautilus)
    • OPA で認可チェックをする際に実行される
    • リクエストされるパスごとに、必要となる権限が異なる
    • OPA 側では、本エンドポイントを実行して、権限を取得
      • その後、必要な権限がある場合は許可、ない場合は拒否するようなロジックを書いている
  • JWT を検証/JWT からリクエストユーザの情報を取り出す middleware
    • 返すデータをサービス側で制御するケースで利用される
      • ex.) 契約一覧などの index action など

苦労したポイント

上手く動かない時に、見ないといけないログが多かった

  • いろいろなソフトを一度に増やしたため、1 つの HTTP リクエストの認可の結果を見るにも、それぞれのソフトのログを一度に見る必要があった
  • (tmux でペインを開いて複数のコンテナを stern しておく…)

tmux 実行イメージ

Envoy ext_authz の設定を入れる Istio の設定を編み出すのが大変だった

  • ext_authz が Istio native でサポートされていないため、EnvoyFilter というリソースで設定を書かないといけなかった
  • EnvoyFilter は Istio が生成してくれる Envoy コンフィグ中の「どの箇所に」設定を追加する、という書き方をする必要がある
  • 希望の場所にコンフィグを持ってくるための書き方を編み出すのに時間がかかった
    • 文字通りの挙動をしてくれなかったり…
    • ex.) INSERT_FIRST が動かなかった

苦労した設定サンプル

   configPatches:
    - applyTo: HTTP_ROUTE
      match: &config_patches_match
        context: SIDECAR_INBOUND
        routeConfiguration:
          name: inbound|3050||
      patch:
        # どうやっても、INSERT_BEFORE/INSERT_FIRST で default route の前に値を入れられないので、
        # 仕方なく、まず MERGE で default を書き換える
        operation: MERGE
        value:
          decorator: &decorator
            operation: gargant-app.gargant.svc.cluster.local:3050/*
          match:
            safe_regex:
              google_re2: {}
              regex: (/api/v[0-9]+)?/healthz
          name: gargant_healthz
          route: &route
            cluster: inbound|3050||
            maxGrpcTimeout: 0s
            timeout: 0s
          typed_per_filter_config: &disable_ext_authz
            envoy.filters.http.ext_authz:
              '@type': type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
              disabled: true

まとめ

  1. Istio を導入し、OPA/Oathkeeper をさらに組み合わせることで、認可をサービスの外に出し、共通化できるようになった
    • 自前実装を増やすことなく、ほとんどの箇所を設定で管理できるようにできた
  2. どのようなサービスに対しても、OIDC 基盤と nautilus 上にあるユーザ・権限情報を利用して、認証認可を追加できるようになった
    • 新たな認可方式を実装する場合も、OPA/Oathkeeper で実現できれば、サービスにタッチせずに実装可能になった
    • ex.) API token 機能(実装中)
      • ブラウザによる認証不要で API を実行できる期限がない token
      • hydra には期限なしの token を発行できる機構はないため、自前実装
      • oathkeeper で token の種類の差異を吸収し、OPA/サービス側には影響を与えない設計で実装可能となった

今回紹介した構成をおすすめする対象者

  • Kubernetes クラスタ上で複数のバックエンドを立ち上げて運用している人におすすめ
  • 構成要素、利用技術はたしかに多い
  • しかし、一度うまく動けば、その後の転用・拡張は比較的容易
    • 構成要素の増加により、拡張ポイントが増えるため
    • 認証認可に関する自前実装も増えない
    • サービスメッシュにより、サービスにリクエストが到達する前に任意の処理を挟めるようになる

付録

運用上ツラいポイント

  1. Istio のバージョンアップが早い
    • 3 か月に 1 度メジャーバージョンアップする
    • サポート期限が半年
      • とはいえ、GKE も自動的にバージョンアップしていき、それに対応した Istio も上げざるを得ないので、ずっと同じバージョンを使い続けるという保守的な使い方はできない
      • 継続的にアップデートできるような枠組みを考える必要がある
    • in-place update は 1.8 からサポートされていて、無停止でのアップデート実績あり
  2. 学習コストが高い
    • Istio 1つでも膨大なリソース/ドキュメント量
    • それに OPA や oathkeeper をさらに乗せたため、初学者は覚えることがどうしても多くなってしまう

oauth2-proxy の裏側

  • 実は EnvoyFilter でヘッダーの入れ替えを行っている
    • oauth2-proxy に OP(OpenID Provider) から取得した access token を upstream に渡してくれるオプションが存在する
    • それが Authorization ヘッダー以外につく(X-Forwarded-Access-Token ヘッダー)
    • そのため、EnvoyFilter で Authorization ヘッダーに入れなおして、oathkeeper にリクエストするように設定している
  configPatches:
    - applyTo: HTTP_FILTER
      match: &match
        context: SIDECAR_INBOUND
        listener:
          filterChain:
            filter:
              name: envoy.http_connection_manager
              subFilter:
                name: envoy.router
          portNumber: 8080
      patch:
        operation: INSERT_BEFORE
        value:
          name: envoy.filters.http.ext_authz
          typed_config:
            '@type': type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
            failure_mode_allow: false
            http_service:
              authorization_request:
                headers_to_add:
                - key: Authorization
                  value: Bearer %REQ(X-FORWARDED-ACCESS-TOKEN)%
              authorization_response:
                allowed_upstream_headers:
                  patterns:
                  - exact: authorization
              path_prefix: /decisions
              server_uri:
                cluster: outbound|4456||oathkeeper-api.oathkeeper.svc.cluster.local
                timeout: 2s
                uri: http://oathkeeper-api.oathkeeper.svc.cluster.local:4456
            status_on_error:
              code: ServiceUnavailable

著者プロフィール

今間 俊介

株式会社WESEEK / バックエンドエンジニア

某 ISP に 2 年弱勤務した後、2013 年に WESEEK へ入社。
現在は、今回ご紹介するインターネットマルチフィード様開発案件などプロジェクト問わず、Rails/Kubernetes を中心としたインフラ/アプリの設計・構築・運用に携わる。

株式会社WESEEKについて

株式会社WESEEKは、システム開発のプロフェッショナル集団です。

【現在の主な事業】

  1. 通信大手企業の業務フロー自動化プロジェクト
  2. ソーシャルゲームの受託開発
  3. 自社発オープンソースプロダクト「GROWI」「GROWI.cloud」の開発

GROWI

GROWIは、Markdown記法でページを記述できるオープンソースのWikiシステムです。

GROWI.cloud

GROWI.cloudはOSSのGROWIを専門的知識がなくても簡単に運用・管理できる、法人・個人向けの商用サービスです。

大手SIer・ISPや中小企業、大学の研究室など様々な場所でご利用いただいております。

【主な特徴】

  • テキストも図表もどんどん書ける、強力な編集機能
  • チーム拡大に迅速に対応できる管理者向け機能を提供
  • 充実した機能・サポートでエンタープライズにも対応

【導入事例記事】
インターネットマルチフィード株式会社様
[https://growi.cloud/interviews/mfeed/?utm_source=connpass-top&utm_medium=web-site&utm_campaign=mf:embed:cite]

株式会社HIKKY(VR法人HIKKY)様
[https://growi.cloud/interviews/hikky:embed:cite]

WESEEK Tech Conference

WESEEK Tech Conferenceは、株式会社WESEEKが主催するエンジニア向けの勉強会。WESEEKに所属するエンジニアが様々なテーマで発表を行う予定です。

次回のWESEEK Tech Conferenceのテーマは「Rails+RSpecで気軽に始めるテスト」!
9/30(木)の19:00~20:00開催予定です。

ついつい書くのが億劫になってしまうテストコード。気軽にテストを書いてプロダクトの品質を高める、WESEEK流の実践方法を紹介します。
また、開発と書いたテストコードの実行をどのように行っているのか、CIについても簡単にお話します。

現在、connpassやTECH PLAYで参加受付中です。皆様のご参加をお待ちしております!
https://weseek.connpass.com/event/224673/
https://techplay.jp/event/830907

一緒に働く仲間を募集しています

東京の高田馬場オフィス、大分にある別府サテライトオフィスにてエンジニアを募集しております。

中途採用だけではなく、インターンシップも積極的に受け入れています!

詳しい募集要項は、弊社HPの採用ページからご確認ください。