mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-06 13:31:48 +00:00
✨(backend) audit external API token and room operations
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
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,16 @@ 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,
|
||||
auth_method="client_credentials",
|
||||
client_id=client_id,
|
||||
)
|
||||
|
||||
scope = " ".join(application.scopes or [])
|
||||
|
||||
token_service = JwtTokenService(
|
||||
@@ -134,13 +144,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 +202,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 +237,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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user