Cloudflare D1でOAuth client setを原子的に作り、stale rollbackを拒否する
勤怠アプリの OAuth client は、Web / iOS / Android を environment ごとの 3-client set として扱うことにした。どれかだけ作成された状態を許さず、active な membership は書き換えさせず、未使用の set だけをまとめて戻したい。Cloudflare D1 の migration-backed test で、作成途中の衝突と stale rollback の両方を壊して確かめた。
最後の client ID が衝突しても、先行 insert は残らなかった
初回 provisioning は、client set、OAuth client、set との membership を同じ D1 batch へ入れる。処理の単位は client 単体ではなく environment だ。入力も、全 client が同じ environment、issuer、manifest version、manifest digest を持つことを先に検査する。
原子性の証拠にしたのは、最後に挿入する client ID と同じ ID を、管理対象外の OAuth client として先に入れておく test だ。batch は一意制約で失敗する。そのあと environment を読むと空で、衝突より前に処理した ID も残っていなかった。成功経路を読むだけでは見えない性質を、途中失敗から確認した。
作成後はもう一度 D1 から読み、manifest から計算した canonical digest と各フィールドを照合する。再実行時に差分を上書きして収束させるのではなく、drift があれば停止する。原子的に作ることと、作った内容が契約どおりであることは別の検査にした。
client_name と stable slug ID では membership を証明できない
既存の OAuth client table だけで set を復元する案は捨てた。表示用のclient_name は変更でき、重複も許される。prefix や名前の規則で検索しても、「たまたま似た名前の client」と「この provisioning が管理する client」を区別できない。
client ID 自体を stable slug にする案も同じ問題を別の場所へ移すだけだった。OAuth の識別子に platform や environment の意味を背負わせると、credential の識別と運用上の相関が同じ key になる。client ID は初回だけ生成する opaque 値にし、相関は専用 registry の provision_key と client_set_idが受け持つように分けた。
表示名、opaque ID、運用上の相関 key は寿命も変更理由も違う。同じ列に兼務させると、あとで安全な削除範囲を証明できなくなる。
active membership は UPDATE も単純 DELETE も拒否する
registry には client set と member の table を分けて置いた。set metadata と member row は database trigger で immutable にし、active な member の更新と削除を拒否する。runtime reader も lifecycle = activeの set だけを返す。
ここで単純な DELETE を許すと、完全な set だったという事実を途中で失う。さらに、OAuth request や consent grant が付いた client まで運用コマンドで消せてしまう。rollback 前には、membership が完全で、どの member にも利用記録がないことを trigger で検査する。使用開始後は rollback の対象ではなく、別の retirement lifecycle が必要になる。
fresh reservation を取った batch だけが rollback できる
未使用 set の rollback は、active から rollback_reservedへの一方向遷移、OAuth client の削除、set の削除を同じ batch で行う。削除条件には environment と lifecycle だけでなく、その実行で生成した random reservation marker も含める。
const reservation = randomHex(16);
await this.executor.batch([
{
sql: `UPDATE managed_oauth2_client_sets
SET lifecycle = 'rollback_reserved', rollback_reservation = ?2, updated_at = ?3
WHERE environment = ?1 AND lifecycle = 'active'`,
params: [environment, reservation, new Date().toISOString()],
},
{
sql: `DELETE FROM oauth2_clients
WHERE id IN (
SELECT m.client_id FROM managed_oauth2_clients m
JOIN managed_oauth2_client_sets s ON s.client_set_id = m.client_set_id
WHERE s.environment = ?1 AND s.lifecycle = 'rollback_reserved'
AND s.rollback_reservation = ?2
)`,
params: [environment, reservation],
},
{
sql: `DELETE FROM managed_oauth2_client_sets
WHERE environment = ?1 AND lifecycle = 'rollback_reserved'
AND rollback_reservation = ?2`,
params: [environment, reservation],
},
]);
marker が必要なのは、古い rollback の予約状態を別の実行が拾わないためだ。test では、先に別 marker で set を reserved にしておく。後続 rollback は active row を予約できず、自分の marker に一致する削除対象もない。最後の read-back が set の残存を検出して失敗し、member rows はそのまま残った。
もし削除条件が lifecycle = rollback_reservedだけなら、後から来た処理が古い予約を自分のものとして消せる。random marker は秘密ではない。今回の batch が利用可否を検査し、予約を取得した主体だと SQL の条件で結び直すための相関値だ。
まとめ
D1 batch を使っただけでは、安全な provisioning と rollback にはならない。作成単位を schema に置き、active membership を immutable にし、削除前の利用検査と fresh reservation を同じ transaction boundary に入れて、初めて「何を作ったか」と「何だけを戻せるか」が一致する。
今回確認できたのは local の migration-backed test までで、live D1 への migration apply や deploy はまだ行っていない。それでも、最後の insert が失敗したときに途中状態が残らないことと、stale reservation を再利用できないことは、production へ進む前に固定できた。