✨(backend) audit transit code issuance and exchange

Trace the login flow of the embedded iframe frontend flow.
An application minting a transit code for its delegated user should
logs the transit_code issuance and it's exchange for a user access
token. The transit_code can be identified and with a
SHA-256 to audit eventual replay attacks.
This commit is contained in:
briquet
2026-10-13 08:29:06 +02:00
parent c8c5a3bf58
commit 936f24735e
13 changed files with 501 additions and 83 deletions
+1
View File
@@ -54,6 +54,7 @@ and this project adheres to
- ✨(backend) audit external API token and room operations
- 🔒️(backend) audit writes and bulk actions made in the Django admin
- ✨(backend) audit recordings, transcripts and summaries
- ✨(backend) audit transit code issuance and exchange
### Changed
+19 -8
View File
@@ -15,7 +15,8 @@ collector can route it to its own index. Events follow the [Elastic Common Schem
(ECS) 9.5.0, which most SIEMs and Elasticsearch understand out of the box.
Personal data is kept to a minimum. A person is identified by their account id, their OIDC `sub` and the domain of their
email address, never by the address itself. Secrets and the messages of unexpected exceptions are never recorded.
email address, never by the address itself. Secrets, transit codes and the messages of unexpected exceptions are never
recorded.
## What an event looks like
@@ -98,7 +99,7 @@ find each one in the JSON above.
|---|---|---|
| **Actor** | Who is behind an Action. It always has an Actor Type. When a person's account is involved, the event also carries the account id, its OIDC `sub` when it has one, and its email domain. | `lasuite.actor.*`, `user.*` |
| **Actor Type** | What kind of Actor it is: `user`, `application`, `service`, `system` or `anonymous`. See [Actors](#actors). Device accounts are `user`s like any other account. | `lasuite.actor.type` |
| **Auth Method** | How the Actor proved who it is for this request: `session`, `oidc`, `password`, `application_jwt`, `client_credentials`, `addons_jwt`, `resource_server`, `livekit_token`, `shared_secret`, `none` when it did not authenticate, or `unknown`. | `lasuite.auth.method` |
| **Auth Method** | How the Actor proved who it is for this request: `session`, `oidc`, `password`, `application_jwt`, `client_credentials`, `addons_jwt`, `user_access_jwt`, `transit_code`, `resource_server`, `livekit_token`, `shared_secret`, `none` when it did not authenticate, or `unknown`. | `lasuite.auth.method` |
| **Application** | A registered third-party integration, identified by its client id, that calls the external API on behalf of users. | `lasuite.application.client_id` |
| **Delegation** | An Application acting for one specific user, through the delegated token it obtained with `application.token.issue`. The event then names both: the Application as the Actor, and the user in `user.*`. | `lasuite.actor.type` = `application`, with `user.*` set |
| **Provisional User** | An account an Application created from an email address before that person ever signed in. It has no OIDC `sub` yet, so its account id is what links its `user.provision` event to its later `user.login`. | `event.action` = `user.provision` |
@@ -109,8 +110,8 @@ find each one in the JSON above.
| Term | Meaning | In the event |
|---|---|---|
| **Target** | The resource the Action was attempted on: a room, a recording, an access grant, an application or a user. Described by its type, its id and a few stable fields. See [Targets](#targets). | `entity.target.*` |
| **User Target** | The person the Action was *about*, when there is one: a provisioned user, the user an access grants a role to, or the account an admin edits. Not to be confused with `user.*`, the account whose authority was used. | `user.target.*` |
| **Target** | The resource the Action was attempted on: a room, a recording, an access grant, an application, a user or a transit code. Described by its type, its id and a few stable fields. See [Targets](#targets). | `entity.target.*` |
| **User Target** | The person the Action was *about*, when there is one: a provisioned user, the user an access grants a role to, the account an admin edits, or the user a transit code is minted for. Not to be confused with `user.*`, the account whose authority was used. | `user.target.*` |
### How it went
@@ -136,6 +137,13 @@ find each one in the JSON above.
| **Sink** | Where Audit Events are written: the `audit` logger, whose handler writes to `AUDIT_LOG_STREAM`, standard output by default. A log shipper takes them from there. | `log.logger` = `audit` |
| **Data Stream** | The data stream events are meant for, `logs-meet.audit-default` by default. It is what lets a shipper route audit events away from the application logs. | `data_stream.*` |
### Embedded frontends
| Term | Meaning | In the event |
|---|---|---|
| **Transit Code** | A single-use, short-lived code an Application mints for its delegated user and hands to an embedded frontend, which exchanges it for a User Access Token. Only its SHA-256 is ever logged. | `entity.target.sub_type` = `transit_code` |
| **User Access Token** | The token an embedded frontend uses to call the API as the user, where session cookies are blocked (in an iframe, for example). Requests made with it are recorded as the Application acting for that user. | `lasuite.auth.method` = `user_access_jwt` |
## Answering common questions
These are the fields to filter on for the questions that come up most. They work the same whatever tool you search
@@ -184,7 +192,7 @@ what ECS does not define: `lasuite.*`, and `entity.target.raw.*` for the fields
| `lasuite.outcome` | `success`, `failure`, `denied` or `unknown`: the same as `event.outcome`, with refusals kept apart |
| `lasuite.actor.type` | `user`, `application`, `service`, `system` or `anonymous`, see [Actors](#actors) |
| `lasuite.actor.sub` | The OIDC `sub` of the account in `user.*`, when it has one: the identity it shares with the other La Suite products |
| `lasuite.auth.method` | `session`, `application_jwt`, `addons_jwt`, `resource_server`, `livekit_token`, `shared_secret`, `client_credentials`, `oidc`, `password`, `none`, or `unknown` for an authentication class that is not registered. Requests served outside DRF, such as the admin and logout, report `session` when signed in |
| `lasuite.auth.method` | `session`, `application_jwt`, `addons_jwt`, `user_access_jwt`, `resource_server`, `livekit_token`, `shared_secret`, `client_credentials`, `transit_code`, `oidc`, `password`, `none`, or `unknown` for an authentication class that is not registered. Requests served outside DRF, such as the admin and logout, report `session` when signed in |
| `lasuite.application.client_id` | The external application acting, if any. Only set once its credentials are verified |
| `user.id`, `user.domain` | The account whose authority the action used (see [Actors](#actors)): its primary key and email domain. The email address itself is never recorded |
| `user.roles` | That account's privileges when the event happened: `superuser`, `staff` |
@@ -209,7 +217,7 @@ The resource an action was performed on is described with ECS `entity.target`, t
| Field | Meaning |
|---|---|
| `entity.target.id` | Its primary key |
| `entity.target.sub_type` | Its model: `room`, `recording`, `user`, `application`, `resourceaccess`... |
| `entity.target.sub_type` | Its model: `room`, `recording`, `user`, `application`, `resourceaccess` or `transit_code` (identified by its SHA-256) |
| `entity.target.type` | Its ECS entity type, when one fits: `user` for an account, `application` for an application |
| `entity.target.name` | Its name, when its model registers one |
| `entity.target.raw.*` | The other fields its model registers, such as a room's `slug` and `access_level`, or the OIDC `sub` of a user |
@@ -222,7 +230,7 @@ an application acts for a user, the actor is the application, but `user.*` is th
| `lasuite.actor.type` | Who | `user.*` |
|---|---|---|
| `user` | A person's account acting for itself: through a session, an OIDC or password login, an add-on token, or the LiveKit token of a known account | That account |
| `application` | A client application acting on behalf of a user. Either a Meet application, through its client credentials or its delegated token, or another La Suite application, through the resource server. `lasuite.application.client_id` names it | The delegating user |
| `application` | A client application acting on behalf of a user. Either a Meet application, through its client credentials, its delegated token or the user access token of its embedded frontend, or another La Suite application, through the resource server. `lasuite.application.client_id` names it | The delegating user |
| `service` | An internal service of the deployment, named by `service.origin.name`: the LiveKit SIP bridge (`roomkit`), the summary service (`summary`), or the LiveKit server reporting on a recording (`livekit`) | Absent |
| `system` | The backend itself, with no inbound request | Absent |
| `anonymous` | A caller that did not authenticate, or failed to | Absent |
@@ -260,6 +268,8 @@ is told by `event.outcome`, `lasuite.outcome` and `event.reason`, never by the n
|---|---|---|
| `application.token.issue` | An application asks for a delegated token (`POST /external-api/v1.0/application/token/`), whether it gets one or is refused. It can be refused for bad credentials, an inactive application, an invalid or unauthorized email domain, an unknown user, or a provisioning conflict | On success: `user.*` is the delegated user, `entity.target` the application, and `lasuite.details` holds `scopes`, `user_provisioned` and `expires_in`. On refusal: `event.reason`, `http.response.status_code` and `lasuite.details.requested_domain`. Until its credentials are verified, the client id the caller submitted is only recorded as `lasuite.details.claimed_client_id`: it never sets `lasuite.application` or `organization` |
| `user.provision` | An application creates a provisional user from an email address, or fails to. It is a `failure` with reason `conflict` when a concurrent request created the user first | `user.target` and `entity.target` are the user, the existing one on a conflict. A provisional user has no `sub` yet: its id links this event to the winning one and to its later `user.login` |
| `transit_code.issue` | An application mints a single-use transit code for its delegated user | `entity.target` is the code, whose `id` is its SHA-256. `user.target` is the user it is minted for. `lasuite.details.expires_in` |
| `transit_code.exchange` | An embedded frontend exchanges a transit code for a user access token | `event.category` is `authentication`, `lasuite.auth.method` is `transit_code`, and `entity.target` is the code's SHA-256. Once the code verifies, even if the exchange is then refused: `lasuite.actor.type` is `application` (the one that minted it), and `user.target` the user it was minted for. On success, `user.*` is that user too, with `lasuite.details.expires_in`. A code that does not verify is recorded as an `anonymous` failed authentication |
### Rooms
@@ -400,7 +410,8 @@ class RoomViewSet(audit.AuditViewMixin, viewsets.GenericViewSet):
- The outcome, reason and status code come from the response status: 401, 403 and 429 are `denied`, other errors are
`failure`, and a 401 is always filed under `authentication`.
- The target is the object `get_object()` returned, unless the view assigns `audit_target`.
- A view can also assign `audit_actor` (`None` records no account) and `audit_details`, or override `get_audit_fields()`.
- A view can also assign `audit_user_target`, `audit_actor` (`None` records no account) and `audit_details`, or override
`get_audit_fields()`.
### Auditing from anywhere else
+40 -26
View File
@@ -125,7 +125,10 @@ class Pagination(pagination.PageNumberPagination):
class UserViewSet(
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
audit.AuditViewMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
mixins.ListModelMixin,
):
"""User ViewSet"""
@@ -133,6 +136,9 @@ class UserViewSet(
queryset = models.User.objects.all()
serializer_class = serializers.UserSerializer
audit_client_id = None
audit_code_refused = False
def get_queryset(self):
"""
Limit listed users by querying the email field with a trigram similarity
@@ -174,6 +180,7 @@ class UserViewSet(
url_path="exchange-access-token",
permission_classes=[],
throttle_classes=[throttling.ExchangeAccessTokenAnonRateThrottle],
audit_action=auditing.TRANSIT_CODE_EXCHANGE,
)
@FeatureFlag.require("user_access_token")
def exchange_access_token(self, request):
@@ -191,44 +198,38 @@ class UserViewSet(
any URL. Role-based permissions apply unchanged.
"""
if request.user and request.user.is_authenticated:
logger.warning(
"Transit code exchange refused: request is already "
"session-authenticated (user_id=%s)",
request.user.id,
)
raise drf_exceptions.PermissionDenied("Already authenticated.")
serializer = serializers.TransitCodeSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
code_data = TransitCodeService().consume_code(serializer.validated_data["code"])
code = serializer.validated_data["code"]
self.audit_target = auditing.transit_code_target(code)
code_data = TransitCodeService().consume_code(code)
if code_data is None:
logger.warning("Invalid, expired or already used transit code")
self.audit_code_refused = True
raise drf_exceptions.PermissionDenied(
"Invalid, expired or already used transit code."
)
client_id = code_data.get("client_id")
self.audit_client_id = client_id
# Re-check the user at exchange time so that a deactivation after
# the transit code was minted is taken into account.
user_id = code_data["user_id"]
try:
user = models.User.objects.get(id=user_id, is_active=True)
except models.User.DoesNotExist as e:
user = models.User.objects.filter(id=code_data["user_id"]).first()
self.audit_user_target = user
if user is None or not user.is_active:
raise drf_exceptions.PermissionDenied(
"This account can no longer access the application."
) from e
)
client_id = code_data.get("client_id")
if not models.Application.has_active_scope(
client_id, models.ApplicationScope.USERS_SESSION
):
logger.warning(
"Transit code exchange refused: application '%s' no longer "
"holds the '%s' grant",
client_id,
models.ApplicationScope.USERS_SESSION,
)
raise drf_exceptions.PermissionDenied(
"This application can no longer create user sessions."
)
@@ -251,15 +252,28 @@ class UserViewSet(
},
)
# Log for auditing
logger.info(
"User access token issued from transit code: user_id=%s, client_id=%s",
user_id,
client_id,
)
self.audit_actor = user
self.audit_details = {"expires_in": settings.USER_ACCESS_TOKEN_TTL}
return drf_response.Response(data)
def get_audit_fields(self, status_code, error=None):
"""Report an exchange as an authentication with its transit code.
The caller is anonymous until the code is consumed, then the
application that minted it, a refusal included, acting for its user
once the exchange succeeds. A code that does not verify is a failed
authentication, though answered with a 403. A request already signed
in is reported as it is.
"""
fields = super().get_audit_fields(status_code, error)
if self.action != "exchange_access_token" or self.request.user.is_authenticated:
return fields
fields |= {"auth_method": "transit_code", "client_id": self.audit_client_id}
if self.audit_code_refused:
fields["reason"] = audit.Reason.AUTHENTICATION_FAILED
return fields
class RoomViewSet(
audit.AuditViewMixin,
+9 -5
View File
@@ -66,11 +66,13 @@ class AuditViewMixin:
by passing ``audit_action`` to its ``@action`` decorator.
While handling a request, a view may *assign* ``audit_target``,
``audit_actor`` and ``audit_details``; ``check_object_permissions`` sets
the target on its own, before a refusal can happen. The actor is the
account of the request unless ``audit_actor`` is assigned, ``None``
recording no account. A detail named after an event field, as ``outcome``
or ``request``, is dropped: overriding one is done in ``get_audit_fields``.
``audit_user_target``, ``audit_actor`` and ``audit_details``;
``check_object_permissions`` sets the target on its own, before a refusal
can happen. The actor is the account of the request unless ``audit_actor``
is assigned, ``None`` recording no account. The account the action is
about is found from the target unless ``audit_user_target`` is assigned.
A detail named after an event field, as ``outcome`` or ``request``, is
dropped: overriding one is done in ``get_audit_fields``.
"""
audit_actions: Mapping[str, Action | str] = {}
@@ -78,6 +80,7 @@ class AuditViewMixin:
# to ``as_view``; the action is read from the handler of the request.
audit_action: Action | str | None = None
audit_target: Any = None
audit_user_target: Any = None
audit_actor: Any = FROM_REQUEST
audit_details: Mapping[str, Any] | None = None
@@ -169,6 +172,7 @@ class AuditViewMixin:
"types": types
or [ACTION_TYPES.get(getattr(self, "action", None), EventType.INFO)],
"target": self.audit_target,
"user_target": self.audit_user_target,
"actor": self.audit_actor,
"status_code": status_code,
}
+17
View File
@@ -11,12 +11,14 @@ from core import audit, models
from core.audit import EventCategory, EventType
from core.authentication.backends import OIDCAuthenticationBackend
from core.authentication.livekit import LiveKitTokenAuthentication
from core.authentication.user_token import UserAccessJWTAuthentication
from core.external_api.authentication import (
AddonsJWTAuthentication,
ApplicationJWTAuthentication,
)
from core.recording.event.authentication import HeaderBasedAuthentication
from core.roomkit.authentication import ServerToServerAuthentication
from core.services.transit_code import TransitCodeService
# Actions
@@ -30,6 +32,12 @@ USER_PROVISION = audit.Action(
category=EventCategory.IAM,
types=(EventType.USER, EventType.CREATION),
)
TRANSIT_CODE_ISSUE = audit.Action("transit_code.issue", types=(EventType.CREATION,))
TRANSIT_CODE_EXCHANGE = audit.Action(
"transit_code.exchange",
category=EventCategory.AUTHENTICATION,
types=(EventType.START,),
)
ROOM_CREATE = audit.Action("room.create")
ROOM_LIST = audit.Action("room.list")
ROOM_RETRIEVE = audit.Action("room.retrieve")
@@ -105,5 +113,14 @@ audit.register_auth_method(ApplicationJWTAuthentication, "application_jwt")
audit.register_auth_method(AddonsJWTAuthentication, "addons_jwt")
audit.register_auth_method(ResourceServerAuthentication, "resource_server")
audit.register_auth_method(LiveKitTokenAuthentication, "livekit_token")
audit.register_auth_method(UserAccessJWTAuthentication, "user_access_jwt")
audit.register_auth_method(HeaderBasedAuthentication, "shared_secret")
audit.register_auth_method(ServerToServerAuthentication, "shared_secret")
# Targets that are not models
def transit_code_target(code: str) -> dict[str, str]:
"""Describe a transit code as a target, by its digest: never the code itself."""
return {"id": TransitCodeService.digest(code), "sub_type": "transit_code"}
+5 -10
View File
@@ -1,7 +1,6 @@
"""External API endpoints"""
import copy
from logging import getLogger
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -35,8 +34,6 @@ from ..services.provisional_user_service import (
)
from . import authentication, permissions, serializers
logger = getLogger(__name__)
class ApplicationViewSet(audit.AuditViewMixin, viewsets.ViewSet):
"""API endpoints for application authentication and token generation."""
@@ -302,7 +299,7 @@ class RoomViewSet(
)
class UserViewSet(viewsets.GenericViewSet):
class UserViewSet(audit.AuditViewMixin, viewsets.GenericViewSet):
"""Application-delegated API for user operations.
Provides JWT-authenticated access to user operations for external
@@ -327,6 +324,7 @@ class UserViewSet(viewsets.GenericViewSet):
methods=["post"],
url_path="transit-code",
url_name="transit-code",
audit_action=auditing.TRANSIT_CODE_ISSUE,
)
@FeatureFlag.require("user_access_token")
def generate_transit_code(self, request):
@@ -346,12 +344,9 @@ class UserViewSet(viewsets.GenericViewSet):
code = TransitCodeService().create_code(request.user, client_id=client_id)
# Log for auditing
logger.info(
"Transit code issued: user_id=%s, client_id=%s",
request.user.id,
client_id,
)
self.audit_target = auditing.transit_code_target(code)
self.audit_user_target = request.user
self.audit_details = {"expires_in": settings.TRANSIT_CODE_TTL}
return drf_response.Response(
{
+10 -3
View File
@@ -18,14 +18,21 @@ class TransitCodeService:
"""Create and consume single-use transit codes."""
@staticmethod
def _cache_key(code):
def digest(code):
"""Return the SHA-256 hex digest of a code.
It identifies a code, in the cache and in audit events
"""
return hashlib.sha256(code.encode("utf-8")).hexdigest()
@classmethod
def _cache_key(cls, code):
"""Build the cache key for a code.
The code is hashed so that a dump of the cache never reveals
directly usable codes.
"""
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()
return f"{settings.TRANSIT_CODE_CACHE_PREFIX}:{digest}"
return f"{settings.TRANSIT_CODE_CACHE_PREFIX}:{cls.digest(code)}"
def create_code(self, user, client_id):
"""Generate a transit code for a user, and store it.
+15 -1
View File
@@ -21,7 +21,7 @@ from rest_framework.test import APIRequestFactory
from core import audit, models
from core.audit.testing import find_events
from core.authentication.backends import SessionAuthenticationWith401
from core.factories import RoomFactory
from core.factories import RoomFactory, UserFactory
pytestmark = pytest.mark.django_db
@@ -358,6 +358,20 @@ def test_details_never_override_event_fields(audit_events):
assert event["lasuite"]["details"] == {"total": 3}
def test_assigned_user_target_is_reported(audit_events):
"""The account an action is about can be assigned apart from its target."""
user = UserFactory(email="user@example.com")
view = ListingViewSet.as_view({"get": "list"}, audit_user_target=user)
response = view(APIRequestFactory().get("/things/"))
assert response.status_code == 200
[event] = find_events(audit_events, "thing.list")
assert event["user"] == {"target": {"id": str(user.pk), "domain": "example.com"}}
def test_failing_audit_never_fails_the_response(audit_events):
"""An error assembling the event is logged, and the response is kept."""
view = ThingViewSet.as_view({"get": "list"})
@@ -16,6 +16,7 @@ from core.audit.registry import (
unregister,
)
from core.audit.testing import override_registration
from core.authentication.user_token import UserAccessJWTAuthentication
from core.external_api.authentication import ApplicationJWTAuthentication
from core.models import Application, Resource, ResourceAccess, Room
@@ -81,6 +82,9 @@ def test_project_declarations_are_discovered():
assert auth_methods()[dotted_path(ApplicationJWTAuthentication)] == (
"application_jwt"
)
assert auth_methods()[dotted_path(UserAccessJWTAuthentication)] == (
"user_access_jwt"
)
assert (
auth_method_for_backend(
"core.authentication.backends.OIDCAuthenticationBackend"
@@ -15,7 +15,14 @@ import jwt
import pytest
from rest_framework.test import APIClient
from core.factories import ApplicationFactory, RoomFactory, UserFactory
from core.audit.testing import find_events
from core.factories import (
ApplicationFactory,
RecordingFactory,
RoomFactory,
UserFactory,
UserRecordingAccessFactory,
)
from core.models import ApplicationScope, RoleChoices
from core.tests.utils import generate_user_access_token
@@ -242,3 +249,32 @@ def test_user_access_token_does_not_override_existing_session():
assert response.status_code == 200
assert response.data["email"] == session_user.email
def test_user_access_token_actions_are_audited_through_the_application(
audit_events,
):
"""The application the token was delegated by acts, for the person it names."""
user = UserFactory(email="jean-neige@winterfell.com")
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
recording = RecordingFactory(status="saved")
UserRecordingAccessFactory(role="owner", user=user, recording=recording)
client = APIClient()
client.credentials(
HTTP_AUTHORIZATION=(
f"Bearer {generate_user_access_token(user, application=application)}"
)
)
response = client.delete(f"/api/v1.0/recordings/{recording.id}/")
assert response.status_code == 204
[event] = find_events(audit_events, "recording.delete")
assert event["lasuite"]["actor"] == {"type": "application", "sub": user.sub}
assert event["lasuite"]["auth"] == {"method": "user_access_jwt"}
assert event["lasuite"]["application"] == {"client_id": application.client_id}
assert event["user"] == {"id": str(user.pk), "domain": "winterfell.com"}
assert event["organization"] == {"id": application.client_id}
@@ -4,15 +4,18 @@ Test users API endpoints in the Meet core app: exchange transit code.
# pylint: disable=W0621
import json
import secrets
import jwt
import pytest
from rest_framework.test import APIClient
from core.audit.testing import find_events
from core.factories import ApplicationFactory, UserFactory
from core.models import ApplicationScope
from core.services.transit_code import TransitCodeService
from core.tests.utils import random_remote_addr, transit_code_target
pytestmark = pytest.mark.django_db
@@ -35,18 +38,8 @@ def generate_unknown_code(settings):
@pytest.fixture
def client():
"""Return an anonymous API client with a random source IP.
A fresh IP per test isolates the anonymous throttle history, both
between the tests of this module and between test runs.
"""
# `secrets` rather than `random`: the global random module is seeded
# deterministically by the factories, its sequence repeats across runs.
remote_addr = (
f"10.{secrets.randbelow(256)}.{secrets.randbelow(256)}"
f".{secrets.randbelow(254) + 1}"
)
return APIClient(REMOTE_ADDR=remote_addr)
"""Return an anonymous API client with a random source IP."""
return APIClient(REMOTE_ADDR=random_remote_addr())
def test_exchange_access_token_missing_code(client):
@@ -151,29 +144,26 @@ def test_exchange_access_token_feature_disabled(client, settings):
assert response.status_code == 404
def test_exchange_access_token_throttled(client, settings):
def test_exchange_access_token_throttled(client, settings, monkeypatch):
"""Anonymous exchange attempts should be rate limited."""
throttle_rates = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]
initial_rate = throttle_rates["exchange_access_token"]
# The rates dict is mutated in place: restore it explicitly, the
# `settings` fixture only rolls back attribute assignments.
throttle_rates["exchange_access_token"] = "2/minute"
try:
for _ in range(2):
response = client.post(
"/api/v1.0/users/exchange-access-token/",
{"code": generate_unknown_code(settings)},
)
assert response.status_code == 403
monkeypatch.setitem(
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"],
"exchange_access_token",
"2/minute",
)
for _ in range(2):
response = client.post(
"/api/v1.0/users/exchange-access-token/",
{"code": generate_unknown_code(settings)},
)
assert response.status_code == 429
finally:
throttle_rates["exchange_access_token"] = initial_rate
assert response.status_code == 403
response = client.post(
"/api/v1.0/users/exchange-access-token/",
{"code": generate_unknown_code(settings)},
)
assert response.status_code == 429
def test_exchange_access_token_refused_when_already_authenticated(client):
@@ -260,3 +250,209 @@ def test_exchange_access_token_end_to_end(client):
assert me.status_code == 200
assert me.data["email"] == user.email
def test_exchange_access_token_success_is_audited(client, audit_events, settings):
"""An exchange is the application that minted a code acting for its user."""
user = UserFactory(email="jean-neige@winterfell.com")
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
code = TransitCodeService().create_code(user, client_id=application.client_id)
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 200
[event] = find_events(audit_events, "transit_code.exchange")
assert event["event"]["category"] == ["authentication"]
assert event["event"]["type"] == ["start"]
assert event["event"]["outcome"] == "success"
assert event["lasuite"]["outcome"] == "success"
assert event["lasuite"]["actor"] == {"type": "application", "sub": user.sub}
assert event["lasuite"]["auth"] == {"method": "transit_code"}
assert event["lasuite"]["application"] == {"client_id": application.client_id}
assert event["user"] == {
"id": str(user.pk),
"domain": "winterfell.com",
"target": {"id": str(user.pk), "domain": "winterfell.com"},
}
assert event["organization"] == {"id": application.client_id}
assert event["entity"]["target"] == transit_code_target(code)
assert event["lasuite"]["details"] == {"expires_in": settings.USER_ACCESS_TOKEN_TTL}
assert event["http"]["response"] == {"status_code": 200}
assert event["log"]["level"] == "info"
serialized = json.dumps(event)
assert code not in serialized
assert response.data["access_token"] not in serialized
def test_exchange_access_token_unknown_code_is_audited(client, audit_events, settings):
"""A code that does not verify is a failed authentication, by its digest."""
code = generate_unknown_code(settings)
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 403
[event] = find_events(audit_events, "transit_code.exchange")
# ECS expects no ``denied`` type for an authentication
assert event["event"]["category"] == ["authentication"]
assert event["event"]["type"] == ["start"]
assert event["event"]["outcome"] == "failure"
assert event["event"]["reason"] == "authentication_failed"
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["actor"] == {"type": "anonymous"}
assert event["lasuite"]["auth"] == {"method": "transit_code"}
assert "application" not in event["lasuite"]
assert "user" not in event
assert "organization" not in event
assert event["entity"]["target"] == transit_code_target(code)
assert event["http"]["response"] == {"status_code": 403}
assert event["error"] == {
"message": "Invalid, expired or already used transit code."
}
assert event["log"]["level"] == "warning"
assert code not in json.dumps(event)
def test_exchange_access_token_replay_is_audited(client, audit_events):
"""A replayed code is refused under the digest of its successful exchange."""
user = UserFactory()
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
code = TransitCodeService().create_code(user, client_id=application.client_id)
for _ in range(2):
client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
exchanged, replayed = find_events(audit_events, "transit_code.exchange")
assert exchanged["event"]["outcome"] == "success"
assert replayed["event"]["reason"] == "authentication_failed"
assert replayed["entity"]["target"] == exchanged["entity"]["target"]
@pytest.mark.parametrize(
"revoked, message",
[
("user", "This account can no longer access the application."),
("application", "This application can no longer create user sessions."),
],
)
def test_exchange_access_token_refused_verified_code_is_audited(
client, audit_events, revoked, message
):
"""Once the code verified, its application is refused, naming its user."""
user = UserFactory(email="jean-neige@winterfell.com")
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
code = TransitCodeService().create_code(user, client_id=application.client_id)
if revoked == "user":
user.is_active = False
user.save()
else:
application.scopes = []
application.save()
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 403
[event] = find_events(audit_events, "transit_code.exchange")
assert event["event"]["reason"] == "permission_denied"
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["actor"] == {"type": "application"}
assert event["lasuite"]["auth"] == {"method": "transit_code"}
assert event["lasuite"]["application"] == {"client_id": application.client_id}
assert event["user"] == {"target": {"id": str(user.pk), "domain": "winterfell.com"}}
assert event["organization"] == {"id": application.client_id}
assert event["entity"]["target"] == transit_code_target(code)
assert event["error"] == {"message": message}
def test_exchange_access_token_deleted_user_is_audited(client, audit_events):
"""The code of a deleted account still names its application."""
user = UserFactory()
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
code = TransitCodeService().create_code(user, client_id=application.client_id)
user.delete()
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 403
[event] = find_events(audit_events, "transit_code.exchange")
assert event["event"]["reason"] == "permission_denied"
assert event["lasuite"]["application"] == {"client_id": application.client_id}
assert "user" not in event
def test_exchange_access_token_already_authenticated_is_audited(client, audit_events):
"""A signed-in browser is refused as itself, before its code is read."""
session_user = UserFactory(email="sansa@winterfell.com")
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
code = TransitCodeService().create_code(
UserFactory(), client_id=application.client_id
)
client.force_login(session_user)
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 403
[event] = find_events(audit_events, "transit_code.exchange")
assert event["event"]["reason"] == "permission_denied"
assert event["lasuite"]["actor"] == {"type": "user", "sub": session_user.sub}
assert event["lasuite"]["auth"] == {"method": "session"}
assert "application" not in event["lasuite"]
assert event["user"] == {"id": str(session_user.pk), "domain": "winterfell.com"}
assert "entity" not in event
def test_exchange_access_token_malformed_code_is_audited(client, audit_events):
"""A malformed code is a validation error, never recorded."""
response = client.post(
"/api/v1.0/users/exchange-access-token/",
{"code": "not-a-valid-code"},
)
assert response.status_code == 400
[event] = find_events(audit_events, "transit_code.exchange")
assert event["event"]["reason"] == "validation_error"
assert event["lasuite"]["outcome"] == "failure"
assert event["lasuite"]["actor"] == {"type": "anonymous"}
assert event["lasuite"]["auth"] == {"method": "transit_code"}
assert "entity" not in event
assert "not-a-valid-code" not in json.dumps(event)
def test_exchange_access_token_throttled_is_audited(
client, audit_events, settings, monkeypatch
):
"""A throttled attempt is denied as rate limited, before its code is read."""
monkeypatch.setitem(
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"],
"exchange_access_token",
"1/minute",
)
for _ in range(2):
response = client.post(
"/api/v1.0/users/exchange-access-token/",
{"code": generate_unknown_code(settings)},
)
assert response.status_code == 429
_, event = find_events(audit_events, "transit_code.exchange")
assert event["event"]["reason"] == "rate_limited"
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["actor"] == {"type": "anonymous"}
assert "entity" not in event
@@ -4,6 +4,7 @@ Tests for external API /users endpoints (transit codes)
# pylint: disable=W0621
import json
from datetime import datetime, timedelta, timezone
from unittest import mock
@@ -14,9 +15,11 @@ import pytest
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient
from core.audit.testing import find_events
from core.factories import ApplicationFactory, UserFactory
from core.models import ApplicationScope
from core.services.transit_code import TransitCodeService
from core.tests.utils import random_remote_addr, transit_code_target
pytestmark = pytest.mark.django_db
@@ -235,3 +238,95 @@ def test_api_users_transit_code_missing_client_id(auth):
assert response.status_code == 401
assert response.json() == {"detail": "Invalid application token."}
mock_create_code.assert_not_called()
def test_api_users_transit_code_success_is_audited(audit_events, settings):
"""A minted code is recorded by its digest, for the application and its user."""
user = UserFactory(email="jean-neige@winterfell.com")
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
token = generate_test_token(
user, [ApplicationScope.USERS_SESSION], application=application
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 200
code = response.data["transit_code"]
[event] = find_events(audit_events, "transit_code.issue")
assert event["event"]["category"] == ["api"]
assert event["event"]["type"] == ["creation"]
assert event["event"]["outcome"] == "success"
assert event["lasuite"]["actor"] == {"type": "application", "sub": user.sub}
assert event["lasuite"]["auth"] == {"method": "application_jwt"}
assert event["lasuite"]["application"] == {"client_id": application.client_id}
assert event["user"] == {
"id": str(user.pk),
"domain": "winterfell.com",
"target": {"id": str(user.pk), "domain": "winterfell.com"},
}
assert event["organization"] == {"id": application.client_id}
assert event["entity"]["target"] == transit_code_target(code)
assert event["lasuite"]["details"] == {"expires_in": settings.TRANSIT_CODE_TTL}
assert code not in json.dumps(event)
def test_api_users_transit_code_missing_scope_is_audited(audit_events):
"""A refused mint is recorded under the action attempted, with no code."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 403
[event] = find_events(audit_events, "transit_code.issue")
assert event["event"]["type"] == ["creation", "denied"]
assert event["event"]["reason"] == "permission_denied"
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["actor"]["type"] == "application"
assert "entity" not in event
def test_api_users_transit_code_requires_authentication_is_audited(audit_events):
"""Minting without a token is a failed authentication of nobody."""
response = APIClient().post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 401
[event] = find_events(audit_events, "transit_code.issue")
assert event["event"]["category"] == ["api", "authentication"]
assert event["event"]["reason"] == "authentication_failed"
assert event["lasuite"]["actor"] == {"type": "anonymous"}
assert "user" not in event
assert "entity" not in event
def test_api_users_transit_code_is_traced_to_its_exchange(audit_events):
"""Minting and exchanging a code are joined by its digest."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
code = client.post("/external-api/v1.0/users/transit-code/").data["transit_code"]
# A fresh IP isolates the anonymous throttle history of the exchange
browser = APIClient(REMOTE_ADDR=random_remote_addr())
response = browser.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 200
[issued] = find_events(audit_events, "transit_code.issue")
[exchanged] = find_events(audit_events, "transit_code.exchange")
assert exchanged["event"]["outcome"] == "success"
assert exchanged["entity"]["target"] == issued["entity"]["target"]
assert exchanged["user"]["id"] == issued["user"]["id"]
+24
View File
@@ -1,5 +1,7 @@
"""Shared helpers for tests in the Meet core application"""
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from django.conf import settings
@@ -39,3 +41,25 @@ def generate_user_access_token(user, application=None, **overrides):
settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=settings.USER_ACCESS_TOKEN_ALG,
)
def random_remote_addr():
"""Return a random private source IP.
A fresh IP per client isolates its anonymous throttle history, both
between tests and between test runs.
"""
# `secrets` rather than `random`: the global random module is seeded
# deterministically by the factories, its sequence repeats across runs.
return (
f"10.{secrets.randbelow(256)}.{secrets.randbelow(256)}"
f".{secrets.randbelow(254) + 1}"
)
def transit_code_target(code):
"""Return the audit target of a code: its SHA-256, never the code itself."""
return {
"id": hashlib.sha256(code.encode("utf-8")).hexdigest(),
"sub_type": "transit_code",
}