<-- mermaid -->

Amazon OpenSearch Serviceへ移行:AWS CDKで構築するSAML+OktaでOpenSearch Dashboardsにログインできる環境

こんにちは。NewsPicksでエンジニアをやっております崔(チェ)です。現在は Data / Algorithm チームで検索エンジンの開発を担当しております。

弊社は、検索エンジンとしてElasticsearch(以下、ES)Amazon EC2に乗せて構築しておりましたが、ヤクの毛刈りも含め、約1年かけてマネージドサービスであるAmazon OpenSearch Service(以下、OpenSearch)に移行することができました!今回は、マネージド化のための諸タスクの中から、かなりハマっていたセキュリティの設定部分を中心にお話したいと思います。ご興味ある方は是非読んでいただけると嬉しいです。

はじめに

そもそもどうしてマネージド化する必要があったのかですが、その背景として以下が挙げられます。

  • セキュリティを強化したい!
  • 監視体制を強化したい!
  • メンテナンスしやすくしたい!

ESを開発したElastic社にもマネージドサービスであるElastic Cloudがありますが、弊社はインフラを主にAWSのサービスを利用し構築しているため、検索基盤もそれに合わせました。

OpenSearch DashboardsにOktaでログインできるようにしたい

Kibanaを利用しESのデータを分析すると同様に、OpenSearchはOpenSearch Dashboards(以下、Dashboards)を利用しデータ分析を行います。Dashboardsはドメイン作成と同時に利用できます。

Amazon EC2に乗せていた頃は、VPNの利用とログインを必須としていました。ただ、その一方で、ログイン情報を知っているメンバーに共有してもらえば、全社員がログインができてしまう状態でもありました。これはセキュリティ的に良くないですよね。

OpenSearchには、基本的に提供してくれるセキュリティの設定が色々とあるので、移行に伴い適宜有効化しセキュリティを強化しました。OpenSearchだと、主に以下のようなセキュリティの設定が利用可能です。

  • データの保護(Encryption)
    • 保管中のデータの暗号化
    • ノード間の暗号化
  • ドメインへのアクセスを制御(Access Management)
    • アクセスポリシーの設定(リソースベース、アイデンティティベース、IPベース)
  • きめ細かなアクセスコントロール(Fine-Grained Access Control)
  • SAML認証
  • Amazon Cognito認証

今回は、上記の中でもSAML認証を有効化し、Oktaからログインできるようにした話ができればと思います。

さて、いきなりSAMLと言われても「なにそれ」となる方が多いと思いますので、まず「SAMLとはなにか?」「どうしてそれが使いたいのか?」を見ていこうと思います。

SAML認証とは

SAML(Security Assertion Markup Language)とは、外部のアプリケーションやサービスに対し、アクセスしようとするユーザが誰なのかを認証する標準規格です。アイデンティティプロバイダー(以下、IdP)からサービスプロバイダー(以下、SP)へアクセス権限を持つユーザである証明を渡します。

  • SAMLはOASIS(Organization for Advancement of Structured Information Standards)のSecurity Service Technical Committeにより作られ、現在は2005年にアップデートされたSAML2.0が標準規格として採択されています。
  • IdP(Identity Provider): ユーザにログインページを表示し、認証を要求します。ユーザの資格が確認できたら、アクセス権限に関するデータをSPに渡します。Okta、Microsoft Active Directory(AD)、Microsoft Azureなどが該当します。
  • SP(Service Provider): ユーザがアクセスしようとするサービスです。IdPから渡された証明書を使用し、ユーザのアクセスを許可もしくは拒否します。Salesforce、Box、Skype、Slackなどが該当します。

このやり取りには、XML文法でセキュリティ情報を記載したSAMLアサーションを用います。SAMLアサーションのステートメントは、認証、属性、認可決定という3種類に分けられます。

  • 認証(Authentication): ユーザを認証したデータであり、認証時刻などが含まれます。認証方法として、パスワード、MFAなどを提供します。
  • 属性(Attribute): SAMLでユーザを識別する際に使用されるデータであり、名前、年齢、性別などが該当します。
  • 認可決定(Authorization decision): ユーザがアクセスしようとするサービスを利用する権限があるか、IdPがアクセスを許可若しくは拒否したかのデータです。

