お疲れ様です!IT業界で働くアライグマです!
個人開発している技術記事キュレーションサービス「DevPick」に、利用者が外部RSSを登録して購読できる機能を追加しました。登録者だけに見せる記事として実装していた一方で、記事IDを直接指定する反応操作まで同じ境界で守れているかを見直すと、認可漏れと、その修正後にも残る競合が見つかりました。
記事IDを知っていれば、別の利用者の記事に反応できる状態だった
カスタムRSSの記事は、公式媒体の記事と違い、登録した利用者のためだけに取り込むものです。フィード、日次の通知、記事一覧、媒体別の件数、画像配信には登録者ごとの絞り込みを入れていました。画面を普通に操作する限り、別の利用者のカスタム記事が混ざらない状態です。
しかし記事に対する反応には、記事IDを受け取る入口があります。いいね、興味なし、あとで読む、既読は、画面に記事が出ていないこととは別に、指定されたIDを受け取って保存処理を進めます。この入口で所有者を確かめなければ、知っているIDを指定するだけで、本来は見えない記事へ新しい反応を作れる余地が残ります。
認証済みであることと、その記事を操作してよいことは別です。前者は「誰が操作しているか」を確かめる仕組みで、後者は「その人がこの対象を扱ってよいか」を確かめる仕組みです。今回の見直しでは、カスタム記事なら有効な自分の購読元に紐づいていることを、反応を新しく作る前提にしました。
また、権限がないときは、対象が存在しない場合と同じ404を返す形にしました。権限がない人にだけ別の応答を返すと、IDを変えながら試すことで「この記事は存在する」と分かってしまうためです。対象を見せない境界は、一覧だけでなく、応答の違いからも漏れないように揃える必要がありました。
IT女子 アラ美書き込みだけでなく、過去の反応を読む経路も同じ境界にそろえた
新しい反応を作れないようにしても、それだけでは十分ではありません。すでに保存されていた反応を表示する一覧、件数、媒体の絞り込み、エクスポートといった読み出し側が、別の利用者の記事を混ぜてしまう可能性があります。書き込みを守る判定と、一覧に載せる判定が別々なら、どちらかだけ修正し忘れるからです。
そこで、反応の一覧では「公式媒体の記事」または「自分のカスタムRSSに紐づく記事」だけを残す共通の絞り込みを用意しました。いいね、興味なし、あとで読む、既読といった反応の種類ごとに条件を複製せず、一覧・件数・媒体・エクスポートが同じ考え方を通るようにしています。
このような共通経路を作っておくと、Web画面だけ直して別のクライアントから見えてしまう、といったずれも防ぎやすくなります。以前の媒体をプロフィールごとに分けた記事でも、媒体を除外する条件を共通のフィード処理に置き、入口ごとに別の実装を持たない形にしました。所有者を分ける条件も同じで、画面単位ではなくデータを取り出す共通部分に置くほうが確認しやすいと感じています。
ただし、共通化は「判定が1か所にある」だけで完結しません。反応を新しく作る処理では、今この瞬間に購読が有効かという状態も見る必要があります。ここで次の問題が残りました。



判定した直後に購読が停止されたら、古い許可で保存できてしまう
最初は、反応を保存する直前にアクセス可否を確認すれば足りると考えました。しかし「確認の直後」と「保存を確定する直前」は同じ瞬間ではありません。たとえば、あるタブで記事に反応しようとし、別のタブで同じ利用者がカスタムRSSを停止または削除するとします。
単純化すると、次の順序が起こり得ます。
反応の処理:この利用者は記事を操作できると確認する
別の処理 :カスタムRSSを停止または削除して確定する
反応の処理:先に得た許可を使って、新しい反応を保存する
確認時点では正しくても、保存時点では前提が変わっています。この種の問題は、確認してから使うまでの間に状態が変わる競合で、TOCTOU(Time Of Check to Time Of Use)と呼ばれます。ここで必要なのは確認を前へ後ろへ動かすことではなく、確認した対象の状態を、保存が終わるまで変えられないようにすることでした。
似た課題は、古い記事を掃除する処理でもありました。削除前の確認だけでは直近の既読を守れなかった話で扱ったのは削除と既読保存の競合です。今回は所有者の境界を守る操作ですが、確認した後に別の処理が前提を変えられる、という性質は共通しています。



