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:
@@ -12,7 +12,10 @@ from httpx import ASGITransport, AsyncClient
|
||||
from tavolo.platform.elo import INITIAL_RATING
|
||||
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
||||
from tavolo.platform.stats import save_match_result
|
||||
from helpers import DummyEngine, async_test, make_platform, oidc_user, use_db
|
||||
from helpers import DummyEngine, async_test, make_platform, make_principal, oidc_user, use_db
|
||||
|
||||
ALICE = make_principal("alice")
|
||||
BOB = make_principal("bob")
|
||||
|
||||
|
||||
def _finished_session(target: int = 2):
|
||||
@@ -24,12 +27,12 @@ def _finished_session(target: int = 2):
|
||||
id="stats-game",
|
||||
game_type=engine.id,
|
||||
join_code="STATS1",
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
||||
creator_sub=ALICE,
|
||||
players=[Seat(user_sub=ALICE, display_name="alice", team="A")],
|
||||
)
|
||||
engine.create(session, {"target": target})
|
||||
engine.join(session, "bob", "bob")
|
||||
engine.handle_action(session, "alice", "play", {})
|
||||
engine.join(session, BOB, "bob")
|
||||
engine.handle_action(session, ALICE, "play", {})
|
||||
return engine, session
|
||||
|
||||
|
||||
@@ -39,7 +42,7 @@ class SaveMatchResultTest(unittest.TestCase):
|
||||
_, _, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine, session = _finished_session()
|
||||
engine.handle_action(session, "alice", "play", {})
|
||||
engine.handle_action(session, ALICE, "play", {})
|
||||
self.assertTrue(engine.is_finished(session))
|
||||
|
||||
with ctx:
|
||||
@@ -53,19 +56,19 @@ class SaveMatchResultTest(unittest.TestCase):
|
||||
# The game type travels from the session onto the row; the
|
||||
# engine's summary is stored verbatim as the result.
|
||||
self.assertEqual("dummy", match.game_type)
|
||||
self.assertEqual("alice", match.result["winner"])
|
||||
self.assertEqual(ALICE, match.result["winner"])
|
||||
self.assertEqual(2, match.result["plays"])
|
||||
winners = await MatchPlayer.filter(won=True)
|
||||
self.assertEqual({"alice"}, {p.user_sub for p in winners})
|
||||
self.assertEqual({ALICE}, {p.user_sub for p in winners})
|
||||
scores = {p.user_sub: p.score for p in await MatchPlayer.all()}
|
||||
self.assertEqual({"alice": 2.0, "bob": 0.0}, scores)
|
||||
self.assertEqual({ALICE: 2.0, BOB: 0.0}, scores)
|
||||
|
||||
@async_test
|
||||
async def test_finished_match_updates_elo_ratings(self) -> None:
|
||||
_, _, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine, session = _finished_session()
|
||||
engine.handle_action(session, "alice", "play", {})
|
||||
engine.handle_action(session, ALICE, "play", {})
|
||||
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
@@ -75,29 +78,59 @@ class SaveMatchResultTest(unittest.TestCase):
|
||||
}
|
||||
self.assertEqual(2, len(ratings))
|
||||
# Two players at 1500: winner gains K/2, loser loses it.
|
||||
self.assertEqual(INITIAL_RATING + 16, ratings["alice"].rating)
|
||||
self.assertEqual(1, ratings["alice"].matches_played)
|
||||
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
|
||||
self.assertEqual(1, ratings["bob"].matches_played)
|
||||
self.assertEqual(INITIAL_RATING + 16, ratings[ALICE].rating)
|
||||
self.assertEqual(1, ratings[ALICE].matches_played)
|
||||
self.assertEqual(INITIAL_RATING - 16, ratings[BOB].rating)
|
||||
self.assertEqual(1, ratings[BOB].matches_played)
|
||||
# The leaderboard aggregates ride along in the same transaction.
|
||||
self.assertEqual(1, ratings["alice"].wins)
|
||||
self.assertEqual(0, ratings["bob"].wins)
|
||||
self.assertEqual(2.0, ratings["alice"].points)
|
||||
self.assertEqual(0.0, ratings["bob"].points)
|
||||
self.assertEqual("alice", ratings["alice"].display_name)
|
||||
self.assertEqual(1, ratings[ALICE].wins)
|
||||
self.assertEqual(0, ratings[BOB].wins)
|
||||
self.assertEqual(2.0, ratings[ALICE].points)
|
||||
self.assertEqual(0.0, ratings[BOB].points)
|
||||
self.assertEqual("alice", ratings[ALICE].display_name)
|
||||
|
||||
# The per-match delta is recorded on each participation row.
|
||||
deltas = {
|
||||
p.user_sub: p.elo_delta for p in await MatchPlayer.all()
|
||||
}
|
||||
self.assertEqual({"alice": 16, "bob": -16}, deltas)
|
||||
self.assertEqual({ALICE: 16, BOB: -16}, deltas)
|
||||
|
||||
@async_test
|
||||
async def test_same_sub_at_two_providers_has_independent_stats(self) -> None:
|
||||
from tavolo.platform import GameSession, Seat
|
||||
|
||||
_, _, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine = DummyEngine()
|
||||
google_alice = make_principal("alice", "google")
|
||||
session = GameSession(
|
||||
id="cross-provider-stats",
|
||||
game_type=engine.id,
|
||||
join_code="XPROV1",
|
||||
creator_sub=ALICE,
|
||||
players=[Seat(user_sub=ALICE, display_name="Alice", team="A")],
|
||||
)
|
||||
engine.create(session, {"target": 1})
|
||||
engine.join(session, google_alice, "Alice")
|
||||
engine.handle_action(session, ALICE, "play", {})
|
||||
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
self.assertEqual(
|
||||
{ALICE, google_alice},
|
||||
{row.user_sub for row in await MatchPlayer.all()},
|
||||
)
|
||||
self.assertEqual(
|
||||
{ALICE, google_alice},
|
||||
{row.user_sub for row in await PlayerRating.all()},
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_elo_ratings_accumulate_across_matches(self) -> None:
|
||||
_, _, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine, session = _finished_session()
|
||||
engine.handle_action(session, "alice", "play", {})
|
||||
engine.handle_action(session, ALICE, "play", {})
|
||||
|
||||
from tavolo.platform import GameSession, Seat
|
||||
|
||||
@@ -105,14 +138,14 @@ class SaveMatchResultTest(unittest.TestCase):
|
||||
id="stats-game-2",
|
||||
game_type="dummy",
|
||||
join_code="STATS2",
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
||||
creator_sub=ALICE,
|
||||
players=[Seat(user_sub=ALICE, display_name="alice", team="A")],
|
||||
)
|
||||
engine2.create(session2, {"target": 2})
|
||||
engine2.join(session2, "bob", "bob")
|
||||
engine2.join(session2, BOB, "bob")
|
||||
# Bob wins the second match.
|
||||
engine2.handle_action(session2, "bob", "play", {})
|
||||
engine2.handle_action(session2, "bob", "play", {})
|
||||
engine2.handle_action(session2, BOB, "play", {})
|
||||
engine2.handle_action(session2, BOB, "play", {})
|
||||
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
@@ -123,9 +156,9 @@ class SaveMatchResultTest(unittest.TestCase):
|
||||
}
|
||||
# Match 1: even teams, alice wins (+16/-16). Match 2: alice
|
||||
# is now the favourite (1516 vs 1484), so losing costs 17.
|
||||
self.assertEqual(INITIAL_RATING - 1, ratings["alice"])
|
||||
self.assertEqual(INITIAL_RATING + 1, ratings["bob"])
|
||||
bob = await PlayerRating.get(user_sub="bob")
|
||||
self.assertEqual(INITIAL_RATING - 1, ratings[ALICE])
|
||||
self.assertEqual(INITIAL_RATING + 1, ratings[BOB])
|
||||
bob = await PlayerRating.get(user_sub=BOB)
|
||||
self.assertEqual(2, bob.matches_played)
|
||||
|
||||
@async_test
|
||||
@@ -160,11 +193,12 @@ async def _seed_two_matches(
|
||||
result={"winner": winner, "plays": 3 + index},
|
||||
)
|
||||
for seat, sub in enumerate(("alice", "bob")):
|
||||
principal = make_principal(sub)
|
||||
score = 2.0 + index if sub == winner else 1.0
|
||||
await MatchPlayer.create(
|
||||
id=uuid.uuid4(),
|
||||
match=match,
|
||||
user_sub=sub,
|
||||
user_sub=principal,
|
||||
display_name=sub,
|
||||
seat=seat,
|
||||
team="A" if seat == 0 else "B",
|
||||
@@ -172,22 +206,22 @@ async def _seed_two_matches(
|
||||
score=score,
|
||||
)
|
||||
aggregate = aggregates.setdefault(
|
||||
(sub, game_types[index]),
|
||||
{"matches": 0, "wins": 0, "points": 0.0},
|
||||
(principal, game_types[index]),
|
||||
{"matches": 0, "wins": 0, "points": 0.0, "display_name": sub},
|
||||
)
|
||||
aggregate["matches"] += 1
|
||||
aggregate["wins"] += 1 if sub == winner else 0
|
||||
aggregate["points"] += score
|
||||
for (sub, game_type), aggregate in aggregates.items():
|
||||
for (principal, game_type), aggregate in aggregates.items():
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub=sub,
|
||||
user_sub=principal,
|
||||
game_type=game_type,
|
||||
rating=INITIAL_RATING,
|
||||
matches_played=aggregate["matches"],
|
||||
wins=aggregate["wins"],
|
||||
points=aggregate["points"],
|
||||
display_name=sub,
|
||||
display_name=aggregate["display_name"],
|
||||
)
|
||||
|
||||
|
||||
@@ -266,13 +300,13 @@ class StatsRouteTest(unittest.TestCase):
|
||||
response = await client.get("/api/leaderboard")
|
||||
self.assertEqual(200, response.status_code)
|
||||
by_sub = {row["user_sub"]: row for row in response.json()["results"]}
|
||||
self.assertEqual(2, by_sub["alice"]["matches"])
|
||||
self.assertEqual(1, by_sub["alice"]["wins"]) # alice won match 1
|
||||
self.assertEqual(3.0, by_sub["alice"]["points"]) # 2.0 + 1.0
|
||||
self.assertEqual(1, by_sub["bob"]["wins"]) # bob won match 2
|
||||
self.assertEqual(4.0, by_sub["bob"]["points"]) # 1.0 + 3.0
|
||||
self.assertEqual(2, by_sub[ALICE]["matches"])
|
||||
self.assertEqual(1, by_sub[ALICE]["wins"]) # alice won match 1
|
||||
self.assertEqual(3.0, by_sub[ALICE]["points"]) # 2.0 + 1.0
|
||||
self.assertEqual(1, by_sub[BOB]["wins"]) # bob won match 2
|
||||
self.assertEqual(4.0, by_sub[BOB]["points"]) # 1.0 + 3.0
|
||||
# Bob leads on points after tying Alice on wins.
|
||||
self.assertEqual("bob", response.json()["results"][0]["user_sub"])
|
||||
self.assertEqual(BOB, response.json()["results"][0]["user_sub"])
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None:
|
||||
@@ -282,7 +316,7 @@ class StatsRouteTest(unittest.TestCase):
|
||||
with ctx:
|
||||
# Alice outranks everyone despite Bob leading on points.
|
||||
await PlayerRating.filter(
|
||||
user_sub="alice", game_type="dummy"
|
||||
user_sub=ALICE, game_type="dummy"
|
||||
).update(rating=1600)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
@@ -290,10 +324,10 @@ class StatsRouteTest(unittest.TestCase):
|
||||
self.assertEqual(200, response.status_code)
|
||||
results = response.json()["results"]
|
||||
by_sub = {row["user_sub"]: row for row in results}
|
||||
self.assertEqual(1600, by_sub["alice"]["elo"])
|
||||
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
|
||||
self.assertEqual(1600, by_sub[ALICE]["elo"])
|
||||
self.assertEqual(INITIAL_RATING, by_sub[BOB]["elo"])
|
||||
# Elo outranks wins/points.
|
||||
self.assertEqual("alice", results[0]["user_sub"])
|
||||
self.assertEqual(ALICE, results[0]["user_sub"])
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_elo_scoped_by_game_type(self) -> None:
|
||||
@@ -304,13 +338,13 @@ class StatsRouteTest(unittest.TestCase):
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
with ctx:
|
||||
await PlayerRating.filter(
|
||||
user_sub="bob", game_type="dummy"
|
||||
user_sub=BOB, game_type="dummy"
|
||||
).update(rating=1516)
|
||||
# Bob's rating in another game must not leak into the
|
||||
# dummy leaderboard.
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="bob",
|
||||
user_sub=BOB,
|
||||
game_type="second",
|
||||
rating=1800,
|
||||
matches_played=1,
|
||||
@@ -321,8 +355,8 @@ class StatsRouteTest(unittest.TestCase):
|
||||
self.assertEqual(200, response.status_code)
|
||||
results = response.json()["results"]
|
||||
by_sub = {row["user_sub"]: row for row in results}
|
||||
self.assertEqual(1516, by_sub["bob"]["elo"])
|
||||
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
|
||||
self.assertEqual(1516, by_sub[BOB]["elo"])
|
||||
self.assertEqual(INITIAL_RATING, by_sub[ALICE]["elo"])
|
||||
self.assertEqual("second", platform.registry.all()[1].id)
|
||||
|
||||
@async_test
|
||||
@@ -334,7 +368,7 @@ class StatsRouteTest(unittest.TestCase):
|
||||
for index, sub in enumerate(("carol", "dave", "erin", "frank")):
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub=sub,
|
||||
user_sub=make_principal(sub),
|
||||
game_type="dummy",
|
||||
rating=1600 + index,
|
||||
matches_played=1,
|
||||
@@ -354,20 +388,21 @@ class StatsRouteTest(unittest.TestCase):
|
||||
self.assertEqual(6, body["total"])
|
||||
self.assertEqual(2, len(body["results"]))
|
||||
self.assertEqual(
|
||||
["frank", "erin"], [row["user_sub"] for row in body["results"]]
|
||||
[make_principal("frank"), make_principal("erin")],
|
||||
[row["user_sub"] for row in body["results"]],
|
||||
)
|
||||
self.assertEqual(
|
||||
["dave", "carol"],
|
||||
[make_principal("dave"), make_principal("carol")],
|
||||
[row["user_sub"] for row in second.json()["results"]],
|
||||
)
|
||||
self.assertEqual(
|
||||
["bob", "alice"],
|
||||
[BOB, ALICE],
|
||||
[row["user_sub"] for row in third.json()["results"]],
|
||||
)
|
||||
# Out-of-range pages clamp to the last page.
|
||||
self.assertEqual(3, beyond.json()["page"])
|
||||
self.assertEqual(
|
||||
["bob", "alice"],
|
||||
[BOB, ALICE],
|
||||
[row["user_sub"] for row in beyond.json()["results"]],
|
||||
)
|
||||
|
||||
@@ -380,7 +415,7 @@ class StatsRouteTest(unittest.TestCase):
|
||||
for index, sub in enumerate(("carol", "dave")):
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub=sub,
|
||||
user_sub=make_principal(sub),
|
||||
game_type="dummy",
|
||||
rating=1600 + index,
|
||||
matches_played=1,
|
||||
@@ -398,7 +433,7 @@ class StatsRouteTest(unittest.TestCase):
|
||||
self.assertEqual(4, body["page"])
|
||||
self.assertEqual(4, body["your_rank"])
|
||||
self.assertEqual(4, body["your_page"])
|
||||
self.assertEqual("alice", body["results"][0]["user_sub"])
|
||||
self.assertEqual(ALICE, body["results"][0]["user_sub"])
|
||||
anonymous_body = anonymous.json()
|
||||
self.assertEqual(1, anonymous_body["page"])
|
||||
self.assertIsNone(anonymous_body["your_rank"])
|
||||
@@ -421,7 +456,7 @@ class StatsRouteTest(unittest.TestCase):
|
||||
app, platform, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine, session = _finished_session()
|
||||
engine.handle_action(session, "alice", "play", {})
|
||||
engine.handle_action(session, ALICE, "play", {})
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
transport = ASGITransport(app=app)
|
||||
@@ -433,10 +468,37 @@ class StatsRouteTest(unittest.TestCase):
|
||||
p["user_sub"]: p
|
||||
for p in response.json()["results"][0]["players"]
|
||||
}
|
||||
self.assertEqual(16, players["alice"]["elo_delta"])
|
||||
self.assertEqual(-16, players["bob"]["elo_delta"])
|
||||
self.assertEqual(16, players[ALICE]["elo_delta"])
|
||||
self.assertEqual(-16, players[BOB]["elo_delta"])
|
||||
self.assertEqual(16, response.json()["results"][0]["your_elo_delta"])
|
||||
|
||||
@async_test
|
||||
async def test_same_sub_other_provider_cannot_read_history_or_ratings(self) -> None:
|
||||
app, platform, tortoise_mixin = make_platform(
|
||||
oidc_providers=("default", "google")
|
||||
)
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine, session = _finished_session()
|
||||
engine.handle_action(session, ALICE, "play", {})
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice", provider="google"):
|
||||
matches = await client.get("/api/me/matches")
|
||||
ratings = await client.get("/api/me/ratings")
|
||||
board = await client.get("/api/leaderboard?focus=me")
|
||||
self.assertEqual([], matches.json()["results"])
|
||||
self.assertEqual([], ratings.json()["results"])
|
||||
self.assertIsNone(board.json()["your_rank"])
|
||||
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
owned_matches = await client.get("/api/me/matches")
|
||||
owned_ratings = await client.get("/api/me/ratings")
|
||||
self.assertEqual(1, len(owned_matches.json()["results"]))
|
||||
self.assertEqual(1, len(owned_ratings.json()["results"]))
|
||||
|
||||
@async_test
|
||||
async def test_my_ratings_requires_auth(self) -> None:
|
||||
app, _, _ = make_platform()
|
||||
@@ -452,14 +514,14 @@ class StatsRouteTest(unittest.TestCase):
|
||||
with ctx:
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="alice",
|
||||
user_sub=ALICE,
|
||||
game_type="dummy",
|
||||
rating=1516,
|
||||
matches_played=1,
|
||||
)
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="bob",
|
||||
user_sub=BOB,
|
||||
game_type="dummy",
|
||||
rating=1484,
|
||||
matches_played=1,
|
||||
@@ -516,9 +578,9 @@ class GameTypeFilterTest(unittest.TestCase):
|
||||
self.assertEqual(200, scoped.status_code)
|
||||
by_sub = {row["user_sub"]: row for row in scoped.json()["results"]}
|
||||
# Only the first match counts: one match per player, alice won.
|
||||
self.assertEqual(1, by_sub["alice"]["matches"])
|
||||
self.assertEqual(1, by_sub["alice"]["wins"])
|
||||
self.assertEqual(0, by_sub["bob"]["wins"])
|
||||
self.assertEqual(1, by_sub[ALICE]["matches"])
|
||||
self.assertEqual(1, by_sub[ALICE]["wins"])
|
||||
self.assertEqual(0, by_sub[BOB]["wins"])
|
||||
self.assertEqual(400, unknown.status_code)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user