diff --git a/CHANGELOG.md b/CHANGELOG.md index 361ee373d..fe5351a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/features/audit-logging.md b/docs/features/audit-logging.md index 4212ff610..0e9c3a94f 100644 --- a/docs/features/audit-logging.md +++ b/docs/features/audit-logging.md @@ -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 diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 43193af85..c17e79951 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -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, diff --git a/src/backend/core/audit/drf.py b/src/backend/core/audit/drf.py index a69a1641e..02537d304 100644 --- a/src/backend/core/audit/drf.py +++ b/src/backend/core/audit/drf.py @@ -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, } diff --git a/src/backend/core/auditing.py b/src/backend/core/auditing.py index ea4c5d7f3..81e74d64d 100644 --- a/src/backend/core/auditing.py +++ b/src/backend/core/auditing.py @@ -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"} diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py index e6606f219..b8bf48f31 100644 --- a/src/backend/core/external_api/viewsets.py +++ b/src/backend/core/external_api/viewsets.py @@ -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( { diff --git a/src/backend/core/services/transit_code.py b/src/backend/core/services/transit_code.py index 73b267429..6d9acacb5 100644 --- a/src/backend/core/services/transit_code.py +++ b/src/backend/core/services/transit_code.py @@ -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. diff --git a/src/backend/core/tests/audit/test_drf.py b/src/backend/core/tests/audit/test_drf.py index dadf87c85..f4230f00d 100644 --- a/src/backend/core/tests/audit/test_drf.py +++ b/src/backend/core/tests/audit/test_drf.py @@ -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"}) diff --git a/src/backend/core/tests/audit/test_registry.py b/src/backend/core/tests/audit/test_registry.py index ec5789645..b0062a657 100644 --- a/src/backend/core/tests/audit/test_registry.py +++ b/src/backend/core/tests/audit/test_registry.py @@ -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" diff --git a/src/backend/core/tests/test_api_user_access_token_authentication.py b/src/backend/core/tests/test_api_user_access_token_authentication.py index 57cfef4dc..39dfd2e87 100644 --- a/src/backend/core/tests/test_api_user_access_token_authentication.py +++ b/src/backend/core/tests/test_api_user_access_token_authentication.py @@ -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} diff --git a/src/backend/core/tests/test_api_users_exchange_access_token.py b/src/backend/core/tests/test_api_users_exchange_access_token.py index 64b6fd5e9..1901c8e97 100644 --- a/src/backend/core/tests/test_api_users_exchange_access_token.py +++ b/src/backend/core/tests/test_api_users_exchange_access_token.py @@ -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 diff --git a/src/backend/core/tests/test_external_api_users.py b/src/backend/core/tests/test_external_api_users.py index da17ae589..9537e3e97 100644 --- a/src/backend/core/tests/test_external_api_users.py +++ b/src/backend/core/tests/test_external_api_users.py @@ -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"] diff --git a/src/backend/core/tests/utils.py b/src/backend/core/tests/utils.py index fdfe453ed..5004606ff 100644 --- a/src/backend/core/tests/utils.py +++ b/src/backend/core/tests/utils.py @@ -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", + }