購読の状態をロックし、判定から保存までを1つの流れにした
この競合に対しては、カスタムRSSと記事の紐づきを読み取るときに行ロックを取得し、そのトランザクションを反応の保存と確定まで保持する形にしました。別の処理が同じRSSを停止・削除しようとした場合は、先に反応の処理が終わるまで待機します。確認した状態と保存した結果が食い違わないようにするためです。
実サービスの実装を一般化すると、処理の順序は次のようになります。
1. 反応を保存する利用者の状態をロックする
2. 対象記事と有効な購読の紐づきをロックして確認する
3. 許可されていれば新しい反応を保存する
4. 保存を確定してからロックを解放する
この順序なら、停止・削除の処理は4まで待つため、途中で許可だけが失効する状態を作りません。逆に、停止・削除が先に確定していれば、後から始まった反応の処理は最新状態を読み、存在しない記事と同じ404で中断します。
開発用に使うSQLiteでは、この行ロックの実際の直列化を再現できません。そのため、ロック指定を含む問い合わせが組み立てられることに加え、本番で使うMySQL専用のテスト用DBで、停止側の確定が反応側の確定より後になる順序を確認しました。ロックがあるつもり、ではなく、競合する2処理の順序そのものをテストで確かめる必要がありました。



削除後に所有者をたどれない記事は、一覧へ戻さないことにした
カスタムRSSを削除すると、そのRSSと記事の紐づきも消えます。このとき「紐づきが残っていない記事は誰にでも見せる」と扱うと、以前に誤って保存された別の利用者の反応が、元の登録者が削除したことをきっかけに一覧へ戻る経路になります。所有者をたどる情報が失われた後に、公開扱いへ戻すことはできません。
そこで、反応の一覧では、公式媒体の記事か、自分のカスタムRSSに紐づく記事だけを表示する方針にしました。削除済みのRSSに由来する記事は、登録者本人の過去の反応であっても一覧、件数、エクスポートから見えなくなります。一方で、保存済みの反応を明示的に解除する操作は残しています。
これは便利さとの引き換えです。削除したRSSを後から復元するための所有者情報を持っていない以上、「保存済みだから見せる」と決めると、他の利用者へ漏らさない根拠も失います。今回のケースでは、過去の保存を表示し続けることより、所有者を確認できない記事を境界の外へ出さないことを優先しました。
認可の設計では、正常時に誰へ見せるかだけでなく、削除や停止で情報が欠けた後にどう扱うかも決めておく必要があります。データを消す処理が、後から別の公開経路を開けないかまで確認することが大切だと感じました。



まとめ
カスタムRSSのように利用者ごとの記事を扱う機能では、一覧に表示しないだけでは所有者の境界を守り切れませんでした。記事IDを受け取る反応操作、反応の一覧や件数、エクスポートまで、すべての経路で「この利用者が扱える記事か」を同じ条件で確認する必要があります。
今回、特に学びになったのは、認可の確認を入れただけでは終わらないことです。確認してから保存を確定するまでに購読状態が変われば、古い許可を使う競合が残ります。対象の状態をロックし、判定から保存までを同じトランザクションで扱うことで、操作の順序まで含めて境界を守る形にしました。
また、削除後に所有者を確認できなくなる記事を安易に公開扱いへ戻さないことも重要でした。認可は、通常の表示経路だけでなく、直接指定する操作、並行する状態変更、削除後の履歴までを通して考える必要があると実感しています。