SAMLは特に、アプリケーションやサービス間の通信が安全に行われることから、シングルサインオン(以下、SSO)の有効化に用いられます。つまり、ユーザはSAMLで一回認証すれば、その認証情報をもとに複数のサービスにアクセスすることが可能です。

  • SSO(Single Sign-On): 一つのIDで複数のサービスにアクセスできるようにする仕組みです。ユーザは各サービスに直接ログインするのではなく、SSOにログインし、SAMLにより権限が与えられます。ユーザとしては、複数のログイン情報を管理する必要がなくなり、アプリケーション管理者も複数のユーザの認証用情報が管理しやすくなります。

さて、マネージド化とともにだれもが同じログイン情報でアクセスできない環境を作りたいです。弊社では社内システムに社員がログインする際、SSOを提供するOktaを利用します。なので、検索基盤に関してもOktaが使えると良さそうです。

一般的なSSOの認証プロセスには、以下の3要素が必要です。

  • 認証を行う主体
  • IdP
  • SP

今回の場合、主体はDashboardsにログインする社員、IdPはOkta、SPはDashboardsです。SAMLを用いたDashboardsへのOktaログインの仕組みを図で表すと、以下のようになるでしょう。

では、OpenSearchの設定画面から「SAML認証を有効化」にチェックすればOktaで早速ログインできるのでしょうか?残念ながらそうではありません。

OpenSearchのSAMLを有効化するには、以下の2つの条件を満たしている必要があります。

✔ バージョンがES 6.7以上であること
✔ きめ細かなアクセスコントロールが有効化されていること

「きめ細かなアクセスコントロール?なにそれ?」となったと思いますので、これから説明します。

きめ細かなアクセスコントロールとは

きめ細かなアクセスコントロール(Fine-Grained Access Control、以下、FGAC)とは、特定のデータに誰がアクセスできるかをこまかく制御する手法です。主に大規模のデータを保存し扱うクラウドコンピューティングに用いられ、各アイテムに対する異なるアクセスポリシーを設定することで実現します。

OpenSearchではFGACを有効化することで、セキュリティプラグインが利用可能になります。セキュリティプラグインを使えば、Dashboardsでユーザとロールを作成し、特定のデータに対する特定のアクションができるユーザを限定するなど、細かく権限管理することができます。

FGACを有効化するには、以下の前提条件を満たす必要があります。

✔ バージョンがES 6.7以上であること
✔ HTTPSだけを利用すること
✔ 保管中のデータの暗号化を有効にすること
✔ ノード間の暗号化を有効にすること

OpenSearchのドメインをAWS CDK(以下、CDK)で構築する場合は、下記のように上記の条件を満たすことができます。

import { Construct } from "constructs";
import * as cdk from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";
import * as kms from "aws-cdk-lib/aws-kms";
import * as opensearch from "aws-cdk-lib/aws-opensearchservice";

export class OpenSearchStack extends cdk.Stack {
    constructor(scope: Construct, id: string, props: cdk.StackProps) {
        super(scope, id, props);

        // 暗号化に使用するKeyを生成
        const kmsKey = new kms.Key(this, "kms-key", {
            alias: "kms-key-for-opensearch",
            policy: new iam.PolicyDocument({...}),
            ...
        });

        const domain = new opensearch.Domain(this, "opensearch-domain", {
            domainName: domainName,
            version: opensearch.EngineVersion.ELASTICSEARCH_6_7,  // バージョンの指定
            enforceHttps: true,  // HTTPSのみが使用可能
            nodeToNodeEncryption: true,  // ノード間の暗号化を有効化
            encryptionAtRest: {  // 保管中のデータの暗号化を有効化
                enabled: true,
                kmsKey: kmsKey,
            },
            ...
        };
        ...
    }
}

これでやっとFGACの設定ができます。fineGrainedAccessControl 設定を加えることで有効にできます。

もしマスターユーザをユーザ名 & パスワードで生成する場合は、AWS Secrets Manager(以下、Secrets Manager)のシークレットをパスワードにする必要があります。その値は、大文字、小文字、数字、記号を含むものである必要があります。

FYI

