mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-06 13:31:48 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b86ac35dc6 | |||
| f182474ed9 | |||
| c075db25a4 | |||
| c0dfd88776 | |||
| 94d2b80c17 | |||
| bd2dfcae80 | |||
| 0b4a83c92e |
+5
-4
@@ -10,14 +10,13 @@ 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) add structured audit logging facility
|
||||
- ✨(backend) audit external API token and room operations
|
||||
- 🔒️(backend) audit writes and bulk actions made in the Django admin
|
||||
- ✨(backend) authenticate external API calls with Menshen exchanged tokens
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -28,13 +27,15 @@ 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
|
||||
|
||||
|
||||
@@ -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 closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
||||
<img alt="GitHub license" 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 Advances features including :
|
||||
- speaker detection
|
||||
- LiveKit advanced features including:
|
||||
- speaker detection
|
||||
- simulcast
|
||||
- end-to-end optimizations
|
||||
- selective subscription
|
||||
|
||||
@@ -14,7 +14,4 @@ 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'
|
||||
" rid=%({x-request-id}o)s"
|
||||
)
|
||||
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(M)s'
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,30 +0,0 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -1,27 +0,0 @@
|
||||
"""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
|
||||
@@ -1,159 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
"""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
|
||||
@@ -1,29 +0,0 @@
|
||||
"""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"
|
||||
@@ -1,174 +0,0 @@
|
||||
"""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
|
||||
@@ -1,161 +0,0 @@
|
||||
"""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
|
||||
@@ -1,74 +0,0 @@
|
||||
"""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"
|
||||
@@ -1,35 +0,0 @@
|
||||
"""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=(",", ":")
|
||||
)
|
||||
@@ -1,99 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,46 +0,0 @@
|
||||
"""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
|
||||
@@ -1,85 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,48 +0,0 @@
|
||||
"""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
|
||||
@@ -1,62 +0,0 @@
|
||||
"""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))
|
||||
@@ -1,48 +0,0 @@
|
||||
"""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
|
||||
@@ -1,94 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,26 +1,19 @@
|
||||
"""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
|
||||
|
||||
|
||||
@@ -67,25 +60,15 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
|
||||
@staticmethod
|
||||
def signup_to_marketing_email(email):
|
||||
"""Pragmatic approach to newsletter signup during authentication flow.
|
||||
"""Add the user to the newsletter list on sign-in.
|
||||
|
||||
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)
|
||||
Uses the team's standard Brevo integration, dispatching the contact
|
||||
creation/update as an asynchronous task to keep authentication fast.
|
||||
"""
|
||||
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
|
||||
)
|
||||
create_or_update_contact.delay(
|
||||
email=email,
|
||||
attributes={"VISIO_SOURCE": ["SIGNIN"]},
|
||||
)
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
|
||||
@@ -4,21 +4,51 @@
|
||||
# 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
|
||||
from core.models import Application, ApplicationScope
|
||||
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."""
|
||||
|
||||
@@ -71,22 +101,12 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
|
||||
if not self.is_enabled:
|
||||
return None
|
||||
|
||||
auth_header = authentication.get_authorization_header(request).split()
|
||||
token = get_bearer_token(request)
|
||||
|
||||
if not auth_header or auth_header[0].lower() != b"bearer":
|
||||
if token is None:
|
||||
# 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):
|
||||
@@ -252,6 +272,139 @@ 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."""
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""External API endpoints"""
|
||||
|
||||
import copy
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
@@ -22,7 +23,7 @@ from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
|
||||
from core import analytics, api, audit, auditing, models
|
||||
from core import analytics, api, models
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.room_management import RoomManagement
|
||||
@@ -34,19 +35,18 @@ from ..services.provisional_user_service import (
|
||||
)
|
||||
from . import authentication, permissions, serializers
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
class ApplicationViewSet(audit.AuditViewMixin, viewsets.ViewSet):
|
||||
|
||||
class ApplicationViewSet(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,10 +68,6 @@ class ApplicationViewSet(audit.AuditViewMixin, 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)
|
||||
@@ -84,8 +80,7 @@ class ApplicationViewSet(audit.AuditViewMixin, viewsets.ViewSet):
|
||||
if not application.is_active:
|
||||
raise drf_exceptions.AuthenticationFailed("Application is inactive")
|
||||
|
||||
self.audit_target = application
|
||||
|
||||
email = serializer.validated_data["scope"]
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError:
|
||||
@@ -97,6 +92,11 @@ class ApplicationViewSet(audit.AuditViewMixin, 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(audit.AuditViewMixin, viewsets.ViewSet):
|
||||
)
|
||||
|
||||
try:
|
||||
user, created = ProvisionalUserService().get_or_create(email, client_id)
|
||||
user, _ = 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,16 +114,6 @@ class ApplicationViewSet(audit.AuditViewMixin, 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(
|
||||
@@ -144,42 +134,13 @@ class ApplicationViewSet(audit.AuditViewMixin, 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,
|
||||
@@ -202,16 +163,10 @@ 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 = [
|
||||
@@ -237,22 +192,29 @@ 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):
|
||||
"""Add a room operation to the audit event and forward it to analytics."""
|
||||
|
||||
self.audit_target = room
|
||||
self.audit_details = extra_properties
|
||||
"""Log a room operation for auditing and forward it to analytics."""
|
||||
|
||||
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 an internal service authenticated by a shared secret, not an account."""
|
||||
"""Represent a non-interactive system user for automated operations."""
|
||||
|
||||
def __init__(self, username: str = "machine_user") -> None:
|
||||
self.pk = None
|
||||
@@ -40,8 +40,6 @@ 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
|
||||
|
||||
@@ -76,7 +74,7 @@ class HeaderBasedAuthentication(BaseAuthentication):
|
||||
)
|
||||
raise AuthenticationFailed("Invalid token")
|
||||
|
||||
return MachineUser(self.MACHINE_USER_NAME), token
|
||||
return MachineUser(), token
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return the WWW-Authenticate header value."""
|
||||
@@ -90,5 +88,4 @@ class RecordingProcessWebhookAuthentication(HeaderBasedAuthentication):
|
||||
"""
|
||||
|
||||
REALM = "External process webhook API"
|
||||
MACHINE_USER_NAME = "summary"
|
||||
EXPECTED_TOKEN_SETTINGS_KEY = "SUMMARY_SERVICE_WEBHOOK_API_TOKEN" # noqa S105
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
"""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,15 +87,17 @@ class ProvisionalUserService:
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
logger.info(
|
||||
"Provisional user created via application: user_id=%s, client_id=%s",
|
||||
"Provisional user created via application: user_id=%s, email=%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: "
|
||||
"client_id=%s",
|
||||
"email=%s, client_id=%s",
|
||||
email,
|
||||
client_id,
|
||||
)
|
||||
user = self._get_by_email(email)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Tests for the audit logging facility."""
|
||||
@@ -1,383 +0,0 @@
|
||||
"""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") == []
|
||||
@@ -1,363 +0,0 @@
|
||||
"""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 == []
|
||||
@@ -1,454 +0,0 @@
|
||||
"""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"]
|
||||
@@ -1,96 +0,0 @@
|
||||
"""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"
|
||||
@@ -1,192 +0,0 @@
|
||||
"""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"])
|
||||
@@ -1,160 +0,0 @@
|
||||
"""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
|
||||
@@ -1,116 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,27 +0,0 @@
|
||||
"""
|
||||
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 ImproperlyConfigured, SuspiciousOperation
|
||||
from django.core.exceptions import 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("core.authentication.backends.get_marketing_service")
|
||||
def test_signup_to_marketing_email_success(mock_marketing):
|
||||
@mock.patch.object(create_or_update_contact, "delay")
|
||||
def test_signup_to_marketing_email_success(mock_create_or_update_contact):
|
||||
"""Test successful marketing signup."""
|
||||
|
||||
email = "test@example.com"
|
||||
@@ -616,46 +616,6 @@ def test_signup_to_marketing_email_success(mock_marketing):
|
||||
OIDCAuthenticationBackend.signup_to_marketing_email(email)
|
||||
|
||||
# Verify service interaction
|
||||
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")
|
||||
mock_create_or_update_contact.assert_called_once_with(
|
||||
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||
)
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from dockerflow.logging import request_id_context
|
||||
|
||||
from core.audit.testing import capture_audit
|
||||
|
||||
USER = "user"
|
||||
TEAM = "team"
|
||||
@@ -17,23 +14,3 @@ 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,8 +24,6 @@ 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):
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,209 @@
|
||||
"""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,7 +17,6 @@ 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,
|
||||
@@ -2376,278 +2375,3 @@ 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,7 +4,6 @@ Tests for external API /token endpoint
|
||||
|
||||
# pylint: disable=W0621
|
||||
|
||||
import json
|
||||
from unittest import mock
|
||||
from urllib.parse import urlencode
|
||||
|
||||
@@ -13,7 +12,6 @@ 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,
|
||||
@@ -676,225 +674,3 @@ 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)
|
||||
|
||||
@@ -311,7 +311,6 @@ 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",
|
||||
@@ -332,7 +331,6 @@ class Base(Configuration):
|
||||
INSTALLED_APPS = [
|
||||
# Meet
|
||||
"core",
|
||||
"core.audit.apps.AuditConfig",
|
||||
"demo",
|
||||
"drf_spectacular",
|
||||
# Third party apps
|
||||
@@ -342,8 +340,7 @@ class Base(Configuration):
|
||||
"parler",
|
||||
"easy_thumbnails",
|
||||
# Django
|
||||
# The admin is served by a site that audits every write it performs.
|
||||
"core.audit.apps.AuditedAdminConfig",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.postgres",
|
||||
@@ -386,13 +383,6 @@ 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",
|
||||
@@ -712,6 +702,20 @@ 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),
|
||||
@@ -948,24 +952,18 @@ class Base(Configuration):
|
||||
environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL",
|
||||
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
|
||||
)
|
||||
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,
|
||||
),
|
||||
}
|
||||
|
||||
# Lobby configurations
|
||||
PRESENCE_KEY_PREFIX = values.Value(
|
||||
@@ -1226,28 +1224,6 @@ 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",
|
||||
@@ -1265,9 +1241,6 @@ class Base(Configuration):
|
||||
"format": "{asctime} {name} {levelname} {message}",
|
||||
"style": "{",
|
||||
},
|
||||
"audit_json": {
|
||||
"()": "core.audit.formatter.AuditJsonFormatter",
|
||||
},
|
||||
},
|
||||
"filters": {
|
||||
"silence_expected_401": {
|
||||
@@ -1280,11 +1253,6 @@ 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": {
|
||||
@@ -1317,11 +1285,6 @@ class Base(Configuration):
|
||||
),
|
||||
"propagate": False,
|
||||
},
|
||||
"audit": {
|
||||
"handlers": ["audit_console"],
|
||||
"level": AUDIT_LOG_LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1502,8 +1465,6 @@ 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):
|
||||
@@ -1555,31 +1516,16 @@ 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -49,6 +49,7 @@ 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",
|
||||
|
||||
Generated
+14
@@ -1327,6 +1327,7 @@ dependencies = [
|
||||
{ name = "jsonschema" },
|
||||
{ name = "livekit-api" },
|
||||
{ name = "markdown" },
|
||||
{ name = "menshen-client" },
|
||||
{ name = "mozilla-django-oidc" },
|
||||
{ name = "nested-multipart-parser" },
|
||||
{ name = "phonenumbers" },
|
||||
@@ -1392,6 +1393,7 @@ 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" },
|
||||
@@ -1428,6 +1430,18 @@ 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,7 +19,9 @@ export const LoadingScreen = ({
|
||||
<Screen layout={layout} header={header} footer={footer}>
|
||||
<CenteredContent>
|
||||
<Center>
|
||||
<p>{t('loading')}</p>
|
||||
<p role="status" aria-live="polite">
|
||||
{t('loading')}
|
||||
</p>
|
||||
</Center>
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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,19 +2,12 @@ import { useTranslation } from 'react-i18next'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useIsSpeaking, useRoomContext } from '@livekit/components-react'
|
||||
import { 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 = ({
|
||||
@@ -25,64 +18,15 @@ export const HandToggle = ({
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
|
||||
|
||||
const room = useRoomContext()
|
||||
const { isHandRaised, toggleRaisedHand, lowerHand } = useRaisedHand({
|
||||
const { isHandRaised, toggleRaisedHand } = 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: handleToggle,
|
||||
handler: toggleRaisedHand,
|
||||
})
|
||||
|
||||
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 (
|
||||
@@ -100,7 +44,7 @@ export const HandToggle = ({
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isHandRaised}
|
||||
onPress={(e) => {
|
||||
handleToggle()
|
||||
toggleRaisedHand()
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-hand-${tooltipLabel}`}
|
||||
|
||||
@@ -31,6 +31,7 @@ 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'
|
||||
|
||||
/**
|
||||
@@ -119,6 +120,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
<ConnectionObserver />
|
||||
<SyncDevicePreferences />
|
||||
<RoomSilentMicDetector />
|
||||
<LowerHandOnSpeaking />
|
||||
<MediaStateObserver />
|
||||
<ChatProvider />
|
||||
<LobbyProvider />
|
||||
|
||||
@@ -50,6 +50,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.agentMetadata.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -50,6 +50,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.agentSubtitles.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -41,6 +41,10 @@ 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,6 +50,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -49,6 +49,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -50,6 +50,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -49,6 +49,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{{- $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
|
||||
@@ -50,6 +51,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -50,6 +50,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.celerySummarize.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -46,6 +46,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.celerySummaryBackend.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{{- $sharedEnvVars := default (dict) .Values.celeryTranscribe.envVars -}}
|
||||
{{- $sharedEnvFrom := default (list) .Values.celeryTranscribe.envFrom -}}
|
||||
{{- $component := "celery-transcribe" -}}
|
||||
|
||||
{{- range $idx, $instance := .Values.celeryTranscribe.instances }}
|
||||
@@ -6,6 +7,8 @@
|
||||
{{- $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
|
||||
@@ -60,6 +63,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -50,6 +50,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.frontend.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -50,6 +50,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.summary.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -232,6 +232,9 @@ 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: {}
|
||||
|
||||
@@ -433,6 +436,9 @@ 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: {}
|
||||
|
||||
@@ -624,6 +630,9 @@ 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: {}
|
||||
|
||||
@@ -751,6 +760,9 @@ 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: {}
|
||||
|
||||
@@ -863,6 +875,9 @@ 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: {}
|
||||
|
||||
@@ -923,6 +938,7 @@ 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
|
||||
@@ -933,6 +949,7 @@ celeryTranscribe:
|
||||
instances:
|
||||
- name: default
|
||||
extraEnvVars: {}
|
||||
extraEnvFrom: []
|
||||
|
||||
## @section celerySummarize
|
||||
|
||||
@@ -990,6 +1007,9 @@ 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: {}
|
||||
|
||||
@@ -1099,6 +1119,9 @@ 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: {}
|
||||
|
||||
@@ -1206,6 +1229,9 @@ 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: {}
|
||||
|
||||
@@ -1297,6 +1323,9 @@ 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: {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user