⏱️ この記事を3行でまとめると
① 🏗️ 匿名Guest用の Dataset をサーバー側で新規発行し、DB保存後に guest:<UUID> のGuest identityを結びつける流れを実装しました。
② 🔐 Dataset IDやroleは利用者側に選ばせず、Adminや既存Guestからの勝手な切替も拒否。DB障害時もfail-closedで止める構造にしました。
③ 🧪 pytestは169→181件へ。全GREEN後も「この181件は何を保証している?」と再監査し、追加修正なしでも安全条件と未実装範囲を確認しました。
はじめに
本業で現役トラックドライバーをしながら、Python / Flaskを中心にWebアプリケーションを個人開発している tosane932 です。
現在、sales_data_app に誰でも操作を試せるゲストデモモードを追加するため、認証・認可・データ分離の実装を段階的に進めています。
ただし、この機能で最優先しているのは、
Guestを早く入れることではなく、Adminデータを絶対に壊さないこと
です。
前回の第2段階①では、
-
login_requiredだけではAdmin専用境界にならない -
admin_requiredを追加 -
GuestUserを追加 -
guest:<UUID>というGuest identityを設計 -
require_current_dataset()で利用可能なDatasetをサーバー側から決定 - Guest A / Guest B / AdminのDataset境界をテスト
というところまで進めました。
前回の記事はこちらです。
今回はその続きです。
第2段階②では、
Guest用のDatasetを実際にサーバー側で作り、
そのDataset専用のGuest identityを発行する
ところまで進めます。
そして最後には、
181件GREENだけど、本当に大丈夫?
と、もう一度疑ってみました。
今回のスタート地点
第2段階①終了時点では、pytestは次の状態でした。
169 passed
ここまでで実装済みだったのは、
Admin専用境界
↓
Guest identity
↓
現在の利用者が使ってよいDatasetの判定
です。
ただし、まだGuest用Datasetそのものを新しく発行する処理はありません。
イメージとしては、
入館証の形式は決めた。
どの部屋へ入ってよいかも判定できる。
でも、Guest専用の部屋をまだ作っていない。
という状態です。
そこで今回は、ここを作ります。
匿名利用者
↓
Guest Dataset作成
↓
DB保存
↓
Guest identity発行
↓
自分のDatasetを認可判定上で解決
ただし、今回もGuest公開ページはまだ作りません。
小変更4:Guest Datasetをサーバー側で発行する
今回追加した内部処理は、
start_guest_session()
です。
この関数の責務はかなり限定しました。
Guest Dataset作成
↓
DB commit
↓
GuestUser生成
↓
login_user()
↓
作成したDatasetを返す
重要なのは、
この関数は引数を取りません。
Guest側から、
dataset_id
UUID
role
kind
system_key
などを指定させない設計にしました。
なぜGuestにDataset IDを選ばせないのか
例えば、こんな仕組みにしてしまうと危険です。
/guest/start?dataset_id=xxxxxxxx
もし、このdataset_idをそのまま信用すると、
Guest
↓
Admin DatasetのUUIDを指定
↓
Admin Datasetへ入れてしまう?
という認可事故につながる可能性があります。
そのため今回のGuest開始処理では、
利用者は部屋を選ばない
という設計にしています。
Guest
↓
「Guestとして開始したい」
↓
サーバー
↓
新しいGuest Datasetを生成
↓
そのUUIDからGuest identityを発行
Dataset UUIDは既存の、
uuid.uuid4
によってサーバー側で生成されます。
まず12件のREDテストを追加
今回も、production codeを先に書かずpytestから始めました。
新しく、
test_guest_session_creation.py
を追加しました。
確認したのは主に次の条件です。
- Guest Datasetが作成される
kind="guest"system_key=None- UUIDはサーバー生成
-
created_atが設定される -
last_activity_atが設定される -
absolute_expires_atが設定される - 最大2時間のabsolute expiry
- Guest identityが発行される
-
require_current_dataset()と接続できる - DB保存成功後に
login_user()する - DB障害時はGuestとしてログインしない
- Adminログイン中はGuestへ切り替えない
- 既存Guestも勝手に別Guestへ切り替えない
- session内の任意値を認可根拠にしない
- Admin Datasetを変更しない
- Guest A / Guest Bは別Dataset
-
login_user()異常時の状態
1つのテストで複数条件を確認しているため、確認項目数とテスト件数は一致していません。
production code変更前に実行すると、
12 failed
でした。
原因はすべて、
start_guest_session() がまだ存在しない
ことによる想定通りのREDです。
Guest Datasetの初期状態
Guest Dataset作成時には、次の値をサーバー側で設定します。
kind="guest"
system_key=None
created_at=現在UTC時刻
last_activity_at=現在UTC時刻
absolute_expires_at=現在UTC時刻 + 2時間
2時間については定数化しました。
GUEST_ABSOLUTE_LIFETIME = datetime.timedelta(hours=2)
Guest Demoの現在の仕様では、
1つのGuest sessionは最大2時間
を予定しています。
ただし、ここは重要です。
現時点で実装したのは、
「2時間後という期限日時をDatasetへ保存する処理」
だけです。
まだ、
「2時間を過ぎたGuestを実際に拒否する処理」
は実装していません。
現在の状態は、
absolute_expires_atを設定する
ところまでです。
後続で、
期限切れ判定
↓
Guest拒否
↓
Guest Dataset削除
を実装する予定です。
DB保存より先にログインさせない
今回かなり重要だと考えたのが、処理順です。
もし、
login_user()
↓
Guest DatasetをDBへ保存
という順番にすると、
Guest identity発行成功
↓
DB保存失敗
↓
存在しないDatasetを指すGuest sessionだけ残る
という中途半端な状態を作れる可能性があります。
そのため今回は、
Guest Dataset生成
↓
db.session.commit()
↓
GuestUser生成
↓
login_user()
の順番にしました。
pytestでも、実際に、
commit
↓
login_user
の順番になっていることを確認しています。
DB保存に失敗したらGuestとして入れない
Guest Dataset保存中にSQLAlchemyErrorが発生した場合は、
rollback
↓
Guest Dataset不成立
↓
login_user()しない
↓
503 Service Unavailable
としました。
つまり、
Guest用の部屋を作れなかったのに、入館証だけ渡す
ことはしません。
ここでもfail-closedです。
Adminログイン中にGuestへ変身させない
もう1つ確認したのが、
すでにAdminとしてログインしている状態でGuest開始処理が呼ばれたらどうするか
です。
例えば、
Admin
↓
Guest開始
↓
logout
↓
Guestへ切替
のような動きを勝手にすると、identity管理がかなり分かりにくくなります。
今回は、
authenticated user
↓
start_guest_session()
↓
409 Conflict
としました。
Adminの場合、
- Admin identity維持
- Admin fingerprint維持
- Guest Dataset作成なし
- Guestへ切替なし
を確認しています。
すでにGuestなら新しいGuestを勝手に作らない
同じことを既存Guestでも確認しました。
例えばGuest Aが、
Guest A
↓
start_guest_session()
↓
Guest Dataset B作成
↓
Guest Bへ切替
となるのも避けています。
すでにauthenticatedなら新しいGuest開始を拒否します。
そのため、
Guest A
↓
409 Conflict
↓
Guest Aのまま
です。
Datasetも増えません。
session内の任意値を認可根拠にしない
次に確認したのが、session内に認可とは無関係な値が存在するケースです。
Flaskの標準sessionは署名によって改ざんを検知できます。
ただし、
だからsession内の
roleやdataset_idを何でも認可根拠にしてよい
ということにはなりません。
認可情報の出所を複数箇所へ分散させないため、例えばテスト上で、
session["role"] = "guest"
session["dataset_id"] = "任意のUUID"
session["is_admin"] = False
という値を入れても、Guest開始処理はこれらをDataset identityの根拠として使わないことを確認しました。
新しいDataset UUIDは必ずサーバー側で生成されます。
つまり、
session内の任意dataset_id
↓
認可根拠にしない
サーバー生成UUID
↓
Guest Dataset
↓
Guest identity
です。
Guest AとGuest Bは別の部屋
独立した匿名sessionからGuest開始処理を行うテストも追加しました。
結果は、
Guest A
↓
Dataset A
↓
guest:<Dataset A UUID>
Guest B
↓
Dataset B
↓
guest:<Dataset B UUID>
となりました。
もちろん、
Dataset A UUID != Dataset B UUID
です。
まだProductやDailySalesの実データ分離までは実装していませんが、
Guest identityとDataset発行の段階では、それぞれ別のDatasetを持てる状態
になりました。
Admin Datasetが本当に変わっていないか
Guest Datasetを作ったことでAdmin Datasetへ副作用が出ていないかも確認しました。
Guest作成前後で、
id
kind
system_key
created_at
last_activity_at
absolute_expires_at
を比較。
Admin Datasetには変化なし。
つまりGuest開始処理では、
Admin Datasetを再利用しない
Admin Datasetを変更しない
Admin Datasetを削除しない
Admin Datasetへfallbackしない
ことを確認しました。
login_user()が失敗したらどうなる?
ここでは1つ、完全には解決していないケースも残りました。
今回の順番は、
DB commit
↓
login_user()
です。
そのため、DB保存成功後に何らかの理由でlogin_user()が成立しなかった場合、
Guest DatasetだけDBに残る
可能性があります。
テストではlogin_user()がFalseを返すケースも確認しました。
この場合、
- Guest identityは発行しない
- 500で終了
- Guest DatasetはDB上に残る
という状態になります。
ただし、そのDatasetは、
kind="guest"
absolute_expires_at=設定済み
なので、将来実装するGuest cleanupの対象として識別できます。
今回は、このケースを解決するためだけに大きな補償処理やcleanup機構まで追加することはしませんでした。
「現在残っている既知の課題」として認識し、後続のGuest Dataset cleanup実装時に回収する方針です。
小変更4の結果
関連pytestを確認したあと、全pytestを実行しました。
181 passed
第2段階①終了時点が、
169 passed
だったので、
169
↓
181
となりました。
しかし、ここで終わりにはしませんでした。
小変更5:181件GREEN。でも最後にもう一度疑ってみた
ここからが今回、自分の中ではかなり印象に残った部分です。
pytestは、
181 passed
でした。
普通なら、
「よし、全部GREEN!」
と次へ進みたくなります。
でも、以前行ったpytest強化シリーズやMutation Testingを経験してから、
GREENだけど、本当に?
と考える癖が少しずつついてきました。
そこで、第2段階を閉じる前に、
コードを増やすためではなく、181件が何を保証しているのか
をもう一度監査することにしました。
「181件ある」ではなく「181件は何を守っている?」
今回確認したのは例えばこんなことです。
181 passed
↓
本当に?
Guest A / Guest Bは混ざらない?
↓
Admin Datasetへ切り替えられない?
↓
外部dataset_idを信用していない?
↓
DB障害時はfail-closed?
↓
Admin login中にGuestへ変身しない?
↓
Guest開始でAdmin Datasetを変更していない?
↓
期限切れ処理まで本当に実装済み?
↓
今Guestを公開しても安全?
つまり、
テスト数を増やすこと
ではなく、
現在のテストが何を保証し、何を保証していないのか
を確認しました。
監査中に気になったflask.g
小変更4のGuest A / Guest Bテストでは、一度だけ想定外の失敗がありました。
原因として出てきたのが、
flask.g
です。
ここで少し気になりました。
第2段階①で作った、
require_current_dataset()
では、意図的にDatasetをflask.gへキャッシュしない設計にしていました。
それなのに、
なぜ
flask.gのキャッシュが出てきた?
となったわけです。
production codeはflask.gを使っていなかった
調査した結果、production側のapp.pyでは、
flask.g
を直接使用していませんでした。
require_current_dataset()もDatasetをrequest内へキャッシュせず、
呼び出すたびにDBを再照合
しています。
では何がキャッシュされていたのか。
原因はFlask-Loginでした。
流れは、
current_user参照
↓
Flask-Loginが利用者をロード
↓
g._login_userへ保存
です。
つまりキャッシュしていたのは、
DatasetではなくFlask-Loginのcurrent_user
でした。
なぜテストだけでGuest A / Bが混ざったように見えたのか
原因はテストfixtureでした。
現在のテスト環境では、flask_app fixtureがテスト中に1つのapp contextを長く保持しています。
その内側で複数の、
test_request_context()
を作っていたため、app contextに属するgが疑似request間で共有される状態になっていました。
通常のFlaskアプリで一般的なリクエスト処理では、リクエストに伴ってcontextが用意され、処理終了時に破棄されます。
request開始
↓
context作成
↓
request処理
↓
request終了
↓
context破棄
今回のテストでは、その通常のライフサイクルとは異なり、
外側のapp contextをfixtureが長く保持していた
ことがポイントでした。
そのため今回の失敗原因は、
production codeがDatasetを
flask.gへキャッシュしていたこと
ではなく、
テストfixtureのcontext構造によってFlask-Loginの
g._login_userが疑似request間で残ったこと
でした。
原因を確認した上で、既存テストと同様に疑似request間のキャッシュを解除してテストを調整しました。
Guest A / Bの混線リスクも再確認
念のため、Guest A / Bのidentity解決ももう一度確認しました。
現在は、
Flaskの署名付きsession
↓
guest:<UUID>
↓
GuestUserロード
↓
Datasetを
id + kind="guest" + system_key IS NULL
でDB再照合
という流れです。
さらに、
require_current_dataset()
はDatasetをキャッシュしていません。
今回確認した範囲では、先ほどのflask.g事象と同じ原因によって、
通常のリクエスト処理をまたいでGuest A / Guest Bが混線する問題は確認されませんでした。
第2段階の安全条件を棚卸し
最終監査では、既存pytestが何を保証しているのか整理しました。
| 分類 | 主な保証 |
|---|---|
| Admin境界 | 匿名はloginへ、非Admin・GuestはAdmin routeで403 |
| AI境界 | 認可拒否時はAI生成処理へ到達しない |
| Admin認証 | fingerprint欠損・不一致・変更済みsessionを拒否 |
| Guest identity |
guest:<UUID>、不正UUID・不存在Datasetなどを拒否 |
| Dataset認可 | Admin→Admin、Guest A→A、Guest B→B |
| 外部ID対策 | URL・form・sessionのDataset IDを認可根拠にしない |
| Dataset切替防止 | Guest→Admin、Admin→Guest、Guest A→Bを禁止 |
| fallback防止 | Guest Dataset不存在時にAdminへfallbackしない |
| DB障害 | rollbackしてfail-closed |
| Guest作成 | UUID・時刻・期限をサーバー側生成 |
| 作成順序 | DB commit後にlogin_user()
|
| identity上書き防止 | Admin・既存Guestからの再開始を409 |
| Admin Dataset保護 | Guest作成前後でAdmin Dataset不変 |
| Guest A / B発行 | 独立したDataset UUIDを発行 |
確認した結果、
第2段階の安全条件として明確なテストの穴は見つかりませんでした。
そのため、小変更5では、
追加pytest:0
production変更:0
です。
「何も変更しなかった」も監査結果
ここは今回ちょっと面白いところでした。
Gitのdiffだけを見ると、
小変更5は存在しないように見えます。
なぜなら、コードを変更していないからです。
でも実際には、
181件GREEN
↓
安全条件を棚卸し
↓
flask.gの正体を調査
↓
Guest A/B混線可能性を確認
↓
既知の未実装機能を確認
↓
追加修正不要と判断
という工程を行いました。
つまり、
疑って調べた結果、変更する必要がなかった
というのも1つの結果でした。
「GREENでも疑う」が普段の開発に残っていた
今回、自分の中で少し嬉しかったことがあります。
以前のpytest強化シリーズでは、
Mutation TestingやFalsificationを使って、
GREENでも重要条件を見逃しているかもしれない
という前提でテストを見直しました。
そのときは、
「pytestを強化するための特別な作業」
という感覚もありました。
でも今回、
181 passed
を見たあと自然に、
でも本当に?
と考えていました。
GREEN
↓
何のGREEN?
↓
何を保証している?
↓
何は保証していない?
↓
途中で失敗したら?
↓
別ユーザーと混ざったら?
という確認が、普通の開発工程の中に入り始めています。
今回意識したのは、
テスト数が多い = 安全
ではなく、
そのテストが何を保証し、何をまだ保証していないか分かっている
という状態です。
pytest強化シリーズで経験した、
GREENでも疑う
という考え方が、単発のテスト強化で終わらず、
普段の開発の癖として残り始めている
のかもしれません。
最終結果:181 passed
最終監査後に、認証・認可関連pytestを実行。
82 passed
続いて全pytest。
181 passed
FAILはありませんでした。
そして今回の変更をcommitしました。
fde9c939115179cfdd45caae148bc3b730728f50
commit message:
feat: add secure guest dataset session creation
commit後は、
working tree clean
です。
なお、
- push
- PR
- main merge
- migration
- 実DB変更
- Render変更
は行っていません。
第2段階でできるようになったこと
第2段階①・②を通して、認証・認可基盤として現在はここまで来ました。
Admin
↓
Admin専用routeへ入れる
↓
require_current_dataset()
↓
Admin Datasetのみ解決
Guest identity
↓
guest:<UUID>
↓
require_current_dataset()
↓
自分のGuest Datasetのみ解決
匿名状態からGuest開始
↓
Guest Datasetを新規生成
↓
DB保存
↓
Guest identity発行
↓
自分のGuest Datasetを解決
つまり、
AdminとGuestを別identityとして扱い、
Guest専用Datasetをサーバー側で発行し、
そのGuestが使ってよいDatasetを認可判定できる
ところまでできました。
ここでいう「Guestが自分のDatasetを解決できる」は、認証・認可の内部処理として確認できたという意味です。
既存の業務routeはまだGuestへ公開していません。
でも、まだGuest公開は禁止
ここはかなり重要です。
第2段階が完了したからといって、
Guest Demoを公開できる状態になったわけではありません。
まだ既存の業務query、
Product
DailySales
Dashboard集計
ランキング
グラフ
AI入力データ
にDatasetスコープを適用していません。
また、これらの既存業務処理からrequire_current_dataset()を使ってDatasetを絞り込むところにも、まだ進んでいません。
つまり現在Guestを業務routeへ入れてしまうと、
Adminや他Guestのデータが検索や集計へ混ざる可能性
があります。
そのため、Guest公開はまだ禁止です。
次は第3段階:業務データを本当に部屋ごとに分ける
次の第3段階では、いよいよ実データへDataset境界を適用します。
例えば、
Admin Product
↓
Adminだけ
Guest A Product
↓
Guest Aだけ
Guest B Product
↓
Guest Bだけ
という状態を作ります。
対象になるのは主に、
Product
DailySales
Dashboard
ランキング
グラフ
AI入力データ
です。
ここはGuest Demo全体の中でも、かなり重要な段階になりそうです。
identityだけ分離しても、
実際のデータqueryが分離されていなければ意味がない
からです。
現在地
ここまでを整理すると、
第1段階
既存データをAdmin Datasetへ移行
↓
完了
第2段階①
Admin専用境界
Guest identity
Dataset認可
↓
完了
第2段階②
Guest Dataset発行
Guest identity発行
最終安全監査
↓
完了
第3段階
Product / DailySales等の
Datasetスコープ化
↓
次回
Guestを早く公開するより、
Guestを入れてもAdminデータが壊れない構造を先に作る
という方針で、引き続き進めます。
👤ゲストデモモード搭載シリーズ
第1段階
既存AdminデータをDatasetへ移行し、Guest領域を作る前の土台を整えました。
第2段階①
Guestを入れる前にAdminを守り、Guest identityとDataset認可の土台を作りました。
第2段階②(※この記事です)
Guest用Datasetをサーバー側で安全に発行し、Guest identityと結びつけたうえで、181件GREENでも最後に安全条件を再監査しました。
第3段階
Guestを業務routeへ通す前に、Product・DailySales・Dashboard・API・AI・seedのDataset境界を固め、pytestを181件から195件へ強化しました。
第4段階
正規Guestを実際の業務routeへ通し、商品・売上・Dashboard・AIまで実request経路でDataset越境が起きないことを確認しました。
✅️pytest強化シリーズ
おわりに
今回、第2段階②では、
Guest Datasetを安全に作成し、Guest identityを発行する
ところまで進みました。
最終結果は、
181 passed
です。
ただ、今回一番残ったのはテスト件数ではありませんでした。
181件全部GREEN。
でも、その181件は何を保証している?
と最後にもう一度確認できたことです。
結果として追加修正はありませんでした。
それでも、
-
flask.gの正体 - Guest A / Guest Bの混線可能性
- 孤立Guest Dataset
- 2時間期限の現在地
- まだGuest公開できない理由
を整理できました。
「GREENだから次へ進む」ではなく、
GREENが何を意味しているのか確認してから次へ進む。
この癖は、今後も残していきたいと思います。
次回はいよいよ、
第3段階:Product / DailySalesなどの業務データをDataset単位で分離する
ところへ進みます。
安全第一で、少しずつ進めます。⛑️
