mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-19 19:21:47 +00:00
✨(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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user