From 5151ac73b1b9c66e562bfe0f7d720c1dd1870f20 Mon Sep 17 00:00:00 2001 From: briquet Date: Sun, 4 Oct 2026 21:08:42 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20audit=20external=20API=20t?= =?UTF-8?q?oken=20and=20room=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit audit events on the most exposed authentication surface: every request to the client-credentials token endpoint, issued or refused, as application.token.issue; provisional user creation; and room create, update, retrieve and list through the delegated rooms API. Both viewsets use AuditViewMixin, so refusals are recorded under the action that was attempted, with their outcome and reason Events identify the application by client id, once its credentials are verified, and the delegated user by id, OIDC sub and email domain --- CHANGELOG.md | 1 + src/backend/core/auditing.py | 18 ++ src/backend/core/external_api/viewsets.py | 79 +++-- .../core/services/provisional_user_service.py | 44 +-- .../services/test_provisional_user_service.py | 108 +++++++ .../core/tests/test_external_api_rooms.py | 276 ++++++++++++++++++ .../core/tests/test_external_api_token.py | 224 ++++++++++++++ 7 files changed, 707 insertions(+), 43 deletions(-) create mode 100644 src/backend/core/tests/services/test_provisional_user_service.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d429e8394..d76354e8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to - ✅(frontend) add vitest so the frontend can carry unit tests - ♿️(frontend) make participant pagination readable and keyboard reachable #1775 - ✨(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 7dff995af..cc4d3b3b3 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 + +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 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 d61d095fa..ed5c4f899 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: @@ -134,13 +134,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 +192,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 +227,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 dbe5e4f36..6b39bcb6a 100644 --- a/src/backend/core/services/provisional_user_service.py +++ b/src/backend/core/services/provisional_user_service.py @@ -1,14 +1,10 @@ """Service for provisional user creation.""" -import logging - from django.conf import settings from django.core.exceptions import SuspiciousOperation, ValidationError from django.db import IntegrityError -from core import models - -logger = logging.getLogger(__name__) +from core import audit, auditing, models class ProvisionalUserError(Exception): @@ -54,6 +50,20 @@ class ProvisionalUserService: "Multiple user accounts share a common email." ) from e + def _audit(self, client_id: str, **fields) -> None: + """Emit a ``user.provision`` event on behalf of the application. + + The user is identified by its id since it has no `sub` yet, therefore + the user id is what correlate this event to the ones of its later sign-ins. + """ + audit.log( + auditing.USER_PROVISION, + actor_type=audit.ActorType.APPLICATION, + auth_method="client_credentials", + client_id=client_id, + **fields, + ) + def get_or_create( self, email: str, client_id: str ) -> tuple[models.User | None, bool]: @@ -86,21 +96,16 @@ class ProvisionalUserService: user = models.User(sub=None, email=email) user.set_unusable_password() user.save() - logger.info( - "Provisional user created via application: user_id=%s, email=%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, - ) + # Race condition on provisional user creation user = self._get_by_email(email) + self._audit( + client_id, + outcome=audit.Outcome.FAILURE, + reason=audit.Reason.CONFLICT, + target=user, + error_type=type(e).__name__, + ) if user: return user, False @@ -108,3 +113,6 @@ class ProvisionalUserService: raise ProvisionalUserIntegrityError( "Failed to create or retrieve provisional user." ) from e + + self._audit(client_id, target=user) + return user, True diff --git a/src/backend/core/tests/services/test_provisional_user_service.py b/src/backend/core/tests/services/test_provisional_user_service.py new file mode 100644 index 000000000..16fb17cc2 --- /dev/null +++ b/src/backend/core/tests/services/test_provisional_user_service.py @@ -0,0 +1,108 @@ +"""Test provisional user service.""" + +# pylint: disable=W0621,W0613 + +import json +from unittest import mock + +from django.db import IntegrityError + +import pytest + +from core.audit.testing import find_events +from core.factories import UserFactory +from core.models import User +from core.services.provisional_user_service import ( + ProvisionalUserIntegrityError, + ProvisionalUserService, +) + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def allow_provisioning(settings): + """Enable provisional user creation.""" + settings.APPLICATION_ALLOW_USER_CREATION = True + settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True + settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False + + +def test_get_or_create_existing_user_is_not_audited(audit_events): + """Finding an existing user provisions nothing, so it emits no event.""" + user = UserFactory(email="john.doe@example.com") + + assert ProvisionalUserService().get_or_create( + "John.Doe@example.com", "my-client" + ) == (user, False) + assert not find_events(audit_events, "user.provision") + + +def test_get_or_create_creation_is_audited(audit_events): + """A created user is identified by its id, without sub nor email.""" + user, created = ProvisionalUserService().get_or_create( + "john.doe@example.com", "my-client" + ) + + assert created is True + assert user.sub is None + + [event] = find_events(audit_events, "user.provision") + + assert event["log"] == {"level": "info"} + assert event["event"]["outcome"] == "success" + assert event["event"]["type"] == ["user", "creation"] + assert event["lasuite"]["actor"] == {"type": "application"} + assert event["lasuite"]["auth"] == {"method": "client_credentials"} + assert event["lasuite"]["application"] == {"client_id": "my-client"} + assert event["lasuite"]["target"] == { + "type": "user", + "id": str(user.pk), + "domain": "example.com", + } + assert "john.doe" not in json.dumps(event) + + +@mock.patch.object(ProvisionalUserService, "_get_by_email") +def test_get_or_create_lost_race_is_audited_with_the_existing_user( + mock_get_by_email, audit_events +): + """A lost race reports the user the concurrent request created.""" + existing_user = UserFactory(sub=None, email="john.doe@example.com") + mock_get_by_email.side_effect = [None, existing_user] + + assert ProvisionalUserService().get_or_create( + "john.doe@example.com", "my-client" + ) == (existing_user, False) + assert User.objects.filter(email="john.doe@example.com").count() == 1 + + [event] = find_events(audit_events, "user.provision") + + assert event["log"] == {"level": "warning"} + assert event["event"]["outcome"] == "failure" + assert event["event"]["reason"] == "conflict" + assert event["error"] == {"type": "ValidationError"} + assert event["lasuite"]["application"] == {"client_id": "my-client"} + assert event["lasuite"]["target"] == { + "type": "user", + "id": str(existing_user.pk), + "domain": "example.com", + } + assert "john.doe" not in json.dumps(event) + + +@mock.patch.object(User, "save", side_effect=IntegrityError) +@mock.patch.object(ProvisionalUserService, "_get_by_email", return_value=None) +def test_get_or_create_unrecoverable_conflict_is_audited( + mock_get_by_email, mock_save, audit_events +): + """A conflict without any user to fall back on is audited without target.""" + with pytest.raises(ProvisionalUserIntegrityError): + ProvisionalUserService().get_or_create("john.doe@example.com", "my-client") + + [event] = find_events(audit_events, "user.provision") + + assert event["event"]["outcome"] == "failure" + assert event["event"]["reason"] == "conflict" + assert event["error"] == {"type": "IntegrityError"} + assert "target" not in event["lasuite"] diff --git a/src/backend/core/tests/test_external_api_rooms.py b/src/backend/core/tests/test_external_api_rooms.py index 842f9f8e1..8ebb0bb49 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,278 @@ 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"} + + +@responses.activate +def test_api_rooms_resource_server_is_audited_as_application(audit_events, settings): + """A La Suite application calling through the resource server acts for the user. + + The application is the client the introspected token was issued to. + """ + user = UserFactory(sub="very-specific-sub") + + settings.OIDC_RS_CLIENT_ID = "some_client_id" + settings.OIDC_RS_CLIENT_SECRET = "some_client_secret" + settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet" + + settings.OIDC_OP_URL = "https://oidc.example.com" + settings.OIDC_VERIFY_SSL = False + settings.OIDC_TIMEOUT = 5 + settings.OIDC_PROXY = None + settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks" + settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect" + + responses.add( + responses.POST, + "https://oidc.example.com/introspect", + json={ + "iss": "https://oidc.example.com", + "aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID + "sub": "very-specific-sub", + "client_id": "some_service_provider", + "scope": "openid lasuite_meet lasuite_meet:rooms:list", + "active": True, + }, + ) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Bearer some_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": "application"} + assert event["lasuite"]["auth"] == {"method": "resource_server"} + assert event["lasuite"]["application"] == {"client_id": "some_service_provider"} + assert event["user"]["id"] == str(user.pk) diff --git a/src/backend/core/tests/test_external_api_token.py b/src/backend/core/tests/test_external_api_token.py index d0505c53c..75eb299c8 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,225 @@ 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"} + # Same mechanism as the token issue it belongs to + assert provision["lasuite"]["auth"] == {"method": "client_credentials"} + 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)