🚗

Nginx + oauth2-proxy + Rundeck の検証を手元で行う

2023/01/03に公開

はじめに

Rundeck を使うとき、ユーザー認証と権限管理をどうやるかは割と悩みどころです。
Enterprise 版を使うと SSO もサポートされている のですが、結構いいお値段しますので、円安でコスト削減圧の強い昨今、札束で殴って解決するのは難しいところです。

OSS 版でも LDAP 等いくつかサポートされている方法はありますが、ユーザー管理のために別途サービスを管理するのも気が重いです。

そこで使えるのが oauth2-proxy/oauth2-proxy です。

Nginx + oauth2-proxy + Rundeck の基本的な設定

Rundeck の Preauthenticated Mode using headers にある通り、いくつかのヘッダーを Rundeck がリクエストを処理する前に設定してやれば、その情報を使って Rundeck 上のユーザーとロールを設定してくれるというものです。

oauth2_proxyでRundeckにGitHub認証でログインする - Qiita にある通り、素直に設定するとユーザー名などに応じてロールを動的に設定することができません。

その問題を解決するために、いくつか調べた結果をまとめました。

構成はこんな感じです。

ローカルで試したいので docker-compose.yml を用意します。

version: "3.1"

services:
  nginx:
    image: nginx
    ports:
      - 8000:80
    volumes:
      - ./image-files/etc/nginx/conf.d/default.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - oauth2-proxy
      - rundeck

  rundeck:
    image: rundeck/rundeck:4.8.0
    environment:
      RUNDECK_SERVER_UUID: <your server uuid>
      RUNDECK_GRAILS_URL: http://localhost:8000
      RUNDECK_SERVER_CONTEXT_PATH: /
      RUNDECK_SERVER_FORWARDED: "true"
      RUNDECK_SERVER_ADDRESS: 0.0.0.0
      RUNDECK_PREAUTH_ENABLED: "true"
      RUNDECK_PREAUTH_ATTRIBUTE_NAME: REMOTE_USER_GROUPS
      RUNDECK_PREAUTH_DELIMITER: ","
      RUNDECK_PREAUTH_USERNAME_HEADER: X-Forwarded-User
      RUNDECK_PREAUTH_ROLES_HEADER: X-Forwarded-Roles
      RUNDECK_PREAUTH_REDIRECT_URL: /oauth2/sign_in
      RUNDECK_PREAUTH_REDIRECT_LOGOUT: "true"
      
  oauth2-proxy:
    image: quay.io/oauth2-proxy/oauth2-proxy:v7.4.0
    environment:
      OAUTH2_PROXY_HTTP_ADDRESS: 0.0.0.0:4180
      OAUTH2_PROXY_PROVIDER: github
      OAUTH2_PROXY_CLIENT_ID: <your client id>
      OAUTH2_PROXY_CLIENT_SECRET: <your client secret>
      OAUTH2_PROXY_COOKIE_SECRET: <your cookie secret>
      OAUTH2_PROXY_SESSION_STORE_TYPE: redis
      OAUTH2_PROXY_REDIS_CONNECTION_URL: redis://redis/
      # メールアドレスが複数ある場合、プライマリのものが使われるので個人用と会社用など、複数のメールアドレスを登録している場合は
      # organization や team などメールアドレス以外の条件を使う方がよい
      OAUTH2_PROXY_EMAIL_DOMAINS: "*"
      # 7.3.0 以降はこれを設定しないと動かない。スコープを必ず指定する
      OAUTH2_PROXY_SCOPE: user:email
      # 特定の organization に所属するアカウントのみ認証したい場合はここに organization をコンマ区切りで書く。
      # OAUTH2_PROXY_GITHUB_ORG: org1,org2
      # 特定の team に所属するアカウントのみ認証したい場合はここに team をコンマ区切りで書く。
      # OAUTH2_PROXY_GITHUB_TEAM: team1,team2
      # HTTPS ではなく HTTP で動かすための設定
      OAUTH2_PROXY_COOKIE_SECURE: 'false'
      OAUTH2_PROXY_REDIRECT_URL: http://localhost:8000/oauth2/callback
    depends_on:
      - redis

  redis:
    image: redis
    volumes:
      - redis:/data

