⏱️ この記事を3行でまとめると
① 🔐 login_required だけではAdmin専用にならないため、Guestを作る前に admin_required で管理者領域へ先に鍵を掛けました。
② 🪪 guest:<UUID> というGuest identityを作り、Admin / Guest A / Guest Bをサーバー側で区別できる土台を整えました。
③ 🧪 Datasetは利用者側から選ばせず、require_current_dataset() でサーバー側から決定。pytestは114→169件となり、Guest公開前の認証・認可境界を固めました。
はじめに
こんにちは。
本業で現役トラックドライバーをしながら、Python / Flaskを中心にWebアプリケーションを個人開発している tosane932 です。
現在、sales_data_app に誰でも操作を試せるゲストデモモードを追加するため、認証・認可・データ分離の実装を段階的に進めています。
ただし、この機能追加で最優先しているのは「早くGuestを動かすこと」ではありません。
Guestを追加したことで、既存のAdminデータを壊す事故を起こさないこと。
ゲストデモモードでは、開発速度よりもAdmin / Guest間のデータ分離と認可を優先しています。
第1段階では、既存データを Dataset という単位に所属させ、
Admin
└─ Admin Dataset
├─ Product
└─ DailySales
という土台を作りました。
前回の記事はこちらです。
そして第2段階。
いよいよGuestの認証を作ろうとしたところで、先に塞いでおくべき重要な問題が見つかりました。
それが、
login_requiredは「ログイン済みか」は確認するが、「Adminかどうか」までは確認しない
という点です。
そこで今回はGuestを実際に公開する前に、
- Admin専用の境界を作る
- Guestのidentity(識別子)を作る
- 現在の利用者が使えるDatasetをサーバー側で決定する
という3つの小変更に分けて、安全側から土台を作りました。
結果、全pytestは次のように増えました。
| 時点 | 主な変更 | 全pytest |
|---|---|---|
| 第2段階開始前 | 既存状態 | 114 |
| 小変更1 | Admin専用境界 | 128 |
| 小変更2 | Guest identity | 152 |
| 小変更3 | Dataset認可 | 169 |
1日で55テスト増えています。
ただし、テスト数を増やすこと自体が目的だったわけではありません。
「GuestにAdmin領域を触らせない」を一つずつ証明していった結果、自然に169件まで増えた
というのが今回の記録です。
第2段階を分割することにした
当初は、第2段階が全部終わってから1本の記事にまとめることも考えていました。
しかし実際に進めてみると、
- Flask-Login
- 認証
- 認可
- Admin / Guest identity
- Dataset分離
- session
- fail-closed
- Guest A / Guest Bの分離
- DB障害時の扱い
など、扱うテーマがかなり増えてきました。
このまま全部を1本にすると、内容は濃くてもかなり長くなります。
そこで第2段階は、
第2段階①
第2段階②
必要なら第2段階③
という形で分割することにしました。
最初に決めたロードマップへ無理に合わせるのではなく、実装して分かったことに合わせて記事も分割します。
ただし、
AdminデータをGuestから守る
という安全方針は変えません。
今回のスタート地点
これまで主要なrouteには、Flask-Loginの
@login_required
を付けていました。
対象は次の6つです。
/
/input
/dashboard
/api/dashboard-data
/api/ai-advice
/api/greeting
Adminしか存在しなかった頃は、これでも実質的には問題ありませんでした。
なぜなら、
ログイン済み
=
Admin
だったからです。
しかしGuestを追加すると、この前提が崩れます。
login_required はAdmin専用ではない
login_required が確認するのは、
この利用者はauthenticatedか?
です。
つまり、
Admin
↓
authenticated
↓
通過
だけではありません。
Guestも認証済み利用者として実装すれば、
Guest
↓
authenticated
↓
通過
になり得ます。
しかし既存routeには、
- 商品登録
- 売上入力
- Dashboard
- AI分析
など、Adminデータへつながる処理があります。
Guestまでここへ入れてはいけません。
login_required は、
「ログイン必須」
であって、
「管理者必須」
ではありません。
今回、認証(Authentication)と認可(Authorization)を分けて考える必要があることを改めて実感しました。
小変更1:Guestを作る前にAdmin側へ鍵を掛ける
最初に考えていたのは、GuestUserを作ることでした。
しかし、実装する順番を変更しました。
危険なのは、
GuestUserを実装
↓
Guestもauthenticatedになる
↓
既存Admin routeへ入れる
↓
後からAdmin制限を追加
という順番です。
そこで今回は、
Admin専用境界を先に作る
↓
非Adminが通れないことを確認
↓
その後でGuestUserを実装する
という順番にしました。
工事中の建物に例えるなら、
一般客を入れる前に、まず管理区域へ鍵を掛ける
という考え方です。
まずREDを確認する
本番コードを変更する前に、
認証済みだがAdminではない利用者
をテスト専用principal(認証主体)として作りました。
期待する結果は、
authenticated非Admin
↓
403 Forbidden
です。
本番コード変更前に実行すると、
8 failed, 13 deselected
となりました。
6つのrouteすべて、
期待:403
実際:200
でした。
つまり、
authenticatedでさえあれば、非Adminでも既存routeへ入れる
状態だったことをREDで確認できました。
AI APIは「403になった」だけでは足りない
特に確認したかったのが、
/api/ai-advice
/api/greeting
です。
単純に、
response.status_code == 403
だけを見るのではなく、
_generate_ai_advice.assert_not_called()
や、
genai.Client.assert_not_called()
まで確認しました。
理由は、
認可されていない人
↓
Gemini API実行
↓
AI回答生成
↓
最後に403
では遅いからです。
例えば発注システムなら、
権限のない人が注文ページへアクセス
↓
商品400個を発注
↓
注文処理完了
↓
「あなたには権限がありません」
となっても意味がありません。
拒否するなら、危険な処理へ到達する前に拒否する必要があります。
admin_required を追加
そこで、Admin専用の認可境界として、
admin_required
を追加しました。
AdminUser にはサーバー側で、
is_admin = True
を持たせます。
そして認可側では、
getattr(current_user, "is_admin", False)
を確認します。
ここで最後の、
False
も重要でした。
is_admin が見つからない利用者を、
よく分からないけど通す
のではなく、
Adminだと確認できない
↓
拒否
にします。
いわゆる fail-closed です。
Admin判定を複数のsession値へ分散させない
今回、
session["role"] = "admin"
のような独自のsession値をAdmin判定の根拠にはしていません。
Flaskのsession自体には署名がありますが、認証・認可の根拠を、
Flask-LoginではGuest
session["role"]ではAdmin
のように複数箇所へ分散させると、実装ミスによる矛盾を作る余地が増えます。
そのためAdminかどうかは、
Flask-Login user ID
↓
user_loader
↓
Admin認証
↓
fingerprint検証
↓
AdminUserとして復元
というサーバー側の流れへ寄せました。
既存Admin認証で使用している、
ADMIN_USERNAME
ADMIN_PASSWORD_HASH
session fingerprint
hmac.compare_digest()
なども維持しています。
Guest機能を追加するために、既存Admin認証を弱めないことも今回の重要な条件です。
小変更1の結果
REDだった同じ8件を再実行すると、
8 passed, 13 deselected
認証・認可関連では、
43 passed
全pytestは、
128 passed
になりました。
この時点で、
匿名
↓
302
↓
/login
authenticated非Admin
↓
403 Forbidden
Admin
↓
通過
という境界ができました。
Guestを作る前に、まずAdmin側の扉を閉めることができました。
小変更2:Guest用の「一時入館証」を作る
Admin領域を守る境界ができたので、次にGuest identityを追加しました。
採用した形式は、
guest:<dataset_uuid>
です。
例えば、
admin
に対して、
guest:550e8400-e29b-41d4-a716-446655440000
のように、AdminとGuestの名前空間を明確に分けます。
Guest AとGuest Bも、
Guest A
guest:<Guest A Dataset UUID>
Guest B
guest:<Guest B Dataset UUID>
となるため、別identityになります。
UUIDを知っているだけではGuestとして復元しない
重要なのは、
UUIDそのものを権限証明にはしない
ことです。
load_user() では、guest: が付いているからといって、そのまま GuestUser へ復元しません。
DB側で、
id = Guest Dataset UUID
AND
kind = "guest"
AND
system_key IS NULL
を確認します。
この照合条件を満たしたDatasetが存在する場合だけ、Guestとして復元します。
現時点ではGuestの「期限切れ判定」はまだ実装していません。
そのため、ここで確認しているのは、
UUIDに対応するDatasetが存在し、Guest用DatasetとしてのDB条件を満たしているか
までです。
Admin Dataset UUIDをGuestとして偽装してみる
例えばAdmin DatasetのUUIDを知っていたとして、
guest:<Admin Dataset UUID>
というidentityを作ったらどうなるでしょうか。
DB検索には、
kind = "guest"
system_key IS NULL
という条件もあります。
Admin Datasetは一致しません。
したがって、
guest:<Admin UUID>
↓
Guest条件に一致しない
↓
Guestとして復元できない
となります。
UUIDを知っているだけでは、Adminへの昇格にもGuestとしての不正な復元にも使えません。
削除されたGuestの古いsessionも拒否する
Guest Datasetが削除されたあと、ブラウザ側に古いGuest identityが残っているケースもテストしました。
guest:<以前存在したUUID>
がsessionに残っていても、DB側ではDatasetが見つかりません。
そのため、
古いGuest identity
↓
DB再確認
↓
Dataset不存在
↓
Guestとして復元しない
となります。
本物のGuestUserでもAdmin routeは403
ここで小変更1が効いてきます。
production用の GuestUser はFlask-Login上では、
is_authenticated = True
です。
つまり、本物の認証済み利用者です。
それでも、
GuestUser
↓
admin_required
↓
is_admin = False
↓
403
となります。
実際にGuestUserから、
/
/input
/dashboard
/api/dashboard-data
/api/ai-advice
/api/greeting
へアクセスさせ、6routeすべて403になることを確認しました。
sessionへ偽Admin情報を混ぜてみる
さらにGuest sessionへ、
role = "admin"
is_admin = True
Admin Datasetのdataset_id
有効なAdmin fingerprint
を混ぜたケースもテストしました。
それでも結果は、
403 Forbidden
です。
これらの独自session値をAdmin判定の根拠として使用していないためです。
小変更2の結果
Guest identity専用テストは、
24 passed
認証・認可関連は、
53 passed
全pytestは、
152 passed
となりました。
この時点で、
Admin
↓
admin
Guest A
↓
guest:<UUID-A>
Guest B
↓
guest:<UUID-B>
という身元の分離ができました。
小変更3:この人が使ってよいDatasetはどれか
次に必要なのは、
認証された利用者が、どのDatasetを使用できるのか
を安全に決定する仕組みです。
そこで、
require_current_dataset()
を追加しました。
重要なのは引数なしであることです。
なぜ dataset_id を引数で受け取らないのか
例えば、
require_current_dataset(dataset_id)
という設計にすると、呼び出し側から、
このDatasetを使いたい
とDatasetを指定できる形になります。
Guest Aが、
Guest BのDataset UUID
を指定できる余地そのものを作りたくありません。
そこで、
require_current_dataset()
にしました。
内部では、
current_userは誰か?
↓
Adminなのか?
Guestなのか?
↓
サーバー側で使用可能なDatasetを決める
という流れにします。
Dataset IDを利用者に選ばせない。
URL・form・JSON・query parameter・独自の session["dataset_id"] を、Dataset認可の根拠にはしません。
Guest AがGuest BのUUIDを送ってみる
テストでは、Guest Aのrequestへ意図的に、
Guest BのUUID
を混ぜました。
例えば、
?dataset_id=<Guest B UUID>
です。
さらに、
form
session["dataset_id"]
からも別Dataset UUIDを与えました。
それでも結果は、
Guest A
↓
require_current_dataset()
↓
Guest A Dataset
です。
利用者側から送られたDataset指定は、認可判断に使いません。
AdminもGuest Datasetへ勝手に切り替えない
Adminについても同じです。
Admin requestへ、
Guest Dataset UUID
を混ぜても、
Admin
↓
Admin Dataset
のままです。
今回の仕様では、
Admin
→ Admin Datasetだけ
Guest A
→ Guest A Datasetだけ
Guest B
→ Guest B Datasetだけ
です。
現段階では、
AdminならGuest Datasetへ自由に切り替えられる
という設計にもしていません。
Guest Datasetが消えてもAdmin Datasetへ逃がさない
もう一つ重要なのがフォールバックです。
例えばGuest Datasetが見つからない場合、
Guest Datasetがない
↓
ではAdmin Datasetを使おう
とはしません。
これは非常に危険です。
Guestに対応するDatasetを確認できないなら、
確認できない
↓
拒否
です。
ここでもfail-closedを優先しています。
load_user() で確認したのに、なぜもう一度DBを見るのか
Guest identityを復元するとき、すでに load_user() でDatasetを確認しています。
それでも require_current_dataset() でも再確認します。
役割が違うからです。
load_user()
↓
このidentityを利用者として復元できるか?
一方、
require_current_dataset()
↓
このrequestで使用してよいDatasetはどれか?
を担当します。
例えば、
load_user()成功
↓
その後Guest Dataset削除
↓
業務処理
というケースでも、Datasetを再確認することで安全側へ倒せる構造にしています。
403 / 500 / 503を分けた
今回、すべての異常を403へまとめることもしませんでした。
今回の実装上では、次のように分けています。
403
→ 利用者を通せない
500
→ システム内部の必須状態がおかしい
503
→ DBなど必要なサービスを現在利用できない
Guest Datasetが存在しない / 削除済み
403 Forbidden
Guest identityに対応して使用できるDatasetが成立していないためです。
正しいAdminなのにAdmin Datasetが存在しない
500 Internal Server Error
Admin自身の認証は成立しています。
それなのに本来存在するはずのAdmin Datasetがないため、利用者ではなくシステム側の異常として扱います。
DB query自体が失敗
503 Service Unavailable
SQLAlchemyError を検出した場合は、
rollback
↓
エラーログ
↓
503
としました。
今回の実装を自分なりに覚えるため、
403 = 通行拒否
500 = 部屋内部の故障
503 = 部屋を動かす設備が現在使えない
と整理しました。
もちろんHTTP statusの一般的な意味をすべてこの比喩だけで表せるわけではありません。
あくまで今回の処理を理解するための自分用のイメージです。
flask.g のキャッシュは今回は使わなかった
同一request内でDatasetを何度も取得するなら、
flask.g
にDatasetを保存する方法も考えられます。
今回は採用しませんでした。
理由は、
効率化より、毎回現在の状態をDBで確認する単純さを優先した
ためです。
現時点では、速度よりも安全性とコードの分かりやすさを優先しています。
必要性が出てきた段階で、改めてキャッシュを検討することにしました。
小変更3のRED → GREEN
本番コードへ require_current_dataset() を作る前に17ケース追加しました。
最初は、
17 failed
です。
原因は、
require_current_dataset()
がまだ存在していなかったためでした。
実装後は、
17 passed
になりました。
認証・認可関連は、
70 passed
全pytestは、
169 passed
まで増えました。
114件から169件へ
今回の推移をまとめると、
第2段階開始前
114 tests
↓
小変更1:Admin専用境界
128 tests
↓
小変更2:Guest identity
152 tests
↓
小変更3:Dataset authorization
169 tests
55件増えました。
しかし最初から、
「今日は55件テストを増やそう」
と考えていたわけではありません。
安全条件を1つ追加すると、
正常なAdminは?
非Adminは?
Guestは?
Guest A / Bは?
Admin UUIDをGuestとして使ったら?
削除済みGuestなら?
sessionへ別の値を混ぜたら?
DBが使えなかったら?
と、確認したいことが増えていきました。
その結果として169件になりました。
以前取り組んだpytest強化シリーズでは、pytestそのものを「事故防止台帳」として強くしていくことが主題でした。
今回は少し逆です。
安全な機能を作ろうとした結果、その安全性を確認する証拠としてpytestが増えていった
という感覚でした。
小変更1〜3をここでcommit
小変更1〜3をまとめて、ここで一度commitしました。
commit message:
feat: add guest identity and dataset authorization foundation
commit SHA:
f1c34f075420c5570b241ed0df264d6d411615c8
commit直前の全pytestは、
169 passed in 24.12s
でした。
変更対象は、
app.py
test_authorization.py
test_guest_identity.py
test_dataset_authorization.py
の4ファイルです。
差分は、
749 insertions
17 deletions
でした。
git diff --check と git diff --cached --check にも問題はありませんでした。
commit後はworking treeもcleanです。
この時点では、
- push
- PR作成
- mainへのmerge
- migration
- 実DB操作
- Render変更
は行っていません。
今日は小変更1〜3のcommitまでで止めました。
それでも、まだGuestデモは動かさない
ここまで読むと、
「もうGuestデモを動かせるのでは?」
と思うかもしれません。
しかし、まだ動かしません。
現時点では、
Admin専用境界
↓
Guest identity
↓
現在の利用者に対応するDataset解決
までです。
まだ、
Guest Datasetを実際に作る処理
Guest開始route
「ゲストで試す」ボタン
ProductのDatasetスコープ化
DailySalesのDatasetスコープ化
DashboardのDatasetスコープ化
AI処理のDatasetスコープ化
には進んでいません。
特に重要なのは、
require_current_dataset()は作ったが、既存の業務queryへはまだ適用していない
ことです。
そのため、今Guest公開入口を作るのはまだ危険です。
GuestUserが存在する = Guestデモを公開してよい
ではありません。
現在できたのは「身元確認と利用可能Datasetを決める土台」までです。
Product・DailySales・Dashboard・AIなどの業務query自体をDataset単位で安全に扱えるようになるまでは、Guestを既存業務機能へ入れません。
Codexには一気に実装させなかった
今回もCodexへ、
「ゲストデモモードを全部作って」
とは指示していません。
小変更を、
小変更1
Admin専用境界
小変更2
Guest identity
小変更3
Dataset authorization
と分割し、それぞれ、
RED確認
↓
最小実装
↓
GREEN確認
↓
全pytest
という順番で進めました。
小変更の外で見つかった改善点についても、今回は触らせていません。
Guestモードのように、認証・認可・既存データ保護が絡む変更では、
一気に完成させることより、「どこまで安全だと確認できたか」を小さく区切る
ほうが、自分には合っていると感じています。
今回できた3つの安全装置
今回の内容を、自分なりに建物へ例えるとこうなりました。
① admin_required
Admin専用区域のゲート
② GuestUser / guest:<UUID>
Guest用の一時入館証
③ require_current_dataset()
その人が利用してよい部屋を決める受付
この3つが揃ったことで、
誰なのか?
↓
AdminなのかGuestなのか?
↓
その人が使ってよいDatasetはどこなのか?
をサーバー側で決定する土台ができました。
次回:第2段階②へ
今回のcommitで、第2段階①はここまでにします。
次回は、
Guest Datasetをサーバー側で安全に作成する
ところから再開します。
現在は、
Guest用identityの形式
まではあります。
しかし、
実際にGuest用の部屋を発行する処理
はまだありません。
次回は、
Guest開始処理
↓
サーバー側でGuest Dataset作成
↓
Guest identity発行
↓
作成失敗時はGuestとしてログインさせない
という部分を作っていく予定です。
その後、Product / DailySales / Dashboard / AIなどの業務queryをDataset単位へ分離していきます。
第2段階① 完了時点
- Admin専用境界:実装済み
- Guest identity:実装済み
- Guest A / Guest B分離:確認済み
- 現在Datasetの安全な解決:実装済み
- 全pytest:169 passed
- 小変更1〜3:commit済み
- Guest Dataset作成:未実装
- Guest公開入口:未実装
- 業務queryのDataset分離:未実装
まだGuestデモは公開しません。
👤ゲストデモモード搭載シリーズ
第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段階を始めたときは、
「GuestUserを作れば、Guestデモへ一歩進める」
くらいに考えていました。
しかし調べていくと、その前に、
Guestを認証済みにした瞬間、既存のAdmin routeへ入れる可能性がある
という問題が見つかりました。
そこでGuest実装を急がず、
まずAdminを守る
↓
Guestの身元を定義する
↓
使えるDatasetをサーバー側で決める
という順番に変更しました。
結果として、まだGuestデモ画面は1つも増えていません。
見た目だけなら、大きく進んだようには見えないかもしれません。
でも内部では、
114 tests
↓
169 tests
まで安全条件の確認が増えました。
今回の作業で改めて感じたのは、
機能を追加する前に、「追加しても壊れない境界」を作ることも開発の一部
だということです。
次回はいよいよ、ここまで作った境界の内側にGuest Datasetを実際に発行していきます。
