AWS

1Password Shell Pluginを自作してCDK for Terraform CLIを指紋認証で実行してみた

Champ

初めまして、2023年1月にDWSへ入社したChamp👑です 🙌

皆さんはCLIで認証情報を利用する際はどのような方法で設定していますか?
以前は認証情報をプレーンテキスト形式でローカルファイルに保存していましたが、現在はパスワード管理ツールを利用して認証情報を管理し、CLIからパスワード管理ツールにアクセスすることで、認証情報をローカルに保存することなく利用できるようにしています。

1PasswordではCLI Pluginを利用することで、AWSの資格情報を手動で設定することなくCLIを実行することができますが、CDK for Terraform(以下、cdktf)など用意されていないCLIでは1Passwordのop readコマンドなどを利用する必要があります。

そこで今回はcdktf実行時に自動で認証情報が設定されるように1Password Shell Pluginプラグインを自作してみました。

1Password Shell Pluginとは

1PasswordではCLI Pluginを利用することで、AWSの資格情報を手動で設定することなくCLIを実行することができますが、CDK for Terraform(以下、cdktf)など1PasswordのCLI Pluginが用意されていない場合には、1Passwordのop readコマンドなどを利用する必要があります。

AWSやGithubなどさまざまなCLIに対応するプラグインが用意されており、以下は2023/02/10時点で利用可能なプラグインの一覧です。

image.png (205.0 kB)

1Password CLI Pluginを自作する

先程のプラグインの一覧にcdktfは含まれていなかったので自作してみたいと思います。
独自のプラグインを自作する際の手順については、公式サイトに記載されていますのでこの手順に沿って進めていきたいと思います。

プラグインのテンプレートの作成

1Password Shell Pluginsリポジトリからクローンした後、makeコマンドを実行します。

git clone https://github.com/1Password/shell-plugins.git
cd shell-plugins
make new-plugin

対話形式でプラグインに必要な情報を入力します。

? Plugin name (e.g. "aws" or "github") [required] cdktf
? Platform name (e.g. "AWS" or "GitHub") [required] AWS
? Executable name (e.g. "aws" or "gh") [required] cdktf
? Name of the credential type (e.g. "Access Key" or "Personal Access Token") Access Key
? Paste in an example credential **************

入力が完了すると、pluginsディレクトリ以下に独自プラグインに必要なファイルがまとめられたディレクトリが作成されます。

ls plugins/cdktf 
access_key.go      access_key_test.go cdktf.go           plugin.go

生成されたファイルにはTODOコメントが追記されており、独自プラグインとして利用するコマンド(今回はcdktf)に応じて修正をする必要があります。

プラグイン定義の更新

plugin.goファイルには認証情報の種類や実行するコマンドなどの基本情報が含まれています。
以下に、plugin.goを記載します。

package cdktf

import (
    "github.com/1Password/shell-plugins/sdk"
    "github.com/1Password/shell-plugins/sdk/schema"
)

func New() schema.Plugin {
    return schema.Plugin{
        Name: "cdktf",
        Platform: schema.PlatformInfo{
            Name:     "AWS",
            Homepage: sdk.URL("https://developer.hashicorp.com/terraform/tutorials/cdktf/cdktf-install"), // URLをcdktf公式サイトに変更
        },
        Credentials: []schema.CredentialType{
            AccessKey(),
        },
        Executables: []schema.Executable{
            AWSCLI(),
        },
    }
}

資格情報の定義の更新

access_key.goには資格情報が定義されており、資格情報のスキーマや1Passwordへのインポート方法などが記載されています。
生成されたテンプレートから変更したソースコードを以下に記載します。

