0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

🪪Guest用Datasetをサーバー側で発行した。それでもGREENを最後に疑う。pytest 169→181件で安全条件を再監査【ゲストデモ実装 第2段階②】

0
Last updated at Posted at 2026-08-22

3137.png

⏱️ この記事を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内のroledataset_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単位で分離する

ところへ進みます。

安全第一で、少しずつ進めます。⛑️

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?