  • generateSecretString を使えばランダムに生成した値が保存できるので、パスワードの文字列をハードコーディングしたりAWS Systems Managerのパラメータストアを利用する必要がなくなります。用いる文字の種類や長さなども指定可能です。
  • 一つのシークレットには複数の値をJSONの形にして保管することが可能です。
import { Construct } from "constructs";
import * as cdk from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";
import * as kms from "aws-cdk-lib/aws-kms";
import * as opensearch from "aws-cdk-lib/aws-opensearchservice";

export class OpenSearchStack extends cdk.Stack {
    constructor(scope: Construct, id: string, props: cdk.StackProps) {
        super(scope, id, props);

        const kmsKey = new kms.Key(this, "kms-key", {
            alias: "kms-key-for-opensearch",
            policy: new iam.PolicyDocument({...}),
            ...
        });

        // シークレットを生成
        const secret = new secretsmanager.Secret(this, "master-user-password-secret", {
            secretName: "master-user-password-secret",
            generateSecretString: {...},
            ...
        });

        const domain = new opensearch.Domain(this, "opensearch-domain", {
            domainName: domainName,
            version: opensearch.EngineVersion.ELASTICSEARCH_6_7,
            enforceHttps: true,
            nodeToNodeEncryption: true,
            encryptionAtRest: {
                enabled: true,
                kmsKey: kmsKey,
            },
            fineGrainedAccessControl: {  // FGACの有効化
                masterUserName: "master-user",
                masterUserPassword: secret.secretValue,
            },
            ...
        };
        ...
    }
}

FGACを有効化すると、Dashboardsを開く際ログイン画面が表示されます。マスターユーザ名 & パスワードを設定した場合、それらを使ってログインできます。一方で、マスターIAM ARNを設定した場合は、Amazon Cognitoのユーザが必要です。

FGACを有効化してから表示されるログイン画面

FYI

  • マスターユーザ名 & パスワードを設定した場合でも、Amazon Cognitoを用いてログインできます。
  • ドキュメントでは、複数のクラスターで同じアカウントを共有しない若しくはベーシック認証を使う場合、最初からユーザ名 & パスワードを設定することを推奨しています。
  • リクエスト時にIAMロールやユーザのクレデンシャルで認証を行う場合は、boto3などのSDKを利用する必要があります。

弊社の場合、SAML + Oktaを利用したかったため、マスターユーザ名 & パスワードを設定しました。

ここで設定したユーザは、Dashboardsでは Internal User Database に属し、マスター権限(全てのデータにアクセス可能、権限設定が可能)を持ちます。Internal User Database が有効化されると、ユーザの作成が可能になります。また、アイデンティティベースのアクセスポリシーを使用している場合、有効化時に default_role というロールが生成され、暫くの間全てのユーザが全てのデータにアクセスできる状態になります。権限管理ができる必要最低限のユーザとロールが用意できたなら、default_role は早速削除するのがいいでしょう。

Dashboards上でユーザとロールをマッピングし権限管理する方法については、こちらをご参照ください。

Dashboards接続時にOktaのログイン画面を表示させる

SAMLを有効化する

さて、本題に入りたいと思います。SAMLの有効化は、以下の画像のようにAWSコンソールからできます。

AWSコンソールからSAML認証を有効化する画面

ただ、CDKでOpenSearchのドメインを管理する場合、手動で作ってしまうと管理できないため、ちゃんとコードで定義したいところです。

しかし、CDKでインフラを構築する際よく用いられるL2コンストラクトでは、SAMLは有効にできません。そのため、FGACを有効化するところまではL2コンストラクトで定義し、SAML認証の部分だけL1コンストラクトで定義する必要があります。

CDKは3つのレイヤーに分けられます。

