diff --git a/CHANGELOG.md b/CHANGELOG.md index c8f1f2db4..cc6ffb2ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to - ✨(backend) add room soft-deletion to the external API - ✨(all) allow any authenticated user to start a recording - ✨(backend) authenticate external API calls with Menshen exchanged tokens +- ✨(backend) audit Menshen calls and users created from introspected tokens ## [1.35.0] - 2026-10-13 diff --git a/docs/features/audit-logging.md b/docs/features/audit-logging.md index 79342feb6..bfbfd5be3 100644 --- a/docs/features/audit-logging.md +++ b/docs/features/audit-logging.md @@ -98,7 +98,7 @@ find each one in the JSON above. |---|---|---| | **Actor** | Who is behind an Action. It always has an Actor Type. When a person's account is involved, the event also carries the account id, its OIDC `sub` when it has one, and its email domain. | `lasuite.actor.*`, `user.*` | | **Actor Type** | What kind of Actor it is: `user`, `application`, `service`, `system` or `anonymous`. See [Actors](#actors). Device accounts are `user`s like any other account. | `lasuite.actor.type` | -| **Auth Method** | How the Actor proved who it is for this request: `session`, `oidc`, `password`, `application_jwt`, `client_credentials`, `addons_jwt`, `user_access_jwt`, `transit_code`, `resource_server`, `livekit_token`, `shared_secret`, `none` when it did not authenticate, or `unknown`. | `lasuite.auth.method` | +| **Auth Method** | How the Actor proved who it is for this request: `session`, `oidc`, `password`, `application_jwt`, `client_credentials`, `addons_jwt`, `user_access_jwt`, `transit_code`, `resource_server`, `menshen_token`, `livekit_token`, `shared_secret`, `none` when it did not authenticate, or `unknown`. | `lasuite.auth.method` | | **Application** | A registered third-party integration, identified by its client id, that calls the external API on behalf of users. | `lasuite.application.client_id` | | **Delegation** | An Application acting for one specific user, through the delegated token it obtained with `application.token.issue`. The event then names both: the Application as the Actor, and the user in `user.*`. | `lasuite.actor.type` = `application`, with `user.*` set | | **Provisional User** | An account an Application created from an email address before that person ever signed in. It has no OIDC `sub` yet, so its account id is what links its `user.provision` event to its later `user.login`. | `event.action` = `user.provision` | @@ -191,8 +191,8 @@ what ECS does not define: `lasuite.*`, and `entity.target.raw.*` for the fields | `lasuite.outcome` | `success`, `failure`, `denied` or `unknown`: the same as `event.outcome`, with refusals kept apart | | `lasuite.actor.type` | `user`, `application`, `service`, `system` or `anonymous`, see [Actors](#actors) | | `lasuite.actor.sub` | The OIDC `sub` of the account in `user.*`, when it has one: the identity it shares with the other La Suite products | -| `lasuite.auth.method` | `session`, `application_jwt`, `addons_jwt`, `user_access_jwt`, `resource_server`, `livekit_token`, `shared_secret`, `client_credentials`, `transit_code`, `oidc`, `password`, `none`, or `unknown` for an authentication class that is not registered. Requests served outside DRF, such as the admin and logout, report `session` when signed in | -| `lasuite.application.client_id` | The external application acting, if any. Only set once its credentials are verified | +| `lasuite.auth.method` | `session`, `application_jwt`, `addons_jwt`, `user_access_jwt`, `resource_server`, `menshen_token`, `livekit_token`, `shared_secret`, `client_credentials`, `transit_code`, `oidc`, `password`, `none`, or `unknown` for an authentication class that is not registered. Requests served outside DRF, such as the admin and logout, report `session` when signed in | +| `lasuite.application.client_id` | The external application acting, if any. Only set once its credentials are verified. For a Menshen token, Menshen's own client id (see [Actors](#actors)) | | `user.id`, `user.domain` | The account whose authority the action used (see [Actors](#actors)): its primary key and email domain. The email address itself is never recorded | | `user.roles` | That account's privileges when the event happened: `superuser`, `staff` | | `user.target.id`, `user.target.domain` | The account the action was about: the target when it is a user, or the user an access grants a role to. `user.*` stays the actor | @@ -229,7 +229,7 @@ an application acts for a user, the actor is the application, but `user.*` is th | `lasuite.actor.type` | Who | `user.*` | |---|---|---| | `user` | A person's account acting for itself: through a session, an OIDC or password login, an add-on token, or the LiveKit token of a known account | That account | -| `application` | A client application acting on behalf of a user. Either a Meet application, through its client credentials, its delegated token or the user access token of its embedded frontend, or another La Suite application, through the resource server. `lasuite.application.client_id` names it | The delegating user | +| `application` | A client application acting on behalf of a user. Either a Meet application, through its client credentials, its delegated token or the user access token of its embedded frontend, or another La Suite application, through the resource server or a token exchanged with Menshen. `lasuite.application.client_id` names it | The delegating user | | `service` | An internal service of the deployment, named by `service.origin.name`: the LiveKit SIP bridge (`roomkit`), the summary service (`summary`), or the LiveKit server reporting on a recording (`livekit`) | Absent | | `system` | The backend itself, with no inbound request | Absent | | `anonymous` | A caller that did not authenticate, or failed to | Absent | @@ -242,6 +242,12 @@ The backend picks the actor type by applying these rules in order: 4. The caller is an account: `user`. 5. Anything else: `anonymous`. +A La Suite application calling the external API with a token it exchanged through Menshen, La Suite's token exchange +server, is recorded as an `application`, with `lasuite.auth.method` `menshen_token`. Menshen reports itself as the client +the token was issued to, not the application that exchanged it, so `lasuite.application.client_id` and `organization.id` +are Menshen's client id for every such call. The token is never recorded, nor its `jti`, which for an opaque token +Menshen takes from the token itself, nor the email Menshen reports. + ### Classification `event.category` and `event.type` are only ever combined in the ways the ECS 9.5.0 `expected_event_types` allow: @@ -266,7 +272,7 @@ is told by `event.outcome`, `lasuite.outcome` and `event.reason`, never by the n | `event.action` | Emitted when | Notable fields | |---|---|---| | `application.token.issue` | An application asks for a delegated token (`POST /external-api/v1.0/application/token/`), whether it gets one or is refused. It can be refused for bad credentials, an inactive application, an invalid or unauthorized email domain, an unknown user, or a provisioning conflict | On success: `user.*` is the delegated user, `entity.target` the application, and `lasuite.details` holds `scopes`, `user_provisioned` and `expires_in`. On refusal: `event.reason`, `http.response.status_code` and `lasuite.details.requested_domain`. Until its credentials are verified, the client id the caller submitted is only recorded as `lasuite.details.claimed_client_id`: it never sets `lasuite.application` or `organization` | -| `user.provision` | An application creates a provisional user from an email address, or fails to. It is a `failure` with reason `conflict` when a concurrent request created the user first | `user.target` and `entity.target` are the user, the existing one on a conflict. A provisional user has no `sub` yet: its id links this event to the winning one and to its later `user.login` | +| `user.provision` | An application creates a provisional user from an email address, or fails to. It is a `failure` with reason `conflict` when a concurrent request created the user first. Also emitted when a La Suite application calls the external API, through the resource server or with a Menshen token, for a `sub` no account has and `OIDC_CREATE_USER` lets the backend create one | `user.target` and `entity.target` are the user, the existing one on a conflict. `lasuite.auth.method` is `client_credentials`, `resource_server` or `menshen_token`. A provisional user has no `sub` yet: its id links this event to the winning one and to its later `user.login`. On a conflict over an email, `lasuite.details.requested_sub` is the `sub` no account was created for | | `transit_code.issue` | An application mints a single-use transit code for its delegated user | `entity.target` is the code, whose `id` is its SHA-256. `user.target` is the user it is minted for. `lasuite.details.expires_in` | | `transit_code.exchange` | An embedded frontend exchanges a transit code for a user access token | `event.category` is `authentication`, `lasuite.auth.method` is `transit_code`, and `entity.target` is the code's SHA-256. Once the code verifies, even if the exchange is then refused: `lasuite.actor.type` is `application` (the one that minted it), and `user.target` the user it was minted for. On success, `user.*` is that user too, with `lasuite.details.expires_in`. A code that does not verify is recorded as an `anonymous` failed authentication | diff --git a/src/backend/core/audit_registry.py b/src/backend/core/audit_registry.py index 95a7003a1..b6ec616cc 100644 --- a/src/backend/core/audit_registry.py +++ b/src/backend/core/audit_registry.py @@ -16,6 +16,7 @@ from core.authentication.user_token import UserAccessJWTAuthentication from core.external_api.authentication import ( AddonsJWTAuthentication, ApplicationJWTAuthentication, + MenshenAuthentication, ) from core.recording.event.authentication import HeaderBasedAuthentication from core.roomkit.authentication import ServerToServerAuthentication @@ -76,6 +77,7 @@ audit.register_auth_method(OIDCAuthenticationBackend, "oidc") audit.register_auth_method(ApplicationJWTAuthentication, "application_jwt") audit.register_auth_method(AddonsJWTAuthentication, "addons_jwt") audit.register_auth_method(ResourceServerAuthentication, "resource_server") +audit.register_auth_method(MenshenAuthentication, "menshen_token") audit.register_auth_method(LiveKitTokenAuthentication, "livekit_token") audit.register_auth_method(UserAccessJWTAuthentication, "user_access_jwt") audit.register_auth_method(HeaderBasedAuthentication, "shared_secret") diff --git a/src/backend/core/external_api/authentication.py b/src/backend/core/external_api/authentication.py index cf512f6c2..5aa32d0a2 100644 --- a/src/backend/core/external_api/authentication.py +++ b/src/backend/core/external_api/authentication.py @@ -17,6 +17,7 @@ from menshen_client import Configuration, IntrospectionRequest, TokenExchangeCli from menshen_client.exceptions import ResponseParsingError from rest_framework import authentication, exceptions +from core import audit, auditing from core.models import Application, ApplicationScope from core.services import jwt_token from core.validators import sub_validator @@ -56,7 +57,7 @@ def get_token(request, token_type): raise exceptions.AuthenticationFailed("Invalid token encoding.") from e -def get_or_create_user_by_sub(sub, email=None): +def get_or_create_user_by_sub(sub, email=None, *, auth_method, client_id=None): """Retrieve the user matching a token subject, creating it if allowed. Shared by the resource server backends, so that tokens introspected with the @@ -65,6 +66,8 @@ def get_or_create_user_by_sub(sub, email=None): Args: sub: Subject identifier from the introspected token email: Email to store on a newly created user + auth_method: How the token was validated, for the audit log + client_id: Client the token was issued to, for the audit log Returns: User instance, or None if unknown and OIDC_CREATE_USER is unset @@ -90,12 +93,23 @@ def get_or_create_user_by_sub(sub, email=None): # Users are never identified by email here. But a second account with the # same email (e.g. provisionnal account) would break the lookups by email: # refuse it. - if ( - settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION - and email - and User.objects.filter(email__iexact=email).exists() - ): + existing_user = ( + User.objects.filter(email__iexact=email).first() + if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and email + else None + ) + if existing_user is not None: logger.warning("Email already used, not creating user: %s", sub) + audit.log( + auditing.USER_PROVISION, + outcome=audit.Outcome.FAILURE, + reason=audit.Reason.CONFLICT, + actor=None, + auth_method=auth_method, + client_id=client_id, + target=existing_user, + requested_sub=sub, + ) raise SuspiciousOperation( "Email already used by another account." ) from None @@ -103,6 +117,13 @@ def get_or_create_user_by_sub(sub, email=None): user = User(sub=sub, email=email) user.set_unusable_password() user.save() + audit.log( + auditing.USER_PROVISION, + actor=None, + auth_method=auth_method, + client_id=client_id, + target=user, + ) if not user.is_active: logger.warning("Inactive user attempted authentication: %s", user.pk) @@ -385,7 +406,7 @@ class MenshenAuthentication(authentication.BaseAuthentication): logger.warning("Menshen introspection failed: %s", e) return None - # Permission classes expect a dict payload in request.auth + # Permission classes expect a dict payload in request.auth. payload = asdict(response) if not payload.get("active"): @@ -427,7 +448,12 @@ class MenshenAuthentication(authentication.BaseAuthentication): raise exceptions.AuthenticationFailed("Invalid token claims.") try: - user = get_or_create_user_by_sub(sub, email=payload.get("email")) + user = get_or_create_user_by_sub( + sub, + email=payload.get("email"), + auth_method="menshen_token", + client_id=payload.get("client_id"), + ) except SuspiciousOperation as e: raise exceptions.AuthenticationFailed(str(e)) from e @@ -471,4 +497,6 @@ class ResourceServerBackend(LaSuiteBackend): logger.debug(message) raise SuspiciousOperation(message) - return get_or_create_user_by_sub(sub) + return get_or_create_user_by_sub( + sub, auth_method="resource_server", client_id=payload.get("client_id") + ) diff --git a/src/backend/core/tests/test_external_api_menshen.py b/src/backend/core/tests/test_external_api_menshen.py index 6d80109e1..9f731aafc 100644 --- a/src/backend/core/tests/test_external_api_menshen.py +++ b/src/backend/core/tests/test_external_api_menshen.py @@ -7,6 +7,7 @@ import responses from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication from rest_framework.test import APIClient +from core.audit.testing import find_events from core.factories import UserFactory from core.models import RoleChoices, Room, User @@ -302,3 +303,112 @@ def test_menshen_unknown_user_email_already_used(settings, existing_sub): existing_user.refresh_from_db() assert existing_user.sub == existing_sub assert not Room.objects.exists() + + +@responses.activate +def test_menshen_is_audited_as_application(audit_events): + """A call with a Menshen token is made by the client Menshen reports, acting for + the user. Neither the token, nor the jti Menshen derives from an opaque token, + nor the email ever appear.""" + + user = UserFactory(email="user@example.com") + + responses.add( + responses.POST, + INTROSPECTION_ENDPOINT, + json=_introspection(user.sub, email="user@example.com", jti=TOKEN[:50]), + ) + + response = _create_room() + + assert response.status_code == 201 + [event] = find_events(audit_events, "room.create") + + assert event["lasuite"]["actor"] == {"type": "application", "sub": user.sub} + assert event["lasuite"]["auth"] == {"method": "menshen_token"} + assert event["lasuite"]["application"] == {"client_id": "menshen"} + assert event["user"] == {"id": str(user.pk), "domain": "example.com"} + assert event["entity"]["target"]["id"] == response.data["id"] + assert not find_events(audit_events, "user.provision") + assert TOKEN not in str(audit_events) + assert "user@example.com" not in str(audit_events) + + +@responses.activate +def test_menshen_unknown_user_created_is_audited(audit_events, settings): + """A user created for an unknown sub is provisioned by the client Menshen reports, + before the call it makes as that user.""" + + settings.OIDC_CREATE_USER = True + + responses.add( + responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub") + ) + + response = _create_room() + + assert response.status_code == 201 + user = User.objects.get(sub="new-sub") + [provision, create] = audit_events + + assert provision["event"]["action"] == "user.provision" + assert provision["event"]["category"] == ["iam"] + assert provision["event"]["type"] == ["user", "creation"] + assert provision["lasuite"]["outcome"] == "success" + assert provision["lasuite"]["actor"] == {"type": "application"} + assert provision["lasuite"]["auth"] == {"method": "menshen_token"} + assert provision["lasuite"]["application"] == {"client_id": "menshen"} + assert provision["user"] == { + "target": {"id": str(user.pk), "domain": "example.com"} + } + assert provision["entity"]["target"] == { + "id": str(user.pk), + "type": ["user"], + "sub_type": "user", + "raw": {"sub": "new-sub"}, + } + assert provision["http"]["request"]["id"] == create["http"]["request"]["id"] + assert "user@example.com" not in str(provision) + + assert create["event"]["action"] == "room.create" + assert create["user"]["id"] == str(user.pk) + + +@responses.activate +def test_menshen_unknown_user_email_already_used_is_audited(audit_events, settings): + """A user that is not created because its email is already used is a conflict + with the existing account, recorded with the sub that was refused.""" + + settings.OIDC_CREATE_USER = True + settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True + + existing_user = UserFactory(sub=None, email="User@Example.com") + + responses.add( + responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub") + ) + + response = _create_room() + + assert response.status_code == 401 + + [provision] = find_events(audit_events, "user.provision") + + assert provision["event"]["outcome"] == "failure" + assert provision["event"]["reason"] == "conflict" + assert provision["lasuite"]["outcome"] == "failure" + assert provision["lasuite"]["actor"] == {"type": "application"} + assert provision["lasuite"]["auth"] == {"method": "menshen_token"} + assert provision["lasuite"]["application"] == {"client_id": "menshen"} + assert provision["user"] == { + "target": {"id": str(existing_user.pk), "domain": "example.com"} + } + assert provision["entity"]["target"]["id"] == str(existing_user.pk) + assert provision["lasuite"]["details"] == {"requested_sub": "new-sub"} + assert provision["log"]["level"] == "warning" + assert "user@example.com" not in str(provision).lower() + + [create] = find_events(audit_events, "room.create") + + assert create["lasuite"]["outcome"] == "denied" + assert create["event"]["reason"] == "authentication_failed" diff --git a/src/backend/core/tests/test_external_api_rooms.py b/src/backend/core/tests/test_external_api_rooms.py index e156a368b..aa9187580 100644 --- a/src/backend/core/tests/test_external_api_rooms.py +++ b/src/backend/core/tests/test_external_api_rooms.py @@ -3057,3 +3057,50 @@ def test_api_rooms_resource_server_is_audited_as_application(audit_events, setti assert event["lasuite"]["auth"] == {"method": "resource_server"} assert event["lasuite"]["application"] == {"client_id": "some_service_provider"} assert event["user"]["id"] == str(user.pk) + + +@responses.activate +def test_api_rooms_resource_server_user_creation_is_audited(audit_events, settings): + """A user created on first authentication is provisioned by the client the + introspected token was issued to.""" + settings.OIDC_CREATE_USER = True + 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", + "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 + user = User.objects.get(sub="very-specific-sub") + + [event] = find_events(audit_events, "user.provision") + + assert event["lasuite"]["outcome"] == "success" + 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"] == {"target": {"id": str(user.pk)}} + assert event["entity"]["target"]["id"] == str(user.pk) + assert event["entity"]["target"]["raw"] == {"sub": "very-specific-sub"}