【Notion】インテグレーションを読み取り用と書き込み用に分けたら、空の Secret で同期が成功していた

この記事でわかること
- 本番の描画と CI の書き戻しで同じ Notion トークンを使っていた問題
- 書き込み用のインテグレーションに付けた権限と共有範囲
- Claude Code の ! で gh secret set を実行すると値が入らないことがある件
- フォールバック(||)があると、新しい Secret が効いていなくても同期が成功して気づけないこと
- ページの last_edited_by で、実際にどのインテグレーションが書いたかを確かめる方法
Notion のインテグレーション(内部インテグレーション)を GitHub Actions から使っている方向けです。
概要
この記事では、このブログで使っている Notion のインテグレーションを、読み取り用と書き込み用の2つに分けたときのことを書いていきます。
作業自体はインテグレーションを1つ作って Secret を入れるだけなのですが、新しい Secret がちゃんと入っていないのに CI が普通に成功していて、しばらく気づけませんでした。その確かめ方も含めて残しておきます。
対象者
- Notion のインテグレーションを本番のサイトと CI の両方で使っている方
- GitHub Actions の Secret にフォールバックを書いている方
動作環境
| 項目 | 内容 |
|---|---|
| CMS | Notion(内部インテグレーション、REST API) |
| CI | GitHub Actions |
| 配信 | Cloudflare Pages |
| Secret の登録 | gh CLI |
何が問題だったのか
このブログでは、デプロイの前に CI が記事の読了時間などを Notion のプロパティに書き戻しています(別の記事で書きました)。このステップに、本番やビルドと同じ NOTION_API_KEY を渡していました。
書き戻しが最後まで成功しているということは、このトークンのインテグレーションには更新の権限があるということです。本番の Worker が必要なのは記事の読み取りだけなのに、書き込みもできるトークンを持っていることになります。
しかも、このトークンは Cloudflare Pages の Secret、GitHub Secrets、1Password、ローカルの環境変数と、割といろいろな場所に置いています。どこか1か所から漏れると、公開中の記事を書き換えられて、次のデプロイでそのままサイトに出ます。漏れている形跡は無いのですが、漏れたときの被害が「下書きまで読める」から「公開中の記事を改ざんできる」まで広がっていた、という最小権限の問題です。
インテグレーションを分ける
なので、下記のように分けました。
| 用途 | 名前(Secret) | 権限 | 使う場所 |
|---|---|---|---|
| 読み取り | NOTION_API_KEY |
読み取りのみ | 本番・ビルド・ウォーム・PR チェック・ローカル |
| 書き込み | NOTION_SYNC_API_KEY |
読み取り + 更新 | deploy.yml の書き戻しステップだけ |
書き込み用には kt-tech-blog-sync というインテグレーションを新しく作りました。権限は「コンテンツを読み取る」と「コンテンツを更新する」だけで、挿入・コメント・ユーザー情報は付けていません。プロパティを書き換えるだけなので、ページを作る権限も要らないです。
共有先も記事のデータベース(Tech Articles Database)だけにしています。Notion のインテグレーションは、ページ側で接続したところにしかアクセスできないので、ここを絞っておけば、漏れても他のページは触れません。
既存のインテグレーションのほうは、確認が取れたあとに読み取りだけに戻しました(後述)。
deploy.yml は、新しい Secret があればそちらを使い、無い間は従来のトークンを使うようにしています。インテグレーションを作る前にマージしても CI が落ちないようにするためです。
- name: Sync article metadata to Notion
env:
NOTION_API_KEY: ${{ secrets.NOTION_SYNC_API_KEY || secrets.NOTION_API_KEY }}新しい Secret が効いていないのに同期が成功していた
Secret の登録は、Claude Code と作業していたので、Claude Code の ! を付けて gh secret set NOTION_SYNC_API_KEY を実行しました。gh secret set は値を引数で渡さないと、貼り付けのプロンプトを出して標準入力から読んでくれます。シェル履歴に値を残さないための書き方です。
ところが、! で実行したときはこのプロンプトが出ずにそのまま終わっていました。標準入力が端末につながっていないので、値を渡せないまま登録されていたっぽいです。
これはまずいと思って、今度はちゃんとプロンプトが出る端末で入れ直しました。✓ Set Actions secret と出たので大丈夫だと思っていたのですが、結論から言うと、これでもまだ新しいトークンは使われていませんでした。原因ははっきりしていません(貼り付けた中身が想定と違ったのかもしれません)。最終的には GitHub の画面から Secret を更新して解決しました。
ここで厄介なのが、さっきのフォールバックです。GitHub Actions の式では空文字は偽として扱われるので、secrets.NOTION_SYNC_API_KEY が空だと || の右側、つまり従来の NOTION_API_KEY が使われます。その時点では従来のトークンにまだ更新の権限が残っていたので、同期は何事もなく成功していました。
CI は緑、ログにも書き込んだ記事が出ている、でも実際に書いていたのは古いトークン、という状態です。正直、これは結果だけ見ていたら絶対に気づけなかったと思います。
どのトークンが書いたかを確かめる
そこで、Notion のページの last_edited_by を見ることにしました。インテグレーションが書き込むと、last_edited_by にはそのインテグレーションのボットが入ります。書き戻しが終わったあとに記事のページを取得して、ここがどのボットになっているかを見れば、実際にどちらのトークンが書いたかが分かります。
ただ、書き戻しは前回の確認より後に Notion で編集された記事にしか走らないので、何もしないとデプロイしても1件も書かれません。なので、確認用に1記事だけ ReadingTime を 0 にしておき、デプロイで 7 に戻るかを見ました。戻ったあとにページを API で取得して、last_edited_by.id を見ます。旧インテグレーションのボットの ID は、旧トークンで GET /v1/users/me を叩けば分かります。
端末から入れ直したあとのデプロイでは、ReadingTime は 7 に戻ったものの、last_edited_by は旧インテグレーションのボットのままでした。GitHub の画面から Secret を更新してもう一度同じことをすると、今度は新しい kt-tech-blog-sync のボットになっていました。ちなみに、新しいインテグレーションのボットの ID は、インテグレーションの設定画面の URL に出ている ID と同じでした。
既存のインテグレーションを読み取り専用に戻したのは、この確認が取れてからです。戻したあとに旧トークンで書き込もうとすると、ちゃんと 403(restricted_resource)が返ってきました。順番を逆にしていたら、新しいトークンが入っていないまま書き戻しが失敗して、continue-on-error で黙ってデプロイが進んでいたと思います。
感想
フォールバックは「移行中に落とさない」ためには便利なのですが、移行が終わったかどうかを CI の結果からは判断できなくなるんだなと思いました。今回みたいに「新しいほうで動いたか」を別の手段(今回は last_edited_by)で確かめるのは、個人的には必須だなと感じています。
あと、対話的なプロンプトを出すコマンドを Claude Code の ! で叩くのは、今後は避けようと思います。✓ Set と出ても中身が正しいとは限らないので、入れたあとに実際に使われているかまで見るのが大事だなと感じました。
おわりに
本番と CI で同じトークンを使っている方は、一度「本番に本当に書き込みの権限が要るか」を見直してみると良いかもしれません。分けるだけなら30分もかからないので、割とすぐできると思います。


