
GrantedでAWSの平文キーをSSOに置き換える — 見分けにくい2つのAWSログインに要注意
GrantedとAWS IAM Identity Centerは、Windows上の静的IAMキーを短命なSSO認証情報に置き換えます。従来のAWSサインイン画面はこの認証情報を受け付けません。
目次
はじめに
AWSの設定ファイルにある、本題とは無関係なリージョンの typo を確認していたとき、エージェントがそのファイルをターミナルに出力するコマンドを実行し、すぐ下に並んでいたアクセスキーIDとシークレットのペアも平文のまま、機密扱いされていないセッションに出力してしまいました。そのキーは2025年2月からアカウント唯一の認証情報で、~/.aws/credentialsに置かれた1つの静的なIAMユーザーシークレットであり、有効期限もローテーションもありませんでした。私はその場でこのキーの使用をやめ、永続的なキーの代わりにAWS IAM Identity Centerの短命なSSO認証情報をラップするCLIであるGrantedへ移行することを決めました。移行自体はうまくいきましたが、途中で正しそうに見えて実は違うログイン画面を通ることになりました。本記事では、WindowsへのGrantedとIAM Identity Centerの構築と、実際に時間を取られた箇所、つまり渡されたログイン情報を絶対に受け付けないサインイン画面について整理します。
キーが平文で出てきた
このアカウントにはAWS CLIプロファイルがdefaultの1つだけあり、Next.jsのデプロイパイプライン用に作られたIAMユーザーの静的アクセスキーで認証していました。本題とは無関係なregion = ap-northeast-1cというtypo(これはリージョンではなくアベイラビリティーゾーンです)を直すために~/.aws/configと~/.aws/credentialsを読んだ結果、両方のファイルをターミナルに出力することになりました。~/.aws/credentialsにはアクセスキーIDとシークレットアクセスキーがそのまま入っていました。
有効期限のない長命なキーは、見えてはいけない場所に一度でも見えた瞬間、常設のリスクになります。チャットの記録はまさにそういう場所です。ここでの正しい対応は、キーをローテーションしてそのまま使い続けることではありませんでした。マシン上に静的なキーを一切置かないようにすることでした。
WindowsへのGranted導入
Grantedは、ロールを切り替えるassumeとセットアップ・アカウント管理を行うgrantedという2つのコマンドの裏に、AWS IAM Identity CenterのSSOフローをラップしています。そのおかげで、ターミナルセッションは永続的なシークレットの代わりに短命な認証情報を保持することになります。Windowsは、Granted自身のドキュメントが主に想定しているプラットフォームではなく、Windows版のインストールに関する3点はREADMEを読んだだけではわかりませんでした。
リリースのアセットはアーキテクチャごとに分かれていて、選択を誤ってもわかりやすいエラーにはならず、静かに失敗します。先に確認しておけば推測せずに済みました。
echo $env:PROCESSOR_ARCHITECTURE結果はAMD64で、つまりarm64ではなくwindows_x86_64版が必要ということです。展開したアーカイブにはgranted.exe、assumego.exe、そしてシェルごとのassume.ps1/assume.bat/assumeが入っています。これらをすべて恒久的なフォルダにコピーし、そのフォルダをユーザーのPATHに追加するところまでは簡単でした。
assumeは実行ファイルとしてPATHに置くだけでは機能しません。ロールをassumeするとは、呼び出し元シェルの環境変数を書き換えることだからです。子プロセスは親のためにそれを行うことが絶対にできません。単なる.exeは自分自身のプロセスで実行されて終了し、そこで設定したものはすべてそのプロセスと一緒に消えます。代わりにassume.ps1は現在のシェルの内側で実行される必要があり、それには新しいセッションが起動するたびに自動で読み込まれるよう、PowerShellのプロファイルにドットソース実行しておく必要があります。
. "$env:USERPROFILE\granted\assume.ps1"これをMicrosoft.PowerShell_profile.ps1に置きます。これを省略してPATH経由でそのままassumeを実行しても、スクリプト自体は実行されますが、そこでエクスポートされた認証情報は子プロセスが終了した瞬間に消え、親シェルは以前とまったく同じ未認証のままになります。
.exeは、終了時に破棄される子プロセスの中に認証情報を設定します。ドットソース実行は子プロセスそのものをなくすため、同じ変数が親シェルに直接入り、そこに留まります。あるシェルで行ったPATHの変更は、すでに起動しているシェルには反映されません。 Grantedのフォルダを
[Environment]::SetEnvironmentVariable("Path", $newPath, "User")でユーザーのPATHに追加すると、レジストリには書き込まれますが、それを読むのは起動時の新しいプロセスだけです。変更が行われた時点ですでに生きているプロセスには何の効果もありません。すでに開いていたターミナルはどれも
granted.exe : The term 'granted.exe' is not recognized as the name of a cmdlet, function, script file, or operable program.というエラーを出し続け、そのシェルがユーザースコープとマシンスコープ両方から$env:Pathを再読み込みするか、新しいターミナルを開くまで直りませんでした。この同じPATHの罠は、この移行の中でさらに2回顔を出しました。1回は同じセッション内で後から再インストールしたpnpmについて、もう1回は別のシェル(Bash)で、同じツールに対してまったく別の、それ自体壊れたPATHエントリがあったケースです。
AWS Organizationなしで IAM Identity Center を構築する
GrantedのSSOフローが通信する先には、実際のIAM Identity Centerインスタンスが必要です。そのため、ローカルのプロファイルを設定する前に、まずアカウント側でIdentity Centerを有効化する必要がありました。AWSのドキュメントには2種類のインスタンスがあり、1つはAWS Organizationsを必要とする組織インスタンス、もう1つはそれを必要とせず単一のスタンドアロンアカウントに限定されるアカウントインスタンスです。
有効化画面のデフォルトの導線は組織を作成するもので、これにはクリックする前に知っておく価値のある課金上の影響があります。 AWSのドキュメント自体がはっきりと述べている通り、無料利用枠のアカウントで、デフォルトの組織インスタンスを持たせるためにAWS Organizationを作成すると、そのアカウントは即座に従量課金制に切り替わり、残っていた無料利用枠のクレジットは失効します。この画面にはもう一方の選択肢であるアカウントインスタンスも用意されていますが、ボタンではなく副次的なリンクとしてです。何もクリックする前にドキュメントを読んでおいたことで、これは課金上の想定外の事態になる前に気づくことができました。
アカウントインスタンスを有効化すると、一見設定ミスに見えて実はそうではない、もう1つの引っかかりが出てきました。コンソールに**「IAM Identity Center is currently configured in the US East (N. Virginia) Region」**と表示され、これは自分が把握している範囲では何もそこに設定していないにもかかわらず表示されたのです。このバナーは、そのアカウントに対してすでに該当リージョンにインスタンスが存在するときにしか出ません。そしてアカウントインスタンスは作成されたリージョンに永久に固定され、後からリージョンを変えるにはインスタンスを削除し、すべてのユーザー・権限セット・割り当てをゼロから作り直す必要があります。このリージョン固定を、アカウントの実リソースがどこで動くかまで制約するものと読んでしまいがちですが、そうではありません。Identity Centerのリージョンが決めるのは、自身のIDメタデータがどこに置かれるかだけです。EC2インスタンスやS3バケットなど、アカウント内の他のものがどこに置けるかには関係ありません。ログインの仕組みをバージニアから管理しながら、実際のリソースはすべて東京に置いておくのはごく普通のことで、言及に値するようなレイテンシーのコストもありません。
1つに見える2つのログイン
アカウントインスタンスが立ち上がった後は、想定通りにセットアップが進みました。ユーザーを1つ作り、権限セット(AdministratorAccess)を1つ作り、その2つをアカウントに紐づける割り当てを作りました。うまくいかなかったのはそれでログインするところで、しかも役に立つエラーが一切出ないという形で失敗しました。
AWSにサインインする場所として真っ先に思いつくのは、AWSのチュートリアルやブックマークがどれも指すsignin.aws.amazon.comです。ここにアカウントID、新しく作ったIdentity Centerのユーザー名、そのパスワードを入力しても、理由のヒントも一切ないまま毎回ログインを拒否されました。従来のサインイン画面とIdentity Centerのログインは、互いに何も共有しない別々のシステムです。ユーザーディレクトリも、パスワードストアも、一致するアカウントという概念すら共有していません。従来の画面が認証するのは、IAM → ユーザーで作成されたIAMユーザーであり、このアカウントが持つ唯一のIAMユーザーは元のデプロイ用ユーザーであるnextjs-deployだけで、Identity Centerのユーザーがそこに登録されることは決してありません。Identity Centerのアイデンティティのパスワードは、正しいかどうかに関わらず、あの画面を満たすことは絶対にありません。
実際に機能する入り口は、アカウント自身のSSO開始URL(https://d-xxxxxxxxxx.awsapps.com/start、Identity Centerコンソールの Settings ページで確認できます)からたどり着くAWSアクセスポータルの方でした。ここでログインすると、アカウントとそれに割り当てられた権限セットAdministratorAccessがクリック可能なリンクとして並ぶページが表示されます。その横にある小さな鍵アイコンではなく、権限セットの名前自体をクリックすることで、従来のサインインフローと同じくフルのAWS Management Consoleが開きます。鍵アイコンの方はCLI用の認証情報を表示するだけです。開くのは永続的なIAMログインの代わりに、一時的なフェデレーテッドセッションを伴うコンソールです。
メールアドレスの重複と、ユーザーの作り直し
最初に作ったIdentity Centerユーザーは、サブで使っているメールアドレスで作成していて、前述のログイン問題を一度も突破できませんでした。原因はログイン画面の方であって認証情報ではないと理解した後、パスワードをリセットして再挑戦しましたが、それでも認証に失敗しました。一度も成功したことのないログインのデバッグを続けるより、ユーザーを削除して作り直す方が単純に見えたため、実際に日常使っているメールアドレスで作り直すことにしました。
1回目の試行では単純にはいきませんでした。
Errors occurred while adding user "oharu121-dev"The user couldn't be addedDuplicate unique attribute value for emails.valueIdentity Centerはディレクトリ全体で1つのメールアドレスにつき1ユーザーというルールを強制しています。そして、最初の壊れたままのユーザーがまだそのアドレスを持っていました。先にそちらを削除し、同じメールアドレスでoharu121-devを作り直すと、今度は問題なく通りました。今回は従来の画面ではなくAWSアクセスポータル経由でログインしたところ、1回目の試行で成功し、それまでの見立てが裏付けられました。このアカウントには、機能しないパスワードがあったのではなく、間違ったシステムに対してログインを試みていただけでした。
Grantedを新しいログインにつなぐ
Identity Centerのユーザーが機能するようになったので、ローカルのAWS CLIプロファイルを生成するのはコマンド1つで済みました。
granted sso populate --sso-region us-east-1 https://d-xxxxxxxxxx.awsapps.com/startこれによりブラウザが開いて1回限りのデバイス認可が行われ、静的なキーの代わりにGranted自身の認証情報リゾルバーを使うプロファイルが書き込まれました。
[profile oharu121/AdministratorAccess]granted_sso_start_url = https://d-xxxxxxxxxx.awsapps.com/startgranted_sso_region = us-east-1granted_sso_account_id = 123456789012granted_sso_role_name = AdministratorAccesscredential_process = granted credential-process --profile oharu121/AdministratorAccess実際のターミナルウィンドウでassume oharu121/AdministratorAccessを実行し、動作を確認できました。
{ "UserId": "AROAR6BADHEROZFPENFQ5:oharu121-dev", "Account": "123456789012", "Arn": "arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_AdministratorAccess_.../oharu121-dev"}以前のarn:aws:iam::...:user/nextjs-deployの代わりに、assumed-roleのARNになっています。
assumeは、プロファイル名を引数に正確に指定しても、非対話型のシェルからは動かせません。 何を選んでも矢印キーのピッカーが確認のために開き、そのピッカーはスクリプトやツール駆動のセッションから読み取れるコンソール入力を持たないためです。
? Please select the profile you would like to assume: [Use arrows to move, type to filter][✘] Incorrect function.そのため、この移行の中のassume呼び出しはすべて、実際に人が張り付いているターミナルで実行する必要がありました。credential_processは、裏側のSSOトークンがキャッシュされていれば、設計上は非対話式です。そのため、素のawsコマンドは、人が張り付いていようがいまいがどこでも動きます。[default]プロファイルを同じリゾルバーに向けることで、コマンドごとに--profileを入力する必要がなくなりました。
[default]region = ap-northeast-1output = jsoncredential_process = granted credential-process --profile oharu121/AdministratorAccess新しいターミナルを開くたびにピッカーが再現した
何かを入力する前に、ただPowerShellのウィンドウを開くだけで、毎回そのままインタラクティブなピッカーに落ちるようになりました。
? Please select the profile you would like to assume: [Use arrows to move, type to filter]
> oharu121/AdministratorAccess default[✘] interrupt原因は、本記事が先に勧めていたそのプロファイル行でした。 エージェントがMicrosoft.PowerShell_profile.ps1を読み直したところ、上で紹介したのと同じ行が見つかりました。
. "$env:USERPROFILE\granted\assume.ps1"assume.ps1は自身のロジックを関数でラップしていません。ドットソース実行しているその行で、直接assumegoを実行します。
$ASSUME_FLAG, $ASSUME_1, ... = `$(& (Join-Path $PSScriptRoot -ChildPath "assumego") $args) -split '\s+'そしてassumego --helpが示す使い方はassume [options][Profile]で、プロファイル引数がなければインタラクティブなピッカーにフォールバックします。まさに空の$argsが生む状態です。スクリプトをそのままプロファイルにドットソース実行すると、その行はシェル起動のたびに即座に実行され、渡すプロファイル引数もないため、ピッカーは最初の1回だけでなく毎回のターミナルで発火していました。
修正はドットソース実行そのものは変えず(単なる.exeは依然として親シェルの環境変数を書き換えられないため)、代わりにそれを関数の後ろに遅延させ、実際にassumeと入力されたときだけその行が実行されるようにするというものです。
function assume { . "$env:USERPROFILE\granted\assume.ps1" @args }この関数を定義するだけのプロファイルは、起動時には何も実行しません。 その後assume oharu121/AdministratorAccessを実行すると、以前とまったく同じように動作しました。@argsによるスプラットが、シェル起動のたびにではなく必要になった時点でプロファイル名をassumegoまで届けます。
動作確認と、管理者権限で見えたもの
--profileフラグなしでaws sts get-caller-identityを実行し、defaultプロファイルがSSO経由で解決されるようになったことを確認しました。次のステップは、静的なキーを完全に退役させることでした。まず.github/workflowsやAWS SDK、キーID自体が出てきそうな場所を含めて、このブログのリポジトリのCI設定やソースコードにそのキーへの参照がないか確認したうえで、nextjs-deployユーザーに対してaws iam delete-access-keyを実行しました。リポジトリ内にはそのキーへの参照は一切ありませんでした。つまり、このブログはホスティング先自身のGit連携を通じてデプロイされており、そのキー経由ではなかったということです。
この管理者セッションのおかげで、アカウント内に他にまだ動いているものがないか監査することもできました。稼働中のコンピュートリソースやアクティブなElastic Beanstalk環境は残っていませんでした。残っていたのは、数キロバイトしかない、とっくに放棄されたプロジェクトのデプロイ成果物である孤立したS3バケットが2つと、何もデプロイされていない空のElastic Beanstalkの「Application」レコードが2つだけでした。
バケットを削除しようとしたところ、AdministratorAccessが実は管理者権限そのものではなかった唯一の箇所が明らかになりました。
An error occurred (AccessDenied) when calling the DeleteBucket operation:User: arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_AdministratorAccess_.../oharu121-devis not authorized to perform: s3:DeleteBucket on resource: "arn:aws:s3:::elasticbeanstalk-..."with an explicit deny in a resource-based policyこのバケットはElastic Beanstalkが自動作成したもので、AWSがデフォルトで付与するステートメントを含む独自のリソースベースポリシーを持っていました。
{ "Sid": "eb-58950a8c-feb6-11e2-89e0-0800277d041b", "Effect": "Deny", "Principal": { "AWS": "*" }, "Action": "s3:DeleteBucket", "Resource": "arn:aws:s3:::elasticbeanstalk-..."}明示的なDenyは、AWSのポリシー評価においてすべてのAllowに優先します。ワイルドカードなAdministratorAccess権限セットも例外ではありません。これは特定のアイデンティティに向けて書かれたものではなく、誰に対しても書かれているため、管理者権限であっても上回ることができないからです。まずバケット自身のリソースベースポリシーを削除し、その後バケットを削除することで解消しました。AdministratorAccessが意味するのは、IDベースのポリシーが邪魔をしないということだけであり、リソース自身のポリシーがそのリソース自身の邪魔をすることについては何も言っていません。
まとめ
このアカウントは最終的に静的なAWSキーがゼロになりました。古いIAMユーザーのアクセスキーはIAMから削除され、~/.aws/credentialsは空になり、すべてのawsコマンドはデフォルトでGrantedのcredential_processを経由してIAM Identity CenterのSSOに解決されるようになりました。ここに至るまでには、何かをインストールする前のアーキテクチャ確認、単なるPATHエントリでは代替できないPowerShellプロファイルの編集、望まないAWS Organizationを避けるためのアカウントインスタンスの選択、そして間違ったパスワードとは何の関係もないログインの失敗がありました。従来のAWSサインイン画面とIdentity Centerのアクセスポータルは、同じ家に入る2つのドアのように見えます。しかし実際には2つの別々の家であり、Identity Centerのユーザーのことを知っているのはそのうち片方だけです。