  • L1(レイヤー1)コンストラクト: 低レベルのコンストラクトです。AWS CloudFormation(Cfn)で使用可能な全てのリソース(CFNリソース)を直接定義してインフラを構築します。Cfn* というのがL1コンストラクトです。
  • L2(レイヤー2)コンストラクト: 高レベルのコンストラクトです。L1コンストラクトより抽象化されており、より少ない情報で簡単に構築できます。L1では直接定義しないといけないリソースを、L2は自動生成する場合があります。
  • L3(レイヤー3)コンストラクト: パターンを指します。よく組み合わされるものをパターンとして提供します。

上記のL2コンストラクトで定義した domain から、 defaultChild を取り出せば簡単にL1コンストラクトに変換できます。これの AdvancedSecurityOptionsSAMLOptions 部分を上書きすると、CDKでもSAMLを有効化することができます。

const domain = new opensearch.Domain(...);
const cfnDomain = domain.node.defaultChild as opensearch.CfnDomain;
cfnDomain.addPropertyOverride("AdvancedSecurityOptions", {
    SAMLOptions: {
        Enabled: true,
        Idp: {  // IdPから取得した認証情報
            EntityId: "{entityId}"
            MetadataContent: "{metadataContent}",
        },
        SessionTimeoutMinutes: 1440,  // ログインセッションが維持される時間
    },
});

別のコンストラクトで設定したFGACとSAMLですが、AWS CloudFormationのテンプレートを確認すると、ちゃんと両方とも有効化されたことが確認できます。

$ npx cdk synth opensearch-stack
...
AdvancedSecurityOptions:
   Enabled: true
   InternalUserDatabaseEnabled: true
   MasterUserOptions:
   MasterUserName: newspicks
   MasterUserPassword:
      Fn::Join:
        - ""
        - - "{{resolve:secretsmanager:"
          - Ref: {secretId}
           - :SecretString:::}}
   SAMLOptions:
   Enabled: true
   Idp:
      EntityId: "{entityId}"
      MetadataContent: "{metadataContent}"
      SessionTimeoutMinutes: 1440
...

Oktaを設定する

L1コンストラクトでSAMLを有効化するコードに IdP: {EntityId: ..., MetadataContent: ...} があることにお気づきでしょうか?その名の通り、IdPの情報を渡す必要があるので、Oktaにその値を発行してもらいたいです。

  • EntityId: IdPまたはSPにとってグローバルに一意に決まるIDです。Oktaではこれを「IdP発行者」と表記しています。
  • Metadata: IdPの方で生成するSAMLメタデータファイルです。セキュリティプラグインがIdPのメタデータを読み込みます。

発行にはSAMLアプリ統合のアプリを作る必要があり、詳しくはこちらをご参照ください。

アプリが作られたなら、その設定画面から「General > App Settings - edit > SAML Settings - edit > Edit SAML Integration」を開き、必要な情報を入力しましょう。入力するのは、OpenSearchドメインのSAML有効化の設定画面から得られる以下のURLです。

✔ サービスプロバイダーエンティティID
✔ IdPによって開始されたSSO URL
✔ SPによって開始されたSSO URL

上記の各URLを下記のようにOktaの設定画面に入力してください。

入力先 入力値
Single sign-on URL IdPによって開始されたSSO URL
Audience URI (SP Entity ID) サービスプロバイダーエンティティID

もし、Oktaを経由せず、直接リンクからページを開きたい場合は、下記の設定も行ってください。

入力先 入力値
Other Requestable SSO URLs - URL SPによって開始されたSSO URL
Other Requestable SSO URLs - Index 0

設定が終わったら、今度はアプリの設定画面から「Sign On > SAML Signing Certificates」を開き、表示されるSAMLメタデータファイルの最新版を保存してください。これを MetadataContent の値として渡す必要があります。EntityId はAWSコンソールだと、MetadataContent をインポートした直後自動で読み取られますが、CDKだとそれができないため、SAMLアサーションの entityID= に続くURLを使ってください。

あれ?でもちょっとおかしいですよね。SAMLとOktaが相互依存してしまいました。困ったー。

実は上記の3つのURLは、ドメインエンドポイントに決まったアドレスが付きます。つまり、カスタムエンドポイントを使えば、 OpenSearchドメインが作られる前からOktaの設定をすることが可能です。もし、カスタムエンドポイントが使えない場合は、2回に分けてリリースするという方法が使えるでしょう。

項目 フォーマット
サービスプロバイダーエンティティID ドメインエンドポイント
IdPによって開始されたSSO URL ドメインエンドポイント/_plugin/kibana/_opendistro/_security/saml/acs/idpinitiated
SPによって開始されたSSO URL ドメインエンドポイント/_plugin/kibana/_opendistro/_security/saml/acs

全て整ったので、CDKのスタックをデプロイすれば完了です。

デプロイが終わると、OpenSearchのログイン画面から、Oktaのログイン画面に変わります。

