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
+9 -9
View File
@@ -23,7 +23,7 @@ from tavolo.briscola.state import (
PlayerState,
)
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
class BriscolaRoutesTest(unittest.TestCase):
@@ -235,14 +235,14 @@ class BriscolaTurnTimeoutTest(unittest.TestCase):
id="briscola-turn-timeout-1",
game_type=engine.id,
join_code="BT0001",
creator_sub="alice",
creator_sub=make_principal("alice"),
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
],
)
engine.create(session, {})
session.state.turn_timeout = 1
engine.join(session, "bob", "Bob")
engine.join(session, make_principal("bob"), "Bob")
await game_store.save(session)
ws_transport = ASGIWebSocketTransport(app=app)
@@ -283,10 +283,10 @@ async def _seed_hand_end_state() -> str:
id="briscola-hand-end-1",
game_type=engine.id,
join_code="BH0001",
creator_sub="alice",
creator_sub=make_principal("alice"),
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub="bob", display_name="Bob", team="B"),
Seat(user_sub=make_principal("alice"), display_name="Alice", team="A"),
Seat(user_sub=make_principal("bob"), display_name="Bob", team="B"),
],
)
session.state = BriscolaState(
@@ -298,11 +298,11 @@ async def _seed_hand_end_state() -> str:
briscola_suit="S",
players=[
PlayerState(
sub="alice", name="Alice", seat=0,
sub=make_principal("alice"), name="Alice", seat=0,
hand=[Card.parse("02D")], captured=[Card.parse("01C")],
),
PlayerState(
sub="bob", name="Bob", seat=1,
sub=make_principal("bob"), name="Bob", seat=1,
hand=[Card.parse("05D")], captured=[Card.parse("10C")],
),
],