func AccessKey() schema.CredentialType {
    return schema.CredentialType{
        Name:          credname.AccessKey, //TODO
        DocsURL:       sdk.URL("https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html"), //認証情報のドキュメントのURL
        ManagementURL: sdk.URL("https://console.aws.amazon.com/iam"), //認証情報の管理画面のURL
        Fields: []schema.CredentialField{
            {
                Name:                fieldname.AccessKeyID,
                MarkdownDescription: "The ID of the access key used to authenticate to AWS.",
                Composition: &schema.ValueComposition{ //AccessKeyのスキーマを定義
                    Length: 20,
                    Prefix: "",
                    Charset: schema.Charset{
                        Uppercase: true,
                        Digits:    true,
                    },
                },
            },
            //生成されたテンプレートにはAccessKeyのオブジェクトのみが生成されるため、SecretAccessKey用のオブジェクトを追加
            {
                Name:                fieldname.SecretAccessKey,
                MarkdownDescription: "The secret access key used to authenticate to AWS.",
                Secret:              true,
                Composition: &schema.ValueComposition{ //SecretAccessKeyのスキーマを定義
                    Length: 40,
                    Charset: schema.Charset{
                        Uppercase: true,
                        Lowercase: true,
                        Digits:    true,
                    },
                },
            },
            //リージョン用のオブジェクトを追加
            {
                Name:                fieldname.DefaultRegion,
                MarkdownDescription: "The default region to use for this access key.",
                Optional:            true,
            },
            //ワンタイムパスワード用オブジェクトを追加
            {
                Name:                fieldname.OneTimePassword,
                MarkdownDescription: "The one-time code value for MFA authentication.",
                Optional:            true,
            },
            //TODO
            {
                Name:                fieldname.MFASerial,
                MarkdownDescription: "ARN of the MFA serial number to use to generate temporary STS credentials if the item contains a TOTP setup.",
                Optional:            true,
            },
        },
        //実行するコマンドへ資格情報を割り当てる方法を記述。AWS用のProvisionerはSDKとして用意されているためそちらを利用する。
        DefaultProvisioner: AWSProvisioner(),
        Importer: importer.TryAll(
            importer.TryEnvVarPair(defaultEnvVarMapping),
            importer.TryEnvVarPair(map[string]sdk.FieldName{
                "AMAZON_ACCESS_KEY_ID":     fieldname.AccessKeyID,
                "AMAZON_SECRET_ACCESS_KEY": fieldname.SecretAccessKey,
                "AWS_DEFAULT_REGION":       fieldname.DefaultRegion,
            }),
            importer.TryEnvVarPair(map[string]sdk.FieldName{
                "AWS_ACCESS_KEY":     fieldname.AccessKeyID,
                "AWS_SECRET_KEY":     fieldname.SecretAccessKey,
                "AWS_DEFAULT_REGION": fieldname.DefaultRegion,
            }),
            importer.TryEnvVarPair(map[string]sdk.FieldName{
                "AWS_ACCESS_KEY":     fieldname.AccessKeyID,
                "AWS_ACCESS_SECRET":  fieldname.SecretAccessKey,
                "AWS_DEFAULT_REGION": fieldname.DefaultRegion,
            }),
            TryCredentialsFile(),
        )}
}
//SDKに必要な変数の定義
var defaultEnvVarMapping = map[string]sdk.FieldName{
    "AWS_ACCESS_KEY_ID":     fieldname.AccessKeyID,
    "AWS_SECRET_ACCESS_KEY": fieldname.SecretAccessKey,
    "AWS_DEFAULT_REGION":    fieldname.DefaultRegion,
}