volumes:
  redis:

oauth2-proxy, Rundeck の設定については以下を参照してください。

OAUTH2_PROXY_GITHUB_ORGOAUTH2_PROXY_GITHUB_TEAM を両方設定することか可能で、両方とも設定した場合は設定した org に所属し、かつ、設定した team にも所属していると認証が通る。

Nginx の設定は以下の通りです。

upstream oauth2 {
    server oauth2-proxy:4180;
}

upstream rundeck {
    server rundeck:4440;
}

server {
    listen       80;
    server_name  localhost;

    access_log /dev/stdout;
    error_log /dev/stderr debug;
    rewrite_log on;

    root   /usr/local/openresty/nginx/html;
    index  index.html index.htm;

    location / {
        auth_request /oauth2/auth;
        error_page 401 = /oauth2/sign_in;

        auth_request_set $x_github_user $upstream_http_x_auth_request_user;
        auth_request_set $x_github_email $upstream_http_x_auth_request_email;

        proxy_set_header X-Forwarded-User $x_github_user;
        proxy_set_header X-Forwarded-Roles admin;

        proxy_pass http://rundeck;
    }

    location /oauth2/ {
        proxy_pass http://oauth2;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Scheme $scheme;
    }

    location /oauth2/auth {
        proxy_pass http://oauth2;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Scheme $scheme;
        proxy_set_header Content-Length "";
        proxy_pass_request_body off;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   html;
    }
}

この設定で Rundeck に admin としてアクセスできるようになりますが、認証したユーザーのロールを動的に変更し権限を絞ることはできていません。
参考にした oauth2_proxyでRundeckにGitHub認証でログインする - Qiita ではここまでしかできていませんした。

$upstream_http_x_auth_request_user には認証した GiiHub user の login が入っています。
最初は $upstream_http_x_auth_request_user に値が入っていることがわからなくてとても困りました。[1]

X-Forwarded-Roles を動的に変更する

Nginx はいくつかの言語で様々なフックを仕掛けてヘッダーを書き換えたり Nginx 内部で使用する変数を追加したりできます。
この仕組みを使って X-Forwarded-Roles を動的に書き換えることにします。

最初は matsumotory/ngx_mruby を使ってやろうと思ったのですが、色々ハマりポイントがあって簡単には解決できそうになかったので断念しました。ハマったポイントは以下のような感じでした。

  • 存在しない var にアクセスしようとしたら SEGV してレスポンスが返ってこない(せめて 500 error 返して欲しい)
  • 曖昧な知識でいい加減にビルドしようとしたら永遠にビルドできない(自業自得)
  • 使いたい mrbgems が使おうとしている mruby に対応しているかどうか動かしてみないとわからない
  • mrbgems にバージョンという概念がなさそう

かわりに何回か使ったことのある Lua を使うことにしました。そんなに Lua 力は高くないので書く度に文法から調べてます。

version: "3.1"

