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
@@ -72,9 +72,10 @@ _DEFAULT_OIDC_CLIENT_ID = "tavolo"
class OidcProviderSettings:
"""One OIDC provider, parsed from the ``OIDC_<n>_*`` variables.
``provider`` names the provider in URLs (``/auth/<provider>/login``)
and namespaces its session state (``oidc:<provider>:...``); it must
match kaya-oidc's ``[A-Za-z0-9_-]+`` pattern.
``provider`` names the provider in URLs (``/auth/<provider>/login``),
namespaces its session state (``oidc:<provider>:...``), and is part of
the canonical user principal. It must match kaya-oidc's
``[A-Za-z0-9_-]+`` pattern and remain stable across deployments.
"""
provider: str
@@ -95,10 +95,11 @@ def session_tag_hook(span: Any, carrier: Any) -> None:
The session id is the session cookie, i.e. a credential, so only a
truncated SHA-256 of it is exported; session ids are
``secrets.token_urlsafe(32)`` (256 bits), which makes the digest
irreversible in practice. The OIDC ``sub`` is a pseudonymous
identifier already persisted in Postgres and served by the API, so it
is exported verbatim as ``user.id`` (OpenTelemetry's current end-user
attribute; ``enduser.id`` is PII-flagged and being superseded).
irreversible in practice. The canonical provider-qualified principal
is a pseudonymous identifier already persisted in Postgres and served
by the API, so it is exported verbatim as ``user.id`` (OpenTelemetry's
current end-user attribute; ``enduser.id`` is PII-flagged and being
superseded).
"""
session = getattr(carrier, "session", None)
if session is None:
@@ -111,9 +112,7 @@ def session_tag_hook(span: Any, carrier: Any) -> None:
)
user = session_user(session)
if user is not None:
sub = user.get("sub")
if sub:
span.set_attribute("user.id", str(sub))
span.set_attribute("user.id", user.principal)
class _NoOtelLogs(logging.Filter):