🍭

Amplify AuthのUser groupsによるマルチテナントS3アクセス制御

2021/12/22に公開

Qiita 「AWS Amplify Advent Calendar 2021」 22日目の記事です。

https://qiita.com/advent-calendar/2021/amplify

やりたいこと

  • Amplify AuthのUser groupsで、マルチテナント環境でのCognito + S3のアクセス制御をやる
  • ユーザーは複数テナントに所属可能で、所属した全てのテナントに対応したS3にアクセスできる
  • S3へはフロントエンド(React)から直接アクセスする

結論

Amplifyだけでは、やりたいことの実現方法がわかりませんでした。
IAM Roleが複数付与されないので、複数テナントに所属した場合だと対応した全てのS3にはアクセスできません。
Amplifyのみに頼らずに、AWS SDK(v2)を使ってRoleを切り替えることで、複数テナントのS3へのアクセスに対応しました。(v3でも同様に実装できると思いますが未確認です)

当初想定

ユーザーを複数グループに所属させれば、所属したグループに対応した権限を全て得られる。
Amplify AuthのUser groupsとAmplify Storageを使えば余裕。

・・・と思っていました。

試したこと

  • Amazon Cognito userpoolに複数グループを作る
  • それぞれのグループに対応したS3へのアクセス権を持つ、別々のIAM Roleを設定する
  • 同一ユーザーを複数グループに所属させる

動いてほしかった動作

  • フロントエンド(React)からAmplify Authでログインすると複数Roleが適用され、Amplify Storageで所属した全てのグループに対応したS3にアクセスできる

実態

403 Forbidden

なんで?

実際の仕様

  • 1つのグループにのみ所属した場合

    • cognito:preferred_roleに所属したグループのロールが設定される
    • AmplifyのAuth機能を使うと、cognito:preferred_roleが使用される
    • つまり所属したグループのS3にアクセスできる
  • 複数のグループに所属し、優先順位(precedence)が同じ場合または優先順位を設定しなかった場合

    • cognito:preferred_roleは設定されない(cognito:rolesには所属する複数のロールが設定される)
    • AmplifyのAuth機能を使うと、cognito:preferred_roleを見に行くが設定されていないので、どのロールも適用されない
    • つまり複数グループに所属すると、全てのS3にアクセスできなくなる
  • 複数のグループに所属し、Cognitoのグループに優先順位(precedence)をつけ、最も優先順位が強い(precedenceが一番小さい)グループが1つの場合

    • 対応するロールがcognito:preferred_roleに設定される
    • AmplifyのAuth機能を使うと、 cognito:preferred_role の1つのみが適用される
    • つまり複数グループに所属しても、1つのS3しかアクセスできない

つまり、、、

  • Amplifyでは同一ユーザーに2つ以上のロールを適用できない!!

グループの存在意義ってなんですか?

先人たちのissue

複数の嘆きの声が上がっているが、基本無視されています。

ロールの切替を実装する

ということで、Amplifyでは、複数グループに所属しても、複数のRoleを適用することはできません。
しかし、aws-sdkで頑張ることで、複数のRoleの同時適用はできませんが、状況に応じてRoleを切り替えることができました。

方針

Amazon Cognitoのドキュメントに下記の記載があります。

Use the GetCredentialsForIdentity CustomRoleArn parameter if it is set and it matches a role in the cognito:roles claim. If this parameter doesn't match a role in cognito:roles, deny access.

つまり、GetCredentialsForIdentity 関数を使えば、cognito:roles 内のRoleに切り替えることができます。

先人が残した記録

Cognitoの設定変更

  • GetCredentialsForIdentityを使用してroleを変更するには、CognitoのIDプールの設定でロールの切り替えを許可する必要がある
    • ユーザープールではなくIDプールの設定なので注意!!
  • idプールの編集 -> 認証プロバイダー -> cognito で下記のように「トークンからロールを選択する」に設定変更する

書いたコード

Amplifyでログイン処理を実施することを前提にしています。
ログイン後にAmplify Authから取得した情報(Credentials)を使いGetCredentialsForIdentityでロールを切り替え、aws-sdkでS3にアクセスします。
Amplify Storageではなくaws-sdkを使う理由は、ロール切り替え後のCredentialsをAmplifyにセットし直す手段が分からなかった為です。
注意点として、AmplifyやGetCredentialsForIdentityではCognitoの認証情報であるAWS.CognitoIdentity.Credentialsが使われますが、aws-sdkではAWSの認証情報であるAWS.Credentialsが必要です。型が違うので直接投げ込むとエラーになるので変換してあげる必要があります。

  • Role切り替え用の関数