services:
  nginx:
    # image: openresty/openresty:bullseye-fat
    build: .
    ports:
      - 8000:80
    volumes:
      - ./image-files/etc/nginx/conf.d/default.conf:/etc/nginx/conf.d/default.conf
      - ./image-files/etc/nginx/lua:/etc/nginx/lua
    depends_on:
      - oauth2-proxy
      - rundeck

  rundeck:
    image: rundeck/rundeck:4.8.0
    environment:
      RUNDECK_SERVER_UUID: <your rundeck server uuid>
      RUNDECK_GRAILS_URL: http://localhost:8000
      RUNDECK_SERVER_CONTEXT_PATH: /
      RUNDECK_SERVER_FORWARDED: "true"
      RUNDECK_SERVER_ADDRESS: 0.0.0.0
      # RUNDECK_DATABASE_URL:
      # RUNDECK_DATABASE_DRIVER:
      # RUNDECK_DATABASE_USER:
      # RUNDECK_DATABASE_PASSWORD:
      RUNDECK_PREAUTH_ENABLED: "true"
      RUNDECK_PREAUTH_ATTRIBUTE_NAME: REMOTE_USER_GROUPS
      RUNDECK_PREAUTH_DELIMITER: ","
      RUNDECK_PREAUTH_USERNAME_HEADER: X-Forwarded-User
      RUNDECK_PREAUTH_ROLES_HEADER: X-Forwarded-Roles
      RUNDECK_PREAUTH_REDIRECT_URL: /oauth2/sign_in
      RUNDECK_PREAUTH_REDIRECT_LOGOUT: "true"
      
  oauth2-proxy:
    image: quay.io/oauth2-proxy/oauth2-proxy:v7.4.0
    environment:
      OAUTH2_PROXY_HTTP_ADDRESS: 0.0.0.0:4180
      OAUTH2_PROXY_PROVIDER: github
      OAUTH2_PROXY_CLIENT_ID: <your client id>
      OAUTH2_PROXY_CLIENT_SECRET: <your client secret>
      OAUTH2_PROXY_COOKIE_SECRET: <your cookie secret>
      OAUTH2_PROXY_SESSION_STORE_TYPE: redis
      OAUTH2_PROXY_REDIS_CONNECTION_URL: redis://redis/
      OAUTH2_PROXY_EMAIL_DOMAINS: "*"
      OAUTH2_PROXY_SCOPE: user:email
      OAUTH2_PROXY_GITHUB_ORG: org1,org2
      OAUTH2_PROXY_GITHUB_TEAM: team1,team2
      # OAUTH2_PROXY_PASS_USER_HEADERS: "true"
      # 以下の2行で GitHub の access token を $upstream_http_x_auth_request_access_token にセットする
      OAUTH2_PROXY_SET_XAUTHREQUEST: "true"
      OAUTH2_PROXY_PASS_ACCESS_TOKEN: "true"
      # OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER: "true"
      # 以下、HTTPS ではなく HTTP で動かすための設定
      OAUTH2_PROXY_COOKIE_SECURE: 'false'
      OAUTH2_PROXY_REDIRECT_URL: http://localhost:8000/oauth2/callback
    depends_on:
      - redis

  redis:
    image: redis
    volumes:
      - redis:/data

volumes:
  redis:

oauth2-proxy から nginx の var に認証したユーザーの GitHub のアクセストークンを設定するために設定を追加しています。
GitHub のアクセストークンは Lua から認証したユーザーの所属するチームを取得するために使います。

FROM openresty/openresty:bullseye-fat

RUN apt-get update && apt-get upgrade -y -q
RUN opm get ledgetech/lua-resty-http

GitHub の API を叩くために ledgetech/lua-resty-http を使います。
Lua のパッケージマネージャといえば LuaRocks が定番だったと思うのですが OpenResty が出している OPM - OpenResty Package Manager を使いました。

OpenResty の Docker image を使うかどうかに関わらず LuaRocks は別途インストールしないといけないのですが OPM だと最初からインストールされているので簡単でした。

Nginx の設定はこうなります。

upstream oauth2 {
    server oauth2-proxy:4180;
}

upstream rundeck {
    server rundeck:4440;
}

server {
    listen       80;
    server_name  localhost;

    access_log /dev/stdout;
    error_log /dev/stderr debug;
    rewrite_log on;

    root   /usr/local/openresty/nginx/html;
    index  index.html index.htm;

    lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
    lua_ssl_verify_depth 2;
    resolver local=on;
    resolver_timeout 5s;

    # Rundeck の API は oauth2-proxy を通さない
    location /api {
        proxy_pass http://rundeck;
    }

    location / {
        auth_request /oauth2/auth;
        error_page 401 = /oauth2/sign_in;

        auth_request_set $x_github_user $upstream_http_x_auth_request_user;
        # email はいらない
        # auth_request_set $x_github_email $upstream_http_x_auth_request_email;
        auth_request_set $x_github_token $upstream_http_x_auth_request_access_token;

        # Set $x_roles by Lua
        set $x_roles "";
        access_by_lua_file /etc/nginx/lua/role.lua;

        proxy_set_header X-Forwarded-User $x_github_user;
        proxy_set_header X-Forwarded-Roles $x_roles;

        proxy_pass http://rundeck;
    }

    location /oauth2/ {
        proxy_pass http://oauth2;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Scheme $scheme;
    }

    location /oauth2/auth {
        proxy_pass http://oauth2;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Scheme $scheme;
        proxy_set_header Content-Length "";
        proxy_pass_request_body off;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   html;
    }
}

