diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b6149c4..af1ee233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to - ✨(frontend) let signed-out visitors start a meeting - ✨(backend) expose `allow_unregistered_rooms` in the frontend configuration - ✨(backend) add structured audit logging facility +- ✨(backend) audit external API token and room operations ### Changed diff --git a/src/backend/core/auditing.py b/src/backend/core/auditing.py index b0b90b44..9d60175d 100644 --- a/src/backend/core/auditing.py +++ b/src/backend/core/auditing.py @@ -6,6 +6,7 @@ Imported by the audit app once it is ready, see ``core.audit.apps``. from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication 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.external_api.authentication import ( @@ -15,6 +16,23 @@ from core.external_api.authentication import ( from core.recording.event.authentication import HeaderBasedAuthentication from core.roomkit.authentication import ServerToServerAuthentication +# Actions. Those of CRUD views take their types from the DRF action. + +APPLICATION_TOKEN_ISSUE = audit.Action( + "application.token.issue", + category=EventCategory.AUTHENTICATION, + types=(EventType.START,), +) +USER_PROVISION = audit.Action( + "user.provision", + category=EventCategory.IAM, + types=(EventType.USER, EventType.CREATION), +) +ROOM_CREATE = audit.Action("room.create") +ROOM_LIST = audit.Action("room.list") +ROOM_RETRIEVE = audit.Action("room.retrieve") +ROOM_UPDATE = audit.Action("room.update") + # Models: the ``fields`` describing them as a target. audit.register(models.Application, fields=("client_id", "name", "is_active", "scopes")) diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py index d61d095f..ae13b540 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.contrib.auth.hashers import check_password @@ -23,7 +22,7 @@ from rest_framework import ( status as drf_status, ) -from core import analytics, api, models +from core import analytics, api, audit, auditing, models from core.api.feature_flag import FeatureFlag from core.services.jwt_token import JwtTokenService from core.services.room_management import RoomManagement @@ -35,18 +34,19 @@ from ..services.provisional_user_service import ( ) from . import authentication, permissions, serializers -logger = getLogger(__name__) - -class ApplicationViewSet(viewsets.ViewSet): +class ApplicationViewSet(audit.AuditViewMixin, viewsets.ViewSet): """API endpoints for application authentication and token generation.""" + audit_client_id = None + @decorators.action( detail=False, methods=["post"], url_path="token", url_name="token", parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser], + audit_action=auditing.APPLICATION_TOKEN_ISSUE, ) @FeatureFlag.require("application") def generate_jwt_access_token(self, request, *args, **kwargs): @@ -68,6 +68,10 @@ class ApplicationViewSet(viewsets.ViewSet): client_id = serializer.validated_data["client_id"] client_secret = serializer.validated_data["client_secret"] + email = serializer.validated_data["scope"] + + self.audit_client_id = client_id + self.audit_details = {"requested_domain": audit.email_domain(email)} try: application = models.Application.objects.get(client_id=client_id) @@ -80,7 +84,8 @@ class ApplicationViewSet(viewsets.ViewSet): if not application.is_active: raise drf_exceptions.AuthenticationFailed("Application is inactive") - email = serializer.validated_data["scope"] + self.audit_target = application + try: validate_email(email) except ValidationError: @@ -92,11 +97,6 @@ class ApplicationViewSet(viewsets.ViewSet): ) if not application.can_delegate_email(email): - logger.warning( - "Application %s denied delegation for %s", - application.client_id, - email, - ) return drf_response.Response( { "error": "This application is not authorized for this email domain.", @@ -105,7 +105,7 @@ class ApplicationViewSet(viewsets.ViewSet): ) try: - user, _ = ProvisionalUserService().get_or_create(email, client_id) + user, created = ProvisionalUserService().get_or_create(email, client_id) except ProvisionalUserCreationDisabledError as not_found_error: raise drf_exceptions.NotFound("User not found.") from not_found_error except ProvisionalUserIntegrityError: @@ -114,6 +114,15 @@ class ApplicationViewSet(viewsets.ViewSet): status=drf_status.HTTP_409_CONFLICT, ) + if created: + audit.log( + auditing.USER_PROVISION, + request=request, + target=user, + actor_type=audit.ActorType.APPLICATION, + client_id=client_id, + ) + scope = " ".join(application.scopes or []) token_service = JwtTokenService( @@ -134,13 +143,42 @@ class ApplicationViewSet(viewsets.ViewSet): }, ) + self.audit_actor = user + self.audit_details = { + "scopes": list(application.scopes or []), + "user_provisioned": created, + "expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS, + } + return drf_response.Response( data, status=drf_status.HTTP_200_OK, ) + def get_audit_fields(self, status_code, error=None): + """Report the application as the actor once its credentials are verified. + + Until then the submitted client id is only a claim: it is kept apart so + that it never names the application or the tenant of the event. + """ + application = self.audit_target + fields = { + **super().get_audit_fields(status_code, error), + "auth_method": "client_credentials", + "actor_type": audit.ActorType.ANONYMOUS, + } + if application: + fields |= { + "actor_type": audit.ActorType.APPLICATION, + "client_id": application.client_id, + } + else: + fields["claimed_client_id"] = self.audit_client_id + return fields + class RoomViewSet( + audit.AuditViewMixin, mixins.CreateModelMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, @@ -163,6 +201,13 @@ class RoomViewSet( http_method_names = ["get", "post", "patch", "head", "options"] + audit_actions = { + "list": auditing.ROOM_LIST, + "retrieve": auditing.ROOM_RETRIEVE, + "create": auditing.ROOM_CREATE, + "partial_update": auditing.ROOM_UPDATE, + } + authentication_classes = [ authentication.ApplicationJWTAuthentication, authentication.AddonsJWTAuthentication, @@ -191,29 +236,22 @@ class RoomViewSet( page = self.paginate_queryset(queryset) if page is not None: serializer = self.get_serializer(page, many=True) + self.audit_details = {"total": self.paginator.page.paginator.count} return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) + self.audit_details = {"total": len(serializer.data)} return drf_response.Response(serializer.data) def _track_room_event(self, room, event, **extra_properties): - """Log a room operation for auditing and forward it to analytics.""" + """Add a room operation to the audit event and forward it to analytics.""" + + self.audit_target = room + self.audit_details = extra_properties auth_method = type(self.request.successful_authenticator).__name__ client_id = (self.request.auth or {}).get("client_id", "unknown") - # Log for auditing - details = "".join(f", {key}={value}" for key, value in extra_properties.items()) - logger.info( - "Room %s via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s%s", - event.removeprefix("room_"), - room.id, - self.request.user.id, - client_id, - auth_method, - details, - ) - analytics.capture( self.request.user, event, diff --git a/src/backend/core/services/provisional_user_service.py b/src/backend/core/services/provisional_user_service.py index dbe5e4f3..257fd0c0 100644 --- a/src/backend/core/services/provisional_user_service.py +++ b/src/backend/core/services/provisional_user_service.py @@ -87,17 +87,15 @@ class ProvisionalUserService: user.set_unusable_password() user.save() logger.info( - "Provisional user created via application: user_id=%s, email=%s, client_id=%s", + "Provisional user created via application: user_id=%s, client_id=%s", user.id, - email, client_id, ) return user, True except (IntegrityError, ValidationError) as e: logger.warning( "Race condition on provisional user creation, fetching existing: " - "email=%s, client_id=%s", - email, + "client_id=%s", client_id, ) user = self._get_by_email(email) diff --git a/src/backend/core/tests/test_external_api_rooms.py b/src/backend/core/tests/test_external_api_rooms.py index 842f9f8e..ed8c4542 100644 --- a/src/backend/core/tests/test_external_api_rooms.py +++ b/src/backend/core/tests/test_external_api_rooms.py @@ -17,6 +17,7 @@ from lasuite.oidc_resource_server.authentication import ResourceServerAuthentica from rest_framework.test import APIClient from core.analytics import AnalyticsEvent +from core.audit.testing import find_events from core.factories import ApplicationFactory, RoomFactory, UserFactory from core.models import ( Application, @@ -2375,3 +2376,233 @@ def test_api_rooms_addons_disabled_does_not_break_application_auth(settings): assert response.status_code == 200 assert response.data["count"] == 1 assert response.data["results"][0]["id"] == str(room.id) + + +def test_api_rooms_create_is_audited(audit_events): + """Creating a room records the application, the delegated user and the room.""" + user = UserFactory(email="jean-neige@winterfell.com") + token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE]) + application = Application.objects.get() + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.post( + "/external-api/v1.0/rooms/", {}, format="json", REMOTE_ADDR="1.2.3.4" + ) + + assert response.status_code == 201 + + room = Room.objects.get(id=response.data["id"]) + [event] = find_events(audit_events, "room.create") + + assert event["event"]["type"] == ["creation"] + assert event["event"]["outcome"] == "success" + assert event["lasuite"]["actor"] == {"type": "application"} + assert event["lasuite"]["auth"] == {"method": "application_jwt"} + assert event["lasuite"]["application"] == {"client_id": str(application.client_id)} + assert event["user"] == { + "id": str(user.pk), + "sub": user.sub, + "domain": "winterfell.com", + } + assert event["organization"] == {"id": str(application.client_id)} + assert event["lasuite"]["target"] == { + "type": "room", + "id": str(room.pk), + "slug": room.slug, + "name": room.name, + "access_level": "trusted", + } + assert event["client"]["ip"] == "1.2.3.4" + assert event["http"]["request"]["method"] == "POST" + assert event["url"]["path"] == "/external-api/v1.0/rooms/" + assert event["trace"]["id"] == response["X-Request-ID"] + assert "jean-neige@winterfell.com" not in str(event) + + +@mock.patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_is_audited(mock_update_metadata, audit_events): + """Updating a room records what changed and the previous access level.""" + user = UserFactory() + room = RoomFactory( + users=[(user, RoleChoices.OWNER)], + access_level=RoomAccessLevel.TRUSTED, + configuration={}, + ) + token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + {"access_level": RoomAccessLevel.RESTRICTED}, + format="json", + ) + + assert response.status_code == 200 + + mock_update_metadata.assert_called_once() + [event] = find_events(audit_events, "room.update") + + assert event["event"]["type"] == ["change"] + assert event["lasuite"]["target"]["id"] == str(room.pk) + assert event["lasuite"]["target"]["access_level"] == "restricted" + assert event["lasuite"]["details"] == { + "updated_fields": ["access_level"], + "previous_access_level": "trusted", + } + + +def test_api_rooms_update_refused_is_audited_with_its_target(audit_events): + """A refused update names the room it was aimed at.""" + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.MEMBER)]) + token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + {"access_level": RoomAccessLevel.RESTRICTED}, + format="json", + ) + + assert response.status_code == 403 + + [event] = find_events(audit_events, "room.update") + + assert event["event"]["reason"] == "permission_denied" + assert event["lasuite"]["outcome"] == "denied" + assert event["lasuite"]["target"]["id"] == str(room.pk) + + +@mock.patch.object( + RoomManagement, "sync_room_metadata", side_effect=RuntimeError("LiveKit down") +) +def test_api_rooms_update_crashing_is_audited(mock_sync_room_metadata, audit_events): + """An update saved but not synced to LiveKit is recorded as a failure.""" + user = UserFactory() + room = RoomFactory( + users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.TRUSTED + ) + token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + with pytest.raises(RuntimeError): + client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + {"access_level": RoomAccessLevel.RESTRICTED}, + format="json", + ) + + mock_sync_room_metadata.assert_called_once() + [event] = find_events(audit_events, "room.update") + + assert event["event"]["reason"] == "internal_error" + assert event["lasuite"]["outcome"] == "failure" + assert event["lasuite"]["target"]["id"] == str(room.pk) + assert event["http"]["response"] == {"status_code": 500} + assert event["error"] == {"type": "builtins.RuntimeError"} + + +def test_api_rooms_list_is_audited(audit_events): + """Listing records how many rooms were visible to the user.""" + user = UserFactory() + RoomFactory(users=[(user, RoleChoices.OWNER)]) + RoomFactory(users=[(user, RoleChoices.OWNER)]) + RoomFactory() + token = generate_test_token(user, [ApplicationScope.ROOMS_LIST]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 200 + + [event] = find_events(audit_events, "room.list") + + assert event["event"]["type"] == ["access"] + assert event["lasuite"]["details"] == {"total": 2} + assert "target" not in event["lasuite"] + assert event["user"]["id"] == str(user.pk) + + +def test_api_rooms_retrieve_is_audited(audit_events): + """Reading a room is recorded as an access to that room.""" + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.OWNER)]) + token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get(f"/external-api/v1.0/rooms/{room.id}/") + + assert response.status_code == 200 + + [event] = find_events(audit_events, "room.retrieve") + + assert event["event"]["type"] == ["access"] + assert event["lasuite"]["target"]["id"] == str(room.pk) + assert event["lasuite"]["target"]["slug"] == room.slug + + +def test_api_rooms_missing_token_is_audited_as_denial(audit_events): + """An unauthenticated call is recorded under the action it attempted.""" + response = APIClient().get("/external-api/v1.0/rooms/", REMOTE_ADDR="1.2.3.4") + + assert response.status_code == 401 + [event] = find_events(audit_events, "room.list") + + assert event["event"]["category"] == ["authentication"] + assert event["event"]["type"] == ["access", "denied"] + assert event["event"]["reason"] == "authentication_failed" + assert event["lasuite"]["outcome"] == "denied" + assert event["lasuite"]["actor"] == {"type": "anonymous"} + assert "details" not in event["lasuite"] + assert event["http"]["response"] == {"status_code": 401} + assert event["client"]["ip"] == "1.2.3.4" + assert event["url"]["path"] == "/external-api/v1.0/rooms/" + + +def test_api_rooms_missing_scope_is_audited_as_denial(audit_events): + """A token without the required scope is a permission denial by the application.""" + user = UserFactory() + token = generate_test_token(user, [ApplicationScope.ROOMS_LIST]) + application = Application.objects.get() + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.post("/external-api/v1.0/rooms/", {}, format="json") + + assert response.status_code == 403 + [event] = find_events(audit_events, "room.create") + + assert event["event"]["type"] == ["creation", "denied"] + assert event["event"]["reason"] == "permission_denied" + assert event["lasuite"]["actor"] == {"type": "application"} + assert event["lasuite"]["auth"] == {"method": "application_jwt"} + assert event["lasuite"]["application"] == {"client_id": str(application.client_id)} + assert event["user"]["id"] == str(user.pk) + assert event["http"]["response"] == {"status_code": 403} + assert "Required scope" in event["error"]["message"] + assert "target" not in event["lasuite"] + + +def test_api_rooms_addons_token_is_audited_as_user(audit_events): + """An add-on token has no application: the actor is the user.""" + user = UserFactory(email="jean-neige@winterfell.com") + RoomFactory(users=[(user, RoleChoices.OWNER)]) + token = generate_addons_test_token(user, [ApplicationScope.ROOMS_LIST]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 200 + [event] = find_events(audit_events, "room.list") + + assert event["lasuite"]["actor"] == {"type": "user"} + assert event["lasuite"]["auth"] == {"method": "addons_jwt"} + assert "application" not in event["lasuite"] + assert event["organization"] == {"id": "winterfell.com"} diff --git a/src/backend/core/tests/test_external_api_token.py b/src/backend/core/tests/test_external_api_token.py index d0505c53..84d4a6bc 100644 --- a/src/backend/core/tests/test_external_api_token.py +++ b/src/backend/core/tests/test_external_api_token.py @@ -4,6 +4,7 @@ Tests for external API /token endpoint # pylint: disable=W0621 +import json from unittest import mock from urllib.parse import urlencode @@ -12,6 +13,7 @@ import pytest from freezegun import freeze_time from rest_framework.test import APIClient +from core.audit.testing import find_events from core.factories import ( ApplicationDomainFactory, ApplicationFactory, @@ -674,3 +676,223 @@ def test_api_applications_token_new_user_race_condition_unrecoverable( assert response.status_code == 409 assert mock_get_or_create.call_count == 1 + + +def _application(**kwargs): + """Create an application whose plain secret is ``test-secret-123``.""" + kwargs.setdefault("is_active", True) + application = ApplicationFactory(**kwargs) + application.client_secret = "test-secret-123" + application.save() + return application + + +def _post_token(client_id, client_secret, scope, **extra): + """Post a client-credentials token request.""" + return APIClient().post( + "/external-api/v1.0/application/token/", + { + "client_id": client_id, + "client_secret": client_secret, + "grant_type": "client_credentials", + "scope": scope, + }, + format="json", + **extra, + ) + + +def test_api_applications_generate_token_success_is_audited(audit_events, settings): + """An issued token records the application, the delegated user and scopes.""" + user = UserFactory(email="jean-neige@winterfell.com") + application = _application(scopes=[ApplicationScope.ROOMS_LIST]) + + response = _post_token( + application.client_id, + "test-secret-123", + "jean-neige@winterfell.com", + REMOTE_ADDR="1.2.3.4", + ) + + assert response.status_code == 200 + [event] = find_events(audit_events, "application.token.issue") + + assert event["event"]["category"] == ["authentication"] + assert event["event"]["type"] == ["start"] + assert event["event"]["outcome"] == "success" + assert event["lasuite"]["actor"] == {"type": "application"} + assert event["lasuite"]["auth"] == {"method": "client_credentials"} + assert event["lasuite"]["application"] == {"client_id": application.client_id} + assert event["user"] == { + "id": str(user.pk), + "sub": user.sub, + "domain": "winterfell.com", + } + assert event["organization"] == {"id": application.client_id} + assert event["lasuite"]["target"] == { + "type": "application", + "id": str(application.pk), + "client_id": application.client_id, + "name": application.name, + "is_active": True, + "scopes": ["rooms:list"], + } + assert event["lasuite"]["details"] == { + "scopes": ["rooms:list"], + "user_provisioned": False, + "expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS, + } + assert event["client"]["ip"] == "1.2.3.4" + assert event["url"]["path"] == "/external-api/v1.0/application/token/" + assert event["trace"]["id"] == response["X-Request-ID"] + assert "jean-neige@winterfell.com" not in json.dumps(event) + + +def test_api_applications_generate_token_wrong_secret_is_audited(audit_events): + """A wrong secret is a denial: the submitted client id is only a claim.""" + UserFactory(email="jean-neige@winterfell.com") + application = _application() + + response = _post_token(application.client_id, "wrong-secret", "user@example.com") + + assert response.status_code == 401 + + [event] = find_events(audit_events, "application.token.issue") + + assert event["event"]["category"] == ["authentication"] + assert event["event"]["type"] == ["start", "denied"] + assert event["event"]["reason"] == "authentication_failed" + assert event["lasuite"]["outcome"] == "denied" + assert event["lasuite"]["actor"] == {"type": "anonymous"} + assert event["lasuite"]["auth"] == {"method": "client_credentials"} + assert "application" not in event["lasuite"] + assert "organization" not in event + assert event["lasuite"]["details"] == { + "requested_domain": "example.com", + "claimed_client_id": application.client_id, + } + assert "target" not in event["lasuite"] + assert event["http"]["response"] == {"status_code": 401} + assert event["error"] == {"message": "Invalid credentials"} + assert event["log"]["level"] == "warning" + assert "jean-neige@winterfell.com" not in json.dumps(event) + + +def test_api_applications_generate_token_unknown_client_is_audited(audit_events): + """An unknown client id is still recorded, so brute force is visible.""" + response = _post_token("does-not-exist", "whatever", "jean-neige@winterfell.com") + + assert response.status_code == 401 + + [event] = find_events(audit_events, "application.token.issue") + + assert event["event"]["reason"] == "authentication_failed" + assert event["lasuite"]["details"]["claimed_client_id"] == "does-not-exist" + assert "application" not in event["lasuite"] + assert "organization" not in event + + +def test_api_applications_generate_token_inactive_application_is_audited( + audit_events, +): + """A disabled application is refused with an explicit message.""" + UserFactory(email="jean-neige@winterfell.com") + application = _application(is_active=False) + + response = _post_token( + application.client_id, "test-secret-123", "jean-neige@winterfell.com" + ) + + assert response.status_code == 401 + [event] = find_events(audit_events, "application.token.issue") + + assert event["event"]["reason"] == "authentication_failed" + assert event["error"] == {"message": "Application is inactive"} + + +def test_api_applications_generate_token_domain_denied_is_audited(audit_events): + """Delegating outside the allowed domains is a permission denial.""" + UserFactory(email="user@random.com") + application = _application() + ApplicationDomainFactory(application=application, domain="allowed.com") + + response = _post_token(application.client_id, "test-secret-123", "user@random.com") + + assert response.status_code == 403 + + [event] = find_events(audit_events, "application.token.issue") + + assert event["event"]["reason"] == "permission_denied" + assert event["lasuite"]["actor"] == {"type": "application"} + assert event["lasuite"]["target"]["id"] == str(application.pk) + assert event["lasuite"]["details"] == {"requested_domain": "random.com"} + assert event["http"]["response"] == {"status_code": 403} + + +def test_api_applications_generate_token_invalid_email_is_audited(audit_events): + """An invalid scope is a validation failure by an authenticated application.""" + application = _application() + + response = _post_token(application.client_id, "test-secret-123", "not-an-email") + + assert response.status_code == 400 + + [event] = find_events(audit_events, "application.token.issue") + + assert event["event"]["reason"] == "validation_error" + assert event["lasuite"]["actor"] == {"type": "application"} + assert event["http"]["response"] == {"status_code": 400} + assert "details" not in event["lasuite"] + + +def test_api_applications_generate_token_unknown_user_is_audited(audit_events): + """An unknown user with provisioning disabled is a not-found denial.""" + application = _application() + + response = _post_token( + application.client_id, "test-secret-123", "nobody@example.com" + ) + + assert response.status_code == 404 + + [event] = find_events(audit_events, "application.token.issue") + + assert event["event"]["reason"] == "not_found" + assert event["lasuite"]["details"] == {"requested_domain": "example.com"} + assert event["http"]["response"] == {"status_code": 404} + + +def test_api_applications_generate_token_provisioning_is_audited( + audit_events, settings +): + """Provisioning a user is its own event, correlated with the token issue.""" + settings.APPLICATION_ALLOW_USER_CREATION = True + settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True + settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False + application = _application(scopes=[ApplicationScope.ROOMS_LIST]) + + response = _post_token( + application.client_id, "test-secret-123", "new.user@example.com" + ) + + assert response.status_code == 200 + + user = User.objects.get(email="new.user@example.com") + [provision] = find_events(audit_events, "user.provision") + + assert provision["event"]["category"] == ["iam"] + assert provision["event"]["type"] == ["user", "creation"] + assert provision["lasuite"]["actor"] == {"type": "application"} + assert provision["lasuite"]["application"] == {"client_id": application.client_id} + assert provision["lasuite"]["target"] == { + "type": "user", + "id": str(user.pk), + "domain": "example.com", + } + assert provision["trace"]["id"] == response["X-Request-ID"] + assert provision["url"]["path"] == "/external-api/v1.0/application/token/" + assert "new.user@example.com" not in json.dumps(provision) + + [issue] = find_events(audit_events, "application.token.issue") + assert issue["lasuite"]["details"]["user_provisioned"] is True + assert issue["user"]["id"] == str(user.pk)