const switchRoles = async(user, region, identityId, roleArn, cognitoArn) => {

  //getCredentialsForIdentityで、切り替え後のロールのアクセスキーを取得
  const cognitoIdentity = new AWS.CognitoIdentity({ apiVersion: '2014-06-30', region });
  const params = {
    IdentityId: identityId,
    CustomRoleArn: roleArn,
    Logins: {
      [cognitoArn]: user
        .getSignInUserSession()
        .getIdToken()
        .getJwtToken(),
    },
  };
  const credentialData = await cognitoIdentity.getCredentialsForIdentity(params).promise()

  //AWS-SDKのロールチェンジ
  AWS.config.update({
    credentials: new AWS.Credentials(
      credentialData.Credentials.AccessKeyId,
      credentialData.Credentials.SecretKey,
      credentialData.Credentials.SessionToken
    ),
    region: region
  });
  return
}
  • ロール切り替えの実行
// Amprifyでログインして必要な情報を取得
const user = await Auth.signIn("****", "****");
const credentials = await Auth.currentUserCredentials();

//指定したいroleのARN
const roleArn = "arn:aws:iam::xxxxxxxxxxxx:role/xxxxxxxxxx"

// aws-sdkのroleを切り替える
await switchRoles(
  user, //amplify user
  awsconfig.aws_project_region, // region
  credentials.identityId, //identityID
  roleArn, //適用するroleのarn
  `cognito-idp.${awsconfig.aws_project_region}.amazonaws.com/${awsconfig.aws_user_pools_id}`  //ユーザープールのID(書き方がarnでないので注意)
);
  • S3へアクセス(署名付きURLを取得)
const s3 = new AWS.S3({'region':awsconfig.aws_project_region});
const url = s3.getSignedUrl( 'getObject', {Bucket: 'xxxx', Key: 'xxxxx.png'})

これで、switchロールした権限でS3にアクセスできるようになりました。
ひとまず解決です!!

ですが、、、

Credentialsが自動で更新されない問題

問題点

  • 前述の方法では、Credentialsを自力で作っている
    • 当然有効期限がある
      • 1時間である
  • 1時間経つとS3にアクセスできなくなるのは、ちょっとしょぼい
  • Credentialsを自動でrefreshさせたいよね

ということで、CredentialProviderChainを使って、Credentialsが自動でrefreshされるようにします。

先人が残した記録

こちらの記事を参考にしてますので、内容を把握したい方は参照してください。
https://qiita.com/kent-hamaguchi/items/53f931f5b60f579ede3d

2022/03/09追記 : 記事、消えてますね。。。どうしよう。。。貴重な情報だったのに。。。

書いたコード

前提としてAmplify Authによるログイン処理は実装済とします。

  • AWS.Credentialsを継承してロールの切り替えとCredentialsの更新を実装したクラス
import Amplify, { Auth } from 'aws-amplify';
import AWS from 'aws-sdk';

import awsconfig from '@/aws-exports';

Amplify.configure(awsconfig);

// Amplify Auth から AWS.Credentialsを取得するクラス(credentialsのrefreshに対応)
export class AmplifyAuthCredentials extends AWS.Credentials {
  roleArn: string = "";

  constructor(accessKeyId: string, secretAccessKey: string, sessionToken: string) {
    super(accessKeyId, secretAccessKey, sessionToken);
  }

  //実質こっちがコンストラクタ(await使いたい)
  static async init(roleArn: string): Promise<AmplifyAuthCredentials> {
    const credentials = await AmplifyAuthCredentials.getSwitchRoleCredentials(roleArn);
    if (credentials == null) {
      return new AmplifyAuthCredentials('', '', '');
    }

    //AWS.CognitoIdentity.CredentialsからAWS.Credentialsを作る
    const obj = new AmplifyAuthCredentials(
      credentials.AccessKeyId,
      credentials.SecretKey,
      credentials.SessionToken,
    );
    obj.expireTime = credentials.Expiration;
    //obj.expireTime = new Date(); //有効期限を書き換える時に使用(テスト用を想定)
    await obj.setRoleArn(roleArn);
    return obj
  }