Oktaのログイン画面

余談

その① SAMLを手動で有効化したら予期せぬBlue/Greenデプロイが発生してしまった

実は、開発当時L1コンストラクトについて無知だったため、SAML以外のものだけをCDKで構築しました。SAMLは、出来上がったドメインの設定画面から手動で有効にしました。

そのまま本番でマネージド化を進めましたが、実際に運用開始すると想定より負荷が高く、データノード数を変更し再度デプロイをする必要がありました。そのデプロイでBlue/Greenデプロイが発生してしまったんです。

  • Blue/Greenデプロイ: 現在のバージョンをBlue、未来のバージョンをGreenとした場合、Blue環境からGreen環境へユーザトラフィックを徐々に転送するアプリケーションリリースモデルです。両バージョンとも稼働しているため、途中で操作できなくなるなどのエラーが起きにくいです。また、デプロイ毎にAmazon EC2のAMIを作成し直すため、一貫性が保たれます。一方で、毎回AMIを作り直すので時間がかかるのと、マスターノードやデータノード全てを差し替えるため、暫くの間コストが2倍になります。

Blue/Greenデプロイ自体はユーザへの影響がないので問題ではありませんが、そもそもデータノード数を変更しただけでは発生しないので、意図していない修正がされたんでしょう。また、この修正に伴いシステムが使えなくなったので、別のバグが潜んでいたことも発覚しました。

当時、差分を出力しても以下のようにSAMLについては出ていなかったので、Blue/Greenデプロイの発生可能性について全く気づいておりませんでした。SAMLをAWSコンソールから手動で有効化したため、CDKで管理するリソースではなくなり、デプロイと同時にSAMLが無効化されたのです。

$ npx cdk diff opensearch-stack
Stack opensearch-stack
Resources
[~] AWS::OpenSearchService::Domain opensearch-domain opensearchdomain1AB23456 
 └─ [~] ClusterConfig
     └─ [~] .InstanceCount:
         ├─ [-] {変更前のデータノード数}
         └─ [+] {変更後のデータノード数}

Blue/Greenデプロイが発生する条件を調べ、SAMLを含む「アドバンスド設定」に変更が加わればBlue/Greenデプロイが発生することがわかりました。その他にも、インスタンスタイプの変更、サービスソフトウェアアップデートなどもトリガーです。

CDKで構築するインフラに手動でリソースをつけないように気をつけましょう。

別のバグ:シークレットの値の渡し方が間違っていた

「別のバグ」とさらっと触れましたが、具体的に書くと、Secrets Managerのシークレットをパスワードとして指定する際、その指定方法が間違っていました。

実は、ユーザ名とパスワードを一つのシークレットで管理できるように実装しました。シークレットの値は、文字列かJSONの形になれるので、{"username": ..., "password": ...} のように作れます。

L2コンストラクトだと SecretValue である必要があり、JSON自体がパスワードとして設定されてしまいました。(どうして最初のリリース時に早速エラーにならなかったのかはとても不思議で今も原因不明ですが)シークレットのJSONキーを用いてパスワードの値だけを指定するようにしたら解消されました。

お気づきだと思いますが、シークレットの値をパーズしてしまうと、もはや SecretValue ではなく String になってしまうため、L2コンストラクトは使えません。なので、これもSAMLと同様にL1コンストラクトを上書きする方法で設定し直しました。

const secret = new secretsmanager.Secret(...);
const domain = new opensearch.Domain(...);
const cfnDomain = domain.node.defaultChild as opensearch.CfnDomain;
cfnDomain.addPropertyOverride("AdvancedSecurityOptions", {
    Enabled: true,
    InternalUserDatabaseEnabled: true,
    MasterUserOptions: {
        MasterUserName: secret.secretValueFromJson("username").unsafeUnwrap(),  // unsafeUnwrapで文字列を取得します
        MasterUserPassword: secret.secretValueFromJson("password").unsafeUnwrap(),
    },
    SAMLOptions: {
        Enabled: true,
        Idp: {
            EntityId: "{entityId}",
            MetadataContent: "{metadataContent}",
        },
        SessionTimeoutMinutes: 1440,
    },
});

その② Secrets Managerのシークレットをローテションさせる

