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
+5 -5
View File
@@ -25,7 +25,7 @@ from tavolo.telemetry import (
)
from tavolo.config import Settings
from tests.helpers import async_test
from tests.helpers import async_test, make_principal
try:
from opentelemetry.sdk._logs import LoggingHandler, LoggerProvider
@@ -158,7 +158,7 @@ class SessionTagHookTest(unittest.TestCase):
hashlib.sha256(b"sid").hexdigest()[:16],
span.attributes["session.id"],
)
self.assertEqual("alice", span.attributes["user.id"])
self.assertEqual(make_principal("alice"), span.attributes["user.id"])
def test_tags_user_of_any_provider(self) -> None:
span = _RecordedSpan()
@@ -166,7 +166,7 @@ class SessionTagHookTest(unittest.TestCase):
session=Session("sid", {"oidc:google:user": {"sub": "alice"}})
)
session_tag_hook(span, carrier)
self.assertEqual("alice", span.attributes["user.id"])
self.assertEqual(make_principal("alice", "google"), span.attributes["user.id"])
def test_ignores_legacy_unprefixed_user_key(self) -> None:
span = _RecordedSpan()
@@ -190,7 +190,7 @@ class SessionTagHookTest(unittest.TestCase):
)
session_tag_hook(span, carrier)
self.assertNotIn("session.id", span.attributes)
self.assertEqual("alice", span.attributes["user.id"])
self.assertEqual(make_principal("alice"), span.attributes["user.id"])
def test_missing_session_is_a_noop(self) -> None:
span = _RecordedSpan()
@@ -266,7 +266,7 @@ class SessionTagIntegrationTest(unittest.TestCase):
attributes = span.attributes
assert attributes is not None
self.assertEqual(digest, attributes["session.id"], name)
self.assertEqual("alice", attributes["user.id"], name)
self.assertEqual(make_principal("alice"), attributes["user.id"], name)
def _metric_points(reader) -> dict: