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 941dda9ff..eb10875d0 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( diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py index d61d095fa..37d190b98 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,20 @@ 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_actor = None + 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 +69,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 +85,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 +98,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 +106,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 +135,43 @@ 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. Either + way, a session the request carries never makes its account the actor. + """ + 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 +194,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 +229,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, @@ -231,6 +262,7 @@ class RoomViewSet( def perform_create(self, serializer: serializers.RoomSerializer): """Set the current user as owner of the newly created room.""" room = serializer.save() + self.audit_target = room models.ResourceAccess.objects.create( resource=room, user=self.request.user, diff --git a/src/backend/core/services/provisional_user_service.py b/src/backend/core/services/provisional_user_service.py index dbe5e4f36..22ea9a55f 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,21 @@ 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=None, + 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 +97,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=audit.exception_type(e), + ) if user: return user, False @@ -108,3 +114,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..957a3071e --- /dev/null +++ b/src/backend/core/tests/services/test_provisional_user_service.py @@ -0,0 +1,109 @@ +"""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"} + # A provisional user has no sub yet + assert event["entity"]["target"] == { + "id": str(user.pk), + "type": ["user"], + "sub_type": "user", + } + 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": "django.core.exceptions.ValidationError"} + assert event["lasuite"]["application"] == {"client_id": "my-client"} + assert event["entity"]["target"] == { + "id": str(existing_user.pk), + "type": ["user"], + "sub_type": "user", + } + 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": "django.db.utils.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..243d35af9 100644 --- a/src/backend/core/tests/test_external_api_rooms.py +++ b/src/backend/core/tests/test_external_api_rooms.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta, timezone from unittest import mock from django.conf import settings +from django.db import DatabaseError import jwt import pytest @@ -17,10 +18,12 @@ 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, ApplicationScope, + ResourceAccess, RoleChoices, Room, RoomAccessLevel, @@ -2375,3 +2378,297 @@ 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", "sub": user.sub} + assert event["lasuite"]["auth"] == {"method": "application_jwt"} + assert event["lasuite"]["application"] == {"client_id": str(application.client_id)} + assert event["user"] == {"id": str(user.pk), "domain": "winterfell.com"} + assert event["organization"] == {"id": str(application.client_id)} + assert event["entity"]["target"] == { + "id": str(room.pk), + "sub_type": "room", + "name": room.name, + "raw": {"slug": room.slug, "access_level": "trusted"}, + } + assert event["client"]["ip"] == "1.2.3.4" + assert event["http"]["request"]["method"] == "POST" + assert event["http"]["response"] == {"status_code": 201} + assert event["url"]["path"] == "/external-api/v1.0/rooms/" + assert event["http"]["request"]["id"] == response["X-Request-ID"] + assert "jean-neige@winterfell.com" not in str(event) + + +@mock.patch.object(ResourceAccess.objects, "create", side_effect=DatabaseError("boom")) +def test_api_rooms_create_crashing_is_audited(mock_create_access, audit_events): + """A room saved without its owner's access is recorded as a failure on it.""" + user = UserFactory() + token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + with pytest.raises(DatabaseError): + client.post("/external-api/v1.0/rooms/", {}, format="json") + + mock_create_access.assert_called_once() + room = Room.objects.get() + [event] = find_events(audit_events, "room.create") + + assert event["event"]["reason"] == "internal_error" + assert event["lasuite"]["outcome"] == "failure" + assert event["entity"]["target"]["id"] == str(room.pk) + assert event["http"]["response"] == {"status_code": 500} + assert event["error"] == {"type": "django.db.utils.DatabaseError"} + + +@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["entity"]["target"]["id"] == str(room.pk) + assert event["entity"]["target"]["raw"]["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["entity"]["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["entity"]["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 "entity" not in event + 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["entity"]["target"]["id"] == str(room.pk) + assert event["entity"]["target"]["raw"]["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") + + # Still an API call, also filed as a failed authentication + assert event["event"]["category"] == ["api", "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", "sub": user.sub} + 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 "entity" not in event + + +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", "sub": user.sub} + 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", "sub": user.sub} + 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..c1344e6e6 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,226 @@ 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", "sub": user.sub} + assert event["lasuite"]["auth"] == {"method": "client_credentials"} + 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} + assert event["entity"]["target"] == { + "id": str(application.pk), + "type": ["application"], + "sub_type": "application", + "name": application.name, + "raw": { + "client_id": application.client_id, + "is_active": True, + "scopes": ["rooms:list"], + }, + } + assert event["http"]["response"] == {"status_code": 200} + 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["http"]["request"]["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"] + # ECS expects no ``denied`` type for an authentication + assert event["event"]["type"] == ["start"] + 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["entity"]["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["entity"]["target"] == { + "id": str(user.pk), + "type": ["user"], + "sub_type": "user", + } + assert provision["http"]["request"]["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)