はじめの部分で、マネージド化の前は共通のアカウントでアクセスしたと書きました。数年間変更しなかったため、結構脆弱な仕組みだったと思います。なので、マネージド化以後のマスターパスワードは定期的に変更したいところです。

Secrets Managerは、シークレットの値を定期的に変更するローテーション機能を提供しています。

ローテーションは、Lambdaに必要な処理を実装し、決まった頻度でLambdaを起動させることで行われます。AWSがサンプルコードをGitHubに公開しているので、ご参照してください。

シークレットには VersionStage というのがあり、現在使用されているシークレットの値がどれかを判断する際に使われます。

VersionStage 意味
AWSCURRENT シークレットの値として取得できるバージョン
AWSPREVIOUS ローテーションされる前に使ったバージョン
AWSPENDING ローテーションにより新たに追加されたバージョン

ローテーションの処理は4段階になっています。各段階名は、Lambdaが受け取る event に含まれており、以下の順で渡されます。

処理段階名 処理内容
createSecret 新しいシークレットの値を作り、AWSPENDING バージョンのものとして保存する
setSecret シークレットを使用しているサービスのパスワードを、新しく作った AWSPENDING バージョンのシークレットの値に差し替える
testSecret シークレットを使用しているサービスに AWSPENDING バージョンのシークレットの値でアクセスできるか確認する
finishSecret AWSCURRENT バージョンタグを新たな値につけなおす

以上を踏まえ、ローテーション前後と最中に、どのパスワードでOpenSearchドメインにアクセスできるかをまとめると以下になります。

処理段階 ログイン可能なバージョン 現シークレットのバージョン 新シークレットのバージョン
開始前 AWSCURRENT, AWSPENDING AWSCURRENT, AWSPENDING
createSecret AWSCURRENT AWSCURRENT AWSPENDING
setSecret AWSPENDING AWSCURRENT AWSPENDING
testSecret AWSPENDING AWSCURRENT AWSPENDING
finishSecret AWSCURRENT, AWSPENDING AWSPREVIOUS AWSCURRENT, AWSPENDING

終わりに

内容のまとめ

マネージド化に伴いセキュリティが強化されたことは開発チームとしてとっても価値のある実績です。

特に、検索データへのアクセス権の管理がしやすくなり、脆弱性が解消されました。まとめると、以下になります。

  • Kibanaの共通アカウントの廃止
  • リクエスト時に認証を強制
  • Secrets Managerのローテーション機能を利用し、定期的 & 自動的にパスワードを更新
  • FGACの有効化によりセキュリティプラグインが利用可能
  • Oktaでアクセス権を持つユーザを管理

本記事ではあまり触れていないですが、ユーザにロールをマッピングしDashboardsで権限管理を行うことは簡単なので、運用に対する負荷も特に変わらないと思います。例えば、以下のような制御ができます。

主体 アクション 必要な権限
検索システムを開発する可能性があるメンバー ドキュメントを閲覧する, スナップショットを保存する, テンプレートを登録する, ... CLUSTER_MONITOR, MANAGE_SNAPSHOTS, indices:admin/template/put, ...
検索データを更新するプログラム ドキュメントを閲覧する, ドキュメントを更新する, ドキュメントを削除する, ... cluster:monitor/health, cluster:monitor/main, cluster:monitor/state, ...

また、ユーザベースでは退職プロセスの中でOktaユーザが自動削除されるため、退職者の権限削除の運用やリードタイムがゼロにできます。Okta with MFAでグループのセキュリティポリシーに準じたアクセスができるので、VPNに接続しなくても良くなり、運用も便利です。検索基盤もその恩恵を受けるようになりました。

感想

検索基盤の開発を担当するようになり、それらのタスクのほとんどが私には初体験でした。今回のマネージド化は特にそうで、セキュリティやCDKのレイヤーのことを何一つわかっておりませんでした。調べてもAWSの公式ドキュメントくらいしかなかったので、かなり時間をかけて試行錯誤もたくさんしました。こんなにAWS Supportの方に頻繁にお問い合わせをしたのも初めてです。

おそらく、同じ経験をされている方は少なくないのではないでしょうか。この記事が参考になれたら幸いです。

これからも引き続き実用的でチャレンジングな記事を書きますのでよろしくお願いいたします。

Page top