// SDKに必要な関数の定義
func TryCredentialsFile() sdk.Importer {
    return importer.TryFile("~/.aws/credentials", func(ctx context.Context, contents importer.FileContents, in sdk.ImportInput, out *sdk.ImportAttempt) {
        credentialsFile, err := contents.ToINI()
        if err != nil {
            out.AddError(err)
            return
        }

        configPath := os.Getenv("AWS_CONFIG_FILE")
        if configPath != "" {
            if strings.HasPrefix(configPath, "~") {
                configPath = in.FromHomeDir(configPath[1:])
            } else {
                configPath = in.FromRootDir(configPath)
            }
        } else {
            configPath = in.FromHomeDir(".aws", "config") // default config file location
        }
        var configFile *ini.File
        configContent, err := os.ReadFile(configPath)
        if err != nil && !os.IsNotExist(err) {
            out.AddError(err)
        }
        configFile, err = importer.FileContents(configContent).ToINI()
        if err != nil {
            out.AddError(err)
        }

        for _, section := range credentialsFile.Sections() {
            profileName := section.Name()
            fields := make(map[sdk.FieldName]string)
            if section.HasKey("aws_access_key_id") && section.Key("aws_access_key_id").Value() != "" {
                fields[fieldname.AccessKeyID] = section.Key("aws_access_key_id").Value()
            }

            if section.HasKey("aws_secret_access_key") && section.Key("aws_secret_access_key").Value() != "" {
                fields[fieldname.SecretAccessKey] = section.Key("aws_secret_access_key").Value()
            }

            if configFile != nil {
                configSection := getConfigSectionByProfile(configFile, profileName)
                if configSection != nil {
                    if configSection.HasKey("region") && configSection.Key("region").Value() != "" {
                        fields[fieldname.DefaultRegion] = configSection.Key("region").Value()
                    }
                }
            }

            if fields[fieldname.AccessKeyID] != "" && fields[fieldname.SecretAccessKey] != "" {
                out.AddCandidate(sdk.ImportCandidate{
                    Fields:   fields,
                    NameHint: importer.SanitizeNameHint(profileName),
                })
            }
        }
    })
}

上記ファイルの変更に加えて、SDKに必要な以下ファイルをplugins/awsからplugins/cdktfにコピーします。

  • provisioner.go
  • sts_provisioner.go
  • utils.go

実行可能ファイルの定義

最後に、cdktf.goを更新します。
cdktf.goでは1Passwordに格納されている認証情報を利用して認証を処理するCLIや実行可能ファイルを定義します。
生成されたテンプレートから変更したソースコードを以下に記載します。

package cdktf

import (
    "github.com/1Password/shell-plugins/sdk"
    "github.com/1Password/shell-plugins/sdk/needsauth"
    "github.com/1Password/shell-plugins/sdk/schema"
    "github.com/1Password/shell-plugins/sdk/schema/credname"
)

func CDKTFCLI() schema.Executable {
    return schema.Executable{
        Name:    "CDK for Terraform CLI",
        Runs:    []string{"cdktf"},
        DocsURL: sdk.URL("https://developer.hashicorp.com/terraform/cdktf"),
        NeedsAuth: needsauth.IfAll(
            needsauth.NotForHelpOrVersion(),
            needsauth.NotWithoutArgs(),
        ),
        Uses: []schema.CredentialUsage{
            {
                Name: credname.AccessKey,
            },
        },
    }
}

プラグインの検証・ビルド

プラグインで資格情報や実行可能ファイルの情報が正しく定義されていることを確認するために、以下のコマンドを実行します。
項目ごとに確認が行われ、正しく定義されている箇所にはチェックマークがつきます。

make cdktf/validate

検証に成功したら、以下のコマンドからビルドを実施します。

make cdktf/build

ビルドの完了後、作成したプラグインが1password CLIのプラグイン一覧に表示されることを確認します。

op plugin list

プラグインの検証

ここでは実際にコマンドを実行し、1password経由でコマンドが認証されることを確認します。

まずは任意のディレクトリで以下を実行し、cdktfで簡単なリソースを作成するスタックを記述します。

mkdir test_op_plugin
cd test_op_plugin
cdktf init --template="typescript" --providers="aws@~>4.0"

次に、作成するリソースを定義します。
本記事の目的ではないのでCDKTFでリソースを定義する方法の説明は割愛します。

最後に、1password経由でcdktfコマンドが認証されることを確認します。

cdktf cdploy

上記のコマンドを実行すると、TerminalにWARNINGが表示され、Touch IDで指紋認証が求められれば成功です。
ダイアログの指示通りにTouch IDを使用すれば認証されます。

スクリーンショット 2023-02-14 18.56.38.png (49.9 kB)

最後に

1Password Shellの独自プラグインを作成し、cdktfを指紋認証で実行できる様にしてみました。
アクセスキーなどの認証情報は極力利用しない方が良いとされていますが、どうしても必要な場合も出てくるかと思います。
そんな時もローカルに保存するのではなく、1Passwordなどのパスワード管理ツールを使用することでリスクを下げることができるのではないでしょうか。

以上、Champ👑でした。

AUTHOR
champ
champ
記事URLをコピーしました