  //SwitchRole後のクレデンシャル(AWS.CognitoIdentity.Credentials)を取得
  static async getSwitchRoleCredentials(roleArn: string): Promise<AWS.CognitoIdentity.Credentials | null> {
    try {
      // switch role
      const cognitoIdentity = new AWS.CognitoIdentity({ apiVersion: '2014-06-30', region: awsconfig.aws_project_region });
      const user = await Auth.currentUserPoolUser();

      // 該当のroleに切り替える権限がないとき、return null
      const payload = user.signInUserSession.idToken.payload;
      if (!payload['cognito:roles']?.includes(roleArn)) { return null; }

      const currentCredentials = await Auth.currentUserCredentials();
      const cognitoArn = `cognito-idp.${awsconfig.aws_project_region}.amazonaws.com/${awsconfig.aws_user_pools_id}`;

      const params = {
        IdentityId: currentCredentials.identityId,
        CustomRoleArn: roleArn,
        Logins: {
          [cognitoArn]: user
            .getSignInUserSession()
            .getIdToken()
            .getJwtToken(),
        },
      };

      //getCredentialsForIdentityで、切り替え後のロールのCredentialsを取得
      const credentials = (await cognitoIdentity.getCredentialsForIdentity(params).promise()).Credentials;
      return credentials;
    } catch (error) {
      return Promise.reject(error);
    }
  }

  // Roleを外部から設定する為の関数
  async setRoleArn(roleArn: string): Promise<void> {
    this.roleArn = roleArn;
    await this.refreshPromise();
  }

  // AWS.Credentialsから継承(オーバーライド)
  refresh(callback) {
    this.setCredentials().then(() => callback()).catch(callback);
  }

  // AWS.Credentialsから継承(オーバーライド)
  async refreshPromise() {
    return this.setCredentials();
  }

  // AWS.Credentialsから継承(オーバーライド)
  async setCredentials() {
    try {
      // role切り替え後のCredentialsを取得
      const credentials = await AmplifyAuthCredentials.getSwitchRoleCredentials(this.roleArn)
      if (credentials == null) { return; }

      //アクセスキーをセット
      this.accessKeyId = credentials.AccessKeyId;
      this.secretAccessKey = credentials.SecretKey;
      this.sessionToken = credentials.SessionToken;
      this.expired = false;

      this.expireTime = credentials.Expiration;

      // 有効期限の変更(テスト時の利用を想定)
      // var date = new Date();
      // date.setSeconds(date.getSeconds() + 30);
      // this.expireTime = date;

    } catch (error) {
      return Promise.reject(error);
    }
  }
}
  • 上記クラスを使って、aws-sdkのロールを切り替える処理
    • Amplify Authでログイン後、S3へのアクセス前に実行
// ロールのArn
const roleArn = "arn:aws:iam::xxxxxxxxxxxx:role/xxxxxx"

// aws-sdkのroleを切り替える
const credentials = await AmplifyAuthCredentials.init(roleArn);
AWS.config.update({
  credentialProvider: new AWS.CredentialProviderChain([
    () => credentials,
  ]),
  region: awsconfig.aws_project_region
});
  • S3にアクセス(署名付きURLを取得)する処理
const s3 = new AWS.S3({ region: "ap-northeast-1" })
const url = await s3.getSignedUrlPromise('getObject', { Bucket: 'bucketName', Key: 'fileName.png' })

注意点

S3から署名付きURLを取得する時に、、、

  • 同期関数getSignedUrlをcallbackなしで使用すると、S3へのアクセスとCredentialsの更新が非同期で動き、Credentialsの更新よりも前にS3にアクセスしようとするので失敗します
    • callbackありで使用すれば、成功します
  • 非同期関数getSignedUrlPromiseを使用すれば、Credentialsの更新後にS3にアクセスするので、成功します

ですので非同期関数を使ってください。
AWSさん、同期関数のgetSignedUrlバグってませんか?

最後に

  • もっとスマートなやり方を知っている方がいれば、教えて下さい
  • 作ってからAWS SDKはv3を使えばよかったと反省したが、もう遅い

その他参考ドキュメント

NCDCエンジニアブログ

Discussion