社内 NPM パッケージの作り方 with Bun + Google Cloud
はじめに
こんにちは。 whatasoda です。
プログラミング言語を TypeScript に統一しつつマルチプロダクトなサービスを開発しているダイニーでは、毎度利用する便利関数など、プロダクト横断で利用できる共通の実装の共有方法を色々と模索してきました。例えば、今回紹介する方法に移行する前は、モノレポで開発していることを生かして Symbolic Link を使った方法をツール化して採用していました。その方法ではソースコードそのものを直接共有しつつ利用側で適宜ビルドしていましたが、以下のような課題がありました。
- 常に最新のファイルが参照されるため、共通部分の変化に気が付きにくい
- 都度ビルドをするが、利用側の tsconfig や型情報と競合して素直にビルドできないことがあった
- 開発者毎の環境の差分やプロジェクトの構成の変化によって New Comer のセットアップを阻害することが多かった
ダイニーは All in One Restaurant Cloud. を目指しており、今後の事業の拡大に伴い新しいアプリケーションがいくつも立ち上がることが確定しています。上記の課題は今後のスケールを阻害する要因の一つになってしまっていました。
そこで、社内限定で NPM パッケージ公開できる Private Registry のセットアップを行いました。この記事ではその過程で浮上した課題・懸念とそこに対する解決策を中心に、ダイニーが行ったセットアップ方法をご紹介します。
記事の対象読者
ダイニーでは以下の技術・サービスを前提として Private Registry を構築しています。この記事の主な対象はこれらの技術を採用している・採用しようとしている方となります。
- Bun (Package Manager として)
- Google Cloud Artifact Registry
- GitHub Actions (CI/CD)
目指す状態
今回 Private Registry を構築するに上で以下の 3 点を必須要件として設定していました。
- Bun に対応していること
- GitHub Actions をはじめとした CI / CD でも社内NPMパッケージにアクセスできていること
- セキュリティリスクが許容できる範囲に抑えられている
また、以前の方法で New Comer のセットアップを何度か阻害してしまった反省から、「New Comer でもコマンドをいくつか実行するだけで簡単にセットアップが完了すること」も意識してセットアップに取り組みました。今後のダイニーではこれまで以上に多くの仲間と一緒に働くことになっていくので、必須ではないにしても非常に重要な要素です。
Private Registry 構築におけるセキュリティリスクについて
3 つの必須要件の中の 3 つ目についてもう少し詳しく触れておきます。一言にセキュリティリスクといっても色々あると思いますが、この記事では社内パッケージの漏洩や第三者による改変を主なリスクとして取り上げます。そもそも Private Registry に秘匿性の高いコードを公開するべきではないと思いますが、仮に秘匿性の低いものであっても会社の資産であることには変わりありません。会社の資産は守っていかねばなりません。
まず、攻撃者の目線に立ってみて、 Private Registry の中身を盗み見たり改変したりする場合に何が必要かを考えます。 NPM の Private Registry は実現方法に関わらず「Registry の URL」と「認証を通すためのトークン」の 2 つが必要になります。例えば GitHub Packages の公式ドキュメント では以下の記述を .npmrc に追加するような案内があります。
//npm.pkg.github.com/:_authToken=TOKEN
URL の方は知られても大した影響はないですが、トークンについては注意深く扱う必要があります。トークンは開発者のローカル環境や CI / CD のリモート環境で、特定のファイルに書き込んだり環境変数に設定して利用します。各環境から漏洩しないようにするのも重要ですが、漏洩してしまった場合のリスクをコントロールするという発想から、今回は以下の観点を考慮しました。それぞれ詳しくは後続のセクションにて説明します。
- トークンのライフサイクル
- 有効期限の有無や rotate の方法など
- トークンが持つ権限の範囲
- 過剰な権限を持っていないか
Google Cloud Artifact Registry でセキュリティリスクを最小化
Private Registry の構築に利用できるサービスはいくつかあります。
- NPM with Paid Organization https://www.npmjs.com/products
- JFrog Artifactory https://jfrog.com/ja/artifactory/
- GitHub Packages https://github.co.jp/features/packages
- Google Cloud Artifact Registry https://cloud.google.com/artifact-registry/docs/nodejs?hl=ja
今回はセキュリティリスクの最小化の観点から Google Cloud Artifact Registry (以後 Artifact Registry)を採用しました。
認証に用いるトークンには必ず有効期限がある
多くのサービスでは Private Registry への認証に有効期限のないトークンを利用します。しかし Artifact Registry では Google Cloud のアクセストークンを利用する方式になっており、このアクセストークンには必ず有効期限が設定されています。(デフォルトで1時間)
有効期限がないトークンが漏洩した場合、そのトークンが無効化されるまでの間ずっと Private Registry 内のパッケージに第三者がアクセスできる状態になってしまいます。トークンの持つ権限によっては不正なプログラムを社内パッケージに仕込まれる危険性もあります。この危険な状態は、漏洩に気がつくことができなければずっと続くことになります。(そして攻撃を受けるまで気がつくのは困難です。)
有効期限があるトークンが漏洩した場合でも、有効期限内であれば期限なしトークンと全く同じリスクがあるため漏洩の対策は変わらず必要です。CI / CD などで毎回漏洩しているということであれば話は別ですが、たまたまどこかに共有されてしまって一時的に漏洩していただけであれば、社内パッケージそのものが外部に漏洩するリスクはぐっと低く見積もることができます。(リスクがないわけではありません。)
Google Cloud の IAM だけで権限を管理することができる
Google Cloud のアクセストークンのすべてが Artifact Registry に対するアクセス権を持っているわけではありません。特定の IAM Role をユーザーアカウントやサービスアカウントに設定することで設定することで初めてアクセス権を得ることができます。
ダイニーではサービスのデプロイに Google Cloud を利用していますし、そもそも組織として Google Workspace を利用しています。Google Workspace にはユーザーをグループで管理できる機能があり、ダイニーでも職能(エンジニア、デザイナー、PdM )や雇用形態(正社員、業務委託、アルバイト)などでのグループ分けを行っています。 IAM Role はこのグループに対して設定することもできるので、エンジニアが所属するグループに設定することで以下のメリットがあります。
- エンジニアとして入社するだけで Private Registry へのアクセス権を得られる
- チームの管理に伴って Private Registry への権限の管理も自動的に行われる
- 退職者はアカウントの無効化に伴って自動的にアクセス権を失う
これは権限管理による安全性の向上だけではなく New Comer のセットアップの簡略化にも貢献してくれています。
Google Cloud Artifact Registry 自体の構築をする
以下の記事が非常に参考になりました。ほとんど重複する説明になってしまうためここでは割愛します。
GitHub Actions で Google Cloud に認証する
こちらも本記事では割愛します。ダイニーでは Workload Identity を設定して利用しています。
Bun が Google Cloud Artifact Registry に認証できるようにする
実は Artifact Registry への認証を簡単に構築することができる google-artifactregistry-auth というツールがあります。これは素の npm をはじめとした .npmrc に記述された Private Registry の情報を解釈できるパッケージマネージャーであれば同様に利用することができます。どうやら Bun でも .npmrc に対応しているようなのですが、以下の理由から今回は google-artifactregistry-auth を真似た独自のスクリプトを作成しました。
トークンのもつ権限を最小限にする
開発者のユーザーアカウントには Artifact Registry の利用に関係のない、より強い権限が付与されていることがあります。極端な話、組織が提供しているサービスを復帰不能なレベルにまで壊せるような権限が付与されていることもあり得ます。
そのようなユーザーアカウントの手元で発行されるアクセストークンにはそういった他の権限も付与されていることがあります。 Google Cloud では必要最低限の権限のみをもったトークンを発行する方法が存在しているので、それを利用して過剰な権限によるリスクを軽減したいです。しかし、 google-artifactregistry-auth ではこれらの要素を指定する方法がなく、自力で実装が必要でした。
Bun の進化による変化を吸収する
Bun はまだまだ活発な開発が行われているテクノロジーです。今後のアップデートによってこれまで動いていた構成が動かなくなるということは想像に易いです。諸々の処理をスクリプトの中に詰め込んでおけば、スクリプトを呼び出すワークフローなどへの変更をせずとも変化に対応することができます。
スクリプトの紹介
さて、ここからは具体のスクリプトの説明をしていきます。実際にダイニーで利用しているものとは一部異なりますが、ここで紹介するものを使えば最低限の構成が実現できるようにしてあります。
スクリプトはいくつかの要素に分かれています。まずは全体像からご紹介します。(getToken と injectRegistryAuthSection の内容については後述のセクションに記載があります。)
#!/usr/bin/env bun
const getToken = async () => {
/* 既述の getToken の実装 */
};
const injectRegistryAuthSection = ({ /* ... */ }) => {
/* 既述の injectRegistryAuthSection の実装 */
};
const main = async () => {
const XDG_CONFIG_HOME_BUNFIG = process.env.XDG_CONFIG_HOME
? `${process.env.XDG_CONFIG_HOME}/.bunfig.toml`
: null;
const HOME_BUNFIG = `${process.env.HOME}/.bunfig.toml`;
const file = Bun.file(XDG_CONFIG_HOME_BUNFIG || HOME_BUNFIG);
let content = (await file.exists()) ? await file.text() : null;
const token = await getToken();
content = injectRegistryAuthSection({
scope: "@{SCOPE NAME}",
url: "https://{LOCATION}-npm.pkg.dev/{PROJECT}/{REPOSITORY}/",
token,
content,
});
await Bun.write(file, content);
};
void main();
main の中では読み取られるグローバルの bunfig の位置を特定し、読み取りと書き込みを行っています(参考: https://bun.sh/docs/runtime/bunfig )。また、社内パッケージの共有を目的とした Private Registry では特定のスコープを紐づける必要があるのですが、その指定をここで行っています。例えば、 @dinii/awesome-package
のように @dinii
というスコープがついているパッケージを Private Registry に取りに行くように設定します。
サービスアカウントの準備
スクリプトの作成の前に権限を絞るためのサービスアカウントを用意します。この記事では細かな作成手順については割愛し、サービスアカウント周辺の IAM の設定についてのみご説明します。説明の都合上、権限絞り込みのためのサービスアカウントをエージェントサービスアカウントと呼びます。
- 以下のプリンシパルが NPM Private Registry 用の Repository に対して
roles/artifactregistry.reader
のロールを持っている- エージェントサービスアカウント
- 開発者のユーザーアカウント
- GitHub Actions で利用するサービスアカウント
- 以下のプリンシパルがエージェントサービスアカウントに対して
roles/iam.serviceAccountTokenCreator
とroles/iam.serviceAccountUser
の権限を持っている- 開発者のユーザーアカウント
- GitHub Actions で利用するサービスアカウント
terraform だと以下のようになります。
resource "google_artifact_registry_repository_iam_binding" "reader" {
project = "your project name"
location = "your repository location"
repository = "your repository name"
role = "roles/artifactregistry.reader"
members = [
"serviceAccount:{AGENT_SERVICE_ACCOUNT_ID}@{PROJECT}.iam.gserviceaccount.com",
# ... principals of developer user accounts and other service accounts
]
}
resource "google_service_account_iam_binding" "agent_users" {
for_each = toset([
"roles/iam.serviceAccountTokenCreator",
"roles/iam.serviceAccountUser",
])
service_account_id = "{AGENT_SERVICE_ACCOUNT_ID}@{PROJECT}.iam.gserviceaccount.com"
role = each.key
members = [
# ... principals of developer user accounts and other service accounts
]
}
トークンの発行
// Artifact Registry を利用するうえで必要な最小限の権限を持ったサービスアカウントを用意する
const AGENT_SERVICE_ACCOUNT = "{SERVICE_ACCOUNT_NAME}@{PROJECT}.iam.gserviceaccount.com";
const getToken = async () => {
const { stdout, exited } = Bun.spawn(
[
...["gcloud", "auth", "print-access-token"],
// 有効期限を制限する
`--lifetime=${Boolean(process.env.GITHUB_OUTPUT) ? 600 : 3600}`,
// AGENT_SERVICE_ACCOUNT としてのアクセストークンを作成する
`--impersonate-service-account=${AGENT_SERVICE_ACCOUNT}`,
`--verbosity=error`,
],
{
stdio: ["ignore", "pipe", "inherit"],
},
);
await exited.then((exitCode) => exitCode && process.exit(exitCode));
return (await new Response(stdout).text()).trim();
};
-
--lifetime
でトークンの期限を制限 -
--impersonate-service-account
でトークンの権限を制限
bunfig に Private Registry への認証のセクションを追加する
const injectRegistryAuthSection = ({
scope,
url,
token,
content,
}: {
scope: `@${string}`;
url: string;
token: string;
content: string | null;
}) => {
const START = `# -----START GOOGLE ARTIFACT REGISTRY AUTH SECTION (${scope})-----`;
const END = `# -----END GOOGLE ARTIFACT REGISTRY AUTH SECTION (${scope})-----`;
const linesToInject = [
START,
`[install.scopes."${scope}"]`,
`url = "${url}"`,
`token = ${JSON.stringify(token)}`,
END,
];
const lines = content?.split("\n") ?? [];
const startIndex = lines.indexOf(START);
const endLastIndex = lines.lastIndexOf(END);
if (startIndex === -1 && endLastIndex === -1) {
const injected = [...lines, "", ...linesToInject];
return `${injected.join("\n").trim()}\n`;
}
if (startIndex !== -1 && endLastIndex !== -1) {
const injected = [
...lines.slice(0, startIndex),
...linesToInject,
...lines.slice(endLastIndex + 1),
];
return `${injected.join("\n").trim()}\n`;
}
if (startIndex === -1) {
console.error("START tag is missing though END tag is there");
}
if (endLastIndex === -1) {
console.error("END tag is missing though START tag is there");
}
process.exit(1);
};
- TOML を解釈するのは大変なので半ば無理やり必要なセクションを追加する (参考: https://bun.sh/docs/install/registries )
スクリプトを呼び出す
ダイニーでは、ここで作成したスクリプトを GitHub リポジトリのルートディレクトリに配置し、下層にある各パッケージから相対パスで呼び出すようにしています。(以下は抜粋した package.json)
パッケージをインストールする前に bun registry-auth
を呼び出す必要がありますが、逆に言えばそれだけで開発者はトークンを直接触ることなくセットアップを完了できます。
{
"scripts": {
"registry-auth": "bun ../../private-registry-auth.ts"
}
}
社内パッケージを Private Registry に登録する
パッケージの登録には Bun ではなく npm コマンドを利用しています。 npm の方は枯れている技術なので変化が起きにくいと考えたからです。詳しい方法についてはこちらの記事が非常にわかりやすかったです。
ダイニーではこのあたりでも色々と工夫をしていますが、記事がかなり長くなってしまったのでまた別で機会があればご紹介しようと思います。
おまけ
GitHub Actions であれば簡単に Google Cloud への認証をセットアップできますが、他の環境ではそれが難しいことがあります。そのような場合、以下のようなファイルを用意して適切なパスに配置したうえで NPM_TOKEN を環境変数として設定することで Artifact Registry への認証を確立することができます。
[install.scopes."@{SCOPE NAME}"]
url = "https://{LOCATION}-npm.pkg.dev/{PROJECT}/{REPOSITORY}/"
token = "$NPM_TOKEN"
- EAS Build
- eas-cli から Secret の作成・更新ができます。ビルドをトリガーする前に発行したトークンを毎回指定すれば有効期限内に
bun install
を完了することができます。
- eas-cli から Secret の作成・更新ができます。ビルドをトリガーする前に発行したトークンを毎回指定すれば有効期限内に
- Docker Build
-
--secret
オプションで環境変数を受け渡します
-
We’re hiring!
ダイニーの Platform Team では飲食業界への価値提供の速度と品質を高める活動を技術的なアプローチから行っていて、一緒に働いてくれる仲間を募集しています。興味がある方はぜひ以下のリンクをチェックしてみてください。
Discussion