24/7 twenty-four seven

iOS/OS X application programing topics.

CIのシークレット変数に1Password CLIを利用する

CIでいろいろなタスクを自動化していると、CIで必要とするAPIのトークンやアカウント情報など設定しているシークレット変数が増えてきます。

たいていの場合はCIサービスのシークレット変数を利用すればよいですが、サービスによっては一度設定したシークレット変数を見ることができなかったり(GitHub ActionsやCircle CIが該当)、トークンやアカウント情報の更新や追加があったときにCIの変数を更新していくのが大変だったり、シークレット変数のメンテナンスはそこそこ面倒な作業です。

性質上かなり強い権限が設定されているトークンだったりすることもあるので、誰がその値をメンテナンスできるか、という管理の問題もあります。

そこで1Passwordをアカウント情報の共有に使っている組織なら、1PasswordはCLIの操作が提供されているのでCIから1Passwordのアカウント情報を取得すると便利だったので方法を共有します。

しばらく運用してみてわかったことは、すべてを1Passwordから取得するようにするのはシークレット変数の設定に1Password CLIのセットアップが必要で、初見の人には何をしているか分かりにくいのでDocs as Codeの側面が弱くなるというデメリットがあるので、ほとんどのシークレット変数は普通にCIサービスの機能を使う方が良いと思います。

その上で、

  • 更新や追加がそこそこある
  • 更新や追加をソフトウェアエンジニア以外の人がやることがある
  • シークレット情報の権限管理を簡単にしたい
  • 2要素認証のワンタイムパスワードをCIで入力したい

という課題がある場合に必要に応じて利用すると良いと思います。

ヤプリではApple IDと、Googleアカウントの情報をCIから1Password CLIを用いて取得しています。

Apple IDはクライアントごとのアプリの申請に必要でクライアントの増加とともにアカウントも増えます。

その際のアカウントの管理はソフトウェアエンジニア以外の人によって行われます。アカウントが追加されたときはCIのシークレット変数にも更新が必要ですが、1Passwordから取得することでメンテナンスの手間が不要になります。

GoogleアカウントはGoogleのサービスでAPIが提供されていない操作を自動化するために、ヘッドレスChrome APIライブラリのPuppeteerでログインするときの2要素認証をクリアするためにワンタイムパスワードを1Passwordから取得しています。

1Password CLIをCIの環境にインストールする

Macホストで動かす場合はHomebrewでインストールできます。

brew install --cask 1password-cli

LinuxやDocker環境の場合は下記のようにバイナリをダウンロードしてPATHの通っている場所に展開します。

export ONE_PASSWORD_VERSION="v1.8.0"

curl -sS -o 1password.zip https://cache.agilebits.com/dist/1P/op/pkg/$ONE_PASSWORD_VERSION/op_linux_amd64_$ONE_PASSWORD_VERSION.zip \
    && unzip -o 1password.zip op -d /usr/bin \
    && rm 1password.zip

詳しくは公式ドキュメントを見てください。

1Password CLIのセットアップ

1Password CLIで保管庫にアクセスするには、まず1Passwordのアカウントでログインして返ってきたセッション変数を環境変数に設定します。

CIのステップでセットアップするためのBashスクリプト、Fastlane等で利用するためのRubyスクリプト、Slack Bot等で利用するためのNode.jsスクリプトのそれぞれのセットアップ方法を下記に示します。

OP_PASSWORDOP_SIGN_IN_ADDRESSOP_EMAIL_ADDRESSOP_SECRET_KEYはCIのシークレット変数に設定します。

人の入れ替わり等でOP_〜変数の更新が発生しないようにCI専用の1Passwordユーザーを作成するとよいです。そのアカウント情報も1Passwordで管理するとうまくいきます。

# Bashスクリプト
yes "$OP_PASSWORD" | op signin "$OP_SIGN_IN_ADDRESS" "$OP_EMAIL_ADDRESS" "$OP_SECRET_KEY"
eval $(yes "$OP_PASSWORD" | op signin "$OP_SIGN_IN_ADDRESS")

 

# Rubyスクリプト
%x(yes "$OP_PASSWORD" | op signin "$OP_SIGN_IN_ADDRESS" "$OP_EMAIL_ADDRESS" "$OP_SECRET_KEY")
ENV["OP_SESSION_#{ENV["OP_SIGN_IN_ADDRESS"]}"] = %x(yes "$OP_PASSWORD" | op signin --raw "$OP_SIGN_IN_ADDRESS").strip

 

// Node.js
const env = process.env;

await exec(
  `yes "${env.OP_PASSWORD}" | op signin ${env.OP_SIGN_IN_ADDRESS} ${env.OP_EMAIL_ADDRESS} ${env.OP_SECRET_KEY}`,
  {
    env: env,
  }
);
const session = await exec(`yes "${env.OP_PASSWORD}" | op signin --raw`, {
  env: env,
});

