✨(backend) audit Menshen calls and users created from tokens

Register MenshenAuthentication as the menshen_token auth method to record
room operations made with a token exchanged through Menshen as an authentication
acting for its user
This commit is contained in:
briquet
2026-10-19 12:56:06 +02:00
committed by briquet
parent ed8799ba06
commit 93115f05de
6 changed files with 208 additions and 14 deletions
+1
View File
@@ -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
+11 -5
View File
@@ -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 |
+2
View File
@@ -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")
@@ -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")
)
@@ -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"
@@ -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"}