Compare commits

..

4 Commits

Author SHA1 Message Date
briquet 68632d73ab 🔒️(backend) audit writes made through the Django admin
Every ModelAdmin now emits an ECS audit event when an object is created,
changed or deleted, and when a bulk action runs. Actions are templated
as admin.<target>.<verb>, after the model name. A signed-in account
without staff access reaching the admin is recorded as a denied
admin.access.

The wiring is a custom AdminSite installed through AdminConfig.default_site:
it mixes the auditing into every admin class at registration, including the
ones Django declares, so a new ModelAdmin is covered without doing anything.
It hangs off log_addition, log_change, log_deletions and get_actions, which
Django calls in every path, rather than off save_model. Deletions noted by
log_deletions are emitted from delete_model and delete_queryset, once their
outcome is known.

Changed field names are always reported; their values only for the
admin_values each model is registered with, and never for anything that
looks like a secret. An account acted upon is reported as user.target.
2026-10-06 12:15:10 +02:00
briquet fc92ac353e 📝(docs) document audit logging
Describe the audit event shape, the catalogue, how views and other code
emit events, the environment variables configuring them, and how the
project registers its actions, models and authentication classes.
2026-10-06 12:15:03 +02:00
briquet e9f8ecc9c9 ✨(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
2026-10-06 12:14:18 +02:00
briquet dca78ae601 ✨(backend) add structured audit logging facility
Add a core.audit package emitting one ECS-shaped JSON line per
security-relevant action on a dedicated "audit" logger.

The actor, auth method, tenant and network fields are read from the request
passed to audit.log. A person is identified by primary key, OIDC sub when
the account has one and email domains.

Actions are audit.Action specs carrying their ECS category and types, so
a call site only names what was attempted. The project declares the rest
in code, from an auditing module the audit app autodiscovers:
audit.register lists the fields describing a model as a target, and
audit.register_auth_method names the DRF authentication classes and the
login backends. A field that cannot be read is skipped, not the event.

AuditViewMixin audits every response of the CRUD actions a view maps in
audit_actions, and of the extra actions naming theirs with
@action(audit_action=...), from finalize_response: the outcome, reason
and status come from the response, so a refusal is recorded under the
action that was attempted. An exception DRF does not handle is recorded
as an internal error before it propagates. The core.audit app also
records Django login and logout signals.
2026-10-06 12:14:17 +02:00
63 changed files with 4733 additions and 610 deletions
+4 -5
View File
@@ -10,13 +10,14 @@ and this project adheres to
### Added
- ✨(helm) import environment variables from Secrets and ConfigMaps
- 🔒(backend) throttle meeting link generation
- 🔒️(backend) add a daily cap on room creation
- 🔧(summary) add setting to control Sentry traces sampling rate
- ✨(frontend) let signed-out visitors start a meeting
- ✨(backend) expose `allow_unregistered_rooms` in the frontend configuration
- ✨(backend) authenticate external API calls with Menshen exchanged tokens
- ✨(backend) add structured audit logging facility
- ✨(backend) audit external API token and room operations
- 🔒️(backend) audit writes and bulk actions made in the Django admin
### Changed
@@ -27,15 +28,13 @@ and this project adheres to
- 🐛(frontend) enforce recording-mode permissions on the checkboxes
- 🔒️(agents) fix util-linux CVEs reported by Cyberwatch
- 🔒️(backend) identify throttled clients by IP using NUM_PROXIES
- 🔒️(backend) fix HIGH CVEs in Django and urllib3
- 🔒️(agents) upgrade libpcre2-8-0 to fix CVE-2026-103111
- 🔒️(frontend) upgrade pcre2 to fix CVE-2026-103111
- 🐛(summary) disable default S3 checksums for GCS-compatible storage
- 🔒️(summary) redact meeting content from Sentry events
- 🐛(frontend) hide tooltips until they have a computed placement
- 🐛(brevo) use django-lasuite for marketing management
- ♿️(frontend) expose loading state to assistive technology
- 🐛(frontend) honour Keep hand raised when picture-in-picture is open
## [1.33.0] - 2026-09-30
+3 -3
View File
@@ -11,7 +11,7 @@
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/meet"/>
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
<img alt="GitHub license" src="https://img.shields.io/github/license/suitenumerique/meet"/>
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
</a>
<a href="https://digitalpublicgoods.net/r/la-suite-meet-simple-video-conferencing">
<img src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" alt="DPG Badge"/>
@@ -49,8 +49,8 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
- Telephony integration
- Secure participation with robust authentication and access control
- Customizable frontend style
- LiveKit advanced features including:
- speaker detection
- LiveKit Advances features including :
- speaker detection
- simulcast
- end-to-end optimizations
- selective subscription
+4 -1
View File
@@ -14,4 +14,7 @@ accesslog = "-"
# Using '-' for the error log file makes gunicorn log errors to stderr
errorlog = "-"
loglevel = "info"
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(M)s'
access_log_format = (
'%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(M)s'
" rid=%({x-request-id}o)s"
)
+238
View File
@@ -0,0 +1,238 @@
# Audit logging
La Suite Meet emits a structured **audit log**: one JSON line per notable action, saying who did what, on behalf of
whom, on which resource, from where, and whether it succeeded.
## What an event looks like
Events are written on the dedicated `audit` logger, one per line and look like this::
```json
{
"@timestamp": "2026-09-15T08:41:12.345+00:00",
"ecs": {"version": "8.11.0"},
"log_type": "audit",
"service": {"name": "meet", "environment": "production"},
"event": {
"kind": "event",
"action": "room.create",
"category": ["api"],
"type": ["creation"],
"outcome": "success"
},
"trace": {"id": "6f1c0d0e2a8b4c1d9e7f0a1b2c3d4e5f"},
"client": {"ip": "1.2.3.4"},
"source": {"ip": "1.2.3.4"},
"http": {"request": {"method": "POST"}},
"url": {"path": "/external-api/v1.0/rooms/"},
"user": {"id": "beecd833-4be4-4675-b139-a196b07144a9", "sub": "0edebfa3-1355-4891-ae63-daf9fd37ac04", "domain": "gouv.fr"},
"organization": {"id": "calendar-app"},
"lasuite": {
"actor": {"type": "application"},
"auth": {"method": "application_jwt"},
"application": {"client_id": "calendar-app"},
"outcome": "success",
"target": {"type": "room", "id": "3b1d…", "slug": "daily-standup", "name": "Daily standup", "access_level": "trusted"}
},
"log": {"level": "info", "logger": "audit"}
}
```
A refusal is recorded under the action that was attempted: the same `room.create`, with
`"event": {"outcome": "failure", "type": ["creation", "denied"], "reason": "permission_denied"}`,
`"lasuite": {"outcome": "denied"}`, `"http": {"response": {"status_code": 403}}` and `"error": {"message": "…"}`.
## Fields
Standard fields follow the [Elastic Common Schema](https://www.elastic.co/guide/en/ecs/current/index.html);
| Field | Meaning |
|---|---|
| `@timestamp` | ISO 8601 with millisecond precision in UTC timezone` |
| `log_type` | Always `audit` |
| `service.name`, `service.environment` | `AUDIT_LOG_SERVICE_NAME` and current environment: the emitter, never the caller |
| `event.action` | What was attempted, from the catalogue below |
| `event.category`, `event.type` | ECS classification (`api`, `authentication`, `iam`... / `creation`, `change`, `access`, `denied`, `user`...) |
| `event.outcome` | ECS `success` or `failure` |
| `event.reason` | Why it did not succeed: `authentication_failed`, `permission_denied`, `rate_limited`, `validation_error`, `not_found`, `conflict`, `internal_error` |
| `lasuite.outcome` | `success`, `failure` or `denied` |
| `lasuite.actor.type` | `user`, `application`, `service`, `system` or `anonymous`, see [Actors](#actors) |
| `lasuite.actor.name` | Name of a `service` actor: `roomkit`, `summary` |
| `lasuite.auth.method` | `session`, `application_jwt`, `addons_jwt`, `resource_server`, `livekit_token`, `shared_secret`, `client_credentials`, `oidc`, `password`, `none`, or `unknown` for a class that is not registered. Requests served outside DRF, as the admin and logout are, report `session` when signed in |
| `lasuite.application.client_id` | The external application acting, when there is one. Only set once its credentials are verified |
| `user.id`, `user.sub`, `user.domain` | The account whose authority the action used, see [Actors](#actors): primary key, OIDC sub when the account has one, and email domain. The email address is never recorded |
| `user.target.id`, `user.target.sub`, `user.target.domain` | The account an IAM action was performed on, when the target is a user. `user.*` stays the actor |
| `organization.id` | Tenant: the application client id when present, else the user's email domain |
| `lasuite.target` | The resource acted on: `type`, `id` and a few stable fields per type |
| `lasuite.details` | Action-specific fields (see catalogue) |
| `client.ip`, `source.ip` | Real client address, the one DRF's throttles identify (see `NUM_PROXIES`) |
| `http.request.method`, `url.path` | Request as received |
| `http.response.status_code` | Set on refusals and failures |
| `trace.id` | Request id, also echoed as the `X-Request-ID` response header and logged by Gunicorn as `rid=`. Generated by the backend unless `REQUEST_ID_TRUST_HEADER` is set |
| `error.message` | Human-readable reason of a failure |
| `error.type` | Class of an unhandled exception. Its message is left out, as it may carry personal data |
| `log.level` | `info` for success, `warning` for failures and denials, `error` for internal errors |
An audited API action that raises an exception DRF does not handle is still recorded, as a `failure` with reason
`internal_error`, status code `500` and `error.type`, before the exception propagates.
### Actors
`lasuite.actor.type` says who acted, and `user.*` whose authority the action used:
| `lasuite.actor.type` | Who | `user.*` |
|---|---|---|
| `user` | A person's account acting for itself: session, OIDC or password login, add-on token, LiveKit token of a known account | That account |
| `application` | A client application acting on behalf of a user: a Meet application through its client credentials or its delegated token, or another La Suite application through the resource server. `lasuite.application.client_id` names it | The delegating user |
| `service` | An internal peer of the deployment acting on its own behalf with a shared secret: the LiveKit SIP bridge (`roomkit`), the summary service (`summary`). Never an account. `lasuite.actor.name` names it | Absent |
| `system` | The backend itself, with no inbound request | Absent |
| `anonymous` | A caller that did not authenticate, or failed to | Absent |
A `client_id` in the token payload makes an application, a principal authenticated without an account a service, and
an account a user. An event emitted with neither a request nor an actor is the system's.
## Catalogue
| `event.action` | Emitted when | Notable fields |
|---|---|---|
| `application.token.issue` | An application requests a delegated token (`POST /external-api/v1.0/application/token/`), whether it obtains one or is refused: bad credentials, inactive application, invalid or unauthorized email domain, unknown user, provisioning conflict | On success: `user.*` = delegated user, `lasuite.target` = application, `lasuite.details.scopes`, `user_provisioned`, `expires_in`. On refusal: `event.reason`, `http.response.status_code`, `lasuite.details.requested_domain`. Until the credentials are verified, the submitted client id is only `lasuite.details.claimed_client_id`: it never sets `lasuite.application` or `organization` |
| `user.provision` | An application creates a provisional user by email | `lasuite.target` = user |
| `room.create` | A room is created through the external API, or the attempt fails | `lasuite.target` = room |
| `room.update` | A room is updated through the external API, or the attempt fails | `lasuite.target` = room, refusals included, `lasuite.details.updated_fields`, `previous_access_level` |
| `room.retrieve` | A room is read through the external API, or the attempt fails | `lasuite.target` = room |
| `room.list` | Rooms are listed through the external API, or the attempt fails | `lasuite.details.total` |
| `user.login` | A user logs in or a login attempt fails, `denied` with reason `authentication_failed` | `lasuite.auth.method` = `oidc` or `password`, or `unknown`: named after the backend on success, `lasuite.details.auth_backend`, and after the credentials submitted on failure (a password, or the nonce of the OIDC callback) |
| `user.logout` | A user logs out | |
| `admin.access` | A signed-in account without staff access reaches an admin page (always denied), once per refused page | `event.reason`, `http.response.status_code`: the redirect to the login page |
| `admin.<target>.<verb>` | A write is made through the Django admin, see below | |
Actions are always dotted, lower-case, with the format `<target>.<verb>`, and name what was attempted: whether it
succeeded is told by `event.outcome`, `lasuite.outcome` and `event.reason`, never by the action.
## Django admin
The admin is the most sensitive surface of the product, so every write made through it emits an audit event next to
the `LogEntry` Django writes itself. Nothing is replaced and there is no extra table: the admin history keeps working.
The action is templated rather than listed: `admin.<target>.<verb>`, where `<target>` is the model name and `<verb>`
one of:
| `<verb>` | Emitted when | Notable fields |
|---|---|---|
| `create` | An object is added | `lasuite.details.changed_fields`, `changes` |
| `update` | An object is changed | `lasuite.details.changed_fields`, `changes` |
| `delete` | An object is deleted, one event per object, once the deletion has run. A deletion that raises is a `failure` with reason `internal_error`; in a bulk deletion every selected object is then reported as failed | `error.message` on failure |
| `action` | A bulk action runs | `lasuite.details.admin_action`, `count` |
So `admin.room.update`, `admin.user.delete`, `admin.recording.action`. `event.category` is `iam` for anything granting
access to the product and `configuration` otherwise. Writes on a user or a group lead `event.type` with `user` or
`group`, as in `["user", "change"]`.
`lasuite.details.changed_fields` always carries the **names** of the fields a form changed, exactly the ones Django
reports in its own history. `lasuite.details.changes` carries their **values**, as `{"from": ..., "to": ...}`, and only
for the fields a model explicitly allows in the `admin_values` it is registered with. Anything that
looks like a secret is refused there whatever the allow-list says, so a password change is reported as a change to `password` and never with its value.
A `JSONField` on the allow-list, such as a room's `configuration`, is recorded as JSON rather than stringified, and both versions are kept
whole.
Objects edited through an **inline** emit their own event, joined to the parent's by `trace.id`: granting a role on a
room produces both `admin.room.update` and `admin.resourceaccess.create`.
What is deliberately **not** covered:
- **Reads.** Opening a change list, a change form or the history page emits nothing. Django's own `LogEntry` remains
the record of who touched what.
- **A custom action bypassing the ORM hooks.** An action calling `queryset.update()` or `queryset.delete()` directly
is reported as `admin.<target>.action` with its name and the number of objects, not one event per object.
`delete_selected` is the exception: Django reports its objects through `log_deletions`, so it emits one
`admin.<target>.delete` each and no `action` event.
The wiring lives in `core/audit/admin.py`: `AuditedAdminSite` mixes the auditing into every admin class at
registration, including those declared by Django itself, and is installed through
`core.audit.apps.AuditedAdminConfig` in `INSTALLED_APPS`. A new `ModelAdmin` is therefore covered without doing
anything; registering its model (see below) only adds its category and its allowed values.
## Emitting events
Actions are declared once, in `core/auditing.py`, as `audit.Action` constants. An action may carry its ECS category
and types, which then apply to every event it emits:
```python
APPLICATION_TOKEN_ISSUE = audit.Action(
"application.token.issue",
category=EventCategory.AUTHENTICATION,
types=(EventType.START,),
)
ROOM_CREATE = audit.Action("room.create")
```
DRF views declare the actions they audit; everything else is derived from the response. CRUD actions are mapped in
`audit_actions`, and an extra action names its own on its route, so that renaming its method cannot silently stop
auditing it:
```python
from core import audit, auditing
class RoomViewSet(audit.AuditViewMixin, viewsets.GenericViewSet):
audit_actions = {"create": auditing.ROOM_CREATE, "retrieve": auditing.ROOM_RETRIEVE}
def perform_create(self, serializer):
self.audit_target = serializer.save()
@action(detail=True, methods=["post"], audit_action=auditing.ROOM_INVITE)
def invite(self, request, pk=None): ...
```
- **Views are audited by `AuditViewMixin`** from DRF's `finalize_response` hook, which runs for every response,
successful or not. The ECS category and types come from the action, else `api` and the DRF action.
The outcome, reason and status code come from the response status: 401, 403 and 429 are `denied`, other
errors `failure`, and a 401 is always filed under `authentication`. The target is the object `get_object()` returned,
unless the view assigns `audit_target`. A view can also assign `audit_actor` and `audit_details`, or override
`get_audit_fields()`.
- **Anything else calls `audit.log`** with the request at hand. A `category` or `types` given here wins over the
action's:
```python
audit.log(auditing.USER_PROVISION, request=request, target=user)
```
- **Request fields are read from `request`**: the real client address and the path. The trace id is the request id,
settled by `AuditLogMiddleware` right after dockerflow assigned it, and echoed in the
`DOCKERFLOW_REQUEST_ID_HEADER_NAME` response header (`X-Request-ID` by default). The inbound id is kept only when
`REQUEST_ID_TRUST_HEADER` is set; otherwise the backend generates one, so a client never picks it.
- **Actors are derived** from `request.user`, `request.auth` and the DRF authenticator, whose class is mapped to an
auth method by `audit.register_auth_method` (see Configuration), as described in [Actors](#actors). Without a request,
the actor is the system. It is possible to override the actor with `actor=`, `actor_type=`, `auth_method=` and
`client_id=`.
- **Targets are described** by their model name, primary key and the `fields` their model is registered with. A model
that is not registered is still identified. Extra keyword arguments land under `lasuite.details`.
- **Emission never raises.** A broken configuration or value is reported on the application logger (and Sentry) and the
business operation proceeds. A registered field that cannot be read is left out of the target, and the event is still
emitted.
## Configuration
| Variable | Default | Meaning |
|---|---|---|
| `AUDIT_LOG_LEVEL` | `INFO` | Level of the `audit` logger. |
| `AUDIT_LOG_STREAM` | `ext://sys.stdout` | Where the handler writes |
| `AUDIT_LOG_SERVICE_NAME` | `meet` | `service.name` |
| `NUM_PROXIES` | `1` | DRF's number of trusted proxies appending to `X-Forwarded-For`, shared with the throttles. The client is the entry that many positions from the right; anything a client injects lands further left and is ignored. `1` matches ingress-nginx defaults; use `2` behind a load balancer that also appends |
| `REQUEST_ID_TRUST_HEADER` | `False` | Reuse the inbound request id as `trace.id`, so the ingress, Gunicorn, application logs and audit events share one id. Only set it when the ingress overwrites the header (`proxy_set_header X-Request-ID $request_id;` on ingress-nginx, which otherwise forwards the client's one): a client could else pick the id of someone else's request |
| `DOCKERFLOW_REQUEST_ID_HEADER_NAME` | `X-Request-ID` | Header carrying that id: read on the request only when `REQUEST_ID_TRUST_HEADER` is set, always echoed on the response |
The project describes itself to the facility in code, from `core/auditing.py`. The audit app imports the `auditing`
module of every installed app once it is ready:
- `audit.register(Model, fields=..., admin_values=..., category=...)`: the `fields` describing a model as a target,
the `admin_values` whose before and after values may be recorded in the admin, and the `category` of its admin
writes. A proxy model falls back to its concrete model. Registering a model twice raises `AlreadyRegistered`.
- `audit.register_auth_method(klass, name)`: the `lasuite.auth.method` of a DRF authentication class or of a login
backend. A DRF class inherits the name of its closest registered base, and DRF's own classes are built in. A login
backend must be registered itself, as custom backends often subclass `ModelBackend` for its permission checks
alone; `ModelBackend` is built in as `password`.
+30
View File
@@ -0,0 +1,30 @@
"""Structured audit logging."""
from .actions import Action
from .actor import email_domain
from .drf import AuditViewMixin
from .emitter import AUDIT_LOGGER_NAME, log
from .enums import ActorType, EventCategory, EventType, Outcome, Reason
from .formatter import AuditJsonFormatter
from .registry import AlreadyRegistered, register, register_auth_method
from .signals import LOGIN_ACTION, LOGOUT_ACTION, connect_auth_signals
__all__ = [
"AUDIT_LOGGER_NAME",
"LOGIN_ACTION",
"LOGOUT_ACTION",
"Action",
"ActorType",
"AlreadyRegistered",
"AuditJsonFormatter",
"AuditViewMixin",
"EventCategory",
"EventType",
"Outcome",
"Reason",
"connect_auth_signals",
"email_domain",
"log",
"register",
"register_auth_method",
]
+27
View File
@@ -0,0 +1,27 @@
"""Specs of the actions audit events are emitted for."""
from dataclasses import dataclass
from .enums import EventCategory, EventType
@dataclass(frozen=True)
class Action:
"""An audited action: its dotted name and its ECS classification.
``category`` and ``types`` are the defaults of every event of the action:
a ``category`` or ``types`` given to ``log`` wins over them.
"""
name: str
category: EventCategory | None = None
types: tuple[EventType, ...] = ()
def __post_init__(self):
"""Validate the classification, so a bad one fails at import."""
if self.category is not None:
object.__setattr__(self, "category", EventCategory(self.category))
object.__setattr__(self, "types", tuple(EventType(t) for t in self.types))
def __str__(self) -> str:
return self.name
+159
View File
@@ -0,0 +1,159 @@
"""Resolve who is acting: actor type, identifiers, auth method and tenant.
Personal data is kept to a minimum on purpose: a person is identified by its
primary key, its OIDC ``sub`` when it has one and the domain of its email
address. The address itself is never recorded.
"""
from collections.abc import Mapping
from typing import Any
from django.contrib.auth import get_user_model
from lasuite.tools.email import get_domain_from_email
from .enums import ActorType
from .registry import auth_methods, dotted_path
AUTH_METHOD_NONE = "none"
AUTH_METHOD_SESSION = "session"
AUTH_METHOD_UNKNOWN = "unknown"
DEFAULT_AUTH_METHODS = {
"rest_framework.authentication.SessionAuthentication": AUTH_METHOD_SESSION,
"rest_framework.authentication.BasicAuthentication": "basic",
"rest_framework.authentication.TokenAuthentication": "token",
"django.contrib.auth.backends.ModelBackend": "password",
}
def _auth_methods() -> dict[str, str]:
return {**DEFAULT_AUTH_METHODS, **auth_methods()}
def auth_method_for(authenticator) -> str:
"""Return the auth method name for a DRF authenticator instance."""
if authenticator is None:
return AUTH_METHOD_NONE
methods = _auth_methods()
for klass in type(authenticator).__mro__:
name = methods.get(dotted_path(klass))
if name:
return name
return AUTH_METHOD_UNKNOWN
def auth_method_for_backend(backend: str | None) -> str:
"""Return the auth method name for the dotted path of a login backend."""
return _auth_methods().get(backend or "", AUTH_METHOD_UNKNOWN)
def request_auth_method(request) -> str:
"""Return how ``request`` was authenticated.
A DRF request names its authenticator. A plain Django request, as served
by the admin or the logout view, can only be authenticated by its session.
"""
if hasattr(request, "successful_authenticator"):
return auth_method_for(request.successful_authenticator)
if _is_authenticated(getattr(request, "user", None)):
return AUTH_METHOD_SESSION
return AUTH_METHOD_NONE
def email_domain(email) -> str | None:
"""Return the lower-cased domain part of an email address, if any.
It is parsed as for ``Application.can_delegate_email``, so an audited
domain is the one a delegation was checked against.
"""
domain = get_domain_from_email(str(email)) if email else None
return domain.lower() if domain else None
def client_id_from_auth(auth) -> str | None:
"""Extract an application client id from a token payload."""
if isinstance(auth, Mapping):
value = auth.get("client_id")
return str(value) if value else None
return None
def _is_authenticated(user) -> bool:
return bool(user is not None and getattr(user, "is_authenticated", False))
def _is_account(user) -> bool:
"""Tell whether ``user`` is a user account.
It stays one once deleted, when Django clears its primary key.
"""
return isinstance(user, get_user_model())
def _is_service(user) -> bool:
"""Tell whether ``user`` authenticated without an account, as a machine user."""
return _is_authenticated(user) and not _is_account(user)
def _default_actor_type(request, user, client_id) -> ActorType:
if client_id:
return ActorType.APPLICATION
if request is None and user is None:
return ActorType.SYSTEM
if _is_service(user):
return ActorType.SERVICE
if _is_account(user):
return ActorType.USER
return ActorType.ANONYMOUS
def describe_user(user) -> dict[str, Any]:
"""Return the fields identifying a person: id, OIDC sub and email domain.
The sub is missing for accounts that never signed in, such as provisional
users, and the id for accounts that were deleted.
"""
return {
"id": str(user.pk) if user.pk is not None else None,
"sub": getattr(user, "sub", None) or None,
"domain": email_domain(getattr(user, "email", None)),
}
def describe_actor(
request,
*,
actor=None,
actor_type: ActorType | str | None = None,
client_id: str | None = None,
auth_method: str | None = None,
) -> dict[str, Any]:
"""Return the ECS ``user`` and ``organization`` fields and the ``lasuite`` ones.
Everything is read from ``request`` unless overridden. Without a request
or an actor, the actor is the system. ``user`` is the account whose
authority the action used, see ``ActorType``: None for a service, the
system or an anonymous caller.
"""
user = actor if actor is not None else getattr(request, "user", None)
client_id = client_id or client_id_from_auth(getattr(request, "auth", None))
actor_type = actor_type or _default_actor_type(request, user, client_id)
lasuite: dict[str, Any] = {
"actor": {
"type": str(ActorType(actor_type)),
"name": user.get_username() if _is_service(user) else None,
},
"auth": {"method": auth_method or request_auth_method(request)},
"application": {"client_id": client_id},
}
is_account = _is_account(user)
tenant = client_id or (
email_domain(getattr(user, "email", None)) if is_account else None
)
return {
"user": describe_user(user) if is_account else None,
"organization": {"id": tenant},
"lasuite": lasuite,
}
+348
View File
@@ -0,0 +1,348 @@
"""Audit the writes performed through the Django admin.
Every ``ModelAdmin`` registered on :class:`AuditedAdminSite` emits an audit
event when an object is created, changed or deleted, and when a bulk action
runs. Django's own ``LogEntry`` keeps being written exactly as before: this
stream is additive.
Actions are named ``admin.<target>.<verb>`` where ``<target>`` is the model
name, so ``admin.room.update`` or ``admin.user.delete``.
Unlike the rest of the catalogue this family is templated rather than
enumerated: it follows whatever models are registered.
Only writes are audited. Browsing a change list or a change form emits
nothing.
Which field values may be recorded, and the event category, are registered
per model; see ``core.audit.registry``.
"""
import copy
import logging
from contextlib import contextmanager
from enum import StrEnum
from functools import wraps
from typing import Any
from django.contrib.admin import ModelAdmin
from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from .actions import Action
from .emitter import log
from .enums import EventCategory, EventType, Outcome, Reason
from .registry import model_options
from .utils import render_value
ADMIN_ACCESS_ACTION = Action("admin.access", category=EventCategory.IAM)
DIFF_ATTRIBUTE = "audit_admin_diff"
PENDING_DELETIONS_ATTRIBUTE = "audit_admin_pending_deletions"
UNAUDITED_ACTIONS = frozenset({"delete_selected"})
SENSITIVE_FIELD_NAMES = frozenset(
{"api_key", "client_secret", "pin_code", "secret", "sub", "token"}
)
SENSITIVE_FIELD_MARKERS = ("password", "secret", "token")
_logger = logging.getLogger(__name__)
class AdminVerb(StrEnum):
"""What was done to an object through the admin."""
CREATE = "create"
UPDATE = "update"
DELETE = "delete"
ACTION = "action"
_VERB_TYPES: dict[AdminVerb, list[EventType]] = {
AdminVerb.CREATE: [EventType.CREATION],
AdminVerb.UPDATE: [EventType.CHANGE],
AdminVerb.DELETE: [EventType.DELETION],
AdminVerb.ACTION: [EventType.CHANGE],
}
def is_sensitive(field_name: str) -> bool:
"""Tell whether the value of a field must never be recorded."""
return field_name in SENSITIVE_FIELD_NAMES or any(
marker in field_name for marker in SENSITIVE_FIELD_MARKERS
)
def value_fields_for(model: type) -> frozenset[str]:
"""Return the fields of ``model`` whose before and after values may be recorded.
Anything that looks like a secret is dropped from the ``admin_values`` of
the model here, so a mistake in the registration cannot leak one.
"""
names = model_options(model).admin_values
return frozenset(name for name in names if not is_sensitive(name))
def category_for(model: type) -> EventCategory:
"""Return the registered category, else IAM for Django's auth models.
Anything granting access to the product is IAM, the rest configuration.
"""
if category := model_options(model).category:
return category
if model is get_user_model() or issubclass(model, (Group, Permission)):
return EventCategory.IAM
return EventCategory.CONFIGURATION
def types_for(model: type, verb: AdminVerb) -> list[EventType]:
"""Return the event types of ``verb`` on ``model``.
ECS expects ``user`` or ``group`` before the verb when one was the target.
"""
if issubclass(model, get_user_model()):
return [EventType.USER, *_VERB_TYPES[verb]]
if issubclass(model, Group):
return [EventType.GROUP, *_VERB_TYPES[verb]]
return _VERB_TYPES[verb]
def action_name(model: type, verb: AdminVerb) -> str:
"""Return the audit action for ``verb`` on ``model``."""
return f"admin.{model._meta.model_name}.{verb}" # noqa: SLF001
def form_diff(form, value_fields: frozenset[str]) -> dict[str, Any]:
"""Return the names of the fields a form changed, and the allowed values.
Field names are always reported. Values are reported for allow-listed
fields only, as ``{"from": ..., "to": ...}``.
"""
changed = sorted(form.changed_data)
changes = {
name: {
"from": render_value(form.initial.get(name)),
"to": render_value(form.cleaned_data.get(name)),
}
for name in changed
if name in value_fields
}
return {"changed_fields": changed, "changes": changes}
def related_diffs(formsets) -> list[tuple[Any, AdminVerb, dict[str, Any] | None]]:
"""Return one ``(object, verb, diff)`` triple per inline object touched.
Called after ``save_related``, so the formsets already carry what they
saved. The objects they list are the very instances their forms bound, so
the matching form, and with it the before and after values, is found by
identity.
"""
touched = []
for formset in formsets or ():
forms = {id(form.instance): form for form in formset.forms}
value_fields = value_fields_for(formset.model)
def diff_of(obj, forms=forms, value_fields=value_fields):
form = forms.get(id(obj))
return form_diff(form, value_fields) if form is not None else None
for obj in getattr(formset, "new_objects", ()):
touched.append((obj, AdminVerb.CREATE, diff_of(obj)))
for obj, _fields in getattr(formset, "changed_objects", ()):
touched.append((obj, AdminVerb.UPDATE, diff_of(obj)))
for obj in getattr(formset, "deleted_objects", ()):
# A deleted inline has no meaningful diff
touched.append((obj, AdminVerb.DELETE, None))
return touched
class AuditedModelAdminMixin:
"""Emit an audit event for every write made through this ModelAdmin."""
def construct_change_message(self, request, form, formsets, add=False):
"""Stash the structured diff for the ``log_*`` hook that follows."""
message = super().construct_change_message(request, form, formsets, add)
try:
diff = {
"own": form_diff(form, value_fields_for(self.model)),
"related": related_diffs(formsets),
}
except Exception: # pylint: disable=broad-exception-caught
_logger.exception("Admin audit diff could not be built")
diff = None
setattr(request, DIFF_ATTRIBUTE, diff)
return message
def log_addition(self, request, obj, message):
"""Record the creation, and that of any inline object saved with it."""
entry = super().log_addition(request, obj, message)
self.audit_form_write(request, AdminVerb.CREATE, obj)
return entry
def log_change(self, request, obj, message):
"""Record the change, and that of any inline object saved with it."""
entry = super().log_change(request, obj, message)
self.audit_form_write(request, AdminVerb.UPDATE, obj)
return entry
def log_deletions(self, request, queryset):
"""Note the objects about to be deleted.
Django calls this before ``delete_model`` and ``delete_queryset``, in
both the single and the bulk path. Those emit the events, once the
deletion has succeeded or failed. Copies are kept because deleting an
instance clears its primary key.
"""
targets = list(queryset)
entries = super().log_deletions(request, targets)
setattr(
request, PENDING_DELETIONS_ATTRIBUTE, [copy.copy(obj) for obj in targets]
)
return entries
def delete_model(self, request, obj):
"""Delete the object, then record one deletion."""
with self.auditing_deletions(request, lambda: [copy.copy(obj)]):
super().delete_model(request, obj)
def delete_queryset(self, request, queryset):
"""Delete the objects, then record one deletion per object."""
with self.auditing_deletions(request, lambda: list(queryset)):
super().delete_queryset(request, queryset)
@contextmanager
def auditing_deletions(self, request, default_targets):
"""Record the deletions noted by ``log_deletions`` with their outcome.
``default_targets`` lists the objects when ``log_deletions`` did not
run, as when a custom action deletes through these methods directly.
"""
targets = getattr(request, PENDING_DELETIONS_ATTRIBUTE, None)
setattr(request, PENDING_DELETIONS_ATTRIBUTE, None)
if targets is None:
targets = default_targets()
try:
yield
except Exception as error:
for obj in targets:
self.audit_write(request, AdminVerb.DELETE, obj, error=error)
raise
for obj in targets:
self.audit_write(request, AdminVerb.DELETE, obj)
def get_actions(self, request):
"""Return the available actions, each wrapped so that running it is audited."""
return {
name: (self.audited_action(func, name), name, description)
for name, (func, _name, description) in super().get_actions(request).items()
}
def audited_action(self, func, name):
"""Wrap an admin action so every run emits an event, success or not."""
if name in UNAUDITED_ACTIONS:
return func
@wraps(func)
def run(modeladmin, request, queryset):
count = queryset.count()
try:
response = func(modeladmin, request, queryset)
except Exception as error:
modeladmin.audit_action(request, name, count, error=error)
raise
modeladmin.audit_action(request, name, count)
return response
return run
def audit_form_write(self, request, verb, obj):
"""Emit the event for a form write and for the inlines saved with it."""
diff = getattr(request, DIFF_ATTRIBUTE, None) or {}
setattr(request, DIFF_ATTRIBUTE, None)
self.audit_write(request, verb, obj, diff.get("own"))
for related_obj, related_verb, related_diff in diff.get("related", ()):
self.audit_write(request, related_verb, related_obj, related_diff)
def audit_write(self, request, verb, obj, diff=None, *, error=None): # pylint: disable=too-many-arguments
"""Emit one event for a write on ``obj``, a failed one if ``error`` is set."""
model = obj.__class__
log(
action_name(model, verb),
request=request,
outcome=Outcome.SUCCESS if error is None else Outcome.FAILURE,
reason=None if error is None else Reason.INTERNAL_ERROR,
error=error,
category=category_for(model),
types=types_for(model, verb),
target=obj,
user_target=obj if isinstance(obj, get_user_model()) else None,
**(diff or {}),
)
def audit_action(self, request, name, count, error=None):
"""Emit one event for a bulk action run on ``count`` objects."""
log(
action_name(self.model, AdminVerb.ACTION),
request=request,
outcome=Outcome.SUCCESS if error is None else Outcome.FAILURE,
reason=None if error is None else Reason.INTERNAL_ERROR,
category=category_for(self.model),
types=types_for(self.model, AdminVerb.ACTION),
error=error,
admin_action=name,
count=count,
)
def audited(admin_class: type) -> type:
"""Return ``admin_class`` with the audit mixin."""
if issubclass(admin_class, AuditedModelAdminMixin):
return admin_class
return type(
f"Audited{admin_class.__name__}",
(AuditedModelAdminMixin, admin_class),
{"__module__": admin_class.__module__, "__doc__": admin_class.__doc__},
)
class AuditedAdminSite(AdminSite):
"""Admin site whose model admins all emit audit events.
Installed through ``AdminConfig.default_site`` so that admin classes
declared by Django itself, or by a third-party app, are covered as well as
the project's own.
"""
def register(self, model_or_iterable, admin_class=None, **options):
"""Register the audited flavour of the given admin class."""
super().register(
model_or_iterable, audited(admin_class or ModelAdmin), **options
)
def admin_view(self, view, cacheable=False):
"""Record when a signed-in account without staff access tries an admin view.
Django asks ``has_permission`` several times per request, the login
page included, so the refusal is recorded here instead: once per
refused view. The answer is taken before the view runs, which may log
the user out.
"""
guarded = super().admin_view(view, cacheable)
@wraps(guarded)
def inner(request, *args, **kwargs):
refused = getattr(
request.user, "is_authenticated", False
) and not self.has_permission(request)
response = guarded(request, *args, **kwargs)
if refused:
log(
ADMIN_ACCESS_ACTION,
outcome=Outcome.DENIED,
reason=Reason.PERMISSION_DENIED,
request=request,
status_code=response.status_code,
)
return response
return inner
+29
View File
@@ -0,0 +1,29 @@
"""Application configurations of the audit facility."""
from django.apps import AppConfig
from django.contrib.admin.apps import AdminConfig
from django.utils.module_loading import autodiscover_modules
from .signals import connect_auth_signals
class AuditConfig(AppConfig):
"""Audit Django's authentication signals and load the project's declarations."""
name = "core.audit"
label = "audit"
def ready(self):
"""Connect the login, failed login and logout receivers.
Then import the ``auditing`` module of every installed app, where the
project registers its models and authentication classes.
"""
connect_auth_signals()
autodiscover_modules("auditing")
class AuditedAdminConfig(AdminConfig):
"""Serve the admin from the site that audits every write."""
default_site = "core.audit.admin.AuditedAdminSite"
+174
View File
@@ -0,0 +1,174 @@
"""Django REST framework integration
``AuditViewMixin`` turns every response of an audited action into one audit
event, from DRF's ``finalize_response`` hook, which runs for successes and for
handled errors alike. An exception DRF does not handle is audited as an
internal error from ``handle_exception`` before it propagates.
The CRUD actions a viewset audits are mapped in ``audit_actions``.
Extra action names require a decorator::
class RoomViewSet(audit.AuditViewMixin, viewsets.ModelViewSet):
audit_actions = {"create": ROOM_CREATE, "retrieve": ROOM_RETRIEVE}
@action(detail=True, methods=["post"], audit_action=ROOM_INVITE)
def invite(self, request, pk=None): ...
A refusal is recorded under the action that was attempted, with its outcome
and reason derived from the response status.
"""
import logging
from collections.abc import Mapping
from typing import Any
from .actions import Action
from .emitter import EVENT_FIELDS, log
from .enums import EventCategory, EventType, Outcome, Reason
from .utils import exception_type
ACTION_TYPES = {
"create": EventType.CREATION,
"update": EventType.CHANGE,
"partial_update": EventType.CHANGE,
"destroy": EventType.DELETION,
"retrieve": EventType.ACCESS,
"list": EventType.ACCESS,
}
STATUS_REASONS = {
400: Reason.VALIDATION_ERROR,
401: Reason.AUTHENTICATION_FAILED,
403: Reason.PERMISSION_DENIED,
404: Reason.NOT_FOUND,
409: Reason.CONFLICT,
429: Reason.RATE_LIMITED,
}
DENIED_STATUSES = frozenset({401, 403, 429})
_logger = logging.getLogger(__name__)
def error_message(response) -> Any:
"""Return the message of an error response, as DRF or the view wrote it."""
data = getattr(response, "data", None)
if isinstance(data, Mapping):
return data.get("detail") or data.get("error")
return None
class AuditViewMixin:
"""Emit one audit event per response of an audited action.
``audit_actions`` maps the CRUD actions only. An extra action is audited
by passing ``audit_action`` to its ``@action`` decorator.
While handling a request, a view may *assign* ``audit_target``,
``audit_actor`` and ``audit_details``; ``check_object_permissions`` sets
the target on its own, before a refusal can happen. A detail named after
an event field, as ``outcome`` or ``request``, is dropped: overriding one
is done in ``get_audit_fields``.
"""
audit_actions: Mapping[str, Action | str] = {}
# Only declared so the router may pass the ``@action`` keyword arguments
# to ``as_view``; the action is read from the handler of the request.
audit_action: Action | str | None = None
audit_target: Any = None
audit_actor: Any = None
audit_details: Mapping[str, Any] | None = None
def __init_subclass__(cls, **kwargs):
"""Refuse extra actions in ``audit_actions``, keyed by a method name."""
super().__init_subclass__(**kwargs)
if extra := sorted(set(cls.audit_actions) - set(ACTION_TYPES)):
raise TypeError(
f"{cls.__qualname__}.audit_actions only maps CRUD actions: "
f"audit {', '.join(extra)} with @action(audit_action=...)"
)
def check_object_permissions(self, request, obj):
"""Remember the object as the target."""
self.audit_target = obj
super().check_object_permissions(request, obj)
def finalize_response(self, request, response, *args, **kwargs):
"""Audit the response once DRF has built it."""
response = super().finalize_response(request, response, *args, **kwargs)
self.emit_audit_event(request, response.status_code, error_message(response))
return response
def handle_exception(self, exc):
"""Audit an exception DRF cannot turn into a response, then let it propagate.
Only its class is recorded since its message could carry personal data.
"""
try:
return super().handle_exception(exc)
except Exception as error:
self.emit_audit_event(self.request, 500, error_type=exception_type(error))
raise
def get_audit_action(self) -> Action | str | None:
"""Return what the current request audits, if anything.
An extra action is read from its handler, so a request that reaches
none, as an OPTIONS request or a refused method, audits nothing.
"""
name = getattr(self, "action", None)
if name in ACTION_TYPES:
return self.audit_actions.get(name)
handler = getattr(self, name, None) if name else None
return getattr(handler, "kwargs", {}).get("audit_action")
def emit_audit_event(self, request, status_code, error=None, error_type=None):
"""Emit the event of the current action, if it is audited.
Never raises: a response must not fail because it could not be audited.
"""
try:
action = self.get_audit_action()
if action is not None:
fields = self.get_audit_fields(status_code, error)
log(action, request=request, error_type=error_type, **fields)
except Exception: # pylint: disable=broad-exception-caught
_logger.exception("Audit event of %s could not be emitted", request.path)
def get_audit_fields(self, status_code, error=None) -> dict[str, Any]:
"""Return the fields of the event for a response of ``status_code``.
The category and types of the ``Action`` win over those derived from
the DRF action.
"""
action = self.get_audit_action()
category, types = None, []
if isinstance(action, Action):
category, types = action.category, list(action.types)
details = {
key: value
for key, value in (self.audit_details or {}).items()
if key not in EVENT_FIELDS
}
fields = {
**details,
"category": category or EventCategory.API,
"types": types
or [ACTION_TYPES.get(getattr(self, "action", None), EventType.INFO)],
"target": self.audit_target,
"actor": self.audit_actor,
}
if status_code >= 400:
fields |= {
"outcome": (
Outcome.DENIED
if status_code in DENIED_STATUSES
else Outcome.FAILURE
),
"reason": STATUS_REASONS.get(
status_code, Reason.INTERNAL_ERROR if status_code >= 500 else None
),
"status_code": status_code,
"error": error,
}
if status_code == 401:
fields["category"] = EventCategory.AUTHENTICATION
return fields
+161
View File
@@ -0,0 +1,161 @@
"""Build ECS audit documents and emit them on the ``audit`` logger."""
import inspect
import logging
from datetime import datetime, timezone
from typing import Any
from django.conf import settings
from .actions import Action
from .actor import describe_actor, describe_user
from .enums import ActorType, EventCategory, EventType, Outcome, Reason
from .request import current_request_id, resolve_client_ip
from .targets import describe_target
from .utils import prune_empty, render_value
AUDIT_LOGGER_NAME = "audit"
ECS_VERSION = "8.11.0"
LOG_TYPE = "audit"
_audit_logger = logging.getLogger(AUDIT_LOGGER_NAME)
_logger = logging.getLogger(__name__)
def log(action: Action | str, **fields: Any) -> None:
"""Emit one audit event."""
try:
document = build_document(action, **fields)
except Exception: # pylint: disable=broad-exception-caught
_logger.exception("Audit event %r could not be built", action)
return
_audit_logger.log(
level_for(document["lasuite"]["outcome"], document["event"].get("reason")),
str(action),
extra={"audit": document},
)
def level_for(outcome: Outcome | str, reason: Reason | str | None) -> int:
"""Derive the logging level so call sites never choose one."""
if Outcome(outcome) == Outcome.SUCCESS:
return logging.INFO
if reason is not None and Reason(reason) == Reason.INTERNAL_ERROR:
return logging.ERROR
return logging.WARNING
def build_document( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-locals
action: Action | str,
*,
request: Any = None,
outcome: Outcome | str = Outcome.SUCCESS,
reason: Reason | str | None = None,
category: EventCategory | str | None = None,
types: list[EventType | str] | None = None,
target: Any = None,
user_target: Any = None,
actor: Any = None,
actor_type: ActorType | str | None = None,
auth_method: str | None = None,
client_id: str | None = None,
status_code: int | None = None,
error: Any = None,
error_type: str | None = None,
message: str | None = None,
**details: Any,
) -> dict[str, Any]:
"""Return the ECS document of an event, pruned of empty values.
``action`` is what was attempted: an ``Action``, whose category and types
apply unless given here, or a bare dotted name (``room.create``).
The actor, auth method and network fields are read from ``request``;
``actor``, ``actor_type``, ``auth_method`` and ``client_id`` override them.
``target`` is the resource acted on and ``user_target`` the account an IAM
action was performed on, reported as ``user.target``. Any other keyword
argument lands under ``lasuite.details``.
"""
outcome = Outcome(outcome)
reason = Reason(reason) if reason is not None else None
if isinstance(action, Action):
category = category or action.category
types = types or list(action.types)
client_ip = resolve_client_ip(request) if request is not None else None
actor_fields = describe_actor(
request,
actor=actor,
actor_type=actor_type,
client_id=client_id,
auth_method=auth_method,
)
return prune_empty(
{
"@timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
"ecs": {"version": ECS_VERSION},
"log_type": LOG_TYPE,
"message": message,
"service": {
"name": getattr(settings, "AUDIT_LOG_SERVICE_NAME", None),
"environment": getattr(settings, "ENVIRONMENT", None),
},
"event": _event_fields(action, outcome, reason, category, types),
"trace": {"id": current_request_id()},
"client": {"ip": client_ip},
"source": {"ip": client_ip},
"http": {
"request": {"method": getattr(request, "method", None)},
"response": {"status_code": status_code},
},
"url": {"path": getattr(request, "path", None) or None},
"user": {
**(actor_fields["user"] or {}),
"target": describe_user(user_target) if user_target else None,
},
"organization": actor_fields["organization"],
"lasuite": {
**actor_fields["lasuite"],
"outcome": str(outcome),
"target": describe_target(target) if target is not None else None,
"details": render_value(details),
},
"error": {
"message": str(error) if error is not None else None,
"type": error_type,
},
}
)
# The keyword arguments of ``log`` that fill an event field rather than a detail
EVENT_FIELDS = frozenset(
name
for name, parameter in inspect.signature(build_document).parameters.items()
if parameter.kind is inspect.Parameter.KEYWORD_ONLY
)
def _event_fields(action, outcome, reason, category, types) -> dict[str, Any]:
type_list = [str(EventType(item)) for item in (types or [])]
if not type_list:
type_list = [str(_default_type(outcome))]
if outcome == Outcome.DENIED and str(EventType.DENIED) not in type_list:
type_list.append(str(EventType.DENIED))
return {
"kind": "event",
"action": str(action),
"category": [str(EventCategory(category or EventCategory.WEB))],
"type": type_list,
"outcome": "success" if outcome == Outcome.SUCCESS else "failure",
"reason": str(reason) if reason is not None else None,
}
def _default_type(outcome: Outcome) -> EventType:
if outcome == Outcome.SUCCESS:
return EventType.INFO
if outcome == Outcome.DENIED:
return EventType.DENIED
return EventType.ERROR
+74
View File
@@ -0,0 +1,74 @@
"""ECS enums shared by every audit event."""
from enum import StrEnum
class Outcome(StrEnum):
"""Whether the audited action succeeded, failed, or was refused."""
SUCCESS = "success"
FAILURE = "failure"
DENIED = "denied"
class Reason(StrEnum):
"""Why an action did not succeed."""
AUTHENTICATION_FAILED = "authentication_failed"
PERMISSION_DENIED = "permission_denied"
RATE_LIMITED = "rate_limited"
VALIDATION_ERROR = "validation_error"
NOT_FOUND = "not_found"
CONFLICT = "conflict"
INTERNAL_ERROR = "internal_error"
class ActorType(StrEnum):
"""Kind of principal behind an action.
``user.*`` is the account whose authority the action used: the actor for
``user``, the delegating user for ``application``, absent otherwise.
"""
# A person's account acting for itself.
USER = "user"
# A client application acting on behalf of a user, named by its client id.
APPLICATION = "application"
# An internal peer of the deployment acting on its own behalf with a
# shared secret, named by ``lasuite.actor.name``. Never an account.
SERVICE = "service"
# The backend itself, with no inbound request.
SYSTEM = "system"
# A caller that did not authenticate, or failed to.
ANONYMOUS = "anonymous"
class EventCategory(StrEnum):
"""Subset of the ECS ``event.category`` ."""
API = "api"
AUTHENTICATION = "authentication"
CONFIGURATION = "configuration"
EMAIL = "email"
FILE = "file"
IAM = "iam"
SESSION = "session"
WEB = "web"
class EventType(StrEnum):
"""Subset of the ECS ``event.type``."""
ACCESS = "access"
ADMIN = "admin"
ALLOWED = "allowed"
CHANGE = "change"
CREATION = "creation"
DELETION = "deletion"
DENIED = "denied"
END = "end"
ERROR = "error"
GROUP = "group"
INFO = "info"
START = "start"
USER = "user"
+35
View File
@@ -0,0 +1,35 @@
"""Render audit records as single-line ECS JSON format."""
import json
import logging
from datetime import datetime, timezone
from typing import Any
class AuditJsonFormatter(logging.Formatter):
"""Serialise the document attached to the record under ``audit`` in ECS format."""
def format(self, record: logging.LogRecord) -> str:
document = getattr(record, "audit", None)
if not isinstance(document, dict):
document = {
"@timestamp": datetime.fromtimestamp(
record.created, tz=timezone.utc
).isoformat(timespec="milliseconds"),
"log_type": "audit",
"message": record.getMessage(),
"event": {"action": record.getMessage()},
}
document = {
**document,
"log": {"level": record.levelname.lower(), "logger": record.name},
}
if record.exc_info:
error: dict[str, Any] = dict(document.get("error") or {})
error["stack_trace"] = self.formatException(record.exc_info)
document["error"] = error
return json.dumps(
document, ensure_ascii=False, default=str, separators=(",", ":")
)
+99
View File
@@ -0,0 +1,99 @@
"""Declare what audit events may say about models and authentication classes.
The project registers them from an ``auditing`` module in one of its apps,
imported once the audit app is ready, so models stay free of audit concerns::
audit.register(
Room,
fields=("slug", "access_level"), # describe the target
admin_values=("name", "access_level"), # values diffed in the admin
category=audit.EventCategory.CONFIGURATION, # ECS category of admin writes
)
audit.register_auth_method(ApplicationJWTAuthentication, "application_jwt")
A target is always identified by its model name and primary key, so a model
that is not registered is still identifiable, just less detailed.
"""
from dataclasses import dataclass
from django.db.models import Model
from .enums import EventCategory
class AlreadyRegistered(Exception):
"""A model or an authentication class that was registered twice."""
@dataclass(frozen=True)
class ModelOptions:
"""What audit events may say about a model.
``fields`` describe the model when it is the target of an event.
``admin_values`` are the fields whose before and after values may be
recorded when they change in the Django admin. ``category`` is the ECS
category of admin writes: ``iam`` for anything granting access to the
product, ``configuration`` by default.
"""
fields: tuple[str, ...] = ()
admin_values: tuple[str, ...] = ()
category: EventCategory | None = None
_models: dict[type[Model], ModelOptions] = {}
_auth_methods: dict[str, str] = {}
def register(
model: type[Model],
*,
fields=(),
admin_values=(),
category: EventCategory | str | None = None,
) -> None:
"""Declare what audit events may say about ``model``."""
if model in _models:
raise AlreadyRegistered(f"{model._meta.label} is already registered") # noqa: SLF001
_models[model] = ModelOptions(
fields=tuple(fields),
admin_values=tuple(admin_values),
category=EventCategory(category) if category is not None else None,
)
def unregister(model: type[Model]) -> ModelOptions | None:
"""Forget ``model`` and return what was registered for it, if anything."""
return _models.pop(model, None)
def model_options(model: type[Model]) -> ModelOptions:
"""Return what is registered for a model, or for its concrete model."""
for klass in (model, model._meta.concrete_model): # noqa: SLF001
if (options := _models.get(klass)) is not None:
return options
return ModelOptions()
def dotted_path(klass: type) -> str:
"""Return the dotted path Django and DRF name a class by."""
return f"{klass.__module__}.{klass.__qualname__}"
def register_auth_method(klass: type, name: str) -> None:
"""Name the ``lasuite.auth.method`` of a DRF authentication class or a login backend.
A DRF class is also the default of its subclasses. A login backend must be
registered itself: custom backends often subclass ``ModelBackend`` only for
its permission checks, and must not pass for password logins.
"""
path = dotted_path(klass)
if path in _auth_methods:
raise AlreadyRegistered(f"{path} is already registered")
_auth_methods[path] = name
def auth_methods() -> dict[str, str]:
"""Return the registered auth methods, keyed by dotted path."""
return dict(_auth_methods)
+46
View File
@@ -0,0 +1,46 @@
"""Read the network fields and the request id behind an audit event."""
import uuid
from django.conf import settings
from dockerflow.logging import request_id_context
from rest_framework.throttling import BaseThrottle
def current_request_id() -> str | None:
"""Return the id of the request being served, if any."""
return request_id_context.get(None)
def resolve_client_ip(request) -> str | None:
"""Return the address of the real client, never the one of a proxy.
It reuses DRF's throttles to identify the client.
"""
return BaseThrottle().get_ident(request) or request.META.get("REMOTE_ADDR")
class AuditLogMiddleware:
"""Settle the request id, then echo it on the response.
It must come right after ``DockerflowMiddleware``, which sets the id from
the inbound ``DOCKERFLOW_REQUEST_ID_HEADER_NAME`` header. Unless
``REQUEST_ID_TRUST_HEADER`` says the ingress overwrites that header, the id
is replaced by a fresh one before anything logs, so that a client, the web
server access log and the audit events of a request can be joined on an id
the client did not choose.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not settings.REQUEST_ID_TRUST_HEADER:
request_id_context.set(str(uuid.uuid4()))
response = self.get_response(request)
header = settings.DOCKERFLOW_REQUEST_ID_HEADER_NAME
if not response.has_header(header):
response[header] = current_request_id()
return response
+85
View File
@@ -0,0 +1,85 @@
"""Audit Django's authentication signals: login, failed login, logout."""
from django.contrib.auth import BACKEND_SESSION_KEY
from django.contrib.auth.signals import (
user_logged_in,
user_logged_out,
user_login_failed,
)
from .actions import Action
from .actor import AUTH_METHOD_UNKNOWN, auth_method_for_backend
from .emitter import log
from .enums import ActorType, EventCategory, EventType, Outcome, Reason
LOGIN_ACTION = Action(
"user.login", category=EventCategory.AUTHENTICATION, types=(EventType.START,)
)
LOGOUT_ACTION = Action(
"user.logout", category=EventCategory.AUTHENTICATION, types=(EventType.END,)
)
def get_login_backend(request, user) -> str | None:
"""Return the dotted path of the backend a login went through."""
session = getattr(request, "session", None)
from_session = session.get(BACKEND_SESSION_KEY) if session is not None else None
return from_session or getattr(user, "backend", None)
def auth_method_from_credentials(credentials) -> str:
"""Name the mechanism of a failed login from the credentials it submitted."""
if "password" in credentials:
return "password"
if "nonce" in credentials:
return "oidc"
return AUTH_METHOD_UNKNOWN
def on_user_logged_in(sender, request, user, **kwargs): # pylint: disable=unused-argument
"""Record a successful login."""
backend = get_login_backend(request, user)
log(
LOGIN_ACTION,
request=request,
actor=user,
auth_method=auth_method_for_backend(backend),
auth_backend=backend,
)
def on_user_login_failed(sender, credentials, request, **kwargs): # pylint: disable=unused-argument
"""Record a failed login.
It is a refusal, like a 401 on the API, whether the credentials were
rejected or a backend raised ``PermissionDenied``. Its actor is anonymous
even without a request, as when ``authenticate`` is called without one.
"""
log(
LOGIN_ACTION,
outcome=Outcome.DENIED,
reason=Reason.AUTHENTICATION_FAILED,
request=request,
actor_type=ActorType.ANONYMOUS,
auth_method=auth_method_from_credentials(credentials),
)
def on_user_logged_out(sender, request, user, **kwargs): # pylint: disable=unused-argument
"""Record a logout, unless no one was signed in."""
if user is None:
return
log(
LOGOUT_ACTION,
request=request,
actor=user,
)
def connect_auth_signals() -> None:
"""Connect the receivers to authentication signal."""
user_logged_in.connect(on_user_logged_in, dispatch_uid="audit.user_logged_in")
user_login_failed.connect(
on_user_login_failed, dispatch_uid="audit.user_login_failed"
)
user_logged_out.connect(on_user_logged_out, dispatch_uid="audit.user_logged_out")
+48
View File
@@ -0,0 +1,48 @@
"""Describe the resource an audit event is about.
The fields describing each model are those registered for it, see
``core.audit.registry``. A target is always identified by its model name and
primary key, so a model that is not registered is still identifiable, just
less detailed.
"""
import logging
from collections.abc import Mapping
from typing import Any
from django.contrib.auth import get_user_model
from django.db.models import Model
from .actor import describe_user
from .registry import model_options
from .utils import render_value
_logger = logging.getLogger(__name__)
def describe_target(obj: Any) -> dict[str, Any]:
"""Return ``{"type": ..., "id": ..., **fields}`` for a target.
A user will carries its OIDC sub and its email domain.
A registered field that cannot be read is left out and simply reported.
"""
if isinstance(obj, Mapping):
return dict(obj)
if not isinstance(obj, Model):
return {"type": obj.__class__.__name__.lower(), "id": str(obj)}
meta = obj._meta # noqa: SLF001
document: dict[str, Any] = {
"type": meta.model_name,
"id": str(obj.pk) if obj.pk is not None else None,
}
for name in model_options(meta.model).fields:
try:
document[name] = render_value(getattr(obj, name))
except Exception: # pylint: disable=broad-exception-caught
_logger.exception(
"Audit field %r of %s could not be read", name, meta.label
)
if isinstance(obj, get_user_model()):
document |= describe_user(obj)
return document
+62
View File
@@ -0,0 +1,62 @@
"""Helpers for asserting on audit events in tests."""
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import asdict
from typing import Any
from . import registry
from .actions import Action
from .emitter import AUDIT_LOGGER_NAME
class _CollectingHandler(logging.Handler):
"""Keep the documents attached to the records it receives."""
def __init__(self):
super().__init__(level=logging.DEBUG)
self.documents: list[dict[str, Any]] = []
def emit(self, record: logging.LogRecord) -> None:
document = getattr(record, "audit", None)
if not isinstance(document, dict):
document = {"message": record.getMessage()}
self.documents.append({**document, "log": {"level": record.levelname.lower()}})
@contextmanager
def capture_audit() -> Iterator[list[dict[str, Any]]]:
"""Collect the audit documents emitted inside the block"""
logger = logging.getLogger(AUDIT_LOGGER_NAME)
handler = _CollectingHandler()
previous_level = logger.level
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
try:
yield handler.documents
finally:
logger.removeHandler(handler)
logger.setLevel(previous_level)
def find_events(
events: list[dict[str, Any]], action: Action | str
) -> list[dict[str, Any]]:
"""Return the captured events whose ``event.action`` is ``action``."""
return [
event for event in events if event.get("event", {}).get("action") == str(action)
]
@contextmanager
def override_registration(model, **options) -> Iterator[None]:
"""Register ``model`` with ``options`` inside the block, whatever it was before."""
previous = registry.unregister(model)
registry.register(model, **options)
try:
yield
finally:
registry.unregister(model)
if previous is not None:
registry.register(model, **asdict(previous))
+48
View File
@@ -0,0 +1,48 @@
"""Value helpers used to assemble audit logs."""
from collections.abc import Mapping
from enum import Enum
from typing import Any
from django.db.models import Model, QuerySet
def render_value(value: Any) -> Any:
"""Render a value as something stable and JSON-friendly.
Model instances are reduced to their primary key, enums to their value.
"""
if isinstance(value, Model):
return str(value.pk)
if isinstance(value, Enum):
return value.value
if isinstance(value, Mapping):
return {str(key): render_value(item) for key, item in value.items()}
if isinstance(value, (QuerySet, list, tuple, set, frozenset)):
return [render_value(item) for item in value]
if value is None or isinstance(value, (bool, int, float, str)):
return value
return str(value)
def exception_type(error: BaseException) -> str:
"""Return the dotted name of an exception's class.
Audit events record it rather than the message, which may carry personal
data.
"""
error_class = type(error)
return f"{error_class.__module__}.{error_class.__qualname__}"
def prune_empty(value: Any) -> Any:
"""Drop ``None`` values and empty mappings, recursively."""
if not isinstance(value, Mapping):
return value
pruned = {}
for key, item in value.items():
cleaned = prune_empty(item)
if cleaned is None or (isinstance(cleaned, dict) and not cleaned):
continue
pruned[key] = cleaned
return pruned
+94
View File
@@ -0,0 +1,94 @@
"""What Meet audits, and what its audit events may say.
Imported by the audit app once it is ready, see ``core.audit.apps``.
"""
from django.contrib.auth.models import Group
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 (
AddonsJWTAuthentication,
ApplicationJWTAuthentication,
)
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, the ``admin_values``
# whose before and after values may be recorded in the admin, and the
# ``category`` of their admin writes: ``iam`` for anything granting access to
# the product, ``configuration`` by default.
audit.register(
models.User,
category=EventCategory.IAM,
admin_values=(
"is_active",
"is_staff",
"is_superuser",
"is_device",
"groups",
"user_permissions",
),
)
audit.register(Group, category=EventCategory.IAM, admin_values=("name", "permissions"))
audit.register(
models.Application,
category=EventCategory.IAM,
fields=("client_id", "name", "is_active", "scopes"),
admin_values=("name", "is_active", "scopes"),
)
audit.register(
models.ApplicationDomain, category=EventCategory.IAM, admin_values=("domain",)
)
audit.register(
models.ResourceAccess,
category=EventCategory.IAM,
fields=("resource_id", "user_id", "role"),
admin_values=("role",),
)
audit.register(
models.RecordingAccess, category=EventCategory.IAM, admin_values=("role",)
)
audit.register(
models.Room,
fields=("slug", "name", "access_level"),
admin_values=("name", "slug", "access_level", "configuration"),
)
audit.register(
models.Recording,
fields=("room_id", "status", "mode"),
admin_values=("status", "mode"),
)
audit.register(models.File, admin_values=("title", "upload_state"))
# Authentication classes and login backends -> ``lasuite.auth.method``
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(LiveKitTokenAuthentication, "livekit_token")
audit.register_auth_method(HeaderBasedAuthentication, "shared_secret")
audit.register_auth_method(ServerToServerAuthentication, "shared_secret")
+25 -8
View File
@@ -1,19 +1,26 @@
"""Authentication Backends for the Meet core app."""
import contextlib
from django.conf import settings
from django.core.exceptions import (
ImproperlyConfigured,
SuspiciousOperation,
ValidationError,
)
from django.utils.translation import gettext_lazy as _
from lasuite.marketing.tasks import create_or_update_contact
from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
)
from rest_framework.authentication import SessionAuthentication
from core.models import User
from core.services.marketing import (
ContactCreationError,
ContactData,
get_marketing_service,
)
from core.validators import sub_validator
@@ -60,15 +67,25 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
@staticmethod
def signup_to_marketing_email(email):
"""Add the user to the newsletter list on sign-in.
"""Pragmatic approach to newsletter signup during authentication flow.
Uses the team's standard Brevo integration, dispatching the contact
creation/update as an asynchronous task to keep authentication fast.
Details:
1. Uses a very short timeout (1s) to prevent blocking the auth process
2. Silently fails if the marketing service is down/slow to prioritize user experience
3. Trade-off: May miss some signups but ensures auth flow remains fast
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
"""
create_or_update_contact.delay(
email=email,
attributes={"VISIO_SOURCE": ["SIGNIN"]},
)
with contextlib.suppress(
ContactCreationError, ImproperlyConfigured, ImportError
):
marketing_service = get_marketing_service()
contact_data = ContactData(
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
)
marketing_service.create_contact(
contact_data, timeout=settings.BREVO_API_TIMEOUT
)
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
+13 -166
View File
@@ -4,51 +4,21 @@
# ruff: noqa: PLR0913
import logging
from dataclasses import asdict
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
import requests
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
from menshen_client import Configuration, IntrospectionRequest, TokenExchangeClient
from menshen_client.exceptions import ResponseParsingError
from rest_framework import authentication, exceptions
from core.models import Application, ApplicationScope
from core.models import Application
from core.services import jwt_token
User = get_user_model()
logger = logging.getLogger(__name__)
def get_bearer_token(request):
"""Extract the bearer token from the Authorization header.
Returns:
Token string, or None if the request carries no bearer token
Raises:
AuthenticationFailed: If the Authorization header is malformed
"""
auth_header = authentication.get_authorization_header(request).split()
if not auth_header or auth_header[0].lower() != b"bearer":
return None
if len(auth_header) != 2:
logger.warning("Invalid token header format")
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
return auth_header[1].decode("utf-8")
except UnicodeError as e:
logger.warning("Token decode error: %s", e)
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
@@ -101,12 +71,22 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
if not self.is_enabled:
return None
token = get_bearer_token(request)
auth_header = authentication.get_authorization_header(request).split()
if token is None:
if not auth_header or auth_header[0].lower() != b"bearer":
# Defer to next authentication backend
return None
if len(auth_header) != 2:
logger.warning("Invalid token header format")
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
token = auth_header[1].decode("utf-8")
except UnicodeError as e:
logger.warning("Token decode error: %s", e)
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
return self.authenticate_credentials(token)
def decode_jwt(self, token):
@@ -272,139 +252,6 @@ class AddonsJWTAuthentication(BaseJWTAuthentication):
)
# Menshen grants scopes following La Suite's "service:resource:action" convention.
# Map them to the scopes expected by the external API permissions.
MENSHEN_SCOPES_MAPPING = {
"meet:room:create": ApplicationScope.ROOMS_CREATE,
}
class MenshenAuthentication(authentication.BaseAuthentication):
"""Authentication for tokens exchanged through Menshen.
Menshen is La Suite's OAuth 2.0 token exchange server (RFC 8693): another service
exchanges its user's access token for a token targeting Meet, then calls the external
API on behalf of that user. Tokens are validated by introspection (RFC 7662). Menshen
only reports a token as active when Meet is among its audiences.
"""
def __init__(self):
"""Initialize the Menshen client from Django settings."""
super().__init__()
self._client = None
if not settings.MENSHEN_ENABLED:
return
self._client = TokenExchangeClient(
config=Configuration(
client_id=settings.MENSHEN_CLIENT_ID,
client_secret=settings.MENSHEN_CLIENT_SECRET,
server_root_url=settings.MENSHEN_SERVER_URL,
)
)
def authenticate(self, request):
"""Introspect the bearer token with Menshen.
Returns:
Tuple of (user, payload) if the token is active, None otherwise
"""
if self._client is None:
return None
token = get_bearer_token(request)
if token is None:
return None
payload = self.introspect(token)
if not payload.get("active"):
# Not a Menshen token, or an expired or revoked one: defer to next
# authentication backend
return None
user = self.get_user(payload)
scopes = payload.get("scope") or ""
payload["scope"] = [
MENSHEN_SCOPES_MAPPING[scope]
for scope in scopes.split()
if scope in MENSHEN_SCOPES_MAPPING
]
return (user, payload)
def introspect(self, token):
"""Submit the token to Menshen's introspection endpoint.
Errors are reported as an inactive token, so a Menshen outage doesn't
prevent the next authentication backends from running.
Args:
token: Bearer token string
Returns:
Introspection response dict
"""
try:
response = self._client.introspect(IntrospectionRequest(token=token))
except (requests.RequestException, ResponseParsingError) as e:
logger.warning("Menshen introspection failed: %s", e)
return {"active": False}
# Permission classes expect a dict payload in request.auth
return asdict(response)
def get_user(self, payload):
"""Retrieve or create the user from the introspection response.
Menshen forwards the `sub` and `email` of the subject token, as introspected
with the OIDC provider.
Args:
payload: Introspection response dict
Returns:
User instance
Raises:
AuthenticationFailed: If user not found or inactive
"""
sub = payload.get("sub")
if not sub:
logger.warning("Missing 'sub' in Menshen introspection response")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
user = User.objects.get(sub=sub)
except User.DoesNotExist as e:
if not settings.OIDC_CREATE_USER:
logger.warning("User not found: %s", sub)
raise exceptions.AuthenticationFailed("User not found.") from e
user = User(sub=sub, email=payload.get("email"))
user.set_unusable_password()
user.save()
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise exceptions.AuthenticationFailed("User account is disabled.")
return user
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
class ResourceServerBackend(LaSuiteBackend):
"""OIDC Resource Server backend for user creation and retrieval."""
+64 -26
View File
@@ -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,10 +202,16 @@ 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,
authentication.MenshenAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
@@ -192,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,
@@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
class MachineUser:
"""Represent a non-interactive system user for automated operations."""
"""Represent an internal service authenticated by a shared secret, not an account."""
def __init__(self, username: str = "machine_user") -> None:
self.pk = None
@@ -40,6 +40,8 @@ class HeaderBasedAuthentication(BaseAuthentication):
AUTH_HEADER = "Authorization"
TOKEN_TYPE = "Bearer" # noqa S105
REALM = ""
# Names the service in the audit log
MACHINE_USER_NAME = "machine_user"
EXPECTED_TOKEN_SETTINGS_KEY = None
@@ -74,7 +76,7 @@ class HeaderBasedAuthentication(BaseAuthentication):
)
raise AuthenticationFailed("Invalid token")
return MachineUser(), token
return MachineUser(self.MACHINE_USER_NAME), token
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
@@ -88,4 +90,5 @@ class RecordingProcessWebhookAuthentication(HeaderBasedAuthentication):
"""
REALM = "External process webhook API"
MACHINE_USER_NAME = "summary"
EXPECTED_TOKEN_SETTINGS_KEY = "SUMMARY_SERVICE_WEBHOOK_API_TOKEN" # noqa S105
+138
View File
@@ -0,0 +1,138 @@
"""Marketing service in charge of pushing data for marketing automation."""
import logging
from dataclasses import dataclass
from functools import lru_cache
from typing import Dict, List, Optional, Protocol
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
import brevo_python
import urllib3
logger = logging.getLogger(__name__)
class ContactCreationError(Exception):
"""Raised when the contact creation fails."""
@dataclass
class ContactData:
"""Contact data for marketing service integration."""
email: str
attributes: Optional[Dict[str, str]] = None
list_ids: Optional[List[int]] = None
update_enabled: bool = True
class MarketingServiceProtocol(Protocol):
"""Interface for marketing automation service integrations."""
def create_contact(
self, contact_data: ContactData, timeout: Optional[int] = None
) -> dict:
"""Create or update a contact.
Args:
contact_data: Contact information and attributes
timeout: API request timeout in seconds
Returns:
dict: Service response
Raises:
ContactCreationError: If contact creation fails
"""
class BrevoMarketingService:
"""Brevo marketing automation integration.
Handles:
- Contact management and segmentation
- Marketing campaigns and automation
- Email communications
Configuration via Django settings:
- BREVO_API_KEY: API authentication
- BREVO_API_CONTACT_LIST_IDS: Default contact lists
- BREVO_API_CONTACT_ATTRIBUTES: Default contact attributes
"""
def __init__(self):
"""Initialize Brevo (ex-sendinblue) marketing service."""
if not settings.BREVO_API_KEY:
raise ImproperlyConfigured("Brevo API key is required")
configuration = brevo_python.Configuration()
configuration.api_key["api-key"] = settings.BREVO_API_KEY
self._api_client = brevo_python.ApiClient(configuration)
def create_contact(self, contact_data: ContactData, timeout=None) -> dict:
"""Create or update a Brevo contact.
Args:
contact_data: Contact information and attributes
timeout: API request timeout in seconds
Returns:
dict: Brevo API response
Raises:
ContactCreationError: If contact creation fails
ImproperlyConfigured: If required settings are missing
Note:
Contact attributes must be pre-configured in Brevo.
Changes to attributes can impact existing workflows.
"""
if not settings.BREVO_API_CONTACT_LIST_IDS:
raise ImproperlyConfigured(
"Default Brevo List IDs must be configured in settings."
)
contact_api = brevo_python.ContactsApi(self._api_client)
attributes = {
**settings.BREVO_API_CONTACT_ATTRIBUTES,
**(contact_data.attributes or {}),
}
list_ids = (contact_data.list_ids or []) + settings.BREVO_API_CONTACT_LIST_IDS
contact = brevo_python.CreateContact(
email=contact_data.email,
attributes=attributes,
list_ids=list_ids,
update_enabled=contact_data.update_enabled,
)
api_configurations = {}
if timeout is not None:
api_configurations["_request_timeout"] = timeout
try:
response = contact_api.create_contact(contact, **api_configurations)
except (
brevo_python.rest.ApiException,
urllib3.exceptions.ReadTimeoutError,
) as err:
logger.warning("Failed to create contact in Brevo", exc_info=True)
raise ContactCreationError("Failed to create contact in Brevo") from err
return response
@lru_cache(maxsize=1)
def get_marketing_service() -> MarketingServiceProtocol:
"""Return cached instance of configured marketing service."""
marketing_service_cls = import_string(settings.MARKETING_SERVICE_CLASS)
return marketing_service_cls()
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Tests for the audit logging facility."""
+383
View File
@@ -0,0 +1,383 @@
"""Tests for the audit of writes made through the Django admin."""
import json
from unittest import mock
from django.test import override_settings
import pytest
from core import models
from core.audit.testing import find_events
from core.factories import (
FileFactory,
RecordingFactory,
RoomFactory,
UserFactory,
UserResourceAccessFactory,
)
pytestmark = pytest.mark.django_db
# Admin pages render static files: serve them without a manifest.
plain_storages = override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
}
)
@pytest.fixture(name="staff_client")
def staff_client_fixture(client):
"""A client signed in as a superuser, able to reach every admin page."""
client.force_login(UserFactory(is_staff=True, is_superuser=True))
return client
def room_payload(room=None, accesses=0, **overrides):
"""Return what the room change form expects, inline management included."""
payload = {
"name": room.name if room else "Weekly sync",
"slug": room.slug if room else "weekly-sync",
"access_level": room.access_level if room else models.RoomAccessLevel.PUBLIC,
"configuration": "{}",
# ``configuration`` has a callable default, so the form renders a hidden
# ``initial-`` input. Without it the field always looks changed.
"initial-configuration": "{}",
"pin_code": (room.pin_code if room else None) or "",
"accesses-TOTAL_FORMS": str(accesses),
"accesses-INITIAL_FORMS": "0",
"accesses-MIN_NUM_FORMS": "0",
"accesses-MAX_NUM_FORMS": "1000",
}
payload.update(overrides)
return payload
def test_room_creation_is_audited(audit_events, staff_client):
"""Adding a room through the admin records a creation on the room."""
response = staff_client.post("/admin/core/room/add/", room_payload())
assert response.status_code == 302
[event] = find_events(audit_events, "admin.room.create")
assert event["event"]["category"] == ["configuration"]
assert event["event"]["type"] == ["creation"]
assert event["event"]["outcome"] == "success"
assert event["lasuite"]["target"]["type"] == "room"
assert event["lasuite"]["target"]["slug"] == "weekly-sync"
assert event["lasuite"]["details"]["changes"]["name"] == {"to": "Weekly sync"}
def test_room_change_records_field_names_and_allowed_values(audit_events, staff_client):
"""A change reports the raw field names, and the values of allowed fields."""
room = RoomFactory(access_level=models.RoomAccessLevel.PUBLIC)
response = staff_client.post(
f"/admin/core/room/{room.pk}/change/",
room_payload(room, access_level=models.RoomAccessLevel.RESTRICTED),
)
assert response.status_code == 302
[event] = find_events(audit_events, "admin.room.update")
assert event["event"]["type"] == ["change"]
assert event["lasuite"]["details"]["changed_fields"] == ["access_level"]
assert event["lasuite"]["details"]["changes"] == {
"access_level": {"from": "public", "to": "restricted"}
}
def test_room_configuration_change_records_both_versions(audit_events, staff_client):
"""The configuration is allow-listed, and kept as JSON rather than stringified."""
room = RoomFactory(configuration={"a": 1})
response = staff_client.post(
f"/admin/core/room/{room.pk}/change/",
room_payload(
room,
configuration='{"a": 2, "b": "new"}',
**{"initial-configuration": '{"a": 1}'},
),
)
assert response.status_code == 302
[event] = find_events(audit_events, "admin.room.update")
assert event["lasuite"]["details"]["changed_fields"] == ["configuration"]
assert event["lasuite"]["details"]["changes"]["configuration"] == {
"from": {"a": 1},
"to": {"a": 2, "b": "new"},
}
def test_room_deletion_is_audited(audit_events, staff_client):
"""Deleting a room from its own page records a deletion."""
room = RoomFactory()
response = staff_client.post(f"/admin/core/room/{room.pk}/delete/", {"post": "yes"})
assert response.status_code == 302
[event] = find_events(audit_events, "admin.room.delete")
assert event["event"]["type"] == ["deletion"]
assert event["lasuite"]["target"]["id"] == str(room.pk)
def test_inline_access_grant_emits_its_own_iam_event(audit_events, staff_client):
"""A role granted through the inline is an IAM event of its own."""
room = RoomFactory()
user = UserFactory()
response = staff_client.post(
f"/admin/core/room/{room.pk}/change/",
room_payload(
room,
accesses=1,
**{
"accesses-0-user": str(user.pk),
"accesses-0-role": models.RoleChoices.OWNER,
"accesses-0-id": "",
"accesses-0-resource": str(room.pk),
},
),
)
assert response.status_code == 302
[room_event] = find_events(audit_events, "admin.room.update")
[access_event] = find_events(audit_events, "admin.resourceaccess.create")
assert access_event["event"]["category"] == ["iam"]
assert access_event["event"]["type"] == ["creation"]
assert access_event["lasuite"]["target"]["role"] == "owner"
assert access_event["lasuite"]["details"]["changes"]["role"] == {"to": "owner"}
# The grant and the room change belong to the same request.
assert access_event["trace"]["id"] == room_event["trace"]["id"]
def test_user_change_targets_the_user_and_never_leaks_the_password(
audit_events, staff_client
):
"""Promoting a user is an IAM event naming the account, never its secret."""
user = UserFactory(email="promoted@example.com", is_staff=False)
response = staff_client.post(
f"/admin/core/user/{user.pk}/password/",
{
"usable_password": "true",
"password1": "sup3r-s3cret-value",
"password2": "sup3r-s3cret-value",
},
)
assert response.status_code == 302
[event] = find_events(audit_events, "admin.user.update")
assert event["event"]["category"] == ["iam"]
assert event["event"]["type"] == ["user", "change"]
assert event["user"]["target"] == {
"id": str(user.pk),
"sub": user.sub,
"domain": "example.com",
}
assert event["lasuite"]["details"]["changed_fields"] == ["password"]
assert "changes" not in event["lasuite"]["details"]
assert "sup3r-s3cret-value" not in json.dumps(event)
def test_user_permission_change_records_the_flag_values(audit_events, staff_client):
"""Staff and superuser flags are allow-listed, so their values are kept."""
user = UserFactory(is_staff=False, is_superuser=False)
response = staff_client.post(
f"/admin/core/user/{user.pk}/change/",
{
"admin_email": "",
"language": user.language,
"timezone": str(user.timezone),
"is_active": "on",
"is_staff": "on",
"files_created-TOTAL_FORMS": "0",
"files_created-INITIAL_FORMS": "0",
"files_created-MIN_NUM_FORMS": "0",
"files_created-MAX_NUM_FORMS": "1000",
},
)
assert response.status_code == 302
[event] = find_events(audit_events, "admin.user.update")
assert event["lasuite"]["details"]["changes"]["is_staff"] == {
"from": False,
"to": True,
}
assert event["user"]["target"]["id"] == str(user.pk)
def test_bulk_delete_audits_each_object_but_not_the_action(audit_events, staff_client):
"""``delete_selected`` reports its objects, and nothing about itself."""
recordings = RecordingFactory.create_batch(2)
response = staff_client.post(
"/admin/core/recording/",
{
"action": "delete_selected",
"_selected_action": [str(recording.pk) for recording in recordings],
"post": "yes",
},
)
assert response.status_code == 302
events = find_events(audit_events, "admin.recording.delete")
assert {event["lasuite"]["target"]["id"] for event in events} == {
str(recording.pk) for recording in recordings
}
assert find_events(audit_events, "admin.recording.action") == []
def test_custom_action_is_audited(audit_events, staff_client):
"""A custom admin action reports its name and how many objects it ran on."""
recordings = RecordingFactory.create_batch(2)
response = staff_client.post(
"/admin/core/recording/",
{
"action": "mark_as_failed_to_stop",
"_selected_action": [str(recording.pk) for recording in recordings],
},
)
assert response.status_code == 302
[event] = find_events(audit_events, "admin.recording.action")
assert event["event"]["outcome"] == "success"
assert event["lasuite"]["details"] == {
"admin_action": "mark_as_failed_to_stop",
"count": 2,
}
def test_hard_deleted_file_is_audited_once(audit_events, staff_client):
"""``FileAdmin`` hard deletes without going through ``Model.delete``."""
file = FileFactory()
response = staff_client.post(f"/admin/core/file/{file.pk}/delete/", {"post": "yes"})
assert response.status_code == 302
[event] = find_events(audit_events, "admin.file.delete")
assert event["lasuite"]["target"]["id"] == str(file.pk)
def test_failed_deletion_is_audited_as_a_failure(audit_events, staff_client):
"""A deletion that raises is recorded as failed, never as done."""
file = FileFactory()
with (
mock.patch("core.admin.hard_delete_file", side_effect=RuntimeError("S3 down")),
pytest.raises(RuntimeError),
):
staff_client.post(f"/admin/core/file/{file.pk}/delete/", {"post": "yes"})
[event] = find_events(audit_events, "admin.file.delete")
assert event["event"]["type"] == ["deletion"]
assert event["event"]["reason"] == "internal_error"
assert event["lasuite"]["outcome"] == "failure"
assert event["lasuite"]["target"]["id"] == str(file.pk)
assert event["error"] == {"message": "S3 down"}
assert event["log"]["level"] == "error"
def test_failed_bulk_deletion_is_audited_as_a_failure(audit_events, staff_client):
"""A bulk deletion that raises reports every selected object as failed."""
files = FileFactory.create_batch(2)
with (
mock.patch("core.admin.hard_delete_file", side_effect=RuntimeError("S3 down")),
pytest.raises(RuntimeError),
):
staff_client.post(
"/admin/core/file/",
{
"action": "delete_selected",
"_selected_action": [str(file.pk) for file in files],
"post": "yes",
},
)
events = find_events(audit_events, "admin.file.delete")
assert {event["lasuite"]["target"]["id"] for event in events} == {
str(file.pk) for file in files
}
assert {event["lasuite"]["outcome"] for event in events} == {"failure"}
@plain_storages
def test_reading_the_admin_emits_nothing(audit_events, staff_client):
"""Browsing is not audited: only writes are."""
room = RoomFactory()
UserResourceAccessFactory(resource=room, user=UserFactory())
assert staff_client.get("/admin/").status_code == 200
assert staff_client.get("/admin/core/room/").status_code == 200
assert staff_client.get(f"/admin/core/room/{room.pk}/change/").status_code == 200
assert staff_client.get(f"/admin/core/room/{room.pk}/history/").status_code == 200
assert [
event["event"]["action"]
for event in audit_events
if event["event"]["action"].startswith("admin.")
] == []
def test_non_staff_user_reaching_the_admin_is_recorded(audit_events, client):
"""A signed-in account without staff access trying the admin is a denial."""
client.force_login(UserFactory(is_staff=False))
response = client.get("/admin/core/room/")
assert response.status_code == 302
[event] = find_events(audit_events, "admin.access")
assert event["event"]["category"] == ["iam"]
assert event["event"]["reason"] == "permission_denied"
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["auth"] == {"method": "session"}
assert event["http"]["response"] == {"status_code": 302}
assert event["log"]["level"] == "warning"
@plain_storages
def test_non_staff_user_is_recorded_once_per_refused_view(audit_events, client):
"""Landing on the login page after the refusal records nothing more."""
client.force_login(UserFactory(is_staff=False))
response = client.get("/admin/", follow=True)
assert response.redirect_chain[-1][0].startswith("/admin/login/")
assert response.status_code == 200
assert len(find_events(audit_events, "admin.access")) == 1
def test_anonymous_visitor_is_not_recorded(audit_events, client):
"""An anonymous hit is a redirect to the login page, not a denial worth keeping."""
assert client.get("/admin/").status_code == 302
assert find_events(audit_events, "admin.access") == []
+363
View File
@@ -0,0 +1,363 @@
"""Tests for the audit of DRF views through ``AuditViewMixin``."""
# pylint: disable=missing-function-docstring,unused-argument
from unittest import mock
from django.core.exceptions import PermissionDenied as DjangoPermissionDenied
import pytest
from rest_framework import (
decorators,
exceptions,
mixins,
permissions,
routers,
viewsets,
)
from rest_framework.response import Response
from rest_framework.test import APIRequestFactory
from core import audit, models
from core.audit.testing import find_events
from core.authentication.backends import SessionAuthenticationWith401
from core.factories import RoomFactory
pytestmark = pytest.mark.django_db
class ThingViewSet(audit.AuditViewMixin, viewsets.ViewSet):
"""A viewset auditing ``list`` and ``create`` but not ``destroy``."""
authentication_classes = [SessionAuthenticationWith401]
permission_classes = []
audit_actions = {"list": "thing.list", "create": "thing.create"}
error = None
def list(self, request):
if self.error is not None:
raise self.error
self.audit_details = {"total": 3}
return Response([])
def create(self, request):
return Response({"error": "Already exists."}, status=409)
def destroy(self, request, pk=None):
return Response(status=204)
class RoomViewSet(
audit.AuditViewMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
"""A viewset whose target comes from ``get_object``."""
authentication_classes = []
permission_classes = []
queryset = models.Room.objects.all()
audit_actions = {"retrieve": "room.retrieve"}
def get_serializer(self, *args, **kwargs):
return type("Serializer", (), {"data": {}})()
GRANT = audit.Action(
"thing.grant",
category=audit.EventCategory.IAM,
types=(audit.EventType.CREATION,),
)
class GrantViewSet(audit.AuditViewMixin, viewsets.ViewSet):
"""A viewset whose extra action declares its audit on the route."""
authentication_classes = [SessionAuthenticationWith401]
permission_classes = []
error = None
@decorators.action(detail=False, methods=["post"], audit_action=GRANT)
def grant(self, request):
if self.error is not None:
raise self.error
return Response({})
@decorators.action(detail=False, methods=["post"])
def ping(self, request):
return Response({})
class ListingViewSet(ThingViewSet):
"""A ``ThingViewSet`` keeping the details it was built with."""
def list(self, request):
return Response([])
class DenyObjects(permissions.BasePermission):
"""Refuse every object, whatever the request."""
def has_object_permission(self, request, view, obj):
return False
def test_success_is_audited_with_the_view_details(audit_events):
"""A successful action is recorded with its type and the view's details."""
view = ThingViewSet.as_view({"get": "list"})
response = view(APIRequestFactory().get("/things/", REMOTE_ADDR="1.2.3.4"))
assert response.status_code == 200
[event] = find_events(audit_events, "thing.list")
assert event["event"]["category"] == ["api"]
assert event["event"]["type"] == ["access"]
assert event["event"]["outcome"] == "success"
assert event["lasuite"]["details"] == {"total": 3}
assert event["client"] == {"ip": "1.2.3.4"}
assert event["url"] == {"path": "/things/"}
assert event["http"] == {"request": {"method": "GET"}}
def test_missing_credentials_are_audited_as_authentication_denial(audit_events):
"""A 401 is a denial in the authentication category."""
view = ThingViewSet.as_view({"get": "list"}, error=exceptions.NotAuthenticated())
response = view(APIRequestFactory().get("/things/"))
assert response.status_code == 401
[event] = find_events(audit_events, "thing.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 event["http"]["response"] == {"status_code": 401}
assert event["error"]["message"] == "Authentication credentials were not provided."
assert event["log"]["level"] == "warning"
assert "details" not in event["lasuite"]
@pytest.mark.parametrize(
"error,status_code,outcome,reason",
[
(
exceptions.AuthenticationFailed("bad"),
401,
"denied",
"authentication_failed",
),
(exceptions.PermissionDenied("scope"), 403, "denied", "permission_denied"),
(DjangoPermissionDenied("nope"), 403, "denied", "permission_denied"),
(exceptions.Throttled(wait=10), 429, "denied", "rate_limited"),
(
exceptions.ValidationError({"name": ["x"]}),
400,
"failure",
"validation_error",
),
(exceptions.NotFound(), 404, "failure", "not_found"),
(exceptions.MethodNotAllowed("PUT"), 405, "failure", None),
],
)
def test_errors_are_audited_from_the_status_code(
audit_events, error, status_code, outcome, reason
):
"""The outcome and the reason are derived from the response status."""
view = ThingViewSet.as_view({"get": "list"}, error=error)
response = view(APIRequestFactory().get("/things/"))
assert response.status_code == status_code
[event] = find_events(audit_events, "thing.list")
assert event["lasuite"]["outcome"] == outcome
assert event["event"].get("reason") == reason
assert event["http"]["response"] == {"status_code": status_code}
def test_error_message_of_a_view_response(audit_events):
"""An error response built by the view reports its ``error`` message."""
view = ThingViewSet.as_view({"post": "create"})
response = view(APIRequestFactory().post("/things/"))
assert response.status_code == 409
[event] = find_events(audit_events, "thing.create")
assert event["event"]["type"] == ["creation"]
assert event["event"]["reason"] == "conflict"
assert event["lasuite"]["outcome"] == "failure"
assert event["error"] == {"message": "Already exists."}
def test_actions_missing_from_the_map_are_not_audited(audit_events):
"""Only the actions listed in ``audit_actions`` emit events."""
view = ThingViewSet.as_view({"delete": "destroy"})
response = view(APIRequestFactory().delete("/things/1/"), pk="1")
assert response.status_code == 204
assert audit_events == []
def test_unhandled_exception_is_audited_as_internal_error(audit_events):
"""An exception DRF does not handle is recorded, by class only, then raised."""
view = ThingViewSet.as_view(
{"get": "list"}, error=RuntimeError("user@example.com is broken")
)
with pytest.raises(RuntimeError):
view(APIRequestFactory().get("/things/"))
[event] = find_events(audit_events, "thing.list")
assert event["event"]["type"] == ["access"]
assert event["event"]["reason"] == "internal_error"
assert event["lasuite"]["outcome"] == "failure"
assert event["http"]["response"] == {"status_code": 500}
assert event["error"] == {"type": "builtins.RuntimeError"}
assert event["log"]["level"] == "error"
assert "user@example.com" not in str(event)
def test_object_permission_denial_keeps_the_target(audit_events):
"""A refusal on a detail route names the object that was refused."""
room = RoomFactory()
view = RoomViewSet.as_view({"get": "retrieve"}, permission_classes=[DenyObjects])
response = view(APIRequestFactory().get(f"/rooms/{room.pk}/"), pk=str(room.pk))
assert response.status_code == 403
[event] = find_events(audit_events, "room.retrieve")
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["target"]["id"] == str(room.pk)
def test_object_of_a_detail_route_is_the_target(audit_events):
"""The object of a detail route becomes the target of the event."""
room = RoomFactory()
view = RoomViewSet.as_view({"get": "retrieve"})
response = view(APIRequestFactory().get(f"/rooms/{room.pk}/"), pk=str(room.pk))
assert response.status_code == 200
[event] = find_events(audit_events, "room.retrieve")
assert event["lasuite"]["target"]["id"] == str(room.pk)
assert event["lasuite"]["target"]["type"] == "room"
def test_extra_actions_cannot_be_mapped_by_method_name():
"""Renaming a method must not silently stop auditing it."""
with pytest.raises(TypeError, match="grant"):
class MappedViewSet(audit.AuditViewMixin, viewsets.ViewSet): # pylint: disable=unused-variable
"""Maps an extra action in ``audit_actions``."""
audit_actions = {"list": "thing.list", "grant": "thing.grant"}
def test_extra_action_is_audited_from_its_route(audit_events):
"""The ``audit_action`` of a routed ``@action`` names the event."""
router = routers.SimpleRouter()
router.register("things", GrantViewSet, basename="thing")
[route] = [url for url in router.urls if url.name == "thing-grant"]
response = route.callback(APIRequestFactory().post("/things/grant/"))
assert response.status_code == 200
[event] = find_events(audit_events, GRANT)
assert event["event"]["category"] == ["iam"]
assert event["event"]["type"] == ["creation"]
def test_extra_action_is_audited_without_a_router(audit_events):
"""A view built by hand reads ``audit_action`` from its handler."""
view = GrantViewSet.as_view({"post": "grant"})
response = view(APIRequestFactory().post("/things/grant/"))
assert response.status_code == 200
assert len(find_events(audit_events, GRANT)) == 1
def test_extra_action_without_audit_action_is_not_audited(audit_events):
"""An ``@action`` that does not name an audit action emits nothing."""
view = GrantViewSet.as_view({"post": "ping"})
response = view(APIRequestFactory().post("/things/ping/"))
assert response.status_code == 200
assert audit_events == []
def test_unauthenticated_extra_action_is_an_authentication_denial(audit_events):
"""A 401 still files the event under ``authentication``, whatever the spec."""
view = GrantViewSet.as_view({"post": "grant"}, error=exceptions.NotAuthenticated())
response = view(APIRequestFactory().post("/things/grant/"))
assert response.status_code == 401
[event] = find_events(audit_events, GRANT)
assert event["event"]["category"] == ["authentication"]
assert event["event"]["type"] == ["creation", "denied"]
@pytest.mark.parametrize("method", ["options", "get"])
def test_requests_reaching_no_extra_action_are_not_audited(audit_events, method):
"""An OPTIONS request, or a method the route refuses, audits nothing.
The router hands the route's ``audit_action`` to every view it builds, the
ones answering those requests included.
"""
router = routers.SimpleRouter()
router.register("things", GrantViewSet, basename="thing")
[route] = [url for url in router.urls if url.name == "thing-grant"]
response = route.callback(getattr(APIRequestFactory(), method)("/things/grant/"))
assert response.status_code == (200 if method == "options" else 405)
assert audit_events == []
def test_details_never_override_event_fields(audit_events):
"""A detail named after an event field is dropped, never raising."""
view = ListingViewSet.as_view(
{"get": "list"},
audit_details={"request": None, "outcome": "failure", "total": 3},
)
response = view(APIRequestFactory().get("/things/"))
assert response.status_code == 200
[event] = find_events(audit_events, "thing.list")
assert event["event"]["outcome"] == "success"
assert event["url"] == {"path": "/things/"}
assert event["lasuite"]["details"] == {"total": 3}
def test_failing_audit_never_fails_the_response(audit_events):
"""An error assembling the event is logged, and the response is kept."""
view = ThingViewSet.as_view({"get": "list"})
with mock.patch.object(
ThingViewSet, "get_audit_fields", side_effect=RuntimeError("boom")
):
response = view(APIRequestFactory().get("/things/"))
assert response.status_code == 200
assert audit_events == []
@@ -0,0 +1,454 @@
"""Tests for building and emitting audit events."""
import json
import logging
import sys
from datetime import datetime
from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory
import pytest
from dockerflow.logging import request_id_context
from core import audit
from core.audit.formatter import AuditJsonFormatter
from core.audit.testing import find_events, override_registration
from core.factories import RoomFactory, UserFactory
from core.models import Room
from core.recording.event.authentication import MachineUser
pytestmark = pytest.mark.django_db
def test_audit_log_emits_ecs_document(audit_events):
"""A minimal call produces a complete, pruned ECS document."""
audit.log("room.create", target={"type": "room", "id": "1"}, extra="x")
assert len(audit_events) == 1
event = audit_events[0]
assert event["log_type"] == "audit"
assert event["ecs"] == {"version": "8.11.0"}
assert event["service"] == {"name": "meet", "environment": "test"}
assert event["event"] == {
"kind": "event",
"action": "room.create",
"category": ["web"],
"type": ["info"],
"outcome": "success",
}
assert event["lasuite"] == {
"actor": {"type": "system"},
"auth": {"method": "none"},
"outcome": "success",
"target": {"type": "room", "id": "1"},
"details": {"extra": "x"},
}
assert event["log"]["level"] == "info"
assert "user" not in event
assert "organization" not in event
def test_audit_log_timestamp_is_utc_with_explicit_offset(audit_events):
"""Timestamps are ISO 8601, millisecond precision, UTC with offset."""
audit.log("something")
timestamp = audit_events[0]["@timestamp"]
parsed = datetime.fromisoformat(timestamp)
assert timestamp.endswith("+00:00")
assert parsed.utcoffset().total_seconds() == 0
@pytest.mark.parametrize(
"outcome,reason,expected",
[
("success", None, ("info", "success", ["info"])),
("failure", "validation_error", ("warning", "failure", ["error"])),
("denied", "permission_denied", ("warning", "failure", ["denied"])),
("failure", "internal_error", ("error", "failure", ["error"])),
],
)
def test_audit_log_outcome_reason_and_level(audit_events, outcome, reason, expected):
"""The level is derived from the outcome."""
level, wire_outcome, types = expected
audit.log("something", outcome=outcome, reason=reason)
event = audit_events[0]
assert event["event"]["outcome"] == wire_outcome
assert event["event"].get("reason") == reason
assert event["event"]["type"] == types
assert event["lasuite"]["outcome"] == outcome
assert event["log"]["level"] == level
def test_audit_log_denied_adds_denied_type_to_explicit_types(audit_events):
"""A denial always carries the ``denied`` ECS type."""
audit.log("something", outcome="denied", reason="rate_limited", types=["access"])
event = audit_events[0]
assert event["event"]["type"] == ["access", "denied"]
def test_audit_log_accepts_categories_and_types(audit_events):
"""Category and types are validated against the ECS subset."""
audit.log(
"user.login",
category=audit.EventCategory.AUTHENTICATION,
types=[audit.EventType.START],
)
event = audit_events[0]
assert event["event"]["category"] == ["authentication"]
assert event["event"]["type"] == ["start"]
def test_audit_log_classifies_an_action_by_its_spec(audit_events):
"""An ``Action`` brings its category and types, and names the event."""
action = audit.Action(
"thing.grant",
category=audit.EventCategory.IAM,
types=(audit.EventType.CREATION,),
)
audit.log(action)
event = audit_events[0]
assert event["event"]["action"] == "thing.grant"
assert event["event"]["category"] == ["iam"]
assert event["event"]["type"] == ["creation"]
def test_audit_log_arguments_win_over_the_spec(audit_events):
"""A category or types given to ``log`` override those of the ``Action``."""
action = audit.Action(
"thing.grant",
category=audit.EventCategory.IAM,
types=(audit.EventType.CREATION,),
)
audit.log(
action,
category=audit.EventCategory.CONFIGURATION,
types=[audit.EventType.CHANGE],
)
event = audit_events[0]
assert event["event"]["category"] == ["configuration"]
assert event["event"]["type"] == ["change"]
def test_action_spec_validates_its_classification():
"""A category or type outside the ECS subset fails where it is declared."""
with pytest.raises(ValueError):
audit.Action("thing.grant", category="nonsense")
with pytest.raises(ValueError):
audit.Action("thing.grant", types=("nonsense",))
def test_audit_log_records_the_error_type(audit_events):
"""The class of an error lands in ``error.type``, next to its message."""
audit.log(
"anything",
outcome="failure",
reason="internal_error",
error="boom",
error_type="builtins.RuntimeError",
)
assert audit_events[0]["error"] == {
"message": "boom",
"type": "builtins.RuntimeError",
}
def test_audit_log_fails_open_on_invalid_input(audit_events, caplog):
"""A bad call never raises: it is reported on the application logger."""
with caplog.at_level(logging.ERROR, logger="core.audit.emitter"):
audit.log("something", outcome="maybe")
assert audit_events == []
assert "could not be built" in caplog.text
def test_audit_log_skips_a_target_field_that_cannot_be_read(audit_events, caplog):
"""A broken registration costs the field, not the event."""
room = RoomFactory()
with (
override_registration(Room, fields=("no_such_field", "slug")),
caplog.at_level(logging.ERROR, logger="core.audit.targets"),
):
audit.log("anything", target=room)
[event] = audit_events
assert event["lasuite"]["target"] == {
"type": "room",
"id": str(room.pk),
"slug": room.slug,
}
assert "no_such_field" in caplog.text
def test_audit_log_describes_registered_targets(audit_events):
"""Describe rooms with the fields registered in ``core.auditing``."""
room = RoomFactory(name="Daily standup")
audit.log("room.create", target=room)
assert audit_events[0]["lasuite"]["target"] == {
"type": "room",
"id": str(room.pk),
"slug": room.slug,
"name": "Daily standup",
"access_level": room.access_level,
}
def test_audit_log_normalises_details(audit_events):
"""Nested details are rendered: enums, models as keys, lists, no ``None``."""
room = RoomFactory()
audit.log(
"something",
rooms=[room],
nested={"outcome": audit.Outcome.DENIED},
empty=None,
)
details = audit_events[0]["lasuite"]["details"]
assert details["rooms"] == [str(room.pk)]
assert details["nested"] == {"outcome": "denied"}
assert "empty" not in details
def test_audit_log_reads_request_fields(audit_events):
"""Should read the HTTP fields from the request, the trace id from dockerflow."""
token = request_id_context.set("trace-1")
request = RequestFactory().post(
"/external-api/v1.0/rooms/",
data="{}",
content_type="application/json",
REMOTE_ADDR="1.2.3.4",
)
try:
audit.log("anything", request=request)
finally:
request_id_context.reset(token)
event = audit_events[0]
assert event["http"] == {"request": {"method": "POST"}}
assert event["url"] == {"path": "/external-api/v1.0/rooms/"}
assert event["client"] == {"ip": "1.2.3.4"}
assert event["trace"] == {"id": "trace-1"}
def test_audit_log_reports_the_client_not_the_proxy(audit_events):
"""Should report the forwarded client address, not the one of the proxy."""
request = RequestFactory().get(
"/", REMOTE_ADDR="1.2.3.4", HTTP_X_FORWARDED_FOR="5.6.7.8"
)
audit.log("something", request=request)
event = audit_events[0]
assert event["client"]["ip"] == "5.6.7.8"
assert event["source"] == {"ip": "5.6.7.8"}
def test_audit_log_actor_user_is_id_sub_and_domain_only(audit_events):
"""A human actor is identified without email or name."""
user = UserFactory(email="john.doe@example.com", full_name="John Doe")
request = RequestFactory().get("/")
request.user = user
audit.log("anything", request=request)
event = audit_events[0]
assert event["user"] == {
"id": str(user.pk),
"sub": user.sub,
"domain": "example.com",
}
assert event["lasuite"]["actor"] == {"type": "user"}
assert event["lasuite"]["auth"] == {"method": "session"}
assert event["organization"] == {"id": "example.com"}
assert "John" not in json.dumps(event)
assert "john.doe" not in json.dumps(event)
def test_audit_log_anonymous_plain_request_has_no_auth_method(audit_events):
"""A plain Django request without a signed-in user is not authenticated."""
request = RequestFactory().get("/")
request.user = AnonymousUser()
audit.log("anything", request=request)
assert audit_events[0]["lasuite"]["auth"] == {"method": "none"}
def test_audit_log_actor_application_with_delegated_user(audit_events):
"""A client id in the token payload makes the actor an application."""
user = UserFactory(email="user@example.com")
request = RequestFactory().get("/")
request.user = user
request.auth = {"client_id": "app-1", "delegated": True}
audit.log("something", request=request)
event = audit_events[0]
assert event["lasuite"]["actor"] == {"type": "application"}
assert event["lasuite"]["application"] == {"client_id": "app-1"}
assert event["user"] == {
"id": str(user.pk),
"sub": user.sub,
"domain": "example.com",
}
assert event["organization"] == {"id": "app-1"}
def test_audit_log_actor_service(audit_events):
"""Machine users are services identified by name."""
request = RequestFactory().get("/")
request.user = MachineUser("roomkit")
audit.log("something", request=request)
event = audit_events[0]
assert event["lasuite"]["actor"] == {"type": "service", "name": "roomkit"}
assert "user" not in event
assert "organization" not in event
def test_audit_log_actor_deleted_user_is_not_a_service(audit_events):
"""A deleted account has no primary key left, yet it is still a user.
A service is named by its username, which for an account is an email address.
"""
user = UserFactory(email="john.doe@example.com", admin_email="admin@example.com")
user.delete()
request = RequestFactory().get("/")
request.user = user
audit.log("anything", request=request)
event = audit_events[0]
assert event["lasuite"]["actor"] == {"type": "user"}
assert event["user"] == {"sub": user.sub, "domain": "example.com"}
assert "admin@example.com" not in json.dumps(event)
def test_audit_log_explicit_overrides(audit_events):
"""Actor, actor type, auth method and client id can be forced."""
user = UserFactory(email="user@example.com")
audit.log(
"something",
actor=user,
actor_type="system",
auth_method="oidc",
client_id="app-2",
)
event = audit_events[0]
assert event["lasuite"]["actor"] == {"type": "system"}
assert event["lasuite"]["auth"] == {"method": "oidc"}
assert event["lasuite"]["application"] == {"client_id": "app-2"}
assert event["user"]["id"] == str(user.pk)
assert event["organization"] == {"id": "app-2"}
def test_audit_log_status_code_error_and_message(audit_events):
"""Response status, error message and free text have their ECS slots."""
audit.log(
"something",
outcome="denied",
reason="permission_denied",
status_code=403,
error="Insufficient permissions.",
message="scope missing",
)
event = audit_events[0]
assert event["http"] == {"response": {"status_code": 403}}
assert event["error"] == {"message": "Insufficient permissions."}
assert event["message"] == "scope missing"
def test_audit_json_formatter_renders_one_line_of_json():
"""The formatter emits compact, single-line, non-ASCII friendly JSON."""
record = logging.makeLogRecord(
{
"name": "audit",
"levelname": "INFO",
"msg": "anything",
"audit": {"event": {"action": "anything"}, "note": "multi\nline wörld"},
}
)
rendered = AuditJsonFormatter().format(record)
assert "\n" not in rendered
assert "wörld" in rendered
assert json.loads(rendered) == {
"event": {"action": "anything"},
"note": "multi\nline wörld",
"log": {"level": "info", "logger": "audit"},
}
def test_audit_json_formatter_wraps_plain_records():
"""A plain record on the audit logger still renders as JSON."""
record = logging.makeLogRecord(
{"name": "audit", "levelname": "WARNING", "msg": "log %s", "args": ("x",)}
)
rendered = json.loads(AuditJsonFormatter().format(record))
assert rendered["log_type"] == "audit"
assert rendered["event"] == {"action": "log x"}
assert rendered["message"] == "log x"
assert rendered["@timestamp"].endswith("+00:00")
def test_audit_json_formatter_adds_stack_trace():
"""An attached traceback lands under ``error.stack_trace``."""
try:
raise ValueError("boom")
except ValueError:
record = logging.makeLogRecord(
{"name": "audit", "levelname": "ERROR", "msg": "x", "audit": {}}
)
record.exc_info = sys.exc_info()
rendered = json.loads(AuditJsonFormatter().format(record))
assert "ValueError: boom" in rendered["error"]["stack_trace"]
def test_find_events_filters_by_action(audit_events):
"""The test helper narrows captured events by action."""
audit.log("first")
audit.log("second")
found = find_events(audit_events, "second")
assert [event["event"]["action"] for event in found] == ["second"]
@@ -0,0 +1,96 @@
"""Tests for the registry of audited models and authentication classes."""
from types import SimpleNamespace
from django.contrib.auth.models import Group
import pytest
from core import audit
from core.audit.actor import auth_method_for, auth_method_for_backend
from core.audit.registry import (
ModelOptions,
auth_methods,
dotted_path,
model_options,
unregister,
)
from core.audit.testing import override_registration
from core.external_api.authentication import ApplicationJWTAuthentication
from core.models import Resource, Room
def test_register_twice_is_refused():
"""A model is registered once, like in the admin."""
audit.register(Resource, fields=("id",))
try:
with pytest.raises(audit.AlreadyRegistered):
audit.register(Resource)
finally:
unregister(Resource)
def test_register_refuses_unknown_options():
"""A misspelled option is an error, not silently ignored."""
with pytest.raises(TypeError):
audit.register(Resource, field=("name",)) # pylint: disable=unexpected-keyword-arg
assert model_options(Resource) == ModelOptions()
def test_register_refuses_unknown_categories():
"""A category outside the ECS subset fails where it is registered."""
with pytest.raises(ValueError):
audit.register(Resource, category="nonsense")
assert model_options(Resource) == ModelOptions()
def test_model_options_falls_back_to_the_concrete_model():
"""A proxy model is described as the model it proxies."""
proxy = type("ProxyRoom", (), {"_meta": SimpleNamespace(concrete_model=Room)})
with override_registration(Room, fields=("slug",)):
assert model_options(proxy).fields == ("slug",)
def test_override_registration_restores_the_previous_one():
"""The test helper puts back what the project registered."""
registered = model_options(Room)
with override_registration(Room, fields=("slug",)):
assert model_options(Room).fields == ("slug",)
assert model_options(Room) == registered
def test_project_declarations_are_discovered():
"""``core.auditing`` is imported when the audit app is ready."""
assert model_options(Room).fields == ("slug", "name", "access_level")
assert model_options(Group).category == audit.EventCategory.IAM
assert auth_methods()[dotted_path(ApplicationJWTAuthentication)] == (
"application_jwt"
)
assert (
auth_method_for_backend(
"core.authentication.backends.OIDCAuthenticationBackend"
)
== "oidc"
)
def test_register_auth_method_twice_is_refused():
"""An authentication class is named once."""
with pytest.raises(audit.AlreadyRegistered):
audit.register_auth_method(ApplicationJWTAuthentication, "other")
def test_auth_method_is_inherited_by_subclasses():
"""A DRF class takes the name of its closest registered base."""
class CustomAuthentication(ApplicationJWTAuthentication):
"""A project subclass nobody registered."""
authenticator = object.__new__(CustomAuthentication)
assert auth_method_for(authenticator) == "application_jwt"
@@ -0,0 +1,192 @@
"""Tests for the network fields and the request id of audit events."""
import uuid
from django.http import HttpResponse
from django.test import RequestFactory
import pytest
from dockerflow.logging import request_id_context
from faker import Faker
from core.api.throttling import CreationCallbackAnonRateThrottle
from core.audit import request as audit_request
fake = Faker()
def _set_num_proxies(settings, count):
"""Trust ``count`` proxies, as DRF's ``NUM_PROXIES`` setting."""
settings.REST_FRAMEWORK = {**settings.REST_FRAMEWORK, "NUM_PROXIES": count}
@pytest.fixture(name="dockerflow_request_id")
def fixture_dockerflow_request_id():
"""Simulate the dockerflow middleware having assigned a request id."""
request_id = fake.uuid4()
token = request_id_context.set(request_id)
try:
yield request_id
finally:
request_id_context.reset(token)
def test_resolve_client_ip_without_forwarded_header():
"""Should use the peer address when no proxy header is present."""
peer_ip = fake.ipv4()
request = RequestFactory().get("/", REMOTE_ADDR=peer_ip)
assert audit_request.resolve_client_ip(request) == peer_ip
def test_resolve_client_ip_prefers_the_client_over_the_proxy():
"""Should return the client the trusted proxy saw, not the proxy address."""
request = RequestFactory().get(
"/", REMOTE_ADDR="1.2.3.4", HTTP_X_FORWARDED_FOR="4.5.6.7, 10.0.0.1"
)
assert audit_request.resolve_client_ip(request) == "10.0.0.1"
def test_resolve_client_ip_skips_trusted_proxies(settings):
"""Should skip the load balancer entry when two proxies are trusted."""
_set_num_proxies(settings, 2)
request = RequestFactory().get(
"/", HTTP_X_FORWARDED_FOR="1.1.1.1, 2.2.2.2, 8.8.8.8"
)
assert audit_request.resolve_client_ip(request) == "2.2.2.2"
def test_resolve_client_ip_clamps_when_fewer_addresses_than_proxies(settings):
"""Should never index out of range on a short chain."""
_set_num_proxies(settings, 5)
request = RequestFactory().get("/", HTTP_X_FORWARDED_FOR="1.2.3.4")
assert audit_request.resolve_client_ip(request) == "1.2.3.4"
def test_resolve_client_ip_ignores_an_empty_forwarded_header():
"""Should fall back to the peer address when the header is blank."""
peer_ip = fake.ipv4()
request = RequestFactory().get("/", REMOTE_ADDR=peer_ip, HTTP_X_FORWARDED_FOR=" , ")
assert audit_request.resolve_client_ip(request) == peer_ip
def test_resolve_client_ip_without_trusted_proxy(settings):
"""Should ignore the header entirely when no proxy is trusted."""
_set_num_proxies(settings, 0)
request = RequestFactory().get(
"/", REMOTE_ADDR="1.2.3.4", HTTP_X_FORWARDED_FOR="4.5.6.7"
)
assert audit_request.resolve_client_ip(request) == "1.2.3.4"
def test_resolve_client_ip_is_the_throttle_identity(settings):
"""Should identify the client exactly as Meet's throttles do."""
_set_num_proxies(settings, 2)
request = RequestFactory().get(
"/", REMOTE_ADDR="1.2.3.4", HTTP_X_FORWARDED_FOR="6.6.6.6, 5.6.7.8, 10.0.0.1"
)
assert audit_request.resolve_client_ip(request) == "5.6.7.8"
assert CreationCallbackAnonRateThrottle().get_ident(request) == "5.6.7.8"
def test_resolve_client_ip_tolerates_bare_requests():
"""Should accept requests built by hand, which have an empty META."""
request = RequestFactory().get("/")
request.META = {}
assert audit_request.resolve_client_ip(request) is None
def test_current_request_id_is_dockerflow_request_id(dockerflow_request_id):
"""Should reuse the dockerflow request id as the trace id."""
assert audit_request.current_request_id() == dockerflow_request_id
def test_current_request_id_outside_a_request():
"""Should have no id when dockerflow did not assign one."""
assert audit_request.current_request_id() is None
def test_middleware_replaces_an_untrusted_request_id(dockerflow_request_id):
"""Should not reuse an inbound id unless the ingress is trusted to set it."""
middleware = audit_request.AuditLogMiddleware(lambda request: HttpResponse())
response = middleware(RequestFactory().get("/"))
request_id = response["X-Request-ID"]
assert request_id != dockerflow_request_id
assert str(uuid.UUID(request_id)) == request_id
assert audit_request.current_request_id() == request_id
def test_middleware_echoes_a_trusted_request_id(settings, dockerflow_request_id):
"""Should keep and echo the inbound id when the ingress is trusted."""
settings.REQUEST_ID_TRUST_HEADER = True
middleware = audit_request.AuditLogMiddleware(lambda request: HttpResponse())
response = middleware(RequestFactory().get("/"))
assert response["X-Request-ID"] == dockerflow_request_id
def test_middleware_echoes_on_the_configured_header(settings, dockerflow_request_id):
"""Should echo the id on the header dockerflow reads it from."""
settings.REQUEST_ID_TRUST_HEADER = True
settings.DOCKERFLOW_REQUEST_ID_HEADER_NAME = "X-Trace-ID"
middleware = audit_request.AuditLogMiddleware(lambda request: HttpResponse())
response = middleware(RequestFactory().get("/"))
assert response["X-Trace-ID"] == dockerflow_request_id
assert not response.has_header("X-Request-ID")
@pytest.mark.usefixtures("dockerflow_request_id")
def test_middleware_keeps_an_existing_response_header():
"""Should leave an X-Request-ID set by the view untouched."""
def view(request): # pylint: disable=unused-argument
response = HttpResponse()
response["X-Request-ID"] = "from-the-view"
return response
response = audit_request.AuditLogMiddleware(view)(RequestFactory().get("/"))
assert response["X-Request-ID"] == "from-the-view"
@pytest.mark.django_db
def test_request_id_from_the_client_is_replaced_by_default(client):
"""Should answer with an id of its own, not the one the client sent."""
response = client.get("/api/v1.0/config/", HTTP_X_REQUEST_ID="abc-123")
assert response.status_code == 200
assert response["X-Request-ID"] != "abc-123"
assert uuid.UUID(response["X-Request-ID"])
@pytest.mark.django_db
def test_request_id_flows_through_the_test_client_when_trusted(client, settings):
"""Should echo the id dockerflow read when the ingress is trusted."""
settings.REQUEST_ID_TRUST_HEADER = True
response = client.get("/api/v1.0/config/", HTTP_X_REQUEST_ID="abc-123")
assert response.status_code == 200
assert response["X-Request-ID"] == "abc-123"
@pytest.mark.django_db
def test_request_id_is_echoed_on_responses_of_outer_middleware(client):
"""Should reach responses that never get to the view, as slash redirects."""
response = client.get("/api/v1.0/config")
assert response.status_code == 301
assert uuid.UUID(response["X-Request-ID"])
@@ -0,0 +1,160 @@
"""Tests for the audit of Django's authentication signals."""
import json
from django.contrib.auth import authenticate, login
from django.contrib.sessions.middleware import SessionMiddleware
from django.http import HttpResponse
from django.test import RequestFactory
import pytest
from core import audit
from core.audit.testing import find_events
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
def _request_with_session(method="get"):
request = getattr(RequestFactory(), method)("/", REMOTE_ADDR="1.2.3.4")
SessionMiddleware(lambda req: HttpResponse())(request)
return request
def test_login_is_audited(audit_events, client):
"""A login records the user, the mechanism and the backend."""
user = UserFactory(email="user@example.com")
client.force_login(user)
[event] = find_events(audit_events, "user.login")
assert event["event"]["category"] == ["authentication"]
assert event["event"]["type"] == ["start"]
assert event["event"]["outcome"] == "success"
assert event["user"] == {
"id": str(user.pk),
"sub": user.sub,
"domain": "example.com",
}
assert event["lasuite"]["actor"] == {"type": "user"}
assert event["lasuite"]["auth"] == {"method": "password"}
assert event["lasuite"]["details"]["auth_backend"].endswith("ModelBackend")
def test_login_through_oidc_backend_is_named_oidc(audit_events):
"""The OIDC backend is reported as the ``oidc`` auth method."""
user = UserFactory()
user.backend = "core.authentication.backends.OIDCAuthenticationBackend"
request = _request_with_session()
login(request, user)
[event] = find_events(audit_events, "user.login")
assert event["lasuite"]["auth"] == {"method": "oidc"}
assert event["client"] == {"ip": "1.2.3.4"}
assert event["lasuite"]["details"]["auth_backend"] == user.backend
def test_login_given_its_backend_is_named_after_it(audit_events):
"""A backend passed to ``login`` rather than set by ``authenticate`` counts."""
backend = "core.authentication.backends.OIDCAuthenticationBackend"
login(_request_with_session(), UserFactory(), backend=backend)
[event] = find_events(audit_events, "user.login")
assert event["lasuite"]["auth"] == {"method": "oidc"}
assert event["lasuite"]["details"]["auth_backend"] == backend
def test_login_through_an_unlisted_backend_is_unknown(audit_events):
"""A backend missing from the setting is unknown, even a ModelBackend subclass."""
user = UserFactory()
user.backend = "django.contrib.auth.backends.RemoteUserBackend"
login(_request_with_session(), user)
[event] = find_events(audit_events, "user.login")
assert event["lasuite"]["auth"] == {"method": "unknown"}
assert event["lasuite"]["details"]["auth_backend"] == user.backend
def test_failed_login_is_audited_without_credentials(audit_events):
"""A failed login is a warning that never contains the credentials."""
request = _request_with_session("post")
assert authenticate(request=request, username="nobody", password="s3cret") is None
[event] = find_events(audit_events, "user.login")
assert event["event"]["outcome"] == "failure"
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": "password"}
assert event["log"]["level"] == "warning"
assert "s3cret" not in json.dumps(event)
assert "nobody" not in json.dumps(event)
def test_failed_login_without_request_is_anonymous(audit_events):
"""A failed login is anonymous even when no request is at hand."""
assert authenticate(username="nobody", password="s3cret") is None
[event] = find_events(audit_events, "user.login")
assert event["event"]["outcome"] == "failure"
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["actor"] == {"type": "anonymous"}
assert event["lasuite"]["auth"] == {"method": "password"}
@pytest.mark.parametrize(
"credentials,method",
[
# What the OIDC callback hands to ``authenticate``.
({"nonce": "n-0nce", "code_verifier": "v3rifier"}, "oidc"),
({"token": "t0ken"}, "unknown"),
],
)
def test_failed_login_is_named_after_its_credentials(audit_events, credentials, method):
"""A failed attempt is named after what it submitted, never recording it."""
request = _request_with_session()
assert authenticate(request=request, **credentials) is None
[event] = find_events(audit_events, "user.login")
assert event["event"]["outcome"] == "failure"
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["auth"] == {"method": method}
for value in credentials.values():
assert value not in json.dumps(event)
def test_logout_is_audited(audit_events, client):
"""A logout records the user who left."""
user = UserFactory()
client.force_login(user)
client.logout()
[event] = find_events(audit_events, "user.logout")
assert event["event"]["type"] == ["end"]
assert event["user"]["id"] == str(user.pk)
def test_logout_without_a_signed_in_user_is_not_audited(audit_events, client):
"""Django signals a logout without a user when no one was signed in."""
client.logout()
assert find_events(audit_events, "user.logout") == []
def test_connect_auth_signals_is_idempotent(audit_events, client):
"""Connecting twice does not duplicate events."""
audit.connect_auth_signals()
audit.connect_auth_signals()
user = UserFactory()
client.force_login(user)
assert len(find_events(audit_events, "user.login")) == 1
@@ -0,0 +1,116 @@
"""Tests for the description of audit targets."""
from django.utils.functional import SimpleLazyObject
import pytest
from core.audit.registry import ModelOptions, model_options
from core.audit.targets import describe_target
from core.audit.testing import override_registration
from core.factories import RecordingFactory, RoomFactory, UserFactory
from core.models import Recording, Resource, Room
pytestmark = pytest.mark.django_db
def test_describe_target_reads_the_registered_fields():
"""A model is described by its name, its key and its registered fields."""
room = RoomFactory()
with override_registration(Room, fields=("slug", "access_level")):
described = describe_target(room)
assert described == {
"type": "room",
"id": str(room.pk),
"slug": room.slug,
"access_level": room.access_level,
}
def test_describe_target_renders_values():
"""Foreign keys, enums and other values are rendered for JSON."""
recording = RecordingFactory()
with override_registration(Recording, fields=("room_id", "room")):
described = describe_target(recording)
assert described == {
"type": "recording",
"id": str(recording.pk),
"room_id": str(recording.room_id),
"room": str(recording.room_id),
}
def test_describe_target_without_fields():
"""A model registered without fields stays identifiable."""
room = RoomFactory()
with override_registration(Room):
described = describe_target(room)
assert described == {"type": "room", "id": str(room.pk)}
def test_describe_target_identifies_users_without_their_email():
"""A user is identified by its key, OIDC sub and email domain."""
user = UserFactory(email="jane@Example.org", sub="oidc-sub-1")
assert describe_target(user) == {
"type": "user",
"id": str(user.pk),
"sub": "oidc-sub-1",
"domain": "example.org",
}
def test_describe_target_of_a_user_without_sub():
"""A user who never signed in, such as a provisional one, has no sub.
The empty value is pruned when the event is built.
"""
user = UserFactory(email="jane@example.org", sub=None)
assert describe_target(user) == {
"type": "user",
"id": str(user.pk),
"sub": None,
"domain": "example.org",
}
def test_describe_target_sees_through_lazy_objects():
"""A lazy proxy is described as the object it wraps."""
room = RoomFactory()
with override_registration(Room, fields=("slug",)):
described = describe_target(SimpleLazyObject(lambda: room))
assert described == {
"type": "room",
"id": str(room.pk),
"slug": room.slug,
}
def test_describe_target_mapping_passes_through():
"""A ready-made dict is used verbatim."""
assert describe_target({"type": "x", "id": "1"}) == {"type": "x", "id": "1"}
def test_describe_target_of_a_plain_object():
"""Anything else is identified by its class and string form."""
class Thing: # pylint: disable=missing-class-docstring
def __str__(self):
return "thing-1"
assert describe_target(Thing()) == {"type": "thing", "id": "thing-1"}
def test_model_options_by_model():
"""Options are looked up by model, and default to nothing."""
with override_registration(Room, fields=("slug",)):
assert model_options(Room) == ModelOptions(fields=("slug",))
assert model_options(Resource) == ModelOptions()
@@ -0,0 +1,27 @@
"""
Test audit.utils.prune_empty
"""
from core.audit.utils import prune_empty
def test_prune_empty_drops_none_and_empty_mappings():
"""Should drop None and emptied mappings but keep falsy values."""
document = {
"none": None,
"emptied": {"inner": None, "deeper": {"again": None}},
"kept": {"zero": 0, "false": False, "blank": "", "none": None},
"list": [],
}
assert prune_empty(document) == {
"kept": {"zero": 0, "false": False, "blank": ""},
"list": [],
}
def test_prune_empty_leaves_non_mappings_untouched():
"""Should return anything that is not a mapping as it is."""
assert prune_empty([None, {}]) == [None, {}]
assert prune_empty("text") == "text"
assert prune_empty(None) is None
@@ -2,14 +2,14 @@
from unittest import mock
from django.core.exceptions import SuspiciousOperation
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
import pytest
from lasuite.marketing.tasks import create_or_update_contact
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
from core.factories import UserFactory
from core.services import marketing
pytestmark = pytest.mark.django_db
@@ -606,8 +606,8 @@ def test_marketing_signup_existing_user(
mock_signup.assert_not_called()
@mock.patch.object(create_or_update_contact, "delay")
def test_signup_to_marketing_email_success(mock_create_or_update_contact):
@mock.patch("core.authentication.backends.get_marketing_service")
def test_signup_to_marketing_email_success(mock_marketing):
"""Test successful marketing signup."""
email = "test@example.com"
@@ -616,6 +616,46 @@ def test_signup_to_marketing_email_success(mock_create_or_update_contact):
OIDCAuthenticationBackend.signup_to_marketing_email(email)
# Verify service interaction
mock_create_or_update_contact.assert_called_once_with(
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
)
mock_service = mock_marketing.return_value
mock_service.create_contact.assert_called_once()
@pytest.mark.parametrize(
"error",
[
ImportError,
ImproperlyConfigured,
],
)
@mock.patch("core.authentication.backends.get_marketing_service")
def test_marketing_signup_handles_service_initialization_errors(
mock_marketing, error, settings
):
"""Tests errors that occur when trying to get/initialize the marketing service."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
mock_marketing.side_effect = error
# Should not raise any exception
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
@pytest.mark.parametrize(
"error",
[
marketing.ContactCreationError,
ImproperlyConfigured,
ImportError,
],
)
@mock.patch("core.authentication.backends.get_marketing_service")
def test_marketing_signup_handles_contact_creation_errors(
mock_marketing, error, settings
):
"""Tests errors that occur during the contact creation process."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
mock_marketing.return_value.create_contact.side_effect = error
# Should not raise any exception
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
+23
View File
@@ -3,6 +3,9 @@
from unittest import mock
import pytest
from dockerflow.logging import request_id_context
from core.audit.testing import capture_audit
USER = "user"
TEAM = "team"
@@ -14,3 +17,23 @@ def mock_user_get_teams():
"""Mock for the "get_teams" method on the User model."""
with mock.patch("core.models.User.get_teams") as mock_get_teams:
yield mock_get_teams
@pytest.fixture
def audit_events():
"""Collect the audit events emitted during the test, as dicts."""
with capture_audit() as events:
yield events
@pytest.fixture(autouse=True)
def isolated_request_id():
"""Keep dockerflow's request id from leaking from one test to the next.
Its middleware sets the context variable on every request the test client
makes and never clears it, which would make the trace id of a later test
depend on the order tests ran in.
"""
token = request_id_context.set(None)
yield
request_id_context.reset(token)
@@ -24,6 +24,8 @@ def test_successful_authentication(settings):
user, token = RecordingProcessWebhookAuthentication().authenticate(request)
assert token == "valid-test-token"
assert isinstance(user, MachineUser)
# Names the summary service in the audit log
assert user.get_username() == "summary"
def test_authentication_fails_when_token_not_configured(settings):
@@ -0,0 +1,212 @@
"""
Test marketing services.
"""
# pylint: disable=W0621,W0613
from unittest import mock
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import brevo_python
import pytest
import urllib3
from core.services.marketing import (
BrevoMarketingService,
ContactCreationError,
ContactData,
get_marketing_service,
)
def test_init_missing_api_key(settings):
"""Test initialization with missing API key."""
settings.BREVO_API_KEY = None
with pytest.raises(ImproperlyConfigured, match="Brevo API key is required"):
BrevoMarketingService()
def test_create_contact_missing_list_ids(settings):
"""Test contact creation with missing list IDs."""
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = None
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
with pytest.raises(
ImproperlyConfigured, match="Default Brevo List IDs must be configured"
):
brevo_service.create_contact(valid_contact_data)
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_success(mock_contact_api):
"""Test successful contact creation."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.return_value = {"id": "test-id"}
response = brevo_service.create_contact(valid_contact_data)
assert response == {"id": "test-id"}
mock_api.create_contact.assert_called_once()
contact_arg = mock_api.create_contact.call_args[0][0]
assert contact_arg.email == "test@example.com"
assert contact_arg.attributes == {
**settings.BREVO_API_CONTACT_ATTRIBUTES,
**valid_contact_data.attributes,
}
assert set(contact_arg.list_ids) == {1, 2, 3, 4}
assert contact_arg.update_enabled is True
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_with_timeout(mock_contact_api):
"""Test contact creation with timeout."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
brevo_service.create_contact(valid_contact_data, timeout=30)
mock_api.create_contact.assert_called_once()
assert mock_api.create_contact.call_args[1]["_request_timeout"] == 30
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_api_error(mock_contact_api):
"""Test contact creation API error handling."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.side_effect = brevo_python.rest.ApiException()
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
brevo_service.create_contact(valid_contact_data)
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_timeout_error(mock_contact_api):
"""Test contact creation timeout error handling."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.side_effect = urllib3.exceptions.ReadTimeoutError(
pool=mock.Mock(),
url="https://api.brevo.com/v3/endpoint",
message="HTTPSConnectionPool(host='api.brevo.com', port=443): Read timed out.",
)
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
brevo_service.create_contact(valid_contact_data)
@pytest.fixture
def clear_marketing_cache():
"""Clear marketing service cache between tests."""
get_marketing_service.cache_clear()
yield
get_marketing_service.cache_clear()
def test_get_marketing_service_caching(clear_marketing_cache):
"""Test marketing service caching behavior."""
settings.BREVO_API_KEY = "test-api-key"
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
service1 = get_marketing_service()
service2 = get_marketing_service()
assert service1 is service2
assert isinstance(service1, BrevoMarketingService)
def test_get_marketing_service_invalid_class(clear_marketing_cache):
"""Test handling of invalid service class."""
settings.MARKETING_SERVICE_CLASS = "invalid.service.path"
with pytest.raises(ImportError):
get_marketing_service()
@mock.patch("core.services.marketing.import_string")
def test_service_instantiation_called_once(mock_import_string, clear_marketing_cache):
"""Test service class is instantiated only once."""
settings.BREVO_API_KEY = "test-api-key"
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
get_marketing_service.cache_clear()
mock_service_cls = mock.Mock()
mock_service_instance = mock.Mock()
mock_service_cls.return_value = mock_service_instance
mock_import_string.return_value = mock_service_cls
service1 = get_marketing_service()
service2 = get_marketing_service()
mock_import_string.assert_called_once_with(settings.MARKETING_SERVICE_CLASS)
mock_service_cls.assert_called_once()
assert service1 is service2
assert service1 is mock_service_instance
@@ -1,209 +0,0 @@
"""Tests for the external API MenshenAuthentication."""
from unittest import mock
import pytest
import responses
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient
from core.factories import UserFactory
from core.models import RoleChoices, Room, User
pytestmark = pytest.mark.django_db
SERVER_URL = "https://menshen.example.com"
INTROSPECTION_ENDPOINT = f"{SERVER_URL}/auth/token/introspect/"
TOKEN = "menshen-exchanged-token"
@pytest.fixture(autouse=True)
def menshen_settings(settings):
"""Enable Menshen authentication."""
settings.MENSHEN_ENABLED = True
settings.MENSHEN_SERVER_URL = SERVER_URL
settings.MENSHEN_CLIENT_ID = "meet"
settings.MENSHEN_CLIENT_SECRET = "meet-secret"
def _introspection(sub, scope="meet:room:create", **overrides):
return {
"active": True,
"sub": sub,
"email": "user@example.com",
"scope": scope,
"aud": "meet",
"client_id": "menshen",
**overrides,
}
def _create_room():
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {TOKEN}")
return client.post("/external-api/v1.0/rooms/", {}, format="json")
@responses.activate
def test_menshen_active_token():
"""An active token with a mapped scope should create a room owned by the user."""
user = UserFactory()
# Catch and mock external HTTP requests
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection(user.sub),
match=[responses.matchers.urlencoded_params_matcher({"token": TOKEN})],
)
response = _create_room()
assert response.status_code == 201
room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER
assert responses.calls[0].request.headers["Authorization"].startswith("Basic ")
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_disabled(mock_rs_authenticate, settings):
"""Menshen should not be called when disabled."""
settings.MENSHEN_ENABLED = False
response = _create_room()
assert response.status_code == 401
assert len(responses.calls) == 0
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_inactive_token_defers(mock_rs_authenticate):
"""An inactive token should defer to the next authentication backend."""
user = UserFactory()
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection(user.sub, scope="meet:room:create", active=False),
)
response = _create_room()
assert response.status_code == 401
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_error_defers(mock_rs_authenticate):
"""A Menshen error should defer to the next authentication backend."""
responses.add(responses.POST, INTROSPECTION_ENDPOINT, status=500)
response = _create_room()
assert response.status_code == 401
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_unparsable_response_defers(mock_rs_authenticate):
"""A response the client can't parse should defer to the next backend."""
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json={"active": True, "unexpected": "field"},
)
response = _create_room()
assert response.status_code == 401
mock_rs_authenticate.assert_called_once()
@responses.activate
def test_menshen_unmapped_scope():
"""Scopes granted for other services or other Meet actions should not grant access."""
user = UserFactory()
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection(user.sub, scope="drive:item:create meet:room:list"),
)
response = _create_room()
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
assert not Room.objects.exists()
@responses.activate
def test_menshen_missing_sub():
"""An active token without sub should be rejected."""
responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(None))
response = _create_room()
assert response.status_code == 401
assert "invalid token claims." in str(response.data).lower()
@responses.activate
def test_menshen_inactive_user():
"""An active token for an inactive user should be rejected."""
user = UserFactory(is_active=False)
responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(user.sub))
response = _create_room()
assert response.status_code == 401
assert "user account is disabled." in str(response.data).lower()
@responses.activate
def test_menshen_unknown_user_created(settings):
"""An unknown sub should create a user when OIDC_CREATE_USER is set."""
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")
assert user.email == "user@example.com"
assert user.is_active is True
@responses.activate
def test_menshen_unknown_user_not_created(settings):
"""An unknown sub should be rejected when OIDC_CREATE_USER is unset."""
settings.OIDC_CREATE_USER = False
responses.add(
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
)
response = _create_room()
assert response.status_code == 401
assert "user not found." in str(response.data).lower()
assert not User.objects.filter(sub="new-sub").exists()
@@ -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)
+81 -27
View File
@@ -311,6 +311,7 @@ class Base(Configuration):
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
"core.audit.request.AuditLogMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
@@ -331,6 +332,7 @@ class Base(Configuration):
INSTALLED_APPS = [
# Meet
"core",
"core.audit.apps.AuditConfig",
"demo",
"drf_spectacular",
# Third party apps
@@ -340,7 +342,8 @@ class Base(Configuration):
"parler",
"easy_thumbnails",
# Django
"django.contrib.admin",
# The admin is served by a site that audits every write it performs.
"core.audit.apps.AuditedAdminConfig",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.postgres",
@@ -383,6 +386,13 @@ class Base(Configuration):
"PAGE_SIZE": 20,
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
# Trusted proxies appending to X-Forwarded-For in front of the backend.
# Throttles and audit events identify the client as the entry that many
# positions from the right; unset, DRF would use the raw header, which a
# client can vary to escape its throttle.
"NUM_PROXIES": values.IntegerValue(
1, environ_name="NUM_PROXIES", environ_prefix=None
),
"DEFAULT_THROTTLE_RATES": {
"room_creation": values.Value(
default="50/minute",
@@ -702,20 +712,6 @@ class Base(Configuration):
default=None, environ_name="OIDC_RS_SCOPES_PREFIX", environ_prefix=None
)
# Menshen, La Suite's OAuth 2.0 token exchange server
MENSHEN_ENABLED = values.BooleanValue(
False, environ_name="MENSHEN_ENABLED", environ_prefix=None
)
MENSHEN_SERVER_URL = values.Value(
None, environ_name="MENSHEN_SERVER_URL", environ_prefix=None
)
MENSHEN_CLIENT_ID = values.Value(
None, environ_name="MENSHEN_CLIENT_ID", environ_prefix=None
)
MENSHEN_CLIENT_SECRET = SecretFileValue(
None, environ_name="MENSHEN_CLIENT_SECRET", environ_prefix=None
)
# Video conference configuration
LIVEKIT_CONFIGURATION = {
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
@@ -952,18 +948,24 @@ class Base(Configuration):
environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL",
environ_prefix=None,
)
LASUITE_MARKETING = {
"BACKEND": values.Value(
"lasuite.marketing.backends.dummy.DummyBackend",
environ_name="LASUITE_MARKETING_BACKEND",
environ_prefix=None,
),
"PARAMETERS": values.DictValue(
default={},
environ_name="LASUITE_MARKETING_PARAMETERS",
environ_prefix=None,
),
}
MARKETING_SERVICE_CLASS = values.Value(
"core.services.marketing.BrevoMarketingService",
environ_name="MARKETING_SERVICE_CLASS",
environ_prefix=None,
)
BREVO_API_KEY = SecretFileValue(
None, environ_name="BREVO_API_KEY", environ_prefix=None
)
BREVO_API_CONTACT_LIST_IDS = values.ListValue(
[],
environ_name="BREVO_API_CONTACT_LIST_IDS",
environ_prefix=None,
converter=int,
)
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
1, environ_name="BREVO_API_TIMEOUT", environ_prefix=None
)
# Lobby configurations
PRESENCE_KEY_PREFIX = values.Value(
@@ -1224,6 +1226,28 @@ class Base(Configuration):
environ_prefix=None,
)
AUDIT_LOG_LEVEL = values.Value(
"INFO", environ_name="AUDIT_LOG_LEVEL", environ_prefix=None
)
AUDIT_LOG_STREAM = values.Value(
"ext://sys.stdout", environ_name="AUDIT_LOG_STREAM", environ_prefix=None
)
AUDIT_LOG_SERVICE_NAME = values.Value(
"meet", environ_name="AUDIT_LOG_SERVICE_NAME", environ_prefix=None
)
# Reuse the inbound request id as the trace id
# Only enable it when the ingress overwrites the header
# When off, the backend generates the id.
REQUEST_ID_TRUST_HEADER = values.BooleanValue(
False, environ_name="REQUEST_ID_TRUST_HEADER", environ_prefix=None
)
DOCKERFLOW_REQUEST_ID_HEADER_NAME = values.Value(
"X-Request-ID",
environ_name="DOCKERFLOW_REQUEST_ID_HEADER_NAME",
environ_prefix=None,
)
LOGGING_SILENCED_401_PATHS = values.ListValue(
default=["/api/v1.0/users/me/"],
environ_name="LOGGING_SILENCED_401_PATHS",
@@ -1241,6 +1265,9 @@ class Base(Configuration):
"format": "{asctime} {name} {levelname} {message}",
"style": "{",
},
"audit_json": {
"()": "core.audit.formatter.AuditJsonFormatter",
},
},
"filters": {
"silence_expected_401": {
@@ -1253,6 +1280,11 @@ class Base(Configuration):
"formatter": "simple",
"filters": ["silence_expected_401"],
},
"audit_console": {
"class": "logging.StreamHandler",
"stream": AUDIT_LOG_STREAM,
"formatter": "audit_json",
},
},
# Override root logger to send it to console
"root": {
@@ -1285,6 +1317,11 @@ class Base(Configuration):
),
"propagate": False,
},
"audit": {
"handlers": ["audit_console"],
"level": AUDIT_LOG_LEVEL,
"propagate": False,
},
},
}
@@ -1465,6 +1502,8 @@ class Base(Configuration):
# Ignore the logs added by the DockerflowMiddleware
ignore_logger("request.summary")
# Audit events are a data stream, not errors to report
ignore_logger("audit")
class Build(Base):
@@ -1516,16 +1555,31 @@ class Test(Base):
{
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"audit_json": {
"()": "core.audit.formatter.AuditJsonFormatter",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
"audit_console": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "audit_json",
},
},
"loggers": {
"meet": {
"handlers": ["console"],
"level": "DEBUG",
},
"audit": {
"handlers": ["audit_console"],
"level": "INFO",
"propagate": False,
},
},
}
)
-1
View File
@@ -49,7 +49,6 @@ dependencies = [
"gunicorn==26.2.0",
"jsonschema==4.26.0",
"markdown==3.10.3",
"menshen-client==0.1.0",
"nested-multipart-parser==1.6.0",
"posthog==7.44.0",
"psycopg[binary]==3.3.4",
-14
View File
@@ -1327,7 +1327,6 @@ dependencies = [
{ name = "jsonschema" },
{ name = "livekit-api" },
{ name = "markdown" },
{ name = "menshen-client" },
{ name = "mozilla-django-oidc" },
{ name = "nested-multipart-parser" },
{ name = "phonenumbers" },
@@ -1393,7 +1392,6 @@ requires-dist = [
{ name = "jsonschema", specifier = "==4.26.0" },
{ name = "livekit-api", specifier = "==1.2.0" },
{ name = "markdown", specifier = "==3.10.3" },
{ name = "menshen-client", specifier = "==0.1.0" },
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
{ name = "phonenumbers", specifier = "==9.0.37" },
@@ -1430,18 +1428,6 @@ dev = [
{ name = "types-requests", specifier = "==2.33.0.20260712" },
]
[[package]]
name = "menshen-client"
version = "0.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/d5/c2be400c190efe26b70e5c60dd9d9ab279ece3b51de457abc184a238c727/menshen_client-0.1.0.tar.gz", hash = "sha256:21fe48788e860c7f2e103787ce0827981e9059146cf7bdb9ac1a8778acb9ff77", size = 6219, upload-time = "2026-09-21T21:04:01.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/bf/11d15da4852b8344eb9f30861bd15a8f1d931ff4232d9b7de18fce09fd3c/menshen_client-0.1.0-py3-none-any.whl", hash = "sha256:d0dfb351812bb93620a4796e0f0ce1a40e8ec859dc8de2a1f50ae7f306d4d29a", size = 7040, upload-time = "2026-09-21T21:04:00.433Z" },
]
[[package]]
name = "mozilla-django-oidc"
version = "5.0.2"
@@ -19,9 +19,7 @@ export const LoadingScreen = ({
<Screen layout={layout} header={header} footer={footer}>
<CenteredContent>
<Center>
<p role="status" aria-live="polite">
{t('loading')}
</p>
<p>{t('loading')}</p>
</Center>
</CenteredContent>
</Screen>
@@ -1,43 +0,0 @@
import { useIsSpeaking, useRoomContext } from '@livekit/components-react'
import { useEffect, useState } from 'react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import {
closeLowerHandToasts,
showLowerHandToast,
} from '@/features/notifications/utils'
const SPEAKING_DETECTION_DELAY = 3000
/**
* Offers to lower the local participant's raised hand after
* SPEAKING_DETECTION_DELAY of speaking. Mount it once: each copy runs its own
* timer and shows its own toast.
*/
export const LowerHandOnSpeaking = () => {
const room = useRoomContext()
const { isHandRaised, lowerHand } = useRaisedHand({
participant: room.localParticipant,
})
const isSpeaking = useIsSpeaking(room.localParticipant)
const [hasOffered, setHasOffered] = useState(false)
useEffect(() => {
if (isHandRaised) return
setHasOffered(false)
closeLowerHandToasts()
}, [isHandRaised])
useEffect(() => {
if (!isSpeaking || !isHandRaised || hasOffered) return
const timer = setTimeout(() => {
setHasOffered(true)
showLowerHandToast(room.localParticipant, lowerHand)
}, SPEAKING_DETECTION_DELAY)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSpeaking, isHandRaised, hasOffered])
return null
}
@@ -2,12 +2,19 @@ import { useTranslation } from 'react-i18next'
import { RiHand } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useRoomContext } from '@livekit/components-react'
import { useIsSpeaking, useRoomContext } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useEffect, useRef, useState } from 'react'
import {
closeLowerHandToasts,
showLowerHandToast,
} from '@/features/notifications/utils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
const SPEAKING_DETECTION_DELAY = 3000
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
export const HandToggle = ({
@@ -18,15 +25,64 @@ export const HandToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
const room = useRoomContext()
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
const { isHandRaised, toggleRaisedHand, lowerHand } = useRaisedHand({
participant: room.localParticipant,
})
const isSpeaking = useIsSpeaking(room.localParticipant)
const speakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [hasShownToast, setHasShownToast] = useState(false)
const resetToastState = () => {
setHasShownToast(false)
}
useEffect(() => {
if (isHandRaised) return
closeLowerHandToasts()
}, [isHandRaised])
const handleToggle = () => {
toggleRaisedHand()
resetToastState()
}
useRegisterKeyboardShortcut({
id: 'raise-hand',
handler: toggleRaisedHand,
handler: handleToggle,
})
useEffect(() => {
const shouldShowToast = isSpeaking && isHandRaised && !hasShownToast
if (shouldShowToast && !speakingTimerRef.current) {
speakingTimerRef.current = setTimeout(() => {
speakingTimerRef.current = null
setHasShownToast(true)
const onClose = () => {
lowerHand()
resetToastState()
}
showLowerHandToast(room.localParticipant, onClose)
}, SPEAKING_DETECTION_DELAY)
}
if ((!isSpeaking || !isHandRaised) && speakingTimerRef.current) {
clearTimeout(speakingTimerRef.current)
speakingTimerRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSpeaking, isHandRaised, hasShownToast, lowerHand])
// Clear any pending timer on unmount
useEffect(() => {
return () => {
if (speakingTimerRef.current) {
clearTimeout(speakingTimerRef.current)
speakingTimerRef.current = null
}
}
}, [])
const tooltipLabel = isHandRaised ? 'lower' : 'raise'
return (
@@ -44,7 +100,7 @@ export const HandToggle = ({
tooltip={t(tooltipLabel)}
isSelected={isHandRaised}
onPress={(e) => {
toggleRaisedHand()
handleToggle()
onPress?.(e)
}}
data-attr={`controls-hand-${tooltipLabel}`}
@@ -31,7 +31,6 @@ import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
import { ChatProvider } from '@/features/chat/components/ChatProvider'
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
import { LowerHandOnSpeaking } from '@/features/rooms/livekit/components/LowerHandOnSpeaking'
import { LobbyProvider } from '@/features/rooms/components/LobbyProvider'
/**
@@ -120,7 +119,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<ConnectionObserver />
<SyncDevicePreferences />
<RoomSilentMicDetector />
<LowerHandOnSpeaking />
<MediaStateObserver />
<ChatProvider />
<LobbyProvider />
@@ -50,10 +50,6 @@ spec:
args:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.agentMetadata.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- if $envVars }}
{{- $envVars | indent 12 }}
@@ -50,10 +50,6 @@ spec:
args:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.agentSubtitles.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- if $envVars }}
{{- $envVars | indent 12 }}
@@ -41,10 +41,6 @@ items:
imagePullPolicy: {{ ($.Values.backend.image | default dict).pullPolicy | default $.Values.image.pullPolicy }}
args:
{{- toYaml .command | nindent 22 }}
{{- with $.Values.backend.envFrom }}
envFrom:
{{- toYaml . | nindent 20 }}
{{- end }}
env:
{{- if $envVars}}
{{- $envVars | indent 22 }}
@@ -50,10 +50,6 @@ spec:
args:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.backend.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- if $envVars }}
{{- $envVars | indent 12 }}
@@ -49,10 +49,6 @@ spec:
args:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.backend.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- if $envVars }}
{{- $envVars | indent 12 }}
@@ -50,10 +50,6 @@ spec:
args:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.backend.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- if $envVars }}
{{- $envVars | indent 12 }}
@@ -49,10 +49,6 @@ spec:
args:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.backend.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- if $envVars }}
{{- $envVars | indent 12 }}
@@ -1,5 +1,4 @@
{{- $envVars := include "meet.env.transformDict" (mergeOverwrite (default dict .Values.backend.envVars) (default dict .Values.celeryBackend.envVars)) -}}
{{- $envFrom := concat (default (list) .Values.backend.envFrom) (default (list) .Values.celeryBackend.envFrom) -}}
{{- $fullName := include "meet.celeryBackend.fullname" . -}}
{{- $component := "celery-backend" -}}
apiVersion: apps/v1
@@ -51,10 +50,6 @@ spec:
args:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with $envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- if $envVars }}
{{- $envVars | indent 12 }}
@@ -50,10 +50,6 @@ spec:
args:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.celerySummarize.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- if $envVars }}
{{- $envVars | indent 12 }}
@@ -46,10 +46,6 @@ spec:
args:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.celerySummaryBackend.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- if $envVars }}
{{- $envVars | indent 12 }}
@@ -1,5 +1,4 @@
{{- $sharedEnvVars := default (dict) .Values.celeryTranscribe.envVars -}}
{{- $sharedEnvFrom := default (list) .Values.celeryTranscribe.envFrom -}}
{{- $component := "celery-transcribe" -}}
{{- range $idx, $instance := .Values.celeryTranscribe.instances }}
@@ -7,8 +6,6 @@
{{- $fullName := printf "%s-%s" (include "meet.celeryTranscribe.fullname" $) $instance.name }}
{{- $extraInstanceEnvVars := default (dict) $instance.extraEnvVars -}}
{{- $mergedInstanceEnvVars := merge $extraInstanceEnvVars $sharedEnvVars -}}
{{- $extraInstanceEnvFrom := default (list) $instance.extraEnvFrom -}}
{{- $envFrom := concat $sharedEnvFrom $extraInstanceEnvFrom -}}
{{- $fakeInstanceObjectForEnvHelper := dict "envVars" $mergedInstanceEnvVars -}}
{{- $envVars := include "meet.common.env" (list . $fakeInstanceObjectForEnvHelper) -}}
apiVersion: apps/v1
@@ -63,10 +60,6 @@ spec:
args:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with $envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- if $envVars }}
{{- $envVars | indent 12 }}
@@ -50,10 +50,6 @@ spec:
args:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.frontend.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- if $envVars }}
{{- $envVars | indent 12 }}
@@ -50,10 +50,6 @@ spec:
args:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.summary.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
{{- if $envVars }}
{{- $envVars | indent 12 }}
-29
View File
@@ -232,9 +232,6 @@ backend:
envVars:
<<: *commonEnvVars
## @param backend.envFrom Import environment variables from ConfigMaps or Secrets
envFrom: []
## @param backend.podAnnotations Annotations to add to the backend Pod
podAnnotations: {}
@@ -436,9 +433,6 @@ frontend:
envVars:
<<: *commonEnvVars
## @param frontend.envFrom Import environment variables from ConfigMaps or Secrets
envFrom: []
## @param frontend.podAnnotations Annotations to add to the frontend Pod
podAnnotations: {}
@@ -630,9 +624,6 @@ summary:
envVars:
<<: *commonEnvVars
## @param summary.envFrom Import environment variables from ConfigMaps or Secrets
envFrom: []
## @param summary.podAnnotations Annotations to add to the summary Pod
podAnnotations: {}
@@ -760,9 +751,6 @@ celeryBackend:
envVars:
<<: *commonEnvVars
## @param celeryBackend.envFrom Import additional environment variables from ConfigMaps or Secrets
envFrom: []
## @param celeryBackend.podAnnotations Annotations to add to the celeryBackend Pod
podAnnotations: {}
@@ -875,9 +863,6 @@ celeryTranscribe:
envVars:
<<: *commonEnvVars
## @param celeryTranscribe.envFrom Import environment variables from ConfigMaps or Secrets
envFrom: []
## @param celeryTranscribe.podAnnotations Annotations to add to the celeryTranscribe Pod
podAnnotations: {}
@@ -938,7 +923,6 @@ celeryTranscribe:
## @extra celeryTranscribe.instances[].name Unique name suffix for the instance (used in the Deployment name and pod labels)
## @extra celeryTranscribe.instances[].replicas Override the number of replicas for this specific instance
## @extra celeryTranscribe.instances[].extraEnvVars Additional environment variables for this specific instance (same structure as envVars)
## @extra celeryTranscribe.instances[].extraEnvFrom Additional ConfigMap or Secret environment sources for this specific instance
## @extra celeryTranscribe.instances[].command Override the container command for this specific instance
## @extra celeryTranscribe.instances[].args Override the container args for this specific instance
## @extra celeryTranscribe.instances[].resources Override resource requirements for this specific instance
@@ -949,7 +933,6 @@ celeryTranscribe:
instances:
- name: default
extraEnvVars: {}
extraEnvFrom: []
## @section celerySummarize
@@ -1007,9 +990,6 @@ celerySummarize:
envVars:
<<: *commonEnvVars
## @param celerySummarize.envFrom Import environment variables from ConfigMaps or Secrets
envFrom: []
## @param celerySummarize.podAnnotations Annotations to add to the celerySummarize Pod
podAnnotations: {}
@@ -1119,9 +1099,6 @@ celerySummaryBackend:
envVars:
<<: *commonEnvVars
## @param celerySummaryBackend.envFrom Import environment variables from ConfigMaps or Secrets
envFrom: []
## @param celerySummaryBackend.podAnnotations Annotations to add to the celerySummaryBackend Pod
podAnnotations: {}
@@ -1229,9 +1206,6 @@ agentMetadata:
envVars:
<<: *commonEnvVars
## @param agentMetadata.envFrom Import environment variables from ConfigMaps or Secrets
envFrom: []
## @param agentMetadata.podAnnotations Annotations to add to the agentMetadata Pod
podAnnotations: {}
@@ -1323,9 +1297,6 @@ agentSubtitles:
# are NOT supported by the LiveKit Deepgram plugin in streaming mode.
# Use language="multi" for automatic multilingual support (10 languages).
## @param agentSubtitles.envFrom Import environment variables from ConfigMaps or Secrets
envFrom: []
## @param agentSubtitles.podAnnotations Annotations to add to the agentSubtitles Pod
podAnnotations: {}