env[`OP_SESSION_${env.OP_SIGN_IN_ADDRESS}`] = session.stdout.trim();

詳しくは公式ドキュメントを見てください。

1Password CLIからアカウント情報を取得する

1Password CLIを用いてアカウント情報を取得する例を下記に示します。

キーに対応するパスワードを取得する

もっとも基本的なキーに対応する値を取得する例は次のようになります。

envman add --key FASTLANE_PASSWORD --value `op get item "Apple ID - $YAPPLI_APP_STORE_USERNAME" --fields password`

このコードはApple ID - $YAPPLI_APP_STORE_USERNAMEという名前に対応したパスワードを1Passwordから取得して、FASTLANE_PASSWORDというキーで環境変数に設定しています。

op get item "<名前>" --fields passwordで名前に対応するパスワードが取得できます。

名前だけで一意に決まらない場合は--vaultオプションで保管庫を指定できます。

名前の代わりに内部的なUUIDを用いて参照することもできます。

名前で参照すると何を取得しているかが読みやすく、UUIDで参照すると何を取得しているのかが分かりにくくなりますが変更に強くなります(名前をいつでも好きに変えられる)。

特定の保管庫に保存されているアカウント情報をすべて取得する

次に特定の保管庫に保存されているアカウント情報をすべて取得してループする例です。

これは先に書いたようなアカウント情報の増減に自動的に対応するような処理を書く際に便利です。

def get_accounts
  %x(yes "$OP_PASSWORD" | op signin "$OP_SIGN_IN_ADDRESS" "$OP_EMAIL_ADDRESS" "$OP_SECRET_KEY")
  ENV["OP_SESSION_#{ENV["OP_SIGN_IN_ADDRESS"]}"] = %x(yes "$OP_PASSWORD" | op signin --raw "$OP_SIGN_IN_ADDRESS").strip

  items = JSON.parse(%x(op list items --vault "アプリ公開関係"))
  accounts = {}
  items.each do |item|
    account = JSON.parse(%x(op get item #{item["uuid"]} --fields username,password))
    accounts[account["username"]] = account["password"]
  end
  accounts
end

上記のコードは、まずop list items --vault "アプリ公開関係"として"アプリ公開関係"という名前の保管庫に保存されている項目をすべて取得します。

ここで返ってくる情報は概要のみ(UUIDや名前など)で実際のアカウント情報は含まれていないので、さらに各項目に対してループの中で先ほどのop get itemコマンドを使ってユーザー名とパスワードを取得しています。

最終的に、ユーザー名とパスワードがキーと値のHashオブジェクトが作られています。

2要素認証のワンタイムパスワードを取得する

1Passwordには2要素認証のワンタイムパスワードも共有できます。

その値もCLIを用いて取得できるので、2要素認証が必要な処理をCIで利用できます。

const env = process.env;
  
...

const totp = await exec(`op get totp ${env.GOOGLE_USERNANE}`, {
  env: env,
});

...

op get totp <名前>で共有されているワンタイムパスワードを取得できます。

ワンタイムパスワード(TOTP)なので時間によって変化するのでCIのシークレット環境変数等に登録することはできませんが、1Passwordに共有されているものを取得するようにすることでCIでも2要素認証をクリアできます。

上記の処理を用いてPuppeteerを使って、APIが提供されていないGoogleアカウントの操作を自動化しています。

実際のコード例を下記に示します。

const env = process.env;
  
await exec(
  `yes "${env.OP_PASSWORD}" | op signin ${env.OP_SIGN_IN_ADDRESS} ${env.OP_EMAIL_ADDRESS} ${env.OP_SECRET_KEY}`,
  {
    env: env,
  }
);
const session = await exec(`yes "${env.OP_PASSWORD}" | op signin --raw`, {
  env: env,
});

env[`OP_SESSION_${env.OP_SIGN_IN_ADDRESS}`] = session.stdout.trim();

const browser = await puppeteer.launch({ headless: false });
const page = await browser.newPage();
await page.goto("https://marketingplatform.google.com/home?authuser=0");

await page.type("#identifierId", env.GOOGLE_USERNANE);
await Promise.all([
  page.waitForNavigation({ waitUntil: ["load", "networkidle2"] }),
  page.click("#identifierNext > div > button"),
]);

await page.waitForTimeout(1000);
await page.waitForSelector('input[type="password"]');
await page.type('input[type="password"]', env.GOOGLE_PASSWORD);
await Promise.all([
  page.waitForNavigation({ waitUntil: ["load", "networkidle2"] }),
  page.click("#passwordNext > div > button"),
]);

await page.waitForTimeout(1000);
await page.waitForSelector("#totpPin");

const totp = await exec(`op get totp ${env.GOOGLE_USERNANE}`, {
  env: env,
});

await page.type("#totpPin", totp.stdout.trim());
await Promise.all([
  page.waitForNavigation({ waitUntil: ["load", "networkidle2"] }),
  page.click("#totpNext > div > button"),
]);

...