Fix OIDC subject collisions by introducing canonical provider-plus-subject principals

- Add canonical_principal(provider, sub) => 'oidc:v1:<provider>:<sub>'
- Preserve provider identity across HTTP sessions and WebSockets
- Apply principals throughout: game creation/joining/seating, WebSocket views/actions,
  Redis engine state, match history, leaderboard focus, ratings, /api/me, OpenAPI,
  and telemetry
- Widen identity columns to 1024 chars with schema-only aerich migration (5_20261024221522_canonical_principals.py)
- Document provider names as stable security identifiers
- Add regression tests: same subject at different providers cannot share games, private hands,
  join codes, moves, history, or ratings; distinct seat and rating ownership verified
- All platform, integration, game, and frontend tests pass; mypy clean
This commit is contained in:
2026-10-24 22:25:44 +00:00
parent 4d940add1c
commit 71493f720b
30 changed files with 591 additions and 228 deletions
+8 -6
View File
@@ -23,7 +23,7 @@ from tavolo.briscola.chiamata import (
Bid,
)
from tavolo.platform import GameSession, Seat
from tests.helpers import async_test, make_user, oidc_user, ws_users
from tests.helpers import async_test, make_principal, make_user, oidc_user, ws_users
PLAYERS = ("alice", "bob", "carol", "dave", "erin")
@@ -163,7 +163,7 @@ class ChiamataWebSocketTest(unittest.TestCase):
session = await game_store.load(game_id)
assert session is not None
for sub in ("carol", "dave", "erin", "alice"):
engine.handle_action(session, sub, "pass", {})
engine.handle_action(session, make_principal(sub), "pass", {})
await game_store.save(session)
self.assertEqual(PHASE_CALLING, session.state.phase)
self.assertEqual(1, session.state.caller)
@@ -227,15 +227,17 @@ class ChiamataTimeoutTest(unittest.TestCase):
id=game_id,
game_type="briscola_chiamata",
join_code=join_code,
creator_sub=PLAYERS[0],
creator_sub=make_principal(PLAYERS[0]),
players=[
Seat(user_sub=name, display_name=name.capitalize())
Seat(user_sub=make_principal(name), display_name=name.capitalize())
for name in PLAYERS
],
)
session.state = rules.create_game(PLAYERS[0], "Alice", turn_timeout=1)
session.state = rules.create_game(
make_principal(PLAYERS[0]), "Alice", turn_timeout=1
)
for name in PLAYERS[1:]:
rules.join_game(session.state, name, name.capitalize())
rules.join_game(session.state, make_principal(name), name.capitalize())
state_mutator(session.state)
await game_store.save(session)
return session.id