ポイントは4つです。

  • 認証したユーザーの GitHub アクセストークンを Nginx の変数にセットする
  • access_by_lua_file の前に set $x_roles ""; で変数を定義する
    • 空の値をセットしておかないと Lua スクリプト内で変数が見えない
  • rewrite_by_lua_file だと auth_request_set でセットした値が見えないので rewrite_by_lua_file ではなく access_by_lua_file を使う
  • API アクセスについては oauth2-proxy を通さないようにした
    • oauth2-proxy のオプションにも --skip-auth-route があるけど手元では動かなかった
local json = require("cjson.safe")
local http = require("resty.http").new()

-- TODO cache?
local fetch_teams = function (github_token)
   local page = 1
   local per_page = 100
   local teams = {}
   -- 認証されたユーザーが 100 を越えるチームに所属することってあんまりない気がするが念のため。
   while true do
      local url = string.format("https://api.github.com/user/teams?per_page=%d&page=%d", per_page, page)
      local res, err = http:request_uri(
         url,
         {
            method = "GET",
            headers = {
               ["Content-Type"] = "application/json",
               ["Accept"] = "application/vnd.github+json",
               ["Authorization"] = "Bearer " .. github_token,
            }
         }
      )
      if not res then
         ngx.log(ngx.ERR, "request failed: ", err)
         return { { name = "no-team", organization = "no-organization" } }
      end
      local tmp_teams = json.decode(res.body)
      ngx.log(ngx.DEBUG, string.format("%s %d", url, table.getn(tmp_teams)))
      if table.getn(tmp_teams) == 0 then
         break
      end

      for _, v in ipairs(tmp_teams) do
         table.insert(teams, { name = v.name, organization = v.organization.login })
      end
      
      page = page + 1
   end

   return teams
end

if ngx.var.x_github_user and ngx.var.x_github_token then
   local teams = fetch_teams(ngx.var.x_github_token)
   local roles = {}

   for i, team in ipairs(teams) do
      ngx.log(ngx.DEBUG, "Organization: " .. team.organization)
      if team.organization ~= "org1" then
         goto continue
      end

      ngx.log(ngx.DEBUG, "Team: " .. team.name)

      if team.name == "rundeck-admin" then
         table.insert(roles, "admin")
      end
      if team.name == "rundeck-user" then
         table.insert(roles, "user")
      end

      ::continue::
   end

   ngx.var.x_roles = table.concat(roles, ",")
else
   ngx.log(ngx.WARN, "missing user/token")
   ngx.var.x_roles = "nogroup"
end

検証用の実装なので非効率な部分がいくつかあります。

  • cjson がリクエストごとに初期化される
    • init_by_lua* で Nginx の初期化時に読み込むこともできる
  • 認証されたユーザーが所属するチームを全て取得するためにリクエストごとに2回 API を叩いている
    • Redis などを使って結果をキャッシュすれば API を叩く回数は減らすことができるはず
  • Rundeck 用のロールに関してハードコーディングしている
    • 頻繁に変わりそうなら GitHub のチームと Rundeck のロールの関係を外部で定義できるようにした方がよさそう

まとめ

少し設定を工夫することで Rundeck に手を加えずに oauth2-proxy で認証しつつ Rundeck のロールを設定することができました。

oauth2-proxy は OIDC をサポートしているので OpenID Connect に対応している任意の IDaaS も使うことができそうです。そういった IDaaS でちょっと機能が足りないなどあれば、この仕組みを使ってその IDaaS の API を叩けばちょっとユーザー名を変換したり、ちょっとロールをカスタマイズしたり簡単にできると思います。

参考

脚注
  1. Nginx で簡単に var の一覧を取得する方法はないのでしょうか? ↩︎

Discussion